Files
stickman/scripts/stickman_editor.gd
T
ryan ab5c79ab6a feat: Implement draggable torso and head IK targets in the test harness
- 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.
2026-08-21 12:39:31 -04:00

681 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"