RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
level_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
7
8const DataCacheManager = preload("res://scripts/systems/dataCacheManager/data_cache_manager.gd")
9
10var level := {}
11var layers := []
12var tile_size := Vector2i(32, 32)
13var legend := {}
14var objects := []
15var triggers := []
16var metadata := {}
17var tile_dimensions_cache := Vector2i(-1, -1)
18var rects_by_type_cache := {}
19var tile_records_cache := {}
20var cache_hits := 0
21var cache_misses := 0
22var data_cache_manager = DataCacheManager.new()
23
24
25
26func load_level(file_path:String) -> void:
27 reset()
28 var data = data_cache_manager.load_json(file_path)
29 if not data is Dictionary:
30 return
31 level = data.duplicate(true)
32 tile_size = _parse_tile_size(level.get("tile_size", [32, 32]))
33 legend = level.get("legend", {}).duplicate(true)
34 metadata = level.get("metadata", level.get("background", {})).duplicate(true) if level.get("metadata", level.get("background", {})) is Dictionary else {}
35 layers = _normalize_layers(level)
36 objects = _normalize_objects(level.get("objects", []), level.get("spawns", []))
37 triggers = level.get("triggers", []).duplicate(true) if level.get("triggers", []) is Array else []
38
39
40
41func get_tile_dimensions() -> Vector2i:
42 if tile_dimensions_cache.x >= 0 and tile_dimensions_cache.y >= 0:
43 cache_hits += 1
44 return tile_dimensions_cache
45 cache_misses += 1
46 var width := 0
47 var height := 0
48 for layer in layers:
49 if not layer is Dictionary:
50 continue
51 for row in layer.get("tiles", []):
52 width = max(width, str(row).length())
53 height = max(height, layer.get("tiles", []).size())
54 tile_dimensions_cache = Vector2i(width, height)
55 return tile_dimensions_cache
56
57
58
59func get_pixel_dimensions() -> Vector2:
60 var tile_dimensions = get_tile_dimensions()
61 return Vector2(tile_dimensions.x * tile_size.x, tile_dimensions.y * tile_size.y)
62
63
64
65func get_tile(x:int, y:int, layer_id:String = "") -> String:
66 var layer = _get_layer(layer_id)
67 if layer.is_empty():
68 return ""
69 var layer_tiles = layer.get("tiles", [])
70 if y < 0 or y >= layer_tiles.size():
71 return ""
72 var row = str(layer_tiles[y])
73 if x < 0 or x >= row.length():
74 return ""
75 return row[x]
76
77
78
79func get_tile_metadata(tile:String) -> Dictionary:
80 return legend.get(tile, {}).duplicate(true)
81
82
83
84func get_rects_by_type(type:String) -> Array:
85 if rects_by_type_cache.has(type):
86 cache_hits += 1
87 return rects_by_type_cache.get(type, []).duplicate(true)
88 cache_misses += 1
89 var rects = []
90 for layer in layers:
91 if not bool(layer.get("collision", true)):
92 continue
93 var layer_tiles = layer.get("tiles", [])
94 for y in range(layer_tiles.size()):
95 var row = str(layer_tiles[y])
96 for x in range(row.length()):
97 var tile = row[x]
98 var tile_metadata = get_tile_metadata(tile)
99 if str(tile_metadata.get("type", "")) == type:
100 rects.append(Rect2(Vector2(x * tile_size.x, y * tile_size.y), Vector2(tile_size)))
101 rects_by_type_cache[type] = rects
102 return rects.duplicate(true)
103
104
105
106func get_tile_records(options:Dictionary = {}) -> Array:
107 var include_empty = bool(options.get("include_empty", false))
108 var records = _tile_records_for_include_empty(include_empty)
109 var world_rect = options.get("world_rect", Rect2())
110 if not world_rect is Rect2 or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
111 return records.duplicate(true)
112 var margin = float(options.get("margin", 0.0))
113 var visible_rect:Rect2 = world_rect.grow(margin) if margin != 0.0 else world_rect
114 var visible_records := []
115 for record in records:
116 if _record_intersects_world_rect(record, visible_rect):
117 visible_records.append(record)
118 return visible_records.duplicate(true)
119
120
121
126func get_objects_in_rect(kind:String = "", world_rect:Rect2 = Rect2(), options:Dictionary = {}) -> Array:
127 var source_objects:Array = get_objects(kind)
128 if world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
129 return source_objects
130 var margin:float = float(options.get("margin", 0.0))
131 var default_size := Vector2(float(tile_size.x), float(tile_size.y))
132 var object_size:Vector2 = _vector2(options.get("object_size", default_size))
133 var offset:Vector2 = _vector2(options.get("offset", Vector2.ZERO))
134 var visible_objects:Array = []
135 for object in source_objects:
136 var rect:Rect2 = object_world_rect(object, object_size, offset)
137 if rect_intersects_world_rect(rect, world_rect, margin):
138 visible_objects.append(object)
139 return visible_objects
140
141
142
143func object_world_rect(object:Dictionary, fallback_size:Vector2 = Vector2.ZERO, offset:Vector2 = Vector2.ZERO) -> Rect2:
144 var default_size := Vector2(float(tile_size.x), float(tile_size.y))
145 var size_value:Vector2 = _vector2(object.get("size", fallback_size if fallback_size != Vector2.ZERO else default_size))
146 var position:Vector2 = _object_world_position(object) + offset
147 return Rect2(position, size_value)
148
149
150
151func rect_intersects_world_rect(rect:Rect2, world_rect:Rect2, margin:float = 0.0) -> bool:
152 var cull_rect:Rect2 = world_rect.grow(margin) if margin != 0.0 else world_rect
153 return rect.intersects(cull_rect) or cull_rect.has_point(rect.position)
154
155
156func _tile_records_for_include_empty(include_empty:bool) -> Array:
157 var cache_key = "all" if include_empty else "non_empty"
158 if tile_records_cache.has(cache_key):
159 cache_hits += 1
160 return tile_records_cache.get(cache_key, [])
161 cache_misses += 1
162 var records := []
163 for layer in layers:
164 var layer_id = str(layer.get("id", "terrain"))
165 var layer_tiles = layer.get("tiles", [])
166 for y in range(layer_tiles.size()):
167 var row = str(layer_tiles[y])
168 for x in range(row.length()):
169 var tile = row[x]
170 var tile_metadata = get_tile_metadata(tile)
171 if not include_empty and str(tile_metadata.get("type", "")) == "empty":
172 continue
173 records.append({
174 "layer": layer_id,
175 "tile": tile,
176 "cell": Vector2i(x, y),
177 "position": Vector2(x * tile_size.x, y * tile_size.y),
178 "metadata": tile_metadata
179 })
180 tile_records_cache[cache_key] = records
181 return records
182
183
184func _record_intersects_world_rect(record:Dictionary, world_rect:Rect2) -> bool:
185 var tile_rect = Rect2(record.get("position", Vector2.ZERO), Vector2(tile_size))
186 return rect_intersects_world_rect(tile_rect, world_rect)
187
188
189
190func get_objects(kind:String = "") -> Array:
191 var results := []
192 for object in objects:
193 if not object is Dictionary:
194 continue
195 if kind != "" and str(object.get("kind", "")) != kind:
196 continue
197 results.append(object.duplicate(true))
198 return results
199
200
201
202func get_spawns(kind:String = "") -> Array:
203 var spawns = []
204 for object in get_objects(kind):
205 var entry = object.duplicate(true)
206 if entry.has("tile"):
207 var tile = entry["tile"]
208 if tile is Array and tile.size() >= 2:
209 entry["position"] = Vector2(float(tile[0]) * tile_size.x, float(tile[1]) * tile_size.y)
210 spawns.append(entry)
211 return spawns
212
213
214func get_exits() -> Array:
215 return get_objects("exit")
216
217
218func get_triggers(type:String = "") -> Array:
219 var results := []
220 for trigger in triggers:
221 if not trigger is Dictionary:
222 continue
223 if type != "" and str(trigger.get("type", "")) != type:
224 continue
225 results.append(trigger.duplicate(true))
226 return results
227
228
229func get_layers() -> Array:
230 return layers.duplicate(true)
231
232
233func get_level() -> Dictionary:
234 return level.duplicate(true)
235
236
237
238func get_cache_stats() -> Dictionary:
239 return {
240 "tile_dimensions_cached": tile_dimensions_cache.x >= 0 and tile_dimensions_cache.y >= 0,
241 "rect_type_cache_entries": rects_by_type_cache.size(),
242 "tile_record_cache_entries": tile_records_cache.size(),
243 "cache_hits": cache_hits,
244 "cache_misses": cache_misses,
245 "data_cache_entries": int(data_cache_manager.get_cache_stats().get("entries", 0)),
246 "data_cache_hits": int(data_cache_manager.get_cache_stats().get("cache_hits", 0)),
247 "data_cache_misses": int(data_cache_manager.get_cache_stats().get("cache_misses", 0))
248 }
249
250
251
252func clear_caches() -> void:
253 tile_dimensions_cache = Vector2i(-1, -1)
254 rects_by_type_cache.clear()
255 tile_records_cache.clear()
256 cache_hits = 0
257 cache_misses = 0
258
259
260
261func get_background() -> Dictionary:
262 if level.has("background") and level["background"] is Dictionary:
263 return level["background"].duplicate(true)
264 return metadata.duplicate(true)
265
266
267
268func get_summary(data:Dictionary = {}) -> Dictionary:
269 var target = data if not data.is_empty() else level
270 var target_layers = _normalize_layers(target)
271 var target_objects = _normalize_objects(target.get("objects", []), target.get("spawns", []))
272 var dimensions := Vector2i.ZERO
273 for layer in target_layers:
274 for row in layer.get("tiles", []):
275 dimensions.x = max(dimensions.x, str(row).length())
276 dimensions.y = max(dimensions.y, layer.get("tiles", []).size())
277 return {
278 "id": str(target.get("id", "")),
279 "name": str(target.get("name", "")),
280 "dimensions": dimensions,
281 "layers": target_layers.size(),
282 "objects": target_objects.size(),
283 "triggers": target.get("triggers", []).size() if target.get("triggers", []) is Array else 0
284 }
285
286
287
288func validate_level(data:Dictionary = {}) -> Array:
289 var target = data if not data.is_empty() else level
290 var issues := []
291 if target.is_empty():
292 return [_issue("empty_level", "level", "No level data loaded.")]
293 var target_legend = target.get("legend", {})
294 if not target_legend is Dictionary or target_legend.is_empty():
295 issues.append(_issue("missing_legend", "legend", "Level must define a tile legend."))
296 var normalized_layers = _normalize_layers(target)
297 if normalized_layers.is_empty():
298 issues.append(_issue("missing_layers", "layers", "Level must define at least one tile layer or legacy tiles array."))
299 var seen_layer_ids := {}
300 var expected_width := -1
301 var expected_height := -1
302 for layer_index in range(normalized_layers.size()):
303 var layer = normalized_layers[layer_index]
304 var layer_path = "layers[%d]" % layer_index
305 var layer_id = str(layer.get("id", ""))
306 if layer_id == "":
307 issues.append(_issue("missing_layer_id", layer_path, "Layer is missing an id."))
308 elif seen_layer_ids.has(layer_id):
309 issues.append(_issue("duplicate_layer_id", layer_path, "Duplicate layer id '%s'." % layer_id))
310 seen_layer_ids[layer_id] = true
311 var layer_tiles = layer.get("tiles", [])
312 if not layer_tiles is Array or layer_tiles.is_empty():
313 issues.append(_issue("empty_layer", "%s.tiles" % layer_path, "Layer must contain tile rows."))
314 continue
315 if expected_height < 0:
316 expected_height = layer_tiles.size()
317 elif layer_tiles.size() != expected_height:
318 issues.append(_issue("layer_height_mismatch", "%s.tiles" % layer_path, "Layer height does not match the first layer."))
319 for y in range(layer_tiles.size()):
320 var row = str(layer_tiles[y])
321 if expected_width < 0:
322 expected_width = row.length()
323 elif row.length() != expected_width:
324 issues.append(_issue("row_width_mismatch", "%s.tiles[%d]" % [layer_path, y], "Tile row width does not match the first row."))
325 for x in range(row.length()):
326 var tile = row[x]
327 if target_legend is Dictionary and not target_legend.has(tile):
328 issues.append(_issue("unknown_tile", "%s.tiles[%d][%d]" % [layer_path, y, x], "Tile '%s' is not in the legend." % tile))
329 var normalized_objects = _normalize_objects(target.get("objects", []), target.get("spawns", []))
330 var seen_object_ids := {}
331 var player_spawns := 0
332 for index in range(normalized_objects.size()):
333 var object = normalized_objects[index]
334 var object_path = "objects[%d]" % index
335 var object_id = str(object.get("id", ""))
336 if object_id != "":
337 if seen_object_ids.has(object_id):
338 issues.append(_issue("duplicate_object_id", object_path, "Duplicate object id '%s'." % object_id))
339 seen_object_ids[object_id] = true
340 if str(object.get("kind", "")) == "player":
341 player_spawns += 1
342 var tile = object.get("tile", [])
343 if not _is_tile_array(tile):
344 issues.append(_issue("invalid_object_tile", "%s.tile" % object_path, "Object tile must be a two-number array."))
345 elif expected_width > 0 and expected_height > 0:
346 var cell = _cell_from_array(tile)
347 if cell.x < 0 or cell.y < 0 or cell.x >= expected_width or cell.y >= expected_height:
348 issues.append(_issue("object_out_of_bounds", "%s.tile" % object_path, "Object is outside level bounds."))
349 if player_spawns <= 0:
350 issues.append(_issue("missing_player_spawn", "objects", "Level needs at least one player spawn."))
351 return issues
352
353
354func make_template_level(id:String = "new_platformer_level", width:int = 24, height:int = 10) -> Dictionary:
355 var empty_row = ".".repeat(max(1, width))
356 var ground_row = "#".repeat(max(1, width))
357 var tile_rows := []
358 for y in range(max(1, height)):
359 tile_rows.append(ground_row if y == height - 1 else empty_row)
360 return {
361 "schema_version": 1,
362 "id": id,
363 "name": id.replace("_", " ").capitalize(),
364 "tile_size": [64, 64],
365 "metadata": {
366 "game_type": "platformer",
367 "theme": "starter",
368 "music": "prototype_loop",
369 "background": "day"
370 },
371 "legend": {
372 ".": {"type": "empty"},
373 "#": {"type": "solid", "sprite": "grass_tile"},
374 "^": {"type": "hazard", "sprite": "spike_strip"},
375 "o": {"type": "pickup", "sprite": "coin_pickup"},
376 "F": {"type": "checkpoint", "sprite": "checkpoint_flag"}
377 },
378 "layers": [
379 {"id": "terrain", "type": "tiles", "collision": true, "visible": true, "tiles": tile_rows}
380 ],
381 "objects": [
382 {"id": "player_start", "kind": "player", "tile": [2, max(0, height - 2)], "facing": "right"},
383 {"id": "exit_01", "kind": "exit", "tile": [max(0, width - 2), max(0, height - 2)], "target": ""}
384 ],
385 "triggers": []
386 }
387
388
389func reset() -> void:
390 level.clear()
391 layers.clear()
392 legend.clear()
393 objects.clear()
394 triggers.clear()
395 metadata.clear()
396 tile_dimensions_cache = Vector2i(-1, -1)
397 rects_by_type_cache.clear()
398 tile_records_cache.clear()
399 cache_hits = 0
400 cache_misses = 0
401 tile_size = Vector2i(32, 32)
402
403
404func _normalize_layers(data:Dictionary) -> Array:
405 var normalized := []
406 if data.get("layers", []) is Array and not data.get("layers", []).is_empty():
407 for raw_layer in data.get("layers", []):
408 if not raw_layer is Dictionary:
409 continue
410 var layer = raw_layer.duplicate(true)
411 layer["id"] = str(layer.get("id", "terrain"))
412 layer["type"] = str(layer.get("type", "tiles"))
413 layer["collision"] = bool(layer.get("collision", true))
414 layer["visible"] = bool(layer.get("visible", true))
415 var layer_tiles := []
416 for row in layer.get("tiles", []):
417 layer_tiles.append(str(row))
418 layer["tiles"] = layer_tiles
419 normalized.append(layer)
420 return normalized
421 var legacy_tiles := []
422 for row in data.get("tiles", []):
423 legacy_tiles.append(str(row))
424 if not legacy_tiles.is_empty():
425 normalized.append({
426 "id": "terrain",
427 "type": "tiles",
428 "collision": true,
429 "visible": true,
430 "tiles": legacy_tiles
431 })
432 return normalized
433
434
435func _normalize_objects(next_objects, legacy_spawns) -> Array:
436 var normalized := []
437 if next_objects is Array and not next_objects.is_empty():
438 for raw_object in next_objects:
439 if not raw_object is Dictionary:
440 continue
441 var object = raw_object.duplicate(true)
442 if not object.has("id") or str(object.get("id", "")) == "":
443 object["id"] = "%s_%d" % [str(object.get("kind", "object")), normalized.size()]
444 normalized.append(object)
445 return normalized
446 if legacy_spawns is Array:
447 for index in range(legacy_spawns.size()):
448 var spawn = legacy_spawns[index]
449 if not spawn is Dictionary:
450 continue
451 var object = spawn.duplicate(true)
452 if not object.has("id") or str(object.get("id", "")) == "":
453 object["id"] = "%s_%d" % [str(object.get("kind", "spawn")), index]
454 normalized.append(object)
455 return normalized
456
457
458func _get_layer(layer_id:String = "") -> Dictionary:
459 if layers.is_empty():
460 return {}
461 if layer_id == "":
462 return layers[0].duplicate(true)
463 for layer in layers:
464 if str(layer.get("id", "")) == layer_id:
465 return layer.duplicate(true)
466 return {}
467
468
469func _parse_tile_size(value) -> Vector2i:
470 if value is Array and value.size() >= 2:
471 return Vector2i(int(value[0]), int(value[1]))
472 if value is Vector2i:
473 return value
474 if value is Vector2:
475 return Vector2i(int(value.x), int(value.y))
476 return Vector2i(32, 32)
477
478
479func _object_world_position(object:Dictionary) -> Vector2:
480 var position_value:Variant = object.get("position", null)
481 if position_value is Vector2:
482 return position_value
483 if position_value is Vector2i:
484 return Vector2(float(position_value.x), float(position_value.y))
485 if position_value is Array and position_value.size() >= 2:
486 return Vector2(float(position_value[0]), float(position_value[1]))
487 var tile_value:Variant = object.get("tile", [])
488 if tile_value is Vector2i:
489 return Vector2(tile_value.x * tile_size.x, tile_value.y * tile_size.y)
490 if tile_value is Vector2:
491 return Vector2(tile_value.x * tile_size.x, tile_value.y * tile_size.y)
492 if tile_value is Array and tile_value.size() >= 2:
493 return Vector2(float(tile_value[0]) * tile_size.x, float(tile_value[1]) * tile_size.y)
494 return Vector2.ZERO
495
496
497func _vector2(value, fallback:Vector2 = Vector2.ZERO) -> Vector2:
498 if value is Vector2:
499 return value
500 if value is Vector2i:
501 return Vector2(float(value.x), float(value.y))
502 if value is Array and value.size() >= 2:
503 return Vector2(float(value[0]), float(value[1]))
504 if value is Dictionary:
505 return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
506 return fallback
507
508
509func _is_tile_array(value) -> bool:
510 return value is Array and value.size() >= 2 and (value[0] is int or value[0] is float) and (value[1] is int or value[1] is float)
511
512
513func _cell_from_array(value:Array) -> Vector2i:
514 return Vector2i(int(value[0]), int(value[1]))
515
516
517func _issue(type:String, path:String, message:String) -> Dictionary:
518 return {"type": type, "path": path, "message": message}
Vector2
Return a world-space rect for an object using tile, position, and size fields.
float
Test whether a world-space rect intersects a camera/rendering slice.