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
122func build_tile_draw_records(tile_rows:Array, tile_definitions:Dictionary = {}, options:Dictionary = {}) -> Array:
123 var include_empty:bool = bool(options.get("include_empty", true))
124 var empty_symbols:Array = options.get("empty_symbols", [" "]) if options.get("empty_symbols", []) is Array else [" "]
125 var records:Array = []
126 for y in range(tile_rows.size()):
127 var row_text:String = str(tile_rows[y])
128 for x in range(row_text.length()):
129 var symbol:String = row_text.substr(x, 1)
130 if not include_empty and empty_symbols.has(symbol):
131 continue
132 var tile:Dictionary = tile_definitions.get(symbol, {}) if tile_definitions.get(symbol, {}) is Dictionary else {}
133 var metadata:Dictionary = tile.get("metadata", {}) if tile.get("metadata", {}) is Dictionary else {}
134 records.append({
135 "cell": Vector2i(x, y),
136 "symbol": symbol,
137 "tile": tile.duplicate(true),
138 "metadata": metadata.duplicate(true)
139 })
140 return records
141
142
143
148func compile_grid_runtime(tile_rows:Array, tile_definitions:Dictionary = {}, objects:Array = [], options:Dictionary = {}) -> Dictionary:
149 var runtime_tile_size:Vector2 = _vector2(options.get("tile_size", Vector2(float(tile_size.x), float(tile_size.y))), Vector2(float(tile_size.x), float(tile_size.y)))
150 var include_empty:bool = bool(options.get("include_empty", true))
151 var empty_symbols:Array = options.get("empty_symbols", [" "]) if options.get("empty_symbols", []) is Array else [" "]
152 var surface_colors:Dictionary = options.get("surface_colors", {}) if options.get("surface_colors", {}) is Dictionary else {}
153 var raw_records:Array = build_tile_draw_records(tile_rows, tile_definitions, {
154 "include_empty": include_empty,
155 "empty_symbols": empty_symbols
156 })
157 var records:Array = []
158 var colliders:Array = []
159 var hazards:Array = []
160 var by_cell:Dictionary = {}
161 var width:int = 0
162 for y in range(tile_rows.size()):
163 width = max(width, str(tile_rows[y]).length())
164 for raw_record in raw_records:
165 if not raw_record is Dictionary:
166 continue
167 var record:Dictionary = _compile_tile_runtime_record(raw_record, runtime_tile_size, surface_colors)
168 var symbol:String = str(record.get("symbol", ""))
169 if not include_empty and empty_symbols.has(symbol):
170 continue
171 records.append(record)
172 by_cell[_cell_key(record.get("cell", Vector2i.ZERO))] = record
173 if str(record.get("type", "")) == "solid":
174 colliders.append(record)
175 elif str(record.get("type", "")) == "hazard":
176 hazards.append(record)
177 var fill_runs:Array = build_tile_fill_runs(records, ["color"], {
178 "include_empty": include_empty,
179 "empty_symbols": empty_symbols
180 })
181 return {
182 "schema_version": 1,
183 "tile_size": runtime_tile_size,
184 "dimensions": Vector2i(width, tile_rows.size()),
185 "tile_records": records,
186 "tile_fill_runs": fill_runs,
187 "solid_records": colliders,
188 "hazard_records": hazards,
189 "records_by_cell": by_cell,
190 "object_draw_records": build_object_draw_records(objects, options)
191 }
192
193
194
200func compile_loaded_runtime(layer_id:String = "", options:Dictionary = {}) -> Dictionary:
201 var layer:Dictionary = _get_layer(layer_id)
202 if layer.is_empty():
203 return {}
204 var tile_rows:Array = layer.get("tiles", []) if layer.get("tiles", []) is Array else []
205 var tile_definitions:Dictionary = {}
206 for symbol in legend.keys():
207 var metadata_value = legend.get(symbol, {})
208 var metadata:Dictionary = metadata_value if metadata_value is Dictionary else {}
209 tile_definitions[str(symbol)] = {
210 "metadata": metadata.duplicate(true),
211 "sprite": str(metadata.get("sprite", metadata.get("sprite_id", "")))
212 }
213 var runtime_options:Dictionary = options.duplicate(true)
214 if not runtime_options.has("tile_size"):
215 runtime_options["tile_size"] = Vector2(float(tile_size.x), float(tile_size.y))
216 return compile_grid_runtime(tile_rows, tile_definitions, objects, runtime_options)
217
218
219
220func runtime_tile_at_cell(runtime:Dictionary, cell:Vector2i, fallback:Dictionary = {}) -> Dictionary:
221 var by_cell:Dictionary = runtime.get("records_by_cell", {}) if runtime.get("records_by_cell", {}) is Dictionary else {}
222 var record = by_cell.get(_cell_key(cell), fallback)
223 return record.duplicate(true) if record is Dictionary else fallback.duplicate(true)
224
225
226
230func runtime_tile_ref_at_cell(runtime:Dictionary, cell:Vector2i) -> Dictionary:
231 var by_cell:Dictionary = runtime.get("records_by_cell", {}) if runtime.get("records_by_cell", {}) is Dictionary else {}
232 var record = by_cell.get(_cell_key(cell), {})
233 return record if record is Dictionary else {}
234
235
236
239func build_tile_fill_runs(records:Array, merge_fields:Array = ["color"], options:Dictionary = {}) -> Array:
240 var include_empty:bool = bool(options.get("include_empty", true))
241 var empty_symbols:Array = options.get("empty_symbols", [" "]) if options.get("empty_symbols", []) is Array else [" "]
242 var runs:Array = []
243 var current:Dictionary = {}
244 var current_key:String = ""
245 var current_row:int = -999999
246 var current_end_x:int = -999999
247 for item in records:
248 if not item is Dictionary:
249 continue
250 var record:Dictionary = item
251 var symbol:String = str(record.get("symbol", ""))
252 if not include_empty and empty_symbols.has(symbol):
253 continue
254 var cell:Vector2i = record.get("cell", Vector2i.ZERO)
255 var key:String = _draw_record_merge_key(record, merge_fields)
256 var can_extend:bool = not current.is_empty() and cell.y == current_row and cell.x == current_end_x and key == current_key
257 if can_extend:
258 var span:Vector2i = current.get("span", Vector2i.ONE)
259 span.x += 1
260 current["span"] = span
261 current_end_x = cell.x + 1
262 continue
263 if not current.is_empty():
264 runs.append(current.duplicate(true))
265 current = record.duplicate(true)
266 current["span"] = Vector2i(1, 1)
267 current_key = key
268 current_row = cell.y
269 current_end_x = cell.x + 1
270 if not current.is_empty():
271 runs.append(current.duplicate(true))
272 return runs
273
274
275
276func get_visible_draw_records(records:Array, world_rect:Rect2, tile_pixel_size:Vector2 = Vector2.ZERO, options:Dictionary = {}) -> Array:
277 if world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
278 return records.duplicate(true)
279 var pixel_size:Vector2 = tile_pixel_size if tile_pixel_size != Vector2.ZERO else Vector2(float(tile_size.x), float(tile_size.y))
280 var origin:Vector2 = _vector2(options.get("origin", Vector2.ZERO), Vector2.ZERO)
281 var margin:float = float(options.get("margin", 0.0))
282 var visible_records:Array = []
283 for record in records:
284 if not record is Dictionary:
285 continue
286 var rect:Rect2 = draw_record_world_rect(record, pixel_size, origin)
287 if rect_intersects_world_rect(rect, world_rect, margin):
288 visible_records.append(record)
289 return visible_records
290
291
292
293func draw_record_world_rect(record:Dictionary, tile_pixel_size:Vector2 = Vector2.ZERO, origin:Vector2 = Vector2.ZERO) -> Rect2:
294 if record.get("rect", null) is Rect2:
295 return record.get("rect", Rect2())
296 var pixel_size:Vector2 = tile_pixel_size if tile_pixel_size != Vector2.ZERO else Vector2(float(tile_size.x), float(tile_size.y))
297 var cell:Vector2i = record.get("cell", Vector2i.ZERO)
298 var span:Vector2i = record.get("span", Vector2i.ONE)
299 var size:Vector2 = Vector2(pixel_size.x * float(max(1, span.x)), pixel_size.y * float(max(1, span.y)))
300 return Rect2(origin + Vector2(float(cell.x) * pixel_size.x, float(cell.y) * pixel_size.y), size)
301
302
303
308func get_objects_in_rect(kind:String = "", world_rect:Rect2 = Rect2(), options:Dictionary = {}) -> Array:
309 var source_objects:Array = get_objects(kind)
310 if world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
311 return source_objects
312 var margin:float = float(options.get("margin", 0.0))
313 var default_size := Vector2(float(tile_size.x), float(tile_size.y))
314 var object_size:Vector2 = _vector2(options.get("object_size", default_size))
315 var offset:Vector2 = _vector2(options.get("offset", Vector2.ZERO))
316 var visible_objects:Array = []
317 for object in source_objects:
318 var rect:Rect2 = object_world_rect(object, object_size, offset)
319 if rect_intersects_world_rect(rect, world_rect, margin):
320 visible_objects.append(object)
321 return visible_objects
322
323
324
325func object_world_rect(object:Dictionary, fallback_size:Vector2 = Vector2.ZERO, offset:Vector2 = Vector2.ZERO) -> Rect2:
326 var default_size := Vector2(float(tile_size.x), float(tile_size.y))
327 var size_value:Vector2 = _vector2(object.get("size", fallback_size if fallback_size != Vector2.ZERO else default_size))
328 var position:Vector2 = _object_world_position(object) + offset
329 return Rect2(position, size_value)
330
331
332
336func object_world_position(object:Dictionary, options:Dictionary = {}) -> Vector2:
337 var size_value:Vector2 = _vector2(options.get("tile_size", Vector2(float(tile_size.x), float(tile_size.y))), Vector2(float(tile_size.x), float(tile_size.y)))
338 var normalized:Dictionary = object.duplicate(true)
339 if options.has("position_units") and not normalized.has("position_units"):
340 normalized["position_units"] = str(options.get("position_units", "pixels"))
341 return _object_world_position_with_tile_size(normalized, size_value)
342
343
344
345func tile_units_to_world_position(value, tile_pixel_size:Vector2 = Vector2.ZERO) -> Vector2:
346 var size_value:Vector2 = tile_pixel_size if tile_pixel_size != Vector2.ZERO else Vector2(float(tile_size.x), float(tile_size.y))
347 return _vector2(value, Vector2.ZERO) * size_value
348
349
350
351func world_position_to_tile_units(position:Vector2, tile_pixel_size:Vector2 = Vector2.ZERO, digits:int = 3) -> Array:
352 var size_value:Vector2 = tile_pixel_size if tile_pixel_size != Vector2.ZERO else Vector2(float(tile_size.x), float(tile_size.y))
353 return [
354 _round_float(position.x / float(max(1.0, size_value.x)), digits),
355 _round_float(position.y / float(max(1.0, size_value.y)), digits)
356 ]
357
358
359
364func object_visual_rect(object:Dictionary, fallback_size:Vector2 = Vector2.ZERO, options:Dictionary = {}) -> Rect2:
365 var default_tile_size:Vector2 = _vector2(options.get("tile_size", Vector2(float(tile_size.x), float(tile_size.y))), Vector2(float(tile_size.x), float(tile_size.y)))
366 var default_size:Vector2 = fallback_size if fallback_size != Vector2.ZERO else default_tile_size
367 var size_value:Vector2 = _vector2(object.get("visual_size", object.get("size", default_size)), default_size)
368 if str(object.get("visual_units", options.get("visual_units", "pixels"))) == "tiles":
369 size_value *= default_tile_size
370 var anchor:Vector2 = _vector2(object.get("visual_anchor", options.get("visual_anchor", Vector2(0.5, 0.5))), Vector2(0.5, 0.5))
371 var visual_offset:Vector2 = _vector2(object.get("visual_offset", options.get("visual_offset", Vector2.ZERO)), Vector2.ZERO)
372 if str(object.get("visual_offset_units", options.get("visual_offset_units", "pixels"))) == "tiles":
373 visual_offset *= default_tile_size
374 var placement:Vector2 = _object_visual_placement(object, default_tile_size, options)
375 return Rect2(placement - size_value * anchor + visual_offset, size_value)
376
377
378
382func build_object_draw_records(objects:Array, options:Dictionary = {}) -> Array:
383 var default_tile_size:Vector2 = _vector2(options.get("tile_size", Vector2(float(tile_size.x), float(tile_size.y))), Vector2(float(tile_size.x), float(tile_size.y)))
384 var default_size:Vector2 = _vector2(options.get("fallback_size", default_tile_size), default_tile_size)
385 var records:Array = []
386 for item in objects:
387 if not item is Dictionary:
388 continue
389 var object:Dictionary = item
390 var fallback_size:Vector2 = _vector2(object.get("_fallback_visual_size", default_size), default_size)
391 var visual_rect:Rect2 = object_visual_rect(object, fallback_size, options)
392 var center:Vector2 = _vector2(object.get("_world_center", visual_rect.get_center()), visual_rect.get_center())
393 records.append({
394 "object": object.duplicate(true),
395 "rect": visual_rect,
396 "center": center,
397 "kind": str(object.get("kind", "")),
398 "sprite_id": str(object.get("sprite_id", "")),
399 "category": str(object.get("category", "object")),
400 "display_name": str(object.get("display_name", object.get("id", object.get("kind", "Object")))),
401 "tags": object.get("tags", []) if object.get("tags", []) is Array else []
402 })
403 return records
404
405
406
407func rect_intersects_world_rect(rect:Rect2, world_rect:Rect2, margin:float = 0.0) -> bool:
408 var cull_rect:Rect2 = world_rect.grow(margin) if margin != 0.0 else world_rect
409 return rect.intersects(cull_rect) or cull_rect.has_point(rect.position)
410
411
412func _compile_tile_runtime_record(raw_record:Dictionary, runtime_tile_size:Vector2, surface_colors:Dictionary) -> Dictionary:
413 var record:Dictionary = raw_record.duplicate(true)
414 var tile:Dictionary = record.get("tile", {}) if record.get("tile", {}) is Dictionary else {}
415 var metadata:Dictionary = record.get("metadata", {}) if record.get("metadata", {}) is Dictionary else {}
416 var surface:String = str(metadata.get("surface", metadata.get("type", "")))
417 var tile_type:String = str(metadata.get("type", "empty" if surface == "void" else "floor"))
418 var cell:Vector2i = record.get("cell", Vector2i.ZERO)
419 record["type"] = tile_type
420 record["surface"] = surface
421 record["collision_shape"] = str(metadata.get("collision_shape", ""))
422 record["friction"] = float(metadata.get("friction", 1.0))
423 record["bounce"] = float(metadata.get("bounce", 0.82))
424 record["sprite_id"] = str(tile.get("sprite", metadata.get("sprite_id", "")))
425 record["draw_grid"] = bool(metadata.get("draw_grid", true)) and str(record.get("symbol", "")) != " "
426 record["draw_sprite"] = str(record.get("symbol", "")) != " "
427 record["rect"] = Rect2(Vector2(float(cell.x) * runtime_tile_size.x, float(cell.y) * runtime_tile_size.y), runtime_tile_size)
428 if metadata.has("color"):
429 record["color"] = _color_from_value(metadata.get("color", "#ffffff"), Color.WHITE)
430 elif surface_colors.has(surface):
431 record["color"] = _color_from_value(surface_colors.get(surface), Color.WHITE)
432 elif surface_colors.has(tile_type):
433 record["color"] = _color_from_value(surface_colors.get(tile_type), Color.WHITE)
434 return record
435
436
437func _cell_key(cell:Vector2i) -> String:
438 return "%d,%d" % [cell.x, cell.y]
439
440
441func _color_from_value(value, fallback:Color) -> Color:
442 if value is Color:
443 return value
444 var text:String = str(value)
445 if text == "":
446 return fallback
447 return Color(text)
448
449
450func _tile_records_for_include_empty(include_empty:bool) -> Array:
451 var cache_key = "all" if include_empty else "non_empty"
452 if tile_records_cache.has(cache_key):
453 cache_hits += 1
454 return tile_records_cache.get(cache_key, [])
455 cache_misses += 1
456 var records := []
457 for layer in layers:
458 var layer_id = str(layer.get("id", "terrain"))
459 var layer_tiles = layer.get("tiles", [])
460 for y in range(layer_tiles.size()):
461 var row = str(layer_tiles[y])
462 for x in range(row.length()):
463 var tile = row[x]
464 var tile_metadata = get_tile_metadata(tile)
465 if not include_empty and str(tile_metadata.get("type", "")) == "empty":
466 continue
467 records.append({
468 "layer": layer_id,
469 "tile": tile,
470 "cell": Vector2i(x, y),
471 "position": Vector2(x * tile_size.x, y * tile_size.y),
472 "metadata": tile_metadata
473 })
474 tile_records_cache[cache_key] = records
475 return records
476
477
478func _record_intersects_world_rect(record:Dictionary, world_rect:Rect2) -> bool:
479 var tile_rect = Rect2(record.get("position", Vector2.ZERO), Vector2(tile_size))
480 return rect_intersects_world_rect(tile_rect, world_rect)
481
482
483
484func get_objects(kind:String = "") -> Array:
485 var results := []
486 for object in objects:
487 if not object is Dictionary:
488 continue
489 if kind != "" and str(object.get("kind", "")) != kind:
490 continue
491 results.append(object.duplicate(true))
492 return results
493
494
495
496func get_spawns(kind:String = "") -> Array:
497 var spawns = []
498 for object in get_objects(kind):
499 var entry = object.duplicate(true)
500 if entry.has("tile"):
501 var tile = entry["tile"]
502 if tile is Array and tile.size() >= 2:
503 entry["position"] = Vector2(float(tile[0]) * tile_size.x, float(tile[1]) * tile_size.y)
504 spawns.append(entry)
505 return spawns
506
507
508func get_exits() -> Array:
509 return get_objects("exit")
510
511
512func get_triggers(type:String = "") -> Array:
513 var results := []
514 for trigger in triggers:
515 if not trigger is Dictionary:
516 continue
517 if type != "" and str(trigger.get("type", "")) != type:
518 continue
519 results.append(trigger.duplicate(true))
520 return results
521
522
523func get_layers() -> Array:
524 return layers.duplicate(true)
525
526
527func get_level() -> Dictionary:
528 return level.duplicate(true)
529
530
531
532func get_cache_stats() -> Dictionary:
533 return {
534 "tile_dimensions_cached": tile_dimensions_cache.x >= 0 and tile_dimensions_cache.y >= 0,
535 "rect_type_cache_entries": rects_by_type_cache.size(),
536 "tile_record_cache_entries": tile_records_cache.size(),
537 "cache_hits": cache_hits,
538 "cache_misses": cache_misses,
539 "data_cache_entries": int(data_cache_manager.get_cache_stats().get("entries", 0)),
540 "data_cache_hits": int(data_cache_manager.get_cache_stats().get("cache_hits", 0)),
541 "data_cache_misses": int(data_cache_manager.get_cache_stats().get("cache_misses", 0))
542 }
543
544
545
546func clear_caches() -> void:
547 tile_dimensions_cache = Vector2i(-1, -1)
548 rects_by_type_cache.clear()
549 tile_records_cache.clear()
550 cache_hits = 0
551 cache_misses = 0
552
553
554
555func get_background() -> Dictionary:
556 if level.has("background") and level["background"] is Dictionary:
557 return level["background"].duplicate(true)
558 return metadata.duplicate(true)
559
560
561
562func get_summary(data:Dictionary = {}) -> Dictionary:
563 var target = data if not data.is_empty() else level
564 var target_layers = _normalize_layers(target)
565 var target_objects = _normalize_objects(target.get("objects", []), target.get("spawns", []))
566 var dimensions := Vector2i.ZERO
567 for layer in target_layers:
568 for row in layer.get("tiles", []):
569 dimensions.x = max(dimensions.x, str(row).length())
570 dimensions.y = max(dimensions.y, layer.get("tiles", []).size())
571 return {
572 "id": str(target.get("id", "")),
573 "name": str(target.get("name", "")),
574 "dimensions": dimensions,
575 "layers": target_layers.size(),
576 "objects": target_objects.size(),
577 "triggers": target.get("triggers", []).size() if target.get("triggers", []) is Array else 0
578 }
579
580
581
582func validate_level(data:Dictionary = {}) -> Array:
583 var target = data if not data.is_empty() else level
584 var issues := []
585 if target.is_empty():
586 return [_issue("empty_level", "level", "No level data loaded.")]
587 var target_legend = target.get("legend", {})
588 if not target_legend is Dictionary or target_legend.is_empty():
589 issues.append(_issue("missing_legend", "legend", "Level must define a tile legend."))
590 var normalized_layers = _normalize_layers(target)
591 if normalized_layers.is_empty():
592 issues.append(_issue("missing_layers", "layers", "Level must define at least one tile layer or legacy tiles array."))
593 var seen_layer_ids := {}
594 var expected_width := -1
595 var expected_height := -1
596 for layer_index in range(normalized_layers.size()):
597 var layer = normalized_layers[layer_index]
598 var layer_path = "layers[%d]" % layer_index
599 var layer_id = str(layer.get("id", ""))
600 if layer_id == "":
601 issues.append(_issue("missing_layer_id", layer_path, "Layer is missing an id."))
602 elif seen_layer_ids.has(layer_id):
603 issues.append(_issue("duplicate_layer_id", layer_path, "Duplicate layer id '%s'." % layer_id))
604 seen_layer_ids[layer_id] = true
605 var layer_tiles = layer.get("tiles", [])
606 if not layer_tiles is Array or layer_tiles.is_empty():
607 issues.append(_issue("empty_layer", "%s.tiles" % layer_path, "Layer must contain tile rows."))
608 continue
609 if expected_height < 0:
610 expected_height = layer_tiles.size()
611 elif layer_tiles.size() != expected_height:
612 issues.append(_issue("layer_height_mismatch", "%s.tiles" % layer_path, "Layer height does not match the first layer."))
613 for y in range(layer_tiles.size()):
614 var row = str(layer_tiles[y])
615 if expected_width < 0:
616 expected_width = row.length()
617 elif row.length() != expected_width:
618 issues.append(_issue("row_width_mismatch", "%s.tiles[%d]" % [layer_path, y], "Tile row width does not match the first row."))
619 for x in range(row.length()):
620 var tile = row[x]
621 if target_legend is Dictionary and not target_legend.has(tile):
622 issues.append(_issue("unknown_tile", "%s.tiles[%d][%d]" % [layer_path, y, x], "Tile '%s' is not in the legend." % tile))
623 var normalized_objects = _normalize_objects(target.get("objects", []), target.get("spawns", []))
624 var seen_object_ids := {}
625 var player_spawns := 0
626 for index in range(normalized_objects.size()):
627 var object = normalized_objects[index]
628 var object_path = "objects[%d]" % index
629 var object_id = str(object.get("id", ""))
630 if object_id != "":
631 if seen_object_ids.has(object_id):
632 issues.append(_issue("duplicate_object_id", object_path, "Duplicate object id '%s'." % object_id))
633 seen_object_ids[object_id] = true
634 if str(object.get("kind", "")) == "player":
635 player_spawns += 1
636 var tile = object.get("tile", [])
637 if not _is_tile_array(tile):
638 issues.append(_issue("invalid_object_tile", "%s.tile" % object_path, "Object tile must be a two-number array."))
639 elif expected_width > 0 and expected_height > 0:
640 var cell = _cell_from_array(tile)
641 if cell.x < 0 or cell.y < 0 or cell.x >= expected_width or cell.y >= expected_height:
642 issues.append(_issue("object_out_of_bounds", "%s.tile" % object_path, "Object is outside level bounds."))
643 if player_spawns <= 0:
644 issues.append(_issue("missing_player_spawn", "objects", "Level needs at least one player spawn."))
645 return issues
646
647
648func make_template_level(id:String = "new_platformer_level", width:int = 24, height:int = 10) -> Dictionary:
649 var empty_row = ".".repeat(max(1, width))
650 var ground_row = "#".repeat(max(1, width))
651 var tile_rows := []
652 for y in range(max(1, height)):
653 tile_rows.append(ground_row if y == height - 1 else empty_row)
654 return {
655 "schema_version": 1,
656 "id": id,
657 "name": id.replace("_", " ").capitalize(),
658 "tile_size": [64, 64],
659 "metadata": {
660 "game_type": "platformer",
661 "theme": "starter",
662 "music": "prototype_loop",
663 "background": "day"
664 },
665 "legend": {
666 ".": {"type": "empty"},
667 "#": {"type": "solid", "sprite": "grass_tile"},
668 "^": {"type": "hazard", "sprite": "spike_strip"},
669 "o": {"type": "pickup", "sprite": "coin_pickup"},
670 "F": {"type": "checkpoint", "sprite": "checkpoint_flag"}
671 },
672 "layers": [
673 {"id": "terrain", "type": "tiles", "collision": true, "visible": true, "tiles": tile_rows}
674 ],
675 "objects": [
676 {"id": "player_start", "kind": "player", "tile": [2, max(0, height - 2)], "facing": "right"},
677 {"id": "exit_01", "kind": "exit", "tile": [max(0, width - 2), max(0, height - 2)], "target": ""}
678 ],
679 "triggers": []
680 }
681
682
683func reset() -> void:
684 level.clear()
685 layers.clear()
686 legend.clear()
687 objects.clear()
688 triggers.clear()
689 metadata.clear()
690 tile_dimensions_cache = Vector2i(-1, -1)
691 rects_by_type_cache.clear()
692 tile_records_cache.clear()
693 cache_hits = 0
694 cache_misses = 0
695 tile_size = Vector2i(32, 32)
696
697
698func _normalize_layers(data:Dictionary) -> Array:
699 var normalized := []
700 if data.get("layers", []) is Array and not data.get("layers", []).is_empty():
701 for raw_layer in data.get("layers", []):
702 if not raw_layer is Dictionary:
703 continue
704 var layer = raw_layer.duplicate(true)
705 layer["id"] = str(layer.get("id", "terrain"))
706 layer["type"] = str(layer.get("type", "tiles"))
707 layer["collision"] = bool(layer.get("collision", true))
708 layer["visible"] = bool(layer.get("visible", true))
709 var layer_tiles := []
710 for row in layer.get("tiles", []):
711 layer_tiles.append(str(row))
712 layer["tiles"] = layer_tiles
713 normalized.append(layer)
714 return normalized
715 var legacy_tiles := []
716 for row in data.get("tiles", []):
717 legacy_tiles.append(str(row))
718 if not legacy_tiles.is_empty():
719 normalized.append({
720 "id": "terrain",
721 "type": "tiles",
722 "collision": true,
723 "visible": true,
724 "tiles": legacy_tiles
725 })
726 return normalized
727
728
729func _normalize_objects(next_objects, legacy_spawns) -> Array:
730 var normalized := []
731 if next_objects is Array and not next_objects.is_empty():
732 for raw_object in next_objects:
733 if not raw_object is Dictionary:
734 continue
735 var object = raw_object.duplicate(true)
736 if not object.has("id") or str(object.get("id", "")) == "":
737 object["id"] = "%s_%d" % [str(object.get("kind", "object")), normalized.size()]
738 normalized.append(object)
739 return normalized
740 if legacy_spawns is Array:
741 for index in range(legacy_spawns.size()):
742 var spawn = legacy_spawns[index]
743 if not spawn is Dictionary:
744 continue
745 var object = spawn.duplicate(true)
746 if not object.has("id") or str(object.get("id", "")) == "":
747 object["id"] = "%s_%d" % [str(object.get("kind", "spawn")), index]
748 normalized.append(object)
749 return normalized
750
751
752func _get_layer(layer_id:String = "") -> Dictionary:
753 if layers.is_empty():
754 return {}
755 if layer_id == "":
756 return layers[0].duplicate(true)
757 for layer in layers:
758 if str(layer.get("id", "")) == layer_id:
759 return layer.duplicate(true)
760 return {}
761
762
763func _parse_tile_size(value) -> Vector2i:
764 if value is Array and value.size() >= 2:
765 return Vector2i(int(value[0]), int(value[1]))
766 if value is Vector2i:
767 return value
768 if value is Vector2:
769 return Vector2i(int(value.x), int(value.y))
770 return Vector2i(32, 32)
771
772
773func _object_world_position(object:Dictionary) -> Vector2:
774 var position_value:Variant = object.get("position", null)
775 if position_value is Vector2:
776 return position_value
777 if position_value is Vector2i:
778 return Vector2(float(position_value.x), float(position_value.y))
779 if position_value is Array and position_value.size() >= 2:
780 return Vector2(float(position_value[0]), float(position_value[1]))
781 var tile_value:Variant = object.get("tile", [])
782 if tile_value is Vector2i:
783 return Vector2(tile_value.x * tile_size.x, tile_value.y * tile_size.y)
784 if tile_value is Vector2:
785 return Vector2(tile_value.x * tile_size.x, tile_value.y * tile_size.y)
786 if tile_value is Array and tile_value.size() >= 2:
787 return Vector2(float(tile_value[0]) * tile_size.x, float(tile_value[1]) * tile_size.y)
788 return Vector2.ZERO
789
790
791func _object_visual_placement(object:Dictionary, default_tile_size:Vector2, options:Dictionary) -> Vector2:
792 var center_value:Variant = object.get("_world_center", null)
793 if center_value is Vector2:
794 return center_value
795 if center_value is Vector2i:
796 return Vector2(float(center_value.x), float(center_value.y))
797 if center_value is Array and center_value.size() >= 2:
798 return Vector2(float(center_value[0]), float(center_value[1]))
799 var origin:Vector2 = _object_world_position_with_tile_size(object, default_tile_size)
800 var anchor_mode:String = str(object.get("placement_anchor", options.get("placement_anchor", "center")))
801 if anchor_mode == "origin":
802 return origin
803 return origin + default_tile_size * 0.5
804
805
806func _object_world_position_with_tile_size(object:Dictionary, size_value:Vector2) -> Vector2:
807 var position_value:Variant = object.get("position", null)
808 if position_value is Vector2:
809 return position_value
810 if position_value is Vector2i:
811 return Vector2(float(position_value.x), float(position_value.y))
812 if position_value is Array and position_value.size() >= 2:
813 var position_units:String = str(object.get("position_units", "pixels"))
814 var parsed:Vector2 = Vector2(float(position_value[0]), float(position_value[1]))
815 return parsed * size_value if position_units == "tiles" else parsed
816 var tile_value:Variant = object.get("tile", [])
817 if tile_value is Vector2i:
818 return Vector2(tile_value.x * size_value.x, tile_value.y * size_value.y)
819 if tile_value is Vector2:
820 return Vector2(tile_value.x * size_value.x, tile_value.y * size_value.y)
821 if tile_value is Array and tile_value.size() >= 2:
822 return Vector2(float(tile_value[0]) * size_value.x, float(tile_value[1]) * size_value.y)
823 return Vector2.ZERO
824
825
826func _vector2(value, fallback:Vector2 = Vector2.ZERO) -> Vector2:
827 if value is Vector2:
828 return value
829 if value is Vector2i:
830 return Vector2(float(value.x), float(value.y))
831 if value is Array and value.size() >= 2:
832 return Vector2(float(value[0]), float(value[1]))
833 if value is Dictionary:
834 return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
835 return fallback
836
837
838func _draw_record_merge_key(record:Dictionary, merge_fields:Array) -> String:
839 var parts:Array = []
840 for field_value in merge_fields:
841 var field:String = str(field_value)
842 parts.append("%s=%s" % [field, str(record.get(field, null))])
843 return "|".join(parts)
844
845
846func _is_tile_array(value) -> bool:
847 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)
848
849
850func _cell_from_array(value:Array) -> Vector2i:
851 return Vector2i(int(value[0]), int(value[1]))
852
853
854func _round_float(value:float, digits:int = 3) -> float:
855 var factor:float = pow(10.0, digits)
856 return round(value * factor) / factor
857
858
859func _issue(type:String, path:String, message:String) -> Dictionary:
860 return {"type": type, "path": path, "message": message}
Vector2
Return cached/static tile records whose world rect intersects a camera slice.
float
Test whether a world-space rect intersects a camera/rendering slice.