Files
stickman/scripts/editors/stickman/stickman_editor.gd
ryan ddb298f631 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.
2026-10-23 15:20:12 -04:00

1206 lines
43 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name StickmanEditor
extends Control
## StickmanEditor - Main controller for the Stickman Studio editor.
##
## Manages menu actions (save/load/clear), coordinates all body part panels
## and the whole stickman preview, and handles JSON serialization/deserialization.
##
## Phase 2: FILE_VERSION "1.1", backward-compatible loading of "1.0" files,
## cross-panel selection coordination.
## Phase 3: Edit/View menus, grid configuration, settings persistence,
## snap-to-grid, Reset Views.
## Phase 4: v1.2 JSON with multi-shape shapes[] array, rotation, scale per part.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
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",
"left_upper_arm", "left_lower_arm",
"right_upper_arm", "right_lower_arm",
"left_upper_leg", "left_lower_leg",
"right_upper_leg", "right_lower_leg"
]
const BODY_PART_DISPLAY: Dictionary = {
"head": "Head",
"torso": "Torso",
"left_upper_arm": "Left Upper Arm",
"left_lower_arm": "Left Lower Arm",
"right_upper_arm": "Right Upper Arm",
"right_lower_arm": "Right Lower Arm",
"left_upper_leg": "Left Upper Leg",
"left_lower_leg": "Left Lower Leg",
"right_upper_leg": "Right Upper Leg",
"right_lower_leg": "Right Lower Leg",
}
const DEFAULT_POSITIONS: Dictionary = {
"head": Vector2(150, 40),
"torso": Vector2(150, 100),
"left_upper_arm": Vector2(140, 105),
"left_lower_arm": Vector2(130, 150),
"right_upper_arm": Vector2(160, 105),
"right_lower_arm": Vector2(170, 150),
"left_upper_leg": Vector2(142, 160),
"left_lower_leg": Vector2(135, 210),
"right_upper_leg": Vector2(158, 160),
"right_lower_leg": Vector2(165, 210),
}
const FILE_VERSION := "1.5"
const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
# Phase 8: rig proportions (scenes/characters/stickman/stickman_rig.tscn rest pose — see spec §2).
const PROPORTIONS: Dictionary = {
"upper_arm_length": 168.0,
"lower_arm_length": 200.0,
"upper_leg_length": 200.0,
"lower_leg_length": 200.0,
"torso_length": 391.5,
}
const X_AXIS_PARTS: PackedStringArray = [
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
]
# Phase 9 Round 5: map each body part to its silhouette-guide joint (see
# whole_stickman_preview.gd GUIDE_JOINTS). Used to compute the per-part
# guide_offset (bbox center − guide joint, preview space) at save time.
const GUIDE_JOINT_FOR_PART: Dictionary = {
"head": "Neck",
"torso": "Hips",
"left_upper_arm": "LeftShoulder",
"left_lower_arm": "LeftElbow",
"right_upper_arm": "RightShoulder",
"right_lower_arm": "RightElbow",
"left_upper_leg": "Hips",
"left_lower_leg": "LeftKnee",
"right_upper_leg": "Hips",
"right_lower_leg": "RightKnee",
}
const SETTINGS_PATH := "user://settings.json"
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
# ---------------------------------------------------------------------------
@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
@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog
@onready var _error_dialog: AcceptDialog = %ErrorDialog
@onready var _grid_config_dialog: ConfirmationDialog = %GridConfigDialog
@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
# ---------------------------------------------------------------------------
var _grid_size: int = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
# Phase 5: shape clipboard
var _shape_clipboard: Dictionary = {}
# Phase 6: recent colors
var _recent_colors: Array[String] = []
# Phase 7: pose silhouette guide (default ON per user decision)
var _show_guide: bool = true
var _edit_menu: PopupMenu
var _view_menu: PopupMenu
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
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:
var mouse_pos := get_global_mouse_position()
for part_name: String in BODY_PART_NAMES:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
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
if _whole_preview.is_cursor_over_preview(mouse_pos):
var world_pos := _whole_preview.global_to_world(mouse_pos)
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
return
_status_cursor_coords.text = ""
# ---------------------------------------------------------------------------
# Menu & Dialog setup
# ---------------------------------------------------------------------------
func _setup_menu_bar() -> void:
var file_menu: PopupMenu = PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("Save", 0)
file_menu.add_item("Load", 1)
file_menu.add_separator()
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")
_edit_menu = PopupMenu.new()
_edit_menu.name = "EditMenu"
_edit_menu.add_item("Configure Grid...", 0)
_edit_menu.add_separator()
_edit_menu.add_item(_snap_menu_label(), 1)
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
_edit_menu.about_to_popup.connect(_on_edit_menu_about_to_popup)
_menu_bar.add_child(_edit_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "Edit")
var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("Reset Views", 0)
view_menu.add_separator()
view_menu.add_item(_guide_menu_label(), 1)
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
_view_menu = view_menu
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
# ---------------------------------------------------------------------------
func _populate_body_part_panels() -> void:
var left_col: VBoxContainer = %LeftColumn
var center_col: VBoxContainer = %CenterColumn
var body_part_scene: PackedScene = load("res://scenes/editors/stickman/body_part_panel.tscn")
var left_names: Array[String] = ["head", "left_upper_arm", "left_lower_arm", "left_upper_leg", "left_lower_leg"]
var center_names: Array[String] = ["torso", "right_upper_arm", "right_lower_arm", "right_upper_leg", "right_lower_leg"]
for part_name: String in left_names:
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
panel.part_name = part_name
panel.display_name = BODY_PART_DISPLAY[part_name]
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
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:
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
panel.part_name = part_name
panel.display_name = BODY_PART_DISPLAY[part_name]
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
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)
# ---------------------------------------------------------------------------
# Signal handlers - menus
# ---------------------------------------------------------------------------
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: # Save
_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)
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:
match id:
0: # Configure Grid...
_grid_size_spin_box.value = float(_grid_size)
_grid_config_dialog.popup_centered()
1: # Snap to Grid (checkable)
_snap_enabled = not _snap_enabled
if _edit_menu:
var idx := _edit_menu.get_item_index(1)
if idx >= 0:
_edit_menu.set_item_text(idx, _snap_menu_label())
_save_settings()
_broadcast_settings()
_update_snap_status_label()
func _on_edit_menu_about_to_popup() -> void:
if _edit_menu:
var idx := _edit_menu.get_item_index(1)
if idx >= 0:
_edit_menu.set_item_text(idx, _snap_menu_label())
func _on_view_menu_id_pressed(id: int) -> void:
match id:
0: # Reset Views
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).reset_view()
_whole_preview.reset_view()
1: # Hide/Show Pose Guide
_show_guide = not _show_guide
_save_settings()
_broadcast_settings()
_update_guide_menu_item()
func _on_view_menu_about_to_popup() -> void:
_update_guide_menu_item()
func _update_guide_menu_item() -> void:
if _view_menu:
var idx := _view_menu.get_item_index(1)
if idx >= 0:
_view_menu.set_item_text(idx, _guide_menu_label())
func _guide_menu_label() -> String:
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
func _on_grid_config_confirmed() -> void:
_grid_size = int(_grid_size_spin_box.value)
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_save_settings()
_broadcast_settings()
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if path.get_extension().to_lower() != "stk":
path = path.get_basename() + ".stk"
if not save_to_path(path):
_cancel_pending_document_action()
return
_resume_pending_document_action()
func _on_load_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if not FileAccess.file_exists(path):
_show_error("File not found: " + path)
return
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
_show_error("Failed to open file: " + str(FileAccess.get_open_error()))
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if json == null:
_show_error("Failed to parse JSON from file: " + path)
return
var err: String = _apply_json_data(json)
if not err.is_empty():
_show_error(err)
return
_current_path = path
_dirty = false
_update_document_status()
func _on_clear_confirmed() -> void:
_clear_all()
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
# ---------------------------------------------------------------------------
# Signal handlers - body-part panels
# ---------------------------------------------------------------------------
func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
_whole_preview.set_body_parts(_collect_all_shape_data())
if _new_data.is_empty():
_whole_preview.set_part_position(part_name, DEFAULT_POSITIONS.get(part_name, Vector2.ZERO))
_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
# ---------------------------------------------------------------------------
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for sd in shapes_arr:
if not sd is Dictionary:
continue
for p in (sd as Dictionary).get("points", []):
if not p is Dictionary:
continue
var px: float = float((p as Dictionary).get("x", 0.0))
var py: float = float((p as Dictionary).get("y", 0.0))
min_x = minf(min_x, px)
min_y = minf(min_y, py)
max_x = maxf(max_x, px)
max_y = maxf(max_y, py)
if min_x > max_x or min_y > max_y:
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
var length: float
if X_AXIS_PARTS.has(part_name):
length = max_x - min_x
else:
length = max_y - min_y
return { "pivot": pivot, "length": length }
func _collect_all_shape_data() -> Dictionary:
var all_data: Dictionary = {}
for part_name: String in BODY_PART_NAMES:
var panel = _body_part_panels.get(part_name)
if panel:
var shapes_arr: Array = panel.get_shape_data()
var pos := _whole_preview.get_part_position(part_name)
var rot := _whole_preview.get_part_rotation(part_name)
var scl := _whole_preview.get_part_scale(part_name)
var pl := _compute_part_pivot_length(shapes_arr, part_name)
var pivot_vector := Vector2(float(pl["pivot"]["x"]), float(pl["pivot"]["y"]))
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
var guide_offset := (pos + pivot_vector) - joint_preview
all_data[part_name] = {
"shapes": shapes_arr,
"position": {"x": pos.x, "y": pos.y},
"rotation": rot,
"scale": {"x": scl.x, "y": scl.y},
"pivot": pl["pivot"],
"length": pl["length"],
"guide_offset": {"x": guide_offset.x, "y": guide_offset.y},
}
return all_data
func _build_json_data() -> Dictionary:
var time_str := Time.get_datetime_string_from_system()
var body_parts: Dictionary = _collect_all_shape_data()
return {
"version": FILE_VERSION,
"stickman_name": _stickman_name_edit.text.strip_edges(),
"part_order": _whole_preview.get_part_order(),
"proportions": PROPORTIONS.duplicate(),
"body_parts": body_parts,
"metadata": {
"created_at": time_str,
"modified_at": time_str,
}
}
func _apply_json_data(json: Variant) -> String:
if not json is Dictionary:
return "Invalid file format: root must be a dictionary."
var dict: Dictionary = json as Dictionary
var version: String = dict.get("version", "")
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
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()
var positions: Dictionary = {}
for part_name: String in BODY_PART_NAMES:
var part_data: Variant = body_dict.get(part_name)
var panel = _body_part_panels.get(part_name) as BodyPartPanel
if part_data is Dictionary:
var pd: Dictionary = part_data as Dictionary
if pd.has("shapes"):
# v1.2 format: shapes array already in correct structure
if panel:
panel.set_shape_data(pd["shapes"])
else:
# v1.0/v1.1 format: single shape dict at top level, wrap in array
if version == "1.0":
if not pd.has("closed"):
var st: String = str(pd.get("shape_type", ""))
pd["closed"] = (st == "rectangle" or st == "circle")
if not pd.has("vertex_flags"):
pd["vertex_flags"] = []
if panel:
panel.set_shape_data(pd)
# Position (all versions)
var pos_variant: Variant = pd.get("position")
if pos_variant is Dictionary:
var pos_dict: Dictionary = pos_variant as Dictionary
positions[part_name] = Vector2(
float(pos_dict.get("x", 0)),
float(pos_dict.get("y", 0))
)
else:
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
# Rotation (v1.2+, defaults to 0 for older versions)
_whole_preview.set_part_rotation(part_name, float(pd.get("rotation", 0.0)))
# Scale (v1.2+, defaults to 1.0 for older versions)
var scl_variant: Variant = pd.get("scale")
if scl_variant is Dictionary:
var sd: Dictionary = scl_variant as Dictionary
_whole_preview.set_part_scale(part_name, Vector2(
float(sd.get("x", 1.0)), float(sd.get("y", 1.0))))
else:
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
else:
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
_whole_preview.set_part_rotation(part_name, 0.0)
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
var loaded_order: Variant = dict.get("part_order")
if loaded_order is Array:
_whole_preview.set_part_order(loaded_order as Array)
_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 ""
# ---------------------------------------------------------------------------
# Clear helpers
# ---------------------------------------------------------------------------
func _clear_all_panels() -> void:
for part_name: String in BODY_PART_NAMES:
var panel = _body_part_panels.get(part_name)
if panel:
panel.clear_shape()
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
# ---------------------------------------------------------------------------
func _on_shape_copy_requested(shape_dict: Dictionary, _part_name: String) -> void:
_shape_clipboard = shape_dict.duplicate(true)
_broadcast_clipboard_state()
func _broadcast_clipboard_state() -> void:
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).set_has_clipboard(not _shape_clipboard.is_empty())
func _broadcast_recent_colors() -> void:
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).set_recent_colors(_recent_colors)
func _paste_shape_into_panel(part_name: String, world_pos: Vector2) -> void:
if _shape_clipboard.is_empty():
return
var panel = _body_part_panels.get(part_name)
if panel:
panel.paste_shape(_shape_clipboard, world_pos)
func _on_shape_paste_requested(world_pos: Vector2, part_name: String) -> void:
_paste_shape_into_panel(part_name, world_pos)
func _on_color_selected(color: Color, _part_name: String) -> void:
var hex_str := color.to_html()
var idx := _recent_colors.find(hex_str)
if idx >= 0:
_recent_colors.remove_at(idx)
_recent_colors.insert(0, hex_str)
while _recent_colors.size() > 8:
_recent_colors.pop_back()
_save_settings()
_broadcast_recent_colors()
_mark_dirty()
# ---------------------------------------------------------------------------
# Phase 3: Settings persistence
# ---------------------------------------------------------------------------
func _load_settings() -> void:
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if not json is Dictionary:
return
var d := json as Dictionary
_grid_size = int(d.get("grid_size", DEFAULT_GRID_SIZE))
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_guide = bool(d.get("show_pose_guide", true))
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
var raw_colors: Variant = d.get("recent_colors", [])
if raw_colors is Array:
var arr: Array = raw_colors as Array
for c in arr:
if c is String:
_recent_colors.append(c as String)
while _recent_colors.size() > 8:
_recent_colors.pop_back()
if _edit_menu:
var idx := _edit_menu.get_item_index(1)
if idx >= 0:
_edit_menu.set_item_text(idx, _snap_menu_label())
if _view_menu:
_update_guide_menu_item()
func _save_settings() -> void:
var data := {
"version": SETTINGS_VERSION,
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"recent_colors": _recent_colors,
"show_pose_guide": _show_guide,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
func _broadcast_settings() -> void:
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
var p := panel as BodyPartPanel
p.set_grid_size(_grid_size)
p.set_snap_enabled(_snap_enabled)
_whole_preview.set_grid_size(_grid_size)
_whole_preview.set_snap_enabled(_snap_enabled)
_whole_preview.set_show_guide(_show_guide)
func _snap_menu_label() -> String:
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
func _update_snap_status_label() -> void:
_status_snap_status.text = "SNAP: ON" if _snap_enabled else "SNAP: OFF"