Add reusable UI components for Phase 7 UI Polish

- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively.
- Implemented configuration methods for each component to handle dynamic content and theming.
- Created tests for the new components to ensure functionality and integration with the existing UI.
- Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies.
- Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
This commit is contained in:
2026-09-18 09:42:48 -04:00
parent baa19a9341
commit 1a2a43c805
35 changed files with 1791 additions and 205 deletions
+3
View File
@@ -135,6 +135,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
_base_font_size = int(sizes.get("action_editor", 18))
_apply_font_recursive(self, _base_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children():
+28 -41
View File
@@ -10,8 +10,9 @@ const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
const THUMB_SIZE := Vector2(120.0, 120.0)
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
var kind: String = ""
@@ -22,6 +23,7 @@ var _cell_texrects: Dictionary = {}
var _placeholder_tex: ImageTexture = null
var _ui_font: Font = null
var _emoji_font: Font = null
var _sandbox_theme: Variant = null
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@@ -87,6 +89,21 @@ func apply_font(ui_font: Font, emoji_font: Font) -> void:
for c: Control in controls:
_apply_font_to(c)
func apply_theme(theme: Variant) -> void:
_sandbox_theme = theme
if theme != null:
self.theme = theme.control_theme()
add_theme_stylebox_override("panel", theme.stylebox("popups.asset_selector"))
var cell_size: Variant = theme.value("popups.asset_selector.cell_size", CELL_MIN_SIZE)
if cell_size is Array and (cell_size as Array).size() == 2:
_cell_min_size = Vector2(float(cell_size[0]), float(cell_size[1]))
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
func _unhandled_input(event: InputEvent) -> void:
if visible and event is InputEventKey:
@@ -152,46 +169,16 @@ func _rebuild() -> void:
func _build_cell(entry: Dictionary) -> Control:
var key := _entry_key(entry)
var cell := Button.new()
cell.custom_minimum_size = CELL_MIN_SIZE
cell.toggle_mode = false
var cell: Variant = ASSET_CARD.new()
# Selector cells inherit the popup theme. Avoid a per-card state override so
# opening a selector never implies that one asset is pre-selected.
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
cell.custom_minimum_size = _cell_min_size
cell.focus_mode = Control.FOCUS_NONE
cell.pressed.connect(_on_cell_pressed.bind(entry))
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_theme_constant_override("separation", 2)
cell.add_child(vbox)
var tex_rect := TextureRect.new()
tex_rect.custom_minimum_size = THUMB_SIZE
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
var tex: Texture2D = _thumbnails.get(key, null)
tex_rect.texture = tex if tex != null else _get_placeholder()
vbox.add_child(tex_rect)
_cell_texrects[key] = tex_rect
var name_label := Label.new()
name_label.text = String(entry.get("name", ""))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_label.add_theme_font_size_override("font_size", 14)
_apply_font_to(name_label)
vbox.add_child(name_label)
if kind == "prop":
var badge := Label.new()
badge.text = String(entry.get("material_label", ""))
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge.add_theme_font_size_override("font_size", 12)
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
_apply_font_to(badge)
vbox.add_child(badge)
cell.set_thumbnail(tex if tex != null else _get_placeholder())
_cell_texrects[key] = cell.thumbnail_rect()
return cell
@@ -211,7 +198,7 @@ func _apply_font_to(c: Control) -> void:
func _get_placeholder() -> ImageTexture:
if _placeholder_tex == null:
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8)
var img := Image.create(int(_thumbnail_size.x), int(_thumbnail_size.y), false, Image.FORMAT_RGBA8)
img.fill(Color(0.16, 0.16, 0.16, 1.0))
_placeholder_tex = ImageTexture.create_from_image(img)
return _placeholder_tex
+3
View File
@@ -191,6 +191,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
if _empty_label != null:
_apply_font_to(_empty_label, _row_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children():
+3
View File
@@ -211,6 +211,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
_base_font_size = int(sizes.get("rule_editor", 18))
_apply_font_recursive(self, _base_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children():
+3
View File
@@ -196,6 +196,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
if _empty_label != null:
_apply_font_to(_empty_label, _row_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children():
+321 -61
View File
@@ -24,6 +24,8 @@ const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
@@ -69,6 +71,8 @@ const ZOOM_STEP: float = 1.10
const MIN_BOX_AREA: float = 16.0
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SELECTOR_DIM_ALPHA := 0.5
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const SETTINGS_PATH := "user://sandbox_settings.json"
const THEME_PATH := "res://sandbox_theme.json"
@@ -165,9 +169,13 @@ var _selector_dim: ColorRect = null
var _browse_dialog: FileDialog = null
var _thumbnail_queue: Array[Dictionary] = []
var _thumbnail_busy: bool = false
var _thumbnail_generation: int = 0
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer = null
var _palette_drag_payload: Dictionary = {}
var _palette_drag_previous_id: String = ""
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
@@ -264,6 +272,7 @@ var _nav_dirty: bool = true
## Parsed sandbox_theme.json (or {} if missing/malformed).
var _theme: Dictionary = {}
var _sandbox_theme = null
var _theme_grid_default: float = DEFAULT_GRID_SIZE
var _accent_edit: Color = Color("#22c6ff")
var _accent_direct: Color = Color("#ffb300")
@@ -419,6 +428,7 @@ func _ready() -> void:
_apply_theme_to_visuals()
_rebuild_grid_cells()
_apply_grid_settings()
_update_stage_chrome_layout()
_apply_mode_frame()
_sync_mode_buttons()
_refresh_mode_badge()
@@ -461,6 +471,18 @@ func _physics_process(_delta: float) -> void:
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if not _palette_drag_payload.is_empty():
if event is InputEventMouseMotion:
_update_palette_drag((event as InputEventMouseMotion).position)
return
if event is InputEventMouseButton:
var drag_button := event as InputEventMouseButton
if drag_button.button_index == MOUSE_BUTTON_LEFT and not drag_button.pressed:
_finish_palette_drag(drag_button.position)
return
if drag_button.button_index == MOUSE_BUTTON_RIGHT and drag_button.pressed:
_cancel_palette_drag()
return
if event is InputEventMagnifyGesture:
# Touchpad pinch zoom.
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
@@ -486,7 +508,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _capture_kind != CaptureKind.NONE:
if not _palette_drag_payload.is_empty():
_cancel_palette_drag()
elif _capture_kind != CaptureKind.NONE:
_cancel_capture()
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
@@ -527,7 +551,17 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed:
_handle_right_click()
if _capture_kind != CaptureKind.NONE:
_cancel_capture()
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
else:
_handle_right_click()
return
if mb.button_index != MOUSE_BUTTON_LEFT:
return
@@ -614,6 +648,8 @@ func set_mode(mode: StageMode) -> void:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode
if not _palette_drag_payload.is_empty():
_cancel_palette_drag()
match mode:
StageMode.EDIT:
_enter_edit_mode()
@@ -927,6 +963,8 @@ func _block_aabb(cell: Vector2i) -> Rect2:
func _begin_terrain_drag(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
_free_ghost()
_drag_painted.clear()
_terrain_dragging = true
@@ -1458,7 +1496,7 @@ func _check_autosave_recovery() -> void:
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered()
get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered()
func _handle_cli_startup(args: Array) -> void:
@@ -1517,7 +1555,7 @@ func _refresh_status() -> void:
sel_text = "%d objects" % sel.size()
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
if _pending_walk_target:
text += " | Click stage for walk target (Esc to cancel)"
text += " | Click stage for walk target (Esc or RMB to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint
if not _toast_text.is_empty():
@@ -1554,9 +1592,10 @@ func _sync_mode_buttons() -> void:
func _apply_mode_frame() -> void:
if _mode_frame == null:
return
_mode_frame.visible = current_mode == StageMode.DIRECT
_update_stage_chrome_layout()
_mode_frame.visible = true
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
sb.border_color = _accent_for_mode(current_mode)
if sb != null: sb.border_color = _accent_for_mode(current_mode)
func _refresh_mode_badge() -> void:
@@ -1643,15 +1682,96 @@ func _apply_ui_font(control: Control) -> void:
control.add_theme_font_override("font", _ui_font)
func _theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
if raw is Array and (raw as Array).size() == 2:
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
return fallback
## Dialogs are separate native/embedded windows, so they do not reliably
## inherit the CanvasLayer's visual defaults. Apply only local overrides.
func _apply_dialog_theme(dialog: Window) -> void:
if dialog == null:
return
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
dialog.add_theme_font_size_override("font_size", size)
if _ui_font != null:
dialog.add_theme_font_override("font", _ui_font)
if dialog is AcceptDialog:
var message: Label = (dialog as AcceptDialog).get_label()
if message != null:
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message.add_theme_font_size_override("font_size", message_size)
if _ui_font != null: message.add_theme_font_override("font", _ui_font)
var ok: Button = (dialog as AcceptDialog).get_ok_button()
if ok != null:
ok.add_theme_font_size_override("font_size", size)
if _ui_font != null: ok.add_theme_font_override("font", _ui_font)
if dialog is ConfirmationDialog:
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
if cancel != null:
cancel.add_theme_font_size_override("font_size", size)
if _ui_font != null: cancel.add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(dialog, size)
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
for child: Node in node.get_children():
if child is Control:
(child as Control).add_theme_font_size_override("font_size", size)
if _ui_font != null: (child as Control).add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(child, size)
func _update_cursor_coords() -> void:
if _status_cursor_coords == null:
return
if _camera == null or not is_instance_valid(_camera):
return
var viewport_pos: Vector2 = get_viewport().get_mouse_position()
if not _is_stage_frame_position(viewport_pos):
_status_cursor_coords.text = "X: — Y: —"
return
var world_pos := _camera.get_global_mouse_position()
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
func _is_stage_frame_position(viewport_pos: Vector2) -> bool:
if _mode_frame == null:
return false
var viewport_size: Vector2 = get_window().size
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
viewport_size = Vector2(1280.0, 720.0)
var left: float = _mode_frame.offset_left
var top: float = _mode_frame.offset_top
var right: float = maxf(left + 1.0, viewport_size.x)
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
return frame_rect.abs().has_point(viewport_pos)
func _update_stage_chrome_layout() -> void:
if _mode_frame == null:
return
var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
var left_inset: float = palette_width if current_mode == StageMode.EDIT else 0.0
_mode_frame.offset_left = left_inset
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_right = 0.0
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
if _mode_badge != null:
_mode_badge.offset_left = left_inset + 12.0
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
func _sync_awaiting_cursor() -> void:
if _is_awaiting_click() != _last_awaiting_click:
_apply_cursor()
@@ -1667,6 +1787,9 @@ func _build_ui() -> void:
_mode_frame.name = "ModeFrame"
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_mode_frame.offset_left = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
var frame_style := StyleBoxFlat.new()
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
frame_style.border_color = _accent_direct
@@ -1676,22 +1799,43 @@ func _build_ui() -> void:
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
top_bar.offset_bottom = TOP_BAR_HEIGHT
ui.add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.name = "Content"
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
var chrome := Control.new()
chrome.name = "Content"
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
top_bar.add_child(chrome)
var left_group := HBoxContainer.new()
left_group.name = "LeftControls"
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
left_group.offset_left = 8.0
left_group.offset_top = 8.0
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.add_theme_constant_override("separation", 8)
chrome.add_child(right_group)
_file_menu = MenuButton.new()
_file_menu.name = "FileMenu"
_file_menu.text = "File"
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22)
var file_popup := _file_menu.get_popup()
var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26
file_popup.add_theme_font_size_override("font_size", file_popup_font_size)
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", file_popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_selected)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
@@ -1700,9 +1844,22 @@ func _build_ui() -> void:
file_popup.add_item("Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
file_popup.about_to_popup.connect(_update_recent_menu)
hbox.add_child(_file_menu)
left_group.add_child(_file_menu)
# 3-segment mode switcher.
# This centered group is independent of left/right controls, preserving its
# window-center position at every supported stage width.
var mode_center := CenterContainer.new()
mode_center.name = "ModeCenter"
# This full-width layout wrapper must not shield File or utility controls.
mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
mode_center.offset_bottom = TOP_BAR_HEIGHT
chrome.add_child(mode_center)
var mode_switcher := HBoxContainer.new()
mode_switcher.name = "ModeSwitcher"
mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS
mode_switcher.add_theme_constant_override("separation", 4)
mode_center.add_child(mode_switcher)
var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"],
[StageMode.DIRECT, "🎬 Direct"],
@@ -1713,39 +1870,32 @@ func _build_ui() -> void:
var btn := Button.new()
btn.text = String(def[1])
btn.toggle_mode = true
btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0)
btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
hbox.add_child(btn)
mode_switcher.add_child(btn)
_mode_buttons[int(mid)] = btn
for id: String in _spawner.get_spawnable_ids():
var btn := Button.new()
btn.text = _spawner.get_label(id)
btn.toggle_mode = true
btn.toggled.connect(_on_palette_toggled.bind(id))
hbox.add_child(btn)
_palette_buttons[id] = btn
_direct_hint_label = Label.new()
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
_direct_hint_label.visible = false
_apply_ui_font(_direct_hint_label)
hbox.add_child(_direct_hint_label)
right_group.add_child(_direct_hint_label)
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled)
hbox.add_child(_grid_check)
right_group.add_child(_grid_check)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = _snap_enabled
_snap_check.toggled.connect(_on_snap_toggled)
hbox.add_child(_snap_check)
right_group.add_child(_snap_check)
_grid_size_label = Label.new()
_grid_size_label.text = "Size"
hbox.add_child(_grid_size_label)
right_group.add_child(_grid_size_label)
_grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = MIN_GRID_SIZE
@@ -1754,12 +1904,32 @@ func _build_ui() -> void:
_grid_size_spin.value = _grid_size
_grid_size_spin.rounded = true
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
hbox.add_child(_grid_size_spin)
right_group.add_child(_grid_size_spin)
# Graphical asset palette lives below chrome so stage tools keep their row.
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
_palette_dock.offset_top = TOP_BAR_HEIGHT
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
_palette_dock.offset_right = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_theme.stylebox("palette"))
var scroll := ScrollContainer.new(); scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED; _palette_dock.add_child(scroll)
var cards := VBoxContainer.new(); cards.name = "PaletteCards"; cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL; scroll.add_child(cards)
for entry: Dictionary in _spawner.get_palette_entries():
var card: Variant = ASSET_CARD.new()
card.configure(entry, null, _sandbox_theme)
card.toggle_mode = true
card.activated.connect(_on_palette_card_activated)
card.drag_started.connect(_begin_palette_drag)
cards.add_child(card)
_palette_buttons[String(entry["placement_id"])] = card
ui.add_child(_palette_dock)
# Bottom status bar (left = status, right = cursor world coords).
var bottom_bar := PanelContainer.new()
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
bottom_bar.offset_top = -28.0
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
ui.add_child(bottom_bar)
var status_hbox := HBoxContainer.new()
@@ -1780,8 +1950,8 @@ func _build_ui() -> void:
_mode_badge.name = "ModeBadge"
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_badge.offset_top = 48.0
_mode_badge.offset_left = 8.0
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
_mode_badge.offset_left = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0) + 12.0
var badge_style := StyleBoxFlat.new()
badge_style.bg_color = _accent_edit
badge_style.set_corner_radius_all(12)
@@ -1872,6 +2042,7 @@ func _build_ui() -> void:
speak_box.add_child(_speak_stop_current_check)
_speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_apply_dialog_theme(_speak_dialog)
_wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait"
@@ -1883,6 +2054,7 @@ func _build_ui() -> void:
_wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog)
_apply_dialog_theme(_wait_dialog)
_apply_popup_theme(_action_popup)
_apply_popup_theme(_trigger_popup)
@@ -1906,6 +2078,7 @@ func _build_ui() -> void:
ui.add_child(_selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
_selector.apply_font(_ui_font, _emoji_font)
_selector.apply_theme(_sandbox_theme)
_browse_dialog = FileDialog.new()
_browse_dialog.title = "Open .stk"
@@ -1935,7 +2108,7 @@ func _build_ui() -> void:
_rename_scene_dialog.name = "RenameSceneDialog"
_rename_scene_dialog.title = "Rename Scene"
_rename_scene_dialog.cancel_button_text = "Cancel"
_rename_scene_dialog.min_size = Vector2i(540, 0)
_rename_scene_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_scene_name_edit = LineEdit.new()
_scene_name_edit.name = "SceneNameEdit"
_scene_name_edit.placeholder_text = "Scene name"
@@ -1945,24 +2118,29 @@ func _build_ui() -> void:
var scene_title := _scene_name_edit.text.strip_edges()
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
ui.add_child(_rename_scene_dialog)
_apply_dialog_theme(_rename_scene_dialog)
_missing_assets_dialog = AcceptDialog.new()
_missing_assets_dialog.name = "MissingAssetsDialog"
_missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = Vector2i(560, 0)
_missing_assets_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_missing_assets_dialog)
_apply_dialog_theme(_missing_assets_dialog)
_cli_error_dialog = AcceptDialog.new()
_cli_error_dialog.name = "CliErrorDialog"
_cli_error_dialog.title = "Could Not Load Scene"
_cli_error_dialog.min_size = Vector2i(560, 0)
_cli_error_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_cli_error_dialog)
_apply_dialog_theme(_cli_error_dialog)
_unsaved_changes_dialog = ConfirmationDialog.new()
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Save changes before continuing?"; _unsaved_changes_dialog.ok_button_text = "Save"
_unsaved_changes_dialog.add_button("Discard", false, "discard")
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "⚠️ Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"; _unsaved_changes_dialog.ok_button_text = "💾 Save"
_unsaved_changes_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_unsaved_changes_dialog)
_apply_dialog_theme(_unsaved_changes_dialog)
# ---- Phase 3c editor tools ----
@@ -1980,8 +2158,10 @@ func _build_ui() -> void:
_apply_popup_theme(_waypoint_context)
_confirm_dialog = ConfirmationDialog.new()
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
ui.add_child(_confirm_dialog)
_apply_dialog_theme(_confirm_dialog)
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
@@ -1990,6 +2170,7 @@ func _build_ui() -> void:
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
ui.add_child(_queue_panel)
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520))
_rule_panel = RULE_PANEL.instantiate() as RulePanel
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
@@ -1999,6 +2180,7 @@ func _build_ui() -> void:
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
ui.add_child(_rule_panel)
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
_action_editor.committed.connect(_on_action_editor_committed)
@@ -2006,6 +2188,7 @@ func _build_ui() -> void:
_action_editor.target_requested.connect(_on_action_editor_target_requested)
ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
_rule_editor.committed.connect(_on_rule_editor_committed)
@@ -2015,6 +2198,7 @@ func _build_ui() -> void:
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
ui.add_child(_rule_editor)
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
func _build_gizmo_layer() -> void:
@@ -2155,6 +2339,57 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
set_placement_mode("")
func _on_palette_card_activated(payload: Dictionary) -> void:
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
if bool(payload.get("opens_selector", false)):
_open_selector(id)
else:
set_placement_mode(id)
func _begin_palette_drag(payload: Dictionary) -> void:
if current_mode != StageMode.EDIT or _selector_open: return
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
_palette_drag_payload = payload.duplicate(true)
_palette_drag_previous_id = _placement_id
_placement_id = id
_spawn_ghost()
func _update_palette_drag(viewport_pos: Vector2) -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.global_position = _viewport_to_world(viewport_pos) + _spawner.get_spawn_offset(_placement_id)
func _finish_palette_drag(viewport_pos: Vector2) -> void:
var id: String = _placement_id
if _is_stage_drop_position(viewport_pos):
_place_at(_viewport_to_world(viewport_pos))
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == _placement_id)
func _cancel_palette_drag() -> void:
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
var rect: Rect2 = get_viewport().get_visible_rect()
if not rect.has_point(viewport_pos): return false
var hovered: Control = get_viewport().gui_get_hovered_control()
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
func _open_selector(kind: String) -> void:
for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
@@ -2189,6 +2424,7 @@ func _on_asset_selected(entry: Dictionary) -> void:
func _close_selector() -> void:
_selector_open = false
_selector_kind = ""
_thumbnail_generation += 1
_thumbnail_queue.clear()
if _selector_dim != null:
_selector_dim.visible = false
@@ -2226,26 +2462,28 @@ func _on_refresh_requested() -> void:
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
_thumbnail_queue.clear()
_thumbnail_generation += 1
var generation: int = _thumbnail_generation
for entry: Dictionary in entries:
if kind == "stickman":
var path := String(entry.get("path", ""))
var key := _thumbnail_cache.stickman_key(path)
var png := _thumbnail_cache.stickman_png(key)
if FileAccess.file_exists(png):
var tex := _thumbnail_cache.load_png(png)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key })
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else:
var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id)
if FileAccess.file_exists(png):
var tex := _thumbnail_cache.load_png(png)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry })
_thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation })
func _drain_thumbnail_queue() -> void:
@@ -2261,15 +2499,15 @@ func _drain_thumbnail_queue() -> void:
var data: Dictionary = entry["data"]
tex = await _stickman_thumb.render(data)
if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.stickman_png(key))
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.prop_png(String(entry["id"])))
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
_thumbnail_busy = false
if _selector != null and _selector_open and tex != null:
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex)
@@ -2727,11 +2965,11 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)"
_rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
TRIG_ENTERED_AREA:
_rule_hint = "Click the trigger area to watch (Esc to cancel)"
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
@@ -2747,7 +2985,7 @@ func _on_rule_action_popup_id_pressed(id: int) -> void:
actions.append(action)
_rule_builder["actions"] = actions
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id)
_rule_hint = "Click the stickman who will %s (Esc or RMB to cancel)" % _action_verb(id)
_refresh_status()
@@ -2830,7 +3068,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a waypoint dot (Esc to cancel)"
_rule_hint = "Click a waypoint dot (Esc or RMB to cancel)"
_refresh_status()
"entered_area":
var hit := _selection.hit_test(world_pos)
@@ -2840,7 +3078,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a trigger area (Esc to cancel)"
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status()
"collided":
var hit2 := _selection.hit_test(world_pos)
@@ -2850,7 +3088,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_refresh_status()
@@ -2869,7 +3107,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
match String(action.get("type", "")):
"walk_to":
_rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name)
_rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
_refresh_status()
"speak":
_rule_step = RuleStep.PARAMS
@@ -3400,7 +3638,7 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
var queue := rig.get_queue()
var pos: Vector2 = queue[index].get("target", rig.global_position - STICKMAN_RIG.FOOT_OFFSET)
_director_visuals.set_edit_waypoint(pos)
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
func _on_walk_edit_captured(value: Vector2) -> void:
@@ -3667,11 +3905,11 @@ func _on_rule_editor_trigger_target_requested(type: String) -> void:
_rule_editor.hide()
match TRIGGER_REGISTRY.target_type(type):
"waypoint":
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
"area":
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
"prop":
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
func _on_rule_editor_action_add_requested() -> void:
@@ -3763,7 +4001,7 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
_show_toast("Action inserted")
"rule_add":
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
return
"rule_edit":
if _rule_editor != null:
@@ -3785,7 +4023,7 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
@@ -3865,6 +4103,8 @@ func _apply_grid_settings() -> void:
func _set_edit_controls_visible(visible: bool) -> void:
for btn: Button in _palette_buttons.values():
btn.visible = visible
if _palette_dock != null:
_palette_dock.visible = visible
if _grid_check != null:
_grid_check.visible = visible
if _snap_check != null:
@@ -3899,6 +4139,8 @@ func _world_children_selectable() -> Array[Node2D]:
## Loads the theme JSON. `source_path` is overridable for tests (missing /
## malformed fallback); defaults to the committed res:// theme.
func _load_theme(source_path: String = THEME_PATH) -> void:
_sandbox_theme = SANDBOX_THEME.new()
_sandbox_theme.load_from(source_path)
_theme = {}
_build_font_sizes()
if not FileAccess.file_exists(source_path):
@@ -3912,7 +4154,7 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
if not json is Dictionary:
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
return
_theme = json as Dictionary
_theme = _sandbox_theme.raw_config()
var fonts: Dictionary = _theme.get("fonts", {})
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
_action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
@@ -3951,6 +4193,24 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
# Schema v2 wins only when the source itself opts into v2. Legacy ad-hoc
# theme files retain their Phase 3 flat-key precedence for compatibility.
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME.SCHEMA_VERSION:
_action_popup_font_size = _sandbox_theme.integer("fonts.sizes.action_popup", _action_popup_font_size, 8, 96)
_action_popup_emoji_size = _sandbox_theme.integer("fonts.sizes.action_popup_emoji", _action_popup_emoji_size, 8, 96)
_tooltip_font_size = _sandbox_theme.integer("fonts.sizes.tooltip", _tooltip_font_size, 8, 96)
_status_pill_font_size = _sandbox_theme.integer("fonts.sizes.status_pill", _status_pill_font_size, 8, 96)
_queue_panel_font_size = _sandbox_theme.integer("fonts.sizes.queue_panel", _queue_panel_font_size, 8, 96)
_rule_panel_font_size = _sandbox_theme.integer("fonts.sizes.rule_panel", _rule_panel_font_size, 8, 96)
_action_editor_font_size = _sandbox_theme.integer("fonts.sizes.action_editor", _action_editor_font_size, 8, 96)
_rule_editor_font_size = _sandbox_theme.integer("fonts.sizes.rule_editor", _rule_editor_font_size, 8, 96)
_panel_row_font_size = _sandbox_theme.integer("fonts.sizes.panel_row", _panel_row_font_size, 8, 96)
_panel_title_font_size = _sandbox_theme.integer("fonts.sizes.panel_title", _panel_title_font_size, 8, 96)
_accent_edit = _sandbox_theme.color("mode_colors.edit", _accent_edit)
_accent_direct = _sandbox_theme.color("mode_colors.direct", _accent_direct)
_accent_play = _sandbox_theme.color("mode_colors.play", _accent_play)
_guide_line_color = _sandbox_theme.color("mode_colors.guide", _guide_line_color)
_theme_grid_default = _sandbox_theme.number("grid.snap_size", _theme_grid_default, MIN_GRID_SIZE, MAX_GRID_SIZE)
_build_font_sizes()
@@ -4018,7 +4278,7 @@ func _apply_theme_to_visuals() -> void:
_director_visuals.bold_font = _ui_font_bold
_director_visuals.italic_font = _ui_font_italic
if _placement_overlay != null:
_placement_overlay.guide_line_color = _guide_line_color
_placement_overlay.apply_style(_sandbox_theme.value("overlays", {}) if _sandbox_theme != null else {})
# ---------------------------------------------------------------------------
# Settings persistence
+180
View File
@@ -0,0 +1,180 @@
class_name SandboxTheme
extends RefCounted
## Validated, cached presentation data for the Sandbox Stage.
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _font_cache: Dictionary = {}
var _style_cache: Dictionary = {}
var _control_theme: Theme = null
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
return false
_apply_legacy_aliases(parsed as Dictionary)
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func value(path: String, fallback: Variant = null) -> Variant:
var cursor: Variant = _config
for piece: String in path.split("."):
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
return fallback
cursor = (cursor as Dictionary)[piece]
return cursor
func color(path: String, fallback: Color) -> Color:
var raw: Variant = value(path, null)
if not raw is String:
return fallback
var text: String = String(raw)
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
return fallback
return Color.from_string(text, fallback)
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
var raw: Variant = value(path, fallback)
if not raw is int and not raw is float:
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
return fallback
var n: float = float(raw)
if not is_finite(n): return fallback
return clampf(n, minimum, maximum)
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
func font(component_path: String = "", preset_id: String = "ui") -> Font:
var path: String = ""
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
if path.is_empty(): path = String(value("fonts.ui_font", ""))
if path.is_empty(): return ThemeDB.fallback_font
if _font_cache.has(path): return _font_cache[path]
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
if resource is Font:
_font_cache[path] = resource
return resource as Font
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
return ThemeDB.fallback_font
func emoji_font() -> Font:
var path: String = String(value("fonts.emoji_font", ""))
if path.is_empty(): return font()
return font("", "emoji") if ResourceLoader.exists(path) else font()
func style(component_path: String) -> Dictionary:
var result: Dictionary = {}
var section: String = component_path.get_slice(".", 0)
result = _deep_merge(result, value("global", {}))
result = _deep_merge(result, value(section + ".style_defaults", {}))
result = _deep_merge(result, value(component_path, {}))
return result
func stylebox(component_path: String) -> StyleBoxFlat:
var cfg: Dictionary = style(component_path)
var key: String = JSON.stringify(cfg)
if not _style_cache.has(key):
var box := StyleBoxFlat.new()
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
box.content_margin_left = padding; box.content_margin_right = padding
box.content_margin_top = padding; box.content_margin_bottom = padding
_style_cache[key] = box
return (_style_cache[key] as StyleBoxFlat).duplicate()
func control_theme() -> Theme:
if _control_theme == null:
_control_theme = Theme.new()
_control_theme.default_font = font()
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
return _control_theme
func swatches() -> Array[Color]:
var result: Array[Color] = []
for item: Variant in value("widgets.swatches", []):
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
return result
func size_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item: Variant in value("widgets.size_presets", []):
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
return result
func font_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var presets: Dictionary = value("fonts.presets", {})
for id: String in presets:
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
return result
func emoji_items() -> Array[String]:
var result: Array[String] = []
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
return result
func invalidate_caches() -> void:
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
func _apply_legacy_aliases(source: Dictionary) -> void:
var modes: Dictionary = source.get("mode_colors", {})
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
var fonts: Dictionary = source.get("fonts", {})
var sizes: Dictionary = fonts.get("sizes", {})
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
var legacy: String = name + "_font_size"
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
else: merged[key] = overrides[key]
return merged
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
return Color.from_string(String(cfg.get(key, "")), fallback)
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key): _warned[key] = true; push_warning(message)
+1
View File
@@ -0,0 +1 @@
uid://02tfgtwtgfcw
+15
View File
@@ -95,6 +95,21 @@ func set_style(cfg: Dictionary) -> void:
mark_dirty()
## Resolved-theme entry point. `set_style` remains for legacy callers/tests.
func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void:
var legacy: Dictionary = {"fonts": fonts.duplicate(true)}
if not fonts.is_empty():
var sizes: Dictionary = fonts.get("sizes", fonts)
legacy["fonts"] = {
"assignment_badge_font_size": sizes.get("assignment_badge", badge_icon_size),
"assignment_badge_radius": style.get("badge_radius", badge_radius),
"rule_label_font_size": sizes.get("rule_label", rule_label_font_size),
"rule_label_bold": fonts.get("flags", {}).get("rule_label_bold", rule_label_bold),
"badge_bold": fonts.get("flags", {}).get("badge_bold", badge_bold),
}
set_style(legacy)
func set_rules(r: Array[Dictionary]) -> void:
rules = r
mark_dirty()
+16 -3
View File
@@ -22,12 +22,25 @@ var action_target: Vector2 = Vector2.ZERO
var action_valid: bool = true
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
var line_width_px: float = 2.0
var dash_length_px: float = 8.0
var dash_gap_px: float = 5.0
const DASH_LENGTH_PX := 8.0
const DASH_GAP_PX := 5.0
const LINE_WIDTH_PX := 2.0
const MARKER_RADIUS_PX := 10.0
func apply_style(style: Dictionary) -> void:
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
var defaults: Dictionary = style.get("style_defaults", {})
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
dash_gap_px = maxf(0.0, float(defaults.get("dash_gap", dash_gap_px)))
queue_redraw()
func _process(_delta: float) -> void:
if terrain_guide_visible or action_visible:
@@ -70,15 +83,15 @@ func _draw() -> void:
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
var zoom := _zoom()
var dash := DASH_LENGTH_PX / zoom
var gap := DASH_GAP_PX / zoom
var dash := dash_length_px / zoom
var gap := dash_gap_px / zoom
var dir := from.direction_to(to)
var total := from.distance_to(to)
var dist := 0.0
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, color, LINE_WIDTH_PX / zoom, true)
draw_line(start, start + dir * len, color, line_width_px / zoom, true)
dist += dash + gap
+12
View File
@@ -63,6 +63,18 @@ func get_spawnable_ids() -> Array[String]:
return ids
## UI-safe registry view. Callers receive no templates or mutable registry state.
func get_palette_entries() -> Array[Dictionary]:
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
var result: Array[Dictionary] = []
for entry: Dictionary in _registry:
var id: String = String(entry.get("id", ""))
var kind: String = String(entry.get("kind", ""))
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
return result
func get_label(id: String) -> String:
var entry := _find_entry(id)
return String(entry.get("label", id)) if not entry.is_empty() else id
+27 -1
View File
@@ -4,6 +4,27 @@ extends RefCounted
const STICKMEN_DIR := "user://thumbnails/stickmen"
const PROP_DIR := "user://thumbnails/props"
const PROP_VERSION := 1
var _memory: Dictionary = {}
func get_texture(cache_path: String) -> Texture2D:
if _memory.has(cache_path):
return _memory[cache_path] as Texture2D
var texture: Texture2D = load_png(cache_path)
if texture != null:
_memory[cache_path] = texture
return texture
func store_texture(cache_path: String, texture: Texture2D) -> Error:
if texture == null:
return ERR_INVALID_PARAMETER
_memory[cache_path] = texture
return save_png(texture, cache_path)
func forget_memory(cache_path: String) -> void:
_memory.erase(cache_path)
func clear_memory() -> void:
_memory.clear()
func stickman_key(path: String) -> String:
@@ -19,12 +40,16 @@ func prop_png(id: String) -> String:
func load_png(png_path: String) -> Texture2D:
if _memory.has(png_path):
return _memory[png_path] as Texture2D
if not FileAccess.file_exists(png_path):
return null
var img := Image.load_from_file(png_path)
if img == null:
return null
return ImageTexture.create_from_image(img)
var texture: Texture2D = ImageTexture.create_from_image(img)
_memory[png_path] = texture
return texture
func save_png(tex: Texture2D, png_path: String) -> Error:
@@ -34,6 +59,7 @@ func save_png(tex: Texture2D, png_path: String) -> Error:
if img == null:
return ERR_CANT_CREATE
ensure_dir(png_path.get_base_dir())
_memory[png_path] = tex
return img.save_png(png_path)
+58
View File
@@ -0,0 +1,58 @@
class_name AssetCard
extends Button
signal activated(payload: Dictionary)
signal drag_started(payload: Dictionary)
var _payload: Dictionary = {}
var _press_position: Vector2 = Vector2.ZERO
var _dragging: bool = false
var _drag_threshold: float = 8.0
var _icon: TextureRect = null
var _glyph: Label = null
var _label: Label = null
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
_payload = payload.duplicate(true)
_drag_threshold = theme.number("palette.drag_threshold", 8.0, 1.0, 128.0) if theme != null else 8.0
custom_minimum_size = Vector2(96, 108)
clip_contents = true
if theme != null:
var size_data: Variant = theme.value("palette.card_size", [96, 94])
if size_data is Array and (size_data as Array).size() == 2: custom_minimum_size = Vector2(float(size_data[0]), maxf(108.0, float(size_data[1])))
add_theme_stylebox_override("normal", theme.stylebox("palette"))
var thumbnail_size: Vector2 = _payload.get("thumbnail_size", Vector2(64, 64))
focus_mode = Control.FOCUS_ALL
tooltip_text = String(_payload.get("label", ""))
for child: Node in get_children(): child.queue_free()
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
func set_thumbnail(texture: Texture2D) -> void:
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
if _glyph != null and texture != null: _glyph.visible = false
func thumbnail_rect() -> TextureRect:
return _icon
func set_selected(selected: bool) -> void:
button_pressed = selected
func payload() -> Dictionary:
return _payload.duplicate(true)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_press_position = event.position; _dragging = false
elif _dragging:
accept_event()
elif event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and not _dragging:
if event.position.distance_to(_press_position) >= _drag_threshold:
_dragging = true; drag_started.emit(payload()); accept_event()
func _pressed() -> void:
if not _dragging: activated.emit(payload())
+1
View File
@@ -0,0 +1 @@
uid://dayameygn6kdb
+17
View File
@@ -0,0 +1,17 @@
class_name ColorSwatchSelector
extends HBoxContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected = selected
for color: Color in colors:
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
button.set_pressed_no_signal(color.is_equal_approx(selected))
func set_selected(color: Color, emit_signal: bool = false) -> void:
_selected = color
if emit_signal: color_selected.emit(color)
func get_selected() -> Color: return _selected
+1
View File
@@ -0,0 +1 @@
uid://djy6yykmgtxk0
+10
View File
@@ -0,0 +1,10 @@
class_name EmojiQuickGrid
extends GridContainer
signal emoji_selected(text: String)
func configure(items: Array[String], columns: int = 8, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
self.columns = maxi(1, columns)
for text: String in items:
var button := Button.new(); button.text = text; button.custom_minimum_size = Vector2(44,44); button.focus_mode = Control.FOCUS_ALL
if theme != null: button.add_theme_font_override("font", theme.emoji_font())
button.pressed.connect(func() -> void: emoji_selected.emit(text)); add_child(button)
+1
View File
@@ -0,0 +1 @@
uid://dlstr7bm0x258
+17
View File
@@ -0,0 +1,17 @@
class_name FontPresetPicker
extends HBoxContainer
signal preset_selected(id: String)
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected_id = selected_id
for item: Dictionary in presets:
var id: String = String(item.get("id", "")); if id.is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", id)); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
if item.get("font", null) is Font: button.add_theme_font_override("font", item["font"])
button.pressed.connect(func() -> void: set_selected(id, true)); add_child(button); button.set_pressed_no_signal(id == selected_id)
func set_selected(id: String, emit_signal: bool = false) -> void:
_selected_id = id
if emit_signal: preset_selected.emit(id)
func get_selected_id() -> String: return _selected_id
+1
View File
@@ -0,0 +1 @@
uid://dfwkdo5sx6i4h
+25
View File
@@ -0,0 +1,25 @@
class_name PresetSizeToggle
extends HBoxContainer
signal preset_selected(id: String, value: int)
var _presets: Array[Dictionary] = []
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_presets = presets.duplicate(true); _selected_id = selected_id
for item: Dictionary in _presets:
if String(item.get("id","")).is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", item["id"])); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
button.pressed.connect(func() -> void: set_selected(String(item["id"]), true)); add_child(button); button.set_pressed_no_signal(String(item["id"]) == selected_id)
if not _presets.is_empty() and _selected_id.is_empty(): _selected_id = String(_presets[0].get("id", ""))
func set_selected(id: String, emit_signal: bool = false) -> void:
for item: Dictionary in _presets:
if String(item.get("id", "")) == id:
_selected_id = id
if emit_signal: preset_selected.emit(id, int(item.get("value", 0)))
return
func get_selected_id() -> String: return _selected_id
func get_selected_value() -> int:
for item: Dictionary in _presets:
if String(item.get("id", "")) == _selected_id: return int(item.get("value", 0))
return 0
+1
View File
@@ -0,0 +1 @@
uid://fx38yavxtiv4