Go forth, slayer of Demons

.Visualize {ggplot2} {ggfx} {magick}

Blending {ggplot2} geoms with {ggfx} and other {magick} tricks.

Michael McCarthy ../../about.html (The Nexus)
06-15-2021

Overview

On the first day
Man was granted a soul
And with it, clarity

On the second day
upon Earth was planted
an irrevocable poison
A soul-devouring demon

Demon’s Souls is an action role-playing video game set in the dark fantasy kingdom of Boletaria, a land cursed with a deep, terrible fog brought forth by an ancient soul-devouring demon called the Old One. To lift the curse and mend the world players must slay and absorb the souls of five powerful archdemons, whereafter they can face the Old One and lull it back to slumber. Demon’s Souls is renowned for its challenge and design, and has made a lasting impact on the video game industry. It is also the progenitor of what has become one of my favourite video game franchises.

Theming Inspiration

Hero text appears on the screen whenever the player performs a significant action in Demon’s Souls, such as slaying a demon or, infamously, dying themselves. These provide a great design reference for plot theming.

In-game screenshots of the hero text from Demon’s Souls.In-game screenshots of the hero text from Demon’s Souls.

Figure 1: In-game screenshots of the hero text from Demon’s Souls.

Demon’s Souls also has a unique logo whose design I want to reference.

The Demon’s Souls logo.

Figure 2: The Demon’s Souls logo.

I want to translate these design elements to my plot like so:

Applying these elements to my plot will help it fit the Demon’s Souls aesthetic.

R

I’ll be using PlayStation Network trophy data for my plot. The data contains statistics for the percent of players who have slain a given boss in Demon’s Souls out of all the players who have ever played the game. I have constructed the data manually since Sony does not provide an API to access PlayStation Network trophy data programmatically. Demon’s Souls was released on February 5, 2009, so it is unlikely these stats will change much in the future.

# Tribbles are not just useful for scaring Klingons, they make it easy to
# create tibbles too
demons_souls <- tribble(
  ~boss,            ~boss_type,  ~location,              ~archstone, ~percent_completed,
  "Phalanx",        "Demon",     "Boletarian Palace",    "1-1",      63.1,               
  "Tower Knight",   "Demon",     "Boletarian Palace",    "1-2",      46.6,               
  "Penetrator",     "Demon",     "Boletarian Palace",    "1-3",      30.3,               
  "False King",     "Archdemon", "Boletarian Palace",    "1-4",      24.2,               
  "Armor Spider",   "Demon",     "Stonefang Tunnel",     "2-1",      43.9,               
  "Flamelurker",    "Demon",     "Stonefang Tunnel",     "2-2",      35.1,               
  "Dragon God",     "Archdemon", "Stonefang Tunnel",     "2-3",      33.1,               
  "Fool’s Idol",    "Demon",     "Tower of Latria",      "3-1",      35.7,               
  "Maneater",       "Demon",     "Tower of Latria",      "3-2",      28.7,               
  "Old Monk",       "Archdemon", "Tower of Latria",      "3-3",      27.7,               
  "Adjudicator",    "Demon",     "Shrine of Storms",     "4-1",      36.1,               
  "Old Hero",       "Demon",     "Shrine of Storms",     "4-2",      28.8,               
  "Storm King",     "Archdemon", "Shrine of Storms",     "4-3",      28.1,               
  "Leechmonger",    "Demon",     "Valley of Defilement", "5-1",      32.5,               
  "Dirty Colossus", "Demon",     "Valley of Defilement", "5-2",      27.2,               
  "Maiden Astraea", "Archdemon", "Valley of Defilement", "5-3",      26.6
) %>%
  mutate(across(boss_type:archstone, as_factor))

demons_souls
#> # A tibble: 16 x 5
#>    boss         boss_type location          archstone percent_complet…
#>    <chr>        <fct>     <fct>             <fct>                <dbl>
#>  1 Phalanx      Demon     Boletarian Palace 1-1                   63.1
#>  2 Tower Knight Demon     Boletarian Palace 1-2                   46.6
#>  3 Penetrator   Demon     Boletarian Palace 1-3                   30.3
#>  4 False King   Archdemon Boletarian Palace 1-4                   24.2
#>  5 Armor Spider Demon     Stonefang Tunnel  2-1                   43.9
#>  6 Flamelurker  Demon     Stonefang Tunnel  2-2                   35.1
#>  7 Dragon God   Archdemon Stonefang Tunnel  2-3                   33.1
#>  8 Fool’s Idol  Demon     Tower of Latria   3-1                   35.7
#>  9 Maneater     Demon     Tower of Latria   3-2                   28.7
#> 10 Old Monk     Archdemon Tower of Latria   3-3                   27.7
#> 11 Adjudicator  Demon     Shrine of Storms  4-1                   36.1
#> 12 Old Hero     Demon     Shrine of Storms  4-2                   28.8
#> 13 Storm King   Archdemon Shrine of Storms  4-3                   28.1
#> 14 Leechmonger  Demon     Valley of Defile… 5-1                   32.5
#> 15 Dirty Colos… Demon     Valley of Defile… 5-2                   27.2
#> 16 Maiden Astr… Archdemon Valley of Defile… 5-3                   26.6

Wrangle

The data is already structured the way I want it for my plot, but there are still some interesting things to explore through wrangling and summary stats.

Within each location, players have to slay each demon in the order specified by the archstones. For example, in the Boletarian Palace a player cannot face the Tower Knight before they have slain the Phalanx. So each location has a first, second, and third boss (and the Boletarian Palace has a fourth that can only be faced after slaying all the other demons). This can be used to get an imperfect idea of player attrition in the game.

# Detect the order of bosses based on archstone suffix
demons_souls <- demons_souls %>%
  mutate(
    archstone_boss = case_when(
      str_detect(archstone, "-1") ~ "First",
      str_detect(archstone, "-2") ~ "Second",
      str_detect(archstone, "-3") ~ "Third",
      str_detect(archstone, "-4") ~ "Fourth (False King)"
    ),
    archstone_boss = as_factor(archstone_boss),
    .after = archstone
  )

demons_souls
#> # A tibble: 16 x 6
#>    boss   boss_type location archstone archstone_boss percent_complet…
#>    <chr>  <fct>     <fct>    <fct>     <fct>                     <dbl>
#>  1 Phala… Demon     Boletar… 1-1       First                      63.1
#>  2 Tower… Demon     Boletar… 1-2       Second                     46.6
#>  3 Penet… Demon     Boletar… 1-3       Third                      30.3
#>  4 False… Archdemon Boletar… 1-4       Fourth (False…             24.2
#>  5 Armor… Demon     Stonefa… 2-1       First                      43.9
#>  6 Flame… Demon     Stonefa… 2-2       Second                     35.1
#>  7 Drago… Archdemon Stonefa… 2-3       Third                      33.1
#>  8 Fool’… Demon     Tower o… 3-1       First                      35.7
#>  9 Manea… Demon     Tower o… 3-2       Second                     28.7
#> 10 Old M… Archdemon Tower o… 3-3       Third                      27.7
#> 11 Adjud… Demon     Shrine … 4-1       First                      36.1
#> 12 Old H… Demon     Shrine … 4-2       Second                     28.8
#> 13 Storm… Archdemon Shrine … 4-3       Third                      28.1
#> 14 Leech… Demon     Valley … 5-1       First                      32.5
#> 15 Dirty… Demon     Valley … 5-2       Second                     27.2
#> 16 Maide… Archdemon Valley … 5-3       Third                      26.6

Now, there are two ways to go about getting this imperfect idea of player attrition in the game. The first involves using the entire data set.

# Calculate the average percent of players who have slain the first, second,
# ..., archstone boss across locations. 
demons_souls %>%
  group_by(archstone_boss) %>%
  summarise(average_completed = mean(percent_completed))
#> # A tibble: 4 x 2
#>   archstone_boss      average_completed
#> * <fct>                           <dbl>
#> 1 First                            42.3
#> 2 Second                           33.3
#> 3 Third                            29.2
#> 4 Fourth (False King)              24.2

The second involves removing the Phalanx from the data set due to its influential pull on the average for the first archstone boss. It has a much higher completion percent (63.1%) than the other bosses in the game, and the reason for this is that the Phalanx is the first boss in the game. Players must slay it before they can go to face the first archstone boss from other locations in the game. Removing the Phalanx might give a more accurate picture of average completion for first archstone bosses.

# Trophy earned: Slayer of Demon "Phalanx"
demons_souls %>%
  filter(boss != "Phalanx") %>%
  group_by(archstone_boss) %>%
  summarise(average_completed = mean(percent_completed))
#> # A tibble: 4 x 2
#>   archstone_boss      average_completed
#> * <fct>                           <dbl>
#> 1 First                            37.0
#> 2 Second                           33.3
#> 3 Third                            29.2
#> 4 Fourth (False King)              24.2

With the Phalanx’s influence removed, it looks like there is roughly a 4% drop in average completion for each successive archstone boss. In order to face the False King players must first slay every other demon and archdemon in the game, so it is interesting the drop stays consistent there. Most players who made it far enough to slay their first archdemon then went on to slay the rest.

About one quarter of Demon’s Souls players persisted to the end of the game. But three quarters did not. Assuming most players at least attempted each location, then averaging by location can give an imperfect idea of their overall difficulty for players during their first playthrough.

# Calculate the average completion rate by location, arranged from "easiest" to
# "hardest"
demons_souls %>%
  group_by(location) %>%
  summarise(average_completed = mean(percent_completed)) %>%
  arrange(desc(average_completed))
#> # A tibble: 5 x 2
#>   location             average_completed
#>   <fct>                            <dbl>
#> 1 Boletarian Palace                 41.0
#> 2 Stonefang Tunnel                  37.4
#> 3 Shrine of Storms                  31  
#> 4 Tower of Latria                   30.7
#> 5 Valley of Defilement              28.8

It looks like there are two clusters here, an easier one with the Boletarian Palace and Stonefang Tunnel, and a harder one with Shrine of Storms, Tower of Latria, and the Valley of Defilement. I finished my first playthrough of the game in 2012, so I only have distant memories to reflect on, but this ranking looks sound to me. For experienced players I think this ranking is less relevant. Once you’re experienced most of the variability in difficulty comes down to the character build you choose.

Visualize

# Define aliases for plot fonts and colours
optimus <- "OptimusPrinceps"
optimus_b <- "OptimusPrincepsSemiBold"
yellow <- "#ffaf24" #  #fec056

The plot I want to make is inspired by this Tidy Tuesday plot by Georgios Karamanis. I used Georgios’ code as a starting point, then modified it to get the behaviour and result I wanted.

The centrepiece of the plot is the coloured text that shows the percent of Demon’s Souls players who have completed a given boss in yellow and who have not in red. This effect is achieved by applying a rectangular filter over the text that only allows the portion of the text within the filter’s borders to be shown. Doing this once for yellow text and once for red text allows the full string to appear, with the ratio of colours within a boss’s name reflecting the percent of players that have completed it. A few calculations are needed in order for the ratios to be accurate, and for the text to look aesthetically pleasing.

demons_souls_plot <- demons_souls %>%
  mutate(
    # Percentages need to be in decimal form for the calculations and plotting
    # to work properly
    percent_completed = percent_completed/100,
    boss = fct_reorder(toupper(boss), percent_completed),
    # In order to justify text to the same width, a ratio of how many times
    # each string would fit into the widest string needs to be calculated. This
    # can then be multiplied by an arbitrary value to determine the final size
    # for each string of text.
    str_width = strwidth(boss, family = optimus_b, units = "inches") * 25.4, # in millimetres
    str_ratio = max(str_width)/str_width,
    text_size = 4.9 * str_ratio,
    # The division here is arbitrary, its effect is reflected in the scale of the
    # y-axis
    tile_height = text_size / 10
  ) %>%
  # Bosses will appear from top to bottom based on completion ratios. The
  # calculation here accounts for the differences in text size for each string.
  arrange(percent_completed) %>%
  mutate(y = cumsum(lag(tile_height/2, default = 0) + tile_height/2))

Now the plot can be constructed. The final code for the plot is roughly 100 lines long, so I’ve hidden it in the section below. However, there are a few parts of the code I want to highlight before showing the final plot.

# The trick for geom spacing is to set the size of the plot from the start
file <- tempfile(fileext = '.png')
ragg::agg_png(file, width = 4, height = 5.5, res = 300, units = "in")

ggplot(demons_souls_plot) +
  # Make it easier to see where 50% is using a vertical line. geom_segment() is
  # used here instead of geom_vline() because the latter goes up into the title
  # text. An empty data frame is supplied so that only one copy of the geom is
  # drawn.
  geom_segment(aes(
    x = 0,
    xend = 0,
    y = 10.9,
    yend = 0,
    size = 0.6),
    data = data.frame(),
    alpha = 0.3,
    colour = "grey",
    lineend = "round",
    linetype = "twodash"
  ) +
  scale_alpha_identity() +
  
  # Set bounding box for yellow portion of centrepiece text
  as_reference(
    geom_rect(aes(
      xmin = -0.5,
      xmax = -0.5 + ((percent_completed)),
      ymin = y - (tile_height * 0.5),
      ymax = y + (tile_height * 0.5)
    )), 
    id = "demon_vanquished"
  ) +
  # Only show the portion of yellow centrepiece text located within the
  # bounding box
  with_blend(
    geom_text(aes(
      x = 0,
      y = y,
      label = boss,
      size = text_size
    ),
    colour = yellow,
    family = optimus_b),
    bg_layer = "demon_vanquished",
    blend_type = "in"
  ) +
  # Set bounding box for red portion of centrepiece text
  as_reference(
    geom_rect(aes(
      xmin = 0.5 - ((1 - percent_completed)),
      xmax = 0.5,
      ymin = y - (tile_height * 0.5),
      ymax = y + (tile_height * 0.5)
    )), 
    id = "you_died"
  ) +
  # Only show the portion of red centrepiece text located within the bounding
  # box
  with_blend(
    geom_text(aes(
      x = 0,
      y = y,
      label = boss,
      size = text_size
    ),
    colour = "red",
    family = optimus_b),
    bg_layer = "you_died",
    blend_type = "in"
  ) +
  
  # Draw "axis" for Demon Vanquished
  annotate(
    "text",
    x = -0.65,
    y = 7.75,
    label = "demon vanquished",
    angle = 90,
    size = 5,
    family = optimus,
    colour = yellow
  ) +
  geom_segment(aes(
    x = -0.645,
    xend = -0.645,
    y = 10.05,
    yend = 10.45),
    lineend = "round",
    colour = yellow,
    size = 0.3,
    arrow = arrow(angle = 45, length = unit(1, "mm"), type = "open")
  ) +
  # Draw "axis" for You Died
  annotate(
    "text",
    x = 0.65,
    y = 4.65,
    label = "you died",
    angle = 270,
    size = 5,
    family = optimus,
    colour = "red"
  ) +
  geom_segment(aes(
    x = 0.645,
    xend = 0.645,
    y = 3.51,
    yend = 3.01),
    lineend = "round",
    colour = "red",
    size = 0.3,
    arrow = arrow(angle = 45, length = unit(1, "mm"), type = "open")
  ) +
  
  # Draw a title surrounded by line decorations at the top of the panel
  geom_segment(aes(
    x = -0.75,
    xend = 0.75,
    y = 13.2,
    yend = 13.2,
    size = 0.3),
    lineend = "round",
    colour = "grey"
  ) +
  annotate(
    "text",
    x = 0,
    y = 12.325,
    size = 7,
    family = optimus_b,
    colour = "white",
    lineheight = 0.75,
    label = "DEMON’S SOULS\nBOSS COMPLETION"
  ) +
  geom_segment(aes(
    x = -0.025,
    xend = -0.75,
    y = 11.4,
    yend = 11.4,
    size = 0.3),
    lineend = "round",
    colour = "grey"
  ) +
  geom_segment(aes(
    x = 0.025,
    xend = 0.75,
    y = 11.4,
    yend = 11.4,
    size = 0.3),
    lineend = "round",
    colour = "grey"
  ) +
  annotate(
    "point",
    x  = 0,
    y = 11.4,
    colour = "grey",
    shape = 5,
    size = 2,
    stroke = 0.6
  ) +
  annotate(
    "point",
    x  = 0,
    y = 11.4,
    colour = "grey",
    shape = 5,
    size = 0.75
  ) +
  
  # Draw plot caption
  annotate(
    "text",
    x = 1,
    y = 10.33,
    angle = 270,
    hjust = 0,
    size = 3,
    alpha = 0.3,
    label = "SOURCE: PLAYSTATION NETWORK | GRAPHIC: MICHAEL MCCARTHY",
    family = optimus,
    color = "grey"
  ) +
  
  # Make sure the text size calculated for each string is used so that strings
  # are justified
  scale_size_identity() +
  # Take axis limits exactly from data so there's no spacing around the panel,
  # allow drawing outside of the panel for annotations, and set the axis limits
  # to match the limits of the text.
  coord_cartesian(expand = FALSE, clip = "off", xlim = c(-0.5, 0.5)) +
  # Specify the panel size manually. This makes it easier to position plot
  # elements with absolute positions.
  ggh4x::force_panelsizes(rows = unit(5, "in"), # height
                          cols = unit(1.8, "in")) + # width
  theme_void() +
  theme(
    legend.position = "none",
    plot.margin = unit(c(0.5, 4, 0.5, 4), "in"),
    plot.background = element_rect(fill = "black", color = NA))

invisible(dev.off())

# Apply a mask texture to the final image to mimic the style of the Demon's
# Souls logo in the plot title
mask <- image_read(
  here("_posts", "2021-06-15_demons-souls", "images", "texture.png")
  ) %>%
  image_transparent("white") %>%
  image_threshold("black", "90%")

final_plot <- image_composite(image_read(file), mask, operator = "Over")

First, the code behind the coloured centrepiece text. It uses ggfx::as_reference() and ggfx::with_blend() to selectively apply a filter over portions of the text, as I discussed earlier. The boundaries of the filter are defined by the ggplot2 geom inside of ggfx::as_reference(), then ggfx::with_blend() applies a filter specified by blend_type to the ggplot2 geom inside of it. By duplicating this process twice—once for yellow text and again for red text—but with different filter boundaries based on the percent completed and not completed, the entire boss name is displayed with accurate colour fills.

  # Set bounding box for yellow portion of centrepiece text
  as_reference(
    geom_rect(aes(
      xmin = -0.5,
      xmax = -0.5 + ((percent_completed)),
      ymin = y - (tile_height * 0.5),
      ymax = y + (tile_height * 0.5)
    )), 
    id = "demon_vanquished"
  ) +
  # Only show the portion of yellow centrepiece text located within the
  # bounding box
  with_blend(
    geom_text(aes(
      x = 0,
      y = y,
      label = boss,
      size = text_size
    ),
    colour = yellow,
    family = optimus_b),
    bg_layer = "demon_vanquished",
    blend_type = "in"
  ) +
   # Set bounding box for red portion of centrepiece text
  as_reference(
    geom_rect(aes(
      xmin = 0.5 - ((1 - percent_completed)),
      xmax = 0.5,
      ymin = y - (tile_height * 0.5),
      ymax = y + (tile_height * 0.5)
    )), 
    id = "you_died"
  ) +
  # Only show the portion of red centrepiece text located within the bounding
  # box
  with_blend(
    geom_text(aes(
      x = 0,
      y = y,
      label = boss,
      size = text_size
    ),
    colour = "red",
    family = optimus_b),
    bg_layer = "you_died",
    blend_type = "in"
  )

Second, the code behind the distressed, broken style of the title text. This one is actually quite simple. It uses magick::image_composite() to apply a texture mask I made in Krita over the composed plot. The mask has a transparent background with black lines located over the space where the plot title is. Both the composed plot and mask images have the same dimensions, so when they’re composed together the effect is applied exactly where I want it.

image_composite(plot, mask, operator = "Over")

Finally, I just wanted to note that the decorative lines around the plot’s title text are actually made up of ggplot2 geoms. I used two ggplot2::geom_point() geoms with different sizes to create the diamond on the bottom line.

Final Graphic

Comments

Data Source

Download the data used in this article.

Session Info

#> R version 4.0.3 (2020-10-10)
#> Platform: x86_64-apple-darwin17.0 (64-bit)
#> Running under: macOS Mojave 10.14.6
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.0/Resources/lib/libRblas.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.0/Resources/lib/libRlapack.dylib
#> 
#> locale:
#> [1] en_CA.UTF-8/en_CA.UTF-8/en_CA.UTF-8/C/en_CA.UTF-8/en_CA.UTF-8
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods  
#> [7] base     
#> 
#> other attached packages:
#>  [1] magick_2.7.2    ggfx_1.0.0      forcats_0.5.1   stringr_1.4.0  
#>  [5] dplyr_1.0.4     purrr_0.3.4     readr_1.4.0     tidyr_1.1.2    
#>  [9] tibble_3.1.2    ggplot2_3.3.3   tidyverse_1.3.0 metathis_1.0.3 
#> [13] here_1.0.1     
#> 
#> loaded via a namespace (and not attached):
#>  [1] Rcpp_1.0.6        lubridate_1.7.9.2 png_0.1-7        
#>  [4] assertthat_0.2.1  rprojroot_2.0.2   digest_0.6.27    
#>  [7] utf8_1.2.1        R6_2.5.0          cellranger_1.1.0 
#> [10] backports_1.2.1   reprex_1.0.0      evaluate_0.14    
#> [13] httr_1.4.2        highr_0.8         pillar_1.6.1     
#> [16] rlang_0.4.11      readxl_1.3.1      rstudioapi_0.13  
#> [19] jquerylib_0.1.3   rmarkdown_2.7     labeling_0.4.2   
#> [22] textshaping_0.3.4 munsell_0.5.0     broom_0.7.4      
#> [25] compiler_4.0.3    modelr_0.1.8      xfun_0.23        
#> [28] systemfonts_1.0.2 pkgconfig_2.0.3   htmltools_0.5.1.1
#> [31] downlit_0.2.1     tidyselect_1.1.0  ggh4x_0.1.2.1    
#> [34] fansi_0.5.0       crayon_1.4.1      dbplyr_2.1.0     
#> [37] withr_2.4.2       grid_4.0.3        jsonlite_1.7.2   
#> [40] gtable_0.3.0      lifecycle_1.0.0   DBI_1.1.1        
#> [43] magrittr_2.0.1    scales_1.1.1      cli_2.5.0        
#> [46] stringi_1.6.2     farver_2.1.0      fs_1.5.0         
#> [49] xml2_1.3.2        bslib_0.2.4       ragg_1.1.2       
#> [52] ellipsis_0.3.2    generics_0.1.0    vctrs_0.3.8      
#> [55] distill_1.2       tools_4.0.3       glue_1.4.2       
#> [58] hms_1.0.0         yaml_2.2.1        colorspace_2.0-1 
#> [61] rvest_0.3.6       knitr_1.31        haven_2.3.1      
#> [64] sass_0.3.1

Fair Dealing

Any of the trademarks, service marks, collective marks, design rights or similar rights that are mentioned, used, or cited in this article are the property of their respective owners. They are used here as fair dealing for the purpose of education in accordance with section 29 of the Copyright Act and do not infringe copyright.

Corrections

If you see mistakes or want to suggest changes, please create an issue on the source repository.

Citation

For attribution, please cite this work as

McCarthy (2021, June 15). Tidy Tales | Michael McCarthy: Go forth, slayer of Demons. Retrieved from https://tidytales.ca/posts/2021-06-15_demons-souls/

BibTeX citation

@misc{mccarthy2021go,
  author = {McCarthy, Michael},
  title = {Tidy Tales | Michael McCarthy: Go forth, slayer of Demons},
  url = {https://tidytales.ca/posts/2021-06-15_demons-souls/},
  year = {2021}
}