Add visual harmonization tests for editor UI components and ground finish chooser

- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
This commit is contained in:
2026-10-23 15:20:12 -04:00
parent 0a5ef0a61e
commit ddb298f631
40 changed files with 3442 additions and 434 deletions
+37 -1
View File
@@ -121,6 +121,7 @@ var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 15
var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _create_point_armed: bool = false
var _context_menu: PopupMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
@@ -214,6 +215,8 @@ func set_edit_mode(mode: int) -> void:
if _stage_size_preview_active:
cancel_stage_size_preview()
_edit_mode = m
if _edit_mode != EditMode.POINTS:
_create_point_armed = false
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
@@ -225,6 +228,21 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func arm_create_point() -> void:
set_edit_mode(EditMode.POINTS)
_create_point_armed = true
queue_redraw()
func cancel_create_point() -> void:
_create_point_armed = false
queue_redraw()
func is_create_point_armed() -> bool:
return _create_point_armed
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
@@ -1254,7 +1272,10 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_handle_left_press(world_pos)
if _create_point_armed and _edit_mode == EditMode.POINTS:
_create_point_from_toolbar(world_pos)
else:
_handle_left_press(world_pos)
else:
_handle_left_release()
@@ -1464,6 +1485,21 @@ func _handle_left_press(world_pos: Vector2) -> void:
shape_selected.emit(-1)
func _create_point_from_toolbar(world_pos: Vector2) -> void:
var shape_idx: int = hit_test_segment(world_pos)
if shape_idx < 0:
return
_selected_shape_idx = shape_idx
shape_selected.emit(shape_idx)
var new_vertex_index: int = create_point_on_edge(world_pos)
if new_vertex_index < 0:
return
_create_point_armed = false
_drag_mode = DragMode.NONE
_emit_changed()
queue_redraw()
func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void:
match _drag_mode:
DragMode.VERTEX:
+103 -82
View File
@@ -12,6 +12,7 @@ extends Control
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
@@ -92,10 +93,10 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _top_right: HBoxContainer = %TopRightControls
@onready var _top_right: FlowContainer = %TopRightControls
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _mode_bar: FlowContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@@ -114,6 +115,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
@@ -139,6 +141,8 @@ var _radius_row: Control
var _reshaped_note: Label
var _vertex_label: Label
var _delete_button: Button
var _create_point_button: Button
var _create_point_hint: Label
var _shape_section: VBoxContainer
var _primitive_option: OptionButton
var _params_box: VBoxContainer
@@ -212,6 +216,8 @@ var _recent_props: Array[String] = []
func _ready() -> void:
if not _stage_authoring:
get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
@@ -260,7 +266,7 @@ func _ready() -> void:
_cursor_coords.add_theme_font_size_override("font_size", _theme.font_status())
_save_status.add_theme_font_size_override("font_size", _theme.font_status())
_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER
_mode_bar.alignment = FlowContainer.ALIGNMENT_CENTER
_refresh_inspector_from_selection()
_sync_mode_buttons(_canvas.get_edit_mode())
@@ -311,7 +317,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
return
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
if _canvas.is_create_point_armed():
_canvas.cancel_create_point()
_update_create_point_feedback()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
@@ -337,8 +347,11 @@ func _update_save_status() -> void:
var name: String = _prop_name_edit.text if _prop_name_edit != null else ""
if name.is_empty():
name = "Unnamed Prop"
var suffix: String = " • Modified" if _dirty else ""
_save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix]
var has_saved_path: bool = not _current_path.is_empty()
var state: String = "Unsaved changes" if _dirty else ("Saved" if has_saved_path else "Not saved yet")
_save_status.text = "%s — %s — %s" % [name, PRESET_LABELS[_material_preset], state]
var status_color: Color = _editor_visual_theme.chrome_color(&"dirty", &"prop") if _dirty else (_editor_visual_theme.chrome_color(&"saved", &"prop") if has_saved_path else _editor_visual_theme.chrome_color(&"text_muted", &"prop"))
_save_status.add_theme_color_override("font_color", status_color)
_update_window_title()
@@ -372,10 +385,7 @@ func _make_mode_button(mode: int) -> Button:
button.button_group = _mode_group
button.custom_minimum_size.y = float(_theme.mode_button_min_height())
button.add_theme_font_size_override("font_size", _theme.font_mode())
# No keyboard focus: the focus stylebox otherwise draws over the pressed
# stylebox, hiding the selected (accent-bordered) state until the cursor
# moves away (Round 4 fix).
button.focus_mode = Control.FOCUS_NONE
button.focus_mode = Control.FOCUS_ALL
_mode_bar.add_child(button)
return button
@@ -399,6 +409,8 @@ func _on_canvas_mode_changed(mode: int) -> void:
return
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
if mode != PropCanvasScript.EditMode.POINTS and _canvas.is_create_point_armed():
_canvas.cancel_create_point()
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
@@ -430,6 +442,11 @@ func _sync_mode_controls(mode: int) -> void:
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _create_point_button != null:
_create_point_button.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
if _create_point_hint != null:
_create_point_hint.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
_update_create_point_feedback()
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
@@ -461,73 +478,15 @@ func _build_attach_tools() -> void:
# ---------------------------------------------------------------------------
func _build_control_theme() -> void:
_ui_theme = Theme.new()
var bg: Color = _theme.canvas_background_color()
var panel := StyleBoxFlat.new()
panel.bg_color = Color(0.12, 0.12, 0.12, 1.0)
panel.set_corner_radius_all(6)
panel.content_margin_left = 8
panel.content_margin_right = 8
panel.content_margin_top = 6
panel.content_margin_bottom = 6
panel.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel.set_border_width_all(1)
var panel_dark := StyleBoxFlat.new()
panel_dark.bg_color = bg.darkened(0.25)
panel_dark.set_corner_radius_all(6)
panel_dark.content_margin_left = 8
panel_dark.content_margin_right = 8
panel_dark.content_margin_top = 6
panel_dark.content_margin_bottom = 6
panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel_dark.set_border_width_all(1)
var panel_pressed := StyleBoxFlat.new()
panel_pressed.bg_color = bg.darkened(0.25)
panel_pressed.set_corner_radius_all(6)
panel_pressed.content_margin_left = 8
panel_pressed.content_margin_right = 8
panel_pressed.content_margin_top = 6
panel_pressed.content_margin_bottom = 6
panel_pressed.border_color = _theme.accent_color()
panel_pressed.set_border_width_all(2)
_ui_theme.set_stylebox("normal", "Button", panel)
_ui_theme.set_stylebox("hover", "Button", panel_dark)
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
_ui_theme.set_stylebox("focus", "Button", panel)
var inspector_surface := StyleBoxFlat.new()
inspector_surface.bg_color = _theme.inspector_background_color()
inspector_surface.border_color = _theme.border_color()
inspector_surface.set_border_width_all(1)
inspector_surface.content_margin_left = 8
inspector_surface.content_margin_right = 8
_ui_theme = _editor_visual_theme.control_theme(&"prop")
theme = _ui_theme
var inspector_surface: StyleBoxFlat = _ui_theme.get_stylebox("panel", "PanelContainer").duplicate() as StyleBoxFlat
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _theme.border_color().lightened(0.35)
splitter.bg_color = _editor_visual_theme.chrome_color(&"border", &"prop").lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_ui_theme.set_stylebox("normal", "OptionButton", panel)
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
_ui_theme.set_stylebox("normal", "SpinBox", panel_dark)
_ui_theme.set_stylebox("normal", "LineEdit", panel_dark)
_ui_theme.set_stylebox("normal", "ColorPickerButton", panel)
_ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark)
_ui_theme.set_stylebox("normal", "PopupMenu", panel_dark)
_ui_theme.set_stylebox("panel", "MenuBar", panel_dark)
_ui_theme.set_stylebox("normal", "CheckBox", panel)
_ui_theme.set_stylebox("panel", "PanelContainer", panel_dark)
_ui_theme.set_color("font_color", "Label", _theme.text_color())
_ui_theme.set_color("font_color", "Button", _theme.text_color())
_ui_theme.set_color("font_hover_color", "Button", Color.WHITE)
_ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color())
_ui_theme.set_font_size("font_size", "Label", _theme.font_body())
_ui_theme.set_font_size("font_size", "Button", _theme.font_body())
_ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body())
_ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body())
_ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body())
_ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu())
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
_menu_bar.theme = _ui_theme
_top_right.theme = _ui_theme
_prop_name_edit.theme = _ui_theme
@@ -654,11 +613,18 @@ func _setup_dialogs() -> void:
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
@@ -708,8 +674,8 @@ func _build_top_right_controls() -> void:
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
_whole_prop_resize_button.text = "Move, Turn, or Resize Prop"
_whole_prop_resize_button.tooltip_text = "Move, turn, or resize the whole prop, including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
@@ -923,9 +889,12 @@ func _build_exit_dialog() -> void:
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
_exit_dialog.add_button("🗑️ Discard", false, "discard")
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel"
_exit_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_exit_dialog)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
@@ -934,9 +903,12 @@ func _build_exit_dialog() -> void:
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_off_origin_save_dialog)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
@@ -1178,9 +1150,22 @@ func _build_inspector() -> void:
_vertex_label = Label.new()
_shape_section.add_child(_vertex_label)
_create_point_button = Button.new()
_create_point_button.text = "📍 Create Point"
_create_point_button.tooltip_text = "Add a point to the selected shape's edge."
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed)
_shape_section.add_child(_create_point_button)
_create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_create_point_hint)
_delete_button = Button.new()
_delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed)
_shape_section.add_child(_delete_button)
@@ -1208,6 +1193,7 @@ func _build_inspector() -> void:
_add_button = Button.new()
_add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button)
@@ -1219,7 +1205,7 @@ func _build_inspector() -> void:
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("TopBar")) as HBoxContainer
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null:
return
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
@@ -1232,6 +1218,7 @@ func _build_stage_authoring_controls() -> void:
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_stage_place_button.theme = _ui_theme
_editor_visual_theme.style_button_role(_stage_place_button, &"primary", &"prop")
_stage_place_button.pressed.connect(_request_stage_return)
top_bar.add_child(_stage_place_button)
@@ -1312,6 +1299,8 @@ func _build_anchor_inspector() -> void:
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
var role: StringName = &"primary" if label == "Add Anchor" else (&"destructive" if label == "Delete" else &"secondary")
_editor_visual_theme.style_button_role(button, role, &"prop")
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
@@ -1324,11 +1313,17 @@ func _build_anchor_inspector() -> void:
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z = _anchor_spin("Held prop layer", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var anchor_layer_hint := Label.new()
anchor_layer_hint.text = "Negative: behind · 0: normal order · Positive: farther in front."
anchor_layer_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
anchor_layer_hint.add_theme_font_size_override("font_size", _theme.font_status())
anchor_layer_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_anchor_section.add_child(anchor_layer_hint)
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
var button := Button.new(); button.text = str(entry["text"]); button.custom_minimum_size.y = float(_theme.button_min_height()); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
@@ -1341,6 +1336,7 @@ func _anchor_option(label_text: String, entries: PackedStringArray, callback: Ca
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
option.custom_minimum_size.y = float(_theme.button_min_height())
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
@@ -1360,6 +1356,7 @@ func _anchor_spin(label_text: String, minimum: float, maximum: float, callback:
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.custom_minimum_size.y = float(_theme.button_min_height())
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
@@ -1467,14 +1464,15 @@ func _apply_control_theme(node: Node) -> void:
func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Friction: %.2f" % mat.friction
_friction_label.text = "Surface grip: %.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_label.text = "Bounce: %.2f" % mat.bounce
func _on_preset_selected(index: int) -> void:
_material_preset = clampi(index, 0, PRESET_LABELS.size() - 1)
_update_physics_labels()
_update_save_status()
_mark_dirty()
func _rebuild_primitive_params() -> void:
@@ -1604,6 +1602,7 @@ func _refresh_inspector_from_selection() -> void:
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
@@ -1700,6 +1699,25 @@ func _center_prop_on_origin(focus_view: bool = true) -> bool:
func _on_canvas_shape_selected(_idx: int) -> void:
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
func _on_create_point_pressed() -> void:
if _canvas.get_selected_index() < 0:
return
_canvas.arm_create_point()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_update_create_point_feedback()
func _update_create_point_feedback() -> void:
if _create_point_button == null or _create_point_hint == null or _canvas == null:
return
var armed: bool = _canvas.is_create_point_armed()
_create_point_button.text = "Click an Edge to Place Point" if armed else "📍 Create Point"
_create_point_button.tooltip_text = "Press Esc to cancel." if armed else "Add a point to the selected shape's edge."
_create_point_hint.text = "Click near an edge to place the point. Press Esc to cancel." if armed else "Adds a point where you click on the selected edge."
func _on_canvas_copy_requested(shape: Dictionary) -> void:
@@ -1770,8 +1788,11 @@ func _check_autosave_recovery() -> void:
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
dialog.add_button("🗑️ Discard", false, "discard")
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
add_child(dialog)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
+20 -22
View File
@@ -10,6 +10,7 @@ extends RefCounted
## truth.
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://prop_theme.json"
@@ -39,19 +40,16 @@ const DEFAULT_CONFIG: Dictionary = {
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26",
},
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
@@ -155,71 +153,71 @@ func canvas_grid_color() -> Color:
# ---------------------------------------------------------------------------
# Kid-friendly UI tokens (Round 2)
# Compatibility accessors: editor chrome is owned by EditorVisualTheme.
# ---------------------------------------------------------------------------
func font_body() -> int:
return integer("ui.fonts.body", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"body", &"prop")
func font_header() -> int:
return integer("ui.fonts.header", 24, 8, 96)
return _editor_visual_theme.chrome_px(&"section", &"prop")
func font_menu() -> int:
return integer("ui.fonts.menu", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_status() -> int:
return integer("ui.fonts.status", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"status", &"prop")
func font_mode() -> int:
return integer("ui.fonts.mode", 26, 8, 96)
return _editor_visual_theme.chrome_px(&"mode", &"prop")
func font_menu_bar() -> int:
return integer("ui.fonts.menu_bar", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_prop_name() -> int:
return integer("ui.fonts.prop_name", 20, 8, 96)
return _editor_visual_theme.chrome_px(&"prop_name", &"prop")
func font_context_menu() -> int:
return integer("ui.fonts.context_menu", 22, 8, 96)
return _editor_visual_theme.chrome_px(&"context_menu", &"prop")
func button_min_height() -> int:
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"button_min_height", &"prop")
func row_min_height() -> int:
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"row_min_height", &"prop")
func mode_button_min_height() -> int:
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop")
func text_color() -> Color:
return color("ui.colors.text", Color("#F2F2F2"))
return _editor_visual_theme.chrome_color(&"text", &"prop")
func text_muted_color() -> Color:
return color("ui.colors.text_muted", Color("#9A9A9A"))
return _editor_visual_theme.chrome_color(&"text_muted", &"prop")
func border_color() -> Color:
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
return _editor_visual_theme.chrome_color(&"border", &"prop")
func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
return _editor_visual_theme.chrome_color(&"edit", &"prop")
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
return _editor_visual_theme.chrome_color(&"surface", &"prop")
func invalidate_caches() -> void:
+51 -39
View File
@@ -63,6 +63,7 @@ const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/
const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const STAGE_GROUND_FINISH_CHOOSER_SCRIPT := preload("res://scripts/shared/stage_ui/stage_ground_finish_chooser.gd")
const STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT := preload("res://scripts/shared/stage_ui/stage_depth_choice_preview.gd")
const PROP_FILE_SCRIPT := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
@@ -277,7 +278,7 @@ var _plain_color_draft: Color = Color("#F5EFE0")
var _new_ground_finish: StringName = &"finish_ground_grass"
var _hosted_prop_editor: PropEditor
var _hosted_artwork_path: String = ""
var _ground_finish_popup: PopupMenu
var _ground_finish_chooser: StageGroundFinishChooser
var _scenery_edit_mode: bool = false
var _scenery_multi_select: bool = false
var _scenery_single_dragging: bool = false
@@ -1729,9 +1730,13 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return
if _scenery_controller != null and _scenery_controller.is_active():
var scenery_world_position: Vector2 = _viewport_to_world(mb.position)
var return_to_scenery_selector: bool = mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed and _scenery_controller.is_placement_tool()
if _scenery_controller.handle_mouse_button(mb, scenery_world_position, _is_mouse_over_ui()):
get_viewport().set_input_as_handled()
_refresh_scenery_capture_bar()
if return_to_scenery_selector:
_return_to_scenery_selector()
else:
_refresh_scenery_capture_bar()
return
if _scenery_edit_mode:
var edit_world_position: Vector2 = _viewport_to_world(mb.position)
@@ -3281,7 +3286,7 @@ func _build_ui() -> void:
_asset_browser.surface_color_confirmed.connect(_record_recent_surface_color)
_asset_browser.set_surface_color_history(_recent_surface_colors)
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
_build_ground_finish_popup(ui)
_build_ground_finish_chooser(ui)
_modal_views.build_file_dialogs(ui)
_scene_open_dialog = _modal_views.scene_open_dialog
@@ -3895,43 +3900,41 @@ func _update_scenery_depth_cursor_badge() -> void:
_scenery_depth_cursor_badge.visible = true
func _build_ground_finish_popup(ui: CanvasLayer) -> void:
_ground_finish_popup = PopupMenu.new()
_ground_finish_popup.name = "NewGroundLook"
_ground_finish_popup.theme = _sandbox_theme.control_theme() if _sandbox_theme != null else null
for index: int in GROUND_FINISH_LABELS.size():
_ground_finish_popup.add_item(GROUND_FINISH_LABELS[index], index)
_ground_finish_popup.set_item_as_checkable(index, true)
_ground_finish_popup.id_pressed.connect(_on_ground_finish_selected)
ui.add_child(_ground_finish_popup)
func _build_ground_finish_chooser(ui: CanvasLayer) -> void:
_ground_finish_chooser = STAGE_GROUND_FINISH_CHOOSER_SCRIPT.new() as StageGroundFinishChooser
_ground_finish_chooser.name = "NewGroundLook"
_ground_finish_chooser.configure(_sandbox_theme as SandboxTheme, _ui_font, GROUND_FINISH_IDS, GROUND_FINISH_LABELS)
_ground_finish_chooser.confirmed.connect(_on_ground_finish_draw_requested)
_ground_finish_chooser.canceled.connect(_on_ground_finish_chooser_closed)
ui.add_child(_ground_finish_chooser)
func _open_ground_finish_popup() -> void:
if _ground_finish_popup == null:
func _open_ground_finish_chooser() -> void:
if _ground_finish_chooser == null:
return
for index: int in GROUND_FINISH_IDS.size():
_ground_finish_popup.set_item_checked(index, GROUND_FINISH_IDS[index] == _new_ground_finish)
var origin: Vector2 = get_viewport().get_mouse_position()
var ground_button: Button = _palette_buttons.get("ground") as Button
if ground_button != null:
var button_rect: Rect2 = ground_button.get_global_rect()
origin = Vector2(button_rect.position.x, button_rect.end.y)
var viewport_size: Vector2 = get_viewport_rect().size
origin.x = clampf(origin.x, 8.0, maxf(8.0, viewport_size.x - 288.0))
origin.y = clampf(origin.y, 8.0, maxf(8.0, viewport_size.y - 320.0))
_ground_finish_popup.popup(Rect2i(Vector2i(origin), Vector2i(280, 312)))
_ground_finish_chooser.open_for_finish(_new_ground_finish)
func _on_ground_finish_selected(index: int) -> void:
if index < 0 or index >= GROUND_FINISH_IDS.size():
func _on_ground_finish_draw_requested() -> void:
if _ground_finish_chooser == null:
return
_new_ground_finish = GROUND_FINISH_IDS[index]
var finish_id: StringName = _ground_finish_chooser.selected_finish()
var index: int = GROUND_FINISH_IDS.find(finish_id)
if index < 0 or index >= GROUND_FINISH_LABELS.size():
return
_new_ground_finish = finish_id
if _terrain_painter != null:
_terrain_painter.set_ground_finish(_new_ground_finish)
set_placement_mode("ground")
_show_toast("New Ground look: %s" % GROUND_FINISH_LABELS[index])
func _on_ground_finish_chooser_closed() -> void:
for placement_id: String in _palette_buttons:
var palette_button: Button = _palette_buttons[placement_id] as Button
palette_button.set_pressed_no_signal(placement_id == _placement_id)
func _build_use_spot_overlay(ui: CanvasLayer) -> void:
_use_spot_overlay = STAGE_USE_SPOT_OVERLAY_SCRIPT.new() as StageUseSpotOverlay
_use_spot_overlay.name = "UseSpotOverlay"
@@ -4938,7 +4941,7 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
_open_selector(id)
return
if id == "ground":
_open_ground_finish_popup()
_open_ground_finish_chooser()
return
set_placement_mode(id)
else:
@@ -4954,7 +4957,7 @@ func _on_palette_card_activated(payload: Dictionary) -> void:
if bool(payload.get("opens_selector", false)):
_open_selector(id)
elif id == "ground":
_open_ground_finish_popup()
_open_ground_finish_chooser()
else:
set_placement_mode(id)
@@ -5001,12 +5004,13 @@ func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
func _open_selector(kind: String) -> void:
func _open_selector(kind: String, preserve_scenery_browse_state: bool = false) -> void:
if kind == "scenery":
if _scenery_edit_mode:
_exit_scenery_edit_mode()
if _scenery_controller != null and _scenery_controller.is_active():
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
_scenery_placed_count = 0
elif _scenery_edit_mode:
_exit_scenery_edit_mode()
@@ -5017,12 +5021,14 @@ func _open_selector(kind: String) -> void:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
if kind == "scenery":
_asset_browser.set_surface_color_history(_recent_surface_colors)
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
var selector_state: Dictionary = _asset_browser.open_selector(
kind,
_spawner.get_selected_stickman_path(),
_sky_id,
_background_kind,
_plain_color
_plain_color,
preserve_scenery_browse_state
)
if kind == "stickman":
var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path()))
@@ -5099,6 +5105,11 @@ func _on_asset_selected(entry: Dictionary) -> void:
set_placement_mode(kind)
func _return_to_scenery_selector() -> void:
_hide_scenery_capture_bar()
_open_selector("scenery", true)
func _load_starter_scene(entry: Dictionary) -> void:
var path: String = CONTENT_PATHS.canonicalize(String(entry.get("path", "")))
var read_result: Dictionary = _document.read(path) if _document != null else {}
@@ -5658,7 +5669,7 @@ func _refresh_scenery_capture_bar() -> void:
actions.append(_make_scenery_depth_capture_action(&"depth_floor", &"floor", "Front", selected_depth))
var artwork_name: String = String(_scenery_controller.get_active_artwork_name())
var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another · Cancel undoes"
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "done_tool", "label": "Back", "primary": true})
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(prompt, actions)
elif _scenery_controller.get_active_tool() in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]:
@@ -5672,8 +5683,7 @@ func _refresh_scenery_capture_bar() -> void:
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(erase_prompt, actions)
else:
actions.append({"id": "choose_scenery", "label": "Choose scenery"})
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "done_tool", "label": "Back", "primary": true})
actions.append({"id": "cancel_tool", "label": "Cancel"})
if _scenery_controller.get_active_tool() == &"background_ground":
var ground_prompt: String = "Draw background ground · No walking"
@@ -5737,8 +5747,6 @@ func _hide_scenery_capture_bar() -> void:
func _on_capture_action_requested(action_id: StringName) -> void:
match action_id:
&"choose_scenery":
_open_selector("scenery")
&"depth_far":
_scenery_controller.set_depth(&"far")
_refresh_scenery_capture_bar()
@@ -5749,8 +5757,12 @@ func _on_capture_action_requested(action_id: StringName) -> void:
_scenery_controller.set_depth(&"floor")
_refresh_scenery_capture_bar()
&"done_tool":
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
if _scenery_controller != null and _scenery_controller.is_placement_tool():
_return_to_scenery_selector()
else:
if _scenery_controller != null:
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
&"cancel_tool":
_scenery_controller.cancel_active_tool()
_hide_scenery_capture_bar()
+13 -4
View File
@@ -160,8 +160,12 @@ func open_selector(
current_stickman_path: String,
current_sky_id: StringName = &"",
background_kind: StringName = &"sky",
plain_color: Color = Color("#F5EFE0")
plain_color: Color = Color("#F5EFE0"),
preserve_scenery_browse_state: bool = false
) -> Dictionary:
var scenery_browse_state: Dictionary = {}
if preserve_scenery_browse_state and kind == "scenery" and selector != null:
scenery_browse_state = selector.capture_browse_state()
_current_background_kind = &"plain" if background_kind == &"plain" else &"sky"
_current_sky_id = current_sky_id if not current_sky_id.is_empty() else &"sky_neutral"
_current_plain_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
@@ -177,7 +181,7 @@ func open_selector(
selected_stickman_path = String(entries[0].get("path", ""))
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
if auto_selection.is_empty():
open(kind, entries, _current_sky_id)
open(kind, entries, _current_sky_id, scenery_browse_state)
return {
"entries": entries,
"selected_stickman_path": selected_stickman_path,
@@ -201,14 +205,19 @@ func set_selector_kind(kind: String) -> void:
selector_kind = kind
func open(kind: String, entries: Array[Dictionary], current_sky_id: StringName = &"") -> void:
func open(
kind: String,
entries: Array[Dictionary],
current_sky_id: StringName = &"",
scenery_browse_state: Dictionary = {}
) -> void:
selector_kind = kind
selector_open = true
if selector_dim != null:
selector_dim.visible = true
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
if selector != null:
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color)
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color, scenery_browse_state)
load_or_enqueue_thumbnails(kind, entries)
@@ -78,6 +78,10 @@ func is_active() -> bool:
return not _active_tool.is_empty()
func is_placement_tool() -> bool:
return _is_placement_tool()
func is_group_preview_pending() -> bool:
return _group_pending
@@ -893,7 +893,7 @@ func _apply_compact_action_labels(compact: bool) -> void:
"resize_group": "Size",
"keep_group": "Keep",
"cancel_group": "Cancel",
"done_tool": "Done",
"done_tool": "Back",
"cancel_tool": "Cancel",
"depth_far": "Back",
"depth_near": "Middle",
+114 -5
View File
@@ -20,6 +20,7 @@ signal shape_selected()
signal shape_copy_requested(shape_dict: Dictionary)
signal shape_paste_requested(world_pos: Vector2)
signal color_selected(color: Color)
signal presentation_safe
# ---------------------------------------------------------------------------
# Exported / public properties
@@ -66,6 +67,7 @@ const ZOOM_STEP: float = 1.10
@onready var context_menu: PopupMenu = %ContextMenu
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
@onready var color_picker: ColorPicker = %ColorPicker
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
@onready var cancel_button: Button = %CancelButton
@onready var ok_button: Button = %OKButton
@@ -89,6 +91,12 @@ var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
var _restore_color: Color = Color.BLACK
var _grid_color: Color = GRID_COLOR
var _selection_core_color: Color = Color.WHITE
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
var _hint_color: Color = Color(0.67, 0.70, 0.75, 0.8)
var _button_target_height: float = 44.0
var _editor_visual_theme: EditorVisualTheme
# Phase 4: tracks which shape was right-clicked for context menu ops.
var _context_shape_idx: int = -1
@@ -117,6 +125,18 @@ func _ready() -> void:
ok_button.pressed.connect(_on_color_picker_ok)
if not cancel_button.pressed.is_connected(_on_color_picker_cancel):
cancel_button.pressed.connect(_on_color_picker_cancel)
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
cancel_button.text = "Cancel"
cancel_button.custom_minimum_size.y = _button_target_height
ok_button.text = "Use Color"
ok_button.custom_minimum_size.y = _button_target_height
color_picker_button_row.add_theme_constant_override("separation", 12)
if _editor_visual_theme != null:
_editor_visual_theme.style_button_role(ok_button, &"primary", &"stickman")
_editor_visual_theme.style_button_role(cancel_button, &"secondary", &"stickman")
_drawing_area_initialized = true
@@ -262,6 +282,75 @@ func reset_view() -> void:
_pan_offset = Vector2.ZERO
drawing_area.queue_redraw()
func can_switch_presentation() -> bool:
return not _is_panning \
and _dragging_vertex < 0 \
and not _is_dragging_shape \
and not context_menu.visible \
and not color_picker_popup.visible
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, hint_color: Color = Color(0.67, 0.70, 0.75, 0.8), button_target_height: float = 44.0, editor_visual_theme: EditorVisualTheme = null) -> void:
_grid_color = grid_color
_selection_core_color = selection_core
_selection_halo_color = selection_halo
_hint_color = hint_color
_button_target_height = button_target_height
_editor_visual_theme = editor_visual_theme
if drawing_area != null:
drawing_area.queue_redraw()
func set_active_presentation(active: bool, surface: Color, border: Color, accent: Color, section_font_size: int = 22) -> void:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = surface
style.border_color = accent if active else border
style.set_border_width_all(2 if active else 1)
style.set_corner_radius_all(8)
style.content_margin_left = 6
style.content_margin_right = 6
style.content_margin_top = 4
style.content_margin_bottom = 4
add_theme_stylebox_override("panel", style)
title_label.add_theme_color_override("font_color", accent if active else _selection_core_color)
title_label.add_theme_font_size_override("font_size", section_font_size)
func add_shape(kind: StringName, at_world: Vector2) -> bool:
match kind:
&"line": _create_line(at_world)
&"rectangle": _create_rectangle(at_world)
&"circle": _create_circle(at_world)
_:
return false
drawing_area.queue_redraw()
_emit_changed()
return true
func selected_shape_index() -> int:
return _selected_shape_idx
func has_selected_shape() -> bool:
return _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size()
func open_selected_color_picker() -> void:
if not has_selected_shape():
return
_context_shape_idx = _selected_shape_idx
_show_color_picker()
func get_view_context() -> Dictionary:
return {"zoom": _zoom, "pan_offset": _pan_offset}
func drawing_center_world() -> Vector2:
return _screen_to_world(drawing_area.size * 0.5)
# ---------------------------------------------------------------------------
# Phase 5: Clipboard
# ---------------------------------------------------------------------------
@@ -358,11 +447,11 @@ func _on_drawing_area_draw() -> void:
drawing_area.draw_string(
font,
hint_pos,
"Right-click to add shape",
"Use Add Shape above",
HORIZONTAL_ALIGNMENT_CENTER,
-1,
14,
Color(0.5, 0.5, 0.5, 0.6)
_hint_color
)
return
@@ -386,7 +475,8 @@ func _on_drawing_area_draw() -> void:
# 3. Selection highlight (only on selected shape)
if i == _selected_shape_idx:
_draw_polyline(pts, Color.WHITE, closed, 2.5)
_draw_polyline(pts, _selection_halo_color, closed, 5.0)
_draw_polyline(pts, _selection_core_color, closed, 2.5)
# 4. Vertex handles (only on selected shape)
for j: int in range(count):
@@ -396,6 +486,7 @@ func _on_drawing_area_draw() -> void:
if sd_vf.size() > j:
flag = sd_vf[j]
drawing_area.draw_circle(pt, (HANDLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
var handle_color := color
if j == _dragging_vertex:
handle_color = Color.YELLOW
@@ -423,12 +514,12 @@ func _draw_grid() -> void:
var x: float = start_x
while x <= world_end.x:
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
y += gs
@@ -470,6 +561,7 @@ func _on_drawing_area_gui_input(event: InputEvent) -> void:
_dragging_vertex = -1
else:
_is_panning = false
_emit_presentation_safe()
return
# 2. Mouse wheel -> zoom
@@ -660,6 +752,7 @@ func _handle_left_release() -> void:
_shape_drag_original_points = PackedVector2Array()
drawing_area.queue_redraw()
_emit_changed()
_emit_presentation_safe()
return
if _dragging_vertex >= 0:
@@ -667,6 +760,22 @@ func _handle_left_release() -> void:
_drag_offset = Vector2.ZERO
drawing_area.queue_redraw()
_emit_changed()
_emit_presentation_safe()
return
_emit_presentation_safe()
func _on_presentation_popup_hidden() -> void:
_emit_presentation_safe()
func _emit_presentation_safe() -> void:
call_deferred("_emit_presentation_safe_if_idle")
func _emit_presentation_safe_if_idle() -> void:
if can_switch_presentation():
presentation_safe.emit()
func _on_context_menu_id_pressed(id: int) -> void:
+536 -18
View File
@@ -1,3 +1,4 @@
class_name StickmanEditor
extends Control
## StickmanEditor - Main controller for the Stickman Studio editor.
##
@@ -15,6 +16,10 @@ extends Control
# ---------------------------------------------------------------------------
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
enum WorkspaceView { OVERVIEW, FOCUS }
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
const BODY_PART_NAMES: PackedStringArray = [
"head", "torso",
@@ -89,6 +94,10 @@ const SETTINGS_VERSION := "1.0"
const DEFAULT_GRID_SIZE := 15
const MIN_GRID_SIZE := 1
const MAX_GRID_SIZE := 100
const OVERVIEW_PANEL_MIN_WIDTH: float = 230.0
const OVERVIEW_PREVIEW_MIN_WIDTH: float = 320.0
const FOCUS_PANEL_MIN_WIDTH: float = 350.0
const WORKSPACE_GAP: float = 8.0
# ---------------------------------------------------------------------------
# On-ready node references
@@ -97,6 +106,19 @@ const MAX_GRID_SIZE := 100
@onready var _stickman_name_edit: LineEdit = %StickmanNameEdit
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _body_part_panels: Dictionary = {} ## { String : BodyPartPanel }
@onready var _body_part_workspace: Control = %BodyPartWorkspace
@onready var _overview_scroll: ScrollContainer = %OverviewScroll
@onready var _focus_host: VBoxContainer = %FocusHost
@onready var _focus_panel_host: Control = %FocusPanelHost
@onready var _focus_part_label: Label = %FocusPartLabel
@onready var _workspace_row: HSplitContainer = %WorkspaceRow
@onready var _overview_button: Button = %OverviewButton
@onready var _focus_button: Button = %FocusButton
@onready var _part_picker: OptionButton = %PartPicker
@onready var _add_shape_button: Button = %AddShapeButton
@onready var _color_button: Button = %ColorButton
@onready var _whole_stickman_button: Button = %WholeStickmanButton
@onready var _back_to_part_button: Button = %BackToPartButton
@onready var _whole_preview: WholeStickmanPreview = %WholeStickmanPreview
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@@ -106,6 +128,24 @@ const MAX_GRID_SIZE := 100
@onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox
@onready var _status_cursor_coords: Label = %CursorCoords
@onready var _status_snap_status: Label = %SnapStatus
@onready var _status_save_status: Label = %SaveStatus
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
var _editor_visual_theme: EditorVisualTheme
var _body_part_origins: Dictionary = {}
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
var _view_choice_was_manual: bool = false
var _active_part_name: String = "head"
var _pending_view: int = -1
var _pending_part_name: String = ""
var _workspace_controls_initialized: bool = false
var _view_button_group: ButtonGroup
var _add_shape_menu: PopupMenu
var _preview_takes_workspace: bool = false
var _current_path: String = ""
var _dirty: bool = false
var _applying_document: bool = false
var _pending_document_action: PendingDocumentAction = PendingDocumentAction.NONE
# ---------------------------------------------------------------------------
# Grid / snap state
@@ -131,19 +171,42 @@ var _view_menu: PopupMenu
# ---------------------------------------------------------------------------
func _ready() -> void:
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
theme = _editor_visual_theme.control_theme(&"stickman")
_setup_menu_bar()
_setup_dialogs()
_populate_body_part_panels()
_setup_workspace_controls()
_whole_preview.part_moved.connect(_on_preview_part_moved)
_whole_preview.part_transform_committed.connect(_on_preview_part_transform_committed)
_whole_preview.part_order_changed.connect(_on_preview_part_order_changed)
_whole_preview.set_body_parts(_collect_all_shape_data())
_whole_preview.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"preview", &"stickman")
)
_stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
_load_settings()
_update_snap_status_label()
_broadcast_settings()
_broadcast_recent_colors()
_update_active_part_presentation()
_update_document_status()
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
call_deferred("_initialize_workspace_presentation")
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_document_action(PendingDocumentAction.QUIT)
elif what == NOTIFICATION_RESIZED:
call_deferred("_refresh_responsive_workspace")
func _process(_delta: float) -> void:
@@ -151,7 +214,7 @@ func _process(_delta: float) -> void:
for part_name: String in BODY_PART_NAMES:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel and panel.is_cursor_over_drawing(mouse_pos):
if panel and panel.is_visible_in_tree() and panel.is_cursor_over_drawing(mouse_pos):
var world_pos := panel.global_to_world(mouse_pos)
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
return
@@ -173,7 +236,7 @@ func _setup_menu_bar() -> void:
file_menu.add_item("Save", 0)
file_menu.add_item("Load", 1)
file_menu.add_separator()
file_menu.add_item("Clear", 2)
file_menu.add_item("Start Over", 2)
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
@@ -201,12 +264,319 @@ func _setup_menu_bar() -> void:
func _setup_dialogs() -> void:
_save_dialog.theme = theme
_load_dialog.theme = theme
_clear_confirm.theme = theme
_unsaved_dialog.theme = theme
_error_dialog.theme = theme
_grid_config_dialog.theme = theme
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
_save_dialog.current_dir = content_dir
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
_clear_confirm.confirmed.connect(_on_clear_confirmed)
_clear_confirm.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_clear_confirm.get_ok_button(), &"destructive", &"stickman")
_editor_visual_theme.style_button_role(_clear_confirm.get_cancel_button(), &"secondary", &"stickman")
var discard_button: Button = _unsaved_dialog.add_button("Discard Changes", false, "discard")
_unsaved_dialog.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_unsaved_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"stickman")
_editor_visual_theme.style_button_role(_unsaved_dialog.get_cancel_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_grid_config_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_grid_config_dialog.get_cancel_button(), &"secondary", &"stickman")
_unsaved_dialog.confirmed.connect(_on_unsaved_save_pressed)
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
_unsaved_dialog.canceled.connect(_cancel_pending_document_action)
get_tree().auto_accept_quit = false
func _setup_workspace_controls() -> void:
_view_button_group = ButtonGroup.new()
_overview_button.button_group = _view_button_group
_focus_button.button_group = _view_button_group
_overview_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
_part_picker.custom_minimum_size = Vector2(160.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
_add_shape_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_add_shape_button, &"primary", &"stickman")
_color_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_color_button, &"secondary", &"stickman")
_whole_stickman_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_part_label.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"section", &"stickman"))
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_save_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
for part_name: String in BODY_PART_NAMES:
_part_picker.add_item(BODY_PART_DISPLAY[part_name])
_overview_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.OVERVIEW))
_focus_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.FOCUS))
_part_picker.item_selected.connect(_on_part_picker_selected)
_add_shape_button.pressed.connect(_on_add_shape_button_pressed)
_color_button.pressed.connect(_on_color_button_pressed)
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
_add_shape_menu = PopupMenu.new()
_add_shape_menu.add_item("Line", 0)
_add_shape_menu.add_item("Rectangle", 1)
_add_shape_menu.add_item("Circle", 2)
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
_add_shape_menu.theme = theme
add_child(_add_shape_menu)
_workspace_controls_initialized = true
_refresh_workspace_controls()
func _initialize_workspace_presentation() -> void:
if _workspace_row.size.x <= 0.0:
await get_tree().process_frame
if not _view_choice_was_manual:
var initial_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
request_workspace_view(initial_view, false)
_refresh_responsive_workspace()
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void:
if user_chosen:
_view_choice_was_manual = true
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
_pending_view = int(view)
return
_pending_view = -1
_apply_workspace_view(view)
func request_active_part(part_name: String) -> void:
if not BODY_PART_NAMES.has(part_name):
return
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
_pending_part_name = part_name
return
_pending_part_name = ""
_set_active_part(part_name)
func get_workspace_view() -> WorkspaceView:
return _workspace_view
func get_active_part_name() -> String:
return _active_part_name
func has_unsaved_changes() -> bool:
return _dirty
func current_path() -> String:
return _current_path
func _apply_pending_presentation() -> void:
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
return
if not _pending_part_name.is_empty():
var requested_part: String = _pending_part_name
_pending_part_name = ""
_set_active_part(requested_part)
if _pending_view >= 0:
var requested_view: WorkspaceView = _pending_view
_pending_view = -1
_apply_workspace_view(requested_view)
func _apply_workspace_view(view: WorkspaceView) -> void:
if view == _workspace_view:
_refresh_workspace_controls()
_refresh_responsive_workspace()
return
if _workspace_view == WorkspaceView.FOCUS:
_restore_panel_to_overview(_active_part_name)
_workspace_view = view
_preview_takes_workspace = false
if _workspace_view == WorkspaceView.FOCUS:
_move_panel_to_focus(_active_part_name)
_overview_scroll.visible = _workspace_view == WorkspaceView.OVERVIEW
_focus_host.visible = _workspace_view == WorkspaceView.FOCUS
_refresh_workspace_controls()
_update_active_part_presentation()
_refresh_responsive_workspace()
func _set_active_part(part_name: String) -> void:
if not BODY_PART_NAMES.has(part_name) or part_name == _active_part_name:
_refresh_workspace_controls()
return
if _workspace_view == WorkspaceView.FOCUS:
_restore_panel_to_overview(_active_part_name)
_active_part_name = part_name
if _workspace_view == WorkspaceView.FOCUS:
_move_panel_to_focus(_active_part_name)
_refresh_workspace_controls()
_update_active_part_presentation()
func _active_panel() -> BodyPartPanel:
return _body_part_panels.get(_active_part_name) as BodyPartPanel
func _move_panel_to_focus(part_name: String) -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel == null or panel.get_parent() == _focus_panel_host:
return
var current_focus_panel: Control = _focus_panel_host.get_child(0) as Control if _focus_panel_host.get_child_count() > 0 else null
if current_focus_panel != null:
var current_part: String = String(current_focus_panel.get_meta("body_part_name", ""))
if not current_part.is_empty():
_restore_panel_to_overview(current_part)
var old_parent: Node = panel.get_parent()
if old_parent == null:
return
old_parent.remove_child(panel)
_focus_panel_host.add_child(panel)
panel.set_meta("body_part_name", part_name)
panel.layout_mode = 1
panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
panel.custom_minimum_size = Vector2(FOCUS_PANEL_MIN_WIDTH, 300.0)
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
func _restore_panel_to_overview(part_name: String) -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
var origin: Dictionary = _body_part_origins.get(part_name, {})
if panel == null or origin.is_empty():
return
var origin_parent: Node = origin.get("parent") as Node
if origin_parent == null or panel.get_parent() == origin_parent:
return
var old_parent: Node = panel.get_parent()
if old_parent != null:
old_parent.remove_child(panel)
panel.layout_mode = 2
panel.custom_minimum_size = origin.get("minimum_size", Vector2(230.0, 160.0))
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
origin_parent.add_child(panel)
origin_parent.move_child(panel, int(origin.get("index", origin_parent.get_child_count() - 1)))
panel.remove_meta("body_part_name")
func _overview_width_fits() -> bool:
var required_width: float = OVERVIEW_PANEL_MIN_WIDTH * 2.0 + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP * 2.0
return _available_workspace_width() >= required_width
func _available_workspace_width() -> float:
var viewport_width: float = get_viewport_rect().size.x
return minf(size.x, viewport_width) if viewport_width > 0.0 else _workspace_row.size.x
func _refresh_responsive_workspace() -> void:
if not is_node_ready():
return
if not _view_choice_was_manual and _workspace_controls_initialized:
var responsive_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
if responsive_view != _workspace_view:
request_workspace_view(responsive_view, false)
var narrow_preview: bool = _available_workspace_width() < FOCUS_PANEL_MIN_WIDTH + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP
if _workspace_view == WorkspaceView.OVERVIEW:
_preview_takes_workspace = false
_body_part_workspace.visible = true
_whole_preview.visible = not narrow_preview
_whole_stickman_button.visible = false
_back_to_part_button.visible = false
return
if narrow_preview and _preview_takes_workspace:
_body_part_workspace.visible = false
_whole_preview.visible = true
_whole_stickman_button.visible = false
_back_to_part_button.visible = true
elif narrow_preview:
_body_part_workspace.visible = true
_whole_preview.visible = false
_whole_stickman_button.visible = true
_back_to_part_button.visible = false
else:
_preview_takes_workspace = false
_body_part_workspace.visible = true
_whole_preview.visible = true
_whole_stickman_button.visible = false
_back_to_part_button.visible = false
func _update_active_part_presentation() -> void:
if _editor_visual_theme == null:
return
var surface: Color = _editor_visual_theme.chrome_color(&"surface", &"stickman")
var border: Color = _editor_visual_theme.chrome_color(&"border", &"stickman")
var accent: Color = _editor_visual_theme.chrome_color(&"edit", &"stickman")
var section_size: int = _editor_visual_theme.chrome_px(&"section", &"stickman")
for part_name: String in BODY_PART_NAMES:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel != null:
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
if is_instance_valid(_focus_part_label):
_focus_part_label.text = BODY_PART_DISPLAY.get(_active_part_name, _active_part_name)
func _refresh_workspace_controls() -> void:
if not _workspace_controls_initialized:
return
_overview_button.set_pressed_no_signal(_workspace_view == WorkspaceView.OVERVIEW)
_focus_button.set_pressed_no_signal(_workspace_view == WorkspaceView.FOCUS)
var part_index: int = BODY_PART_NAMES.find(_active_part_name)
if part_index >= 0:
_part_picker.select(part_index)
var active: BodyPartPanel = _active_panel()
var selected: bool = active != null and active.has_selected_shape()
_color_button.disabled = not selected
_color_button.tooltip_text = "Select a shape to choose its color." if not selected else "Choose this shape's color."
_update_active_part_presentation()
func _on_part_picker_selected(index: int) -> void:
if index >= 0 and index < BODY_PART_NAMES.size():
request_active_part(BODY_PART_NAMES[index])
func _on_add_shape_button_pressed() -> void:
var rect: Rect2 = Rect2(_add_shape_button.global_position + Vector2(0, _add_shape_button.size.y), Vector2(_add_shape_button.size.x, 1.0))
_add_shape_menu.popup(rect)
func _on_add_shape_menu_id_pressed(id: int) -> void:
var kind: StringName = [&"line", &"rectangle", &"circle"][clampi(id, 0, 2)]
var panel: BodyPartPanel = _active_panel()
if panel != null:
panel.add_shape(kind, panel.drawing_center_world())
_refresh_workspace_controls()
func _on_color_button_pressed() -> void:
var panel: BodyPartPanel = _active_panel()
if panel != null:
panel.open_selected_color_picker()
func _on_whole_stickman_button_pressed() -> void:
_preview_takes_workspace = true
_refresh_responsive_workspace()
func _on_back_to_part_button_pressed() -> void:
_preview_takes_workspace = false
_refresh_responsive_workspace()
# ---------------------------------------------------------------------------
# Body-part panel population
@@ -230,7 +600,17 @@ func _populate_body_part_panels() -> void:
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
panel.color_selected.connect(_on_color_selected.bind(part_name))
panel.presentation_safe.connect(_apply_pending_presentation)
panel.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
_editor_visual_theme
)
_body_part_panels[part_name] = panel
_body_part_origins[part_name] = {"parent": left_col, "index": left_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
left_col.add_child(panel)
for part_name: String in center_names:
@@ -242,7 +622,17 @@ func _populate_body_part_panels() -> void:
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
panel.color_selected.connect(_on_color_selected.bind(part_name))
panel.presentation_safe.connect(_apply_pending_presentation)
panel.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
_editor_visual_theme
)
_body_part_panels[part_name] = panel
_body_part_origins[part_name] = {"parent": center_col, "index": center_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
center_col.add_child(panel)
# ---------------------------------------------------------------------------
@@ -252,11 +642,121 @@ func _populate_body_part_panels() -> void:
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: # Save
_save_dialog.popup_centered_ratio(0.6)
_save_current_document()
1: # Load
_request_document_action(PendingDocumentAction.LOAD)
2: # Start Over
_request_document_action(PendingDocumentAction.CLEAR)
func _save_current_document() -> void:
if _current_path.is_empty():
_save_dialog.popup_centered_ratio(0.6)
return
save_to_path(_current_path)
func save_to_path(path: String) -> bool:
var save_path: String = ContentPathsScript.canonicalize(path)
if save_path.get_extension().to_lower() != "stk":
save_path = save_path.get_basename() + ".stk"
var file: FileAccess = FileAccess.open(save_path, FileAccess.WRITE)
if file == null:
_show_error("Could not save the stickman. Check that the file location is available.")
return false
file.store_string(JSON.stringify(_build_json_data(), "\t", false))
var write_error: Error = file.get_error()
file.close()
if write_error != OK:
_show_error("Could not finish saving the stickman. Check the file location and try again.")
return false
_current_path = save_path
_dirty = false
_update_document_status()
return true
func _request_document_action(action: PendingDocumentAction) -> void:
if _dirty:
_pending_document_action = action
match action:
PendingDocumentAction.LOAD:
_unsaved_dialog.dialog_text = "Save changes before loading another stickman?"
PendingDocumentAction.CLEAR:
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
PendingDocumentAction.QUIT:
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
_unsaved_dialog.popup_centered()
return
_continue_document_action(action)
func _continue_document_action(action: PendingDocumentAction) -> void:
match action:
PendingDocumentAction.LOAD:
_load_dialog.popup_centered_ratio(0.6)
2: # Clear
PendingDocumentAction.CLEAR:
_clear_confirm.popup_centered()
PendingDocumentAction.QUIT:
get_tree().quit()
_pending_document_action = PendingDocumentAction.NONE
func _on_unsaved_save_pressed() -> void:
if _pending_document_action == PendingDocumentAction.NONE:
return
if _current_path.is_empty():
_save_dialog.popup_centered_ratio(0.6)
return
if save_to_path(_current_path):
_resume_pending_document_action()
else:
_cancel_pending_document_action()
func _on_unsaved_custom_action(action: StringName) -> void:
if action != &"discard":
return
_resume_pending_document_action()
func _resume_pending_document_action() -> void:
var action: PendingDocumentAction = _pending_document_action
_pending_document_action = PendingDocumentAction.NONE
if action == PendingDocumentAction.CLEAR:
_clear_all()
return
_continue_document_action(action)
func _cancel_pending_document_action() -> void:
_pending_document_action = PendingDocumentAction.NONE
func _on_stickman_name_changed(_value: String) -> void:
if not _applying_document:
_mark_dirty()
func _mark_dirty() -> void:
if _applying_document:
return
_dirty = true
_update_document_status()
func _update_document_status() -> void:
if _status_save_status == null:
return
if _dirty:
_status_save_status.text = "Unsaved changes"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"dirty", &"stickman"))
elif _current_path.is_empty():
_status_save_status.text = "Not saved yet"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"text_muted", &"stickman"))
else:
_status_save_status.text = "Saved"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"saved", &"stickman"))
func _on_edit_menu_id_pressed(id: int) -> void:
@@ -320,19 +820,12 @@ func _on_grid_config_confirmed() -> void:
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if not path.ends_with(".stk"):
path += ".stk"
var data := _build_json_data()
var json_text := JSON.stringify(data, "\t", false)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
_show_error("Failed to save file: " + str(FileAccess.get_open_error()))
if path.get_extension().to_lower() != "stk":
path = path.get_basename() + ".stk"
if not save_to_path(path):
_cancel_pending_document_action()
return
file.store_string(json_text)
file.close()
_resume_pending_document_action()
func _on_load_file_selected(path: String) -> void:
@@ -358,6 +851,9 @@ func _on_load_file_selected(path: String) -> void:
if not err.is_empty():
_show_error(err)
return
_current_path = path
_dirty = false
_update_document_status()
func _on_clear_confirmed() -> void:
@@ -379,19 +875,31 @@ func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
_whole_preview.set_part_rotation(part_name, 0.0)
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
_broadcast_clipboard_state()
_mark_dirty()
_refresh_workspace_controls()
func _on_body_part_shape_selected(part_name: String) -> void:
request_active_part(part_name)
for other_name: String in BODY_PART_NAMES:
if other_name != part_name:
var panel: BodyPartPanel = _body_part_panels.get(other_name) as BodyPartPanel
if panel:
panel.deselect()
_refresh_workspace_controls()
func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
pass
func _on_preview_part_transform_committed(_part_name: String, _position: Vector2, _rotation_deg: float, _scale: Vector2) -> void:
_mark_dirty()
func _on_preview_part_order_changed(_part_name: String, _new_order: Array[String]) -> void:
_mark_dirty()
# ---------------------------------------------------------------------------
# Data collection / JSON helpers
# ---------------------------------------------------------------------------
@@ -478,13 +986,13 @@ func _apply_json_data(json: Variant) -> String:
if version not in SUPPORTED_VERSIONS:
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
_stickman_name_edit.text = dict.get("stickman_name", "")
var body_parts: Variant = dict.get("body_parts")
if not body_parts is Dictionary:
return "Invalid file format: 'body_parts' must be a dictionary."
var body_dict: Dictionary = body_parts as Dictionary
_applying_document = true
_stickman_name_edit.text = str(dict.get("stickman_name", ""))
_clear_all_panels()
@@ -546,6 +1054,9 @@ func _apply_json_data(json: Variant) -> String:
_whole_preview.reset_view()
_whole_preview.set_all_part_positions(positions)
_whole_preview.set_body_parts(_collect_all_shape_data())
_applying_document = false
_dirty = false
_update_document_status()
return ""
@@ -561,12 +1072,18 @@ func _clear_all_panels() -> void:
func _clear_all() -> void:
_applying_document = true
_clear_all_panels()
_whole_preview.clear_all()
_whole_preview.set_all_part_positions(DEFAULT_POSITIONS.duplicate())
_stickman_name_edit.text = ""
_shape_clipboard.clear()
_broadcast_clipboard_state()
_applying_document = false
_current_path = ""
_dirty = false
_update_document_status()
_refresh_workspace_controls()
# ---------------------------------------------------------------------------
# Phase 5: Shape clipboard
@@ -611,6 +1128,7 @@ func _on_color_selected(color: Color, _part_name: String) -> void:
_recent_colors.pop_back()
_save_settings()
_broadcast_recent_colors()
_mark_dirty()
# ---------------------------------------------------------------------------
# Phase 3: Settings persistence
@@ -18,6 +18,8 @@ class_name WholeStickmanPreview
# ---------------------------------------------------------------------------
signal part_moved(part_name: String, new_position: Vector2)
signal part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)
signal part_order_changed(part_name: String, new_order: Array[String])
# ---------------------------------------------------------------------------
# Constants
@@ -147,6 +149,14 @@ var _selected_gizmo_bounds: Rect2 = Rect2()
# Phase 7: pose silhouette guide (default ON per user decision)
var _show_guide: bool = true
var _grid_color: Color = GRID_COLOR
var _selection_core_color: Color = Color.WHITE
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
var _preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)
var _interaction_start_part: String = ""
var _interaction_start_position: Vector2 = Vector2.ZERO
var _interaction_start_rotation: float = 0.0
var _interaction_start_scale: Vector2 = Vector2.ONE
# ---------------------------------------------------------------------------
# Public API
@@ -271,6 +281,14 @@ func set_show_guide(enabled: bool) -> void:
preview_area.queue_redraw()
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)) -> void:
_grid_color = grid_color
_selection_core_color = selection_core
_selection_halo_color = selection_halo
_preview_color = preview_color
preview_area.queue_redraw()
## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names.
@@ -399,25 +417,35 @@ func _on_preview_draw() -> void:
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
preview_area.draw_rect(bounds as Rect2, _preview_color, false, 1.5 / _zoom)
# Draw selection gizmos on top of all parts
if _selected_gizmo_bounds.has_area():
var b: Rect2 = _selected_gizmo_bounds
preview_area.draw_rect(b, Color.WHITE, false, 1.5 / _zoom)
preview_area.draw_rect(b, _selection_halo_color, false, 4.0 / _zoom)
preview_area.draw_rect(b, _selection_core_color, false, 1.5 / _zoom)
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
preview_area.draw_circle(rot_center, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, _selection_core_color)
var cross_half := SCALE_CROSS_SIZE / _zoom
for i: int in range(4):
var corner := _get_corner_position(b, i)
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
Color.WHITE, 1.5 / _zoom)
_selection_halo_color, 4.0 / _zoom)
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
Color.WHITE, 1.5 / _zoom)
_selection_halo_color, 4.0 / _zoom)
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
_selection_core_color, 1.5 / _zoom)
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
_selection_core_color, 1.5 / _zoom)
func _draw_grid() -> void:
@@ -436,12 +464,12 @@ func _draw_grid() -> void:
var x: float = start_x
while x <= world_end.x:
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
y += gs
@@ -688,6 +716,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
_interaction = Interaction.ROTATE
_capture_transform_start(_selected_part)
_rotate_start_rotation = _part_rotations[_selected_part]
_rotate_total_delta = 0.0
var bounds_center := bounds.position + bounds.size * 0.5
@@ -700,6 +729,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
var corner := _get_corner_position(bounds, i)
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
_interaction = Interaction.SCALE
_capture_transform_start(_selected_part)
_scale_corner = i
_scale_anchor = _get_opposite_corner(bounds, i)
_scale_original_size = _compute_original_bounds_size(_selected_part)
@@ -717,6 +747,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
if rect.grow(8.0).has_point(world_pos):
_selected_part = part_name
_interaction = Interaction.TRANSLATE
_capture_transform_start(_selected_part)
_dragging_part = part_name
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
_drag_offset = world_pos - pos
@@ -731,13 +762,40 @@ func _try_start_interaction(world_pos: Vector2) -> void:
func _end_interaction() -> void:
if _interaction != Interaction.NONE:
var changed_part: String = _interaction_start_part
var changed: bool = changed_part != "" and (
_part_positions.get(changed_part, Vector2.ZERO) != _interaction_start_position
or not is_equal_approx(float(_part_rotations.get(changed_part, 0.0)), _interaction_start_rotation)
or Vector2(_part_scales.get(changed_part, Vector2.ONE)) != _interaction_start_scale
)
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
_dragging_part = ""
_drag_offset = Vector2.ZERO
_interaction = Interaction.NONE
if changed:
_emit_transform_committed(changed_part)
_interaction_start_part = ""
preview_area.queue_redraw()
func _capture_transform_start(part_name: String) -> void:
_interaction_start_part = part_name
_interaction_start_position = _part_positions.get(part_name, Vector2.ZERO)
_interaction_start_rotation = float(_part_rotations.get(part_name, 0.0))
_interaction_start_scale = _part_scales.get(part_name, Vector2.ONE)
func _emit_transform_committed(part_name: String) -> void:
if part_name.is_empty():
return
part_transform_committed.emit(
part_name,
_part_positions.get(part_name, Vector2.ZERO),
float(_part_rotations.get(part_name, 0.0)),
_part_scales.get(part_name, Vector2.ONE)
)
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
if _dragging_part.is_empty():
return
@@ -996,6 +1054,7 @@ func _send_back_part() -> void:
_part_order.remove_at(idx)
_part_order.insert(idx - 1, _selected_part)
preview_area.queue_redraw()
part_order_changed.emit(_selected_part, _part_order.duplicate())
func _bring_forward_part() -> void:
@@ -1007,6 +1066,7 @@ func _bring_forward_part() -> void:
_part_order.remove_at(idx)
_part_order.insert(idx + 1, _selected_part)
preview_area.queue_redraw()
part_order_changed.emit(_selected_part, _part_order.duplicate())
func _mirror_part_x() -> void:
@@ -1016,6 +1076,7 @@ func _mirror_part_x() -> void:
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
_emit_transform_committed(_selected_part)
func _mirror_part_y() -> void:
@@ -1025,3 +1086,4 @@ func _mirror_part_y() -> void:
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
_emit_transform_committed(_selected_part)
@@ -0,0 +1,284 @@
class_name EditorVisualTheme
extends RefCounted
## Validated semantic chrome tokens shared by the standalone editors.
## Canvas artwork and editor-specific drawing values stay with their owners.
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"chrome": {
"colors": {
"background": "#171B24F2",
"surface": "#252B38FA",
"surface_hover": "#30394A",
"text": "#F5F7FA",
"text_muted": "#AAB2C0",
"edit": "#22C6FF",
"preview": "#22C6FF66",
"focus": "#8BD9FF",
"action_primary": "#087DBA",
"destructive": "#E66B75",
"border": "#FFFFFF33",
"disabled": "#77808F",
"saved": "#8FD6A4",
"dirty": "#FFD166",
"error": "#FF8A8A",
},
"fonts": {
"body": 18,
"helper": 16,
"section": 22,
"title": 26,
"menu": 20,
"status": 16,
"mode": 24,
"prop_name": 20,
"context_menu": 20,
},
"spacing": {"small": 8, "medium": 12, "large": 16},
"radii": {"control": 8, "panel": 10},
"borders": {"normal": 1, "focus": 2},
"targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56},
},
"editors": {"prop": {}, "stickman": {}},
"stickman_canvas": {
"grid_color": "#FFFFFF26",
"selection_core": "#FFFFFF",
"selection_halo": "#000000D9",
},
}
const COLOR_ROLE_SECTIONS: Dictionary = {
"background": "colors",
"surface": "colors",
"surface_hover": "colors",
"text": "colors",
"text_muted": "colors",
"edit": "colors",
"preview": "colors",
"focus": "colors",
"action_primary": "colors",
"destructive": "colors",
"border": "colors",
"disabled": "colors",
"saved": "colors",
"dirty": "colors",
"error": "colors",
}
const SIZE_ROLE_SECTIONS: Dictionary = {
"body": "fonts",
"helper": "fonts",
"section": "fonts",
"title": "fonts",
"menu": "fonts",
"status": "fonts",
"mode": "fonts",
"prop_name": "fonts",
"context_menu": "fonts",
"small": "spacing",
"medium": "spacing",
"large": "spacing",
"control": "radii",
"panel": "radii",
"normal_border": "borders",
"focus_border": "borders",
"button_min_height": "targets",
"row_min_height": "targets",
"mode_button_min_height": "targets",
}
const CONFIG_SIZE_KEYS: Dictionary = {
"normal_border": "normal",
"focus_border": "focus",
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
_warned.clear()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "EditorVisualTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "EditorVisualTheme: '%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, "EditorVisualTheme: '%s' is malformed; using defaults." % path)
return false
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func chrome_color(role: StringName, editor: StringName = &"") -> Color:
var role_key: String = String(role)
if not COLOR_ROLE_SECTIONS.has(role_key):
_warn_once("color:" + role_key, "EditorVisualTheme: unknown color role '%s'." % role_key)
return Color.MAGENTA
var section: String = COLOR_ROLE_SECTIONS[role_key]
var raw: Variant = _resolved_value(section, role_key, editor)
var fallback_config: Dictionary = DEFAULT_CONFIG["chrome"][section]
var fallback: Color = Color.from_string(String(fallback_config[role_key]), Color.WHITE)
if not raw is String:
_warn_once("color:" + role_key, "EditorVisualTheme: invalid value for color role '%s'." % role_key)
return fallback
var hex: String = String(raw)
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
_warn_once("color:" + role_key, "EditorVisualTheme: invalid color at '%s'." % role_key)
return fallback
return Color.from_string(hex, fallback)
func chrome_px(role: StringName, editor: StringName = &"") -> int:
var role_key: String = String(role)
if not SIZE_ROLE_SECTIONS.has(role_key):
_warn_once("size:" + role_key, "EditorVisualTheme: unknown size role '%s'." % role_key)
return 0
var section: String = SIZE_ROLE_SECTIONS[role_key]
var config_key: String = String(CONFIG_SIZE_KEYS.get(role_key, role_key))
var raw: Variant = _resolved_value(section, config_key, editor)
var fallback: int = int(DEFAULT_CONFIG["chrome"][section][config_key])
if not raw is int and not raw is float:
_warn_once("size:" + role_key, "EditorVisualTheme: invalid value for size role '%s'." % role_key)
return fallback
return clampi(int(round(float(raw))), 0, 256)
func stickman_canvas_color(role: StringName) -> Color:
var role_key: String = String(role)
var fallback_text: String = String(DEFAULT_CONFIG["stickman_canvas"].get(role_key, "#FFFFFF"))
var fallback: Color = Color.from_string(fallback_text, Color.WHITE)
var raw: Variant = _get_nested(_config, ["stickman_canvas", role_key], fallback_text)
if not raw is String:
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
return fallback
var hex: String = String(raw)
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
return fallback
return Color.from_string(hex, fallback)
func control_theme(editor: StringName) -> Theme:
var theme: Theme = Theme.new()
var surface: Color = chrome_color(&"surface", editor)
var background: Color = chrome_color(&"background", editor)
var hover: Color = chrome_color(&"surface_hover", editor)
var text: Color = chrome_color(&"text", editor)
var muted: Color = chrome_color(&"text_muted", editor)
var edit: Color = chrome_color(&"edit", editor)
var focus: Color = chrome_color(&"focus", editor)
var border: Color = chrome_color(&"border", editor)
var radius: int = chrome_px(&"control", editor)
var normal_width: int = chrome_px(&"normal_border", editor)
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
var normal_box: StyleBoxFlat = _make_box(surface, border, radius, normal_width)
var hover_box: StyleBoxFlat = _make_box(hover, edit, radius, normal_width)
var pressed_box: StyleBoxFlat = _make_box(surface.darkened(0.12), edit, radius, focus_width)
var focus_box: StyleBoxFlat = _make_box(Color(0, 0, 0, 0), focus, radius, focus_width)
var disabled_box: StyleBoxFlat = _make_box(background.darkened(0.1), border, radius, normal_width)
for type_name: String in ["Button", "OptionButton", "ColorPickerButton", "CheckBox"]:
theme.set_stylebox("normal", type_name, normal_box.duplicate())
theme.set_stylebox("hover", type_name, hover_box.duplicate())
theme.set_stylebox("pressed", type_name, pressed_box.duplicate())
theme.set_stylebox("focus", type_name, focus_box.duplicate())
theme.set_stylebox("disabled", type_name, disabled_box.duplicate())
theme.set_color("font_color", type_name, text)
theme.set_color("font_hover_color", type_name, text)
theme.set_color("font_pressed_color", type_name, edit)
theme.set_color("font_disabled_color", type_name, muted)
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
var input_box: StyleBoxFlat = _make_box(background, border, radius, normal_width)
var input_focus: StyleBoxFlat = _make_box(background, focus, radius, focus_width)
for type_name: String in ["LineEdit", "SpinBox", "TextEdit"]:
theme.set_stylebox("normal", type_name, input_box.duplicate())
theme.set_stylebox("focus", type_name, input_focus.duplicate())
theme.set_color("font_color", type_name, text)
theme.set_color("font_placeholder_color", type_name, muted)
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width)
theme.set_stylebox("panel", "PanelContainer", panel_box)
theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate())
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
theme.set_color("font_color", "Label", text)
theme.set_color("font_hover_color", "Label", text)
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor))
theme.set_color("font_color", "MenuBar", text)
return theme
func style_button_role(button: Button, role: StringName, editor: StringName) -> void:
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, float(chrome_px(&"button_min_height", editor)))
if role != &"primary" and role != &"destructive":
return
var fill_role: StringName = &"action_primary" if role == &"primary" else &"destructive"
var fill: Color = chrome_color(fill_role, editor)
var focus: Color = chrome_color(&"focus", editor)
var radius: int = chrome_px(&"control", editor)
var border_width: int = chrome_px(&"normal_border", editor)
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
button.add_theme_stylebox_override("normal", _make_box(fill, fill.lightened(0.12), radius, border_width))
button.add_theme_stylebox_override("hover", _make_box(fill.lightened(0.12), focus, radius, border_width))
button.add_theme_stylebox_override("pressed", _make_box(fill.darkened(0.12), focus, radius, focus_width))
button.add_theme_stylebox_override("focus", _make_box(Color(0.0, 0.0, 0.0, 0.0), focus, radius, focus_width))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
func _resolved_value(section: String, key: String, editor: StringName) -> Variant:
if not editor.is_empty():
var editor_value: Variant = _get_nested(_config, ["editors", String(editor), section, key], null)
if editor_value != null:
return editor_value
return _get_nested(_config, ["chrome", section, key], null)
func _get_nested(source: Dictionary, keys: Array[String], fallback: Variant) -> Variant:
var cursor: Variant = source
for key: String in keys:
if not cursor is Dictionary or not (cursor as Dictionary).has(key):
return fallback
cursor = (cursor as Dictionary)[key]
return cursor
func _make_box(fill: Color, outline: Color, radius: int, width: int) -> StyleBoxFlat:
var box: StyleBoxFlat = StyleBoxFlat.new()
box.bg_color = fill
box.border_color = outline
box.set_border_width_all(width)
box.set_corner_radius_all(radius)
box.content_margin_left = 8
box.content_margin_right = 8
box.content_margin_top = 6
box.content_margin_bottom = 6
return box
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] as Dictionary, overrides[key] as Dictionary)
else:
merged[key] = overrides[key]
return merged
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key):
_warned[key] = true
push_warning(message)
@@ -0,0 +1 @@
uid://c2mnpqu565na
+30 -11
View File
@@ -12,6 +12,9 @@ const DETAIL_META := &"stage_dialog_detail"
const ACTIONS_META := &"stage_dialog_actions"
const EXTRAS_META := &"stage_dialog_extra_controls"
const CARD_META := &"stage_dialog_card"
const CARD_WIDTH_META := &"stage_dialog_card_width"
const MAX_HEIGHT_RATIO_META := &"stage_dialog_max_height_ratio"
const SCREEN_MARGIN_META := &"stage_dialog_screen_margin"
const TITLE_META := &"stage_dialog_title"
const ICON_META := &"stage_dialog_icon"
const ICON_LABEL_META := &"stage_dialog_icon_label"
@@ -54,21 +57,13 @@ static func popup(dialog: Window) -> void:
dialog.hide()
dialog.set_meta(OPEN_META, true)
overlay.show()
var host_viewport: Viewport = overlay.get_viewport()
if host_viewport != null:
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
if card != null:
var card_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
card.custom_minimum_size.x = minf(float(card_width), maxf(280.0, float(viewport_size.x - 36)))
_fit_body_scroll(dialog, viewport_size, active_theme)
refit(dialog)
var tree: SceneTree = dialog.get_tree()
if tree != null:
var fit_after_final_layout := func() -> void:
if not is_instance_valid(dialog) or not dialog.is_inside_tree() or not is_open(dialog):
return
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size) if host_viewport != null else Vector2i.ZERO
_fit_body_scroll(dialog, viewport_size, active_theme)
refit(dialog)
var fit_after_layout := func() -> void:
if is_instance_valid(tree):
tree.process_frame.connect(fit_after_final_layout, Object.CONNECT_ONE_SHOT)
@@ -91,6 +86,27 @@ static func is_open(dialog: Window) -> bool:
return dialog.visible
static func refit(dialog: Window) -> void:
if dialog == null or not is_instance_valid(dialog) or not dialog.has_meta(OVERLAY_META):
return
var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
if overlay == null or not is_instance_valid(overlay):
return
var host_viewport: Viewport = overlay.get_viewport()
if host_viewport == null:
return
var active_theme: SandboxTheme = _theme_for(dialog)
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
if card != null:
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
var default_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
var card_width: int = int(dialog.get_meta(CARD_WIDTH_META, default_width))
var available_width: float = maxf(0.0, float(viewport_size.x - screen_margin * 2))
card.custom_minimum_size.x = minf(float(card_width), available_width)
_fit_body_scroll(dialog, viewport_size, active_theme)
static func is_styled(dialog: Window) -> bool:
return dialog != null and is_instance_valid(dialog) and dialog.has_meta(OVERLAY_META)
@@ -498,7 +514,10 @@ static func _fit_body_scroll(dialog: Window, viewport_size: Vector2i, stage_them
var body_width: float = maxf(0.0, card_width - card_padding * 2.0)
var preferred_body_height: float = body.get_combined_minimum_size().y
var fixed_height: float = header.get_combined_minimum_size().y + actions.get_combined_minimum_size().y + card_padding * 2.0 + content_gap * 2.0
var max_body_height: float = maxf(48.0, float(viewport_size.y) - 36.0 - fixed_height)
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
var max_height_ratio: float = clampf(float(dialog.get_meta(MAX_HEIGHT_RATIO_META, 1.0)), 0.35, 1.0)
var available_height: float = maxf(0.0, minf(float(viewport_size.y - screen_margin * 2), float(viewport_size.y) * max_height_ratio))
var max_body_height: float = maxf(48.0, available_height - fixed_height)
if preferred_body_height > max_body_height:
var vertical_bar: VScrollBar = body_scroll.get_v_scroll_bar()
if vertical_bar != null:
@@ -0,0 +1,396 @@
class_name StageGroundFinishChooser
extends ConfirmationDialog
## Modal single-choice gallery for choosing the look of newly painted Ground.
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const GROUND_PREVIEW := preload("res://scripts/shared/stage_ui/stage_ground_finish_preview.gd")
const DEFAULT_THEME := preload("res://scripts/stage/sandbox_theme.gd")
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
var _stage_theme: SandboxTheme
var _ui_font: Font
var _finish_ids: Array[StringName] = []
var _finish_labels: Array[String] = []
var _selected_finish: StringName = &""
var _button_group: ButtonGroup
var _choice_buttons: Dictionary = {}
var _choice_previews: Dictionary = {}
var _choice_labels: Dictionary = {}
var _content_root: VBoxContainer
var _layout_row: HBoxContainer
var _choice_grid: GridContainer
var _preview_panel: PanelContainer
var _selected_preview: StageGroundFinishPreview
var _choice_height: int = 96
var _choice_preview_height: int = 48
var _choice_font_size: int = 18
var _preview_height: int = 128
var _compact_preview_height: int = 88
var _preview_min_width: int = 224
var _choice_min_width: int = 104
var _grid_gap: int = 8
var _compact_choice_height: int = 54
var _compact_choice_preview_height: int = 22
var _compact_choice_font_size: int = 16
var _compact_grid_gap: int = 6
var _compact_layout_max_width: int = 600
var _compact_layout_max_height: int = 460
var _wide_layout_min_width: int = 760
var _screen_margin: int = 24
var _is_wide_layout: bool = true
var _is_compact_layout: bool = false
func configure(
stage_theme: SandboxTheme,
ui_font: Font,
finish_ids: Array[StringName],
finish_labels: Array[String]
) -> void:
_stage_theme = stage_theme if stage_theme != null else DEFAULT_THEME.new() as SandboxTheme
_ui_font = ui_font
_finish_ids.assign(finish_ids)
_finish_labels.assign(finish_labels)
if not _finish_ids.is_empty():
_selected_finish = _finish_ids[0]
_configure_theme_values()
func open_for_finish(finish_id: StringName) -> void:
_select_finish(finish_id)
_apply_responsive_layout()
DIALOG_STYLER.popup(self)
call_deferred("_refit_dialog")
call_deferred("_focus_selected_choice")
func selected_finish() -> StringName:
return _selected_finish
func choice_buttons() -> Dictionary:
return _choice_buttons.duplicate()
func selected_preview_finish() -> StringName:
return &"" if _selected_preview == null else _selected_preview.finish_id
func _ready() -> void:
if _stage_theme == null:
_stage_theme = DEFAULT_THEME.new() as SandboxTheme
_configure_theme_values()
title = "Choose Ground"
dialog_text = "Stickmen walk here"
cancel_button_text = "Close"
ok_button_text = "Draw Grass Ground"
theme = _stage_theme.control_theme()
set_meta(DIALOG_STYLER.CARD_WIDTH_META, _theme_int("card_width", 880, 420, 1200))
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("max_height_ratio", 0.86, 0.55, 0.96))
set_meta(DIALOG_STYLER.SCREEN_MARGIN_META, _screen_margin)
_build_content()
DIALOG_STYLER.apply(self, _stage_theme, _ui_font, get_ok_button())
var icon_badge: Control = get_meta(DIALOG_STYLER.ICON_BADGE_META, null) as Control
if icon_badge != null:
icon_badge.hide()
_apply_responsive_layout()
var viewport: Viewport = _stage_viewport()
if viewport != null:
viewport.size_changed.connect(_on_viewport_size_changed)
func _configure_theme_values() -> void:
_choice_font_size = _theme_int("choice_font_size", 18, 14, 26)
_choice_height = _theme_int("choice_height", 96, 72, 132)
_choice_preview_height = _theme_int("choice_preview_height", 48, 32, 72)
_compact_choice_height = _theme_int("compact_choice_height", 50, 48, 72)
_compact_choice_preview_height = _theme_int("compact_choice_preview_height", 22, 16, 36)
_compact_choice_font_size = _theme_int("compact_choice_font_size", 16, 14, 20)
_compact_grid_gap = _theme_int("compact_grid_gap", 6, 4, 12)
_compact_layout_max_width = _theme_int("compact_layout_max_width", 600, 440, 760)
_compact_layout_max_height = _theme_int("compact_layout_max_height", 460, 360, 640)
_preview_height = _theme_int("preview_height", 128, 88, 220)
_compact_preview_height = _theme_int("compact_preview_height", 88, 64, 140)
_preview_min_width = _theme_int("preview_min_width", 224, 160, 360)
_choice_min_width = _theme_int("choice_min_width", 104, 88, 160)
_grid_gap = _theme_int("grid_gap", 8, 4, 24)
_wide_layout_min_width = _theme_int("wide_layout_min_width", 760, 600, 1100)
_screen_margin = _theme_int("screen_margin", 24, 12, 64)
func _build_content() -> void:
_button_group = ButtonGroup.new()
_button_group.allow_unpress = false
_content_root = VBoxContainer.new()
_content_root.name = "GroundFinishGallery"
_content_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_content_root.add_theme_constant_override("separation", _grid_gap)
_layout_row = HBoxContainer.new()
_layout_row.name = "GalleryAndPreview"
_layout_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_layout_row.add_theme_constant_override("separation", 16)
_choice_grid = GridContainer.new()
_choice_grid.name = "GroundFinishChoices"
_choice_grid.columns = 3
_choice_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_choice_grid.add_theme_constant_override("h_separation", _grid_gap)
_choice_grid.add_theme_constant_override("v_separation", _grid_gap)
_build_choice_buttons()
_preview_panel = _build_selected_preview_panel()
_layout_row.add_child(_choice_grid)
_layout_row.add_child(_preview_panel)
_content_root.add_child(_layout_row)
add_child(_content_root)
func _build_choice_buttons() -> void:
for index: int in mini(_finish_ids.size(), _finish_labels.size()):
var finish_id: StringName = _finish_ids[index]
var finish_label: String = _finish_labels[index]
var button := Button.new()
button.name = "GroundFinish_%s" % String(finish_id).trim_prefix("finish_ground_")
button.toggle_mode = true
button.button_group = _button_group
button.text = ""
button.custom_minimum_size = Vector2(float(_choice_min_width), float(_choice_height))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
_apply_choice_style(button)
var contents := VBoxContainer.new()
contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
contents.add_theme_constant_override("separation", 4)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sample := GROUND_PREVIEW.new() as StageGroundFinishPreview
sample.name = "FinishSample"
sample.custom_minimum_size.y = float(_choice_preview_height)
sample.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sample.configure(finish_id, DEFAULT_PAINT_TINT)
contents.add_child(sample)
var label := Label.new()
label.name = "FinishLabel"
label.text = finish_label
label.custom_minimum_size.y = 20.0
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", _choice_font_size)
label.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
if _ui_font != null:
label.add_theme_font_override("font", _ui_font)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
contents.add_child(label)
button.add_child(contents)
button.pressed.connect(_on_choice_pressed.bind(finish_id))
_choice_grid.add_child(button)
_choice_buttons[finish_id] = button
_choice_previews[finish_id] = sample
_choice_labels[finish_id] = label
func _build_selected_preview_panel() -> PanelContainer:
var panel := PanelContainer.new()
panel.name = "SelectedGroundPreview"
panel.custom_minimum_size = Vector2(float(_preview_min_width), 0.0)
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override("panel", _preview_panel_style())
var stack := VBoxContainer.new()
stack.alignment = BoxContainer.ALIGNMENT_CENTER
stack.add_theme_constant_override("separation", 10)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
var heading := Label.new()
heading.name = "GroundPreviewHeading"
heading.text = "New Ground will look like this"
heading.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", 18)
heading.add_theme_color_override("font_color", _theme_color("global.text_color", Color("#F5F7FA")))
if _ui_font != null:
heading.add_theme_font_override("font", _ui_font)
stack.add_child(heading)
_selected_preview = GROUND_PREVIEW.new() as StageGroundFinishPreview
_selected_preview.name = "SelectedGroundFinishSample"
_selected_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_selected_preview.custom_minimum_size.y = float(_preview_height)
_selected_preview.configure(_selected_finish if not _selected_finish.is_empty() else &"finish_ground_grass", DEFAULT_PAINT_TINT)
stack.add_child(_selected_preview)
panel.add_child(stack)
return panel
func _select_finish(finish_id: StringName) -> void:
if not _finish_ids.has(finish_id):
if _finish_ids.is_empty():
return
finish_id = _finish_ids[0]
_selected_finish = finish_id
for key: Variant in _choice_buttons:
var button: Button = _choice_buttons[key] as Button
button.set_pressed_no_signal(StringName(key) == finish_id)
if _selected_preview != null:
_selected_preview.configure(finish_id, DEFAULT_PAINT_TINT)
_update_primary_button_text()
func _update_primary_button_text() -> void:
var label: String = "Ground"
var index: int = _finish_ids.find(_selected_finish)
if index >= 0 and index < _finish_labels.size():
label = _finish_labels[index]
var button: Button = get_ok_button()
if button == null:
return
button.text = "Draw %s Ground" % label
var visual_button: Button = DIALOG_STYLER.visual_button(button)
if visual_button != null and visual_button != button:
visual_button.text = button.text
func _on_choice_pressed(finish_id: StringName) -> void:
_select_finish(finish_id)
func _on_viewport_size_changed() -> void:
call_deferred("_apply_responsive_layout")
call_deferred("_refit_dialog")
func _apply_responsive_layout() -> void:
if _content_root == null or _choice_grid == null or _preview_panel == null or _layout_row == null:
return
var viewport: Viewport = _stage_viewport()
if viewport == null:
return
var viewport_size: Vector2 = viewport.get_visible_rect().size
var viewport_width: float = viewport_size.x
var card_width: float = minf(
float(_theme_int("card_width", 880, 420, 1200)),
maxf(0.0, viewport_width - float(_screen_margin * 2))
)
var body_width: float = maxf(0.0, card_width - 64.0)
var wide: bool = viewport_width >= float(_wide_layout_min_width) and body_width >= float(_preview_min_width + _choice_min_width * 3 + _grid_gap * 4)
var compact: bool = viewport_width < float(_compact_layout_max_width) or viewport_size.y < float(_compact_layout_max_height)
var columns: int = 3 if body_width >= float(_choice_min_width * 3 + _grid_gap * 2) else 2
_choice_grid.columns = columns
_is_wide_layout = wide
_is_compact_layout = compact
var card: Control = get_meta(DIALOG_STYLER.CARD_META) as Control
if card != null:
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("compact_height_ratio", 0.89, 0.70, 0.89) if compact else _theme_number("max_height_ratio", 0.86, 0.55, 0.86))
_apply_choice_density(compact)
if wide:
if _choice_grid.get_parent() != _layout_row:
if _choice_grid.get_parent() == _content_root:
_content_root.remove_child(_choice_grid)
if _preview_panel.get_parent() == _content_root:
_content_root.remove_child(_preview_panel)
_layout_row.add_child(_choice_grid)
_layout_row.add_child(_preview_panel)
_content_root.add_child(_layout_row)
_preview_panel.visible = true
_preview_panel.custom_minimum_size.x = float(_preview_min_width)
_selected_preview.custom_minimum_size.y = float(_preview_height)
else:
if _choice_grid.get_parent() != _content_root:
if _choice_grid.get_parent() == _layout_row:
_layout_row.remove_child(_choice_grid)
if _preview_panel.get_parent() == _layout_row:
_layout_row.remove_child(_preview_panel)
if _layout_row.get_parent() == _content_root:
_content_root.remove_child(_layout_row)
_content_root.add_child(_preview_panel)
_content_root.add_child(_choice_grid)
_preview_panel.visible = not compact
_preview_panel.custom_minimum_size.x = 0.0
_selected_preview.custom_minimum_size.y = float(_compact_preview_height if compact else _preview_height)
_preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_choice_grid.add_theme_constant_override("v_separation", _compact_grid_gap if compact else _grid_gap)
_content_root.queue_sort()
func _refit_dialog() -> void:
if DIALOG_STYLER.is_open(self):
DIALOG_STYLER.refit(self)
func _stage_viewport() -> Viewport:
var overlay: Control = get_meta(DIALOG_STYLER.OVERLAY_META, null) as Control
if overlay != null and is_instance_valid(overlay):
return overlay.get_viewport()
return get_viewport()
func _apply_choice_density(compact: bool) -> void:
var target_height: float = float(_compact_choice_height if compact else _choice_height)
var target_preview_height: float = float(_compact_choice_preview_height if compact else _choice_preview_height)
var target_font_size: int = _compact_choice_font_size if compact else _choice_font_size
for finish_id: StringName in _choice_buttons:
var button: Button = _choice_buttons[finish_id] as Button
button.custom_minimum_size.y = target_height
_apply_choice_style(button)
var sample: StageGroundFinishPreview = _choice_previews[finish_id] as StageGroundFinishPreview
if sample != null:
sample.custom_minimum_size.y = target_preview_height
var label: Label = _choice_labels[finish_id] as Label
if label != null:
label.add_theme_font_size_override("font_size", target_font_size)
label.custom_minimum_size.y = 18.0 if compact else 20.0
func _focus_selected_choice() -> void:
if not is_inside_tree() or not DIALOG_STYLER.is_open(self):
return
var button: Button = _choice_buttons.get(_selected_finish) as Button
if button != null and button.visible:
button.grab_focus()
func _apply_choice_style(button: Button) -> void:
var neutral: Color = _theme_color("stage_panels.choice.neutral_surface", Color("#171B24"))
var hover: Color = _theme_color("stage_panels.choice.hover_surface", Color("#303A4B"))
var selected: Color = _theme_color("stage_panels.choice.selected_surface", Color("#12394A"))
var border: Color = _theme_color("stage_panels.choice.border_color", Color("#FFFFFF33"))
var accent: Color = _theme_color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _choice_box(neutral, border, 2))
button.add_theme_stylebox_override("hover", _choice_box(hover, accent, 2))
button.add_theme_stylebox_override("pressed", _choice_box(selected, accent, 3))
button.add_theme_stylebox_override("hover_pressed", _choice_box(selected.lightened(0.06), accent, 3))
button.add_theme_stylebox_override("focus", _choice_box(Color.TRANSPARENT, Color("#F5F7FA"), 3))
func _choice_box(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(_theme_int("choice_corner_radius", 12, 8, 24))
style.content_margin_left = 8.0
style.content_margin_right = 8.0
var vertical_padding: float = 2.0 if _is_compact_layout else 6.0
style.content_margin_top = vertical_padding
style.content_margin_bottom = vertical_padding
return style
func _preview_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = _theme_color("preview_surface", Color("#151C28"))
style.border_color = _theme_color("preview_border", Color("#39465B"))
style.set_border_width_all(1)
style.set_corner_radius_all(_theme_int("preview_corner_radius", 12, 8, 24))
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 12.0
style.content_margin_bottom = 12.0
return style
func _theme_int(key: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
func _theme_number(key: String, fallback: float, minimum: float, maximum: float) -> float:
return fallback if _stage_theme == null else _stage_theme.number("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
func _theme_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color("popups.ground_finish_chooser." + key, fallback)
@@ -0,0 +1 @@
uid://dfo24vsra2cye
@@ -0,0 +1,30 @@
class_name StageGroundFinishPreview
extends Control
## Render-only Ground finish sample using the same motifs as Stage terrain.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
var finish_id: StringName = &"finish_ground_grass"
var paint_tint: Color = DEFAULT_PAINT_TINT
func configure(p_finish_id: StringName, p_paint_tint: Color = DEFAULT_PAINT_TINT) -> void:
finish_id = p_finish_id
paint_tint = p_paint_tint
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
func _draw() -> void:
var preview_top: float = 2.0
var motif_scale := Vector2.ONE
if finish_id == &"finish_ground_grass":
preview_top = minf(12.0, maxf(2.0, size.y * 0.48))
motif_scale.y = maxf(1.0, 12.0 / maxf(1.0, preview_top - 1.0))
var rect := Rect2(Vector2(2.0, preview_top), Vector2(size.x - 4.0, size.y - preview_top - 2.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
SURFACE_RENDERER.draw_ground(self, rect, finish_id, paint_tint, Vector2.ZERO, motif_scale)
draw_rect(rect, Color("#111821"), false, 2.0)
@@ -0,0 +1 @@
uid://cgh4be2rdultd
+73 -29
View File
@@ -150,8 +150,13 @@ func open(
entries: Array[Dictionary],
current_sky_id: String = "",
current_background_kind: String = "sky",
plain_color: Color = Color("#F5EFE0")
plain_color: Color = Color("#F5EFE0"),
browse_state: Dictionary = {}
) -> void:
var restore_scenery_browse_state: bool = p_kind == "scenery" and not browse_state.is_empty()
var restored_gallery_tab: String = String(browse_state.get("gallery_tab", "backgrounds")) if restore_scenery_browse_state else "backgrounds"
var restored_filter_id: String = String(browse_state.get("tag_id", "all")) if restore_scenery_browse_state else "all"
var restored_scroll: int = int(browse_state.get("scroll_vertical", 0)) if restore_scenery_browse_state else 0
_cancel_plain_picker_preview(false)
kind = p_kind
_setup_kind = &""
@@ -164,28 +169,62 @@ func open(
_current_sky_id = current_sky_id if kind == "scenery" else ""
_current_background_kind = &"plain" if current_background_kind == "plain" else &"sky"
_current_plain_color = _opaque_color(plain_color)
_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"))
_title_label.text = "Choose a Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_backgrounds_back_button.visible = false
_apply_chooser_visual_style()
_tabs.visible = kind == "scenery"
_filter_chips.visible = false
_make_artwork_button.visible = false
_edit_scenery_button.visible = false
_browse_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
_refresh_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props found. Browse for a .prop file." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen found."))
_gallery_tab = "backgrounds"
_tag_id = "all"
_backgrounds_tab.set_pressed_no_signal(true)
_artwork_tab.set_pressed_no_signal(false)
_browse_button.visible = false
_refresh_button.visible = false
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props in the library yet." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen in the library yet."))
_gallery_tab = restored_gallery_tab
_tag_id = restored_filter_id
_backgrounds_tab.set_pressed_no_signal(_gallery_tab == "backgrounds")
_artwork_tab.set_pressed_no_signal(_gallery_tab == "artwork")
set_entries(entries)
if kind == "scenery":
_set_gallery_tab("backgrounds")
_set_gallery_tab(_gallery_tab)
popup_centered(size)
call_deferred("_settle_popup_after_open")
if restore_scenery_browse_state:
call_deferred("_restore_browse_scroll_after_open", restored_scroll, restored_gallery_tab)
func capture_browse_state() -> Dictionary:
if kind != "scenery":
return {}
return {
"gallery_tab": _gallery_tab,
"tag_id": _tag_id,
"scroll_vertical": _grid_scroll.scroll_vertical if _grid_scroll != null else 0,
}
func _restore_browse_scroll_after_open(scroll_vertical: int, gallery_tab: String) -> void:
await get_tree().process_frame
if not is_inside_tree() or not visible or kind != "scenery" or _gallery_tab != gallery_tab:
return
_grid_scroll.scroll_vertical = maxi(0, scroll_vertical)
func _settle_popup_after_open() -> void:
await get_tree().process_frame
await get_tree().process_frame
if is_inside_tree() and visible:
# PopupPanel can resize after the first native layout pass. Reapply the
# content-sized width once child minimum sizes and DPI scaling have settled.
var size_before_settle: Vector2i = size
_update_popup_layout(true)
if size != size_before_settle:
popup_centered(size)
func set_entries(entries: Array[Dictionary]) -> void:
_entries = entries
if _grid_scroll != null:
_grid_scroll.scroll_vertical = 0
if kind == "scenery":
_rebuild_artwork_filter_chips()
_thumbnails.clear()
@@ -881,8 +920,8 @@ func _set_gallery_tab(tab: String) -> void:
_filter_chips.visible = kind == "scenery" and tab == "artwork"
_make_artwork_button.visible = kind == "scenery" and tab == "artwork"
_edit_scenery_button.visible = kind == "scenery"
_browse_button.visible = kind == "stickman" or kind == "prop"
_refresh_button.visible = kind == "stickman" or kind == "prop"
_browse_button.visible = false
_refresh_button.visible = false
_empty_label.text = "No artwork here yet." if tab == "artwork" else "Choose a Sky look or wall finish."
_page = 0
_update_popup_layout(true)
@@ -953,6 +992,7 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
var filtered: Array[Dictionary] = _filtered_entries()
_tabs.visible = kind == "scenery" and not in_subview
_filter_chips.visible = scenery_artwork_gallery
@@ -972,17 +1012,17 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
_empty_label.visible = filtered.is_empty() and not in_subview
_grid.visible = not filtered.is_empty() and not in_subview
_grid_scroll.visible = not filtered.is_empty() or in_subview
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or asset_scrolling_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
_footer.add_theme_constant_override(
"separation",
_theme_integer("popups.asset_selector.footer_gap", 16, 8, 32) if in_subview or kind == "scenery" else 8
)
var available: Vector2 = _usable_screen_size()
var width_ratio: float = 0.98 if in_subview else (0.96 if kind == "starters" else 0.94)
var width_ratio: float = 0.98 if in_subview or asset_scrolling_gallery else (0.96 if kind == "starters" else 0.94)
var max_width: float = maxf(1.0, minf(available.x * width_ratio, available.x - POPUP_SAFE_SCREEN_MARGIN))
var height_ratio: float = _theme_number("popups.asset_selector.starter_viewport_height_ratio", 0.96, 0.7, 0.98) if kind == "starters" else 0.86
var max_height: float = maxf(1.0, minf(available.y * height_ratio, available.y - POPUP_SAFE_SCREEN_MARGIN * 2.0))
var preferred_width: float = CELL_MIN_SIZE.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
var preferred_width: float = _cell_min_size.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
var preferred_height: float = max_height
if kind == "starters":
preferred_width = _theme_number("popups.asset_selector.starter_dialog_width", 1160.0, 440.0, 2400.0)
@@ -993,14 +1033,14 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
var popup_width: float = minf(preferred_width, max_width)
_position_footer(popup_width)
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
var minimum_card_width: float = CELL_MIN_SIZE.x
var minimum_card_width: float = _cell_min_size.x
if kind == "starters":
minimum_card_width = _theme_number("popups.asset_selector.starter_card_min_width", 340.0, 220.0, 800.0)
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (minimum_card_width + GRID_H_SEPARATION)), 1, 2 if kind == "starters" else COLUMNS)
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or kind == "starters" else _active_columns * ROWS
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or asset_scrolling_gallery or kind == "starters" else _active_columns * ROWS
_grid.columns = _active_columns
var available_card_width: float = (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns)
var card_width: float = available_card_width if kind == "starters" else minf(CELL_MIN_SIZE.x, available_card_width)
var card_width: float = available_card_width if kind == "starters" else minf(_cell_min_size.x, available_card_width)
var card_height_limit: float = 248.0 if kind != "starters" else INF
var card_height: float
if kind == "starters":
@@ -1128,6 +1168,7 @@ func _rebuild() -> void:
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
_position_footer(float(size.x))
_sync_stage_preview_dimming()
_close_button.visible = not in_setup and not in_color_picker
@@ -1144,8 +1185,8 @@ func _rebuild() -> void:
_color_use_button.visible = in_color_picker
_edit_scenery_button.visible = kind == "scenery" and not in_subview
_make_artwork_button.visible = scenery_artwork_gallery
_browse_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
_refresh_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
_browse_button.visible = false
_refresh_button.visible = false
_footer_spacer.visible = scenery_artwork_gallery
_empty_label.visible = filtered.is_empty() and not in_subview
_grid.visible = not filtered.is_empty() and not in_subview
@@ -1255,6 +1296,10 @@ func _is_scenery_artwork_gallery() -> bool:
return kind == "scenery" and _gallery_tab == "artwork"
func _uses_scrolling_asset_gallery() -> bool:
return kind == "prop" or kind == "stickman"
func _count_scene_background_entries(entries: Array[Dictionary]) -> int:
var count: int = 0
for entry: Dictionary in entries:
@@ -1324,7 +1369,7 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
card_payload["status"] = "Current background"
card_payload["status_color"] = _theme_color("mode_colors.edit", Color("#22C6FF"))
cell.configure(card_payload, _thumbnails.get(key, null), null)
if kind == "scenery" or kind == "starters":
if kind == "scenery" or kind == "starters" or _uses_scrolling_asset_gallery():
var card_button: Button = cell as Button
var card_border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5)
var card_corner_radius: int = _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32)
@@ -1351,13 +1396,12 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
card_hover_border_width,
card_corner_radius
))
if kind == "starters":
card_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(
card_selected_surface.lightened(0.05),
_theme_color("mode_colors.edit", Color("#22C6FF")),
card_hover_border_width,
card_corner_radius
))
card_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(
card_selected_surface.lightened(0.05),
_theme_color("mode_colors.edit", Color("#22C6FF")),
card_hover_border_width,
card_corner_radius
))
var focus_color: Color = _theme_color("stage_panels.type.text_color", Color("#F5F7FA")) if kind == "starters" else _theme_color("mode_colors.edit", Color("#22C6FF"))
var focus_style: StyleBoxFlat = _make_rounded_style(
Color(0, 0, 0, 0),
@@ -1447,7 +1491,7 @@ func _apply_chooser_visual_style() -> void:
frame_style.set_corner_radius_all(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32))
preview_frame.add_theme_stylebox_override("panel", frame_style)
if _title_label != null:
var title_size: int = _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48) if kind == "scenery" else (_theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else 24)
var title_size: int = _theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48)
_title_label.add_theme_font_size_override("font_size", title_size)
_title_label.add_theme_color_override("font_color", text_color)
_apply_emphasis_font(_title_label)
+30
View File
@@ -125,6 +125,36 @@ func _apply_phase19_defaults() -> void:
if not asset_selector.has(key):
asset_selector[key] = starter_defaults[key]
popups["asset_selector"] = asset_selector
var ground_finish_defaults: Dictionary = {
"card_width": 880,
"max_height_ratio": 0.86,
"screen_margin": 24,
"choice_height": 96,
"choice_preview_height": 48,
"choice_font_size": 18,
"choice_min_width": 104,
"choice_corner_radius": 12,
"compact_choice_height": 50,
"compact_choice_preview_height": 22,
"compact_choice_font_size": 16,
"compact_grid_gap": 6,
"compact_layout_max_width": 600,
"compact_layout_max_height": 460,
"compact_height_ratio": 0.89,
"preview_height": 128,
"compact_preview_height": 88,
"preview_min_width": 224,
"preview_corner_radius": 12,
"preview_surface": "#151C28",
"preview_border": "#39465B",
"grid_gap": 8,
"wide_layout_min_width": 760,
}
var ground_finish: Dictionary = popups.get("ground_finish_chooser", {}).duplicate(true)
for key: String in ground_finish_defaults:
if not ground_finish.has(key):
ground_finish[key] = ground_finish_defaults[key]
popups["ground_finish_chooser"] = ground_finish
_config["popups"] = popups
var overlays: Dictionary = _config.get("overlays", {}).duplicate(true)
var gizmos: Dictionary = overlays.get("gizmos", {}).duplicate(true)