RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
stats_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
10
11const GLOBAL_ACHIEVEMENT_PATH := "user://global_achievements.cfg"
12const DataCacheManager = preload("res://scripts/systems/dataCacheManager/data_cache_manager.gd")
13
14var counters := {}
15var records := {}
16var flags := {}
17var derived_stats := {}
18var run_summaries := []
19var achievements := {}
20var achievement_unlock_times := {}
21
22var data_cache_manager = DataCacheManager.new()
23
24
25
27func load_achievements(file_path:String, game_id:String = "") -> void:
28 load_achievement_data(data_cache_manager.load_json(file_path), game_id)
29
30
31
32func load_achievement_data(data, game_id:String = "") -> void:
33 if not data is Dictionary:
34 return
35 for achievement in data.get("achievements", []):
36 if not achievement is Dictionary:
37 continue
38 var id = str(achievement.get("id", ""))
39 if id == "":
40 continue
41 var definition = achievement.duplicate(true)
42 definition.erase("id")
43 if definition.has("conditions") and definition["conditions"] is Dictionary:
44 for key in definition["conditions"].keys():
45 if not definition.has(key):
46 definition[key] = definition["conditions"][key]
47 definition.erase("conditions")
48 if game_id != "" and not definition.has("game_id"):
49 definition["game_id"] = game_id
50 register_achievement(id, definition)
51
52
53
54func set_counter(id:String, value:int) -> void:
55 counters[id] = value
56
57
58
59func increment(id:String, amount:int = 1) -> int:
60 var value = int(counters.get(id, 0)) + amount
61 counters[id] = value
62 return value
63
64
65
71func record_events(events:Array, evaluate:bool = true) -> Array:
72 for event in events:
73 if not event is Dictionary:
74 continue
75 var id := str(event.get("id", ""))
76 if id == "":
77 continue
78 match str(event.get("type", "counter")):
79 "counter":
80 increment(id, int(event.get("amount", 1)))
81 "set_counter":
82 set_counter(id, int(event.get("value", 0)))
83 "record_high":
84 record_high(id, int(event.get("value", 0)))
85 "record_low":
86 record_low(id, int(event.get("value", 0)))
87 "flag":
88 set_flag(id, bool(event.get("value", true)))
89 return evaluate_achievements() if evaluate else []
90
91
92
93func get_counter(id:String, default_value:int = 0) -> int:
94 return int(counters.get(id, default_value))
95
96
97
98func record_high(id:String, value:int) -> bool:
99 if value <= int(records.get(id, value - 1)):
100 return false
101 records[id] = value
102 return true
103
104
105
106func record_low(id:String, value:int) -> bool:
107 if records.has(id) and value >= int(records[id]):
108 return false
109 records[id] = value
110 return true
111
112
113
114func get_record(id:String, default_value:int = 0) -> int:
115 return int(records.get(id, default_value))
116
117
118
119func set_flag(id:String, value:bool = true) -> void:
120 flags[id] = value
121 if id.begins_with("achievement_"):
122 var achievement_id = id.trim_prefix("achievement_")
123 if value:
124 if not achievement_unlock_times.has(achievement_id):
125 achievement_unlock_times[achievement_id] = Time.get_unix_time_from_system()
126 else:
127 achievement_unlock_times.erase(achievement_id)
128
129
130
131func get_flag(id:String, default_value:bool = false) -> bool:
132 return bool(flags.get(id, default_value))
133
134
135
137func register_derived_stat(id:String, definition:Dictionary) -> void:
138 derived_stats[id] = definition.duplicate(true)
139
140
141
142func get_derived_stat(id:String, default_value = 0):
143 if not derived_stats.has(id):
144 return default_value
145 var definition = derived_stats[id]
146 var op = str(definition.get("op", "sum"))
147 var fields = definition.get("fields", [])
148 var values = []
149 for field in fields:
150 values.append(_get_numeric_stat(str(field)))
151 match op:
152 "sum":
153 var total = 0
154 for value in values:
155 total += value
156 return total
157 "difference":
158 return (values[0] if values.size() > 0 else 0) - (values[1] if values.size() > 1 else 0)
159 "ratio":
160 var denominator = values[1] if values.size() > 1 else 0
161 return default_value if denominator == 0 else float(values[0]) / float(denominator)
162 "percent":
163 var denominator = values[1] if values.size() > 1 else 0
164 return default_value if denominator == 0 else (float(values[0]) / float(denominator)) * 100.0
165 "max":
166 return values.max() if not values.is_empty() else default_value
167 "min":
168 return values.min() if not values.is_empty() else default_value
169 return default_value
170
171
172
173func add_run_summary(summary:Dictionary, max_entries:int = 20) -> void:
174 var entry = summary.duplicate(true)
175 if not entry.has("ended_at"):
176 entry["ended_at"] = Time.get_unix_time_from_system()
177 run_summaries.append(entry)
178 while run_summaries.size() > max_entries:
179 run_summaries.pop_front()
180
181
182
183func get_run_summaries() -> Array:
184 return run_summaries.duplicate(true)
185
186
187
189func register_achievement(id:String, definition:Dictionary) -> void:
190 achievements[id] = definition.duplicate(true)
191
192
193
194func get_achievements() -> Dictionary:
195 return achievements.duplicate(true)
196
197
198
202func get_achievement_collection(filters:Dictionary = {}) -> Array:
203 var collection = []
204 for id in achievements.keys():
205 var achievement = achievements[id].duplicate(true)
206 achievement["id"] = id
207 achievement["unlocked"] = get_flag("achievement_%s" % id)
208 if achievement_unlock_times.has(id):
209 achievement["unlocked_at"] = achievement_unlock_times[id]
210 if not _achievement_matches_filters(achievement, filters):
211 continue
212 collection.append(achievement)
213 return collection
214
215
216
217func load_global_achievement_state(path:String = GLOBAL_ACHIEVEMENT_PATH) -> void:
218 var file = ConfigFile.new()
219 if file.load(path) != OK:
220 return
221 var global_flags = file.get_value("achievements", "flags", {})
222 var global_times = file.get_value("achievements", "unlock_times", {})
223 merge_achievement_state({"flags": global_flags, "achievement_unlock_times": global_times})
224
225
226
227func save_global_achievement_state(path:String = GLOBAL_ACHIEVEMENT_PATH) -> void:
228 var file = ConfigFile.new()
229 file.load(path)
230 var global_flags = file.get_value("achievements", "flags", {})
231 var global_times = file.get_value("achievements", "unlock_times", {})
232 if not global_flags is Dictionary:
233 global_flags = {}
234 if not global_times is Dictionary:
235 global_times = {}
236 for flag_id in flags.keys():
237 if not str(flag_id).begins_with("achievement_") or not bool(flags[flag_id]):
238 continue
239 global_flags[str(flag_id)] = true
240 var achievement_id = str(flag_id).trim_prefix("achievement_")
241 if achievement_unlock_times.has(achievement_id):
242 global_times[achievement_id] = achievement_unlock_times[achievement_id]
243 elif not global_times.has(achievement_id):
244 global_times[achievement_id] = Time.get_unix_time_from_system()
245 file.set_value("achievements", "flags", global_flags)
246 file.set_value("achievements", "unlock_times", global_times)
247 file.save(path)
248
249
250
251func merge_achievement_state(state:Dictionary) -> void:
252 var source_flags = state.get("flags", {})
253 if source_flags is Dictionary:
254 for flag_id in source_flags.keys():
255 if str(flag_id).begins_with("achievement_") and bool(source_flags[flag_id]):
256 flags[str(flag_id)] = true
257 var source_times = state.get("achievement_unlock_times", {})
258 if source_times is Dictionary:
259 for achievement_id in source_times.keys():
260 if int(source_times[achievement_id]) > 0:
261 achievement_unlock_times[str(achievement_id)] = source_times[achievement_id]
262
263
264
265func get_achievement_filter_options() -> Dictionary:
266 var game_ids := {"all": true}
267 var categories := {"all": true}
268 for achievement in achievements.values():
269 if not achievement is Dictionary:
270 continue
271 game_ids[str(achievement.get("game_id", "all"))] = true
272 categories[str(achievement.get("category", "general"))] = true
273 return {
274 "game_ids": game_ids.keys(),
275 "categories": categories.keys(),
276 "states": ["all", "locked", "unlocked"]
277 }
278
279
280
281func get_achievement_summary() -> Dictionary:
282 var by_game := {}
283 var by_category := {}
284 for achievement in achievements.values():
285 if not achievement is Dictionary:
286 continue
287 var game_id := str(achievement.get("game_id", "all"))
288 var category := str(achievement.get("category", "general"))
289 by_game[game_id] = int(by_game.get(game_id, 0)) + 1
290 by_category[category] = int(by_category.get(category, 0)) + 1
291 return {
292 "total": achievements.size(),
293 "by_game": by_game,
294 "by_category": by_category
295 }
296
297
298
299func get_cache_stats() -> Dictionary:
300 return data_cache_manager.get_cache_stats()
301
302
303
304func evaluate_achievements() -> Array:
305 var unlocked = []
306 for id in achievements.keys():
307 var flag_id = "achievement_%s" % id
308 if get_flag(flag_id):
309 continue
310 if _achievement_condition_met(achievements[id]):
311 set_flag(flag_id, true)
312 unlocked.append(id)
313 if not unlocked.is_empty():
314 save_global_achievement_state()
315 return unlocked
316
317
318
319func get_state() -> Dictionary:
320 return {
321 "counters": counters.duplicate(true),
322 "records": records.duplicate(true),
323 "flags": flags.duplicate(true),
324 "derived_stats": derived_stats.duplicate(true),
325 "run_summaries": run_summaries.duplicate(true),
326 "achievements": achievements.duplicate(true),
327 "achievement_unlock_times": achievement_unlock_times.duplicate(true)
328 }
329
330
331
332func apply_state(state:Dictionary) -> void:
333 counters = state.get("counters", {}).duplicate(true)
334 records = state.get("records", {}).duplicate(true)
335 flags = state.get("flags", {}).duplicate(true)
336 derived_stats = state.get("derived_stats", {}).duplicate(true)
337 run_summaries = state.get("run_summaries", []).duplicate(true)
338 achievement_unlock_times = state.get("achievement_unlock_times", {}).duplicate(true)
339 var registered_achievements = achievements.duplicate(true)
340 var saved_achievements = state.get("achievements", {})
341 if registered_achievements.is_empty() and saved_achievements is Dictionary:
342 achievements = saved_achievements.duplicate(true)
343 elif saved_achievements is Dictionary:
344 for id in saved_achievements.keys():
345 if not achievements.has(id):
346 achievements[id] = saved_achievements[id]
347
348
349
350func reset() -> void:
351 counters.clear()
352 records.clear()
353 flags.clear()
354 derived_stats.clear()
355 run_summaries.clear()
356 achievements.clear()
357 achievement_unlock_times.clear()
358
359
360func _get_numeric_stat(id:String) -> int:
361 if counters.has(id):
362 return int(counters[id])
363 if records.has(id):
364 return int(records[id])
365 return 0
366
367
368func _achievement_condition_met(definition:Dictionary) -> bool:
369 for id in definition.get("counter_at_least", {}).keys():
370 if get_counter(str(id)) < int(definition["counter_at_least"][id]):
371 return false
372 for id in definition.get("record_at_least", {}).keys():
373 if get_record(str(id)) < int(definition["record_at_least"][id]):
374 return false
375 for id in definition.get("record_at_most", {}).keys():
376 if get_record(str(id), 999999999) > int(definition["record_at_most"][id]):
377 return false
378 for id in definition.get("flag", []):
379 if not get_flag(str(id)):
380 return false
381 return true
382
383
384func _achievement_matches_filters(achievement:Dictionary, filters:Dictionary) -> bool:
385 if filters.has("game_id") and not _filter_value_matches(achievement.get("game_id", ""), filters["game_id"]):
386 return false
387 if filters.has("category") and not _filter_value_matches(achievement.get("category", ""), filters["category"]):
388 return false
389 if filters.has("locked") and bool(filters["locked"]) == bool(achievement.get("unlocked", false)):
390 return false
391 if filters.has("unlocked") and bool(filters["unlocked"]) != bool(achievement.get("unlocked", false)):
392 return false
393 if filters.has("state"):
394 var state = str(filters["state"])
395 if state == "all":
396 return true
397 if state == "locked" and bool(achievement.get("unlocked", false)):
398 return false
399 if state == "unlocked" and not bool(achievement.get("unlocked", false)):
400 return false
401 return true
402
403
404func _filter_value_matches(value, filter_value) -> bool:
405 if filter_value is Array:
406 for accepted in filter_value:
407 if str(value) == str(accepted):
408 return true
409 return false
410 return str(value) == str(filter_value)
int
Increment a numeric counter and return the new value.
bool
Apply a batch of stat events and return newly unlocked achievements.