RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
settings_manager.gd
Go to the documentation of this file.
1extends Node
2
3
9
10const SETTINGS_PATH := "user://settings.cfg"
11const SETTINGS_SCHEMA_VERSION := 5
12const LEGACY_DEFAULT_MUSIC_VOLUME := 0.7
13const PREVIOUS_DEFAULT_MUSIC_VOLUME := 0.4
14const CURRENT_DEFAULT_MUSIC_VOLUME := 0.2
15const THEME_CATALOG_PATH := "res://data/themes/default.json"
16const CUSTOM_THEME_CATALOG_PATH := "user://custom_themes.json"
17const LOW_POWER_PRESET_PATH := "res://data/system/low_power_presets.json"
18const WINDOW_SIZE_OPTIONS := {
19 "Phone 390 x 844": Vector2i(390, 844),
20 "Small 480 x 800": Vector2i(480, 800),
21 "Square 800 x 800": Vector2i(800, 800),
22 "Desktop 1280 x 720": Vector2i(1280, 720)
23}
24const TEXT_SIZE_OPTIONS := {
25 "Small": "small",
26 "Medium": "medium",
27 "Large": "large"
28}
29const DEFAULT_THEME_OPTIONS := {
30 "Dark": "dark",
31 "High Contrast": "high_contrast"
32}
33const TEXT_SIZE_PRESETS := {
34 "small": {"body": 15, "caption": 13, "button": 16, "title": 25, "list": 15},
35 "medium": {"body": 17, "caption": 15, "button": 18, "title": 31, "list": 17},
36 "large": {"body": 21, "caption": 18, "button": 22, "title": 37, "list": 21}
37}
38
39signal settings_changed(settings:Dictionary)
40
41var settings := {
42 "window_size": Vector2i(800, 800),
43 "fullscreen": false,
44 "master_volume": 0.85,
45 "music_volume": CURRENT_DEFAULT_MUSIC_VOLUME,
46 "sfx_volume": 0.85,
47 "master_muted": false,
48 "music_muted": false,
49 "sfx_muted": false,
50 "theme": "dark",
51 "text_size": "medium",
52 "low_power_mode": false,
53 "input_remaps": {},
54 "input_mode": "touch"
55}
56const INPUT_MODE_OPTIONS := {
57 "Auto": "auto",
58 "Touch / Analog": "touch",
59 "Keyboard / WASD": "keyboard",
60 "Controller": "controller"
61}
62var theme_options := DEFAULT_THEME_OPTIONS.duplicate(true)
63var theme_typography := {}
64var low_power_profiles := {}
65var font_cache := {}
66
67
68func _ready() -> void:
69 load_theme_options()
70 load_low_power_profiles()
71 load_settings()
72 apply_window_settings()
73
74
75
76func load_settings() -> void:
77 var file = ConfigFile.new()
78 if file.load(SETTINGS_PATH) != OK:
79 return
80 settings["window_size"] = file.get_value("display", "window_size", settings["window_size"])
81 settings["fullscreen"] = bool(file.get_value("display", "fullscreen", settings["fullscreen"]))
82 settings["theme"] = str(file.get_value("display", "theme", settings["theme"]))
83 if not theme_options.values().has(settings["theme"]):
84 settings["theme"] = "dark"
85 settings["text_size"] = str(file.get_value("display", "text_size", settings["text_size"]))
86 settings["low_power_mode"] = bool(file.get_value("display", "low_power_mode", settings["low_power_mode"]))
87 settings["master_volume"] = float(file.get_value("audio", "master_volume", settings["master_volume"]))
88 settings["music_volume"] = float(file.get_value("audio", "music_volume", settings["music_volume"]))
89 settings["sfx_volume"] = float(file.get_value("audio", "sfx_volume", settings["sfx_volume"]))
90 settings["master_muted"] = bool(file.get_value("audio", "master_muted", settings["master_muted"]))
91 settings["music_muted"] = bool(file.get_value("audio", "music_muted", settings["music_muted"]))
92 settings["sfx_muted"] = bool(file.get_value("audio", "sfx_muted", settings["sfx_muted"]))
93 settings["input_remaps"] = file.get_value("input", "remaps", settings["input_remaps"])
94 settings["input_mode"] = str(file.get_value("input", "mode", settings["input_mode"]))
95 if not INPUT_MODE_OPTIONS.values().has(settings["input_mode"]):
96 settings["input_mode"] = "touch"
97 if _migrate_loaded_settings(file):
98 save_settings()
99
100
101
102func save_settings() -> void:
103 var file = ConfigFile.new()
104 file.set_value("meta", "settings_version", SETTINGS_SCHEMA_VERSION)
105 file.set_value("display", "window_size", settings["window_size"])
106 file.set_value("display", "fullscreen", settings["fullscreen"])
107 file.set_value("display", "theme", settings["theme"])
108 file.set_value("display", "text_size", settings["text_size"])
109 file.set_value("display", "low_power_mode", settings["low_power_mode"])
110 file.set_value("audio", "master_volume", settings["master_volume"])
111 file.set_value("audio", "music_volume", settings["music_volume"])
112 file.set_value("audio", "sfx_volume", settings["sfx_volume"])
113 file.set_value("audio", "master_muted", settings["master_muted"])
114 file.set_value("audio", "music_muted", settings["music_muted"])
115 file.set_value("audio", "sfx_muted", settings["sfx_muted"])
116 file.set_value("input", "remaps", settings["input_remaps"])
117 file.set_value("input", "mode", settings["input_mode"])
118 file.save(SETTINGS_PATH)
119
120
121
122func _migrate_loaded_settings(file:ConfigFile) -> bool:
123 var loaded_version:int = int(file.get_value("meta", "settings_version", 1))
124 if loaded_version >= SETTINGS_SCHEMA_VERSION:
125 return false
126 var changed := true
127 var has_music_volume:bool = file.has_section_key("audio", "music_volume")
128 if loaded_version < 2 and has_music_volume:
129 var loaded_music_volume:float = float(file.get_value("audio", "music_volume", LEGACY_DEFAULT_MUSIC_VOLUME))
130 if absf(loaded_music_volume - LEGACY_DEFAULT_MUSIC_VOLUME) <= 0.001:
131 settings["music_volume"] = CURRENT_DEFAULT_MUSIC_VOLUME
132 if loaded_version < 3 and has_music_volume:
133 var loaded_music_volume:float = float(file.get_value("audio", "music_volume", PREVIOUS_DEFAULT_MUSIC_VOLUME))
134 if absf(loaded_music_volume - PREVIOUS_DEFAULT_MUSIC_VOLUME) <= 0.001:
135 settings["music_volume"] = CURRENT_DEFAULT_MUSIC_VOLUME
136 if loaded_version < 5 and str(settings.get("input_mode", "auto")) == "auto":
137 settings["input_mode"] = "touch"
138 return changed
139
140
141
142func get_settings() -> Dictionary:
143 return settings.duplicate(true)
144
145
146
147func get_window_size_options() -> Dictionary:
148 return WINDOW_SIZE_OPTIONS.duplicate(true)
149
150
151
152func get_text_size_options() -> Dictionary:
153 return TEXT_SIZE_OPTIONS.duplicate(true)
154
155
156
157func get_input_mode_options() -> Dictionary:
158 return INPUT_MODE_OPTIONS.duplicate(true)
159
160
161
162func get_theme_options() -> Dictionary:
163 if theme_options.is_empty():
164 load_theme_options()
165 return theme_options.duplicate(true)
166
167
168
169func load_low_power_profiles(file_path:String = LOW_POWER_PRESET_PATH) -> void:
170 var loaded_profiles := {}
171 var file = FileAccess.open(file_path, FileAccess.READ)
172 if file != null:
173 var data = JSON.parse_string(file.get_as_text())
174 if data is Dictionary and data.get("profiles", {}) is Dictionary:
175 for profile_id in data.get("profiles", {}).keys():
176 var profile = data.get("profiles", {}).get(profile_id)
177 if profile is Dictionary:
178 loaded_profiles[str(profile_id)] = profile.duplicate(true)
179 if loaded_profiles.is_empty():
180 loaded_profiles["default"] = _default_low_power_profile()
181 low_power_profiles = loaded_profiles
182
183
184
185func get_low_power_profile(profile_id:String = "default") -> Dictionary:
186 if low_power_profiles.is_empty():
187 load_low_power_profiles()
188 var profile:Dictionary = low_power_profiles.get(profile_id, low_power_profiles.get("default", _default_low_power_profile())) as Dictionary
189 return profile.duplicate(true)
190
191
192
193func load_theme_options(file_path:String = THEME_CATALOG_PATH) -> void:
194 var loaded := {}
195 var typography_defaults := _default_typography()
196 var loaded_typography := {}
197 for catalog_path in _theme_catalog_paths(file_path):
198 var file = FileAccess.open(catalog_path, FileAccess.READ)
199 if file == null:
200 continue
201 var data = JSON.parse_string(file.get_as_text())
202 if data is Dictionary:
203 if data.get("typography", {}) is Dictionary:
204 typography_defaults = _merge_dict(typography_defaults, data.get("typography", {}))
205 var themes = data.get("themes", {})
206 if themes is Dictionary:
207 for theme_id in themes.keys():
208 var definition = themes[theme_id]
209 var label := str(theme_id).capitalize()
210 if definition is Dictionary:
211 label = str(definition.get("name", label))
212 var theme_typography_definition = definition.get("typography", {})
213 if theme_typography_definition is Dictionary:
214 loaded_typography[str(theme_id)] = _merge_dict(typography_defaults, theme_typography_definition)
215 loaded[label] = str(theme_id)
216 if loaded.is_empty():
217 loaded = DEFAULT_THEME_OPTIONS.duplicate(true)
218 theme_options = loaded
219 for theme_id in theme_options.values():
220 if not loaded_typography.has(theme_id):
221 loaded_typography[theme_id] = typography_defaults.duplicate(true)
222 theme_typography = loaded_typography
223
224
225func _theme_catalog_paths(primary_path:String) -> Array:
226 var paths := [primary_path]
227 if primary_path == THEME_CATALOG_PATH and FileAccess.file_exists(CUSTOM_THEME_CATALOG_PATH):
228 paths.append(CUSTOM_THEME_CATALOG_PATH)
229 return paths
230
231
232
233func get_text_size_preset(size_id:String = "") -> Dictionary:
234 var resolved_id = str(settings["text_size"]) if size_id == "" else size_id
235 var theme_id := str(settings.get("theme", "dark"))
236 return get_theme_text_size_preset(theme_id, resolved_id)
237
238
239
240func get_theme_text_size_preset(theme_id:String = "", size_id:String = "") -> Dictionary:
241 if theme_typography.is_empty():
242 load_theme_options()
243 var resolved_theme := str(settings.get("theme", "dark")) if theme_id == "" else theme_id
244 var resolved_size := str(settings.get("text_size", "medium")) if size_id == "" else size_id
245 var typography = theme_typography.get(resolved_theme, _default_typography())
246 var base_sizes:Dictionary = typography.get("base_sizes", TEXT_SIZE_PRESETS["medium"])
247 var scale_presets:Dictionary = typography.get("scale_presets", {"small": 0.88, "medium": 1.0, "large": 1.22})
248 var scale := float(scale_presets.get(resolved_size, 1.0))
249 var preset := {}
250 for key in TEXT_SIZE_PRESETS["medium"].keys():
251 preset[key] = max(1, int(round(float(base_sizes.get(key, TEXT_SIZE_PRESETS["medium"][key])) * scale)))
252 preset["button_height_multiplier"] = float(typography.get("button_height_multiplier", 2.4))
253 preset["font_paths"] = typography.get("font_paths", {})
254 return preset
255
256
257
258func set_window_size(value:Vector2i) -> void:
259 settings["window_size"] = value
260 apply_window_settings()
261 _commit()
262
263
264func set_window_size_by_label(label:String) -> void:
265 if not WINDOW_SIZE_OPTIONS.has(label):
266 return
267 set_window_size(WINDOW_SIZE_OPTIONS[label])
268
269
270
271func set_fullscreen(value:bool) -> void:
272 settings["fullscreen"] = value
273 apply_window_settings()
274 _commit()
275
276
277
278func set_text_size(value:String) -> void:
279 if not TEXT_SIZE_PRESETS.has(value):
280 return
281 settings["text_size"] = value
282 _commit()
283
284
285func set_text_size_by_label(label:String) -> void:
286 if not TEXT_SIZE_OPTIONS.has(label):
287 return
288 set_text_size(TEXT_SIZE_OPTIONS[label])
289
290
291
292func set_theme(value:String) -> void:
293 if not get_theme_options().values().has(value):
294 return
295 settings["theme"] = value
296 _commit()
297
298
299func set_theme_by_label(label:String) -> void:
300 var options = get_theme_options()
301 if not options.has(label):
302 return
303 set_theme(options[label])
304
305
306
307func set_low_power_mode(value:bool) -> void:
308 settings["low_power_mode"] = value
309 _commit()
310
311
312
313func is_low_power_mode() -> bool:
314 return bool(settings.get("low_power_mode", false))
315
316
317
318func set_audio_value(key:String, value) -> void:
319 if not settings.has(key):
320 return
321 settings[key] = value
322 _commit()
323
324
325
326func set_input_remaps(value:Dictionary) -> void:
327 settings["input_remaps"] = value.duplicate(true)
328 _commit()
329
330
331
332func get_input_remaps() -> Dictionary:
333 return settings.get("input_remaps", {}).duplicate(true)
334
335
336
337func apply_text_size(root:Node) -> void:
338 if root == null:
339 return
340 var preset = get_text_size_preset()
341 _apply_text_size_recursive(root, preset)
342
343
344func apply_window_settings() -> void:
345 if bool(settings["fullscreen"]):
346 DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
347 else:
348 DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
349 DisplayServer.window_set_size(settings["window_size"])
350
351
352func _commit() -> void:
353 save_settings()
354 settings_changed.emit(get_settings())
355
356
357
358func set_input_mode(value:String) -> void:
359 var normalized := value if INPUT_MODE_OPTIONS.values().has(value) else "touch"
360 if str(settings.get("input_mode", "touch")) == normalized:
361 return
362 settings["input_mode"] = normalized
363 save_settings()
364 settings_changed.emit(get_settings())
365
366
367func _apply_text_size_recursive(node:Node, preset:Dictionary) -> void:
368 if node == null:
369 return
370 if node is Label:
371 var node_name = str(node.name).to_lower()
372 var size_key = "body"
373 if node_name.contains("title"):
374 size_key = "title"
375 elif node_name.contains("description") or node_name.contains("info") or node_name.contains("subtitle") or node_name.contains("status") or node_name.contains("details") or node_name.contains("preview"):
376 size_key = "caption"
377 var font_role:String = size_key
378 var font_size := int(preset[size_key])
379 if node.has_meta("max_font_size"):
380 font_size = min(font_size, int(node.get_meta("max_font_size")))
381 if node.has_meta("text_size_role"):
382 var role := str(node.get_meta("text_size_role"))
383 if preset.has(role):
384 font_role = role
385 font_size = int(preset[role])
386 if node.has_meta("max_font_size"):
387 font_size = min(font_size, int(node.get_meta("max_font_size")))
388 node.add_theme_font_size_override("font_size", font_size)
389 _apply_font_override(node, preset, font_role)
390 if node is Button or node is OptionButton or node is CheckButton:
391 var button_font_size := int(preset["button"])
392 if node.has_meta("max_font_size"):
393 button_font_size = min(button_font_size, int(node.get_meta("max_font_size")))
394 node.add_theme_font_size_override("font_size", button_font_size)
395 _apply_font_override(node, preset, "button")
396 if node is Button:
397 node.custom_minimum_size.y = max(float(node.custom_minimum_size.y), float(button_font_size) * float(preset.get("button_height_multiplier", 2.4)))
398 if node is AcceptDialog:
399 node.add_theme_font_size_override("font_size", int(preset["body"]))
400 if node.get_ok_button() != null:
401 node.get_ok_button().add_theme_font_size_override("font_size", int(preset["button"]))
402 node.get_ok_button().custom_minimum_size.y = max(float(node.get_ok_button().custom_minimum_size.y), float(preset["button"]) * 2.4)
403 if node.get_cancel_button() != null:
404 node.get_cancel_button().add_theme_font_size_override("font_size", int(preset["button"]))
405 node.get_cancel_button().custom_minimum_size.y = max(float(node.get_cancel_button().custom_minimum_size.y), float(preset["button"]) * 2.4)
406 if node is ItemList:
407 node.add_theme_font_size_override("font_size", int(preset["list"]))
408 if node is RichTextLabel:
409 node.add_theme_font_size_override("normal_font_size", int(preset["body"]))
410 for child in node.get_children():
411 _apply_text_size_recursive(child, preset)
412
413
414func _apply_font_override(node:Control, preset:Dictionary, role:String) -> void:
415 var font_paths = preset.get("font_paths", {})
416 if not font_paths is Dictionary:
417 return
418 var path := str(font_paths.get(role, font_paths.get("default", "")))
419 if path == "":
420 return
421 if not ResourceLoader.exists(path):
422 return
423 var font = font_cache.get(path)
424 if font == null:
425 font = load(path)
426 font_cache[path] = font
427 if font != null:
428 node.add_theme_font_override("font", font)
429
430
431func _default_typography() -> Dictionary:
432 return {
433 "font_paths": {"default": "", "title": "", "button": "", "mono": ""},
434 "base_sizes": TEXT_SIZE_PRESETS["medium"].duplicate(true),
435 "scale_presets": {"small": 0.88, "medium": 1.0, "large": 1.22},
436 "button_height_multiplier": 2.4
437 }
438
439
440func _default_low_power_profile() -> Dictionary:
441 return {
442 "audio": {"queue_next_music_track": false},
443 "arcade": {
444 "max_field_powerups": 1,
445 "powerup_spawn_pace_multiplier": 0.55,
446 "minimum_powerup_spawn_pace": 0.25,
447 "powerup_lifetime": 8.0,
448 "held_powerup_icon_size": 22,
449 "show_active_effect_text": false
450 },
451 "visuals": {
452 "prefer_static_effects": true,
453 "background_animation_rate": 0.0,
454 "decorative_effect_limit": 0
455 },
456 "cache": {
457 "warm_optional_screens": false,
458 "preserve_source_images": true
459 }
460 }
461
462
463func _merge_dict(base:Dictionary, override:Dictionary) -> Dictionary:
464 var merged := base.duplicate(true)
465 for key in override.keys():
466 if merged.get(key) is Dictionary and override[key] is Dictionary:
467 merged[key] = _merge_dict(merged[key], override[key])
468 else:
469 merged[key] = override[key]
470 return merged
int
Apply one-time persisted settings migrations after loading an older config.