first commit

This commit is contained in:
2026-08-08 00:00:50 -04:00
commit b97bc145e4
34 changed files with 6874 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://blq30ou2f1boe
+513
View File
@@ -0,0 +1,513 @@
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"
+1
View File
@@ -0,0 +1 @@
uid://dlacmxk257ro
+872
View File
@@ -0,0 +1,872 @@
extends PanelContainer
class_name WholeStickmanPreview
## WholeStickmanPreview - Renders all body-part shapes assembled together.
##
## The user can drag individual body parts around the preview area
## to reposition them relative to one another.
##
## Phase 2: Zoom support (mouse wheel + draw_set_transform), closed-based
## rendering instead of shape_type-based, input coordinate transforms.
## Phase 3: Middle-mouse panning, background grid, snap-to-grid on part drag,
## Reset Views, settings broadcast integration.
## Phase 4: Selection with white bounding box, rotation gizmo (circle below),
## scale gizmo (corner crosses), Ctrl snap for rotation/scale, multi-shape
## rendering per part.
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal part_moved(part_name: String, new_position: Vector2)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const ROTATION_CIRCLE_RADIUS: float = 5.0
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0
const ROTATION_CIRCLE_OFFSET: float = 26.0
const SCALE_CROSS_SIZE: float = 5.0
const SCALE_CROSS_HIT_RADIUS: float = 12.0
const DEFAULT_PART_ORDER: 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"
]
enum Interaction { NONE, TRANSLATE, ROTATE, SCALE }
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@onready var preview_area: Control = %PreviewArea
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _part_shapes: Dictionary = {}
var _part_positions: Dictionary = {}
var _part_rotations: Dictionary = {}
var _part_scales: Dictionary = {}
var _part_bounds: Dictionary = {}
# Translation drag
var _dragging_part: String = ""
var _drag_offset: Vector2 = Vector2.ZERO
# Phase 4 interaction state
var _selected_part: String = ""
var _interaction: int = Interaction.NONE
# Rotation state
var _rotate_start_rotation: float = 0.0
var _rotate_total_delta: float = 0.0
var _rotate_prev_angle: float = 0.0
# Scale state
var _scale_corner: int = -1
var _scale_anchor: Vector2 = Vector2.ZERO
var _scale_original_size: Vector2 = Vector2.ZERO
# Zoom + pan
var _zoom: float = 1.0
# Phase 3 state
var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 15
var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
# Phase 5: z-ordering
var _part_order: Array[String] = []
var _context_menu: PopupMenu
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_body_parts(parts_data: Dictionary) -> void:
## parts_data: { part_name: { shapes: Array, position?, rotation?, scale? } }
for part_name: String in parts_data:
var entry: Variant = parts_data[part_name]
if entry is Dictionary:
var d := entry as Dictionary
# Phase 4: extract shapes array
if d.has("shapes"):
_part_shapes[part_name] = _variant_to_shapes_array(d["shapes"])
else:
# Backward compat: single shape dict (v1.0/v1.1 data shape)
_part_shapes[part_name] = [_variant_to_single_shape(d)]
# Position
var pos_v: Variant = d.get("position")
if pos_v is Dictionary:
var pd := pos_v as Dictionary
_part_positions[part_name] = Vector2(
float(pd.get("x", 0)), float(pd.get("y", 0)))
elif not _part_positions.has(part_name):
_part_positions[part_name] = Vector2.ZERO
# Rotation (Phase 4)
_part_rotations[part_name] = float(d.get("rotation", 0.0))
# Scale (Phase 4)
var scl_v: Variant = d.get("scale")
if scl_v is Dictionary:
var sd := scl_v as Dictionary
_part_scales[part_name] = Vector2(
float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
else:
_part_scales[part_name] = Vector2(1.0, 1.0)
else:
_part_shapes[part_name] = []
_part_positions[part_name] = Vector2.ZERO
_part_rotations[part_name] = 0.0
_part_scales[part_name] = Vector2(1.0, 1.0)
if _part_order.is_empty():
_part_order = []
_part_order.assign(DEFAULT_PART_ORDER)
for pn: String in DEFAULT_PART_ORDER:
if _part_order.find(pn) == -1:
_part_order.append(pn)
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_position(part_name: String) -> Vector2:
return _part_positions.get(part_name, Vector2.ZERO)
func set_part_position(part_name: String, pos: Vector2) -> void:
_part_positions[part_name] = pos
_rebuild_bounds()
preview_area.queue_redraw()
func set_all_part_positions(positions: Dictionary) -> void:
for part_name: String in positions:
_part_positions[part_name] = positions[part_name]
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_rotation(part_name: String) -> float:
return _part_rotations.get(part_name, 0.0)
func set_part_rotation(part_name: String, rot: float) -> void:
_part_rotations[part_name] = rot
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_scale(part_name: String) -> Vector2:
return _part_scales.get(part_name, Vector2(1.0, 1.0))
func set_part_scale(part_name: String, scl: Vector2) -> void:
_part_scales[part_name] = scl
_rebuild_bounds()
preview_area.queue_redraw()
func clear_all() -> void:
_part_shapes.clear()
_part_positions.clear()
_part_rotations.clear()
_part_scales.clear()
_part_bounds.clear()
_selected_part = ""
_interaction = Interaction.NONE
_pan_offset = Vector2.ZERO
_zoom = 1.0
_part_order.clear()
preview_area.queue_redraw()
# Phase 3 public API
func set_grid_size(size: int) -> void:
_grid_size = size
preview_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
preview_area.queue_redraw()
func get_part_order() -> Array:
return _part_order.duplicate()
func set_part_order(order: Array) -> void:
_part_order.clear()
for pn in order:
if pn is String:
_part_order.append(pn as String)
preview_area.queue_redraw()
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if not preview_area.draw.is_connected(_on_preview_draw):
preview_area.draw.connect(_on_preview_draw)
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
preview_area.gui_input.connect(_on_preview_gui_input)
_context_menu = PopupMenu.new()
_context_menu.name = "PreviewContextMenu"
add_child(_context_menu)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
func _on_preview_draw() -> void:
preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
for part_name: String in _part_order:
if not _part_shapes.has(part_name):
continue
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
continue
var arr: Array = shapes_array as Array
if arr.is_empty():
continue
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(part_name, 0.0)
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
# Collect all points (translated by position) to find center
var all_raw: PackedVector2Array = PackedVector2Array()
for sd in arr:
if sd is Dictionary:
var sd_pts := _variant_to_points((sd as Dictionary).get("points", []))
for pt: Vector2 in sd_pts:
all_raw.append(pt + pos)
if all_raw.is_empty():
continue
var center: Vector2 = _compute_center(all_raw)
# Draw each shape
for sd in arr:
if not sd is Dictionary:
continue
var sdd := sd as Dictionary
var pts := _variant_to_points(sdd.get("points", []))
if pts.size() < 2:
continue
var color := Color.from_string(str(sdd.get("color", "#000000")), Color.BLACK)
var closed: bool = bool(sdd.get("closed", false))
var transformed := PackedVector2Array()
for pt: Vector2 in pts:
transformed.append(_transform_point(pt, pos, center, rot_deg, scl))
if closed:
preview_area.draw_colored_polygon(transformed, color)
_draw_polyline_preview(transformed, color, closed)
# Draw label
var font := preview_area.get_theme_default_font()
var label := _short_label(part_name)
if font:
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
var label_pos := Vector2(bounds.position.x, bounds.position.y - 14)
preview_area.draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color.WHITE)
# Phase 4: Draw gizmos for selected part
if part_name == _selected_part:
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
if bounds.has_area():
# White bounding box
preview_area.draw_rect(bounds, Color.WHITE, false, 1.5 / _zoom)
# Rotation circle
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
# Scale crosses at 4 corners
var cross_half := SCALE_CROSS_SIZE / _zoom
for i: int in range(4):
var corner := _get_corner_position(bounds, i)
# Horizontal line
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
Color.WHITE, 1.5 / _zoom)
# Vertical line
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
Color.WHITE, 1.5 / _zoom)
# Highlight dragged part
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
func _draw_grid() -> void:
var gs := float(_grid_size)
if gs <= 0:
return
var area_size := preview_area.size
var world_origin := -_pan_offset / _zoom
var world_size := area_size / _zoom
var world_end := world_origin + world_size
var start_x: float = floor(world_origin.x / gs) * gs
var start_y: float = floor(world_origin.y / gs) * gs
var line_width: float = 1.0 / _zoom
var x: float = start_x
while x <= world_end.x:
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
y += gs
func _draw_polyline_preview(pts: PackedVector2Array, color: Color, closed: bool) -> void:
for i: int in range(pts.size() - 1):
preview_area.draw_line(pts[i], pts[i + 1], color, 1.5)
if closed and pts.size() >= 2:
preview_area.draw_line(pts[pts.size() - 1], pts[0], color, 1.5)
# ---------------------------------------------------------------------------
# Transform helpers
# ---------------------------------------------------------------------------
func _transform_point(pt: Vector2, pos: Vector2, center: Vector2, rot_deg: float, scl: Vector2) -> Vector2:
var wp := pt + pos
wp = wp - center
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
wp = wp.rotated(deg_to_rad(rot_deg))
wp = wp + center
return wp
func _compute_center(pts: PackedVector2Array) -> Vector2:
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for pt: Vector2 in pts:
min_x = min(min_x, pt.x)
min_y = min(min_y, pt.y)
max_x = max(max_x, pt.x)
max_y = max(max_y, pt.y)
return Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5)
func _compute_transformed_bounds_from_points(raw_pts: PackedVector2Array, center: Vector2, rot_deg: float, scl: Vector2) -> Rect2:
if raw_pts.is_empty():
return Rect2()
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for pt: Vector2 in raw_pts:
var wp := pt - center
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
wp = wp.rotated(deg_to_rad(rot_deg))
wp = wp + center
min_x = min(min_x, wp.x)
min_y = min(min_y, wp.y)
max_x = max(max_x, wp.x)
max_y = max(max_y, wp.y)
if min_x > max_x or min_y > max_y:
return Rect2()
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
func _get_corner_position(bounds: Rect2, corner: int) -> Vector2:
match corner:
0: return bounds.position # top-left
1: return Vector2(bounds.end.x, bounds.position.y) # top-right
2: return bounds.end # bottom-right
3: return Vector2(bounds.position.x, bounds.end.y) # bottom-left
return bounds.position
func _get_opposite_corner(bounds: Rect2, corner: int) -> Vector2:
match corner:
0: return bounds.end
1: return Vector2(bounds.position.x, bounds.end.y)
2: return bounds.position
3: return Vector2(bounds.end.x, bounds.position.y)
return bounds.position
func _compute_original_bounds_size(part_name: String) -> Vector2:
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
return Vector2.ZERO
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
var wp := pt + pos
min_x = min(min_x, wp.x); min_y = min(min_y, wp.y)
max_x = max(max_x, wp.x); max_y = max(max_y, wp.y)
if min_x > max_x or min_y > max_y:
return Vector2.ZERO
return Vector2(max_x - min_x, max_y - min_y)
# ---------------------------------------------------------------------------
# GUI Input
# ---------------------------------------------------------------------------
func _on_preview_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
# Middle mouse -> panning
if mb.button_index == MOUSE_BUTTON_MIDDLE:
if mb.pressed:
_is_panning = true
_pan_start = mb.position
_end_interaction()
else:
_is_panning = false
return
# Mouse wheel -> zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
preview_area.queue_redraw()
return
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
preview_area.queue_redraw()
return
# Left mouse button
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
var world_pos := _screen_to_world(mb.position)
_try_start_interaction(world_pos)
else:
_end_interaction()
# Right mouse button -> context menu
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb)
return
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _is_panning:
_pan_offset += (mm.position - _pan_start) / _zoom
_pan_start = mm.position
preview_area.queue_redraw()
return
match _interaction:
Interaction.TRANSLATE:
_handle_translate_drag(mm)
Interaction.ROTATE:
_handle_rotate_drag(mm)
Interaction.SCALE:
_handle_scale_drag(mm)
func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _try_start_interaction(world_pos: Vector2) -> void:
_interaction = Interaction.NONE
# Check gizmos on selected part first
if not _selected_part.is_empty():
var shapes_array: Variant = _part_shapes.get(_selected_part, [])
if shapes_array is Array and not (shapes_array as Array).is_empty():
var all_raw := _collect_all_raw_points(_selected_part)
if not all_raw.is_empty():
var pos: Vector2 = _part_positions.get(_selected_part, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
var center := _compute_center(all_raw)
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
if bounds.has_area():
# Rotation circle hit
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
_interaction = Interaction.ROTATE
_rotate_start_rotation = _part_rotations[_selected_part]
_rotate_total_delta = 0.0
var bounds_center := bounds.position + bounds.size * 0.5
_rotate_prev_angle = rad_to_deg(bounds_center.angle_to_point(world_pos))
preview_area.queue_redraw()
return
# Scale cross hit
for i: int in range(4):
var corner := _get_corner_position(bounds, i)
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
_interaction = Interaction.SCALE
_scale_corner = i
_scale_anchor = _get_opposite_corner(bounds, i)
_scale_original_size = _compute_original_bounds_size(_selected_part)
preview_area.queue_redraw()
return
# Check part hit-test for translation (reverse order: front to back)
for i: int in range(_part_order.size() - 1, -1, -1):
var part_name: String = _part_order[i]
if not _part_bounds.has(part_name):
continue
var bounds: Variant = _part_bounds[part_name]
if bounds is Rect2:
var rect: Rect2 = bounds as Rect2
if rect.grow(8.0).has_point(world_pos):
_selected_part = part_name
_interaction = Interaction.TRANSLATE
_dragging_part = part_name
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
_drag_offset = world_pos - pos
preview_area.queue_redraw()
return
# Clicked on empty space -> deselect
_selected_part = ""
_interaction = Interaction.NONE
preview_area.queue_redraw()
func _end_interaction() -> void:
if _interaction != Interaction.NONE:
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
_dragging_part = ""
_drag_offset = Vector2.ZERO
_interaction = Interaction.NONE
preview_area.queue_redraw()
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
if _dragging_part.is_empty():
return
var world_pos := _screen_to_world(mm.position)
var new_pos := world_pos - _drag_offset
_part_positions[_dragging_part] = new_pos
_rebuild_bounds()
if _snap_enabled:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
var bb_top_left := (bounds as Rect2).position
var gs := float(_grid_size)
if gs > 0.0:
var snapped_tl := Vector2(
round(bb_top_left.x / gs) * gs,
round(bb_top_left.y / gs) * gs
)
new_pos += snapped_tl - bb_top_left
_part_positions[_dragging_part] = new_pos
_rebuild_bounds()
preview_area.queue_redraw()
part_moved.emit(_dragging_part, new_pos)
func _handle_rotate_drag(mm: InputEventMouseMotion) -> void:
if _selected_part.is_empty():
return
var all_raw := _collect_all_raw_points(_selected_part)
if all_raw.is_empty():
return
var center := _compute_center(all_raw)
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
var bounds_center := bounds.position + bounds.size * 0.5
var world_pos := _screen_to_world(mm.position)
var current_angle := rad_to_deg(bounds_center.angle_to_point(world_pos))
var frame_delta := current_angle - _rotate_prev_angle
if frame_delta > 180.0:
frame_delta -= 360.0
elif frame_delta < -180.0:
frame_delta += 360.0
_rotate_total_delta += frame_delta
_rotate_prev_angle = current_angle
var new_rotation := _rotate_start_rotation + _rotate_total_delta
if Input.is_key_pressed(KEY_CTRL):
new_rotation = round(new_rotation / 15.0) * 15.0
_part_rotations[_selected_part] = new_rotation
preview_area.queue_redraw()
func _handle_scale_drag(mm: InputEventMouseMotion) -> void:
if _selected_part.is_empty() or _scale_corner < 0:
return
if _scale_original_size.x <= 0 or _scale_original_size.y <= 0:
return
var all_raw := _collect_all_raw_points(_selected_part)
if all_raw.is_empty():
return
var center := _compute_center(all_raw)
var world_pos := _screen_to_world(mm.position)
if _snap_enabled:
var gs := float(_grid_size)
if gs > 0.0:
world_pos.x = round(world_pos.x / gs) * gs
world_pos.y = round(world_pos.y / gs) * gs
var rot_rad := deg_to_rad(_part_rotations.get(_selected_part, 0.0))
var mouse_rel := world_pos - center
var anchor_rel := _scale_anchor - center
var mouse_unrot := mouse_rel.rotated(-rot_rad)
var anchor_unrot := anchor_rel.rotated(-rot_rad)
var new_scale := Vector2(
abs(mouse_unrot.x - anchor_unrot.x) / _scale_original_size.x,
abs(mouse_unrot.y - anchor_unrot.y) / _scale_original_size.y
)
new_scale = Vector2(max(new_scale.x, 0.01), max(new_scale.y, 0.01))
if Input.is_key_pressed(KEY_CTRL):
var uniform := maxf(new_scale.x, new_scale.y)
new_scale = Vector2(uniform, uniform)
_part_scales[_selected_part] = new_scale
preview_area.queue_redraw()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
func _collect_all_raw_points(part_name: String) -> PackedVector2Array:
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
return PackedVector2Array()
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var result := PackedVector2Array()
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
result.append(pt + pos)
return result
func _variant_to_shapes_array(data: Variant) -> Array:
if data is Array:
var arr: Array = []
for item in (data as Array):
if item is Dictionary:
arr.append(_variant_to_single_shape(item))
return arr
return []
func _variant_to_single_shape(d: Dictionary) -> Dictionary:
return {
"shape_type": d.get("shape_type", ""),
"points": _variant_to_points(d.get("points", [])),
"color": d.get("color", "#000000"),
"closed": d.get("closed", false),
"vertex_flags": _variant_to_int_array(d.get("vertex_flags", [])),
}
func _variant_to_points(pts_variant: Variant) -> PackedVector2Array:
if pts_variant is PackedVector2Array:
return pts_variant as PackedVector2Array
if pts_variant is Array:
var arr: Array = pts_variant as Array
var out := PackedVector2Array()
for item in arr:
if item is Dictionary:
var d: Dictionary = item as Dictionary
out.append(Vector2(float(d.get("x", 0)), float(d.get("y", 0))))
elif item is Vector2:
out.append(item as Vector2)
return out
return PackedVector2Array()
func _variant_to_int_array(vf_variant: Variant) -> PackedInt32Array:
if vf_variant is PackedInt32Array:
return vf_variant as PackedInt32Array
if vf_variant is Array:
var arr: Array = vf_variant as Array
var out := PackedInt32Array()
out.resize(arr.size())
for i: int in arr.size():
out[i] = int(arr[i])
return out
return PackedInt32Array()
func _rebuild_bounds() -> void:
_part_bounds.clear()
for part_name: String in _part_order:
if not _part_shapes.has(part_name):
continue
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
continue
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(part_name, 0.0)
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
var all_raw := PackedVector2Array()
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
all_raw.append(pt + pos)
if all_raw.is_empty():
continue
var center := _compute_center(all_raw)
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
for pt: Vector2 in all_raw:
var transformed := _transform_point(pt, Vector2.ZERO, center, rot_deg, scl)
min_x = min(min_x, transformed.x)
min_y = min(min_y, transformed.y)
max_x = max(max_x, transformed.x)
max_y = max(max_y, transformed.y)
if min_x <= max_x and min_y <= max_y:
_part_bounds[part_name] = Rect2(
Vector2(min_x, min_y),
Vector2(max_x - min_x, max_y - min_y)
)
func _short_label(part_name: String) -> String:
match part_name:
"head": return "H"
"torso": return "T"
"left_upper_arm": return "LUA"
"left_lower_arm": return "LLA"
"right_upper_arm": return "RUA"
"right_lower_arm": return "RLA"
"left_upper_leg": return "LUL"
"left_lower_leg": return "LLL"
"right_upper_leg": return "RUL"
"right_lower_leg": return "RLL"
return part_name.left(3)
# ---------------------------------------------------------------------------
# Phase 5: Context menu, z-ordering, mirroring
# ---------------------------------------------------------------------------
func _handle_right_click(mb: InputEventMouseButton) -> void:
var world_pos := _screen_to_world(mb.position)
for i: int in range(_part_order.size() - 1, -1, -1):
var part_name: String = _part_order[i]
if not _part_bounds.has(part_name):
continue
var bounds: Variant = _part_bounds[part_name]
if bounds is Rect2:
var rect: Rect2 = bounds as Rect2
if rect.has_point(world_pos):
_selected_part = part_name
_context_menu.clear()
_context_menu.add_item("Send Back", 0)
_context_menu.add_item("Bring Forward", 1)
_context_menu.add_separator()
_context_menu.add_item("Mirror X", 2)
_context_menu.add_item("Mirror Y", 3)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
preview_area.queue_redraw()
return
_selected_part = ""
preview_area.queue_redraw()
func _on_context_menu_id_pressed(id: int) -> void:
match id:
0: _send_back_part()
1: _bring_forward_part()
2: _mirror_part_x()
3: _mirror_part_y()
func _send_back_part() -> void:
if _selected_part.is_empty():
return
var idx := _part_order.find(_selected_part)
if idx <= 0 or idx >= _part_order.size():
return
_part_order.remove_at(idx)
_part_order.insert(idx - 1, _selected_part)
preview_area.queue_redraw()
func _bring_forward_part() -> void:
if _selected_part.is_empty():
return
var idx := _part_order.find(_selected_part)
if idx < 0 or idx >= _part_order.size() - 1:
return
_part_order.remove_at(idx)
_part_order.insert(idx + 1, _selected_part)
preview_area.queue_redraw()
func _mirror_part_x() -> void:
if _selected_part.is_empty():
return
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
func _mirror_part_y() -> void:
if _selected_part.is_empty():
return
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
+1
View File
@@ -0,0 +1 @@
uid://codv3g6qw1tef