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 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.4" const FILE_FILTER := "*.stk ; Stickman Files" const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"] # Phase 8: rig proportions (master_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", ] 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 # --------------------------------------------------------------------------- # 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 _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 # --------------------------------------------------------------------------- # 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: _setup_menu_bar() _setup_dialogs() _populate_body_part_panels() _whole_preview.part_moved.connect(_on_preview_part_moved) _whole_preview.set_body_parts(_collect_all_shape_data()) _load_settings() _update_snap_status_label() _broadcast_settings() _broadcast_recent_colors() if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed): _grid_config_dialog.confirmed.connect(_on_grid_config_confirmed) 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_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("Clear", 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.file_selected.connect(_on_save_file_selected) _load_dialog.file_selected.connect(_on_load_file_selected) _clear_confirm.confirmed.connect(_on_clear_confirmed) # --------------------------------------------------------------------------- # 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/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)) _body_part_panels[part_name] = panel 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)) _body_part_panels[part_name] = panel center_col.add_child(panel) # --------------------------------------------------------------------------- # Signal handlers - menus # --------------------------------------------------------------------------- func _on_file_menu_id_pressed(id: int) -> void: match id: 0: # Save _save_dialog.popup_centered_ratio(0.6) 1: # Load _load_dialog.popup_centered_ratio(0.6) 2: # Clear _clear_confirm.popup_centered() 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: if not path.ends_with(".stk"): path += ".stk" var data := _build_json_data() var json_text := JSON.stringify(data, "\t", false) var file := FileAccess.open(path, FileAccess.WRITE) if file == null: _show_error("Failed to save file: " + str(FileAccess.get_open_error())) return file.store_string(json_text) file.close() func _on_load_file_selected(path: String) -> void: 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 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() func _on_body_part_shape_selected(part_name: String) -> void: 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() func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void: pass # --------------------------------------------------------------------------- # 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) 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"], } 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', or '1.4')" % version _stickman_name_edit.text = dict.get("stickman_name", "") var body_parts: Variant = dict.get("body_parts") if not body_parts is Dictionary: return "Invalid file format: 'body_parts' must be a dictionary." var body_dict: Dictionary = body_parts as Dictionary _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()) 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: _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() # --------------------------------------------------------------------------- # 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() # --------------------------------------------------------------------------- # 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"