RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
layout_manager.gd
Go to the documentation of this file.
1extends RefCounted
2
3
9
10const PHONE_WIDTH := 600
11const TABLET_WIDTH := 1000
12
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}
17}
18
19const BREAKPOINT_DENSITIES := {
20 "phone": "compact",
21 "tablet": "comfortable",
22 "desktop": "spacious"
23}
24
25const THEME_PRESETS := {
26 "dark": {
27 "name": "Midnight",
28 "background": "#111820",
29 "surface": "#1a222b",
30 "surface_alt": "#202a34",
31 "panel": "#1a222b",
32 "panel_alt": "#202a34",
33 "button": "#202a34",
34 "button_hover": "#273442",
35 "button_pressed": "#18202a",
36 "button_disabled": "#161d25",
37 "border": "#33404d",
38 "text": "#eef3f8",
39 "muted": "#aab6c4",
40 "accent": "#f2c86b",
41 "positive": "#55d17a",
42 "negative": "#ef5f68",
43 "warning": "#ffd166"
44 }
45}
46
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}
51}
52const THEME_COLOR_KEYS := [
53 "background",
54 "surface",
55 "surface_alt",
56 "panel",
57 "panel_alt",
58 "button",
59 "button_hover",
60 "button_pressed",
61 "button_disabled",
62 "border",
63 "text",
64 "muted",
65 "accent",
66 "positive",
67 "negative",
68 "warning"
69]
70const CUSTOM_THEME_CATALOG_PATH := "user://custom_themes.json"
71
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
76var style_cache := {}
77var style_cache_hits:int = 0
78var style_cache_misses:int = 0
79
80
81
82func load_theme_file(file_path:String) -> void:
83 clear_style_cache()
84 for catalog_path in _theme_catalog_paths(file_path):
85 var file = FileAccess.open(catalog_path, FileAccess.READ)
86 if file == null:
87 continue
88 var data = JSON.parse_string(file.get_as_text())
89 if not data is Dictionary:
90 continue
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]
95
96
97
98func get_layout_mode(viewport_size:Vector2) -> String:
99 return "mobile" if viewport_size.x <= PHONE_WIDTH else "desktop"
100
101
102
103func get_breakpoint(viewport_size:Vector2) -> String:
104 if viewport_size.x <= PHONE_WIDTH:
105 return "phone"
106 if viewport_size.x <= TABLET_WIDTH:
107 return "tablet"
108 return "desktop"
109
110
111
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)
117
118
119
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"])
123 var colors := {}
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":
128 continue
129 if _is_color_like(source[key]):
130 colors[key] = _coerce_theme_color(source[key], Color.WHITE)
131 return colors
132
133
134
135func get_theme_catalog() -> Dictionary:
136 var catalog := {}
137 for theme_id in theme_presets.keys():
138 var definition = _normalize_theme_definition(theme_presets[theme_id])
139 catalog[theme_id] = {
140 "id": 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)
146 }
147 return catalog
148
149
150
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)
157
158
159
160func get_custom_draw_palette(name:String = "dark", domain:String = "generic") -> Dictionary:
161 var colors = get_theme_colors(name)
162 match domain:
163 "reflecting_pool":
164 return {
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)
172 }
173 return colors
174
175
176
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
195 return style
196
197
198
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"
208 match state:
209 "hover":
210 bg_key = "button_hover"
211 "pressed":
212 bg_key = "button_pressed"
213 "disabled":
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
224 return style
225
226
227
228func clear_style_cache() -> void:
229 style_cache.clear()
230 style_cache_hits = 0
231 style_cache_misses = 0
232
233
234
235func get_cache_stats() -> Dictionary:
236 return {
237 "styleboxes": style_cache.size(),
238 "style_cache_hits": style_cache_hits,
239 "style_cache_misses": style_cache_misses
240 }
241
242
243
244func apply_theme(root:Node, theme_name:String = "dark") -> void:
245 if root == null:
246 return
247 var colors = get_theme_colors(theme_name)
248 _apply_theme_recursive(root, theme_name, colors)
249
250
251
252func apply_screen_background(root:Control, theme_name:String = "dark") -> void:
253 _apply_style_target(root, "screen", get_theme_styles(theme_name))
254
255
256
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"]))
264
265
266
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)
275
276
277
278func create_screen_shell(name:String = "ScreenShell", options:Dictionary = {}) -> VBoxContainer:
279 var shell = VBoxContainer.new()
280 shell.name = name
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)):
294 shell.add_child(nav)
295 return shell
296
297
298
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
305
306
307
308func fit_popup_to_viewport(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> Vector2i:
309 if popup == null:
310 return Vector2i.ZERO
311 var requested = _coerce_size(options.get("size", popup.size), Vector2i(360, 420))
312 var margin := int(options.get("margin", 24))
313 var minimum = _coerce_size(options.get("minimum", Vector2i(280, 180)), Vector2i(280, 180))
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
322 return popup_size
323
324
325
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))
329 return Vector2i(
330 clamp(preferred.x, effective_min.x, available.x),
331 clamp(preferred.y, effective_min.y, available.y)
332 )
333
334
335
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)
342
343
344
345func create_panel_shell(name:String = "PanelShell") -> PanelContainer:
346 var panel = PanelContainer.new()
347 panel.name = name
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)
355 return panel
356
357
358
359func create_scroll_shell(name:String = "ScrollShell") -> ScrollContainer:
360 var scroll = ScrollContainer.new()
361 scroll.name = name
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)
369 return scroll
370
371
372
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
378 for item in items:
379 if not item is Dictionary:
380 continue
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)
386 var callback = item.get("callback", Callable())
387 if callback is Callable and callback.is_valid():
388 button.pressed.connect(callback)
389 nav.add_child(button)
390 return nav
391
392
393
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
398 for slot in slots:
399 if slot is Dictionary:
400 list.add_child(create_save_slot_row(slot, open_callback, delete_callback))
401 return list
402
403
404
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)
427 return row
428
429
430
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)
435 if icon != null:
436 content.add_child(icon)
437 var title = Label.new()
438 title.name = "Title"
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)
446 return panel
447
448
449
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)
463 return scroll
464 for achievement in achievements:
465 if not achievement is Dictionary:
466 continue
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"))
470 row_button.text = ""
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()
488 row.name = "Row"
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)))
495 if icon != null:
496 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
497 icon.custom_minimum_size = Vector2(34, 34)
498 row.add_child(icon)
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()
530 meta.name = "Meta"
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)
543 return scroll
544
545
546func _achievement_title(achievement:Dictionary) -> String:
547 var title = str(achievement.get("title", achievement.get("name", ""))).strip_edges()
548 if title != "":
549 return title
550 var id = str(achievement.get("id", "Achievement")).replace("-", "_")
551 var words := []
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"
556
557
558func _achievement_meta(achievement:Dictionary) -> String:
559 var parts := [
560 "Unlocked" if bool(achievement.get("unlocked", false)) else "Locked",
561 str(achievement.get("category", "general")).replace("_", " ").capitalize()
562 ]
563 var date_text = _achievement_unlock_date(achievement)
564 if date_text != "":
565 parts.append(date_text)
566 return " | ".join(parts)
567
568
569func _achievement_unlock_date(achievement:Dictionary) -> String:
570 if not bool(achievement.get("unlocked", false)):
571 return ""
572 if not achievement.has("unlocked_at"):
573 return ""
574 var timestamp := int(achievement.get("unlocked_at", 0))
575 if timestamp <= 0:
576 return ""
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))]
579
580
581
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))
595 return bar
596
597
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
602 for value in values:
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:
608 option.select(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))))
611 return option
612
613
614func _create_achievement_summary(achievements:Array) -> Label:
615 var unlocked := 0
616 var locked := 0
617 var categories := {}
618 for achievement in achievements:
619 if not achievement is Dictionary:
620 continue
621 if bool(achievement.get("unlocked", false)):
622 unlocked += 1
623 else:
624 locked += 1
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()]
631 return summary
632
633
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()
641 style.bg_color = bg
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
650 return style
651
652
653
654func create_pause_overlay(actions:Dictionary = {}) -> PopupPanel:
655 var popup = PopupPanel.new()
656 popup.name = "PauseOverlay"
657 popup.size = Vector2i(340, 500)
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()
664 title.name = "Title"
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):
670 continue
671 var action = actions[action_id]
672 if not action is Dictionary:
673 continue
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)
684 return popup
685
686
687func _ensure_ui_sprites_loaded() -> void:
688 if sprites_loaded:
689 return
690 sprite_manager.load_packs([
691 "res://assets/sprites/ui/ui_core.json",
692 "res://assets/sprites/ui/ui_navigation.json"
693 ])
694 sprites_loaded = true
695
696
697func _make_sprite_icon(sprite_id:String, size:int = 28):
698 if sprite_id == "":
699 return null
700 _ensure_ui_sprites_loaded()
701 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
702 if texture == null:
703 return null
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
712 return icon
713
714
715func _apply_button_icon(button:Button, sprite_id:String, size:int = 24) -> void:
716 if sprite_id == "":
717 return
718 _ensure_ui_sprites_loaded()
719 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
720 if texture == null:
721 return
722 button.icon = texture
723 button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
724
725
726func _default_pause_sprite(action_id:String) -> String:
727 return {
728 "resume": "play",
729 "settings": "settings_gear",
730 "input": "keyboard",
731 "stats": "trophy",
732 "achievements": "achievement_star",
733 "main_menu": "home",
734 "back": "back",
735 "quit": "cancel"
736 }.get(action_id, "")
737
738
739func _apply_theme_recursive(node:Node, theme_name:String, colors:Dictionary) -> void:
740 if node == null:
741 return
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)
746 if node is Label:
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)
756 if node is ItemList:
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)
761
762
763func _normalize_theme_definition(definition) -> Dictionary:
764 var base := THEME_PRESETS["dark"].duplicate(true)
765 if not definition is Dictionary:
766 return base
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]
778 else:
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"))
794 return base
795
796
797func _is_color_like(value) -> bool:
798 if value is Color:
799 return true
800 if value is String:
801 return Color.html_is_valid(str(value).strip_edges())
802 return false
803
804
805func _coerce_theme_color(value, fallback:Color) -> Color:
806 if value is Color:
807 return value
808 if value is String:
809 var text := str(value).strip_edges()
810 if Color.html_is_valid(text):
811 return Color.html(text)
812 return fallback
813
814
815func _apply_style_target(node:Node, target:String, styles:Dictionary) -> void:
816 if not styles.has(target) or not styles[target] is Dictionary:
817 return
818 if not node is Control:
819 return
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):
823 return
824 var texture = sprite_manager.get_texture_for_path(image_path)
825 if texture == null:
826 return
827 var control := node as Control
828 if control.get_node_or_null("ThemeBackgroundImage") != null:
829 return
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")):
837 "tile":
838 image.stretch_mode = TextureRect.STRETCH_TILE
839 "keep":
840 image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
841 _:
842 image.stretch_mode = TextureRect.STRETCH_SCALE
843 control.add_child(image)
844 control.move_child(image, 0)
845
846
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])
852 else:
853 merged[key] = override[key]
854 return merged
855
856
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)
861 return paths
862
863
864func _coerce_size(value, fallback:Vector2i) -> Vector2i:
865 if value is Vector2i:
866 return value
867 if value is Vector2:
868 return Vector2i(int(value.x), int(value.y))
869 if value is Dictionary:
870 return Vector2i(int(value.get("x", fallback.x)), int(value.get("y", fallback.y)))
871 return fallback
872
873
874func _apply_density_recursive(node:Node, density_name:String) -> void:
875 if node is Control:
876 apply_density(node, density_name)
877 for child in node.get_children():
878 _apply_density_recursive(child, density_name)
879
880
881
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)
906 return panel
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.