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