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: Finding our flair!

1647404372

Edited 1648660141
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Welcome back to A Sheet Author's Journey! This post got a little delayed by me working on some updates to the K-scaffold to fix a variety of bugs that had been reported, but it's finally week 5! And we're going to talk about adding flair  and some actual navigation  to our sheet. Last time, we had a pretty basic layout completed, but our sheet was still looking pretty rough. By the end of this blog post, our sheet will look like this though: Series Posts The Beginning Do the Pug Repeating Sections and sheetworkers Style and Layout Finding our Flair! Roll on! Beautiful Translation How are we getting there though? The first part is that I finished the section specific layouts that we started looking at last week. The next part is getting rid of those red bits of bracketed text that we've had in our sheet since the beginning. Those red bits of text in our previous versions of the sheet meant that there wasn't a translation file available for the provided translation key. This is because we hadn't setup our translation file yet. Let's take a look at how the translation file works. The wiki has a pretty  comprehensive description  of everything that the translation system (known as i18n) can do on Roll20. First of all, the translation file for every sheet will be called translation.json. The translation data is stored in a JSON object with the keys being the lookup value and the values being the translated text. You can check out our freshly created translation.json file in the  tutorial's repo . Here's a short snippet of what the file looks like: { "character sheet button title":"Open the character sheet", "character sheet":"character sheet", "npc sheet button title":"Open the NPC sheet", "npc sheet":"npc sheet", "open the settings page":"Open the settings page" } Keep in mind that JSON is a lot more restrictive than actual javascript objects when it comes to formatting. If you run into problems getting your translation file applied to your sheet, run it through a JSON linter like  JSONlint.com . The rest of this transformation is thanks to our CSS. Beautiful Images The ripped paper effect that we're using here is just using the `border-image` series of CSS properties to nine-slice an image to create the background image and the border images. This SCSS code looks like: .paper-background{ border:var(--half-gap) solid transparent; border-image:{ source:var(--paper-background); slice:40 fill; width:var(--grid-gap); outset:var(--half-gap); repeat:repeat; }; filter:drop-shadow(var(--shadowColor1) 0px 3px 6px) drop-shadow(var(--shadowColor2) 0px 3px 6px); }; If you want a deep dive into how border-image works, I recommend reading the articles at  Mozilla Developer Network  or  w3schools . One piece of this that is hard to find in the documentation is that you have to set a border for the container that is going to use border-image, and it has to be defined before the border-image properties. So, with the use of a simple CSS property, the styling issue that I was worried about at the beginning becomes very easy to handle. And, with the increased style of our sheet, we can see it approaching an acutally usable state. Let's dive back into some functionality by setting up the navigation in our sheet. Getting Around Our sheet is pretty organized right now, but we'll eventually have a few tabs on this sheet; the character view that we have now, a settings page, and an NPC view to help out game masters. In order to make it easy to keep all of this straight for the user, we'll separate each of these views into a separate tab of the sheet that will be accessible from the top of the sheet window. We'll create the NPC sheet later on (probably a few weeks from now), but for now we'll make a simple settings sheet to allow players and GMs to set what type of sheet the character should use (character or NPC), and how rolls should be sent to chat. Here's what that settings page will look like: The creation of the settings page itself is the same process that we've done for the rest of the sheet, except that I've applied the  .paper-background  class to the settings article instead of a section within it. But, let's take a look at how our navigation is working. - varObjects.sheetTypes = ['character','npc']; nav#main-nav.sheet-nav each val in varObjects.sheetTypes +navButton({name:val,class:`sheet-nav__tab ${val}`,'data-i18n':`${val} sheet`,'data-i18n-title':`${val} sheet button title`,role:'heading','aria-level':5,trigger:{triggeredFuncs:['navigateSheet']}}) +navButton({name:'settings',class:'pictos active sheet-nav__tab settings','data-i18n-title':'open the settings page',role:'heading','aria-level':5,trigger:{triggeredFuncs:['navigateSheet']}}) |y Our navigation functionality starts here, in our main  nav  element. We're creating three navigation buttons. We're using the navButton mixin because it will automatically export our navigation button names to our JS for use later on when we iterate through them. For a similar reason, we're storing the names of our two sheet types in the  varObjects . Additionally, we've defined a triggerFunction for our navigation buttons that will handle hiding/revealing the appropriate sheet sections when the button is clicked. Sidebar - BEM Methodology You might be asking yourself what's with the crazy looking class names, like  sheet-nav__tab . We're utilizing a slightly modified  Block Element Modifier  (aka BEM) methodology of class naming here. The short run down is that the  sheet-nav__tab  bit means that this is a tab within the  sheet-nav  element, and it is styled the way it is because it is. BEM may look a little strange, but it offers a way to semantically link your classes when you have styling that is occurring because of the relationship between two items. Now that our buttons are hooked up to our navigation function, let's take a look at what that function looks like. It needs to do two things; hide areas of the sheet that are not the currently selected tab, and show sections that are the currently selected tab. const navigateSheet = function({trigger,attributes}){ let name = trigger ? trigger.name : attributes.sheet_state; let [,,page] = k.parseTriggerName(name); page = page.replace(/^nav-|-action$/g,''); navButtons.forEach((button)=>{ let element = button.replace(/-action/,''); $20(`.${element}`).removeClass('active'); }); $20(`.${page}`).addClass('active'); attributes.sheet_state = page; }; k.registerFuncs({navigateSheet},{type:['opener']}); This function is going to get called in two ways; when we click on a navigation button, and when the sheet is opened. This is because we're going to make use of  Roll20's limited implementation of jQuery  to show/hide the sections of our sheet. There's a few things to consider when using jQuery in a character sheets;  1)  the changes made by jQuery are transient and will be wiped once the sheet is closed again, and  2)  the changes made by jQuery are client-side only which means that they will not affect the view of anyone else viewing the sheet. Because this function is getting called in the K-scaffold's handling of the sheet opened event, it needs to be able to function with the trigger object being present, and without it. This is what the ternary in the first three lines of the function is doing. If a trigger is passed, which means we're calling it in response to a button click, use the trigger's name, but if there is no trigger the function is being called as part of the sheet opening and we just need to load the display from memory. The next few lines are simply sanitizing the name of the button to make it ready for use. The actual functionality of the function happens in the last few lines. navButtons.forEach((button)=>{ let element = button.replace(/-action/,''); $20(`.${element}`).removeClass('active'); }); $20(`.${page}`).addClass('active'); attributes.sheet_state = page; Here, we're iterating through each of our navButton names and removing the active class from any element with a matching class. Then we add the active element back to elements that have the class that matches the current sheet state. And, finally, we store the new active page in the sheet_state attribute so that we open to the correct display the next time. Toggling this way allows us to properly handle elements that might have two or more of the navigation classes on them, although we don't have any sections like this in the sheet. Of course, all that the function does is add and remove a CSS class to elements. Let's take a look at the SCSS that responds to these changes. .nav-display{ &:not(.active){ display:none !important; } } This overrides the display attribute of our various sections when they are not active. The only thing that we had to add to our existing pug code was to tag our navigable sections with the  .nav-display  class, which is just our character and settings articles at this point. Usually, it's a good idea to  stay away from the  !important  CSS flag , but we're using it here because if this is an element that should be hidden when not in use, we want it to always be hidden when not in use. How far have we come? It's been 5 posts (and 7 weeks) since we started on this sheet. We've now got a fully styled sheet that is navigable and calculates our attribute values for us, and we started with nothing but a sketch of what we wanted to do. That's some pretty good progress, but we aren't done yet! Next week, we'll look at designing a roll template and getting our roll buttons to actually function. The week after that, we'll look at what needs to change on the sheet to handle an NPC sheet. And finally we'll talk applying finishing touches and submitting the sheet to the  roll20 repository on github  so that everyone will be able to use it. Want input on what the next blog series will be about? Then d rop by the   Kurohyou Studios Patreon ! See the previous post  |  Check out the Repository  |  See the Next Post
1647440711

Edited 1647440738
Nick Turner
Plus
Marketplace Creator
Hi Scott, I'm not sure what went wrong, but all of the HTML examples have disappeared from the k-scaffold pug manual with your last checkin. Also thank you for such an invaluable resource as this scaffold and blog series. I still feel totally over my head (webdev really isn't my strong suit and the sheet I'm trying to make is waay beyond my capabilities), but this is helping me understand things no end, and I'm making some progress thanks to you.
1647445760
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Thanks Nick! Glad the blog series helping you out. Feel free to ask questions about what you're trying to do in your own sheet. Sheets are complex enough that any tutorial can't demonstrate every technique that can be used. I noticed the documentation problem right before I went to bed last night. Didn't have time to fix it then, but I'll get the document generator corrected later today.
1647447008
Nick Turner
Plus
Marketplace Creator
I'm building a sheet for a Powered-by-the-Apocalypse game, and I'm trying to make as much of it automated as I can, populating moves, stats, etc all from JSON. Hopefully once I crack the basics of manipulating repeating sections from code, and copying values from repeating sections into sheet attributes it shouldn't be that hard of a job, just a long one. Hopefully. Maybe.
1647450083
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
That all sounds pretty doable, and I'd love to see other sheets being made with the K-scaffold! And, on another note, the pug documentation has been fixed so that it actually shows the html that will be generated.
1647455658
Nick Turner
Plus
Marketplace Creator
Thanks. I've found examples of other sheets doing similar things (the Thirsty Sword Lesbians sheet is a masterpiece of automation, although I suspect it might be a little unwieldy to actually navigate in the middle of a game). I think I have a pretty good design worked out, it's just a case of figuring out the details one at a time I guess.
1647511501

Edited 1647521429
Nick Turner
Plus
Marketplace Creator
I just upgraded to the latest version of the scaffold, and I'm unable to build. Edit: never mind, I figured it out. For some reason (I can't remember why), I had nulls in some of my calls, which the previous version of the scaffold seemed to be okay with (but also didn't seem to be loading the JS side of itself, probably related). Changing: +input-label('name',{name:'character name',class:'underlined',type:'text'},null,{role:'heading','aria-level':5}) To: +input-label({ label:'name', inputObj:{name:'character name',class:'underlined',type:'text'}, spanObj:{role:'heading','aria-level':5} }) Seems to have fixed everything.
1647524165
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Yeah, it's a change I made to the scaffold to make the complex mixins easier to read. But I forgot to put the changelog section in the readme :face palm:. Essentially the mixins that previously accepted multiple arguments now use the destructuring assignment pattern.
1647525024
Nick Turner
Plus
Marketplace Creator
That's fine. It's the perils of being an early-adopter of a new framework I guess. I'm happy to have helped spot that one. Thankfully my sheet really hasn't progressed beyond the point that I can't just import your sheet, test that your version runs, then diff with my version and move my changes across incrementally until I find the sticking point. I would suggest that when you're done tinkering and improving things, that you go back to the first part of this tutorial series, and basically add a section for "here's a basic working sheet, it's minimal but it has things split into appropriate files, it's got one or two basic non-repeating attributes, one repeating one, and a simple script called on each row." That way, there will be a clear and simple breakdown of the basics people are likely to need, and a simple test setup to make sure that their pug compiles, the javascript loads and runs, and everything is in a simple state to begin writing whatever makes each sheet unique.
1647528343

Edited 1647528567
Andreas J.
Forum Champion
Sheet Author
Translator
Nick Turner said: I would suggest that when you're done tinkering and improving things, that you go back to the first part of this tutorial series, and basically add a section for "here's a basic working sheet, Agreed. This is a great guide, but the beginning glosses over the whole setup your tools & don't advice on how roll20 sheet editor / sheet sandbox works. The bar to get started with roll20 sheet dev based on this is higher than it needs to be. OTOH if the wiki had better guide on those it wouldn't be needed, but I've already written like 80% of the sheet dev docs anyway, and not planning on taking on any bigger rewrites in the near future.
1648218506
Nick Turner
Plus
Marketplace Creator
Hi Scott, I'm afraid I've found another documentation bug. The section on  Triggers  seems to have gotten itself quite mangled, and it's rather difficult to read. Which is unfortunate, as that seems like one of the most important parts of understanding the sheetworker side of things (something I'm currently struggling to do). Also, would it be possible to ask for a little advice (or an example) of how to have a repeating section that automatically populates and depopulates from code, using your framework? I.e. Something triggers a function (a button press would be ideal), and that causes a sheetworker to add a bunch of rows to a repeating section, then the user selects one of those rows, and another sheetworker copies that row's data into some static fields, and empties the repeating section again. Basically emulating a custom dropdown list, but without doing anything that violates the sanctity of the sandbox.
1648228448

Edited 1648229027
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Hey Nick, Yeah, I've been trying to figure out what went wrong with my document generator for the last week. If I can't figure out what happened to the autogenerator today, I'm just going to go in and manually edit the docs to fix the formatting issues. For the repeating section issue, I'd be happy to demonstrate a simple method of doing what you're talking about. There's actually a couple methods of doing this depending on what you're end goal is. Fieldset Creation We're going to use a custom listener function from our button click to populate our fieldset with the details of each class. This is useful if users need to be able to see details about the thing they are selecting, or edit the values of the attributes that will be set before they get applied to the sheet itself. See CRP driven dynamic selection  below if you're just wanting to create a dynamic select. In Action PUG include scaffold/_kpug.pug //-Container for our area that will be a very basic class selection dialog div //- Input to store our class' name +text({name:'class name'}) //- Input to store our class' hp +input-label({ label:'hp', inputObj:{name:'hp'} }) //- Input to store our attack bonus +input-label({ label:'attack bonus', inputObj:{name:'attack bonus'} }) //- Input to store our attack bonus +input-label({ label:'spell bonus', inputObj:{name:'spell bonus'} }) //- Action button to trigger our selection dialog +action({name:'class select',trigger:{listenerFunc:'populateSection'}}) |View Classes +fieldset({name:'class'}) +text({name:'name'}) +input-label({ label:'hp', inputObj:{name:'default hp'} }) //- Input to store our attack bonus +input-label({ label:'attack bonus', inputObj:{name:'default attack bonus'} }) //- Input to store our attack bonus +input-label({ label:'spell bonus', inputObj:{name:'default spell bonus'} }) +action({name:'choose',trigger:{listenerFunc:'applySelection'}}) |Choose this class +kscript include listenerfunctions.js JS //listenerfunctions.js const dynamicSelects = { class:{ barbarian:{ class_name:'barbarian', hp:12, attack_bonus:5, spell_bonus:-2 }, wizard:{ class_name:'wizard', hp:6, attack_bonus:-2, spell_bonus:5 }, bard:{ class_name:'bard', hp:8, attack_bonus:2, spell_bonus:2 }, }, ancestry:{ //We can store several different selection routines here. } }; //Function to populate our repeating section with options const populateSection = function(event){ //Get the details of the button we just pushed. We won't need section or id for this demo, but including them for completeness const [section,id,field] = k.parseTriggerName(event.triggerName); //sanitize our button name to get the thing we are selecting for. const button = field.replace(/-select$/,''); const setObj = {}; Object.entries(dynamicSelects[button]).forEach(([itemName,itemAttrs]) => { //Generate a new row in the repeating section that corresponds to our button that was clicked, and add it to the record of rowIDs in the sections argument const rowID = generateRowID(); setObj[`repeating_${button}_${rowID}_name`] = k.capitalize(itemName); Object.entries(itemAttrs).forEach(([attrName,attrValue]) => { setObj[`repeating_${button}_${rowID}_default_${attrName}`] = attrValue; }); }); k.setAttrs(setObj); }; k.registerFuncs({populateSection}); //Function to apply our selection from the repeating section to the non repeating part of the sheet. const applySelection = function(event){ k.debug({event}); //Get the details of the button we just pushed. We will be using section and id here. const [section,id,button] = k.parseTriggerName(event.triggerName); const selectType = section.replace(/repeating_/,''); k.getAllAttrs({ callback:(attributes,sections,casc) => { k.debug({attributes}); const itemName = attributes[`${section}_${id}_name`]; const values = dynamicSelects[selectType]; k.debug({values,itemName}); Object.keys(values[itemName.toLowerCase()]).forEach((attrName) => { //apply our the values from our selected repeating section to the non repeating attributes. attributes[attrName] = attrName.endsWith('_name') ? itemName : attributes[`${section}_${id}_default_${attrName}`]; //Add the attributes affected by this attribute to our queue. const trigger = casc[`attr_${attrName}`]; k.debug({trigger}); if(Array.isArray(trigger.affects)){ attributes.queue.push(...trigger.affects); } }); k.debug('reached line 68'); //And now we need to clear the repeating section sections[section].forEach((rowID)=>{ k.removeRepeatingRow(`${section}_${rowID}`,attributes,sections); }); //Set the attributes and trigger any attribute cascades that would have been triggered if we had set these manually. attributes.set({attributes,sections,casc}); } }) }; k.registerFuncs({applySelection}); CRP driven dynamic selection If you're just aiming for a select that has dynamic options, I'd actually recommend using Custom Roll Parsing (CRP) to handle that. I'll be doing a deeper dive on CRP today. But, here's what this would look like for this use case: In Action PUG include scaffold/_kpug.pug //-Container for our area that will be a very basic class selection dialog div //- Input to store our class' name +text({name:'class name'}) //- Input to store our class' hp +input-label({ label:'hp', inputObj:{name:'hp'} }) //- Input to store our attack bonus +input-label({ label:'attack bonus', inputObj:{name:'attack bonus'} }) //- Input to store our attack bonus +input-label({ label:'spell bonus', inputObj:{name:'spell bonus'} }) //- Action button to trigger our selection dialog +action({name:'class select',trigger:{listenerFunc:'crpSelect'}}) |Select Class +kscript include listenerfunctions.js JS //listenerfunctions.js const dynamicSelects = { class:{ barbarian:{ class_name:'barbarian', hp:12, attack_bonus:5, spell_bonus:-2 }, wizard:{ class_name:'wizard', hp:6, attack_bonus:-2, spell_bonus:5 }, bard:{ class_name:'bard', hp:8, attack_bonus:2, spell_bonus:2 }, }, ancestry:{ //We can store several different selection routines here. } }; //Note that this is an asynchronous function so that we can utilize the custom roll parsing. const crpSelect = async function(event){ //Get the details of the button we just pushed. We won't need section or id for this demo, but including them for completeness const [section,id,field] = k.parseTriggerName(event.triggerName); //sanitize our button name to get the thing we are selecting for. const button = field.replace(/-select$/,''); const options = Object.keys(dynamicSelects[button]).join('|'); //Await the input of the user on which class they'd like to select const selection = await k.extractQueryResult(`Which ${button}|${options}`); //I'm going to do a full getAllAttrs here to demonstrate how we can hook a custom listener back into the automated attribute cascade of the K-scaffold, but if the attributes you're setting wouldn't have any of these additional affects, you could skip this and just do a k.setAttrs instead. k.getAllAttrs({ callback:(attributes,sections,casc)=>{ Object.entries(dynamicSelects[button][selection]).forEach(([attrName,attrValue])=>{ //assign our value attributes[attrName] = attrValue; //Extract the trigger details for this attribute const trigger = casc[`attr_${attrName}`]; //Add the attributes affected by this attribute to our queue. if(Array.isArray(trigger.affects)){ attributes.queue.push(...trigger.affects); } //Set the attributes and trigger any attribute cascades that would have been triggered if we had set these manually. attributes.set({attributes,sections,casc}); }); } }); }; k.registerFuncs({crpSelect});
1648230118
Nick Turner
Plus
Marketplace Creator
You sir are a scholar and a gentleman and I quite literally cannot thank you enough. But I'm going to try anyway. Thank you! That first example not only does exactly what I'm after right now, but it is also clear enough that I should be able to adapt it to handle most/all of the other things I need further down the line.