Handling Tilemaps! - UbiBelETF/dagger GitHub Wiki
To make tilemaps work, we need the following components:
- understanding the general idea
- a struct to hold the tilemap data
- a system to load the tilemaps
- parsing the data into useful objects
- using the custom tilemap generators
The idea of tilemaps comes from wanting to easily load complex entities from simple text files. For example, we could have a file like this:
######+######
#...........#
#...........#
#...........#
#############
If we do a good job defining what each letter means, we can turn it into fully-decorated objects (showing walls, floor at the right depth, a door that actually has all the attached components needed to track whether they're opened or closed).
We can imagine two tilemaps working together, one to load static objects and one to create dynamic ones. For example, continuing the same example from above, a layer above the first could be something like this:
.............
...........$.
[email protected]..
.............
.............
In this one, the dots don't mean "floor", but rather "empty", while the "@" marks where we want to spawn the player, "o" is an enemy, and "$" is a treasure chest. If we superimpose the two, we get a full level!
To do this, we need a way to recognize what's what first, so we can think about reading a tilemap as parsing letters into objects. Given that we don't know what every symbol will exactly have (whether sprites, input controllers, etc.), it's best to do so by mapping characters to general function types:
// in: tilemap.h
// important: you might need to #include <functional>
using TileProcessor = std::function<Entity(Registry&, UInt32, UInt32)>;
using TilemapLegend = Map<Char, TileProcessor>;
struct Tilemap
{
Sequence<Entity> tiles; // the tiles that have been loaded so far
};
To load the data, we need to look at how other resources in the engine are loaded, say textures or shaders. They each have a system that cares about one single resource and does the following:
- on
SpinUp()
it subscribes to theRequestAssetLoad<Tilemap>
event - on
WindDown()
it cancels its subscription to theRequestAssetLoad<Tilemap>
event - it has a
void OnLoadAsset(AssetLoadRequest<Tilemap>)
function that actually parses the tilemap!
For example, look at shaders for references.
We'll have something similar, but a bit more complex: we'll need a bit more info in the asset load request, so let's extend that first:
// in: tilemap.h
struct TilemapLoadRequest : pbulic AssetLoadRequest<TileMap>
{
ViewPtr<TilemapLegend> legend;
};
// in: tilemaps.h
using namespace dagger;
class TilemapSystem
: public System
, public Subscriber<TilemapLoadRequest>
{
void SpinUp() override;
void WindDown() override;
void OnLoadAsset(TilemapLoadRequest request_);
};
Implementationally, the spin up and wind down are similar to other systems:
void TilemapSystem::SpinUp()
{
Engine::Dispatcher().sink<TilemapLoadRequest>().connect<&TilemapSystem::OnLoadAsset>(this);
}
void TilemapSystem::WindDown()
{
Engine::Dispatcher().sink<TilemapLoadRequest>().disconnect<&TilemapSystem::OnLoadAsset>(this);
}
I thought about this long and hard, but we probably don't want to preload all of our tilemaps, so I'm throwing that part out for now, even though you may have seen it in the shader or texture system.
To see whether your system works, you can implement a dummy OnLoadAsset
function to just print Hello world!
, and then trigger a faux asset load request for a tilemap in main
:
Engine::Dispatcher().trigger<TilemapLoadRequest>(TilemapLoadRequest{ "my/path/to/asset", nullptr });
When you run this, the function should be triggered. The part where these loading systems differ is in the actual file parsing, so let's get down to business!
To parse files like these, we will use nothing but simple C++ faculties. We can just go ahead and read all the file using a simple loop.
// note: #include <ios>
void TilemapSystem::OnLoadAsset(TilemapLoadRequest request_)
{
assert(request_.legend != nullptr);
Tilemap* tilemap = new Tilemap();
// we'll need these to extract characters and fumble around the map
Char ch;
UInt32 x = 0, y = 0;
// open input file
FileInputStream input{request_.path};
// read everything character by character without skipping whitespace (space, tab, newline)
while (input >> std::noskipws >> ch) {
// do work here loading content! the rest of this tutorial will be talking about this!
}
// save the loaded tilemap into shared memory
Engine::Res<Tilemap>()[request_.path.filename()] = tilemap;
}
There's a couple of edgecases here to watch out for. We're starting parsing from the tile at (0, 0), and keep increasing the x coordinate as we go right. When we reach a newline, we'll reset x and increase y by one. Any other character will be checked for in the legend that we passed in with the request and the appropriate function called:
// cont'd
while (input >> std::noskipws >> ch) {
if(ch == '\n')
{
// newline, go back to the start of the next line
x = 0; y++;
}
else
{
// check that our map has this character, debug-fail if not
assert(request_.legend->contains(ch));
// call the function mapped under that key with the current coordinates
// and save that entity into the tiles sequence in our tilemap
tilemap->tiles.push_back((request_.legend->at(ch))(Engine::Registry(), x, y));
}
}
So how do we use this now?
Well, somewhere in some SetupWorld
-like method, we set up a mapping between characters and some useful functions. All of them have the same signature as our TileProcessor
, ie. std::function<Entity(Registry&, UInt32, UInt32)>
:
Entity CreateFloor(Registry& reg_, UInt32 x_, UInt32 y_)
{
Entity entity = reg_.create();
auto& sprite = reg_.emplace<Sprite>(entity);
sprite.position = { x * 16, y * 16, 90 };
AssignSprite(sprite, "floor-tile");
}
Entity CreateWall(Registry& reg_, UInt32 x_, UInt32 y_)
{
Entity entity = reg_.create();
auto& sprite = reg_.emplace<Sprite>(entity);
sprite.position = { x * 16, y * 16, 90 };
AssignSprite(sprite, "wall-tile");
// setup other behaviours as well
reg_.emplace<NoWalk>(entity);
// ...
}
TilemapLegend legend;
legend['#'] = &CreateWall;
legend['.'] = &CreateFloor;
Next, send a request to a tilemap file you've created that you want read as well as the legend to use:
Engine::Dispatcher().trigger<TilemapLoadRequest>(TilemapLoadRequest{ "my-file.map", &legend });
You can now lay back, relax and watch your map load on its own. You can send multiple requests, repeat requests, and use different legends too:
Engine::Dispatcher().trigger<TilemapLoadRequest>(TilemapLoadRequest{ "my-floor-plan.map", &floorLegend });
Engine::Dispatcher().trigger<TilemapLoadRequest>(TilemapLoadRequest{ "my-inhabitants.map", &creatureLegend });
Engine::Dispatcher().trigger<TilemapLoadRequest>(TilemapLoadRequest{ "my-treasures.map", &treasureLegend });
Hope this helps!