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