Advanced Tilemap Railroad
This project is based on work started in Littletown Journey's code.
It's a system that can use the rail elements in a tilemap to automatically build a rail network, but with the improvement that tiles are no longer limited to single-tile sizes.
Multi-tile Tiles
In the previous project
In Littletown Journey, all rail elements had to be single tiles - taking up 1x1 space on the tilemap.
This greatly simplified the code required to find connections and build the network. However, despite being stylistic, this made for some pretty abrupt looking turns, especially at faster speeds.
In the current project
This project's goal was to expand on the previous game's code to allow for track elements of any number of tiles. Allowing for not just smoother turns, but a larger variety of possible track elements in general.
This change would allow for a lot more flexibility in designing tracks, and allow designers to create curves that don't look like they'd cause trauma to trains and passengers.
This did come with additional complexity, however. It required having an ability to pair up tiles with data that mapped out each tile's outgoing and incoming connections, and to build a list of which tiles were adjacent to each other. When each tile was 1x1 in size it was sufficient to simply explore up/down/left/right from the current tile to find adjacencies. But with arbitrary sized tiles we instead need to find another way to inform tiles of what nearby cells they should be connected to (as those tiles might have a center that's not just 1 cell away.)
Rail Network Algorithm
Cache tile connections
This step is a setup process that makes it easier to detect connections in the next step. Here we build a mapping of [tile_coordinate] -> [neighboring_tile]
.
It does this by iterating through all tiles, obtaining their adjacency information (relative to their transformation). A tile type's adjacency information is stored for each type of tile.
Build adjacency list
We get a list of all tiles used in the tilemap with tilemap.get_used_cells()
.
Then we visit every tile, attempting to walk from one tile to another, while jumping to an arbitrary tile if there are no more connected tiles to our current one. This is accomplished with:
while !unvisited_tiles.empty():
_expand_adjacency_list_from(unvisited_tiles.front())
func _expand_adjacency_list_from(tile):
unvisited_tiles.erase(tile)
if !adjacency_list.has(tile):
adjacency_list[tile] = []
var rail_class = _rail_class_for_tile(tile)
if rail_class.is_node:
_add_node(tile)
_explore_neighbors_of(tile, rail_class.connections(_transformation_for(tile)))
We'll explore the neighbors of a tile if they exist, or else trigger grabbing the front of the unvisited_tiles
array if we fall back into the while loop.
While expanding on this walk, if a tile is a special node
type of tile (something with special behavior like the cross or the switch) then we'll add a corresponding instanced scene node at this time.
Build paths
With the adjacency list built, we can now build the paths. We start by duplicating the adjacency list, and then walking through the elements constructing rail segments as we go. Similar to the walk before, we explore direct neighbors but fall back to a random node if no neighbors exist.
func _build_paths():
unpathed_neighbors = adjacency_list.duplicate()
while !unpathed_neighbors.empty():
_build_segment_from(unpathed_neighbors.keys().front())
As we iterate through this, there is logic that can examine the type of tile (a straight segment, vs a turn, vs something special like a switch) and can take the correct action. For something simple like a straight segment we can just modify the points of the current path we're working on to move them along. If it's a turn we can add points that handle the curve. And if it's something special, like a switch or cross, we can create and connect new rail segments.
I would like to open-source this, as I think it could prove useful to other developers, but this final step of the algorithm needs a lot of cleanup before I can sensibly share it. It generally works, but can behave oddly when connecting certain nodes together, and might need tweaks to limit the maximum length of a path segment.