Files
ryan 59f1817063 Add StageBackgroundView and related tests for plain background functionality
- Implemented StageBackgroundView class for managing sky and plain backgrounds in the stage.
- Added functionality to set background type and color, bind camera, and apply themes.
- Created test_phase19_plain_background.gd to validate plain background behavior and migration.
- Developed test_phase19_starter_chooser_layout.gd to ensure proper layout and functionality of the starter chooser.
- Included visual capture tests for UI elements and interactions.
2026-10-22 16:18:25 -04:00

168 lines
8.0 KiB
GDScript

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 _box: VBoxContainer = null
var _label: Label = null
var _description: Label = null
var _status_label: Label = null
static var _rounded_preview_shader: Shader
const ROUNDED_PREVIEW_SHADER_CODE := """
shader_type canvas_item;
uniform float corner_radius_px = 12.0;
void fragment() {
vec4 preview_color = texture(TEXTURE, UV);
vec2 preview_size = vec2(1.0) / TEXTURE_PIXEL_SIZE;
vec2 half_size = preview_size * 0.5;
vec2 local_position = UV * preview_size;
vec2 rounded_box = abs(local_position - half_size) - (half_size - vec2(corner_radius_px));
float edge_distance = length(max(rounded_box, vec2(0.0))) + min(max(rounded_box.x, rounded_box.y), 0.0) - corner_radius_px;
float antialias_width = max(fwidth(edge_distance), 1.0);
preview_color.a *= 1.0 - smoothstep(0.0, antialias_width, edge_distance);
COLOR = preview_color;
}
"""
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()
_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, float(_payload.get("label_min_height", 28.0))); _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)
if _payload.has("label_font_size"):
_label.add_theme_font_size_override("font_size", int(_payload["label_font_size"]))
var description: String = String(_payload.get("description", ""))
var status: String = String(_payload.get("status", ""))
if not status.is_empty():
_status_label = Label.new()
_status_label.name = "StatusLabel"
_status_label.text = status
_status_label.custom_minimum_size = Vector2(0.0, 22.0)
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status_label.add_theme_font_size_override("font_size", 16)
_status_label.add_theme_color_override("font_color", _payload.get("status_color", Color.WHITE))
_status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_box.add_child(_status_label)
if not description.is_empty():
_description = Label.new()
_description.text = description
_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
_description.max_lines_visible = 2
_description.add_theme_font_size_override("font_size", 14)
if _payload.has("description_font_size"):
_description.add_theme_font_size_override("font_size", int(_payload["description_font_size"]))
_description.mouse_filter = Control.MOUSE_FILTER_IGNORE
_box.add_child(_description)
func set_preview_control(preview: Control) -> void:
if preview == null or _box == null:
return
var preview_size: Vector2 = _icon.custom_minimum_size if _icon != null else Vector2(64.0, 64.0)
if _icon != null and _icon.get_parent() == _box:
_box.remove_child(_icon)
_icon.queue_free()
_icon = null
if _glyph != null and _glyph.get_parent() == _box:
_box.remove_child(_glyph)
_glyph.queue_free()
_glyph = null
preview.custom_minimum_size = preview_size
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
var preview_corner_radius: float = float(_payload.get("preview_corner_radius", 0.0))
if preview_corner_radius > 0.0:
var rounded_material := ShaderMaterial.new()
rounded_material.shader = _get_rounded_preview_shader()
rounded_material.set_shader_parameter("corner_radius_px", preview_corner_radius)
preview.material = rounded_material
var horizontal_inset: int = int(_payload.get("preview_horizontal_inset", 0))
var top_inset: int = int(_payload.get("preview_top_inset", 0))
if horizontal_inset > 0 or top_inset > 0:
var preview_frame := MarginContainer.new()
preview_frame.name = "PreviewInsetFrame"
preview_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview_frame.add_theme_constant_override("margin_left", horizontal_inset)
preview_frame.add_theme_constant_override("margin_right", horizontal_inset)
preview_frame.add_theme_constant_override("margin_top", top_inset)
preview_frame.add_child(preview)
_box.add_child(preview_frame)
_box.move_child(preview_frame, 0)
else:
_box.add_child(preview)
_box.move_child(preview, 0)
func _get_rounded_preview_shader() -> Shader:
if _rounded_preview_shader == null:
_rounded_preview_shader = Shader.new()
_rounded_preview_shader.code = ROUNDED_PREVIEW_SHADER_CODE
return _rounded_preview_shader
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, selected_surface: Color = Color("#12394A"), selected_border: Color = Color("#22C6FF")) -> void:
toggle_mode = true
set_pressed_no_signal(selected)
if not selected:
return
var selected_style := StyleBoxFlat.new()
selected_style.bg_color = selected_surface
selected_style.border_color = selected_border
selected_style.set_border_width_all(3)
selected_style.set_corner_radius_all(8)
add_theme_stylebox_override("normal", selected_style)
add_theme_stylebox_override("pressed", selected_style)
add_theme_stylebox_override("hover", selected_style)
add_theme_stylebox_override("hover_pressed", selected_style)
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())