RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
putt_putt_course_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
6
7const DEFAULT_MODULES_PATH := "res://data/games/putt_putt_roguelike/course_modules.json"
8const DEFAULT_PALETTE_PATH := "res://data/games/putt_putt_roguelike/editor_palette.json"
9
10var modules:Array = []
11var pieces:Array = []
12var piece_generation:Dictionary = {}
13var collision_shapes:Dictionary = {}
14var tile_lookup:Dictionary = {}
15var object_lookup:Dictionary = {}
16
17
18func load_catalogs(options:Dictionary = {}) -> Dictionary:
19 var module_path:String = str(options.get("modules", DEFAULT_MODULES_PATH))
20 var palette_path:String = str(options.get("palette", DEFAULT_PALETTE_PATH))
21 var module_data:Dictionary = _load_dictionary(module_path)
22 var palette_data:Dictionary = _load_dictionary(palette_path)
23 modules = module_data.get("modules", []) if module_data.get("modules", []) is Array else []
24 pieces = module_data.get("pieces", []) if module_data.get("pieces", []) is Array else []
25 piece_generation = module_data.get("piece_generation", {}) if module_data.get("piece_generation", {}) is Dictionary else {}
26 collision_shapes.clear()
27 for shape in module_data.get("collision_shapes", []):
28 if shape is Dictionary and str(shape.get("id", "")) != "":
29 collision_shapes[str(shape.get("id", ""))] = shape.duplicate(true)
30 tile_lookup.clear()
31 for tile in palette_data.get("tiles", []):
32 if tile is Dictionary and str(tile.get("tile", "")) != "":
33 tile_lookup[str(tile.get("tile", ""))] = tile.duplicate(true)
34 object_lookup.clear()
35 for object_def in palette_data.get("objects", []):
36 if object_def is Dictionary and str(object_def.get("id", "")) != "":
37 object_lookup[str(object_def.get("id", ""))] = object_def.duplicate(true)
38 return {
39 "modules": modules.size(),
40 "pieces": pieces.size(),
41 "collision_shapes": collision_shapes.size(),
42 "tiles": tile_lookup.size(),
43 "objects": object_lookup.size()
44 }
45
46
47func build_course(options:Dictionary = {}) -> Dictionary:
48 if modules.is_empty():
49 load_catalogs()
50 if str(options.get("mode", "grid")) != "vertical" and not pieces.is_empty() and not piece_generation.is_empty():
51 return _build_grid_course(options)
52 return _build_vertical_course(options)
53
54
55func validate_catalog() -> Array:
56 var issues:Array = []
57 if pieces.is_empty():
58 issues.append(_issue("missing_pieces", "Putt-Putt catalog has no course pieces."))
59 if piece_generation.is_empty():
60 issues.append(_issue("missing_piece_generation", "Putt-Putt catalog has no piece_generation rules."))
61 var valid_sides:Array = ["up", "down", "left", "right"]
62 for piece in pieces:
63 if not piece is Dictionary:
64 issues.append(_issue("invalid_piece", "Course piece rows must be dictionaries."))
65 continue
66 var piece_id:String = str(piece.get("id", ""))
67 if piece_id == "":
68 issues.append(_issue("missing_piece_id", "Course piece is missing an id."))
69 var rows:Array = piece.get("tiles", []) if piece.get("tiles", []) is Array else []
70 var size:Array = piece.get("size", []) if piece.get("size", []) is Array else []
71 var width:int = int(size[0]) if size.size() >= 1 else _piece_width_from_rows(rows)
72 var height:int = int(size[1]) if size.size() >= 2 else rows.size()
73 if width <= 0 or height <= 0:
74 issues.append(_issue("invalid_piece_size", "Course piece %s has an invalid size." % piece_id, piece_id))
75 if rows.size() != height:
76 issues.append(_issue("piece_height_mismatch", "Course piece %s row count does not match size." % piece_id, piece_id))
77 for y in range(rows.size()):
78 var row_text:String = str(rows[y])
79 if row_text.length() != width:
80 issues.append(_issue("piece_width_mismatch", "Course piece %s row %d width does not match size." % [piece_id, y], piece_id))
81 for x in range(row_text.length()):
82 var symbol:String = row_text.substr(x, 1)
83 if not tile_lookup.has(symbol):
84 issues.append(_issue("unknown_tile_symbol", "Course piece %s uses unknown tile '%s'." % [piece_id, symbol], piece_id))
85 var connections:Array = _string_array(piece.get("connections", []))
86 for side in connections:
87 if not valid_sides.has(str(side)):
88 issues.append(_issue("invalid_connection", "Course piece %s has invalid connection '%s'." % [piece_id, str(side)], piece_id))
89 for object_row in piece.get("objects", []):
90 if not object_row is Dictionary:
91 issues.append(_issue("invalid_piece_object", "Course piece %s has a non-dictionary object." % piece_id, piece_id))
92 continue
93 var object_id:String = str(Dictionary(object_row).get("id", "object"))
94 if Dictionary(object_row).has("position"):
95 var position:Array = Dictionary(object_row).get("position", []) if Dictionary(object_row).get("position", []) is Array else []
96 if position.size() < 2:
97 issues.append(_issue("invalid_object_position", "Object %s in %s has an invalid position." % [object_id, piece_id], piece_id))
98 elif float(position[0]) < 0.0 or float(position[1]) < 0.0 or float(position[0]) > float(max(1, width)) or float(position[1]) > float(max(1, height)):
99 issues.append(_issue("object_position_out_of_bounds", "Object %s in %s is outside the piece bounds." % [object_id, piece_id], piece_id))
100 else:
101 var cell:Array = Dictionary(object_row).get("tile", []) if Dictionary(object_row).get("tile", []) is Array else []
102 if cell.size() < 2:
103 issues.append(_issue("invalid_object_tile", "Object %s in %s has no tile or position." % [object_id, piece_id], piece_id))
104 elif int(cell[0]) < 0 or int(cell[1]) < 0 or int(cell[0]) >= max(1, width) or int(cell[1]) >= max(1, height):
105 issues.append(_issue("object_tile_out_of_bounds", "Object %s in %s is outside the piece bounds." % [object_id, piece_id], piece_id))
106 _validate_piece_generation_rules(issues)
107 return issues
108
109
110func _build_vertical_course(options:Dictionary = {}) -> Dictionary:
111 var seed:int = int(options.get("seed", Time.get_unix_time_from_system()))
112 var middle_count:int = max(1, int(options.get("middle_count", 1)))
113 var rng := RandomNumberGenerator.new()
114 rng.seed = seed
115 var selected:Array = []
116 var finish:Dictionary = _first_module_with_role("finish")
117 var start:Dictionary = _first_module_with_role("start")
118 if not finish.is_empty():
119 selected.append(finish)
120 var middle_modules:Array = _modules_with_role("middle")
121 for _index in range(middle_count):
122 if not middle_modules.is_empty():
123 selected.append(middle_modules[rng.randi_range(0, middle_modules.size() - 1)])
124 if not start.is_empty():
125 selected.append(start)
126 selected.reverse()
127 return _compose_vertical(selected, seed)
128
129
130func get_tile_definition(tile_symbol:String) -> Dictionary:
131 return tile_lookup.get(tile_symbol, {}).duplicate(true) if tile_lookup.get(tile_symbol, {}) is Dictionary else {}
132
133
134func get_object_definition(object_id:String) -> Dictionary:
135 return object_lookup.get(object_id, {}).duplicate(true) if object_lookup.get(object_id, {}) is Dictionary else {}
136
137
138func build_object_from_palette(object_id:String, overrides:Dictionary = {}) -> Dictionary:
139 var definition:Dictionary = get_object_definition(object_id)
140 if definition.is_empty():
141 return overrides.duplicate(true)
142 var object:Dictionary = {}
143 var defaults:Dictionary = definition.get("defaults", {}) if definition.get("defaults", {}) is Dictionary else {}
144 object.merge(defaults.duplicate(true), true)
145 object["id"] = str(overrides.get("id", definition.get("id", object_id)))
146 object["kind"] = str(overrides.get("kind", definition.get("kind", "object")))
147 if str(definition.get("sprite", "")) != "":
148 object["sprite_id"] = str(definition.get("sprite", ""))
149 if str(definition.get("label", "")) != "":
150 object["display_name"] = str(definition.get("label", ""))
151 object.merge(overrides.duplicate(true), true)
152 return object
153
154
155func get_collision_shape(shape_id:String) -> Dictionary:
156 return collision_shapes.get(shape_id, {}).duplicate(true) if collision_shapes.get(shape_id, {}) is Dictionary else {}
157
158
159func _compose_vertical(selected:Array, seed:int) -> Dictionary:
160 var width:int = 0
161 for module in selected:
162 if module is Dictionary:
163 var size:Array = module.get("size", []) if module.get("size", []) is Array else []
164 if size.size() >= 1:
165 width = max(width, int(size[0]))
166 var rows:Array[String] = []
167 var objects:Array = []
168 var tee:Array = []
169 var cup:Array = []
170 var module_ranges:Array = []
171 var y_offset:int = 0
172 for module in selected:
173 if not module is Dictionary:
174 continue
175 var module_size:Array = module.get("size", []) if module.get("size", []) is Array else []
176 var module_width:int = int(module_size[0]) if module_size.size() >= 1 else 0
177 var x_offset:int = int(floor(float(width - module_width) / 2.0))
178 var module_rows:Array = module.get("tiles", []) if module.get("tiles", []) is Array else []
179 var start_y:int = y_offset
180 for raw_row in module_rows:
181 var row_text:String = str(raw_row)
182 rows.append(_pad_row(row_text, width, x_offset))
183 y_offset += 1
184 for object_row in module.get("objects", []):
185 if object_row is Dictionary:
186 var object_copy:Dictionary = object_row.duplicate(true)
187 object_copy["tile"] = _offset_cell(object_copy.get("tile", [0, 0]), x_offset, start_y)
188 objects.append(object_copy)
189 if module.has("tee"):
190 tee = _offset_cell(module.get("tee", [0, 0]), x_offset, start_y)
191 if module.has("cup"):
192 cup = _offset_cell(module.get("cup", [0, 0]), x_offset, start_y)
193 module_ranges.append({
194 "id": str(module.get("id", "")),
195 "role": str(module.get("role", "")),
196 "y": start_y,
197 "height": module_rows.size()
198 })
199 return {
200 "schema_version": 1,
201 "generation_mode": "vertical_modules",
202 "seed": seed,
203 "size": [width, rows.size()],
204 "tiles": rows,
205 "objects": objects,
206 "tee": tee,
207 "cup": cup,
208 "modules": module_ranges
209 }
210
211
212func _build_grid_course(options:Dictionary = {}) -> Dictionary:
213 var seed:int = int(options.get("seed", Time.get_unix_time_from_system()))
214 var rng := RandomNumberGenerator.new()
215 rng.seed = seed
216 var grid_size:Array = piece_generation.get("normal_grid_size", [3, 3]) if piece_generation.get("normal_grid_size", []) is Array else [3, 3]
217 var piece_size:Array = piece_generation.get("piece_size", [5, 4]) if piece_generation.get("piece_size", []) is Array else [5, 4]
218 var grid_width:int = max(1, int(grid_size[0]) if grid_size.size() >= 1 else 3)
219 var grid_height:int = max(1, int(grid_size[1]) if grid_size.size() >= 2 else 3)
220 var piece_width:int = max(1, int(piece_size[0]) if piece_size.size() >= 1 else 5)
221 var piece_height:int = max(1, int(piece_size[1]) if piece_size.size() >= 2 else 4)
222 var role_rows:Array = piece_generation.get("normal_roles", []) if piece_generation.get("normal_roles", []) is Array else []
223 var selected_grid:Array = []
224 for y in range(grid_height):
225 var selected_row:Array = []
226 for x in range(grid_width):
227 var role:String = _role_at(role_rows, x, y)
228 var required_connections:Array = _required_connections_for_slot(role_rows, x, y)
229 selected_row.append(_select_piece(role, required_connections, rng))
230 selected_grid.append(selected_row)
231 return _compose_piece_grid(selected_grid, seed, piece_width, piece_height, int(options.get("hole_index", 0)) + 1)
232
233
234func _compose_piece_grid(selected_grid:Array, seed:int, piece_width:int, piece_height:int, run_hole:int = 1) -> Dictionary:
235 var rng := RandomNumberGenerator.new()
236 rng.seed = seed + 17013
237 var grid_height:int = selected_grid.size()
238 var grid_width:int = selected_grid[0].size() if grid_height > 0 and selected_grid[0] is Array else 0
239 var width:int = grid_width * piece_width
240 var height:int = grid_height * piece_height
241 var rows:Array[String] = []
242 for _row_index in range(height):
243 var row_text:String = ""
244 for _column_index in range(width):
245 row_text += " "
246 rows.append(row_text)
247 var objects:Array = []
248 var tee:Array = []
249 var cup:Array = []
250 var module_ranges:Array = []
251 for gy in range(grid_height):
252 var selected_row:Array = selected_grid[gy] if selected_grid[gy] is Array else []
253 for gx in range(grid_width):
254 var piece:Dictionary = selected_row[gx] if gx < selected_row.size() and selected_row[gx] is Dictionary else {}
255 if piece.is_empty():
256 continue
257 var x_offset:int = gx * piece_width
258 var y_offset:int = gy * piece_height
259 _blit_piece_tiles(rows, piece, x_offset, y_offset, piece_width, piece_height)
260 var source_size:Array = piece.get("size", [piece_width, piece_height]) if piece.get("size", []) is Array else [piece_width, piece_height]
261 for object_row in piece.get("objects", []):
262 if object_row is Dictionary:
263 var object_copy:Dictionary = object_row.duplicate(true)
264 if object_copy.has("position"):
265 object_copy["position"] = _offset_position(_scale_piece_position(object_copy.get("position", [0.5, 0.5]), source_size, piece_width, piece_height), x_offset, y_offset)
266 else:
267 object_copy["tile"] = _offset_cell(_scale_piece_cell(object_copy.get("tile", [0, 0]), source_size, piece_width, piece_height), x_offset, y_offset)
268 object_copy["source_piece"] = str(piece.get("id", ""))
269 objects.append(object_copy)
270 if piece.has("tee"):
271 tee = _offset_cell(_scale_piece_cell(piece.get("tee", [0, 0]), source_size, piece_width, piece_height), x_offset, y_offset)
272 if piece.has("cup"):
273 cup = _offset_cell(_scale_piece_cell(piece.get("cup", [0, 0]), source_size, piece_width, piece_height), x_offset, y_offset)
274 module_ranges.append({
275 "id": str(piece.get("id", "")),
276 "role": str(piece.get("role", "")),
277 "grid_cell": [gx, gy],
278 "x": x_offset,
279 "y": y_offset,
280 "width": piece_width,
281 "height": piece_height,
282 "connections": _string_array(piece.get("connections", []))
283 })
284 objects.append_array(_scatter_random_objects(rows, rng, run_hole))
285 return {
286 "schema_version": 1,
287 "generation_mode": "grid_pieces",
288 "seed": seed,
289 "grid_size": [grid_width, grid_height],
290 "piece_size": [piece_width, piece_height],
291 "size": [width, rows.size()],
292 "tiles": rows,
293 "objects": objects,
294 "tee": tee,
295 "cup": cup,
296 "modules": module_ranges
297 }
298
299
300func _first_module_with_role(role:String) -> Dictionary:
301 for module in modules:
302 if module is Dictionary and str(module.get("role", "")) == role:
303 return module.duplicate(true)
304 return {}
305
306
307func _modules_with_role(role:String) -> Array:
308 var found:Array = []
309 for module in modules:
310 if module is Dictionary and str(module.get("role", "")) == role:
311 found.append(module.duplicate(true))
312 return found
313
314
315func _role_at(role_rows:Array, x:int, y:int) -> String:
316 if y < 0 or y >= role_rows.size() or not role_rows[y] is Array:
317 return "void"
318 var row:Array = role_rows[y]
319 if x < 0 or x >= row.size():
320 return "void"
321 return str(row[x])
322
323
324func _required_connections_for_slot(role_rows:Array, x:int, y:int) -> Array:
325 var role:String = _role_at(role_rows, x, y)
326 if role == "void":
327 return []
328 var required:Array = []
329 for side in ["up", "down", "left", "right"]:
330 var offset:Vector2i = _side_offset(side)
331 if _role_at(role_rows, x + offset.x, y + offset.y) != "void":
332 required.append(side)
333 return required
334
335
336func _select_piece(role:String, required_connections:Array, rng:RandomNumberGenerator) -> Dictionary:
337 var candidates:Array = []
338 for piece in pieces:
339 if not piece is Dictionary:
340 continue
341 if bool(piece.get("disabled", false)):
342 continue
343 if str(piece.get("role", "green")) != role:
344 continue
345 if _piece_has_connections(piece, required_connections):
346 candidates.append(piece.duplicate(true))
347 if candidates.is_empty() and role != "void":
348 for piece in pieces:
349 if piece is Dictionary and not bool(piece.get("disabled", false)) and str(piece.get("role", "")) == role:
350 candidates.append(piece.duplicate(true))
351 if candidates.is_empty():
352 for piece in pieces:
353 if piece is Dictionary and not bool(piece.get("disabled", false)) and str(piece.get("role", "")) == "void":
354 return piece.duplicate(true)
355 return {}
356 return candidates[rng.randi_range(0, candidates.size() - 1)]
357
358
359func _piece_has_connections(piece:Dictionary, required_connections:Array) -> bool:
360 var connections:Array = _string_array(piece.get("connections", []))
361 for side in required_connections:
362 if not connections.has(str(side)):
363 return false
364 return true
365
366
367func _side_offset(side:String) -> Vector2i:
368 match side:
369 "up":
370 return Vector2i(0, -1)
371 "down":
372 return Vector2i(0, 1)
373 "left":
374 return Vector2i(-1, 0)
375 "right":
376 return Vector2i(1, 0)
377 _:
378 return Vector2i.ZERO
379
380
381func _blit_piece_tiles(rows:Array[String], piece:Dictionary, x_offset:int, y_offset:int, piece_width:int, piece_height:int) -> void:
382 var piece_rows:Array = piece.get("tiles", []) if piece.get("tiles", []) is Array else []
383 var source_height:int = max(1, piece_rows.size())
384 var source_width:int = 1
385 var piece_size:Array = piece.get("size", []) if piece.get("size", []) is Array else []
386 if piece_size.size() >= 1:
387 source_width = max(1, int(piece_size[0]))
388 else:
389 for piece_row in piece_rows:
390 source_width = max(source_width, str(piece_row).length())
391 for local_y in range(piece_height):
392 var source_y:int = clampi(floori((float(local_y) + 0.5) / float(piece_height) * float(source_height)), 0, source_height - 1)
393 var source_row:String = str(piece_rows[source_y]) if source_y < piece_rows.size() else ""
394 while source_row.length() < source_width:
395 source_row += " "
396 var target_y:int = y_offset + local_y
397 if target_y < 0 or target_y >= rows.size():
398 continue
399 var target_row:String = rows[target_y]
400 for local_x in range(piece_width):
401 var source_x:int = clampi(floori((float(local_x) + 0.5) / float(piece_width) * float(source_width)), 0, source_width - 1)
402 var target_x:int = x_offset + local_x
403 if target_x < 0 or target_x >= target_row.length():
404 continue
405 target_row = target_row.substr(0, target_x) + source_row.substr(source_x, 1) + target_row.substr(target_x + 1)
406 rows[target_y] = target_row
407
408
409func _string_array(raw) -> Array:
410 var result:Array = []
411 if not raw is Array:
412 return result
413 for value in raw:
414 result.append(str(value))
415 return result
416
417
418func _pad_row(row_text:String, width:int, x_offset:int) -> String:
419 var prefix:String = ""
420 for _index in range(max(0, x_offset)):
421 prefix += " "
422 var row:String = prefix + row_text
423 while row.length() < width:
424 row += " "
425 return row.substr(0, width)
426
427
428func _offset_cell(raw, x_offset:int, y_offset:int) -> Array:
429 if raw is Array and raw.size() >= 2:
430 return [int(raw[0]) + x_offset, int(raw[1]) + y_offset]
431 return [x_offset, y_offset]
432
433
434func _offset_position(raw, x_offset:int, y_offset:int) -> Array:
435 if raw is Array and raw.size() >= 2:
436 return [float(raw[0]) + float(x_offset), float(raw[1]) + float(y_offset)]
437 return [float(x_offset), float(y_offset)]
438
439
440func _scale_piece_cell(raw, source_size:Array, target_width:int, target_height:int) -> Array:
441 if not raw is Array or raw.size() < 2:
442 return [0, 0]
443 var source_width:int = max(1, int(source_size[0]) if source_size.size() >= 1 else target_width)
444 var source_height:int = max(1, int(source_size[1]) if source_size.size() >= 2 else target_height)
445 var x:int = clampi(floori((float(int(raw[0])) + 0.5) / float(source_width) * float(target_width)), 0, target_width - 1)
446 var y:int = clampi(floori((float(int(raw[1])) + 0.5) / float(source_height) * float(target_height)), 0, target_height - 1)
447 return [x, y]
448
449
450func _scale_piece_position(raw, source_size:Array, target_width:int, target_height:int) -> Array:
451 if not raw is Array or raw.size() < 2:
452 return [0.5, 0.5]
453 var source_width:int = max(1, int(source_size[0]) if source_size.size() >= 1 else target_width)
454 var source_height:int = max(1, int(source_size[1]) if source_size.size() >= 2 else target_height)
455 return [
456 (float(raw[0]) + 0.5) / float(source_width) * float(target_width) - 0.5,
457 (float(raw[1]) + 0.5) / float(source_height) * float(target_height) - 0.5
458 ]
459
460
461func _scatter_random_objects(rows:Array[String], rng:RandomNumberGenerator, run_hole:int) -> Array:
462 var result:Array = []
463 var random_objects:Array = piece_generation.get("random_objects", []) if piece_generation.get("random_objects", []) is Array else []
464 if random_objects.is_empty():
465 return result
466 var candidates:Array = []
467 for y in range(rows.size()):
468 var row_text:String = str(rows[y])
469 for x in range(row_text.length()):
470 var symbol:String = row_text.substr(x, 1)
471 var tile:Dictionary = get_tile_definition(symbol)
472 var metadata:Dictionary = tile.get("metadata", {}) if tile.get("metadata", {}) is Dictionary else {}
473 if str(metadata.get("type", "")) in ["solid", "hazard", "void"] or symbol in ["T", "C", "#", " ", "/", "\\", "1", "2"]:
474 continue
475 candidates.append(Vector2i(x, y))
476 for definition in random_objects:
477 if not definition is Dictionary:
478 continue
479 if run_hole < int(definition.get("min_hole", 1)):
480 continue
481 for index in range(max(0, int(definition.get("count", 1)))):
482 if candidates.is_empty():
483 return result
484 var candidate_index:int = rng.randi_range(0, candidates.size() - 1)
485 var cell:Vector2i = candidates[candidate_index]
486 candidates.remove_at(candidate_index)
487 var palette_id:String = str(definition.get("palette_id", definition.get("object_id", definition.get("id", ""))))
488 var object_copy:Dictionary = build_object_from_palette(palette_id, definition.duplicate(true))
489 object_copy["id"] = "%s_%d" % [str(definition.get("id", "random_object")), result.size()]
490 object_copy["position"] = [
491 float(cell.x) + rng.randf_range(0.25, 0.75),
492 float(cell.y) + rng.randf_range(0.25, 0.75)
493 ]
494 object_copy["placement"] = "random_green"
495 object_copy.erase("count")
496 object_copy.erase("min_hole")
497 object_copy.erase("palette_id")
498 object_copy.erase("object_id")
499 result.append(object_copy)
500 return result
501
502
503func _validate_piece_generation_rules(issues:Array) -> void:
504 if piece_generation.is_empty():
505 return
506 var role_rows:Array = piece_generation.get("normal_roles", []) if piece_generation.get("normal_roles", []) is Array else []
507 var grid_size:Array = piece_generation.get("normal_grid_size", [3, 3]) if piece_generation.get("normal_grid_size", []) is Array else [3, 3]
508 var grid_width:int = max(1, int(grid_size[0]) if grid_size.size() >= 1 else 3)
509 var grid_height:int = max(1, int(grid_size[1]) if grid_size.size() >= 2 else 3)
510 if role_rows.size() != grid_height:
511 issues.append(_issue("role_grid_height_mismatch", "Putt-Putt normal_roles height does not match normal_grid_size."))
512 for y in range(grid_height):
513 var row:Array = role_rows[y] if y < role_rows.size() and role_rows[y] is Array else []
514 if row.size() != grid_width:
515 issues.append(_issue("role_grid_width_mismatch", "Putt-Putt normal_roles row %d width does not match normal_grid_size." % y))
516 for x in range(grid_width):
517 var role:String = _role_at(role_rows, x, y)
518 var required_connections:Array = _required_connections_for_slot(role_rows, x, y)
519 if _candidate_count_for_role(role, required_connections) == 0:
520 issues.append(_issue("missing_piece_candidate", "No enabled Putt-Putt piece can fill %s slot %d,%d with required connections %s." % [role, x, y, str(required_connections)]))
521
522
523func _candidate_count_for_role(role:String, required_connections:Array) -> int:
524 var count:int = 0
525 for piece in pieces:
526 if not piece is Dictionary:
527 continue
528 if bool(piece.get("disabled", false)):
529 continue
530 if str(piece.get("role", "green")) != role:
531 continue
532 if _piece_has_connections(piece, required_connections):
533 count += 1
534 return count
535
536
537func _piece_width_from_rows(rows:Array) -> int:
538 var width:int = 0
539 for row in rows:
540 width = max(width, str(row).length())
541 return width
542
543
544func _issue(issue_type:String, message:String, piece_id:String = "") -> Dictionary:
545 var issue:Dictionary = {
546 "type": issue_type,
547 "message": message
548 }
549 if piece_id != "":
550 issue["piece_id"] = piece_id
551 return issue
552
553
554func _load_dictionary(path:String) -> Dictionary:
555 if not FileAccess.file_exists(path):
556 return {}
557 var file := FileAccess.open(path, FileAccess.READ)
558 if file == null:
559 return {}
560 var parsed = JSON.parse_string(file.get_as_text())
561 return parsed if parsed is Dictionary else {}