- 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.
1206 lines
43 KiB
GDScript
1206 lines
43 KiB
GDScript
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"
|