Some time ago I noticed an interesting product on DM’s Guild. Someone has written a series of solo adventures that are played in a similar manner to choose your own adventure games of the good old days. An adventure comes as a pdf file, describing a scene, presenting the player with options and directing the player to another section in the pdf file based on their choices. What makes in different is that it incorporates rules of D&D, asking for checks and other rolls when needed, setting up encounters on grid maps, prompting the player to use certain items from their inventory if they have them etc…
While playing, I found myself with three pdf files open in front of me. One for narrative sections, another copy of the same file for tile-map descriptions, one to see the actual tile-maps. At the same time on my browser, I have my own interactive character sheet app in one tab, rule references in few other tabs, and a text editor window to keep notes. It was an enjoyable but busy experience.
I stopped and got back to work while thinking to myself, “it’s a shame that this can’t be automated with ease” which made me decide to write an application that would do just that. I announced my intentions to the supportive 5th Edition community, got their input for a few issues and started building the application. We are here now. I will be writing this during the production process, so it will be a mixture of a design document and a summary of pitfalls that someone whose expertise isn’t to develop web-tools will fall along the way. It will be edited as I continue building the application as some sort of a diary. Sections might be added non-chronologically to create some appearance of narrative and to keep the related parts close to each other. Section headers will be followed by the date they were last updated
I do bioinformatics by trade and my “native” programming language is R. Surely a statistical computing language isn’t the best tool for this task but rest of my D&D related code that I intend to re-use is written in R already so I’m sticking to it… The whole thing will likely be a combination of R and javascript haphazardly glued together.
Picking the adventure format (16-02-2020)
I want to start the project by defining the file format for the adventure first. This will allow me to see what features I need to implement. My initial thoughts on this are:
It should be easy to learn. Not even drastically different from creating a pdf like the solo adventures that inspired me in the first place. So the file format will probably be markdown with some special syntax
I don’t have a background on language design. So it can’t be very complex I am hoping to get away with using existing markdown parsers to tokenize the input. I will probably be using
commonmark
package for this purpose.It will not be executing user generated R code. I briefly considered allowing using RMD files but since the application will be run on my servers, I don’t want people to have that kind of power. This also means that the language will not be anywhere near Turing complete.
I am aware that there are interactive fiction writing software out there with their own languages, so I might take a look at the stuff they did to see anything I might need.
Upon some reading and Redditting, I came up with a list of features. The format should support:
Asking for characters to be uploaded. Currently I do not plan on having the ability to restrict the nature of these characters in any way. Creator should explain what the suggested level for the adventure is and what kind of character would be preferred but there will be no validation. It should be possible to have multiple party members if the adventure calls for it. Currently unsure if I will allow players to add more characters outside the creator’s instructions.
Being able to create scenes that can be linked to other scenes through words/buttons.
A way to add scenes that are appended after a scene rather than force a full scene switch.
Being able to add buttons for character features such as saves or ability checks. If multiple characters are present in the party, choosing between adding buttons for all party members, just picking member with the maximum modifier or rolling a group check
Being able to add referencable objects. These will represent characters, monster stat blocks, items, spells. They will have to be represented in a standard format which will probably a table, under headers with reserved names. I will use the
import5eChar
’s character definitionmonsteR
’s monster definition andwizaRd
’s spell definition. Not sure how to treat the items.Being able to add a custom PC to the party
Being able to refer to external files. This will turn the format into something like a zip file rather than a single markdown file but it should make working on a project easier. One can also make compendiums of referencable objects to be used in multiple projects. Possibly I may allow including things like image files in here as well though this will add some overhead to the server. Could be better to just trust that there will always be a URL with the required resources.
Being able to suggest changes to the player’s inventory, notes, character and any other record keeping device I end up adding.
Being able to check player record keeping devices for some basic conditions, probably regex dependent to alter a scene. The suggested use of this feature is to highlight certain options rather than restricting them.
Being able to start encounters with a defined map image and monsters tokens/data
Pre-built PCs for the adventure
Many interactive fiction software has ways to add some flare to the text being displayed I think I will leave this out or now. Normal html tags continue to work and I can allow people to use their own CSS files if they are really into it.
Finally, as a final sanity check, I want to make sure that any interactive action that was attempted in the 5e Solo Gamebooks to be supported.
Based on these criteria I will be using this file as a minimal example. The exact format and features might change as the project evolves. Currently monster and npc definitions aren’t complete for instance. The linked file will change if I explain the changes on this chapter to make it look like I have planned it all, it will stay the same if I choose to explain them along with the aspect of the project that caused the change. I haven’t decided yet…
Parsing the files (27-02-2020)
I need a reliable way to parse the syntactical abomination I just defined up here. As I am writing this, there are still a few undefined parts such as NPC and monster definitions so the way to deal with them will have to wait for a bit.
The main package that will be helping me here is commonmark
which parses an md file and can convert it into an xml, which in turn I can turn into a list. There is currently one option that is defined by a yaml on top of the file, which can be read with yaml
package.
I will be using the markdown_html
to get the output in html structure since that will allow me to insert the scenes I extract
directly into the user UI, probably using shiny::insertUI
.
Lets start with the minimal example as is
library(commonmark)
library(dplyr)
library(xml2)
library(purrr)
library(gameicons)
yamlOptions = yaml::read_yaml('files/5eif/minimalExample.txt')
# in this minimal example these files aren't real but these files would be processed
# in the same manner as the main campaign file
# currently only yaml option is the import but that might change later
yamlOptions
## $import
## [1] "helperfile.md" "helpferfile2.md"
fileLines = readLines('files/5eif/minimalExample.txt')
# the first two repeated -s are parts of the yaml and they are wrongly added into
# the html as an h2 if passed down there so we'll be removing them and the lines in between
fileLines = grep('--',fileLines)[1:2] %>%
{seq(.[1],.[2])} %>%
{fileLines[-.]}
# let's cheat a little and place anything related to our syntax in a script tag so that they can be parsed
# as html directly. otherwise we'd have to go through text after it is parsed
fileLines %<>%
gsub('{{','<script class="5eifCode">',.,fixed = 'TRUE') %>% gsub('}}','</script>',.,fixed = 'TRUE')
campaign = fileLines %>%
paste0(collapse = '\n') %>%
markdown_html() %>%
xml2::read_html() %>%
xml2::as_list() %$%
html %$% body
Won’t be displaying the campaign
object here since it’s large and messy but the important part is that
we know that:
Scenes are separated by
h2
headersCampaign name is the first and currently only
h1
headerh1
,h2
andp
and other tags are named elements in our listOur custom commands appears withing
script
tags with a unique class name
So lets start getting relevant information from the file.
# ensure that there is only one h1.
assertthat::assert_that(sum(names(campaign) == 'h1') == 1)
## [1] TRUE
# get the title
campaignTitle =
campaign %>%
{.[[which(names(.) == 'h1')]]} %>%
unlist %>% {.[. != '[ comment ]']} %>% # remove comments
paste(collapse = '')
campaignTitle
## [1] "The minimal example "
# get scene titles
sceneIndices = which(names(campaign) == 'h2')
sceneTitles = campaign[sceneIndices] %>% sapply(function(x){
x %>% unlist %>% {.[. != '[ comment ]']}
})
# a small function to extract a scene
get_scene = function(scene,campaign,sceneIndices, sceneTitles){
sceneIndexIndex = which(sceneTitles == scene)
sceneBorders = sceneIndices[c(sceneIndexIndex,sceneIndexIndex+1)]
if(is.na(sceneBorders[2])){
sceneBorders[2] = length(campaign)
}
sceneData = campaign[seq(sceneBorders[1], sceneBorders[2]-1)]
}
room = get_scene("The Room", campaign, sceneIndices, sceneTitles)
room %>% head
## $h2
## $h2[[1]]
## [1] "The Room"
##
##
## [[2]]
## [1] "\n"
##
## $p
## $p[[1]]
## [1] "There is a chest in this room."
##
##
## [[4]]
## [1] "\n"
##
## $p
## $p[[1]]
## [1] "If you want to check for traps, roll an investigation check (DC 15)"
##
##
## [[6]]
## [1] "\n"
We will have to embed this data into a shiny application eventually so let’s see if we can do that in a straightforward manner
scene_to_tags = function(scene){
lapply(seq_along(scene),function(i){
tagName = names(scene[i])
if(!is.null(tagName) && tagName!=''){
# make sure we are not leaving attributes behind
attributes = attributes(scene[[i]])
attributes = attributes[!names(attributes) %in% 'names']
if(!is.null(names(attributes))){
names(attributes) = gsub('.','',names(attributes),fixed = TRUE)
}
if(length(scene[[i]]) > 1){
return(do.call(htmltools::tags[[tagName]],
# create a list to add the attributes if there are any
c(scene_to_tags(scene[[i]]),attributes)))
} else{
return(do.call(htmltools::tags[[tagName]],
c(scene[[i]],attributes)))
}
} else{
# if the tag has no name, it's a child of the parent tag so pass it as is.
# it will be processed by the parent call
if(scene[[i]]!= '[ comment ]'){
return(scene[[i]])
} else{return(NULL)}
}
}) %>% do.call(shiny::tagList,.)
}
scene_to_tags(room)
The Room
There is a chest in this room.
If you want to check for traps, roll an investigation check (DC 15)
If you passed
If you failed
scene_to_tags
function will allow us to turn these scenes into html tags. Currently
buttons are invisible since they are encoded as script
tags. Ultimately we will be replacing
the script
tags with intractable buttons. My current thinking is to deal with the replacement
within this function since we are already traversing the object. Since we will be adding
buttons directly through html instead of the shiny functions, we will have to manually bind
them to shiny inputs. I will probably set a single shiny input that will be set to information about what button is pressed instead of creating shiny inputs for each new button. That means we can
just have an internal syntax that tells a single listener what to do. I also want to extract some
information about the scene while doing this.
The details will of this will wait for the rest of the infrastructure but for now, lets turn the output into a list with two elements. First element is the tagList while second element is information extracted from the code within the scene. For now, showing which other scenes the scene is connected to. This is somewhat annoying to do due to recursive nature of the function. The tagList still needs to be created recursively, while the data we are extracting is non-hierarchical and can pop up anywhere within the function. We can deal with that by creating an object in the environment of the first function call and modify that object in the recursive calls by referring to the original environment.
# modify the scene_to_tags function to intercept script tags
process_scene = function(scene, env = NULL){
# if this is the first call, create the sceneData object
if(is.null(env)){
sceneData = list(
links = list()
)
env = environment()
isRoot = TRUE
} else{
isRoot = FALSE
}
tags = lapply(seq_along(scene),function(i){
tagName = names(scene[i])
if(!is.null(tagName) && tagName!=''){
# make sure we are not leaving attributes behind
attributes = attributes(scene[[i]])
attributes = attributes[!names(attributes) %in% 'names']
if(!is.null(names(attributes))){
names(attributes) = gsub('.','',names(attributes),fixed = TRUE)
}
# intercept the script call to extract data
if(tagName =='script' && !is.null(attributes$class) && attributes$class == '5eifCode'){
if(grepl('^\\\\',scene[[i]][[1]])){
# ignore this for now. any command that starts with a \ is not a location link
} else{
# processing the syntax for room links. this syntax may change
if(grepl('\\|',scene[[i]][[1]])){
linkData = strsplit(scene[[i]][[1]],'\\|')[[1]]
linkText = linkData[1]
linkData = linkData[2] %>% strsplit(':') %>% {.[[1]]}
linkTarget = linkData[1]
linkArgs = linkData[2]
} else{
linkData = strsplit(scene[[i]][[1]],':')[[1]]
linkText =linkData[1]
linkTarget = linkData[1]
linkArgs = linkData[2]
}
append = list(list(
text = linkText,
target = linkTarget,
args = linkArgs))
names(append) = linkTarget
env$sceneData$links = c(env$sceneData$links,
append)
}
}
if(length(scene[[i]]) > 1){
return(do.call(htmltools::tags[[tagName]],
# create a list to add the attributes if there are any
c(process_scene(scene[[i]], env),attributes)))
} else{
return(do.call(htmltools::tags[[tagName]],
c(scene[[i]],attributes)))
}
} else{
# if the tag has no name, it's a child of the parent tag so pass it as is.
# it will be processed by the parent call
if(scene[[i]]!= '[ comment ]'){
return(scene[[i]])
} else{return(NULL)}
}
}) %>% do.call(shiny::tagList,.)
if(!isRoot){
return(tags)
} else{
return(list(tags = tags,
data = sceneData))
}
}
roomData = process_scene(room)
roomData$data$links
## $`Invest Pass`
## $`Invest Pass`$text
## [1] "click here"
##
## $`Invest Pass`$target
## [1] "Invest Pass"
##
## $`Invest Pass`$args
## [1] "appendhere"
##
##
## $`Invest Fail`
## $`Invest Fail`$text
## [1] "click here"
##
## $`Invest Fail`$target
## [1] "Invest Fail"
##
## $`Invest Fail`$args
## [1] "appendhere"
Now we have a function that returns the tag lists and some information about a room.
Ultimately this will be modified to intercept all script tags with class 5eifCode
and replace them with relevant input devices. That part will come later.
For now we can make a quick and dirty visualization tool
library(DiagrammeR)
processedScenes = sceneTitles %>% lapply(function(x){
get_scene(x,campaign,sceneIndices,sceneTitles) %>% process_scene()
})
names(processedScenes) = sceneTitles
# just a way to fidget with graphs (https://stackoverflow.com/questions/54109621/r-diagrammer-horizontal-graph/56590762#56590762)
draw_graph = function(graph){
graph %>%
generate_dot() %>%
gsub(pattern = 'neato',replacement = 'dot',x= .) %>%
gsub(pattern = "graph \\[",'graph \\[rankdir = LR,\n',x = .) %>%
grViz()
}
graph = create_graph()
# remove special headers for now. Not sure if they'll be reserved words or will have a different syntax for them
processedScenes = processedScenes[!names(processedScenes) %in% c('Characters','Stat Blocks')]
for (i in seq_along(processedScenes)){
graph %<>% add_node(label = names(processedScenes[i]))
links = processedScenes[[i]]$data$links %>% names
linkIndex = which(names(processedScenes) %in% links)
for(j in linkIndex){
# fun fact: apparently we can add edges to not-yet-existing nodes
graph %<>% add_edge(from =i, to = j)
}
}
graph %<>% set_node_attrs(
node_attr = 'fontcolor',
values = 'black'
)
draw_graph(graph)
I am also planning to make something like this available to content creators somehow.
Dealing with maps (14-02-2020)
The most annoying task for me in this project seems to be the grid combat layer since I am not good at javascript myself and there aren’t specialized tools to deal with the issue We need the ability to use a custom background image to act as a map and some draggable tokens. It would be a bonus to be able align tokens to a grid but I have concerns about it since it would force content creator to align the files properly as well.
A quick search for a javascript library that I may port using htmlwidgets
lead to
nowhere. So it appears that the best scenario here is to use leaflet
or something similar. From memory I can say leaflet can have image overlays, it can add markers with custom images on a map. So I should be able to use a leaflet map without a background tile
layer. Maybe I should consider allowing creation of actual tile layers to allow for larger maps but let’s stick to the basics for now.
Initially, googling for how to add images to a leaflet map lead me to the addRasterImage
function. However it appears
that it doesn’t behave well with non-projected coordinate systems which we want to be using because we are not mapping a globe here. Also my quick and dirty attempt to show this random map I found lead to an undesirable result.
library(leaflet)
library(raster)
library(magrittr)
download.file('https://external-preview.redd.it/7tYT__KHEh8FBKO6bsqPgC02OgLCHAFVPyjdVZI4bms.jpg?auto=webp&s=ff2fa2e448bb92c4ed6c049133f80370f306acb3',
destfile = 'map.jpg')
map = raster::raster('map.jpg')
crs(map) = CRS("+init=epsg:4326")
leaflet() %>%
leaflet::addRasterImage(map)
## Warning in rgdal::rawTransform(projfrom, projto, nrow(xy), xy[, 1], xy[, : 681
## projected point(s) not finite
## Warning in rgdal::rawTransform(projfrom, projto, nrow(xy), xy[, 1], xy[, : 681
## projected point(s) not finite
## Warning in rgdal::rawTransform(projfrom, projto, nrow(xy), xy[, 1], xy[, : 681
## projected point(s) not finite
After a quick visit to stackoverflow I don’t know what exactly is wrong with addRasterImage
function but I know that I can use javascript to work around it.
imageURL = 'https://external-preview.redd.it/7tYT__KHEh8FBKO6bsqPgC02OgLCHAFVPyjdVZI4bms.jpg?auto=webp&s=ff2fa2e448bb92c4ed6c049133f80370f306acb3'
# get image data. we'll use this to set the image size
imageData =
magick::image_read(imageURL) %>% magick::image_info()
leaflet(options = leafletOptions(crs = leafletCRS('L.CRS.Simple'), minZoom = -1)) %>%
setView(0,0,zoom = -1) %>%
htmlwidgets::onRender(glue::glue("
function(el, x) {
var myMap = this;
var imageUrl = '<imageURL>';
var imageBounds = [[<-imageData$height/2>,<-imageData$width/2>], [<imageData$height/2>,<imageData$width/2>]];
L.imageOverlay(imageUrl, imageBounds).addTo(myMap);
}
",.open = '<', .close = '>'))
So now we can show arbitrary images as maps using leaflet. If we add movable tokens of different sizes to this map, the basic functionality will be complete. addMarkers
function of leaflet is the easiest way to do this. You give it an image and coordinates and it places it where you want. Lets use a random map I grabbed from Dave’s Mapper
and try adding some tokens that are grabbed from game-icons.net. While doing that we should make sure that the size of the token we are adding matches the size of a single square on the map. In the actual app, the creator should be able to give a unit square size and spawn characters of
imagePath = 'files/5eif/tile.png'
imageData =
magick::image_read(imagePath) %>% magick::image_info()
# this tile is 10x10 squares
gridWidth = imageData$height/10
gridHeight = imageData$width/10
leaflet(options = leafletOptions(crs = leafletCRS('L.CRS.Simple'), minZoom = 0)) %>%
setView(0,0,zoom = 0) %>%
addMarkers(lng = gridWidth/2, lat = gridHeight/2,options = markerOptions(draggable = TRUE),
icon = list(iconUrl = 'https://game-icons.net/icons/000000/ffffff/1x1/cathelineau/swordman.svg',
iconSize = c(gridHeight,gridWidth))) %>%
htmlwidgets::onRender(glue::glue("
function(el, x) {
var myMap = this;
var imageUrl = '<imagePath>';
var imageBounds = [[<-imageData$height/2>,<-imageData$width/2>], [<imageData$height/2>,<imageData$width/2>]];
L.imageOverlay(imageUrl, imageBounds).addTo(myMap);
}
",.open = '<', .close = '>'))
Now we have a token that is appears to be at the right size on the map and fits a tile square. However, if you zoom in, you’ll notice that the size of the marker doesn’t scale with the map’s zoom level. Markers’ normal use case is to be map markers that are visible regardless of map’s zoom level so they are not scaled by default. Also there are no options to make them simply resize on a zoom event. After searching for a while I find this answer in stack exchange which leads me to believe I’ll have to use zoomend event to resize the tokens when zoom level changes.
There is some experimenting in between finding that answer and writing the code below but basically, I found that
if you have multiple tokens in different layers, you have to apply setIcon
function on each layer to update them all. I also seem to be doing something wrong when using addMarker
function as is doesn’t like me adding multiple markers with a single call so I’ll likely have to iterate over multiple layers to change the icon size. The easiest way to iterate a bunch of layers appears to be grouping them together and using map.layerManager.getLayerGroup()
to iterate over all the layers. We will be adding the code required into the
htmlwidgets::onRender
function.
imagePath = 'files/5eif/tile.png'
imageData =
magick::image_read(imagePath) %>% magick::image_info()
# this tile is 10x10 squares
gridWidth = imageData$height/10
gridHeight = imageData$width/10
leaflet(options = leafletOptions(crs = leafletCRS('L.CRS.Simple'), minZoom = 0)) %>%
setView(0,0,zoom = 0) %>%
addMarkers(lng = gridWidth/2, lat = gridHeight/2,group = 'tokens',options = markerOptions(draggable = TRUE),
icon = list(iconUrl = 'https://game-icons.net/icons/000000/ffffff/1x1/cathelineau/swordman.svg',
iconSize = c(gridHeight,gridWidth))) %>%
addMarkers(lng = gridWidth/2+2*gridWidth, lat = gridHeight/2,group = 'tokens',options = markerOptions(draggable = TRUE),
icon = list(iconUrl = 'https://game-icons.net/icons/000000/ffffff/1x1/lorc/bowman.svg',
iconSize = c(gridHeight,gridWidth))) %>%
htmlwidgets::onRender(glue::glue("
function(el, x) {
var myMap = this;
var imageUrl = '/<imagePath>';
var imageBounds = [[<-imageData$height/2>,<-imageData$width/2>], [<imageData$height/2>,<imageData$width/2>]];
L.imageOverlay(imageUrl, imageBounds).addTo(myMap);
myMap.on('zoomend', function() {
var currentZoom = myMap.getZoom();
myMap.layerManager.getLayerGroup('tokens').getLayers().forEach(function(value,i){
value.setIcon(L.icon({iconUrl: value._icon.src, iconSize: [<gridHeight>*Math.pow(2,currentZoom),<gridWidth>*Math.pow(2,currentZoom)]}))
})
})
}
",.open = '<', .close = '>'))
As it stands, we’re done at a bare bones level. I’ll have to add groups for each size category unless I find a reliable way to pull token size. Token object properties doesn’t seem to be consistently updated on a size change so I don’t trust them yet. It shouldn’t affect performance too much… I will also turn this into a module with the inputs:
- background map
- minimum, maximum zoom
- initial zoom
- image paths for the tokens
- size category for the tokens (need to decide what to do about possible rectangles even though 5e doesn’t technically have them)
- initial locations of the tokens. If no location is provided, possibly line them on the top of the map so the player can place the tokens as instructed. It would be difficult to have conditional token placement unless alternative encounter scenes are created.
- possibly pop up data for the tokens. At a bare minimum, I should be adding names. Might display more information if I can.
UI design (05-03-2020)
It’s time to merge everything into a UI now. You’ll notice that our plan is nowhere near complete now but I’ll be canibalizing lots of code to make this work so it is a good idea how things get together before going too crazy with the details. I am planning to go with a shinydashboard
design since we will need multiple windows to display all information on demand. Also my UI skills aren’t amazing so I don’t want to be designing features that will fail because I can’t implement them on the UI end.
My initial thinking is something like this
The main window is where the scenes and encounters will be displayed. Current plan is to have separate windows for scenes and encounters since encounters will require more complex UI elements, I might end up merging the two, allowing embedding encounters directly into the story text. The complex UI elements might be outsourced into the shortcuts section
The main window can be used to display whole character sheets and notes such as inventory contents and other information that the campaign requires. Considering allowing a scene that can work as a world map/game summary/quest log here as well.
The shortcuts section is there to save people from having to switch to viewing whole character sheets or notes all the time so it will have some of the information that may be relevant regardless of scene context.
Finally the console output is where the reporting will happen. Any roll will be reported there. I can cut it in half for some encounter specific elements later.
Not pictured here but the sidebar will probably have something like an initiative tracker above or below the import/export buttons. Now I can proceed to write the modules.
Diversion: gameicons package (05-03-2020)
Shiny allows adding icons from fontawesome and glyphicons with the icon
function. This is nice because one doesn’t have to deal with css or adding the full tag to make the icons appear. These glyph fonts are a little limited for our purposes though. They are for general web use not for an RPG ui. Thankfully we have game-icons that include glyphs that are more useful for our purposes featuring both dungeons and dragons .
The code for the entire package is rather short with a single important function (game_icon()
) and the output class is identical to the icon
package, making it usable as a replacement. Most of the heavy lifting was already done in this repository where svg files provided by the site is turned into a font, also automatically incorporating any changes with daily updates. So the package only loads the CSS from there and inserts the correct syntax for the desired glyph into the site.
One useful addition is the iconTable
data frame which includes the author and license information for all icons provided through the package. Most of these icons were released under CC BY 3.0 and require giving credit to the creator.
head(gameicons::iconTable)
## icon author license
## 1 police-badge Andy Meneely CC BY 3.0
## 2 pistol-gun John Colburn CC BY 3.0
## 3 arrest Delapouite CC BY 3.0
## 4 banging-gavel Delapouite CC BY 3.0
## 5 chalk-outline-murder Delapouite CC BY 3.0
## 6 convict Delapouite CC BY 3.0