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"
12var piece_generation:Dictionary = {}
13var collision_shapes:Dictionary = {}
14var tile_lookup:Dictionary = {}
15var object_lookup:Dictionary = {}
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)
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)
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)
39 "modules": modules.size(),
40 "pieces": pieces.size(),
41 "collision_shapes": collision_shapes.size(),
42 "tiles": tile_lookup.size(),
43 "objects": object_lookup.size()
47func build_course(options:Dictionary = {}) -> Dictionary:
48 if modules.is_empty():
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)
55func validate_catalog() -> Array:
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"]
63 if not piece
is Dictionary:
64 issues.append(_issue(
"invalid_piece",
"Course piece rows must be dictionaries."))
66 var piece_id:String = str(piece.get(
"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))
93 var object_id:String = str(
Dictionary(object_row).get(
"id",
"object"))
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))
101 var cell:Array =
Dictionary(object_row).get(
"tile", [])
if Dictionary(object_row).get(
"tile", [])
is Array
else []
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)
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()
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)
127 return _compose_vertical(selected, seed)
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 {}
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 {}
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)
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 {}
159func _compose_vertical(selected:Array, seed:int) -> Dictionary:
161 for module
in selected:
162 if module
is Dictionary:
163 var size:Array = module.get(
"size", [])
if module.get(
"size", [])
is Array
else []
165 width = max(width,
int(size[0]))
166 var rows:Array[String] = []
167 var objects:Array = []
170 var module_ranges:Array = []
172 for module
in selected:
173 if not module
is Dictionary:
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))
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",
"")),
197 "height": module_rows.size()
201 "generation_mode":
"vertical_modules",
203 "size": [width, rows.size()],
208 "modules": module_ranges
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()
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)
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):
246 rows.append(row_text)
247 var objects: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 {}
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)
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)
271 tee = _offset_cell(_scale_piece_cell(piece.get(
"tee", [0, 0]), source_size, piece_width, piece_height), x_offset, y_offset)
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],
280 "width": piece_width,
281 "height": piece_height,
282 "connections": _string_array(piece.get(
"connections", []))
284 objects.append_array(_scatter_random_objects(rows, rng, run_hole))
287 "generation_mode":
"grid_pieces",
289 "grid_size": [grid_width, grid_height],
290 "piece_size": [piece_width, piece_height],
291 "size": [width, rows.size()],
296 "modules": module_ranges
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)
307func _modules_with_role(role:String) -> Array:
309 for module
in modules:
310 if module
is Dictionary
and str(module.get(
"role",
"")) == role:
311 found.append(module.duplicate(true))
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:
318 var row:Array = role_rows[y]
319 if x < 0
or x >= row.size():
324func _required_connections_for_slot(role_rows:Array, x:int, y:int) -> Array:
325 var role:String = _role_at(role_rows, x, y)
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)
336func _select_piece(role:String, required_connections:Array, rng:RandomNumberGenerator) -> Dictionary:
337 var candidates:Array = []
339 if not piece
is Dictionary:
341 if bool(piece.get(
"disabled", false)):
343 if str(piece.get(
"role",
"green")) != role:
345 if _piece_has_connections(piece, required_connections):
346 candidates.append(piece.duplicate(true))
347 if candidates.is_empty()
and role !=
"void":
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():
353 if piece
is Dictionary
and not bool(piece.get(
"disabled", false))
and str(piece.get(
"role",
"")) ==
"void":
354 return piece.duplicate(true)
356 return candidates[rng.randi_range(0, candidates.size() - 1)]
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)):
367func _side_offset(side:String) -> Vector2i:
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]))
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:
396 var target_y:int = y_offset + local_y
397 if target_y < 0
or target_y >= rows.size():
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():
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
409func _string_array(raw) -> Array:
410 var result:Array = []
414 result.append(str(value))
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)):
422 var row:String = prefix + row_text
423 while row.length() < width:
425 return row.substr(0, width)
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]
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)]
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:
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)
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:
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)
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
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():
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"]:
476 for definition
in random_objects:
477 if not definition
is Dictionary:
479 if run_hole <
int(definition.get(
"min_hole", 1)):
481 for index
in range(max(0,
int(definition.get(
"count", 1)))):
482 if candidates.is_empty():
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)
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)
503func _validate_piece_generation_rules(issues:Array) -> void:
504 if piece_generation.is_empty():
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)]))
523func _candidate_count_for_role(role:String, required_connections:Array) -> int:
526 if not piece
is Dictionary:
528 if bool(piece.get(
"disabled", false)):
530 if str(piece.get(
"role",
"green")) != role:
532 if _piece_has_connections(piece, required_connections):
537func _piece_width_from_rows(rows:Array) -> int:
540 width = max(width, str(row).length())
544func _issue(issue_type:String, message:String, piece_id:String =
"") -> Dictionary:
545 var issue:Dictionary = {
550 issue[
"piece_id"] = piece_id
554func _load_dictionary(path:String) -> Dictionary:
555 if not FileAccess.file_exists(path):
557 var file := FileAccess.open(path, FileAccess.READ)
560 var parsed = JSON.parse_string(file.get_as_text())
561 return parsed
if parsed
is Dictionary
else {}