10const PHONE_WIDTH := 600
11const TABLET_WIDTH := 1000
13const DENSITY_PRESETS := {
14 "compact": {
"margin": 8,
"gap": 6,
"button_height": 44,
"font_size": 14},
15 "comfortable": {
"margin": 14,
"gap": 10,
"button_height": 54,
"font_size": 16},
16 "spacious": {
"margin": 22,
"gap": 14,
"button_height": 62,
"font_size": 18}
19const BREAKPOINT_DENSITIES := {
21 "tablet":
"comfortable",
25const THEME_PRESETS := {
28 "background":
"#111820",
30 "surface_alt":
"#202a34",
32 "panel_alt":
"#202a34",
34 "button_hover":
"#273442",
35 "button_pressed":
"#18202a",
36 "button_disabled":
"#161d25",
41 "positive":
"#55d17a",
42 "negative":
"#ef5f68",
47const DEFAULT_STYLE_TARGETS := {
48 "screen": {
"background_image":
"",
"texture_mode":
"stretch",
"opacity": 1.0},
49 "panel": {
"background_image":
"",
"texture_mode":
"stretch",
"opacity": 1.0},
50 "button": {
"background_image":
"",
"texture_mode":
"stretch",
"opacity": 1.0}
52const THEME_COLOR_KEYS := [
70const CUSTOM_THEME_CATALOG_PATH :=
"user://custom_themes.json"
72var theme_presets := THEME_PRESETS.duplicate(true)
73var density_presets := DENSITY_PRESETS.duplicate(true)
74var sprite_manager = preload(
"res://scripts/systems/spriteManager/sprite_manager.gd").new()
75var sprites_loaded := false
77var style_cache_hits:int = 0
78var style_cache_misses:int = 0
82func load_theme_file(file_path:String) -> void:
84 for catalog_path
in _theme_catalog_paths(file_path):
85 var file = FileAccess.open(catalog_path, FileAccess.READ)
88 var data = JSON.parse_string(file.get_as_text())
89 if not data
is Dictionary:
91 for key
in data.get(
"themes", {}).keys():
92 theme_presets[key] = _normalize_theme_definition(data[
"themes"][key])
93 for key
in data.get(
"densities", {}).keys():
94 density_presets[key] = data[
"densities"][key]
98func get_layout_mode(viewport_size:Vector2) -> String:
99 return "mobile" if viewport_size.x <= PHONE_WIDTH
else "desktop"
103func get_breakpoint(viewport_size:Vector2) -> String:
104 if viewport_size.x <= PHONE_WIDTH:
106 if viewport_size.x <= TABLET_WIDTH:
112func get_density_preset(name:String =
"comfortable", viewport_size:Vector2 = Vector2.ZERO) -> Dictionary:
113 var preset_name = name
114 if viewport_size != Vector2.ZERO:
115 preset_name = BREAKPOINT_DENSITIES.get(get_breakpoint(viewport_size), name)
116 return density_presets.get(preset_name, density_presets[
"comfortable"]).duplicate(true)
120func get_theme_colors(name:String =
"dark") -> Dictionary:
121 var source = _normalize_theme_definition(theme_presets.get(name, theme_presets[
"dark"]))
122 var fallback = _normalize_theme_definition(THEME_PRESETS[
"dark"])
124 for key
in THEME_COLOR_KEYS:
125 colors[key] = _coerce_theme_color(source.get(key, fallback.get(key,
"#ffffff")), _coerce_theme_color(fallback.get(key,
"#ffffff"), Color.WHITE))
126 for key
in source.keys():
127 if colors.has(key)
or key ==
"name" or key ==
"description" or key ==
"typography" or key ==
"styles":
129 if _is_color_like(source[key]):
130 colors[key] = _coerce_theme_color(source[key], Color.WHITE)
135func get_theme_catalog() -> Dictionary:
137 for theme_id
in theme_presets.keys():
138 var definition = _normalize_theme_definition(theme_presets[theme_id])
139 catalog[theme_id] = {
141 "name": str(definition.get(
"name", str(theme_id).capitalize())),
142 "description": str(definition.get(
"description",
"")),
143 "colors": get_theme_colors(theme_id),
144 "typography": definition.get(
"typography", {}),
145 "styles": get_theme_styles(theme_id)
151func get_theme_styles(name:String =
"dark") -> Dictionary:
152 var source = _normalize_theme_definition(theme_presets.get(name, theme_presets[
"dark"]))
153 var styles = source.get(
"styles", DEFAULT_STYLE_TARGETS)
154 if not styles
is Dictionary:
155 return DEFAULT_STYLE_TARGETS.duplicate(true)
156 return _merge_dict(DEFAULT_STYLE_TARGETS, styles)
160func get_custom_draw_palette(name:String =
"dark", domain:String =
"generic") -> Dictionary:
161 var colors = get_theme_colors(name)
165 "background": colors.get(
"background", Color(
"#213122")).lerp(Color(
"#2f4a30"), 0.35),
166 "lawn": colors.get(
"surface", Color(
"#2f4a30")).lerp(Color(
"#3f653f"), 0.45),
167 "far_lawn": colors.get(
"surface_alt", Color(
"#2b442d")).lerp(Color(
"#335536"), 0.35),
168 "path": colors.get(
"accent", Color(
"#c8b997")).lerp(Color(
"#c8b997"), 0.62),
169 "stone": colors.get(
"text", Color(
"#e8e1d1")).lerp(Color(
"#d7d0c2"), 0.32),
170 "muted_stone": colors.get(
"muted", Color(
"#b9ad99")).lerp(Color(
"#b9ad99"), 0.5),
171 "water_border": colors.get(
"border", Color(
"#6c8a9b")).lerp(Color(
"#6c8a9b"), 0.45)
177func make_panel_style(theme_name:String =
"dark", density_name:String =
"comfortable") -> StyleBoxFlat:
178 var cache_key :=
"panel|%s|%s" % [theme_name, density_name]
179 if style_cache.has(cache_key):
180 style_cache_hits += 1
181 return style_cache[cache_key]
182 style_cache_misses += 1
183 var colors = get_theme_colors(theme_name)
184 var density = get_density_preset(density_name)
185 var style = StyleBoxFlat.new()
186 style.bg_color = colors.get(
"panel", colors.get(
"surface", Color(
"#1a222b")))
187 style.border_color = colors[
"border"]
188 style.set_border_width_all(1)
189 style.set_corner_radius_all(8)
190 style.content_margin_left =
int(density[
"margin"])
191 style.content_margin_right =
int(density[
"margin"])
192 style.content_margin_top =
int(density[
"margin"])
193 style.content_margin_bottom =
int(density[
"margin"])
194 style_cache[cache_key] = style
199func make_button_style(theme_name:String =
"dark", state:String =
"normal") -> StyleBoxFlat:
200 var cache_key :=
"button|%s|%s" % [theme_name, state]
201 if style_cache.has(cache_key):
202 style_cache_hits += 1
203 return style_cache[cache_key]
204 style_cache_misses += 1
205 var colors = get_theme_colors(theme_name)
206 var style = StyleBoxFlat.new()
207 var bg_key =
"button"
210 bg_key =
"button_hover"
212 bg_key =
"button_pressed"
214 bg_key =
"button_disabled"
215 style.bg_color = colors.get(bg_key, colors.get(
"surface_alt", colors[
"surface"]))
216 style.border_color = colors[
"accent"]
if state ==
"hover" or state ==
"pressed" else colors[
"border"]
217 style.set_border_width_all(1)
218 style.set_corner_radius_all(8)
219 style.content_margin_left = 14
220 style.content_margin_right = 14
221 style.content_margin_top = 10
222 style.content_margin_bottom = 10
223 style_cache[cache_key] = style
228func clear_style_cache() -> void:
231 style_cache_misses = 0
235func get_cache_stats() -> Dictionary:
237 "styleboxes": style_cache.size(),
238 "style_cache_hits": style_cache_hits,
239 "style_cache_misses": style_cache_misses
244func apply_theme(root:Node, theme_name:String =
"dark") -> void:
247 var colors = get_theme_colors(theme_name)
248 _apply_theme_recursive(root, theme_name, colors)
252func apply_screen_background(root:Control, theme_name:String =
"dark") -> void:
253 _apply_style_target(root,
"screen", get_theme_styles(theme_name))
257func apply_density(control:Control, density_name:String =
"comfortable") -> void:
258 var density = get_density_preset(density_name)
259 if control
is BoxContainer:
260 control.add_theme_constant_override(
"separation",
int(density[
"gap"]))
261 if control
is Button:
262 control.custom_minimum_size.y =
int(density[
"button_height"])
263 control.add_theme_font_size_override(
"font_size",
int(density[
"font_size"]))
267func apply_responsive_shell(root:Control, viewport_size:Vector2) -> void:
268 var density_name = BREAKPOINT_DENSITIES.get(get_breakpoint(viewport_size),
"comfortable")
269 var density = get_density_preset(density_name)
270 root.offset_left =
int(density[
"margin"])
271 root.offset_top =
int(density[
"margin"])
272 root.offset_right = -
int(density[
"margin"])
273 root.offset_bottom = -
int(density[
"margin"])
274 _apply_density_recursive(root, density_name)
278func create_screen_shell(name:String =
"ScreenShell", options:Dictionary = {}) -> VBoxContainer:
279 var shell = VBoxContainer.new()
281 shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
282 shell.size_flags_vertical = Control.SIZE_EXPAND_FILL
283 var header = HBoxContainer.new()
284 header.name =
"Header"
285 header.custom_minimum_size =
Vector2(0,
int(options.get(
"header_height", 54)))
286 shell.add_child(header)
287 var body = create_scroll_shell(
"Body")
288 body.size_flags_vertical = Control.SIZE_EXPAND_FILL
289 shell.add_child(body)
290 var nav = HBoxContainer.new()
291 nav.name =
"Navigation"
292 nav.custom_minimum_size =
Vector2(0,
int(options.get(
"nav_height", 58)))
293 if bool(options.get(
"show_navigation", true)):
299func apply_full_rect(control:Control, margin:int = 0) -> void:
300 control.set_anchors_preset(Control.PRESET_FULL_RECT)
301 control.offset_left = margin
302 control.offset_top = margin
303 control.offset_right = -margin
304 control.offset_bottom = -margin
308func fit_popup_to_viewport(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> Vector2i:
311 var requested = _coerce_size(options.get(
"size", popup.size),
Vector2i(360, 420))
312 var margin :=
int(options.get(
"margin", 24))
314 var popup_size = popup_size_for_viewport(viewport_size, requested, minimum, margin)
315 popup.min_size =
Vector2i(min(minimum.x, popup_size.x), min(minimum.y, popup_size.y))
316 popup.size = popup_size
317 var content = popup.get_node_or_null(
"Content")
318 if content
is Control:
319 apply_full_rect(content,
int(options.get(
"content_margin", 14)))
320 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
321 content.size_flags_vertical = Control.SIZE_EXPAND_FILL
326func popup_size_for_viewport(viewport_size:Vector2, preferred:Vector2i =
Vector2i(420, 560), minimum:Vector2i =
Vector2i(280, 180), margin:int = 24) -> Vector2i:
327 var available =
Vector2i(max(1,
int(viewport_size.x) - margin), max(1,
int(viewport_size.y) - margin))
328 var effective_min =
Vector2i(min(minimum.x, available.x), min(minimum.y, available.y))
330 clamp(preferred.x, effective_min.x, available.x),
331 clamp(preferred.y, effective_min.y, available.y)
336func popup_centered_standard(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> void:
337 var popup_size = fit_popup_to_viewport(popup, viewport_size, options)
338 if popup_size != Vector2i.ZERO:
339 popup.size = popup_size
340 if popup_size != Vector2i.ZERO
and popup.is_inside_tree():
341 popup.popup_centered(popup_size)
345func create_panel_shell(name:String =
"PanelShell") -> PanelContainer:
346 var panel = PanelContainer.new()
348 panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
349 panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
350 var content = VBoxContainer.new()
351 content.name =
"Content"
352 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
353 content.size_flags_vertical = Control.SIZE_EXPAND_FILL
354 panel.add_child(content)
359func create_scroll_shell(name:String =
"ScrollShell") -> ScrollContainer:
360 var scroll = ScrollContainer.new()
362 scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
363 scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
364 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
365 var content = VBoxContainer.new()
366 content.name =
"Content"
367 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
368 scroll.add_child(content)
373func create_bottom_nav(items:Array) -> HBoxContainer:
374 var nav = HBoxContainer.new()
375 nav.name =
"BottomNav"
376 nav.custom_minimum_size =
Vector2(0, 58)
377 nav.size_flags_horizontal = Control.SIZE_EXPAND_FILL
379 if not item
is Dictionary:
381 var button = Button.new()
382 button.text = str(item.get(
"label",
"Item"))
383 _apply_button_icon(button, str(item.get(
"sprite_id",
"")), 24)
384 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
385 button.custom_minimum_size =
Vector2(0, 54)
387 if callback
is Callable
and callback.is_valid():
388 button.pressed.connect(callback)
389 nav.add_child(button)
394func create_save_slot_list(slots:Array, open_callback:Callable, delete_callback:Callable =
Callable()) -> VBoxContainer:
395 var list = VBoxContainer.new()
396 list.name =
"SaveSlotList"
397 list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
399 if slot
is Dictionary:
400 list.add_child(create_save_slot_row(slot, open_callback, delete_callback))
405func create_save_slot_row(slot:Dictionary, open_callback:Callable, delete_callback:Callable =
Callable()) -> HBoxContainer:
406 var row = HBoxContainer.new()
407 row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
408 row.add_theme_constant_override(
"separation", 8)
409 var slot_button = Button.new()
410 slot_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
411 slot_button.custom_minimum_size =
Vector2(0, 76)
412 slot_button.text = str(slot.get(
"display_label",
"Slot %d" %
int(slot.get(
"slot", 0))))
413 _apply_button_icon(slot_button,
"save", 26)
414 if open_callback.is_valid():
415 slot_button.pressed.connect(func(): open_callback.call(slot))
416 row.add_child(slot_button)
417 var delete_button = Button.new()
418 delete_button.custom_minimum_size =
Vector2(52, 76)
419 delete_button.text =
""
420 _apply_button_icon(delete_button,
"trash", 22)
421 delete_button.expand_icon = true
422 delete_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
423 delete_button.disabled = bool(slot.get(
"empty", false))
or not delete_callback.is_valid()
424 if delete_callback.is_valid():
425 delete_button.pressed.connect(func(): delete_callback.call(slot))
426 row.add_child(delete_button)
431func create_achievement_notification(achievement:Dictionary) -> PanelContainer:
432 var panel = create_panel_shell(
"AchievementNotification")
433 var content = panel.get_node(
"Content")
434 var icon = _make_sprite_icon(
"achievement_star", 34)
436 content.add_child(icon)
437 var title = Label.new()
439 title.text =
"Achievement: %s" % str(achievement.get(
"name", achievement.get(
"id",
"Unlocked")))
440 content.add_child(title)
441 var description = Label.new()
442 description.name =
"Description"
443 description.text = str(achievement.get(
"description",
""))
444 description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
445 content.add_child(description)
450func create_achievement_collection(achievements:Array, options:Dictionary = {}) -> ScrollContainer:
451 var scroll = create_scroll_shell(
"AchievementCollection")
452 scroll.custom_minimum_size =
Vector2(0, 220)
453 var content = scroll.get_node(
"Content")
454 content.add_theme_constant_override(
"separation", 8)
455 if bool(options.get(
"show_summary", true)):
456 content.add_child(_create_achievement_summary(achievements))
457 if achievements.is_empty():
458 var empty = Label.new()
459 empty.name =
"Status"
460 empty.text =
"No achievements yet."
461 empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
462 content.add_child(empty)
464 for achievement
in achievements:
465 if not achievement
is Dictionary:
467 var row_height :=
int(options.get(
"row_height", 126))
468 var row_button = Button.new()
469 row_button.name =
"Achievement_%s" % str(achievement.get(
"id",
"row"))
471 row_button.custom_minimum_size =
Vector2(0, row_height)
472 row_button.set_meta(
"max_font_size", 1)
473 row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
474 row_button.focus_mode = Control.FOCUS_ALL
475 row_button.add_theme_stylebox_override(
"normal", _achievement_row_style(Color(
"#1c2630"), Color(
"#33404d")))
476 row_button.add_theme_stylebox_override(
"hover", _achievement_row_style(Color(
"#25313d"), Color(
"#f2c86b")))
477 row_button.add_theme_stylebox_override(
"pressed", _achievement_row_style(Color(
"#25313d"), Color(
"#f2c86b")))
478 var margin = MarginContainer.new()
479 margin.name =
"RowMargin"
480 margin.set_anchors_preset(Control.PRESET_FULL_RECT)
481 margin.offset_left = 10
482 margin.offset_top = 7
483 margin.offset_right = -10
484 margin.offset_bottom = -7
485 margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
486 row_button.add_child(margin)
487 var row = HBoxContainer.new()
489 row.add_theme_constant_override(
"separation", 8)
490 row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
491 row.size_flags_vertical = Control.SIZE_EXPAND_FILL
492 row.mouse_filter = Control.MOUSE_FILTER_IGNORE
493 var icon_id =
"achievement_star" if bool(achievement.get(
"unlocked", false))
else "lock"
494 var icon = _make_sprite_icon(icon_id,
int(options.get(
"icon_size", 30)))
496 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
497 icon.custom_minimum_size =
Vector2(34, 34)
499 var text_stack = VBoxContainer.new()
500 text_stack.name =
"Text"
501 text_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
502 text_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
503 text_stack.add_theme_constant_override(
"separation", 4)
504 text_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
505 var title = Label.new()
506 title.name =
"AchievementName"
507 title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
508 title.clip_text = true
509 title.max_lines_visible =
int(options.get(
"title_lines", 2))
510 title.custom_minimum_size =
Vector2(0,
int(options.get(
"title_min_height", 26)))
511 title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
512 title.mouse_filter = Control.MOUSE_FILTER_IGNORE
513 title.set_meta(
"text_size_role",
"body")
514 title.set_meta(
"max_font_size",
int(options.get(
"title_max_font_size", 17)))
515 title.text = _achievement_title(achievement)
516 text_stack.add_child(title)
517 var description = Label.new()
518 description.name =
"Description"
519 description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
520 description.max_lines_visible =
int(options.get(
"description_lines", 2))
521 description.clip_text = true
522 description.custom_minimum_size =
Vector2(0,
int(options.get(
"description_min_height", 40)))
523 description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
524 description.mouse_filter = Control.MOUSE_FILTER_IGNORE
525 description.set_meta(
"text_size_role",
"caption")
526 description.set_meta(
"max_font_size",
int(options.get(
"description_max_font_size", 13)))
527 description.text = str(achievement.get(
"description",
""))
528 text_stack.add_child(description)
529 var meta = Label.new()
531 meta.autowrap_mode = TextServer.AUTOWRAP_OFF
532 meta.clip_text = true
533 meta.custom_minimum_size =
Vector2(0,
int(options.get(
"meta_min_height", 18)))
534 meta.size_flags_horizontal = Control.SIZE_EXPAND_FILL
535 meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
536 meta.set_meta(
"text_size_role",
"caption")
537 meta.set_meta(
"max_font_size",
int(options.get(
"meta_max_font_size", 11)))
538 meta.text = _achievement_meta(achievement)
539 text_stack.add_child(meta)
540 row.add_child(text_stack)
541 margin.add_child(row)
542 content.add_child(row_button)
546func _achievement_title(achievement:Dictionary) -> String:
547 var title = str(achievement.get(
"title", achievement.get(
"name",
""))).strip_edges()
550 var id = str(achievement.get(
"id",
"Achievement")).replace(
"-",
"_")
552 for part
in id.split(
"_"):
553 if str(part).strip_edges() !=
"":
554 words.append(str(part).capitalize())
555 return " ".join(words)
if not words.is_empty()
else "Achievement"
558func _achievement_meta(achievement:Dictionary) -> String:
560 "Unlocked" if bool(achievement.get(
"unlocked", false))
else "Locked",
561 str(achievement.get(
"category",
"general")).replace(
"_",
" ").capitalize()
563 var date_text = _achievement_unlock_date(achievement)
565 parts.append(date_text)
566 return " | ".join(parts)
569func _achievement_unlock_date(achievement:Dictionary) -> String:
570 if not bool(achievement.get(
"unlocked", false)):
572 if not achievement.has(
"unlocked_at"):
574 var timestamp :=
int(achievement.get(
"unlocked_at", 0))
577 var date = Time.get_datetime_dict_from_unix_time(timestamp)
578 return "%02d-%02d-%04d" % [
int(date.get(
"month", 1)),
int(date.get(
"day", 1)),
int(date.get(
"year", 1970))]
582func create_achievement_filter_bar(options:Dictionary = {}, current_filters:Dictionary = {}, changed_callback:Callable =
Callable()) -> HBoxContainer:
583 var bar = HBoxContainer.new()
584 bar.name =
"AchievementFilters"
585 bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
586 bar.add_theme_constant_override(
"separation", 8)
587 var state_button = _create_filter_option(
"State", [
"all",
"locked",
"unlocked"], str(current_filters.get(
"state",
"all")), changed_callback)
588 bar.add_child(state_button)
589 var game_values = options.get(
"game_ids", [])
590 if not game_values.is_empty():
591 bar.add_child(_create_filter_option(
"Game", game_values, str(current_filters.get(
"game_id",
"all")), changed_callback))
592 var category_values = options.get(
"categories", [])
593 if not category_values.is_empty():
594 bar.add_child(_create_filter_option(
"Category", category_values, str(current_filters.get(
"category",
"all")), changed_callback))
598func _create_filter_option(label:String, values:Array, selected_value:String, changed_callback:Callable) -> OptionButton:
599 var option = OptionButton.new()
600 option.name =
"%sFilter" % label
601 option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
603 option.add_item(str(value).replace(
"_",
" ").capitalize())
604 option.set_item_metadata(option.item_count - 1, str(value))
605 if str(value) == selected_value:
606 option.select(option.item_count - 1)
607 if option.selected < 0
and option.item_count > 0:
609 if changed_callback.is_valid():
610 option.item_selected.connect(func(index): changed_callback.call(label.to_lower(), str(option.get_item_metadata(index))))
614func _create_achievement_summary(achievements:Array) -> Label:
618 for achievement
in achievements:
619 if not achievement
is Dictionary:
621 if bool(achievement.get(
"unlocked", false)):
625 var category = str(achievement.get(
"category",
"general"))
626 categories[category] = true
627 var summary = Label.new()
628 summary.name =
"Status"
629 summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
630 summary.text =
"%d unlocked / %d locked - %d categories" % [unlocked, locked, categories.keys().
size()]
634func _achievement_row_style(bg:Color, border:Color) -> StyleBoxFlat:
635 var cache_key :=
"achievement_row|%s|%s" % [bg.to_html(true), border.to_html(true)]
636 if style_cache.has(cache_key):
637 style_cache_hits += 1
638 return style_cache[cache_key]
639 style_cache_misses += 1
640 var style = StyleBoxFlat.new()
642 style.border_color = border
643 style.set_border_width_all(1)
644 style.set_corner_radius_all(6)
645 style.content_margin_left = 8
646 style.content_margin_right = 8
647 style.content_margin_top = 6
648 style.content_margin_bottom = 6
649 style_cache[cache_key] = style
654func create_pause_overlay(actions:Dictionary = {}) -> PopupPanel:
655 var popup = PopupPanel.new()
656 popup.name =
"PauseOverlay"
658 var content = VBoxContainer.new()
659 content.name =
"Content"
660 apply_full_rect(content, 14)
661 content.add_theme_constant_override(
"separation", 10)
662 popup.add_child(content)
663 var title = Label.new()
665 title.text = str(actions.get(
"title",
"Paused"))
666 title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
667 content.add_child(title)
668 for action_id
in [
"resume",
"settings",
"input",
"stats",
"achievements",
"main_menu",
"quit"]:
669 if not actions.has(action_id):
671 var action = actions[action_id]
672 if not action
is Dictionary:
674 var button = Button.new()
675 button.name =
"%sButton" % action_id.capitalize()
676 button.text = str(action.get(
"label", action_id.capitalize()))
677 _apply_button_icon(button, str(action.get(
"sprite_id", _default_pause_sprite(action_id))), 26)
678 button.custom_minimum_size =
Vector2(0, 52)
679 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
680 var callback = action.get(
"callback",
Callable())
681 if callback
is Callable
and callback.is_valid():
682 button.pressed.connect(callback)
683 content.add_child(button)
687func _ensure_ui_sprites_loaded() -> void:
690 sprite_manager.load_packs([
691 "res://assets/sprites/ui/ui_core.json",
692 "res://assets/sprites/ui/ui_navigation.json"
694 sprites_loaded = true
697func _make_sprite_icon(sprite_id:String, size:int = 28):
700 _ensure_ui_sprites_loaded()
701 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
704 var icon = TextureRect.new()
705 icon.texture = texture
706 icon.custom_minimum_size =
Vector2(size, size)
707 icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
708 icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
709 icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
710 icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER
711 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
715func _apply_button_icon(button:Button, sprite_id:String, size:int = 24) -> void:
718 _ensure_ui_sprites_loaded()
719 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
722 button.icon = texture
723 button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
726func _default_pause_sprite(action_id:String) -> String:
729 "settings":
"settings_gear",
732 "achievements":
"achievement_star",
739func _apply_theme_recursive(node:Node, theme_name:String, colors:Dictionary) -> void:
742 var styles = get_theme_styles(theme_name)
743 if node
is PanelContainer
or node
is PopupPanel:
744 node.add_theme_stylebox_override(
"panel", make_panel_style(theme_name))
745 _apply_style_target(node,
"panel", styles)
747 var color_key =
"muted" if str(node.name).to_lower().contains(
"description")
or str(node.name).to_lower().contains(
"info")
or str(node.name).to_lower().contains(
"status")
else "text"
748 node.add_theme_color_override(
"font_color", colors[color_key])
749 if node
is Button
or node
is OptionButton
or node
is CheckButton:
750 node.add_theme_color_override(
"font_color", colors[
"text"])
751 node.add_theme_stylebox_override(
"normal", make_button_style(theme_name,
"normal"))
752 node.add_theme_stylebox_override(
"hover", make_button_style(theme_name,
"hover"))
753 node.add_theme_stylebox_override(
"pressed", make_button_style(theme_name,
"pressed"))
754 node.add_theme_stylebox_override(
"disabled", make_button_style(theme_name,
"disabled"))
755 _apply_style_target(node,
"button", styles)
757 node.add_theme_color_override(
"font_color", colors[
"text"])
758 node.add_theme_color_override(
"font_selected_color", colors[
"text"])
759 for child
in node.get_children():
760 _apply_theme_recursive(child, theme_name, colors)
763func _normalize_theme_definition(definition) -> Dictionary:
764 var base := THEME_PRESETS[
"dark"].duplicate(true)
765 if not definition
is Dictionary:
767 var source:Dictionary = definition
768 if source.has(
"colors")
and source[
"colors"]
is Dictionary:
769 base[
"name"] = str(source.get(
"name", base.get(
"name",
"Dark")))
770 base[
"description"] = str(source.get(
"description",
""))
771 if source.get(
"typography", {})
is Dictionary:
772 base[
"typography"] = source.get(
"typography", {})
773 if source.get(
"styles", {})
is Dictionary:
774 base[
"styles"] = source.get(
"styles", {})
775 for key
in source[
"colors"].keys():
776 if _is_color_like(source[
"colors"][key]):
777 base[key] = source[
"colors"][key]
779 for key
in source.keys():
780 if key ==
"styles" and source[key]
is Dictionary:
781 base[
"styles"] = source[key]
782 elif key ==
"typography" and source[key]
is Dictionary:
783 base[
"typography"] = source[key]
784 elif key ==
"name" or key ==
"description":
785 base[key] = source[key]
786 elif _is_color_like(source[key]):
787 base[key] = source[key]
788 base[
"panel"] = base.get(
"panel", base.get(
"surface",
"#1a222b"))
789 base[
"panel_alt"] = base.get(
"panel_alt", base.get(
"surface_alt",
"#202a34"))
790 base[
"button"] = base.get(
"button", base.get(
"surface_alt",
"#202a34"))
791 base[
"button_hover"] = base.get(
"button_hover", base.get(
"button",
"#202a34"))
792 base[
"button_pressed"] = base.get(
"button_pressed", base.get(
"surface",
"#1a222b"))
793 base[
"button_disabled"] = base.get(
"button_disabled", base.get(
"surface",
"#1a222b"))
797func _is_color_like(value) -> bool:
801 return Color.html_is_valid(str(value).strip_edges())
805func _coerce_theme_color(value, fallback:Color) -> Color:
809 var text := str(value).strip_edges()
810 if Color.html_is_valid(text):
811 return Color.html(text)
815func _apply_style_target(node:Node, target:String, styles:Dictionary) -> void:
816 if not styles.has(target)
or not styles[target]
is Dictionary:
818 if not node
is Control:
820 var style:Dictionary = styles[target]
821 var image_path := str(style.get(
"background_image",
""))
822 if image_path ==
"" or not ResourceLoader.exists(image_path):
824 var texture = sprite_manager.get_texture_for_path(image_path)
827 var control := node
as Control
828 if control.get_node_or_null(
"ThemeBackgroundImage") != null:
830 var image = TextureRect.new()
831 image.name =
"ThemeBackgroundImage"
832 image.texture = texture
833 image.modulate.a = clampf(float(style.get(
"opacity", 1.0)), 0.0, 1.0)
834 image.mouse_filter = Control.MOUSE_FILTER_IGNORE
835 image.set_anchors_preset(Control.PRESET_FULL_RECT)
836 match str(style.get(
"texture_mode",
"stretch")):
838 image.stretch_mode = TextureRect.STRETCH_TILE
840 image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
842 image.stretch_mode = TextureRect.STRETCH_SCALE
843 control.add_child(image)
844 control.move_child(image, 0)
847func _merge_dict(base:Dictionary, override:Dictionary) -> Dictionary:
848 var merged := base.duplicate(true)
849 for key
in override.keys():
850 if merged.get(key)
is Dictionary
and override[key]
is Dictionary:
851 merged[key] = _merge_dict(merged[key], override[key])
853 merged[key] = override[key]
857func _theme_catalog_paths(primary_path:String) -> Array:
858 var paths := [primary_path]
859 if primary_path ==
"res://data/themes/default.json" and FileAccess.file_exists(CUSTOM_THEME_CATALOG_PATH):
860 paths.append(CUSTOM_THEME_CATALOG_PATH)
864func _coerce_size(value, fallback:Vector2i) -> Vector2i:
865 if value
is Vector2i:
869 if value
is Dictionary:
870 return Vector2i(
int(value.get(
"x", fallback.x)),
int(value.get(
"y", fallback.y)))
874func _apply_density_recursive(node:Node, density_name:String) -> void:
876 apply_density(node, density_name)
877 for child
in node.get_children():
878 _apply_density_recursive(child, density_name)
882func create_confirmation_dialog(title:String, message:String, confirm_label:String =
"Confirm", cancel_label:String =
"Cancel") -> PanelContainer:
883 var panel = create_panel_shell(
"ConfirmationDialog")
884 var content = panel.get_node(
"Content")
885 var title_label = Label.new()
886 title_label.text = title
887 title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
888 content.add_child(title_label)
889 var message_label = Label.new()
890 message_label.text = message
891 message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
892 content.add_child(message_label)
893 var actions = HBoxContainer.new()
894 actions.name =
"Actions"
895 content.add_child(actions)
896 var confirm = Button.new()
897 confirm.name =
"Confirm"
898 confirm.text = confirm_label
899 confirm.size_flags_horizontal = Control.SIZE_EXPAND_FILL
900 actions.add_child(confirm)
901 var cancel = Button.new()
902 cancel.name =
"Cancel"
903 cancel.text = cancel_label
904 cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
905 actions.add_child(cancel)
Vector2i
Return a mobile-safe popup size for a viewport.
int
Apply full-rect anchors and optional margins to a Control.
Callable
Create a reusable save-slot list from slot dictionaries.