Files
stickman/scripts/stickman_editor.gd
T
2026-08-08 00:00:50 -04:00

514 lines
17 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.3"
const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3"]
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
# ---------------------------------------------------------------------------
# Grid / snap state
# ---------------------------------------------------------------------------
var _grid_size: int = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
# Phase 5: shape clipboard
var _shape_clipboard: Dictionary = {}
var _edit_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()
_broadcast_settings()
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
# ---------------------------------------------------------------------------
# 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.id_pressed.connect(_on_view_menu_id_pressed)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
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))
_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))
_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:
_edit_menu.set_item_text(1, _snap_menu_label())
_save_settings()
_broadcast_settings()
func _on_edit_menu_about_to_popup() -> void:
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
func _on_view_menu_id_pressed(id: int) -> void:
if id == 0: # Reset Views
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).reset_view()
_whole_preview.reset_view()
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 _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)
all_data[part_name] = {
"shapes": shapes_arr,
"position": {"x": pos.x, "y": pos.y},
"rotation": rot,
"scale": {"x": scl.x, "y": scl.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(),
"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', or '1.3')" % 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 _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)
# ---------------------------------------------------------------------------
# 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))
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
func _save_settings() -> void:
var data := {
"version": SETTINGS_VERSION,
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
}
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)
func _snap_menu_label() -> String:
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"