8const DataCacheManager = preload(
"res://scripts/systems/dataCacheManager/data_cache_manager.gd")
12var tile_size := Vector2i(32, 32)
17var tile_dimensions_cache := Vector2i(-1, -1)
18var rects_by_type_cache := {}
19var tile_records_cache := {}
22var data_cache_manager = DataCacheManager.new()
26func load_level(file_path:String) -> void:
28 var data = data_cache_manager.load_json(file_path)
29 if not data
is Dictionary:
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 []
41func get_tile_dimensions() -> Vector2i:
42 if tile_dimensions_cache.x >= 0
and tile_dimensions_cache.y >= 0:
44 return tile_dimensions_cache
49 if not layer
is Dictionary:
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
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)
65func get_tile(x:int, y:int, layer_id:String =
"") -> String:
66 var layer = _get_layer(layer_id)
69 var layer_tiles = layer.get(
"tiles", [])
70 if y < 0
or y >= layer_tiles.size():
72 var row = str(layer_tiles[y])
73 if x < 0
or x >= row.length():
79func get_tile_metadata(tile:String) -> Dictionary:
80 return legend.get(tile, {}).duplicate(true)
84func get_rects_by_type(type:String) -> Array:
85 if rects_by_type_cache.has(type):
87 return rects_by_type_cache.get(type, []).duplicate(true)
91 if not bool(layer.get(
"collision", true)):
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()):
98 var tile_metadata = get_tile_metadata(tile)
99 if str(tile_metadata.get(
"type",
"")) == type:
101 rects_by_type_cache[type] = rects
102 return rects.duplicate(true)
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)
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))
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
143func object_world_rect(object:Dictionary, fallback_size:Vector2 = Vector2.ZERO, offset:Vector2 = Vector2.ZERO) -> Rect2:
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)
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)
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):
160 return tile_records_cache.get(cache_key, [])
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()):
170 var tile_metadata = get_tile_metadata(tile)
171 if not include_empty
and str(tile_metadata.get(
"type",
"")) ==
"empty":
176 "cell": Vector2i(x, y),
177 "position":
Vector2(x * tile_size.x, y * tile_size.y),
178 "metadata": tile_metadata
180 tile_records_cache[cache_key] = records
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)
190func get_objects(kind:String =
"") -> Array:
192 for object
in objects:
193 if not object
is Dictionary:
195 if kind !=
"" and str(object.get(
"kind",
"")) != kind:
197 results.append(object.duplicate(true))
202func get_spawns(kind:String =
"") -> Array:
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)
214func get_exits() -> Array:
215 return get_objects(
"exit")
218func get_triggers(type:String =
"") -> Array:
220 for trigger
in triggers:
221 if not trigger
is Dictionary:
223 if type !=
"" and str(trigger.get(
"type",
"")) != type:
225 results.append(trigger.duplicate(true))
229func get_layers() -> Array:
230 return layers.duplicate(true)
233func get_level() -> Dictionary:
234 return level.duplicate(true)
238func get_cache_stats() -> Dictionary:
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))
252func clear_caches() -> void:
253 tile_dimensions_cache = Vector2i(-1, -1)
254 rects_by_type_cache.clear()
255 tile_records_cache.clear()
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)
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())
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
288func validate_level(data:Dictionary = {}) -> Array:
289 var target = data
if not data.is_empty()
else level
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",
""))
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."))
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()):
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",
""))
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":
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."))
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))
358 for y
in range(max(1, height)):
359 tile_rows.append(ground_row
if y == height - 1
else empty_row)
363 "name": id.replace(
"_",
" ").capitalize(),
364 "tile_size": [64, 64],
366 "game_type":
"platformer",
368 "music":
"prototype_loop",
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"}
379 {
"id":
"terrain",
"type":
"tiles",
"collision": true,
"visible": true,
"tiles": tile_rows}
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":
""}
396 tile_dimensions_cache = Vector2i(-1, -1)
397 rects_by_type_cache.clear()
398 tile_records_cache.clear()
401 tile_size = Vector2i(32, 32)
404func _normalize_layers(data:Dictionary) -> Array:
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:
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)
421 var legacy_tiles := []
422 for row
in data.get(
"tiles", []):
423 legacy_tiles.append(str(row))
424 if not legacy_tiles.is_empty():
430 "tiles": legacy_tiles
435func _normalize_objects(next_objects, legacy_spawns) -> Array:
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:
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)
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:
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)
458func _get_layer(layer_id:String =
"") -> Dictionary:
459 if layers.is_empty():
462 return layers[0].duplicate(true)
464 if str(layer.get(
"id",
"")) == layer_id:
465 return layer.duplicate(true)
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:
475 return Vector2i(
int(value.x),
int(value.y))
476 return Vector2i(32, 32)
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:
485 if position_value
is Array
and position_value.size() >= 2:
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)
497func _vector2(value, fallback:Vector2 = Vector2.ZERO) -> Vector2:
500 if value
is Vector2i:
502 if value
is Array
and value.size() >= 2:
504 if value
is Dictionary:
505 return Vector2(
float(value.get(
"x", fallback.x)),
float(value.get(
"y", fallback.y)))
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)
513func _cell_from_array(value:Array) -> Vector2i:
514 return Vector2i(
int(value[0]),
int(value[1]))
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.