Godot Version
4.4.1.stable
Question
TL;DR How can I save and restore a procedurally generated TileMapLayer and get the navigation to work.
I procedurally generate the world.
For generating the TileMapLayer I use
@onready var grass_layer: TileMapLayer = %GrassLayer
func _generate_world() -> void:
grass_layer.set_cells_terrain_connect(grass_tiles, GRASS_DATA["source_id"], 0)
Objects are being added by placing scenes as child of the corresponding TileMapLayer. Example for a tree which has a CollisionShape2D
:
func _place_tree(position: Vector2) -> void:
var x_offset = _rng.randi_range(0, tile_size - object_padding)
x_offset -= int(tile_size / 2)
var y_offset = _rng.randi_range(0, tile_size - object_padding)
y_offset -= int(tile_size / 2)
position = position + Vector2(x_offset, y_offset)
# If tree is out of border do not instantiate
if position.x < object_padding or position.x > (_map_size_pixel.x - object_padding) or position.y < object_padding or position.y > (_map_size_pixel.y - object_padding):
return
var tree_instance = _tree_scene.instantiate() as Node2D
tree_instance.global_position = position
tree_layer.add_child(tree_instance)
Since the map may get big, I chunked the navigation using the following reference:
Source Code
Explanation
My Source code:
func _ready() -> void:
# Other stuff
call_deferred("_bake_navigation_mesh") # Delay baking until after the tilemap is fully loaded
func _bake_navigation_mesh() -> void:
print("Generating navigation mesh")
var source_geometry = _generate_source_geometry()
_create_region_chunks(region_chunks, source_geometry, chunk_size)
func _generate_source_geometry() -> NavigationMeshSourceGeometryData2D:
var source_geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
var parse_settings: NavigationPolygon = NavigationPolygon.new()
parse_settings.parsed_geometry_type = NavigationPolygon.PARSED_GEOMETRY_STATIC_COLLIDERS
NavigationServer2D.parse_source_geometry_data(parse_settings, source_geometry, tilemap)
var traversable_outline: PackedVector2Array = PackedVector2Array(
[
Vector2(0, 0),
Vector2(_map_size_pixel.x, 0),
_map_size_pixel,
Vector2(0, _map_size_pixel.y)
]
)
source_geometry.add_traversable_outline(traversable_outline)
return source_geometry
func _create_region_chunks(chunks_root_node: Node2D, p_source_geometry: NavigationMeshSourceGeometryData2D, p_chunk_size: float) -> void:
# We need to know how many chunks are required for the input geometry.
# So first get an axis aligned bounding box that covers all vertices.
var input_geometry_bounds: Rect2 = p_source_geometry.get_bounds()
# Rasterize bounding box into chunk grid to know range of required chunks.
var start_chunk: Vector2 = floor(
input_geometry_bounds.position / p_chunk_size
)
var end_chunk: Vector2 = floor(
(input_geometry_bounds.position + input_geometry_bounds.size)
/ p_chunk_size
)
for chunk_y in range(start_chunk.y, end_chunk.y + 1):
for chunk_x in range(start_chunk.x, end_chunk.x + 1):
var chunk_bounding_box: Rect2 = Rect2(
Vector2(chunk_x, chunk_y) * p_chunk_size,
Vector2(p_chunk_size, p_chunk_size),
)
# We grow the chunk bounding box to include geometry
# from all the neighbor chunks so edges can align.
# The border size is the same value as our grow amount so
# the final navigation mesh ends up with the intended chunk size.
var baking_bounds: Rect2 = chunk_bounding_box.grow(p_chunk_size)
var chunk_navmesh: NavigationPolygon = NavigationPolygon.new()
chunk_navmesh.baking_rect = baking_bounds
chunk_navmesh.border_size = p_chunk_size
chunk_navmesh.agent_radius = nav_agent_radius
NavigationServer2D.bake_from_source_geometry_data(chunk_navmesh, p_source_geometry)
print("Polygon size: ", chunk_navmesh.get_polygon_count())
# The only reason we reset the baking bounds here is to not render its debug.
chunk_navmesh.baking_rect = Rect2()
# Snap vertex positions to avoid most rasterization issues with float precision.
var navmesh_vertices: PackedVector2Array = chunk_navmesh.vertices
for i in navmesh_vertices.size():
var vertex: Vector2 = navmesh_vertices[i]
navmesh_vertices[i] = vertex.snappedf(0.1)
chunk_navmesh.vertices = navmesh_vertices
var chunk_region: NavigationRegion2D = NavigationRegion2D.new()
chunk_region.navigation_polygon = chunk_navmesh
chunks_root_node.add_child(chunk_region)
navigation_regions.append([chunk_region, chunk_bounding_box])
When generating a new world everything works fine. All tiles are generated, all trees added, the navigation is baked. Navigation is possible and works as expected.
For Saving/Loading mechanism I used this reference video by Rapid Vectors.
My source code for the Resources:
class_name SaveGameDataComponent
extends Node
var game_scene_name: String = SaveGameManager.world_name
var save_game_data_path: String = SaveGameManager.save_game_data_path
var save_file_name: String = SaveGameManager.save_file_name
var game_data_resource: SaveGameDataResource
func _ready() -> void:
add_to_group("save_game_data_component")
if game_scene_name == null:
game_scene_name = get_parent().name
func save_node_data() -> void:
var nodes = get_tree().get_nodes_in_group("save_data_component")
game_data_resource = SaveGameDataResource.new()
if nodes != null:
for node in nodes:
if node is SaveDataComponent:
var save_data_resource: NodeDataResource = node._save_data()
var save_final_resource = save_data_resource.duplicate()
game_data_resource.save_data_nodes.append(save_final_resource)
func save_game() -> void:
if !DirAccess.dir_exists_absolute(save_game_data_path):
DirAccess.make_dir_absolute(save_game_data_path)
var game_save_file_name: String = save_file_name % game_scene_name
save_node_data()
var result: int = ResourceSaver.save(game_data_resource, save_game_data_path + game_save_file_name)
if result != 0:
push_error("Unable to save! Error code: ", result)
else:
print("Game saved successfully")
func load_game() -> void:
var game_save_file_name = save_file_name % game_scene_name
var save_game_path: String = save_game_data_path + game_save_file_name
if !FileAccess.file_exists(save_game_path):
return
game_data_resource = ResourceLoader.load(save_game_path)
if game_data_resource == null:
return
var root_node: Window = get_tree().root
for resource in game_data_resource.save_data_nodes:
if resource is Resource:
if resource is NodeDataResource:
resource._load_data(root_node)
class_name NodeDataResource
extends Resource
@export var global_position: Vector2
@export var node_path: NodePath
@export var parent_node_path: NodePath
func _save_data(node: Node2D) -> void:
global_position = node.global_position
node_path = node.get_path()
var parent_node = node.get_parent()
if parent_node != null:
parent_node_path = parent_node.get_path()
func _load_data(window: Window) -> void:
pass
class_name TileMapLayerDataResource
extends NodeDataResource
@export var tile_map_data: PackedByteArray
func _save_data(node: Node2D) -> void:
super._save_data(node)
var tilemap_layer: TileMapLayer = node as TileMapLayer
tile_map_data = tilemap_layer.tile_map_data
func _load_data(window: Window) -> void:
var scene_node = window.get_node_or_null(node_path)
if scene_node != null:
var tilemap_layer: TileMapLayer = scene_node as TileMapLayer
tilemap_layer.tile_map_data = tile_map_data
My issue
Loading itself works. All the tiles are set, all trees exist, etc.
Setting Collision Visibility Mode
to Force Show
displays the collision correctly on the TileMapLayers. Yet the navigation does not work.
Debugging tells me, that source_geometry.get_bounds()
seems to be correct. All NavigationRegion2D
are being generated. The only difference I was able to spot so far is that chunk_navmesh.get_polygon_count()
is always 0 when loading. This seems to be the issue since I think this means no collision-objects have been detected to calculate the navigation with.
I have set Visible Navigation
to True
which displays the navigation regions correctly when generating a new world, but nothing is visible when loading a world.
I guess somehow I have to tell the NavigationServer2D
something I dont’ know yet.
What else have I tried
I also tried saving/loading each cell using set_cell
func _save_data(node: Node2D) -> void:
super._save_data(node)
var tilemap_layer: TileMapLayer = node as TileMapLayer
for cell in tilemap_layer.get_used_cells():
var atlas_coords = tilemap_layer.get_cell_atlas_coords(cell)
var source_id = tilemap_layer.get_cell_source_id(cell)
var alternative_tile = tilemap_layer.get_cell_alternative_tile(cell)
tilemap_info.append([cell, atlas_coords, source_id, alternative_tile])
func _load_data(window: Window) -> void:
var scene_node = window.get_node_or_null(node_path)
if scene_node != null:
var tilemap_layer: TileMapLayer = scene_node as TileMapLayer
for cell in tilemap_info:
var coords = cell[0]
var atlas_coords = cell[1]
var source_id = cell[2]
var alternative_tile = cell[3]
tilemap_layer.set_cell(coords, source_id, atlas_coords, alternative_tile)
I also thought it might be a timing issue. So I added a manual baking trigger but the result is the same.
func _input(_event: InputEvent) -> void:
if Input.is_action_just_pressed("test"):
print("Manually generating navigation mesh")
_bake_navigation_mesh()