- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary. - Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes. - Defined constants for default proportions and bone paths. - Included error handling for missing nodes and invalid data structures.
661 lines
21 KiB
GDScript
661 lines
21 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.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"
|