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)
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:
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:
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:
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))
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)
349 target = available * maxf(0.0, min_height) / maxf(0.01, flex_min_total)
350 target = minf(target, max_height)
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))
364func _container_gap(control:Control) -> float:
365 if control
is BoxContainer:
366 return float(control.get_theme_constant(
"separation"))
373func measure_vertical_footprint(control:Control) -> float:
374 if control == null
or not control.visible:
376 var own_height:float = control.custom_minimum_size.y
377 if control
is ScrollContainer:
379 if control
is GridContainer:
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)
408func vertical_budget_fits(page:Control, page_height:float, tolerance:float = 1.0) -> bool:
411 return measure_vertical_footprint(page) <= page_height + tolerance
416func control_fits_viewport(control:Control, viewport_size:Vector2, tolerance:float = 1.0) -> bool:
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
427func create_screen_shell(name:String =
"ScreenShell", options:Dictionary = {}) -> VBoxContainer:
428 var shell = VBoxContainer.new()
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)):
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
457func fit_popup_to_viewport(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> Vector2i:
460 var requested = _coerce_size(options.get(
"size", popup.size),
Vector2i(360, 420))
461 var margin :=
int(options.get(
"margin", 24))
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
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
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))
482 clamp(preferred.x, effective_min.x, available.x),
483 clamp(preferred.y, effective_min.y, available.y)
488func popup_centered_standard(popup:PopupPanel, viewport_size:Vector2, options:Dictionary = {}) -> void:
489 var resolved_options := options.duplicate(true)
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)
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
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)
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"))
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)
575 frame.add_child(table)
576 return {
"container":frame,
"table":table,
"values":values}
580func create_panel_shell(name:String =
"PanelShell") -> PanelContainer:
581 var panel = PanelContainer.new()
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)
594func create_scroll_shell(name:String =
"ScrollShell") -> ScrollContainer:
595 var scroll = ScrollContainer.new()
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)
609func wrap_label_in_scroll_frame(label:Label, options:Dictionary = {}) -> Dictionary:
612 if label.get_parent()
is ScrollContainer:
613 var existing_scroll:ScrollContainer = label.get_parent()
615 "frame": existing_scroll.get_parent()
if existing_scroll.get_parent()
is PanelContainer
else null,
616 "scroll": existing_scroll,
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}
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)
655 frame.custom_minimum_size.y = target_height
657 scroll.custom_minimum_size.y = target_height
659 label.custom_minimum_size.y = 0.0
660 label.max_lines_visible =
int(options.get(
"max_lines", 64))
661 label.clip_text = false
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
672 if not item
is Dictionary:
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)
680 if callback
is Callable
and callback.is_valid():
681 button.pressed.connect(callback)
682 nav.add_child(button)
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
692 if slot
is Dictionary:
693 list.add_child(create_save_slot_row(slot, open_callback, delete_callback))
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)
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)
729 content.add_child(icon)
730 var title = Label.new()
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)
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)
757 for achievement
in achievements:
758 if not achievement
is Dictionary:
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"))
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()
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)
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()
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)
835func _achievement_title(achievement:Dictionary) -> String:
836 var title = str(achievement.get(
"title", achievement.get(
"name",
""))).strip_edges()
839 var id = str(achievement.get(
"id",
"Achievement")).replace(
"-",
"_")
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"
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",
""))
851 icon_id =
"achievement_star" if bool(achievement.get(
"unlocked", false))
else "lock"
852 var icon = _make_sprite_icon(icon_id, icon_size)
855 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
856 icon.custom_minimum_size =
Vector2(34, 34)
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"))
872func _achievement_meta(achievement:Dictionary) -> String:
874 "Unlocked" if bool(achievement.get(
"unlocked", false))
else "Locked",
875 str(achievement.get(
"category",
"general")).replace(
"_",
" ").capitalize()
877 var date_text = _achievement_unlock_date(achievement)
879 parts.append(date_text)
880 return " | ".join(parts)
883func _achievement_unlock_date(achievement:Dictionary) -> String:
884 if not bool(achievement.get(
"unlocked", false)):
886 if not achievement.has(
"unlocked_at"):
888 var timestamp :=
int(achievement.get(
"unlocked_at", 0))
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))]
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))
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
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:
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))))
928func _create_achievement_summary(achievements:Array) -> Label:
932 for achievement
in achievements:
933 if not achievement
is Dictionary:
935 if bool(achievement.get(
"unlocked", false)):
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()]
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()
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
968func create_pause_overlay(actions:Dictionary = {}) -> PopupPanel:
969 var popup = PopupPanel.new()
970 popup.name =
"PauseOverlay"
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()
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):
987 var action = actions[action_id]
988 if not action
is Dictionary:
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)
1003func _ensure_ui_sprites_loaded() -> void:
1006 sprite_manager.load_packs([
1007 "res://assets/sprites/ui/ui_core.json",
1008 "res://assets/sprites/ui/ui_navigation.json"
1010 sprites_loaded = true
1013func _make_sprite_icon(sprite_id:String, size:int = 28):
1016 _ensure_ui_sprites_loaded()
1017 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
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
1031func _apply_button_icon(button:Button, sprite_id:String, size:int = 24) -> void:
1034 _ensure_ui_sprites_loaded()
1035 var texture = sprite_manager.get_scaled_texture(sprite_id, size)
1038 button.icon = texture
1039 button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
1042func _default_pause_sprite(action_id:String) -> String:
1045 "settings":
"settings_gear",
1046 "input":
"keyboard",
1048 "achievements":
"achievement_star",
1049 "main_menu":
"home",
1052 }.get(action_id,
"")
1055func _apply_theme_recursive(node:Node, theme_name:String, colors:Dictionary) -> void:
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)
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)
1079func _normalize_theme_definition(definition) -> Dictionary:
1080 var base := THEME_PRESETS[
"dark"].duplicate(true)
1081 if not definition
is Dictionary:
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]
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"))
1113func _is_color_like(value) -> bool:
1117 return Color.html_is_valid(str(value).strip_edges())
1121func _coerce_theme_color(value, fallback:Color) -> Color:
1125 var text := str(value).strip_edges()
1126 if Color.html_is_valid(text):
1127 return Color.html(text)
1131func _apply_style_target(node:Node, target:String, styles:Dictionary) -> void:
1132 if not styles.has(target)
or not styles[target]
is Dictionary:
1134 if not node
is Control:
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):
1140 var texture = sprite_manager.get_texture_for_path(image_path)
1143 var control := node
as Control
1144 if control.get_node_or_null(
"ThemeBackgroundImage") != null:
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")):
1154 image.stretch_mode = TextureRect.STRETCH_TILE
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)
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])
1169 merged[key] = override[key]
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)
1180func _coerce_size(value, fallback:Vector2i) -> Vector2i:
1181 if value
is Vector2i:
1183 if value
is Vector2:
1185 if value
is Dictionary:
1186 return Vector2i(
int(value.get(
"x", fallback.x)),
int(value.get(
"y", fallback.y)))
1190func _apply_density_recursive(node:Node, density_name:String) -> void:
1192 apply_density(node, density_name)
1193 for child
in node.get_children():
1194 _apply_density_recursive(child, density_name)
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)
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.