7const DEFAULT_MAX_HEALTH := 12
8const DEFAULT_RUN_MODE_ID :=
"tutorial_3"
10var run_modes:Array = []
13var artifacts:Array = []
14var consequences:Array = []
18func load_catalogs(options:Dictionary = {}) -> Dictionary:
19 run_modes = _load_array(str(options.get(
"run_modes",
"res://data/games/putt_putt_roguelike/run_modes.json")),
"run_modes")
20 golfers = _load_array(str(options.get(
"golfers",
"res://data/games/putt_putt_roguelike/golfers.json")),
"golfers")
21 clubs = _load_array(str(options.get(
"clubs",
"res://data/games/putt_putt_roguelike/clubs.json")),
"clubs")
22 artifacts = _load_array(str(options.get(
"artifacts",
"res://data/games/putt_putt_roguelike/artifacts.json")),
"artifacts")
23 consequences = _load_array(str(options.get(
"consequences",
"res://data/games/putt_putt_roguelike/consequences.json")),
"consequences")
24 holes = _load_array(str(options.get(
"holes",
"res://data/games/putt_putt_roguelike/holes.json")),
"holes")
26 "run_modes": run_modes.size(),
27 "golfers": golfers.size(),
28 "clubs": clubs.size(),
29 "artifacts": artifacts.size(),
30 "consequences": consequences.size(),
35func create_run(options:Dictionary = {}) -> Dictionary:
36 var golfer_id:String = str(options.get(
"golfer_id", _first_id(golfers,
"balanced_rookie")))
37 var club_id:String = str(options.get(
"club_id", _first_id(clubs,
"standard_putter")))
38 var unlocked:Dictionary = _normalized_unlocks(options.get(
"unlocked", {}), golfer_id, club_id)
39 var run_mode_id:String = str(options.get(
"run_mode_id", DEFAULT_RUN_MODE_ID))
40 if not _id_in_rows(get_unlocked_run_modes({
"unlocked": unlocked}), run_mode_id):
41 run_mode_id = DEFAULT_RUN_MODE_ID
42 var run_mode:Dictionary = get_run_mode(run_mode_id)
43 var golfer:Dictionary = get_golfer(golfer_id)
44 var stats:Dictionary = golfer.get(
"stats", {}).duplicate(true)
if golfer.get(
"stats", {})
is Dictionary
else {}
45 var max_health:int =
int(stats.get(
"max_health", DEFAULT_MAX_HEALTH))
46 var seed:int =
int(options.get(
"seed", Time.get_unix_time_from_system()))
47 var hole_count:int =
int(options.get(
"hole_count",
int(run_mode.get(
"hole_count", 3))))
48 var sequence:Array = build_hole_sequence(seed, hole_count, run_mode)
51 "game_id":
"putt_putt_roguelike",
54 "run_mode_id": run_mode_id,
55 "run_mode_name": str(run_mode.get(
"name", run_mode_id)),
56 "golfer_id": golfer_id,
59 "health":
int(options.get(
"health", max_health)),
60 "max_health": max_health,
62 "hole_sequence": sequence,
75func build_hole_sequence(seed:int, count:int, run_mode:Dictionary = {}) -> Array:
76 var rng := RandomNumberGenerator.new()
78 var candidates:Array = holes.duplicate(true)
79 candidates.sort_custom(func(a, b):
return int(a.get(
"difficulty", 1)) <
int(b.get(
"difficulty", 1)))
80 var sequence:Array = []
81 for index
in range(count):
82 var max_difficulty:int = clampi(index + 1, 1, 99)
84 for hole
in candidates:
85 if int(hole.get(
"difficulty", 1)) <= max_difficulty:
91 var chosen:Dictionary = pool[rng.randi_range(0, pool.size() - 1)].duplicate(true)
92 chosen[
"run_hole"] = index + 1
93 var boss_stage:Dictionary = _boss_stage_for_hole(run_mode, index + 1, rng)
94 if not boss_stage.is_empty():
95 chosen[
"encounter_type"] = str(boss_stage.get(
"type",
"mini_boss"))
96 chosen[
"boss_stage"] = boss_stage
97 sequence.append(chosen)
101func get_current_hole(run_state:Dictionary) -> Dictionary:
102 var sequence:Array = run_state.get(
"hole_sequence", [])
if run_state.get(
"hole_sequence", [])
is Array
else []
103 var index:int =
int(run_state.get(
"hole_index", 0))
104 if index < 0
or index >= sequence.size():
106 return sequence[index].duplicate(true)
if sequence[index]
is Dictionary
else {}
109func get_golfer(golfer_id:String) -> Dictionary:
110 return _find_by_id(golfers, golfer_id)
113func get_club(club_id:String) -> Dictionary:
114 return _find_by_id(clubs, club_id)
117func get_run_mode(run_mode_id:String) -> Dictionary:
118 return _find_by_id(run_modes, run_mode_id)
121func get_artifact(artifact_id:String) -> Dictionary:
122 return _find_by_id(artifacts, artifact_id)
125func get_unlocked_golfers(run_state:Dictionary = {}) -> Array:
126 return _unlocked_rows(golfers, run_state,
"golfers")
129func get_unlocked_clubs(run_state:Dictionary = {}) -> Array:
130 return _unlocked_rows(clubs, run_state,
"clubs")
133func get_unlocked_run_modes(run_state:Dictionary = {}) -> Array:
134 return _unlocked_rows(run_modes, run_state,
"run_modes")
137func begin_current_encounter(run_state:Dictionary) -> Dictionary:
138 var next:Dictionary = run_state.duplicate(true)
139 var hole:Dictionary = get_current_hole(next)
140 var encounter_type:String = str(hole.get(
"encounter_type",
"normal"))
141 if encounter_type ==
"normal":
142 next[
"encounter"] = {}
144 var current:Dictionary = next.get(
"encounter", {})
if next.get(
"encounter", {})
is Dictionary
else {}
145 if int(current.get(
"hole_index", -1)) ==
int(next.get(
"hole_index", 0))
and not current.is_empty():
147 var boss_stage:Dictionary = hole.get(
"boss_stage", {})
if hole.get(
"boss_stage", {})
is Dictionary
else {}
148 next[
"encounter"] = {
149 "hole_index":
int(next.get(
"hole_index", 0)),
150 "run_hole":
int(hole.get(
"run_hole",
int(next.get(
"hole_index", 0)) + 1)),
151 "type": encounter_type,
152 "phase":
"exploration",
153 "exploration": str(boss_stage.get(
"exploration",
"fog_room")),
154 "minigame": str(boss_stage.get(
"selected_minigame", boss_stage.get(
"script",
"boss_challenge"))),
157 next[
"last_result"] = {
158 "type":
"encounter_started",
159 "message": encounter_prompt(next)
164func advance_current_encounter(run_state:Dictionary) -> Dictionary:
165 var next:Dictionary = begin_current_encounter(run_state)
166 var encounter:Dictionary = next.get(
"encounter", {})
if next.get(
"encounter", {})
is Dictionary
else {}
167 if encounter.is_empty()
or bool(encounter.get(
"complete", false)):
169 match str(encounter.get(
"phase",
"exploration")):
171 encounter[
"phase"] =
"boss" if str(encounter.get(
"type",
"")) ==
"final_boss" else "minigame"
172 next[
"last_result"] = {
173 "type":
"encounter_phase",
174 "message": encounter_prompt({
"encounter": encounter})
177 encounter[
"phase"] =
"complete"
178 encounter[
"complete"] = true
179 next[
"last_result"] = {
180 "type":
"encounter_complete",
181 "message":
"Boss challenge cleared. The cup accepts your paperwork."
183 next[
"encounter"] = encounter
187func encounter_blocks_putting(run_state:Dictionary) -> bool:
188 var encounter:Dictionary = run_state.get(
"encounter", {})
if run_state.get(
"encounter", {})
is Dictionary
else {}
189 return not encounter.is_empty()
and not bool(encounter.get(
"complete", false))
192func encounter_prompt(run_state:Dictionary) -> String:
193 var encounter:Dictionary = run_state.get(
"encounter", {})
if run_state.get(
"encounter", {})
is Dictionary
else {}
194 if encounter.is_empty():
196 var run_hole:int =
int(encounter.get(
"run_hole", 0))
197 match str(encounter.get(
"phase",
"exploration")):
199 return "Hole %d boss approach: explore the fogged room before the ball challenge." % run_hole
201 return "Mini-boss challenge: %s. Placeholder for the ball minigame." % str(encounter.get(
"minigame",
"ball challenge"))
203 return "Final boss: %s. Placeholder for the scripted boss fight." % str(encounter.get(
"minigame",
"final boss"))
205 return "Boss encounter cleared. Finish the run flow."
210func apply_putt(run_state:Dictionary, options:Dictionary = {}) -> Dictionary:
211 var next:Dictionary = run_state.duplicate(true)
212 if encounter_blocks_putting(next):
213 next[
"last_result"] = {
"type":
"encounter_blocked",
"message": encounter_prompt(next)}
215 var free_first:bool = has_artifact(next,
"golden_club")
and int(next.get(
"hole_putts", 0)) == 0
216 next[
"total_putts"] =
int(next.get(
"total_putts", 0)) + 1
217 next[
"hole_putts"] =
int(next.get(
"hole_putts", 0)) + 1
219 next[
"health"] =
int(next.get(
"health", 0)) -
int(options.get(
"health_cost", 1))
220 if int(next.get(
"health", 0)) <= 0:
221 next[
"status"] =
"failed"
222 next[
"last_result"] = {
224 "message":
"The round ends one putt short of glory."
229func complete_hole(run_state:Dictionary) -> Dictionary:
230 var next:Dictionary = run_state.duplicate(true)
231 var hole:Dictionary = get_current_hole(next)
232 var strokes:int =
int(next.get(
"hole_putts", 0))
233 var par:int =
int(hole.get(
"par", max(1, strokes)))
234 var heal:int =
int(hole.get(
"heal", 3))
235 if has_artifact(next,
"first_aid_kit"):
237 var max_health:int =
int(next.get(
"max_health", DEFAULT_MAX_HEALTH))
238 var health_before:int =
int(next.get(
"health", 0))
239 next[
"health"] = min(max_health, health_before + heal)
240 var health_received:int =
int(next.get(
"health", 0)) - health_before
241 next[
"completed_holes"] =
int(next.get(
"completed_holes", 0)) + 1
242 next[
"furthest_hole"] = max(
int(next.get(
"furthest_hole", 1)),
int(next.get(
"completed_holes", 0)) + 1)
243 next[
"pending_wheel"] = spin_wheel(next)
244 next[
"hole_complete_summary"] = {
248 "health_received": health_received,
249 "health_before": health_before,
250 "health_after":
int(next.get(
"health", 0))
252 next[
"last_result"] = {
253 "type":
"hole_complete",
254 "message":
"Level complete: %d strokes, par %d, +%d health. Choose a wheel reward to continue." % [strokes, par, health_received]
256 var sequence:Array = next.get(
"hole_sequence", [])
if next.get(
"hole_sequence", [])
is Array
else []
257 if int(next.get(
"completed_holes", 0)) >= sequence.size():
258 next[
"status"] =
"won"
259 next[
"last_result"] = {
261 "message":
"Run complete in %d putts." %
int(next.get(
"total_putts", 0))
266func advance_to_next_hole(run_state:Dictionary) -> Dictionary:
267 var next:Dictionary = run_state.duplicate(true)
268 if str(next.get(
"status",
"active")) !=
"active":
270 next[
"hole_index"] =
int(next.get(
"hole_index", 0)) + 1
271 next[
"hole_putts"] = 0
272 next[
"encounter"] = {}
273 next[
"pending_wheel"] = {}
277func spin_wheel(run_state:Dictionary) -> Dictionary:
278 var luck:int =
int(_stats(run_state).get(
"luck", 0))
279 if has_artifact(run_state,
"lucky_horseshoe"):
281 var good_chance:float = clampf(0.5 +
float(luck) * 0.08, 0.2, 0.85)
282 var roll:float = randf()
284 "good_chance": good_chance,
286 "result":
"good" if roll <= good_chance
else "bad"
290func apply_good_choice(run_state:Dictionary, choice:String) -> Dictionary:
291 var next:Dictionary = run_state.duplicate(true)
292 if choice ==
"health":
293 next[
"health"] = min(
int(next.get(
"max_health", DEFAULT_MAX_HEALTH)),
int(next.get(
"health", 0)) + 3)
294 next[
"last_result"] = {
"type":
"wheel_good",
"message":
"Good spin: restored health."}
296 var artifact:Dictionary = draw_artifact(next)
297 if not artifact.is_empty():
298 var ids:Array = next.get(
"artifacts", [])
if next.get(
"artifacts", [])
is Array
else []
299 ids.append(str(artifact.get(
"id",
"")))
300 next[
"artifacts"] = ids
301 next[
"last_result"] = {
"type":
"wheel_good",
"message":
"Good spin: gained %s." % str(artifact.get(
"name",
"an artifact"))}
302 next[
"pending_wheel"] = {}
303 return advance_to_next_hole(next)
306func apply_bad_result(run_state:Dictionary) -> Dictionary:
307 var next:Dictionary = run_state.duplicate(true)
308 var consequence:Dictionary = draw_consequence()
309 var effect:Dictionary = consequence.get(
"effect", {})
if consequence.get(
"effect", {})
is Dictionary
else {}
310 match str(effect.get(
"type",
"")):
312 next[
"health"] =
int(next.get(
"health", 0)) -
int(effect.get(
"amount", 1))
314 next[
"max_health"] = max(1,
int(next.get(
"max_health", DEFAULT_MAX_HEALTH)) +
int(effect.get(
"amount", -1)))
315 next[
"health"] = min(
int(next.get(
"health", 0)),
int(next.get(
"max_health", DEFAULT_MAX_HEALTH)))
317 var stats:Dictionary = _stats(next)
318 stats[
"luck"] =
int(stats.get(
"luck", 0)) +
int(effect.get(
"amount", -1))
319 next[
"stats"] = stats
320 if int(next.get(
"health", 0)) <= 0:
321 next[
"status"] =
"failed"
322 next[
"last_result"] = {
"type":
"wheel_bad",
"message":
"Bad spin: %s" % str(consequence.get(
"name",
"trouble"))}
323 next[
"pending_wheel"] = {}
324 if str(next.get(
"status",
"")) ==
"active":
325 next = advance_to_next_hole(next)
329func draw_artifact(run_state:Dictionary) -> Dictionary:
330 var owned:Array = run_state.get(
"artifacts", [])
if run_state.get(
"artifacts", [])
is Array
else []
332 for artifact
in artifacts:
333 if artifact
is Dictionary
and not owned.has(str(artifact.get(
"id",
""))):
334 pool.append(artifact)
339 return pool[randi() % pool.size()].duplicate(true)
342func draw_consequence() -> Dictionary:
343 if consequences.is_empty():
345 return consequences[randi() % consequences.size()].duplicate(true)
348func unlocks_for_run_win(run_state:Dictionary) -> Array:
349 var run_mode:Dictionary = get_run_mode(str(run_state.get(
"run_mode_id", DEFAULT_RUN_MODE_ID)))
350 var ids:Array = run_mode.get(
"unlocks_on_win", [])
if run_mode.get(
"unlocks_on_win", [])
is Array
else []
351 var result:Array = []
353 var mode:Dictionary = get_run_mode(str(id))
354 if not mode.is_empty():
359func has_artifact(run_state:Dictionary, artifact_id:String) -> bool:
360 var ids:Array = run_state.get(
"artifacts", [])
if run_state.get(
"artifacts", [])
is Array
else []
361 return ids.has(artifact_id)
364func get_summary(run_state:Dictionary) -> Dictionary:
366 "status": str(run_state.get(
"status",
"active")),
367 "run_mode_id": str(run_state.get(
"run_mode_id", DEFAULT_RUN_MODE_ID)),
368 "run_mode_name": str(run_state.get(
"run_mode_name",
"")),
369 "hole":
int(run_state.get(
"hole_index", 0)) + 1,
370 "completed_holes":
int(run_state.get(
"completed_holes", 0)),
371 "furthest_hole":
int(run_state.get(
"furthest_hole", 1)),
372 "health":
int(run_state.get(
"health", 0)),
373 "max_health":
int(run_state.get(
"max_health", DEFAULT_MAX_HEALTH)),
374 "putts":
int(run_state.get(
"total_putts", 0)),
375 "artifacts": run_state.get(
"artifacts", [])
if run_state.get(
"artifacts", [])
is Array
else []
379func validate_run_mode_minigame_refs(known_minigame_ids:Array) -> Array:
380 var known:Dictionary = {}
381 for id
in known_minigame_ids:
382 known[str(id)] = true
383 var missing:Array = []
384 for mode
in run_modes:
385 if not mode
is Dictionary:
387 var mode_id:String = str(mode.get(
"id",
""))
388 var stages:Array = mode.get(
"boss_stages", [])
if mode.get(
"boss_stages", [])
is Array
else []
390 if not stage
is Dictionary:
392 var hole:int =
int(stage.get(
"hole", 0))
393 var minigames:Array = stage.get(
"minigame_pool", [])
if stage.get(
"minigame_pool", [])
is Array
else []
394 for minigame_id
in minigames:
395 var id:String = str(minigame_id)
396 if id !=
"" and not known.has(id):
397 missing.append({
"run_mode": mode_id,
"hole": hole,
"minigame": id})
398 var script_id:String = str(stage.get(
"script",
""))
399 if script_id !=
"" and not known.has(script_id):
400 missing.append({
"run_mode": mode_id,
"hole": hole,
"minigame": script_id})
404func _load_array(path:String, key:String) -> Array:
405 if not FileAccess.file_exists(path):
407 var file := FileAccess.open(path, FileAccess.READ)
410 var parsed = JSON.parse_string(file.get_as_text())
411 if parsed
is Dictionary
and parsed.get(key, [])
is Array:
412 return parsed.get(key, [])
418func _find_by_id(rows:Array, id:String) -> Dictionary:
420 if row
is Dictionary
and str(row.get(
"id",
"")) == id:
421 return row.duplicate(true)
425func _id_in_rows(rows:Array, id:String) -> bool:
427 if row
is Dictionary
and str(row.get(
"id",
"")) == id:
432func _first_id(rows:Array, fallback:String) -> String:
434 if row
is Dictionary
and str(row.get(
"id",
"")) !=
"":
435 return str(row.get(
"id",
""))
439func _stats(run_state:Dictionary) -> Dictionary:
440 return run_state.get(
"stats", {}).duplicate(true)
if run_state.get(
"stats", {})
is Dictionary
else {}
443func _unlocked_rows(rows:Array, run_state:Dictionary, key:String) -> Array:
444 var unlocked:Dictionary = run_state.get(
"unlocked", {})
if run_state.get(
"unlocked", {})
is Dictionary
else {}
445 var ids:Array = unlocked.get(key, [])
if unlocked.get(key, [])
is Array
else []
446 var result:Array = []
448 if not row
is Dictionary:
450 var row_id:String = str(row.get(
"id",
""))
451 if bool(row.get(
"unlocked", false))
or ids.has(row_id):
452 result.append(row.duplicate(true))
456func _normalized_unlocks(raw, golfer_id:String, club_id:String) -> Dictionary:
457 var unlocked:Dictionary = raw.duplicate(true)
if raw
is Dictionary
else {}
458 var run_mode_ids:Array = unlocked.get(
"run_modes", [])
if unlocked.get(
"run_modes", [])
is Array
else []
459 var golfer_ids:Array = unlocked.get(
"golfers", [])
if unlocked.get(
"golfers", [])
is Array
else []
460 var club_ids:Array = unlocked.get(
"clubs", [])
if unlocked.get(
"clubs", [])
is Array
else []
461 var artifact_ids:Array = unlocked.get(
"artifacts", [])
if unlocked.get(
"artifacts", [])
is Array
else []
462 for mode
in run_modes:
463 if mode
is Dictionary
and bool(mode.get(
"unlocked_by_default", false))
and not run_mode_ids.has(str(mode.get(
"id",
""))):
464 run_mode_ids.append(str(mode.get(
"id",
"")))
465 if not golfer_ids.has(golfer_id):
466 golfer_ids.append(golfer_id)
467 if not club_ids.has(club_id):
468 club_ids.append(club_id)
470 "run_modes": run_mode_ids,
471 "golfers": golfer_ids,
473 "artifacts": artifact_ids
477func _boss_stage_for_hole(run_mode:Dictionary, run_hole:int, rng:RandomNumberGenerator) -> Dictionary:
478 var stages:Array = run_mode.get(
"boss_stages", [])
if run_mode.get(
"boss_stages", [])
is Array
else []
480 if not stage
is Dictionary
or int(stage.get(
"hole", 0)) != run_hole:
482 var next:Dictionary = stage.duplicate(true)
483 var minigames:Array = next.get(
"minigame_pool", [])
if next.get(
"minigame_pool", [])
is Array
else []
484 if not minigames.is_empty():
485 next[
"selected_minigame"] = str(minigames[rng.randi_range(0, minigames.size() - 1)])