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
280func apply_vertical_budget(page:Control, page_height:float, fixed_specs:Array = [], flex_specs:Array = [], options:Dictionary = {}) -> Dictionary:
281 var result := {"page_height": maxf(0.0, page_height), "fixed_total": 0.0, "flex_total": 0.0}
282 if page == null or page_height <= 0.0:
283 return result
284 var gap:float = float(options.get("gap", _container_gap(page)))
285 var padding:float = float(options.get("padding", 0.0))
286 var fit_within_page:bool = bool(options.get("fit_within_page", true))
287 var visible_fixed := []
288 var visible_flex := []
289 for spec in fixed_specs:
290 if not spec is Dictionary:
291 continue
292 var control = spec.get("control", null)
293 if control is Control and control.visible:
294 visible_fixed.append(spec)
295 for spec in flex_specs:
296 if not spec is Dictionary:
297 continue
298 var control = spec.get("control", null)
299 if control is Control and control.visible:
300 visible_flex.append(spec)
301 var visible_count:int = visible_fixed.size() + visible_flex.size()
302 var gap_total:float = gap * float(max(0, visible_count - 1))
303 var fixed_total:float = padding
304 var fixed_heights := []
305 for spec in visible_fixed:
306 var control:Control = spec.get("control")
307 var height:float = float(spec.get("height", control.custom_minimum_size.y))
308 height = clampf(height, float(spec.get("min_height", height)), float(spec.get("max_height", height)))
309 fixed_heights.append(height)
310 fixed_total += height
311 fixed_total += gap_total
312 var flex_floor_total:float = 0.0
313 var flex_min_total:float = 0.0
314 for spec in visible_flex:
315 flex_floor_total += maxf(0.0, float(spec.get("floor_height", 0.0)))
316 flex_min_total += maxf(0.0, float(spec.get("min_height", 80.0)))
317 if fit_within_page and fixed_heights.size() > 0 and fixed_total + flex_floor_total > page_height:
318 var available_for_fixed:float = maxf(0.0, page_height - padding - gap_total - flex_floor_total)
319 var desired_fixed:float = maxf(0.01, fixed_total - padding - gap_total)
320 var scale:float = clampf(available_for_fixed / desired_fixed, 0.0, 1.0)
321 fixed_total = padding + gap_total
322 for index in range(fixed_heights.size()):
323 var spec:Dictionary = visible_fixed[index]
324 var floor_height:float = float(spec.get("floor_height", 0.0))
325 var scaled_height:float = maxf(minf(floor_height, available_for_fixed), float(fixed_heights[index]) * scale)
326 fixed_heights[index] = scaled_height
327 fixed_total += scaled_height
328 for index in range(visible_fixed.size()):
329 var fixed_control:Control = visible_fixed[index].get("control")
330 fixed_control.custom_minimum_size.y = float(fixed_heights[index])
331 var available:float = maxf(0.0, page_height - fixed_total)
332 var total_weight:float = 0.0
333 for spec in visible_flex:
334 total_weight += maxf(0.01, float(spec.get("weight", 1.0)))
335 for spec in visible_flex:
336 var control:Control = spec.get("control")
337 var weight:float = maxf(0.01, float(spec.get("weight", 1.0)))
338 var target:float = available * weight / maxf(0.01, total_weight)
339 var min_height:float = float(spec.get("min_height", 80.0))
340 var max_height:float = float(spec.get("max_height", maxf(page_height, target)))
341 var floor_height:float = float(spec.get("floor_height", 0.0))
342 if fit_within_page:
343 if available <= flex_floor_total and flex_floor_total > 0.0:
344 target = available * maxf(0.0, floor_height) / flex_floor_total
345 elif available >= flex_min_total:
346 target = minf(target, max_height)
347 target = maxf(min_height, target)
348 else:
349 target = available * maxf(0.0, min_height) / maxf(0.01, flex_min_total)
350 target = minf(target, max_height)
351 else:
352 target = clampf(target, min_height, max_height)
353 control.custom_minimum_size.y = target
354 result["flex_total"] = float(result["flex_total"]) + target
355 result["fixed_total"] = fixed_total
356 if bool(options.get("measure_recursive_fit", false)):
357 var footprint:float = measure_vertical_footprint(page)
358 result["measured_total"] = footprint
359 result["overflow"] = maxf(0.0, footprint - page_height)
360 result["fits"] = footprint <= page_height + float(options.get("fit_tolerance", 1.0))
361 return result
362
363
364func _container_gap(control:Control) -> float:
365 if control is BoxContainer:
366 return float(control.get_theme_constant("separation"))
367 return 0.0
368
369
370
373func measure_vertical_footprint(control:Control) -> float:
374 if control == null or not control.visible:
375 return 0.0
376 var own_height:float = control.custom_minimum_size.y
377 if control is ScrollContainer:
378 return own_height
379 if control is GridContainer:
380 return own_height
381 if control is HBoxContainer:
382 var row_height:float = 0.0
383 for child in control.get_children():
384 if child is Control and child.visible:
385 row_height = maxf(row_height, measure_vertical_footprint(child))
386 return maxf(own_height, row_height)
387 if control is VBoxContainer:
388 var stack_total:float = 0.0
389 var stack_visible_count:int = 0
390 for child in control.get_children():
391 if child is Control and child.visible:
392 stack_total += measure_vertical_footprint(child)
393 stack_visible_count += 1
394 if stack_visible_count > 1:
395 stack_total += _container_gap(control) * float(stack_visible_count - 1)
396 return maxf(own_height, stack_total)
397 if control is Container:
398 var child_height:float = 0.0
399 for child in control.get_children():
400 if child is Control and child.visible:
401 child_height = maxf(child_height, measure_vertical_footprint(child))
402 return maxf(own_height, child_height)
403 return own_height
404
405
406
408func vertical_budget_fits(page:Control, page_height:float, tolerance:float = 1.0) -> bool:
409 if page == null:
410 return true
411 return measure_vertical_footprint(page) <= page_height + tolerance
412
413
414
416func control_fits_viewport(control:Control, viewport_size:Vector2, tolerance:float = 1.0) -> bool:
417 if control == null:
418 return true
419 var rect:Rect2 = control.get_global_rect()
420 return rect.position.x >= -tolerance \
421 and rect.position.y >= -tolerance \
422 and rect.end.x <= viewport_size.x + tolerance \
423 and rect.end.y <= viewport_size.y + tolerance
424
425
426
427func create_screen_shell(name:String = "ScreenShell", options:Dictionary = {}) -> VBoxContainer:
428 var shell = VBoxContainer.new()
429 shell.name = name
430 shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
431 shell.size_flags_vertical = Control.SIZE_EXPAND_FILL
432 var header = HBoxContainer.new()
433 header.name = "Header"
434 header.custom_minimum_size = Vector2(0, int(options.get("header_height", 54)))
435 shell.add_child(header)
436 var body = create_scroll_shell("Body")
437 body.size_flags_vertical = Control.SIZE_EXPAND_FILL
438 shell.add_child(body)
439 var nav = HBoxContainer.new()
440 nav.name = "Navigation"
441 nav.custom_minimum_size = Vector2(0, int(options.get("nav_height", 58)))
442 if bool(options.get("show_navigation", true)):
443 shell.add_child(nav)
444 return shell
445
446
447
448func apply_full_rect(control:Control, margin:int = 0) -> void:
449 control.set_anchors_preset(Control.PRESET_FULL_RECT)
450 control.offset_left = margin
451 control.offset_top = margin
452 control.offset_right = -margin
453 control.offset_bottom = -margin
454
455
456
457func fit_popup_to_viewport(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> Vector2i:
458 if popup == null:
459 return Vector2i.ZERO
460 var requested = _coerce_size(options.get("size", popup.size), Vector2i(360, 420))
461 var margin := int(options.get("margin", 24))
462 var minimum = _coerce_size(options.get("minimum", Vector2i(280, 180)), Vector2i(280, 180))
463 var popup_size = popup_size_for_viewport(viewport_size, requested, minimum, margin)
464 if bool(options.get("fixed_size", false)):
465 popup.min_size = popup_size
466 else:
467 popup.min_size = Vector2i(min(minimum.x, popup_size.x), min(minimum.y, popup_size.y))
468 popup.size = popup_size
469 var content = popup.get_node_or_null("Content")
470 if content is Control:
471 apply_full_rect(content, int(options.get("content_margin", 14)))
472 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
473 content.size_flags_vertical = Control.SIZE_EXPAND_FILL
474 return popup_size
475
476
477
478func popup_size_for_viewport(viewport_size:Vector2, preferred:Vector2i = Vector2i(420, 560), minimum:Vector2i = Vector2i(280, 180), margin:int = 24) -> Vector2i:
479 var available = Vector2i(max(1, int(viewport_size.x) - margin), max(1, int(viewport_size.y) - margin))
480 var effective_min = Vector2i(min(minimum.x, available.x), min(minimum.y, available.y))
481 return Vector2i(
482 clamp(preferred.x, effective_min.x, available.x),
483 clamp(preferred.y, effective_min.y, available.y)
484 )
485
486
487
488func popup_centered_standard(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> void:
489 var resolved_options := options.duplicate(true)
490 # A standard popup's requested/clamped size is authoritative. PopupPanel's
491 # default wrapping can otherwise expand it again after child layout resolves.
492 if not resolved_options.has("fixed_size"):
493 resolved_options["fixed_size"] = true
494 popup.wrap_controls = false
495 var popup_size = fit_popup_to_viewport(popup, viewport_size, resolved_options)
496 if popup_size != Vector2i.ZERO:
497 popup.size = popup_size
498 if popup_size != Vector2i.ZERO and popup.is_inside_tree():
499 popup.popup_centered(popup_size)
500
501
502
503func create_scroll_popup_shell(popup:PopupPanel, title_text:String = "", options:Dictionary = {}) -> Dictionary:
504 var content:VBoxContainer = VBoxContainer.new()
505 content.name = str(options.get("content_name", "Content"))
506 content.add_theme_constant_override("separation", int(options.get("separation", 10)))
507 popup.add_child(content)
508 var title:Label = null
509 if title_text != "":
510 title = Label.new()
511 title.name = "Title"
512 title.text = title_text
513 title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
514 title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
515 title.add_theme_font_size_override("font_size", int(options.get("title_font_size", 22)))
516 content.add_child(title)
517 var scroll:ScrollContainer = ScrollContainer.new()
518 scroll.name = str(options.get("scroll_name", "Scroll"))
519 scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
520 scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
521 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
522 content.add_child(scroll)
523 var body:VBoxContainer = VBoxContainer.new()
524 body.name = str(options.get("body_name", "Body"))
525 body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
526 body.add_theme_constant_override("separation", int(options.get("body_separation", 10)))
527 scroll.add_child(body)
528 var actions:HBoxContainer = HBoxContainer.new()
529 actions.name = str(options.get("actions_name", "Actions"))
530 actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
531 actions.add_theme_constant_override("separation", int(options.get("action_separation", 8)))
532 content.add_child(actions)
533 return {
534 "content": content,
535 "title": title,
536 "scroll": scroll,
537 "body": body,
538 "actions": actions
539 }
540
541
542
543func create_stat_table(rows:Array, options:Dictionary = {}) -> Dictionary:
544 var frame := MarginContainer.new()
545 frame.name = str(options.get("frame_name", "%sFrame" % str(options.get("name", "StatTable"))))
546 frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
547 frame.add_theme_constant_override("margin_right", int(options.get("right_padding", 18)))
548 var table := GridContainer.new()
549 table.name = str(options.get("name", "StatTable"))
550 table.columns = 3
551 table.size_flags_horizontal = Control.SIZE_EXPAND_FILL
552 table.add_theme_constant_override("h_separation", int(options.get("column_separation", 8)))
553 table.add_theme_constant_override("v_separation", int(options.get("row_separation", 6)))
554 var values:Array[Label] = []
555 for index in range(rows.size()):
556 var row:Dictionary = rows[index] if rows[index] is Dictionary else {"label":str(rows[index]), "value":""}
557 var label := Label.new()
558 label.name = "Label%d" % index
559 label.text = str(row.get("label", ""))
560 label.set_meta("text_size_role", str(options.get("text_size_role", "body")))
561 table.add_child(label)
562 var spacer := Control.new()
563 spacer.name = "Spacer%d" % index
564 spacer.custom_minimum_size = Vector2(float(options.get("minimum_spacing", 12)), 0)
565 spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
566 spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
567 table.add_child(spacer)
568 var value := Label.new()
569 value.name = "Value%d" % index
570 value.text = str(row.get("value", ""))
571 value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
572 value.set_meta("text_size_role", str(options.get("text_size_role", "body")))
573 table.add_child(value)
574 values.append(value)
575 frame.add_child(table)
576 return {"container":frame, "table":table, "values":values}
577
578
579
580func create_panel_shell(name:String = "PanelShell") -> PanelContainer:
581 var panel = PanelContainer.new()
582 panel.name = name
583 panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
584 panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
585 var content = VBoxContainer.new()
586 content.name = "Content"
587 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
588 content.size_flags_vertical = Control.SIZE_EXPAND_FILL
589 panel.add_child(content)
590 return panel
591
592
593
594func create_scroll_shell(name:String = "ScrollShell") -> ScrollContainer:
595 var scroll = ScrollContainer.new()
596 scroll.name = name
597 scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
598 scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
599 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
600 var content = VBoxContainer.new()
601 content.name = "Content"
602 content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
603 scroll.add_child(content)
604 return scroll
605
606
607
609func wrap_label_in_scroll_frame(label:Label, options:Dictionary = {}) -> Dictionary:
610 if label == null:
611 return {}
612 if label.get_parent() is ScrollContainer:
613 var existing_scroll:ScrollContainer = label.get_parent()
614 return {
615 "frame": existing_scroll.get_parent() if existing_scroll.get_parent() is PanelContainer else null,
616 "scroll": existing_scroll,
617 "label": label
618 }
619 var parent = label.get_parent()
620 if not parent is Container:
621 return {"label": label}
622 var insert_index := label.get_index()
623 parent.remove_child(label)
624 var frame = PanelContainer.new()
625 frame.name = str(options.get("frame_name", "%sFrame" % label.name))
626 frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
627 var scroll = ScrollContainer.new()
628 scroll.name = str(options.get("scroll_name", "%sScroll" % label.name))
629 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
630 scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
631 scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
632 scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
633 frame.add_child(scroll)
634 label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
635 label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
636 label.custom_minimum_size = Vector2.ZERO
637 label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
638 label.clip_text = false
639 label.max_lines_visible = int(options.get("max_lines", 64))
640 scroll.add_child(label)
641 parent.add_child(frame)
642 parent.move_child(frame, insert_index)
643 return {"frame": frame, "scroll": scroll, "label": label}
644
645
646
648func resize_text_scroll_frame(frame:Control, scroll:Control, label:Label, visible_lines:int, font_size:int, options:Dictionary = {}) -> float:
649 var min_height := float(options.get("min_height", 58.0))
650 var max_height := float(options.get("max_height", 172.0))
651 var padding := float(options.get("padding", 18.0))
652 var line_multiplier := float(options.get("line_multiplier", 1.35))
653 var target_height:float = clampf(padding + (float(max(2, visible_lines)) * float(font_size) * line_multiplier), min_height, max_height)
654 if frame != null:
655 frame.custom_minimum_size.y = target_height
656 if scroll != null:
657 scroll.custom_minimum_size.y = target_height
658 if label != null:
659 label.custom_minimum_size.y = 0.0
660 label.max_lines_visible = int(options.get("max_lines", 64))
661 label.clip_text = false
662 return target_height
663
664
665
666func create_bottom_nav(items:Array) -> HBoxContainer:
667 var nav = HBoxContainer.new()
668 nav.name = "BottomNav"
669 nav.custom_minimum_size = Vector2(0, 58)
670 nav.size_flags_horizontal = Control.SIZE_EXPAND_FILL
671 for item in items:
672 if not item is Dictionary:
673 continue
674 var button = Button.new()
675 button.text = str(item.get("label", "Item"))
676 _apply_button_icon(button, str(item.get("sprite_id", "")), 24)
677 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
678 button.custom_minimum_size = Vector2(0, 54)
679 var callback = item.get("callback", Callable())
680 if callback is Callable and callback.is_valid():
681 button.pressed.connect(callback)
682 nav.add_child(button)
683 return nav
684
685
686
687func create_save_slot_list(slots:Array, open_callback:Callable, delete_callback:Callable = Callable()) -> VBoxContainer:
688 var list = VBoxContainer.new()
689 list.name = "SaveSlotList"
690 list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
691 for slot in slots:
692 if slot is Dictionary:
693 list.add_child(create_save_slot_row(slot, open_callback, delete_callback))
694 return list
695
696
697
698func create_save_slot_row(slot:Dictionary, open_callback:Callable, delete_callback:Callable = Callable()) -> HBoxContainer:
699 var row = HBoxContainer.new()
700 row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
701 row.add_theme_constant_override("separation", 8)
702 var slot_button = Button.new()
703 slot_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
704 slot_button.custom_minimum_size = Vector2(0, 76)
705 slot_button.text = str(slot.get("display_label", "Slot %d" % int(slot.get("slot", 0))))
706 _apply_button_icon(slot_button, "save", 26)
707 if open_callback.is_valid():
708 slot_button.pressed.connect(func(): open_callback.call(slot))
709 row.add_child(slot_button)
710 var delete_button = Button.new()
711 delete_button.custom_minimum_size = Vector2(52, 76)
712 delete_button.text = ""
713 _apply_button_icon(delete_button, "trash", 22)
714 delete_button.expand_icon = true
715 delete_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
716 delete_button.disabled = bool(slot.get("empty", false)) or not delete_callback.is_valid()
717 if delete_callback.is_valid():
718 delete_button.pressed.connect(func(): delete_callback.call(slot))
719 row.add_child(delete_button)
720 return row
721
722
723
724func create_achievement_notification(achievement:Dictionary) -> PanelContainer:
725 var panel = create_panel_shell("AchievementNotification")
726 var content = panel.get_node("Content")
727 var icon = _make_sprite_icon("achievement_star", 34)
728 if icon != null:
729 content.add_child(icon)
730 var title = Label.new()
731 title.name = "Title"
732 title.text = "Achievement: %s" % str(achievement.get("name", achievement.get("id", "Unlocked")))
733 content.add_child(title)
734 var description = Label.new()
735 description.name = "Description"
736 description.text = str(achievement.get("description", ""))
737 description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
738 content.add_child(description)
739 return panel
740
741
742
743func create_achievement_collection(achievements:Array, options:Dictionary = {}) -> ScrollContainer:
744 var scroll = create_scroll_shell("AchievementCollection")
745 scroll.custom_minimum_size = Vector2(0, 220)
746 var content = scroll.get_node("Content")
747 content.add_theme_constant_override("separation", 8)
748 if bool(options.get("show_summary", true)):
749 content.add_child(_create_achievement_summary(achievements))
750 if achievements.is_empty():
751 var empty = Label.new()
752 empty.name = "Status"
753 empty.text = "No achievements yet."
754 empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
755 content.add_child(empty)
756 return scroll
757 for achievement in achievements:
758 if not achievement is Dictionary:
759 continue
760 var row_height := int(options.get("row_height", 126))
761 var row_button = Button.new()
762 row_button.name = "Achievement_%s" % str(achievement.get("id", "row"))
763 row_button.text = ""
764 row_button.custom_minimum_size = Vector2(0, row_height)
765 row_button.set_meta("max_font_size", 1)
766 row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
767 row_button.focus_mode = Control.FOCUS_ALL
768 row_button.add_theme_stylebox_override("normal", _achievement_row_style(Color("#1c2630"), Color("#33404d")))
769 row_button.add_theme_stylebox_override("hover", _achievement_row_style(Color("#25313d"), Color("#f2c86b")))
770 row_button.add_theme_stylebox_override("pressed", _achievement_row_style(Color("#25313d"), Color("#f2c86b")))
771 var margin = MarginContainer.new()
772 margin.name = "RowMargin"
773 margin.set_anchors_preset(Control.PRESET_FULL_RECT)
774 margin.offset_left = 10
775 margin.offset_top = 7
776 margin.offset_right = -10
777 margin.offset_bottom = -7
778 margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
779 row_button.add_child(margin)
780 var row = HBoxContainer.new()
781 row.name = "Row"
782 row.add_theme_constant_override("separation", 8)
783 row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
784 row.size_flags_vertical = Control.SIZE_EXPAND_FILL
785 row.mouse_filter = Control.MOUSE_FILTER_IGNORE
786 var icon = _make_achievement_row_icon(achievement, options)
787 row.add_child(icon)
788 var text_stack = VBoxContainer.new()
789 text_stack.name = "Text"
790 text_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
791 text_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
792 text_stack.add_theme_constant_override("separation", 4)
793 text_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
794 var title = Label.new()
795 title.name = "AchievementName"
796 title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
797 title.clip_text = true
798 title.max_lines_visible = int(options.get("title_lines", 2))
799 title.custom_minimum_size = Vector2(0, int(options.get("title_min_height", 26)))
800 title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
801 title.mouse_filter = Control.MOUSE_FILTER_IGNORE
802 title.set_meta("text_size_role", "body")
803 title.set_meta("max_font_size", int(options.get("title_max_font_size", 17)))
804 title.text = _achievement_title(achievement)
805 text_stack.add_child(title)
806 var description = Label.new()
807 description.name = "Description"
808 description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
809 description.max_lines_visible = int(options.get("description_lines", 2))
810 description.clip_text = true
811 description.custom_minimum_size = Vector2(0, int(options.get("description_min_height", 40)))
812 description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
813 description.mouse_filter = Control.MOUSE_FILTER_IGNORE
814 description.set_meta("text_size_role", "caption")
815 description.set_meta("max_font_size", int(options.get("description_max_font_size", 13)))
816 description.text = str(achievement.get("description", ""))
817 text_stack.add_child(description)
818 var meta = Label.new()
819 meta.name = "Meta"
820 meta.autowrap_mode = TextServer.AUTOWRAP_OFF
821 meta.clip_text = true
822 meta.custom_minimum_size = Vector2(0, int(options.get("meta_min_height", 18)))
823 meta.size_flags_horizontal = Control.SIZE_EXPAND_FILL
824 meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
825 meta.set_meta("text_size_role", "caption")
826 meta.set_meta("max_font_size", int(options.get("meta_max_font_size", 11)))
827 meta.text = _achievement_meta(achievement)
828 text_stack.add_child(meta)
829 row.add_child(text_stack)
830 margin.add_child(row)
831 content.add_child(row_button)
832 return scroll
833
834
835func _achievement_title(achievement:Dictionary) -> String:
836 var title = str(achievement.get("title", achievement.get("name", ""))).strip_edges()
837 if title != "":
838 return title
839 var id = str(achievement.get("id", "Achievement")).replace("-", "_")
840 var words := []
841 for part in id.split("_"):
842 if str(part).strip_edges() != "":
843 words.append(str(part).capitalize())
844 return " ".join(words) if not words.is_empty() else "Achievement"
845
846
847func _make_achievement_row_icon(achievement:Dictionary, options:Dictionary) -> Control:
848 var icon_size := int(options.get("icon_size", 30))
849 var icon_id = str(achievement.get("icon", ""))
850 if icon_id == "":
851 icon_id = "achievement_star" if bool(achievement.get("unlocked", false)) else "lock"
852 var icon = _make_sprite_icon(icon_id, icon_size)
853 if icon != null:
854 icon.name = "Icon"
855 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
856 icon.custom_minimum_size = Vector2(34, 34)
857 return icon
858 var fallback = Label.new()
859 fallback.name = "IconFallback"
860 fallback.text = "★" if bool(achievement.get("unlocked", false)) else "🔒"
861 fallback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
862 fallback.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
863 fallback.custom_minimum_size = Vector2(34, 34)
864 fallback.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
865 fallback.size_flags_vertical = Control.SIZE_SHRINK_CENTER
866 fallback.mouse_filter = Control.MOUSE_FILTER_IGNORE
867 fallback.add_theme_font_size_override("font_size", max(18, icon_size - 4))
868 fallback.add_theme_color_override("font_color", Color("#f2c86b") if bool(achievement.get("unlocked", false)) else Color("#aab6c4"))
869 return fallback
870
871
872func _achievement_meta(achievement:Dictionary) -> String:
873 var parts := [
874 "Unlocked" if bool(achievement.get("unlocked", false)) else "Locked",
875 str(achievement.get("category", "general")).replace("_", " ").capitalize()
876 ]
877 var date_text = _achievement_unlock_date(achievement)
878 if date_text != "":
879 parts.append(date_text)
880 return " | ".join(parts)
881
882
883func _achievement_unlock_date(achievement:Dictionary) -> String:
884 if not bool(achievement.get("unlocked", false)):
885 return ""
886 if not achievement.has("unlocked_at"):
887 return ""
888 var timestamp := int(achievement.get("unlocked_at", 0))
889 if timestamp <= 0:
890 return ""
891 var date = Time.get_datetime_dict_from_unix_time(timestamp)
892 return "%02d-%02d-%04d" % [int(date.get("month", 1)), int(date.get("day", 1)), int(date.get("year", 1970))]
893
894
895
896func create_achievement_filter_bar(options:Dictionary = {}, current_filters:Dictionary = {}, changed_callback:Callable = Callable()) -> HBoxContainer:
897 var bar = HBoxContainer.new()
898 bar.name = "AchievementFilters"
899 bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
900 bar.add_theme_constant_override("separation", 8)
901 var state_button = _create_filter_option("State", ["all", "locked", "unlocked"], str(current_filters.get("state", "all")), changed_callback)
902 bar.add_child(state_button)
903 var game_values = options.get("game_ids", [])
904 if not game_values.is_empty():
905 bar.add_child(_create_filter_option("Game", game_values, str(current_filters.get("game_id", "all")), changed_callback))
906 var category_values = options.get("categories", [])
907 if not category_values.is_empty():
908 bar.add_child(_create_filter_option("Category", category_values, str(current_filters.get("category", "all")), changed_callback))
909 return bar
910
911
912func _create_filter_option(label:String, values:Array, selected_value:String, changed_callback:Callable) -> OptionButton:
913 var option = OptionButton.new()
914 option.name = "%sFilter" % label
915 option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
916 for value in values:
917 option.add_item(str(value).replace("_", " ").capitalize())
918 option.set_item_metadata(option.item_count - 1, str(value))
919 if str(value) == selected_value:
920 option.select(option.item_count - 1)
921 if option.selected < 0 and option.item_count > 0:
922 option.select(0)
923 if changed_callback.is_valid():
924 option.item_selected.connect(func(index): changed_callback.call(label.to_lower(), str(option.get_item_metadata(index))))
925 return option
926
927
928func _create_achievement_summary(achievements:Array) -> Label:
929 var unlocked := 0
930 var locked := 0
931 var categories := {}
932 for achievement in achievements:
933 if not achievement is Dictionary:
934 continue
935 if bool(achievement.get("unlocked", false)):
936 unlocked += 1
937 else:
938 locked += 1
939 var category = str(achievement.get("category", "general"))
940 categories[category] = true
941 var summary = Label.new()
942 summary.name = "Status"
943 summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
944 summary.text = "%d unlocked / %d locked - %d categories" % [unlocked, locked, categories.keys().size()]
945 return summary
946
947
948func _achievement_row_style(bg:Color, border:Color) -> StyleBoxFlat:
949 var cache_key := "achievement_row|%s|%s" % [bg.to_html(true), border.to_html(true)]
950 if style_cache.has(cache_key):
951 style_cache_hits += 1
952 return style_cache[cache_key]
953 style_cache_misses += 1
954 var style = StyleBoxFlat.new()
955 style.bg_color = bg
956 style.border_color = border
957 style.set_border_width_all(1)
958 style.set_corner_radius_all(6)
959 style.content_margin_left = 8
960 style.content_margin_right = 8
961 style.content_margin_top = 6
962 style.content_margin_bottom = 6
963 style_cache[cache_key] = style
964 return style
965
966
967
968func create_pause_overlay(actions:Dictionary = {}) -> PopupPanel:
969 var popup = PopupPanel.new()
970 popup.name = "PauseOverlay"
971 popup.size = Vector2i(340, 500)
972 var content = VBoxContainer.new()
973 content.name = "Content"
974 apply_full_rect(content, 14)
975 content.add_theme_constant_override("separation", 10)
976 popup.add_child(content)
977 var title = Label.new()
978 title.name = "Title"
979 title.text = str(actions.get("title", "Paused"))
980 title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
981 title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
982 title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
983 content.add_child(title)
984 for action_id in ["resume", "settings", "input", "stats", "achievements", "main_menu", "quit"]:
985 if not actions.has(action_id):
986 continue
987 var action = actions[action_id]
988 if not action is Dictionary:
989 continue
990 var button = Button.new()
991 button.name = "%sButton" % action_id.capitalize()
992 button.text = str(action.get("label", action_id.capitalize()))
993 _apply_button_icon(button, str(action.get("sprite_id", _default_pause_sprite(action_id))), 26)
994 button.custom_minimum_size = Vector2(0, 52)
995 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
996 var callback = action.get("callback", Callable())
997 if callback is Callable and callback.is_valid():
998 button.pressed.connect(callback)
999 content.add_child(button)
1000 return popup
1001
1002
1003func _ensure_ui_sprites_loaded() -> void:
1004 if sprites_loaded:
1005 return
1006 sprite_manager.load_packs([
1007 "res://assets/sprites/ui/ui_core.json",
1008 "res://assets/sprites/ui/ui_navigation.json"
1009 ])
1010 sprites_loaded = true
1011
1012
1013func _make_sprite_icon(sprite_id:String, size:int = 28):
1014 if sprite_id == "":
1015 return null
1016 _ensure_ui_sprites_loaded()
1017 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
1018 if texture == null:
1019 return null
1020 var icon = TextureRect.new()
1021 icon.texture = texture
1022 icon.custom_minimum_size = Vector2(size, size)
1023 icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
1024 icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
1025 icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
1026 icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER
1027 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
1028 return icon
1029
1030
1031func _apply_button_icon(button:Button, sprite_id:String, size:int = 24) -> void:
1032 if sprite_id == "":
1033 return
1034 _ensure_ui_sprites_loaded()
1035 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
1036 if texture == null:
1037 return
1038 button.icon = texture
1039 button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
1040
1041
1042func _default_pause_sprite(action_id:String) -> String:
1043 return {
1044 "resume": "play",
1045 "settings": "settings_gear",
1046 "input": "keyboard",
1047 "stats": "trophy",
1048 "achievements": "achievement_star",
1049 "main_menu": "home",
1050 "back": "back",
1051 "quit": "cancel"
1052 }.get(action_id, "")
1053
1054
1055func _apply_theme_recursive(node:Node, theme_name:String, colors:Dictionary) -> void:
1056 if node == null:
1057 return
1058 var styles = get_theme_styles(theme_name)
1059 if node is PanelContainer or node is PopupPanel:
1060 node.add_theme_stylebox_override("panel", make_panel_style(theme_name))
1061 _apply_style_target(node, "panel", styles)
1062 if node is Label:
1063 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"
1064 node.add_theme_color_override("font_color", colors[color_key])
1065 if node is Button or node is OptionButton or node is CheckButton:
1066 node.add_theme_color_override("font_color", colors["text"])
1067 node.add_theme_stylebox_override("normal", make_button_style(theme_name, "normal"))
1068 node.add_theme_stylebox_override("hover", make_button_style(theme_name, "hover"))
1069 node.add_theme_stylebox_override("pressed", make_button_style(theme_name, "pressed"))
1070 node.add_theme_stylebox_override("disabled", make_button_style(theme_name, "disabled"))
1071 _apply_style_target(node, "button", styles)
1072 if node is ItemList:
1073 node.add_theme_color_override("font_color", colors["text"])
1074 node.add_theme_color_override("font_selected_color", colors["text"])
1075 for child in node.get_children():
1076 _apply_theme_recursive(child, theme_name, colors)
1077
1078
1079func _normalize_theme_definition(definition) -> Dictionary:
1080 var base := THEME_PRESETS["dark"].duplicate(true)
1081 if not definition is Dictionary:
1082 return base
1083 var source:Dictionary = definition
1084 if source.has("colors") and source["colors"] is Dictionary:
1085 base["name"] = str(source.get("name", base.get("name", "Dark")))
1086 base["description"] = str(source.get("description", ""))
1087 if source.get("typography", {}) is Dictionary:
1088 base["typography"] = source.get("typography", {})
1089 if source.get("styles", {}) is Dictionary:
1090 base["styles"] = source.get("styles", {})
1091 for key in source["colors"].keys():
1092 if _is_color_like(source["colors"][key]):
1093 base[key] = source["colors"][key]
1094 else:
1095 for key in source.keys():
1096 if key == "styles" and source[key] is Dictionary:
1097 base["styles"] = source[key]
1098 elif key == "typography" and source[key] is Dictionary:
1099 base["typography"] = source[key]
1100 elif key == "name" or key == "description":
1101 base[key] = source[key]
1102 elif _is_color_like(source[key]):
1103 base[key] = source[key]
1104 base["panel"] = base.get("panel", base.get("surface", "#1a222b"))
1105 base["panel_alt"] = base.get("panel_alt", base.get("surface_alt", "#202a34"))
1106 base["button"] = base.get("button", base.get("surface_alt", "#202a34"))
1107 base["button_hover"] = base.get("button_hover", base.get("button", "#202a34"))
1108 base["button_pressed"] = base.get("button_pressed", base.get("surface", "#1a222b"))
1109 base["button_disabled"] = base.get("button_disabled", base.get("surface", "#1a222b"))
1110 return base
1111
1112
1113func _is_color_like(value) -> bool:
1114 if value is Color:
1115 return true
1116 if value is String:
1117 return Color.html_is_valid(str(value).strip_edges())
1118 return false
1119
1120
1121func _coerce_theme_color(value, fallback:Color) -> Color:
1122 if value is Color:
1123 return value
1124 if value is String:
1125 var text := str(value).strip_edges()
1126 if Color.html_is_valid(text):
1127 return Color.html(text)
1128 return fallback
1129
1130
1131func _apply_style_target(node:Node, target:String, styles:Dictionary) -> void:
1132 if not styles.has(target) or not styles[target] is Dictionary:
1133 return
1134 if not node is Control:
1135 return
1136 var style:Dictionary = styles[target]
1137 var image_path := str(style.get("background_image", ""))
1138 if image_path == "" or not ResourceLoader.exists(image_path):
1139 return
1140 var texture = sprite_manager.get_texture_for_path(image_path)
1141 if texture == null:
1142 return
1143 var control := node as Control
1144 if control.get_node_or_null("ThemeBackgroundImage") != null:
1145 return
1146 var image = TextureRect.new()
1147 image.name = "ThemeBackgroundImage"
1148 image.texture = texture
1149 image.modulate.a = clampf(float(style.get("opacity", 1.0)), 0.0, 1.0)
1150 image.mouse_filter = Control.MOUSE_FILTER_IGNORE
1151 image.set_anchors_preset(Control.PRESET_FULL_RECT)
1152 match str(style.get("texture_mode", "stretch")):
1153 "tile":
1154 image.stretch_mode = TextureRect.STRETCH_TILE
1155 "keep":
1156 image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
1158 image.stretch_mode = TextureRect.STRETCH_SCALE
1159 control.add_child(image)
1160 control.move_child(image, 0)
1161
1162
1163func _merge_dict(base:Dictionary, override:Dictionary) -> Dictionary:
1164 var merged := base.duplicate(true)
1165 for key in override.keys():
1166 if merged.get(key) is Dictionary and override[key] is Dictionary:
1167 merged[key] = _merge_dict(merged[key], override[key])
1168 else:
1169 merged[key] = override[key]
1170 return merged
1171
1172
1173func _theme_catalog_paths(primary_path:String) -> Array:
1174 var paths := [primary_path]
1175 if primary_path == "res://data/themes/default.json" and FileAccess.file_exists(CUSTOM_THEME_CATALOG_PATH):
1176 paths.append(CUSTOM_THEME_CATALOG_PATH)
1177 return paths
1178
1179
1180func _coerce_size(value, fallback:Vector2i) -> Vector2i:
1181 if value is Vector2i:
1182 return value
1183 if value is Vector2:
1184 return Vector2i(int(value.x), int(value.y))
1185 if value is Dictionary:
1186 return Vector2i(int(value.get("x", fallback.x)), int(value.get("y", fallback.y)))
1187 return fallback
1188
1189
1190func _apply_density_recursive(node:Node, density_name:String) -> void:
1191 if node is Control:
1192 apply_density(node, density_name)
1193 for child in node.get_children():
1194 _apply_density_recursive(child, density_name)
1195
1196
1197
1198func create_confirmation_dialog(title:String, message:String, confirm_label:String = "Confirm", cancel_label:String = "Cancel") -> PanelContainer:
1199 var panel = create_panel_shell("ConfirmationDialog")
1200 var content = panel.get_node("Content")
1201 var title_label = Label.new()
1202 title_label.text = title
1203 title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
1204 content.add_child(title_label)
1205 var message_label = Label.new()
1206 message_label.text = message
1207 message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
1208 content.add_child(message_label)
1209 var actions = HBoxContainer.new()
1210 actions.name = "Actions"
1211 content.add_child(actions)
1212 var confirm = Button.new()
1213 confirm.name = "Confirm"
1214 confirm.text = confirm_label
1215 confirm.size_flags_horizontal = Control.SIZE_EXPAND_FILL
1216 actions.add_child(confirm)
1217 var cancel = Button.new()
1218 cancel.name = "Cancel"
1219 cancel.text = cancel_label
1220 cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
1221 actions.add_child(cancel)
1222 return panel
Vector2i
Return a mobile-safe popup size for a viewport.
float
Return whether a page's visible nested footprint fits inside the budgeted height.
int
Apply full-rect anchors and optional margins to a Control.
Callable
Create a reusable save-slot list from slot dictionaries.