Add Stage Surface Color Dialog and Background Ground Functionality

- Implemented StageSurfaceColorDialog for custom color selection with live preview.
- Created StageBackgroundGround class for non-walkable scenery behind the set.
- Introduced StageRectSurface for shared rectangular geometry and tint preview.
- Added StageSurfacePreview for rendering surface previews.
- Developed comprehensive tests for surface color selection, rendering, and serialization.
This commit is contained in:
2026-10-17 21:00:03 -04:00
parent a0510e6405
commit 26dd9c64e1
39 changed files with 3118 additions and 142 deletions
@@ -166,8 +166,8 @@ func _build_shell() -> void:
func _fit_to_content() -> void:
_fit_pending = false
if not is_inside_tree() or _body == null:
_fit_pending = false
return
var rect := _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
@@ -179,7 +179,16 @@ func _fit_to_content() -> void:
size.x = card_width
await get_tree().process_frame
if not is_inside_tree():
_fit_pending = false
return
# A resize can arrive while the first layout pass is waiting for the body
# minimum size. Re-read the safe rectangle so this pass uses the new bounds.
rect = _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
rect = get_viewport_rect()
card_width = minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
@@ -189,10 +198,13 @@ func _fit_to_content() -> void:
var natural_height := fixed_height + body_height
var card_height := minf(natural_height, available_height)
var scroll_height := maxf(0.0, card_height - fixed_height)
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
# previous full-content minimum can clamp the new height until another layout.
_scroll.custom_minimum_size.y = scroll_height
_scroll.update_minimum_size()
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
_fit_pending = false
func _style_action_button(button: Button, prominent: bool) -> void:
@@ -0,0 +1,147 @@
class_name StageSurfaceColorControls
extends VBoxContainer
## Shared wrapping Color history and preset controls for scenery surfaces.
signal color_selected(color: Color)
signal custom_color_requested
const COLOR_SWATCH_SELECTOR := preload("res://scripts/shared/ui/color_swatch_selector.gd")
var _stage_theme: SandboxTheme
var _selected: Color = Color.WHITE
var _recents: Array[Color] = []
var _presets: Array[Color] = []
var _built: bool = false
var _current_chip: Button
var _current_value: Label
var _recent_empty_label: Label
var _recent_swatches: ColorSwatchSelector
var _preset_swatches: ColorSwatchSelector
var _more_button: Button
func configure(selected: Color, recents: Array[Color], presets: Array[Color], stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
_selected = selected
_recents = recents.duplicate()
_presets = presets.duplicate()
if not _built:
_build()
_refresh_visuals()
func set_selected_no_signal(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
func set_recent_colors(colors: Array[Color]) -> void:
if _recents == colors:
return
_recents = colors.duplicate()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
func focus_custom_button() -> void:
if _more_button != null:
_more_button.grab_focus()
func _build() -> void:
_built = true
add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
var current_heading: Label = _make_heading("Color")
add_child(current_heading)
var current_row := HBoxContainer.new()
current_row.add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
add_child(current_row)
_current_chip = Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48, 44, 96))
_current_chip.custom_minimum_size = Vector2(swatch_size, swatch_size)
_current_chip.focus_mode = Control.FOCUS_NONE
_current_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
current_row.add_child(_current_chip)
_current_value = Label.new()
_current_value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_current_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
current_row.add_child(_current_value)
add_child(_make_heading("Recently used"))
_recent_empty_label = Label.new()
_recent_empty_label.text = "Colors you use will appear here"
_recent_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_recent_empty_label.add_theme_color_override("font_color", _color("global.muted_text_color", Color("#AAB2C0")))
add_child(_recent_empty_label)
_recent_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_recent_swatches.color_selected.connect(_on_swatch_selected)
add_child(_recent_swatches)
add_child(_make_heading("Colors"))
_preset_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_preset_swatches.color_selected.connect(_on_swatch_selected)
add_child(_preset_swatches)
_more_button = Button.new()
_more_button.text = "More colors…"
_more_button.custom_minimum_size.y = float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
_more_button.focus_mode = Control.FOCUS_ALL
_more_button.pressed.connect(func() -> void: custom_color_requested.emit())
add_child(_more_button)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
_current_value.add_theme_font_override("font", font)
_more_button.add_theme_font_override("font", font)
_current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_color_override("font_color", _color("global.text_color", Color("#F5F7FA")))
_more_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
_more_button.add_theme_stylebox_override("hover", _stage_theme.stylebox("stage_panels.action").duplicate())
func _refresh_visuals() -> void:
if not _built:
return
_refresh_current_color()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
_preset_swatches.configure(_presets, _selected, _stage_theme)
func _refresh_current_color() -> void:
_current_value.text = "#%s" % _selected.to_html(false).to_upper()
var chip_style := StyleBoxFlat.new()
chip_style.bg_color = _selected
chip_style.border_color = _color("stage_panels.choice.border_color", Color("#FFFFFFAA"))
chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2, 0, 8))
chip_style.set_corner_radius_all(_integer("stage_surface_colors.swatch_corner_radius", 8, 0, 24))
_current_chip.add_theme_stylebox_override("normal", chip_style)
_current_chip.add_theme_stylebox_override("hover", chip_style.duplicate())
func _on_swatch_selected(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
color_selected.emit(color)
func _make_heading(text_value: String) -> Label:
var heading := Label.new()
heading.text = text_value
heading.add_theme_font_size_override("font_size", _integer("stage_panels.type.section_size", 20, 12, 40))
heading.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if _stage_theme != null and _stage_theme.font() != null:
heading.add_theme_font_override("font", _stage_theme.font())
return heading
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://d37coqfbju18u
@@ -0,0 +1,160 @@
class_name StageSurfaceColorDialog
extends Window
## Bounded custom-color dialog with a live Stage surface preview.
signal preview_color_changed(color: Color)
signal color_confirmed(color: Color)
signal cancelled
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
var _surface_kind: StringName = &"wall"
var _finish_id: StringName = &"finish_wall_paint"
var _initial_color: Color = Color.WHITE
var _stage_theme: SandboxTheme
var _color_picker: ColorPicker
var _surface_preview: StageSurfacePreview
var _built: bool = false
func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void:
_surface_kind = surface_kind
_finish_id = finish
_initial_color = color
_stage_theme = stage_theme
if not _built:
_build()
_color_picker.color = color
_update_preview(color)
title = "Choose a color"
exclusive = true
transient = true
theme = stage_theme.control_theme() if stage_theme != null else null
func open_dialog() -> void:
var max_size: Vector2i = _usable_window_size()
var preferred_size := Vector2i(560, 640)
var raw_size: Variant = _stage_theme.value("popups.dialog.surface_color_size", [560, 640]) if _stage_theme != null else [560, 640]
if raw_size is Array and (raw_size as Array).size() == 2:
preferred_size = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1]))
var dialog_size := Vector2i(mini(preferred_size.x, max_size.x), mini(preferred_size.y, max_size.y))
dialog_size.x = maxi(1, dialog_size.x)
dialog_size.y = maxi(1, dialog_size.y)
size = dialog_size
popup_centered(dialog_size)
_color_picker.grab_focus()
func _unhandled_key_input(event: InputEvent) -> void:
if visible and event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE:
_cancel()
get_viewport().set_input_as_handled()
func _build() -> void:
_built = true
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for side: String in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 12)
add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 8)
margin.add_child(layout)
var preview_heading := Label.new()
preview_heading.text = "Preview"
layout.add_child(preview_heading)
_surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
var preview_size := Vector2(420.0, 140.0)
var raw_preview: Variant = _stage_theme.value("popups.dialog.surface_color_preview_size", [420, 140]) if _stage_theme != null else [420, 140]
if raw_preview is Array and (raw_preview as Array).size() == 2:
preview_size = Vector2(float((raw_preview as Array)[0]), float((raw_preview as Array)[1]))
preview_size.x = minf(preview_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_surface_preview.custom_minimum_size = preview_size
_surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_surface_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
layout.add_child(_surface_preview)
var picker_scroll := ScrollContainer.new()
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
picker_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.add_child(picker_scroll)
_color_picker = ColorPicker.new()
_color_picker.name = "ColorPickerControl"
var picker_min_size := Vector2(320.0, 340.0)
var raw_picker_size: Variant = _stage_theme.value("popups.dialog.surface_color_picker_min_size", [320, 340]) if _stage_theme != null else [320, 340]
if raw_picker_size is Array and (raw_picker_size as Array).size() == 2:
picker_min_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1]))
picker_min_size.x = minf(picker_min_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_color_picker.custom_minimum_size = picker_min_size
_color_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_color_picker.focus_mode = Control.FOCUS_ALL
_color_picker.color_changed.connect(_on_picker_color_changed)
picker_scroll.add_child(_color_picker)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 8)
layout.add_child(actions)
var use_button := Button.new()
use_button.text = "Use color"
var action_width: float = maxf(44.0, minf(120.0, (float(_usable_window_size().x) - 40.0) * 0.5))
use_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
use_button.focus_mode = Control.FOCUS_ALL
use_button.pressed.connect(_confirm_color)
actions.add_child(use_button)
var cancel_button := Button.new()
cancel_button.text = "✕ Cancel"
cancel_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
cancel_button.focus_mode = Control.FOCUS_ALL
cancel_button.pressed.connect(_cancel)
actions.add_child(cancel_button)
close_requested.connect(_cancel)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
preview_heading.add_theme_font_override("font", font)
use_button.add_theme_font_override("font", font)
cancel_button.add_theme_font_override("font", font)
preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40))
use_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
cancel_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
use_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
cancel_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
func _on_picker_color_changed(color: Color) -> void:
_update_preview(color)
preview_color_changed.emit(color)
func _update_preview(color: Color) -> void:
if _surface_preview != null:
_surface_preview.configure(_surface_kind, _finish_id, color)
func _confirm_color() -> void:
var confirmed_color: Color = _color_picker.color
hide()
color_confirmed.emit(confirmed_color)
func _cancel() -> void:
_update_preview(_initial_color)
hide()
cancelled.emit()
func _usable_window_size() -> Vector2i:
var available: Vector2i = Vector2i(get_tree().root.size)
var usable: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
if usable.size.x > 0 and usable.size.y > 0:
available.x = mini(available.x, usable.size.x)
available.y = mini(available.y, usable.size.y)
available.x = maxi(1, available.x - 32)
available.y = maxi(1, available.y - 32)
return available
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
@@ -0,0 +1 @@
uid://c56y4dpi7w2pu
+1 -1
View File
@@ -50,7 +50,7 @@ func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Varia
if not description.is_empty():
_description = Label.new()
_description.text = description
_description.custom_minimum_size = Vector2(0.0, 34.0)
_description.custom_minimum_size = Vector2(0.0, float(_payload.get("description_min_height", 34.0)))
_description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_description.vertical_alignment = VERTICAL_ALIGNMENT_TOP
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+354 -10
View File
@@ -7,16 +7,27 @@ signal browse_requested()
signal refresh_requested()
signal make_artwork_requested()
signal edit_scenery_requested()
signal surface_color_confirmed(color: Color)
const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS
const GRID_H_SEPARATION: float = 8.0
const GRID_V_SEPARATION: float = 8.0
const POPUP_SIDE_PADDING: float = 40.0
const POPUP_VERTICAL_PADDING: float = 32.0
const POPUP_SAFE_SCREEN_MARGIN: float = 24.0
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
const BACKGROUND_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_background_card_preview.gd")
const STARTER_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const SURFACE_COLOR_CONTROLS := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const SURFACE_COLOR_DIALOG := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
var kind: String = ""
@@ -32,9 +43,24 @@ var _gallery_tab: String = "backgrounds"
var _tag_id: String = "all"
var _current_sky_id: String = ""
var _sky_card_group: ButtonGroup
var _active_columns: int = COLUMNS
var _page_capacity: int = PAGE_SIZE
var _effective_cell_size: Vector2 = CELL_MIN_SIZE
var _surface_color_history: Array[Color] = []
var _setup_view: VBoxContainer
var _setup_kind: StringName = &""
var _setup_finish: StringName = &""
var _setup_color: Color = Color.WHITE
var _setup_original_color: Color = Color.WHITE
var _setup_color_chosen: bool = false
var _setup_preview: StageSurfacePreview
var _setup_color_controls: StageSurfaceColorControls
var _setup_color_dialog: StageSurfaceColorDialog
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@onready var _content_stack: VBoxContainer = %ContentStack
@onready var _grid_scroll: ScrollContainer = %GridScroll
@onready var _empty_label: Label = %EmptyLabel
@onready var _page_label: Label = %PageLabel
@onready var _prev_button: Button = %PrevButton
@@ -48,10 +74,13 @@ var _sky_card_group: ButtonGroup
@onready var _tag_filter: OptionButton = %TagFilter
@onready var _make_artwork_button: Button = %MakeArtworkButton
@onready var _edit_scenery_button: Button = %EditSceneryButton
@onready var _title_bar: Control = $MarginContainer/VBoxContainer/TitleBar
@onready var _footer: Control = $MarginContainer/VBoxContainer/Footer
func _ready() -> void:
exclusive = true
_build_surface_setup_view()
if _tag_filter.item_count == 0:
for label: String in ["All artwork", "Nature", "Buildings", "Walls", "Roads", "Objects"]:
_tag_filter.add_item(label)
@@ -70,11 +99,15 @@ func _ready() -> void:
func _on_root_size_changed() -> void:
if visible:
_update_popup_layout()
_rebuild()
popup_centered()
func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = "") -> void:
kind = p_kind
_setup_kind = &""
_setup_view.visible = false
_current_sky_id = current_sky_id if kind == "scenery" else ""
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_tabs.visible = kind == "scenery"
@@ -100,9 +133,16 @@ func set_entries(entries: Array[Dictionary]) -> void:
_thumbnails.clear()
_cell_texrects.clear()
_page = 0
_update_popup_layout()
_rebuild()
func set_surface_color_history(colors: Array[Color]) -> void:
_surface_color_history = colors.duplicate()
if _setup_color_controls != null and is_instance_valid(_setup_color_controls):
_setup_color_controls.set_recent_colors(_surface_color_history)
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
var key := _entry_key(entry)
_thumbnails[key] = tex
@@ -144,6 +184,221 @@ func apply_theme(theme: Variant) -> void:
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 wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group", null) as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group", null) as StagePanelChoiceGroup
if wall_choices != null:
wall_choices.apply_theme(theme as SandboxTheme)
if ground_choices != null:
ground_choices.apply_theme(theme as SandboxTheme)
if _setup_color_controls != null:
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), theme as SandboxTheme)
func _build_surface_setup_view() -> void:
_setup_view = VBoxContainer.new()
_setup_view.name = "SurfaceSetupView"
_setup_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_view.add_theme_constant_override("separation", _theme_integer("popups.asset_selector.setup_gap", 10, 0, 32))
_setup_view.visible = false
_content_stack.add_child(_setup_view)
var heading_row := HBoxContainer.new()
heading_row.add_theme_constant_override("separation", 8)
_setup_view.add_child(heading_row)
var back_button := Button.new()
back_button.text = "‹ Back"
back_button.custom_minimum_size = Vector2(float(_theme_integer("stage_panels.action.close_width", 92, 72, 180)), float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96)))
back_button.focus_mode = Control.FOCUS_ALL
back_button.pressed.connect(_back_from_surface_setup)
heading_row.add_child(back_button)
var heading := Label.new()
heading.name = "SetupHeading"
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
heading.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.title_size", 24, 12, 48))
heading_row.add_child(heading)
var look_heading := Label.new()
look_heading.text = "Look"
look_heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 20, 12, 40))
_setup_view.add_child(look_heading)
var wall_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
wall_choices.name = "WallLookChoices"
wall_choices.add_choice(&"finish_wall_paint", "Paint", _make_look_cue(&"wall", &"finish_wall_paint"))
wall_choices.add_choice(&"finish_wall_brick", "Brick", _make_look_cue(&"wall", &"finish_wall_brick"))
wall_choices.add_choice(&"finish_wall_wood", "Wood", _make_look_cue(&"wall", &"finish_wall_wood"))
wall_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(wall_choices)
var ground_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
ground_choices.name = "BackgroundGroundLookChoices"
ground_choices.add_choice(&"finish_background_grass", "Grass", _make_look_cue(&"background_ground", &"finish_background_grass"))
ground_choices.add_choice(&"finish_background_concrete", "Concrete", _make_look_cue(&"background_ground", &"finish_background_concrete"))
ground_choices.add_choice(&"finish_background_carpet", "Carpet", _make_look_cue(&"background_ground", &"finish_background_carpet"))
ground_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(ground_choices)
set_meta("wall_look_group", wall_choices)
set_meta("ground_look_group", ground_choices)
var preview_frame := PanelContainer.new()
preview_frame.name = "SurfacePreviewFrame"
var preview_size: Vector2 = _theme_vector2("popups.asset_selector.surface_preview_size", Vector2(420.0, 132.0))
preview_frame.custom_minimum_size.y = preview_size.y + 10.0
preview_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
_setup_preview.custom_minimum_size = preview_size
_setup_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview_frame.add_child(_setup_preview)
_setup_view.add_child(preview_frame)
_setup_color_controls = SURFACE_COLOR_CONTROLS.new() as StageSurfaceColorControls
_setup_color_controls.name = "SurfaceColorControls"
_setup_color_controls.color_selected.connect(_on_setup_color_selected)
_setup_color_controls.custom_color_requested.connect(_open_setup_color_dialog)
_setup_view.add_child(_setup_color_controls)
var helper := Label.new()
helper.name = "SurfaceRoleHelper"
helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
helper.add_theme_color_override("font_color", _theme_color("global.muted_text_color", Color("#AAB2C0")))
_setup_view.add_child(helper)
var draw_button := Button.new()
draw_button.name = "DrawSurfaceButton"
draw_button.custom_minimum_size.y = float(_theme_integer("stage_panels.choice.minimum_height", 56, 44, 112))
draw_button.focus_mode = Control.FOCUS_ALL
draw_button.pressed.connect(_draw_selected_surface)
_setup_view.add_child(draw_button)
set_meta("setup_heading", heading)
set_meta("setup_helper", helper)
set_meta("setup_draw_button", draw_button)
_apply_font_to(back_button)
_apply_font_to(heading)
_apply_font_to(look_heading)
_apply_font_to(helper)
_apply_font_to(draw_button)
if _sandbox_theme != null:
draw_button.add_theme_stylebox_override("normal", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_stylebox_override("hover", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
draw_button.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.body_size", 20, 14, 40))
for choice: StagePanelChoiceGroup in [wall_choices, ground_choices]:
choice.apply_theme(_sandbox_theme as SandboxTheme)
_apply_font_to(choice)
func _make_look_cue(kind: StringName, finish: StringName) -> Control:
var preview: StageSurfacePreview = SURFACE_PREVIEW.new() as StageSurfacePreview
preview.custom_minimum_size = _theme_vector2("popups.asset_selector.look_cue_size", Vector2(80.0, 42.0))
preview.configure(kind, finish, SURFACE_RENDERER.default_tint(finish))
return preview
func _open_surface_setup(kind: StringName) -> void:
_setup_kind = kind
_setup_color_chosen = false
_setup_finish = &"finish_wall_paint" if kind == &"wall" else &"finish_background_grass"
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_original_color = _setup_color
var wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group") as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group") as StagePanelChoiceGroup
wall_choices.visible = kind == &"wall"
ground_choices.visible = kind == &"background_ground"
wall_choices.select_no_signal(_setup_finish)
ground_choices.select_no_signal(_setup_finish)
(get_meta("setup_heading") as Label).text = "Wall panel" if kind == &"wall" else "Background ground"
(get_meta("setup_helper") as Label).text = "Looks like a wall · Stickmen can pass through." if kind == &"wall" else "Decorative only · Stickmen don’t walk here."
(get_meta("setup_draw_button") as Button).text = "Draw wall panel" if kind == &"wall" else "Draw background ground"
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), _sandbox_theme as SandboxTheme)
_update_setup_preview()
_page = 0
_update_popup_layout()
_rebuild()
back_button_focus()
func back_button_focus() -> void:
var back_button: Button = _setup_view.get_child(0).get_child(0) as Button
if back_button != null:
back_button.grab_focus()
func _back_from_surface_setup() -> void:
_setup_kind = &""
_setup_view.visible = false
_update_popup_layout()
_rebuild()
_close_button.grab_focus()
func _on_setup_look_choice(id: StringName) -> void:
var supported: bool = SURFACE_RENDERER.is_wall_finish(id) if _setup_kind == &"wall" else SURFACE_RENDERER.is_background_finish(id)
if not supported:
return
_setup_finish = id
if not _setup_color_chosen:
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_update_popup_layout()
func _supports_setup_color() -> bool:
return _setup_kind == &"wall" or _setup_finish == &"finish_background_carpet"
func _on_setup_color_selected(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
func _update_setup_preview() -> void:
if _setup_preview == null:
return
_setup_preview.configure(_setup_kind, _setup_finish, _setup_color)
_setup_color_controls.set_selected_no_signal(_setup_color)
func _preset_surface_colors() -> Array[Color]:
if _sandbox_theme == null:
return [Color.WHITE, Color.BLACK, Color("#4C9AFF"), Color("#29A36A"), Color("#FFD84D"), Color("#D94F70")]
return (_sandbox_theme as SandboxTheme).swatches()
func _open_setup_color_dialog() -> void:
if _setup_color_dialog == null or not is_instance_valid(_setup_color_dialog):
_setup_color_dialog = SURFACE_COLOR_DIALOG.new() as StageSurfaceColorDialog
add_child(_setup_color_dialog)
_setup_color_dialog.preview_color_changed.connect(_on_setup_color_preview)
_setup_color_dialog.color_confirmed.connect(_on_setup_color_confirmed)
_setup_color_dialog.cancelled.connect(_on_setup_color_cancelled)
_setup_original_color = _setup_color
_setup_color_dialog.configure(_setup_kind, _setup_finish, _setup_original_color, _sandbox_theme as SandboxTheme)
_setup_color_dialog.open_dialog()
func _on_setup_color_preview(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_update_setup_preview()
func _on_setup_color_confirmed(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
_setup_color_controls.focus_custom_button()
func _on_setup_color_cancelled() -> void:
_setup_color = _setup_original_color
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_setup_color_controls.focus_custom_button()
func _draw_selected_surface() -> void:
if _setup_kind.is_empty():
return
var background_kind: String = "wall" if _setup_kind == &"wall" else "background_ground"
item_selected.emit({"background_kind": background_kind, "finish_id": String(_setup_finish), "color": _setup_color})
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
@@ -176,10 +431,13 @@ func _on_tag_filter_selected(index: int) -> void:
var ids: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"]
_tag_id = ids[clampi(index, 0, ids.size() - 1)]
_page = 0
_update_popup_layout()
_rebuild()
func _set_gallery_tab(tab: String) -> void:
_setup_kind = &""
_setup_view.visible = false
_gallery_tab = tab
_backgrounds_tab.set_pressed_no_signal(tab == "backgrounds")
_artwork_tab.set_pressed_no_signal(tab == "artwork")
@@ -190,6 +448,7 @@ func _set_gallery_tab(tab: String) -> void:
_refresh_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork")
_empty_label.text = "No artwork in this group yet." if tab == "artwork" else "Choose a Sky look or wall finish."
_page = 0
_update_popup_layout()
_rebuild()
@@ -211,12 +470,14 @@ func _filtered_entries() -> Array[Dictionary]:
func _on_prev() -> void:
if _page > 0:
_page -= 1
_update_popup_layout()
_rebuild()
func _on_next() -> void:
if (_page + 1) * PAGE_SIZE < _filtered_entries().size():
if (_page + 1) * _page_capacity < _filtered_entries().size():
_page += 1
_update_popup_layout()
_rebuild()
@@ -224,7 +485,55 @@ func _page_count() -> int:
var entries: Array[Dictionary] = _filtered_entries()
if entries.is_empty():
return 1
return int(ceil(float(entries.size()) / float(PAGE_SIZE)))
return int(ceil(float(entries.size()) / float(_page_capacity)))
func _usable_screen_size() -> Vector2:
var root_window: Window = get_tree().root
var available: Vector2 = Vector2(root_window.size)
var screen_index: int = DisplayServer.window_get_current_screen()
var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(screen_index)
if usable_rect.size.x > 0 and usable_rect.size.y > 0:
available.x = minf(available.x, float(usable_rect.size.x))
available.y = minf(available.y, float(usable_rect.size.y))
return available
func _update_popup_layout() -> void:
var available: Vector2 = _usable_screen_size()
var max_width: float = maxf(1.0, minf(available.x * 0.94, available.x - POPUP_SAFE_SCREEN_MARGIN))
var max_height: float = maxf(1.0, minf(available.y * 0.90, available.y - POPUP_SAFE_SCREEN_MARGIN))
var preferred_width: float = CELL_MIN_SIZE.x * COLUMNS + GRID_H_SEPARATION * (COLUMNS - 1) + POPUP_SIDE_PADDING
var popup_width: float = minf(preferred_width, max_width)
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (CELL_MIN_SIZE.x + GRID_H_SEPARATION)), 1, COLUMNS)
_page_capacity = _active_columns * ROWS
_grid.columns = _active_columns
var card_width: float = minf(CELL_MIN_SIZE.x, (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns))
var card_height_limit: float = 248.0 if kind == "starters" else CELL_MIN_SIZE.y
var card_height: float = minf(card_height_limit, card_width + (58.0 if kind == "starters" else 30.0))
_effective_cell_size = Vector2(maxf(96.0, card_width), maxf(180.0, card_height))
var filtered_count: int = _filtered_entries().size()
var page_count: int = maxi(1, int(ceil(float(filtered_count) / float(_page_capacity))))
_page = clampi(_page, 0, page_count - 1)
var page_start: int = _page * _page_capacity
var page_items: int = clampi(filtered_count - page_start, 0, _page_capacity)
var grid_rows: int = int(ceil(float(page_items) / float(_active_columns))) if page_items > 0 else 0
var body_height: float
if not _setup_kind.is_empty():
body_height = maxf(180.0, _setup_view.get_combined_minimum_size().y)
else:
body_height = maxf(120.0, float(grid_rows) * _effective_cell_size.y + float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION)
if page_items == 0:
body_height = maxf(body_height, _empty_label.get_combined_minimum_size().y)
var fixed_height: float = _title_bar.get_combined_minimum_size().y + _footer.get_combined_minimum_size().y + POPUP_VERTICAL_PADDING
var visible_sections: int = 3
if _tabs.visible:
fixed_height += _tabs.get_combined_minimum_size().y
visible_sections += 1
fixed_height += float(visible_sections - 1) * 8.0
var popup_height: float = minf(fixed_height + body_height, max_height)
size = Vector2i(maxi(1, roundi(popup_width)), maxi(1, roundi(popup_height)))
func _rebuild() -> void:
@@ -233,19 +542,30 @@ func _rebuild() -> void:
c.queue_free()
_cell_texrects.clear()
var filtered: Array[Dictionary] = _filtered_entries()
_empty_label.visible = filtered.is_empty()
_grid.visible = not filtered.is_empty()
var in_setup: bool = not _setup_kind.is_empty()
_setup_view.visible = in_setup
_empty_label.visible = filtered.is_empty() and not in_setup
_grid.visible = not filtered.is_empty() and not in_setup
_grid_scroll.visible = not filtered.is_empty() or in_setup
var total := _page_count()
var single := total <= 1
_prev_button.visible = not single
_next_button.visible = not single
_page_label.visible = not single
_prev_button.disabled = _page <= 0
_next_button.disabled = (_page + 1) * PAGE_SIZE >= filtered.size()
_next_button.disabled = (_page + 1) * _page_capacity >= filtered.size()
_page_label.text = "Page %d/%d" % [_page + 1, total]
if in_setup:
_prev_button.visible = false
_next_button.visible = false
_page_label.visible = false
_browse_button.visible = false
_refresh_button.visible = false
_make_artwork_button.visible = false
return
if filtered.is_empty():
return
var bounds := page_bounds(filtered.size(), _page)
var bounds := page_bounds(filtered.size(), _page, _page_capacity)
for i: int in range(int(bounds["start"]), int(bounds["end"])):
_grid.add_child(_build_cell(filtered[i]))
@@ -257,12 +577,16 @@ func _build_cell(entry: Dictionary) -> Control:
# opening a selector never implies that one asset is pre-selected.
var categories: Array = entry.get("tags", []) as Array
var category: String = ", ".join(PackedStringArray(categories)) if not categories.is_empty() else String(entry.get("category", entry.get("material_label", "")))
var card_size: Vector2 = _cell_min_size
var thumb_size: Vector2 = _thumbnail_size
var card_size: Vector2 = _effective_cell_size
var is_background_ground_setup: bool = String(entry.get("background_kind", "")) == "background_ground_setup"
var thumb_height_budget: float = card_size.y - (74.0 if is_background_ground_setup else 64.0)
var thumb_edge: float = minf(_thumbnail_size.x, minf(card_size.x - 20.0, thumb_height_budget))
var thumb_size := Vector2.ONE * maxf(72.0, thumb_edge)
if kind == "starters":
card_size.y = maxf(card_size.y, 248.0)
thumb_size = Vector2(128.0, 128.0)
thumb_size = Vector2.ONE * minf(128.0, maxf(72.0, thumb_edge))
var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": String(entry.get("description", "")), "thumbnail_size": thumb_size}
if is_background_ground_setup:
card_payload["description_min_height"] = 42.0
if entry.has("background_kind"):
card_payload["background_kind"] = entry["background_kind"]
card_payload["finish_id"] = entry.get("finish_id", "")
@@ -301,7 +625,27 @@ func _theme_color(path: String, fallback: Color) -> Color:
return fallback
func _theme_integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _sandbox_theme == null else _sandbox_theme.integer(path, fallback, minimum, maximum)
func _theme_vector2(path: String, fallback: Vector2) -> Vector2:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value(path, [fallback.x, fallback.y])
if not raw is Array or (raw as Array).size() != 2:
return fallback
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
func _on_cell_pressed(entry: Dictionary) -> void:
var background_kind: String = String(entry.get("background_kind", ""))
if background_kind == "wall_setup":
_open_surface_setup(&"wall")
return
if background_kind == "background_ground_setup":
_open_surface_setup(&"background_ground")
return
item_selected.emit(entry)
+114 -9
View File
@@ -1,17 +1,122 @@
class_name ColorSwatchSelector
extends HBoxContainer
extends FlowContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
var _buttons: Array[Button] = []
var _stage_theme: SandboxTheme
var _colors: Array[Color] = []
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_stage_theme = theme as SandboxTheme if theme is SandboxTheme else null
_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))
_colors = colors.duplicate()
var focus_key: String = ""
var focus_owner: Control = get_viewport().gui_get_focus_owner() if is_inside_tree() else null
if focus_owner != null and focus_owner.get_parent() == self:
var focused_color: Color = focus_owner.get_meta("surface_color", Color.WHITE)
focus_key = "#" + focused_color.to_html(false).to_upper()
_group = ButtonGroup.new()
_group.allow_unpress = true
_buttons.clear()
for child: Node in get_children():
remove_child(child)
child.queue_free()
add_theme_constant_override("h_separation", _integer("stage_surface_colors.swatch_gap", 8))
add_theme_constant_override("v_separation", _integer("stage_surface_colors.swatch_gap", 8))
var seen: Dictionary = {}
for color: Color in _colors:
var color_key: String = color.to_html(true).to_upper()
if seen.has(color_key):
continue
seen[color_key] = true
var button := Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48))
button.custom_minimum_size = Vector2(swatch_size, swatch_size)
button.toggle_mode = true
button.button_group = _group
button.focus_mode = Control.FOCUS_ALL
button.tooltip_text = "Color #%s" % color.to_html(false).to_upper()
button.set_meta("surface_color", color)
button.pressed.connect(_on_pressed.bind(button))
add_child(button)
_buttons.append(button)
_apply_button_style(button, color)
_sync_pressed_state()
if not focus_key.is_empty():
call_deferred("_restore_color_focus", focus_key)
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
_sync_pressed_state()
if emit_signal:
color_selected.emit(color)
func get_selected() -> Color:
return _selected
func _restore_color_focus(color_key: String) -> void:
for button: Button in _buttons:
var button_color: Color = button.get_meta("surface_color", Color.WHITE)
if "#" + button_color.to_html(false).to_upper() == color_key:
button.grab_focus()
return
func _on_pressed(button: Button) -> void:
var color: Color = button.get_meta("surface_color", Color.WHITE)
set_selected(color, true)
func _sync_pressed_state() -> void:
for button: Button in _buttons:
var color: Color = button.get_meta("surface_color", Color.WHITE)
button.set_pressed_no_signal(color.to_html(true) == _selected.to_html(true))
button.text = "✓" if color.to_html(true) == _selected.to_html(true) else ""
var label_color: Color = Color("#111821") if color.get_luminance() > 0.58 else Color("#FFFFFF")
button.add_theme_color_override("font_color", label_color)
button.add_theme_color_override("font_hover_color", label_color)
button.add_theme_color_override("font_pressed_color", label_color)
func _apply_button_style(button: Button, color: Color) -> void:
var border: Color = _color("stage_surface_colors.swatch_border", Color("#FFFFFFB8"))
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var corner: int = _integer("stage_surface_colors.swatch_corner_radius", 8)
var normal := StyleBoxFlat.new()
normal.bg_color = color
normal.border_color = border
normal.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2))
normal.set_corner_radius_all(corner)
var selected := normal.duplicate() as StyleBoxFlat
selected.border_color = accent
selected.set_border_width_all(_integer("stage_surface_colors.selected_border_width", 4))
var hover := normal.duplicate() as StyleBoxFlat
hover.border_color = Color.WHITE
hover.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", selected)
button.add_theme_stylebox_override("hover_pressed", selected.duplicate())
var focus := normal.duplicate() as StyleBoxFlat
focus.draw_center = false
focus.border_color = _color("stage_surface_colors.swatch_focus_border", Color.WHITE)
focus.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("focus", focus)
if _stage_theme != null and _stage_theme.font() != null:
button.add_theme_font_override("font", _stage_theme.font())
button.add_theme_font_size_override("font_size", _integer("stage_surface_colors.swatch_check_size", 24))
func _integer(path: String, fallback: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, 0, 64)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)