RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
putt_putt_run_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
6
7const DEFAULT_MAX_HEALTH := 12
8const DEFAULT_RUN_MODE_ID := "tutorial_3"
9
10var run_modes:Array = []
11var golfers:Array = []
12var clubs:Array = []
13var artifacts:Array = []
14var consequences:Array = []
15var holes:Array = []
16
17
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")
25 return {
26 "run_modes": run_modes.size(),
27 "golfers": golfers.size(),
28 "clubs": clubs.size(),
29 "artifacts": artifacts.size(),
30 "consequences": consequences.size(),
31 "holes": holes.size()
32 }
33
34
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)
49 return {
50 "schema_version": 1,
51 "game_id": "putt_putt_roguelike",
52 "status": "active",
53 "seed": seed,
54 "run_mode_id": run_mode_id,
55 "run_mode_name": str(run_mode.get("name", run_mode_id)),
56 "golfer_id": golfer_id,
57 "club_id": club_id,
58 "stats": stats,
59 "health": int(options.get("health", max_health)),
60 "max_health": max_health,
61 "hole_index": 0,
62 "hole_sequence": sequence,
63 "total_putts": 0,
64 "hole_putts": 0,
65 "furthest_hole": 1,
66 "completed_holes": 0,
67 "artifacts": [],
68 "encounter": {},
69 "pending_wheel": {},
70 "last_result": {},
71 "unlocked": unlocked
72 }
73
74
75func build_hole_sequence(seed:int, count:int, run_mode:Dictionary = {}) -> Array:
76 var rng := RandomNumberGenerator.new()
77 rng.seed = seed
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)
83 var pool:Array = []
84 for hole in candidates:
85 if int(hole.get("difficulty", 1)) <= max_difficulty:
86 pool.append(hole)
87 if pool.is_empty():
88 pool = candidates
89 if pool.is_empty():
90 break
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)
98 return sequence
99
100
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():
105 return {}
106 return sequence[index].duplicate(true) if sequence[index] is Dictionary else {}
107
108
109func get_golfer(golfer_id:String) -> Dictionary:
110 return _find_by_id(golfers, golfer_id)
111
112
113func get_club(club_id:String) -> Dictionary:
114 return _find_by_id(clubs, club_id)
115
116
117func get_run_mode(run_mode_id:String) -> Dictionary:
118 return _find_by_id(run_modes, run_mode_id)
119
120
121func get_artifact(artifact_id:String) -> Dictionary:
122 return _find_by_id(artifacts, artifact_id)
123
124
125func get_unlocked_golfers(run_state:Dictionary = {}) -> Array:
126 return _unlocked_rows(golfers, run_state, "golfers")
127
128
129func get_unlocked_clubs(run_state:Dictionary = {}) -> Array:
130 return _unlocked_rows(clubs, run_state, "clubs")
131
132
133func get_unlocked_run_modes(run_state:Dictionary = {}) -> Array:
134 return _unlocked_rows(run_modes, run_state, "run_modes")
135
136
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"] = {}
143 return next
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():
146 return next
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"))),
155 "complete": false
156 }
157 next["last_result"] = {
158 "type": "encounter_started",
159 "message": encounter_prompt(next)
160 }
161 return next
162
163
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)):
168 return next
169 match str(encounter.get("phase", "exploration")):
170 "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})
175 }
176 _:
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."
182 }
183 next["encounter"] = encounter
184 return next
185
186
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))
190
191
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():
195 return ""
196 var run_hole:int = int(encounter.get("run_hole", 0))
197 match str(encounter.get("phase", "exploration")):
198 "exploration":
199 return "Hole %d boss approach: explore the fogged room before the ball challenge." % run_hole
200 "minigame":
201 return "Mini-boss challenge: %s. Placeholder for the ball minigame." % str(encounter.get("minigame", "ball challenge"))
202 "boss":
203 return "Final boss: %s. Placeholder for the scripted boss fight." % str(encounter.get("minigame", "final boss"))
204 "complete":
205 return "Boss encounter cleared. Finish the run flow."
206 _:
207 return ""
208
209
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)}
214 return 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
218 if not free_first:
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"] = {
223 "type": "failed",
224 "message": "The round ends one putt short of glory."
225 }
226 return next
227
228
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"):
236 heal += 1
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"] = {
245 "strokes": strokes,
246 "par": par,
247 "heal": heal,
248 "health_received": health_received,
249 "health_before": health_before,
250 "health_after": int(next.get("health", 0))
251 }
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]
255 }
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"] = {
260 "type": "run_won",
261 "message": "Run complete in %d putts." % int(next.get("total_putts", 0))
262 }
263 return next
264
265
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":
269 return next
270 next["hole_index"] = int(next.get("hole_index", 0)) + 1
271 next["hole_putts"] = 0
272 next["encounter"] = {}
273 next["pending_wheel"] = {}
274 return next
275
276
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"):
280 luck += 1
281 var good_chance:float = clampf(0.5 + float(luck) * 0.08, 0.2, 0.85)
282 var roll:float = randf()
283 return {
284 "good_chance": good_chance,
285 "roll": roll,
286 "result": "good" if roll <= good_chance else "bad"
287 }
288
289
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."}
295 else:
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)
304
305
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", "")):
311 "damage":
312 next["health"] = int(next.get("health", 0)) - int(effect.get("amount", 1))
313 "max_health":
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)))
316 "luck":
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)
326 return next
327
328
329func draw_artifact(run_state:Dictionary) -> Dictionary:
330 var owned:Array = run_state.get("artifacts", []) if run_state.get("artifacts", []) is Array else []
331 var pool:Array = []
332 for artifact in artifacts:
333 if artifact is Dictionary and not owned.has(str(artifact.get("id", ""))):
334 pool.append(artifact)
335 if pool.is_empty():
336 pool = artifacts
337 if pool.is_empty():
338 return {}
339 return pool[randi() % pool.size()].duplicate(true)
340
341
342func draw_consequence() -> Dictionary:
343 if consequences.is_empty():
344 return {}
345 return consequences[randi() % consequences.size()].duplicate(true)
346
347
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 = []
352 for id in ids:
353 var mode:Dictionary = get_run_mode(str(id))
354 if not mode.is_empty():
355 result.append(mode)
356 return result
357
358
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)
362
363
364func get_summary(run_state:Dictionary) -> Dictionary:
365 return {
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 []
376 }
377
378
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:
386 continue
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 []
389 for stage in stages:
390 if not stage is Dictionary:
391 continue
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})
401 return missing
402
403
404func _load_array(path:String, key:String) -> Array:
405 if not FileAccess.file_exists(path):
406 return []
407 var file := FileAccess.open(path, FileAccess.READ)
408 if file == null:
409 return []
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, [])
413 if parsed is Array:
414 return parsed
415 return []
416
417
418func _find_by_id(rows:Array, id:String) -> Dictionary:
419 for row in rows:
420 if row is Dictionary and str(row.get("id", "")) == id:
421 return row.duplicate(true)
422 return {}
423
424
425func _id_in_rows(rows:Array, id:String) -> bool:
426 for row in rows:
427 if row is Dictionary and str(row.get("id", "")) == id:
428 return true
429 return false
430
431
432func _first_id(rows:Array, fallback:String) -> String:
433 for row in rows:
434 if row is Dictionary and str(row.get("id", "")) != "":
435 return str(row.get("id", ""))
436 return fallback
437
438
439func _stats(run_state:Dictionary) -> Dictionary:
440 return run_state.get("stats", {}).duplicate(true) if run_state.get("stats", {}) is Dictionary else {}
441
442
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 = []
447 for row in rows:
448 if not row is Dictionary:
449 continue
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))
453 return result
454
455
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)
469 return {
470 "run_modes": run_mode_ids,
471 "golfers": golfer_ids,
472 "clubs": club_ids,
473 "artifacts": artifact_ids
474 }
475
476
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 []
479 for stage in stages:
480 if not stage is Dictionary or int(stage.get("hole", 0)) != run_hole:
481 continue
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)])
486 return next
487 return {}