Code
library(tidyverse)
library(sf)
library(osmdata)
library(tmap)
set.seed(65280)library(tidyverse)
library(sf)
library(osmdata)
library(tmap)
set.seed(65280)The objective is to make a hexcrawl map for a sandbox OSR campaign from real life data. Since I want to run a campaign based on Catalan folklore/mythology, I’ll be using data from this massif in Northeastern Catalonia, for no other reason that I’m familiar with it. We’ll be using data from OpenStreetMap, using the osmdata package. Let’s see how it looks like.
g <- opq("Catalonia") |>
add_osm_feature("name", "Espai d'interès natural de les Gavarres") |>
osmdata_sf()
g <- g$osm_multipolygons
g <- st_transform(g, "EPSG:25831")
tmap_mode("view")
qtm(g, fill = NULL, borders = "red", basemaps = "OpenTopoMap")To see how many hexes we want, let’s see first how large the massif actually is.
bbox <- st_bbox(g)
dist <- c("height" = bbox$ymax-bbox$ymin, "width" = bbox$xmax-bbox$xmin)
print(dist)height.ymax width.xmax
21979.81 26390.90
Fine, we’re talking 21.979 km from North to South and 26.390 from East to West. Assuming a movement ratio of 10 km per day and 10 km hexes (~6 miles) (1 hex short side to short side, 1/2 hex far side to far side, per here per day in easy conditions, half that in thick, trackless forest, this gives us five measly hexes, hardly the massive forest we’re looking for. Hence, we’ll have to pretend this forest is four times as big as it really is. This gives us 25 hexes, should be enough for quite a few adventures and between 5 to 12 days to cross the forest on foot.
hex <- st_make_grid(g, cellsize = 5000, square = FALSE)
hex <- st_sf(hex)
hexSimple feature collection with 39 features and 0 fields
Geometry type: POLYGON
Dimension: XY
Bounding box: xmin: 480572.8 ymin: 4627797 xmax: 515572.8 ymax: 4655221
Projected CRS: ETRS89 / UTM zone 31N
First 10 features:
hex
1 POLYGON ((483072.8 4632127,...
2 POLYGON ((483072.8 4640787,...
3 POLYGON ((483072.8 4649448,...
4 POLYGON ((485572.8 4627797,...
5 POLYGON ((485572.8 4636457,...
6 POLYGON ((485572.8 4645117,...
7 POLYGON ((488072.8 4632127,...
8 POLYGON ((488072.8 4640787,...
9 POLYGON ((488072.8 4649448,...
10 POLYGON ((490572.8 4627797,...
hex$n <- 1:39
hex_intersect <- st_intersection(hex, st_buffer(g, 300))
hex <- hex[hex$n %in% hex_intersect$n,]
hex$n <- 1:25
# give ordered numbers
centroids <- st_centroid(hex)
centroids$x <- st_coordinates(centroids)[,1]
centroids$y <- st_coordinates(centroids)[,2]
centroids <- centroids |>
arrange(-y, x) |> # arrange hexes left to right, top to bottom
mutate(n2 = 1:25)
hex <- merge(hex[,"n"], st_drop_geometry(centroids)[,c("n", "n2")], by = "n")
ggplot() +
geom_sf(data = g, fill = "green4") +
geom_sf(data = hex, fill = NA) +
geom_sf_text(data = hex, aes(label = n2)) +
theme_void()Some hexes are out of the forest, but never mind, we can live with that. As far as we’re concerned, this is a heavily forested, sparsely populated area. Besides all the fae shenanigans, I imagine this being a contested site between two lords (ideally, a noble and a church hierarch, as conflicts get more interesting).
Now, we need a few rivers, don’t we? Let’s download all rivers and streams and keep only those systems that are at least 15 km long (we’ll pretend they are bigger).
riv <- getbb("Espai d'interès natural de les Gavarres",
format_out = "osm_type_id") |>
opq() |>
add_osm_features(list("waterway" = "stream",
"waterway" = "river")) |>
osmdata_sf()
riv <- riv$osm_lines
library(igraph)
my_idx_touches <- st_touches(riv) # determinar quins segments es toquen entre sí
riv_network <- graph_from_adj_list(my_idx_touches) # crear una xarxa
ids <- components(riv_network)$membership # crear un vector amb el grup al qual pertany cada segment de riu
riv_group <- riv |>
group_by(conca = as.character({{ids}})) |>
summarise()
# keep all river networks longer than 15 km irl and keep only intersection
riv_filt <- riv_group |>
filter(as.numeric(st_length(geometry)) >= 15000)
riv_filt <- st_transform(riv_filt, st_crs(hex))
riv_filt <- st_intersection(riv_filt, hex)
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1) +
geom_sf_text(data = hex, aes(label = n2)) +
theme_void() +
theme(plot.caption = element_text(size = 12),
plot.title = element_text(size = 16))You see that some rivers apparently lead nowhere, we’ll just pretend they go into lakes which drain via subterranean currents. Honestly, I’m not interested in what lies beyond the Woods, and I’ll hope my players don’t either.
Now we need to populate it. We’ll get some castles and ruins/dolmens/remains first. My equivalent of Winter fae will have seats of power in these old ruins. Castles will be either populated by humans or some other kind of monsters.
dol <- getbb("Espai d'interès natural de les Gavarres",
format_out = "osm_type_id") |>
opq() |>
add_osm_feature("historic") |>
osmdata_sf()
hist <- bind_rows(dol$osm_points,
st_centroid(dol$osm_polygons),
st_centroid(dol$osm_multipolygons))
hist <- filter(hist, historic %in% c("archaeological_site", "castle"))
hist$historic <- ifelse(hist$historic == "archaeological_site", "ruins", hist$historic)
pal <- c("ruins" = "\u6708",
"castle" = "\u265C")
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1) +
geom_sf(data = hist, aes(shape = historic), size = 5) +
scale_shape_manual("", values = pal) +
geom_sf_text(data = hex, aes(label = n2)) +
scale_fill_manual("", values = pal) +
theme_void()There are far too many ruins to be read properly, so let’s keep one per hex only. Ditto with the castles.
Note, a castle could be a tower or any seat of power, not necessarily a castle per se.
# only one of a kind per hex
hist <- hist |>
st_transform(st_crs(hex)) |>
st_join(hex[,"n2"]) |>
group_by(n2, historic) |>
slice_sample(n = 1)
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1) +
geom_sf(data = hist, aes(shape = historic), size = 5) +
scale_shape_manual("", values = pal) +
geom_sf_text(data = hex, aes(label = n2)) +
scale_fill_manual("", values = pal) +
theme_void()Also, some churches and chapels wouldn’t be out of place either.
church <- getbb("Espai d'interès natural de les Gavarres",
format_out = "osm_type_id") |>
opq() |>
add_osm_feature("amenity", "place_of_worship") |>
osmdata_sf()
church <- bind_rows(church$osm_points, st_centroid(church$osm_polygons), st_centroid(church$osm_multipolygons))
# only one of a kind per hex
church <- church |>
st_transform(st_crs(hex)) |>
st_join(hex[,"n2"]) |>
group_by(n2) |>
slice_sample(n = 1)
# still too many
church <- slice_sample(ungroup(church), n = 5)
hist <- hist |>
select(historic) |>
bind_rows({church |> select(amenity) |> rename("historic" = "amenity")})
hist$historic <- ifelse(hist$historic %in% c("place_of_worship", NA_character_), "church", hist$historic)
pal <- c("ruins" = "\u6708",
"castle" = "\u265C",
"church" = "\u271A")
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1) +
geom_sf(data = hist, aes(shape = historic), size = 5) +
scale_shape_manual("", values = pal) +
geom_sf_text(data = hex, aes(label = n2)) +
scale_fill_manual("", values = pal) +
theme_void()Now, in my setting water women are one of the main fae factions (kind of like Summer fae but not really), we need some wells and fountains for them to dwell in. Plus, a few caves always look good.
water_caves <- getbb("Espai d'interès natural de les Gavarres",
format_out = "osm_type_id") |>
opq() |>
add_osm_features(list("man_made" = "water_well",
"natural" = "spring",
"natural" = "cave_entrance")) |>
osmdata_sf()
water_caves <- water_caves$osm_points
water_caves <- mutate(water_caves, type = case_when(man_made == "water_well" ~ "well",
natural == "spring" ~ "spring",
natural == "cave_entrance" ~ "cave"))
# keep one per type and hex
water_caves <- water_caves |>
st_transform(st_crs(hex)) |>
st_join(hex[,"n2"]) |>
group_by(type) |>
slice_sample(n = 5) |>
ungroup() |>
group_by(n2, type) |>
slice_sample(n = 1)
hist <- hist |>
bind_rows({water_caves |> select(type) |>
rename("historic" = "type")})
pal <- c("ruins" = "\u6708",
"castle" = "\u265C",
"church" = "\u271A",
"spring" = "\u26f2",
"well" = "\u4DEF",
"cave" = "\u7a74")
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1, alpha = .5) +
geom_sf(data = hist, aes(shape = historic), size = 5) +
scale_shape_manual("", values = pal) +
geom_sf_text(data = hex, aes(label = n2)) +
scale_fill_manual("", values = pal) +
theme_void()Now, to top it all, we need a few villages and cottages. We’ll use named houses
vils <- getbb("Espai d'interès natural de les Gavarres",
format_out = "osm_type_id") |>
opq() |>
add_osm_features(list("building" = "yes",
"building" = "house")) |>
osmdata_sf()
vils <- bind_rows(vils$osm_points, st_centroid(vils$osm_polygons), st_centroid(vils$osm_multipolygons))
vils <- vils[!is.na(vils$name) & vils$name != "",]
# just one per hex
vils <- vils |>
st_transform(st_crs(hex)) |>
st_join(hex[,"n2"]) |>
group_by(n2) |>
slice_sample(n = 1)
vils$type <- "house"
hist <- hist |>
bind_rows({vils |> select(type) |>
rename("historic" = "type")})
pal <- c("ruins" = "\u6708",
"castle" = "\u265C",
"church" = "\u271A",
"spring" = "\u26f2",
"well" = "\u4DEF",
"cave" = "\u7a74",
"house" = "\u2302")
ggplot(hex) +
geom_sf(fill = "darkgreen", colour = "black", alpha = .35) +
geom_sf(data = riv_filt, colour = "blue", linewidth = 1, alpha = .5) +
geom_sf(data = hist, aes(shape = historic), size = 5) +
scale_shape_manual("", values = pal) +
geom_sf_text(data = hex, aes(label = n2)) +
scale_fill_manual("", values = pal) +
theme_void()And there we go, a real-life inspired hexmap. Now it’s a matter of keying it and we’re ready to run.