The Current Project #8: Fabulous Prefabs
I guess I’m renaming this series to TCP now, for “The Current Project.” I’ve updated the tags on the old posts accordingly.
Anyways! Work on that project I talked about last week is going pretty great. While it’s indev, I’ve code-named1 it “Robot Language” after the meganeko album. Starting fresh has been really good for me, I think; I have gotten way more done in a week and a bit than I could have on Foxfire. I’ve implemented rendering, collision, FOV calculations … lots and lots of things.
Here’s a screenshot of the game as it is so far.
It’s a mystery game, so I won’t reveal too much … but I will say you wake up on a spacecraft with no memory, and have to figure out what is going on. I’ve been playing Outer Wilds recently, and I’m really inspired by the idea of a metroidvania where your “keys” are knowledge.
I don’t think I’ve heard of a game that mixes metroidvanias with Zelda-like dungeons. To me, a Zelda dungeon feels kinda like a metroidvania in minature.2 The plan is to have the larger challenges in the game be unlocked with knowledge, and smaller, self-contained challenges styled after Zelda dungeons with a key item that unlocks new areas in the ship.
But we’ll see as I design it more.
Prefabs#
But anyways this post is not really about Robot Language; it’s about a (technology? idea? algorithm?) I’ve developed as I write the game.
Robot Language’s world layout will have no randomization, given it’s a metroidvania-like. So, I need some way to make the world, and doing it from code would be a pain in the ass.
So, in short, what I do is I draw out a map in RexPaint, a kinda crufty but very useful tool for drawing ASCII art. Then, the game loads the file at runtime using the aptly-named rexpaint crate. Each character and its foreground color is then mapped to a prefab. That mapping is written in KDL, a language like XML but not horrible to write which Lemma has totally hooked me on. Then, those prefabs are mapped to actual collections of components in code.
I realize that makes no sense, so here’s some pictures to show you what I’m talking about.
Here’s part of prefabs.kdl
, which is loaded by the game on startup.
"#" color=0xffffff {
prefab "wall"
type "inner"
}
"#" color=0x3333ff {
prefab "wall"
type "outer"
}
"+" color=0xff0000 pass-color=true {
prefab "door"
closed true
}
"+" color=0x00ff00 pass-color=true {
prefab "door"
closed false
}
// --- Furniture ---
// This orangey color is for food
"T" color=0xff4000 { prefab "table"; type "dining"; }
"T" color=0x6666ff { prefab "table"; type "stainless"; }
"c" color=0xff4000 {
prefab "widget"
char 0xe3 // pi
fg "dark-yellow"
bg null
z-level 300
collider false
occluder false
description "chair?plastic"
}
// Angstrom for any and all misc furniture
"0x86" color=0xff4000 { prefab "microwave"; }
"0x86" color=0xd900d9 { prefab "sink"; }
Every character in one of those maps has to have an entry in here where the character and foreground color both match.
By marking pass-color=true
, they can also pass their background color as an argument, which I use for things like notes
that need a whole bunch of indices. (A white ≈
with background color 0x000000
is note #0, a white ≈
with background color 0x000001
is note #1 … etc.) Then, the prefab
string is matched against a bank of strings in the game code and used to spawn it with the given arguments.
Here’s part of that code:
pub fn spawn_prefab(
world: &mut World,
globals: &Globals,
prefab_name: &str,
color_arg: Option<u32>,
args: &AHashMap<SmolStr, KdlLiteral>,
pos: Position,
) -> Entity {
if let Some(color_arg) = color_arg {
match prefab_name {
"door" => {
let closed = args["closed"].as_bool().unwrap();
door(world.spawn(), pos, closed, color_arg).build()
}
"note" => note(world.spawn(), pos, color_arg).build(),
_ => panic!(
"unknown prefab {:?} (color argument {:#x})",
prefab_name, color_arg
),
}
} else {
match prefab_name {
"player" => {
let player = player(world.spawn(), pos).build();
world.insert_resource(ThePlayerEntity(player));
player
}
"wall" => {
let ty = &args["type"].as_string().unwrap();
wall(world.spawn(), pos, ty).build()
}
"table" => {
let ty = &args["type"].as_string().unwrap();
table(world.spawn(), pos, ty).build()
}
"sink" => sink(world.spawn(), pos).build(),
"microwave" => microwave(world.spawn(), pos).build(),
_ => panic!("unknown prefab {:?} (with no color arg)", prefab_name),
}
}
}
fn player<B: EntityBuilder>(builder: B, pos: Position) -> B {
builder
.with(Positioned(pos))
.with(ZLevel(u16::MAX))
.with(IsDrawable)
.with(HasSimpleDrawable(Drawable::simple(
Color::Magenta,
None,
b'@',
)))
.with(Describable(SmolStr::new("player")))
}
fn wall<B: EntityBuilder>(builder: B, pos: Position, ty: &str) -> B {
let (desc, fg, bg) = match ty {
"inner" => ("inner-wall", Color::Gray, Color::DarkGray),
"outer" => ("outer-wall", Color::White, Color::Gray),
_ => panic!("unknown wall type {:?}", ty),
};
builder
.with(Positioned(pos))
.with(Collider)
.with(Occluder)
.with(ZLevel(200))
.with(IsDrawable)
.with(HasSimpleDrawable(Drawable::box_drawing(
fg,
Some(bg),
true,
"walls",
)))
.with(Describable(SmolStr::new(desc)))
}
fn table<B: EntityBuilder>(builder: B, pos: Position, ty: &str) -> B {
let (desc, fg, bg) = match ty {
"dining" => ("table?dining", Color::DarkYellow, None),
"stainless" => ("table?stainless", Color::DarkBlue, Some(Color::Gray)),
_ => panic!("unknown table type {:?}", ty),
};
builder
.with(Positioned(pos))
.with(ZLevel(200))
.with(Collider)
.with(IsDrawable)
.with(HasSimpleDrawable(Drawable::box_drawing(
fg, bg, true, "table",
)))
.with(Describable(SmolStr::new(desc)))
}
// many other spawner funcs elided ...
Let’s look at the lifecycle of one of the orange T
s in the dining hall. “T
with background color #ff4000
” is mapped against
eack entry in prefabs.kdl
, and it matches this one:
"T" color=0xff4000 { prefab "table"; type "dining"; }
Then, the game calls spawn_prefab
, looking for a prefab named "table"
and passing it the arguments {type: "dining"}
.
The match
statement matches it to the table
function, which is called and spawns the entity. Et voila!
I stand on the shoulders of giants here:
- I first saw the idea of reading prefabs from a file at all from Caves of Qud. I have a bit of an intermediary layer here; Qud’s prefabs (called “blueprints”) directly put components on the entities, whereas I have this abstraction in the middle. It’s less flexible, but it does save me some serde work. (However! I just finished writing serde support for palkia this morning, so a future version of this might deserialize components directly from the prefab file.)
- Kyzrati/Josh Ge, the person behind rexpaint and cogmind, has this blog post about how they use rexpaint to add prefabs to dungeon maps. Their use of “prefab” is a bit different than mine; they mean specific rooms or features. Their implementation is a lot more regimented; each layer has a specific job, tiles are matched by their color alone on layer 1, and entities are matched by their character alone on layers 3 and 4. But that works for their game, because they have a lot of random generation to spruce things up (where I have to draw it all myself.)
- The idea of KDL as a prefab language goes to Lemma and its Minecraft mod Kdly Content, which lets you add blocks and items using kdl. Also, Caves of Qud uses XML, and KDL more or less maps 1-to-1 to XML, so I figured it would be a good shot. (Early versions of this idea I had used EDN, which would have been … fraught.)
So yeah! This system lets me prototype really rapidly, which I think is REALLY important for game development.3 I’m also really glad to have figured KDL out; it really is a joy to write. (All the descriptions for tiles in the game are also written in KDL, and I’m planning for all the misc. strings and things to be KDL as well.)
I’ve been working hard making Palkia more useable as I figure out where the rough edges are by working on Robot Language. Next week will probably be a more game-design-y article, as I bootstrap Robot Language enough that I can start working on the game as opposed to the engine.
So I’ll see you all next week …