- Added draggable handles for the torso and head to the test harness. - Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso". - Implemented distinct colors for the torso (magenta) and head (yellow) markers. - Added a visual aid (aim line) to indicate the head's LookAt target direction. - Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
681 lines
22 KiB
GDScript
681 lines
22 KiB
GDScript
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.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 (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",
|
||
]
|
||
|
||
# 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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|
||
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
|
||
|
||
_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"
|