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)
24const TEXT_SIZE_OPTIONS := {
29const DEFAULT_THEME_OPTIONS := {
31 "High Contrast":
"high_contrast"
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}
39signal settings_changed(settings:Dictionary)
42 "window_size": Vector2i(800, 800),
44 "master_volume": 0.85,
45 "music_volume": CURRENT_DEFAULT_MUSIC_VOLUME,
47 "master_muted": false,
51 "text_size":
"medium",
52 "low_power_mode": false,
56const INPUT_MODE_OPTIONS := {
58 "Touch / Analog":
"touch",
59 "Keyboard / WASD":
"keyboard",
60 "Controller":
"controller"
62var theme_options := DEFAULT_THEME_OPTIONS.duplicate(true)
63var theme_typography := {}
64var low_power_profiles := {}
70 load_low_power_profiles()
72 apply_window_settings()
76func load_settings() -> void:
77 var file = ConfigFile.new()
78 if file.load(SETTINGS_PATH) != OK:
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):
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)
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:
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"
142func get_settings() -> Dictionary:
143 return settings.duplicate(true)
147func get_window_size_options() -> Dictionary:
148 return WINDOW_SIZE_OPTIONS.duplicate(true)
152func get_text_size_options() -> Dictionary:
153 return TEXT_SIZE_OPTIONS.duplicate(true)
157func get_input_mode_options() -> Dictionary:
158 return INPUT_MODE_OPTIONS.duplicate(true)
162func get_theme_options() -> Dictionary:
163 if theme_options.is_empty():
165 return theme_options.duplicate(true)
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)
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
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)
193func load_theme_options(file_path:String = THEME_CATALOG_PATH) -> void:
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)
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
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)
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)
240func get_theme_text_size_preset(theme_id:String =
"", size_id:String =
"") -> Dictionary:
241 if theme_typography.is_empty():
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))
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", {})
258func set_window_size(value:Vector2i) -> void:
259 settings[
"window_size"] = value
260 apply_window_settings()
264func set_window_size_by_label(label:String) -> void:
265 if not WINDOW_SIZE_OPTIONS.has(label):
267 set_window_size(WINDOW_SIZE_OPTIONS[label])
271func set_fullscreen(value:bool) -> void:
272 settings[
"fullscreen"] = value
273 apply_window_settings()
278func set_text_size(value:String) -> void:
279 if not TEXT_SIZE_PRESETS.has(value):
281 settings[
"text_size"] = value
285func set_text_size_by_label(label:String) -> void:
286 if not TEXT_SIZE_OPTIONS.has(label):
288 set_text_size(TEXT_SIZE_OPTIONS[label])
292func set_theme(value:String) -> void:
293 if not get_theme_options().values().has(value):
295 settings[
"theme"] = value
299func set_theme_by_label(label:String) -> void:
300 var options = get_theme_options()
301 if not options.has(label):
303 set_theme(options[label])
307func set_low_power_mode(value:bool) -> void:
308 settings[
"low_power_mode"] = value
313func is_low_power_mode() -> bool:
314 return bool(settings.get(
"low_power_mode", false))
318func set_audio_value(key:String, value) -> void:
319 if not settings.has(key):
321 settings[key] = value
326func set_input_remaps(value:Dictionary) -> void:
327 settings[
"input_remaps"] = value.duplicate(true)
332func get_input_remaps() -> Dictionary:
333 return settings.get(
"input_remaps", {}).duplicate(true)
337func apply_text_size(root:Node) -> void:
340 var preset = get_text_size_preset()
341 _apply_text_size_recursive(root, preset)
344func apply_window_settings() -> void:
345 if bool(settings[
"fullscreen"]):
346 DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
348 DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
349 DisplayServer.window_set_size(settings[
"window_size"])
352func _commit() -> void:
354 settings_changed.emit(get_settings())
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:
362 settings[
"input_mode"] = normalized
364 settings_changed.emit(get_settings())
367func _apply_text_size_recursive(node:Node, preset:Dictionary) -> void:
371 var node_name = str(node.name).to_lower()
372 var size_key =
"body"
373 if node_name.contains(
"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"):
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"))
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")
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)
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)
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:
418 var path := str(font_paths.get(role, font_paths.get(
"default",
"")))
421 if not ResourceLoader.exists(path):
423 var font = font_cache.get(path)
426 font_cache[path] = font
428 node.add_theme_font_override(
"font", font)
431func _default_typography() -> Dictionary:
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
440func _default_low_power_profile() -> Dictionary:
442 "audio": {
"queue_next_music_track": false},
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
452 "prefer_static_effects": true,
453 "background_animation_rate": 0.0,
454 "decorative_effect_limit": 0
457 "warm_optional_screens": false,
458 "preserve_source_images": true
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])
469 merged[key] = override[key]
int
Apply one-time persisted settings migrations after loading an older config.