Files
stickman/scripts/editors/prop/prop_editor.gd
ryan ddb298f631 Add visual harmonization tests for editor UI components and ground finish chooser
- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
2026-10-23 15:20:12 -04:00

1948 lines
79 KiB
GDScript

class_name PropEditor
extends Control
## PropEditor - Standalone vector prop authoring tool (Phase 9).
##
## Mirrors the stickman editor's controller conventions (menu bar,
## save/load/clear, JSON (de)serialization, status bar) around a PropCanvas.
## Owns the material-preset inspector (physics readouts from PropBlock's
## static preset tables), per-shape fill/outline/width controls (live sync),
## primitive creation with theme-driven defaults, a local inspector Theme
## (never mutates global GUI settings), the shape clipboard, and the
## save-side thumbnail pipeline (PropThumbnail + ThumbnailCache).
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
const ThumbnailCacheScript := preload("res://scripts/shared/thumbnails/thumbnail_cache.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"]
const EDITABLE_ROLE_STRINGS: PackedStringArray = ["grip", "emitter", "impact", "mount", "custom"]
# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode.
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"]
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"]
const UNTITLED_NAME: String = "Untitled"
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
const AUTOSAVE_DEBOUNCE: float = 1.0
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const STAGE_REFERENCE_HEIGHT: float = 939.5 # WholeStickmanPreview head top (-463.5 - 100) to ankle line (376).
const STAGE_SIZE_STEP: float = 1.1
const ART_TAG_IDS: Array[String] = ["nature", "buildings", "walls", "roads", "objects"]
const ART_TAG_LABELS: Array[String] = ["Nature", "Buildings", "Walls", "Roads", "Objects"]
signal artwork_saved(path: String)
signal return_requested
const MENU_NEW := 0
const MENU_LOAD := 2
const MENU_SAVE := 3
const MENU_SAVE_AS := 4
const MENU_RESET_VIEWS := 20
static func mode_button_text(mode: int) -> String:
var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1)
return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]]
## Pure window-title builder: [Prop name][filename.prop][*].
static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String:
var name := prop_name.strip_edges()
if name.is_empty():
name = UNTITLED_NAME
var title := name
if not file_path.is_empty():
title += " — " + file_path.get_file()
if dirty:
title += " *"
return title
## Most-recent-first dedupe + front-insert + trim (headless-testable).
static func update_recent_list(list: Array, path: String, max_count: int = RECENT_MAX) -> Array:
path = ContentPathsScript.canonicalize(path)
var result: Array = []
result.append(path)
for entry: Variant in list:
var canonical_entry: String = ContentPathsScript.canonicalize(str(entry))
if canonical_entry != path and not result.has(canonical_entry):
result.append(canonical_entry)
if result.size() > max_count:
result.resize(max_count)
return result
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
if not has_geometry: return false
var threshold: float = maxf(OFF_ORIGIN_WARNING_MIN_DISTANCE, OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length())
return bounds.get_center().length() > threshold
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _top_right: FlowContainer = %TopRightControls
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: FlowContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@onready var _main_layout: HSplitContainer = %MainLayout
@onready var _canvas_panel: PanelContainer = %CanvasPanel
@onready var _inspector_column: PanelContainer = %InspectorColumn
@onready var _inspector_list: VBoxContainer = %InspectorList
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@onready var _error_dialog: AcceptDialog = %ErrorDialog
@onready var _cursor_coords: Label = %CursorCoords
@onready var _save_status: Label = %SaveStatus
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
var _clipboard: Dictionary = {}
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _tags: Array[String] = []
var _selected_anchor_id: String = ""
var _thumb: PropThumbnail
var _thumb_cache: ThumbnailCache
var _ui_theme: Theme
# Inspector controls
var _preset_option: OptionButton
var _material_section: VBoxContainer
var _mass_label: Label
var _friction_label: Label
var _bounce_label: Label
var _fill_button: ColorPickerButton
var _outline_button: ColorPickerButton
var _width_spin: SpinBox
var _radius_spin: SpinBox
var _radius_row: Control
var _reshaped_note: Label
var _vertex_label: Label
var _delete_button: Button
var _create_point_button: Button
var _create_point_hint: Label
var _shape_section: VBoxContainer
var _primitive_option: OptionButton
var _params_box: VBoxContainer
var _param_spins: Dictionary = {}
var _add_button: Button
var _updating_inspector: bool = false
var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer
var _stage_authoring: bool = false
var _stage_return_pending: bool = false
var _stage_place_button: Button
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
var _size_for_stage_button: Button
var _stage_size_controls: FlowContainer
var _stage_size_status: Label
var _show_stickman_guide_check: CheckBox
var _show_stickman_guide: bool = false
var _whole_prop_resize_button: Button
var _stage_size_preview_active: bool = false
var _stage_size_preview_factor: float = 1.0
var _stage_size_entry_extent: float = 0.0
var _pending_save_after_stage_size: int = -1
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
var _move_button: Button
var _points_button: Button
var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer
var _anchor_section: VBoxContainer
var _anchor_list: ItemList
var _anchor_role: OptionButton
var _anchor_hand: OptionButton
var _anchor_x: SpinBox
var _anchor_y: SpinBox
var _anchor_rotation: SpinBox
var _anchor_z: SpinBox
var _rename_anchor_button: Button
var _delete_anchor_button: Button
var _attach_preview: Control
var _show_preview_check: CheckBox
var _inactive_anchors_check: CheckBox
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
var _last_canvas_mode: int = PropCanvasScript.EditMode.TRANSFORM
var _inspector_split_initialized: bool = false
# Exit/new dialog + autosave (Round 3/4)
var _exit_dialog: ConfirmationDialog
var _off_origin_save_dialog: ConfirmationDialog
var _pending_quit: bool = false
var _pending_new: bool = false
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
var _autosave_remaining: float = 0.0
# Recent props (Round 5)
var _recent_props: Array[String] = []
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if not _stage_authoring:
get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
_load_settings()
_thumb = PropThumbnailScript.new()
add_child(_thumb)
_build_control_theme()
_setup_menu_bar()
_build_mode_toolbar()
_build_top_right_controls()
_build_size_tools()
_build_inspector()
_build_attach_tools()
_attach_preview = PropAttachPreviewScene.instantiate() as Control
_canvas_workspace.add_child(_attach_preview)
_apply_control_theme(_attach_preview)
_attach_preview.call("set_ui_hit_target_height", float(_theme.button_min_height()))
_attach_preview.visible = false
_setup_dialogs()
_canvas.shape_changed.connect(_on_canvas_shape_changed)
_canvas.shape_selected.connect(_on_canvas_shape_selected)
_canvas.shape_copy_requested.connect(_on_canvas_copy_requested)
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
_canvas.mode_changed.connect(_on_canvas_mode_changed)
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
_canvas.stage_size_cancel_requested.connect(func() -> void: _finish_stage_size_preview(false))
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
_canvas.set_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color()
origin_marker_color.a = 0.75
_canvas.set_origin_marker_color(origin_marker_color)
_canvas.set_show_stickman_guide(_show_stickman_guide)
_prop_name_edit.text_changed.connect(_on_name_changed)
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
_cursor_coords.add_theme_font_size_override("font_size", _theme.font_status())
_save_status.add_theme_font_size_override("font_size", _theme.font_status())
_mode_bar.alignment = FlowContainer.ALIGNMENT_CENTER
_refresh_inspector_from_selection()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_update_save_status()
_update_cursor_coords()
_canvas.call_deferred("reset_view")
call_deferred("_initialize_inspector_split")
_refresh_center_prop_button()
if _stage_authoring:
if _canvas.get_edit_mode() == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
_do_new()
_build_stage_authoring_controls()
_apply_authoring_context()
else:
_check_autosave_recovery()
func configure_stage_authoring() -> void:
_stage_authoring = true
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(delta: float) -> void:
_update_cursor_coords()
if not _stage_authoring and _autosave_remaining > 0.0:
_autosave_remaining -= delta
if _autosave_remaining <= 0.0:
_autosave_remaining = 0.0
_write_autosave()
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
if _stage_size_preview_active:
_finish_stage_size_preview(false)
get_viewport().set_input_as_handled()
return
if _stage_authoring:
var stage_focus: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if stage_focus is LineEdit or stage_focus is TextEdit:
return
_request_stage_return()
get_viewport().set_input_as_handled()
return
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_create_point_armed():
_canvas.cancel_create_point()
_update_create_point_feedback()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
_set_whole_prop_resize_enabled(false)
get_viewport().set_input_as_handled()
return
if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE):
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if focus_owner == null or focus_owner is Label:
if _canvas.get_selected_index() >= 0:
_canvas.delete_selected()
func _update_cursor_coords() -> void:
if _canvas != null and _canvas.is_cursor_over_drawing(get_global_mouse_position()):
var world_pos := _canvas.global_to_world(get_global_mouse_position())
_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
else:
_cursor_coords.text = "X: - Y: -"
func _update_save_status() -> void:
var name: String = _prop_name_edit.text if _prop_name_edit != null else ""
if name.is_empty():
name = "Unnamed Prop"
var has_saved_path: bool = not _current_path.is_empty()
var state: String = "Unsaved changes" if _dirty else ("Saved" if has_saved_path else "Not saved yet")
_save_status.text = "%s — %s — %s" % [name, PRESET_LABELS[_material_preset], state]
var status_color: Color = _editor_visual_theme.chrome_color(&"dirty", &"prop") if _dirty else (_editor_visual_theme.chrome_color(&"saved", &"prop") if has_saved_path else _editor_visual_theme.chrome_color(&"text_muted", &"prop"))
_save_status.add_theme_color_override("font_color", status_color)
_update_window_title()
func _update_window_title() -> void:
if not is_inside_tree():
return
get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty)
# ---------------------------------------------------------------------------
# Edit-mode toolbar (Round 2)
# ---------------------------------------------------------------------------
func _build_mode_toolbar() -> void:
_mode_group = ButtonGroup.new()
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
_attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH)
_move_button.pressed.connect(_on_move_mode_pressed)
_points_button.pressed.connect(_on_points_mode_pressed)
_attach_button.pressed.connect(_on_attach_mode_pressed)
_move_button.theme = _ui_theme
_points_button.theme = _ui_theme
_attach_button.theme = _ui_theme
func _make_mode_button(mode: int) -> Button:
var button := Button.new()
button.text = mode_button_text(mode)
button.toggle_mode = true
button.button_group = _mode_group
button.custom_minimum_size.y = float(_theme.mode_button_min_height())
button.add_theme_font_size_override("font_size", _theme.font_mode())
button.focus_mode = Control.FOCUS_ALL
_mode_bar.add_child(button)
return button
func _on_move_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
func _on_points_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS)
func _on_attach_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.ATTACH)
func _on_canvas_mode_changed(mode: int) -> void:
if _stage_size_preview_active:
_cancel_stage_size_preview()
if _stage_authoring and mode == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
return
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
if mode != PropCanvasScript.EditMode.POINTS and _canvas.is_create_point_armed():
_canvas.cancel_create_point()
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
_last_canvas_mode = mode
_sync_mode_buttons(mode)
_sync_mode_controls(mode)
func _sync_mode_buttons(mode: int) -> void:
if _move_button == null or _points_button == null or _attach_button == null:
return
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
_points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS)
_attach_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.ATTACH)
var accent: Color = _theme.accent_color()
_move_button.add_theme_color_override("font_pressed_color", accent)
_points_button.add_theme_color_override("font_pressed_color", accent)
_attach_button.add_theme_color_override("font_pressed_color", accent)
func _sync_mode_controls(mode: int) -> void:
if _whole_prop_resize_button != null:
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _size_for_stage_button != null:
_size_for_stage_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _add_section != null:
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _create_point_button != null:
_create_point_button.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
if _create_point_hint != null:
_create_point_hint.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
_update_create_point_feedback()
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _anchor_section != null:
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _attach_preview != null:
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
if _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _build_attach_tools() -> void:
_show_preview_check = CheckBox.new()
_show_preview_check.text = "Show Stickman Preview"
_show_preview_check.button_pressed = true
_show_preview_check.toggled.connect(func(_enabled: bool) -> void: _sync_mode_controls(_canvas.get_edit_mode()))
_attach_tools.add_child(_show_preview_check)
_inactive_anchors_check = CheckBox.new()
_inactive_anchors_check.text = "Show Other Markers"
_inactive_anchors_check.button_pressed = true
_inactive_anchors_check.toggled.connect(func(enabled: bool) -> void:
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled))
_attach_tools.add_child(_inactive_anchors_check)
# ---------------------------------------------------------------------------
# Theme (Task 1.3 - local inspector theme, no global mutation)
# ---------------------------------------------------------------------------
func _build_control_theme() -> void:
_ui_theme = _editor_visual_theme.control_theme(&"prop")
theme = _ui_theme
var inspector_surface: StyleBoxFlat = _ui_theme.get_stylebox("panel", "PanelContainer").duplicate() as StyleBoxFlat
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _editor_visual_theme.chrome_color(&"border", &"prop").lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_menu_bar.theme = _ui_theme
_top_right.theme = _ui_theme
_prop_name_edit.theme = _ui_theme
_save_dialog.theme = _ui_theme
_load_dialog.theme = _ui_theme
_error_dialog.theme = _ui_theme
# ---------------------------------------------------------------------------
# Menu & dialogs
# ---------------------------------------------------------------------------
func _setup_menu_bar() -> void:
var file_menu := PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("🆕 New", MENU_NEW)
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
var recent_popup := PopupMenu.new()
recent_popup.name = "RecentProps"
recent_popup.id_pressed.connect(_on_recent_prop_selected)
file_menu.add_child(recent_popup)
file_menu.add_submenu_item("🕑 Recent", "RecentProps")
file_menu.id_pressed.connect(_on_menu_id_pressed)
file_menu.about_to_popup.connect(_update_recent_menu)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File")
var view_menu := PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)
view_menu.id_pressed.connect(_on_menu_id_pressed)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View")
# ---------------------------------------------------------------------------
# Recent props (Round 5, mirrors the sandbox recent-scenes menu)
# ---------------------------------------------------------------------------
func _update_recent_menu() -> void:
var recent: PopupMenu = _menu_bar.get_node_or_null(NodePath("FileMenu/RecentProps")) as PopupMenu
if recent == null:
return
recent.clear()
var existing: Array[String] = []
for path: String in _recent_props:
var canonical_path: String = ContentPathsScript.canonicalize(path)
if FileAccess.file_exists(canonical_path) and not existing.has(canonical_path):
existing.append(canonical_path)
if existing.size() != _recent_props.size():
_recent_props = existing
_save_settings()
if _recent_props.is_empty():
recent.add_item("(No recent props)", -1)
recent.set_item_disabled(0, true)
else:
for index: int in _recent_props.size():
recent.add_item(_recent_props[index], index)
func _on_recent_prop_selected(index: int) -> void:
if index < 0 or index >= _recent_props.size():
return
var path: String = ContentPathsScript.canonicalize(_recent_props[index])
if not FileAccess.file_exists(path):
_recent_props.remove_at(index)
_save_settings()
_show_error("File not found: %s" % path)
return
_load_from(path)
func _push_recent(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
var updated: Array = update_recent_list(_recent_props, path, RECENT_MAX)
_recent_props.clear()
for entry: Variant in updated:
_recent_props.append(str(entry))
_save_settings()
func _load_settings() -> void:
_recent_props.clear()
_show_stickman_guide = false
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
return
_show_stickman_guide = bool((parsed as Dictionary).get("show_stickman_guide", false))
var raw_list: Variant = (parsed as Dictionary).get("recent_props", [])
if raw_list is Array:
var migrated: bool = false
for entry: Variant in raw_list as Array:
if entry is String and not (entry as String).is_empty():
var old_path: String = entry as String
var path: String = ContentPathsScript.canonicalize(old_path)
migrated = migrated or path != old_path
if not _recent_props.has(path):
_recent_props.append(path)
if migrated:
_save_settings()
func _save_settings() -> void:
var data := {"version": 1, "recent_props": _recent_props, "show_stickman_guide": _show_stickman_guide}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file == null:
return
file.store_string(JSON.stringify(data))
file.close()
func _setup_dialogs() -> void:
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.PROPS_DIR)
_save_dialog.current_dir = content_dir
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog)
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void:
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
return
var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled:
ok_button.pressed.emit()
func _initialize_inspector_split() -> void:
if _inspector_split_initialized or _main_layout == null:
return
var inspector_minimum: float = _inspector_column.get_combined_minimum_size().x
if _main_layout.size.x <= inspector_minimum:
call_deferred("_initialize_inspector_split")
return
_main_layout.split_offset = maxi(0, int(_main_layout.size.x - inspector_minimum - _main_layout.get_theme_constant("separation")))
_inspector_split_initialized = true
func _build_top_right_controls() -> void:
# Grid size + snap, right side of the menu bar row (sandbox-style).
var grid_label := Label.new()
grid_label.text = "Grid:"
grid_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_top_right.add_child(grid_label)
_grid_spin = SpinBox.new()
_grid_spin.min_value = 1
_grid_spin.max_value = 512
_grid_spin.value = _theme.grid_size()
_grid_spin.custom_minimum_size.x = 90.0
_grid_spin.custom_minimum_size.y = float(_theme.button_min_height())
_grid_spin.value_changed.connect(_on_top_grid_changed)
_top_right.add_child(_grid_spin)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = false
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
_snap_check.toggled.connect(_on_top_snap_toggled)
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "Move, Turn, or Resize Prop"
_whole_prop_resize_button.tooltip_text = "Move, turn, or resize the whole prop, including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
_size_tools.add_child(_whole_prop_resize_button)
_size_for_stage_button = Button.new()
_size_for_stage_button.text = "📏 Size for Stage"
_size_for_stage_button.tooltip_text = "Compare this drawing with a standing stickman."
_size_for_stage_button.custom_minimum_size = Vector2(148.0, maxf(44.0, float(_theme.button_min_height())))
_size_for_stage_button.pressed.connect(_on_size_for_stage_pressed)
_size_tools.add_child(_size_for_stage_button)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
_size_tools.add_child(_center_prop_button)
_show_stickman_guide_check = CheckBox.new()
_show_stickman_guide_check.text = "Show Stickman Guide"
_show_stickman_guide_check.button_pressed = _show_stickman_guide
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
_size_tools.add_child(_show_stickman_guide_check)
_stage_size_controls = FlowContainer.new()
_stage_size_controls.name = "StageSizeControls"
_stage_size_controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_stage_size_controls.add_theme_constant_override("h_separation", 6)
_stage_size_controls.add_theme_constant_override("v_separation", 6)
_stage_size_controls.hide()
_size_tools.add_child(_stage_size_controls)
for preset: Dictionary in [
{"label": "Small", "factor": 0.35},
{"label": "Room", "factor": 1.0},
{"label": "Big", "factor": 2.0},
]:
var preset_button: Button = _make_stage_size_button(str(preset["label"]))
var preset_factor: float = float(preset["factor"])
preset_button.pressed.connect(func() -> void: _choose_stage_size(preset_factor))
_stage_size_controls.add_child(preset_button)
var smaller_button: Button = _make_stage_size_button("Make smaller")
smaller_button.pressed.connect(func() -> void: _adjust_stage_size(1.0 / STAGE_SIZE_STEP))
_stage_size_controls.add_child(smaller_button)
var bigger_button: Button = _make_stage_size_button("Make bigger")
bigger_button.pressed.connect(func() -> void: _adjust_stage_size(STAGE_SIZE_STEP))
_stage_size_controls.add_child(bigger_button)
var keep_button: Button = _make_stage_size_button("Keep size", true)
keep_button.pressed.connect(func() -> void: _finish_stage_size_preview(true))
_stage_size_controls.add_child(keep_button)
var cancel_button: Button = _make_stage_size_button("✕ Cancel")
cancel_button.pressed.connect(func() -> void: _finish_stage_size_preview(false))
_stage_size_controls.add_child(cancel_button)
_stage_size_status = Label.new()
_stage_size_status.name = "StageSizeStatus"
# This label lives in the size-tools FlowContainer. With an auto-wrapping
# label and no minimum width, FlowContainer can collapse it to a sliver and
# grow the toolbar to thousands of pixels tall when the preview opens.
_stage_size_status.custom_minimum_size.x = 360.0
_stage_size_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_stage_size_status.hide()
_size_tools.add_child(_stage_size_status)
_refresh_scale_buttons()
func _make_stage_size_button(label: String, prominent: bool = false) -> Button:
var button: Button = Button.new()
button.text = label
button.custom_minimum_size.y = maxf(44.0, float(_theme.button_min_height()))
button.focus_mode = Control.FOCUS_ALL
button.theme = _ui_theme
if prominent:
button.add_theme_color_override("font_color", _theme.accent_color())
return button
func _on_top_grid_changed(value: float) -> void:
_canvas.set_grid_size(int(value))
func _on_top_snap_toggled(pressed: bool) -> void:
_canvas.set_snap_enabled(pressed)
func _on_size_for_stage_pressed() -> void:
if _stage_size_preview_active or _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM:
return
_set_whole_prop_resize_enabled(false)
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
_stage_size_entry_extent = maxf(bounds.size.x, bounds.size.y)
if not is_finite(_stage_size_entry_extent) or _stage_size_entry_extent <= 0.0:
return
if not _canvas.begin_stage_size_preview(_anchors):
return
_stage_size_preview_factor = 1.0
_stage_size_preview_active = true
_stage_size_status.text = "Compare the drawing with the guide, then keep it or cancel."
_update_stage_size_controls_visibility()
# Hiding the inspector and mode bar changes the canvas allocation. Wait for
# that layout pass before centering/fitting, otherwise the guide can be
# pushed outside the newly-sized viewport until a preset is clicked.
await get_tree().process_frame
if not _stage_size_preview_active:
return
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
func _choose_stage_size(relative_height: float) -> void:
if not _stage_size_preview_active or not is_finite(relative_height) or relative_height <= 0.0:
return
var factor: float = STAGE_REFERENCE_HEIGHT * relative_height / _stage_size_entry_extent
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
else:
_stage_size_status.text = "That size is too large to display. Try a smaller choice."
func _adjust_stage_size(multiplier: float) -> void:
if not _stage_size_preview_active:
return
var factor: float = _stage_size_preview_factor * multiplier
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, false)
else:
_stage_size_status.text = "That size is outside the drawing area. Try a smaller change."
func _finish_stage_size_preview(keep: bool) -> void:
if not _stage_size_preview_active:
return
var pending_save: int = _pending_save_after_stage_size if keep else -1
_pending_save_after_stage_size = -1
if keep:
var result: Dictionary = _canvas.commit_stage_size_preview()
_anchors = (result.get("anchors", _anchors) as Dictionary).duplicate(true)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
else:
_canvas.cancel_stage_size_preview()
_stage_size_preview_active = false
_stage_size_preview_factor = 1.0
_stage_size_entry_extent = 0.0
_stage_size_status.text = ""
_update_stage_size_controls_visibility()
_refresh_scale_buttons()
if pending_save >= 0:
_continue_save_after_stage_size(pending_save)
func _cancel_stage_size_preview() -> void:
if _stage_size_preview_active:
_pending_save_after_stage_size = -1
_finish_stage_size_preview(false)
func _request_save_after_stage_size(menu_id: int) -> void:
_pending_save_after_stage_size = menu_id
_stage_size_status.text = "Keep size before saving, or cancel to leave the drawing unchanged."
func _continue_save_after_stage_size(menu_id: int) -> void:
if menu_id == MENU_SAVE:
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
elif menu_id == MENU_SAVE_AS:
_save_dialog.popup_centered()
func _update_stage_size_controls_visibility() -> void:
if _stage_size_controls == null:
return
_stage_size_controls.visible = _stage_size_preview_active
_stage_size_status.visible = _stage_size_preview_active
_size_for_stage_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_whole_prop_resize_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_center_prop_button.visible = not _stage_size_preview_active
_show_stickman_guide_check.visible = not _stage_size_preview_active and not _stage_authoring
_mode_bar.visible = not _stage_size_preview_active
_inspector_column.visible = not _stage_size_preview_active
func _on_stickman_guide_toggled(enabled: bool) -> void:
_show_stickman_guide = enabled
_canvas.set_show_stickman_guide(enabled)
_save_settings()
func _set_whole_prop_resize_enabled(enabled: bool) -> void:
var allowed: bool = enabled and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM and _canvas.has_shape_geometry()
_canvas.set_whole_prop_resize_enabled(allowed)
if _whole_prop_resize_button != null:
_whole_prop_resize_button.set_pressed_no_signal(allowed)
_sync_mode_controls(_canvas.get_edit_mode())
func _on_whole_prop_resize_requested(factor: float) -> void:
_scale_document(factor)
_refresh_center_prop_button()
func _on_whole_prop_move_requested(delta: Vector2) -> void:
_translate_document(delta)
func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
_rotate_document(angle_rad)
func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new()
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel"
_exit_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_exit_dialog)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_off_origin_save_dialog)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false))
_off_origin_save_dialog.canceled.connect(_cancel_pending_save_request)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_close()
func _request_close() -> void:
if _stage_authoring:
_request_stage_return()
return
if not _dirty:
get_tree().quit()
return
_pending_quit = true
_pending_new = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.popup_centered()
func _request_new() -> void:
_cancel_stage_size_preview()
if not _dirty:
_do_new()
return
_pending_new = true
_pending_quit = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?"
_exit_dialog.popup_centered()
func _request_stage_return() -> void:
if not _stage_authoring:
return
_cancel_stage_size_preview()
if not _dirty:
if not _current_path.is_empty():
artwork_saved.emit(_current_path)
return_requested.emit()
return
_stage_return_pending = true
_pending_new = false
_pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
_exit_dialog.popup_centered()
func _on_exit_save() -> void:
_exit_dialog.hide()
if _current_path.is_empty():
_save_dialog.popup_centered()
return
_begin_save_request(_current_path, false)
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
if not _stage_authoring:
_clear_autosave()
if _stage_return_pending:
_stage_return_pending = false
return_requested.emit()
elif _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
func _on_exit_cancel() -> void:
_pending_quit = false
_pending_new = false
_stage_return_pending = false
_cancel_pending_save_request()
func _do_new() -> void:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_tags.clear()
_refresh_tag_controls()
_preset_option.selected = _material_preset
_update_physics_labels()
_canvas.clear()
_anchors = PropAnchorDataScript.default_anchors()
_selected_anchor_id = ""
_canvas.set_anchor_data(_anchors)
_clipboard = {}
_canvas.set_has_clipboard(false)
_current_path = ""
_dirty = false
_clear_autosave()
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_update_save_status()
_refresh_tag_controls()
func _on_menu_id_pressed(id: int) -> void:
match id:
MENU_NEW:
_request_new()
MENU_SAVE:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
MENU_SAVE_AS:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE_AS)
return
_save_dialog.popup_centered()
MENU_LOAD:
_cancel_stage_size_preview()
_load_dialog.popup_centered()
MENU_RESET_VIEWS:
_canvas.reset_view()
# ---------------------------------------------------------------------------
# Inspector (Tasks 1.3 / 3.1 / 3.2 / 2.1)
# ---------------------------------------------------------------------------
func _build_inspector() -> void:
var header_size: int = _theme.font_header()
var row_height: int = _theme.row_min_height()
# Material preset section
_material_section = VBoxContainer.new()
_material_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_material_section)
var material_title := Label.new()
material_title.text = "Material"
material_title.add_theme_font_size_override("font_size", header_size)
_material_section.add_child(material_title)
_preset_option = OptionButton.new()
for label: String in PRESET_LABELS:
_preset_option.add_item(label)
_preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
_preset_option.item_selected.connect(_on_preset_selected)
_material_section.add_child(_preset_option)
_mass_label = Label.new()
_friction_label = Label.new()
_bounce_label = Label.new()
_material_section.add_child(_mass_label)
_material_section.add_child(_friction_label)
_material_section.add_child(_bounce_label)
_update_physics_labels()
# Selected-shape section
_shape_section = VBoxContainer.new()
_shape_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_shape_section)
var shape_title := Label.new()
shape_title.text = "Selected Shape"
shape_title.add_theme_font_size_override("font_size", header_size)
_shape_section.add_child(shape_title)
var fill_row := HBoxContainer.new()
fill_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(fill_row)
var fill_label := Label.new()
fill_label.text = "Fill:"
fill_label.custom_minimum_size = Vector2(90.0, 0.0)
fill_row.add_child(fill_label)
_fill_button = ColorPickerButton.new()
_fill_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_fill_button.custom_minimum_size.y = float(_theme.button_min_height())
_fill_button.color_changed.connect(_on_fill_color_changed)
fill_row.add_child(_fill_button)
var outline_row := HBoxContainer.new()
outline_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(outline_row)
var outline_label := Label.new()
outline_label.text = "Outline:"
outline_label.custom_minimum_size = Vector2(90.0, 0.0)
outline_row.add_child(outline_label)
_outline_button = ColorPickerButton.new()
_outline_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_outline_button.custom_minimum_size.y = float(_theme.button_min_height())
_outline_button.color_changed.connect(_on_outline_color_changed)
outline_row.add_child(_outline_button)
var width_row := HBoxContainer.new()
width_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(width_row)
var width_label := Label.new()
width_label.text = "Width:"
width_label.custom_minimum_size = Vector2(90.0, 0.0)
width_row.add_child(width_label)
_width_spin = SpinBox.new()
_width_spin.min_value = _theme.outline_width_min()
_width_spin.max_value = _theme.outline_width_max()
_width_spin.step = 0.5
_width_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_width_spin.custom_minimum_size.y = float(_theme.button_min_height())
_width_spin.value_changed.connect(_on_width_changed)
width_row.add_child(_width_spin)
_radius_row = HBoxContainer.new()
_radius_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(_radius_row)
var radius_label := Label.new()
radius_label.text = "Radius:"
radius_label.custom_minimum_size = Vector2(90.0, 0.0)
_radius_row.add_child(radius_label)
_radius_spin = SpinBox.new()
_radius_spin.min_value = 1.0
_radius_spin.max_value = 1000.0
_radius_spin.step = 1.0
_radius_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_radius_spin.custom_minimum_size.y = float(_theme.button_min_height())
_radius_spin.value_changed.connect(_on_radius_changed)
_radius_row.add_child(_radius_spin)
_reshaped_note = Label.new()
_reshaped_note.text = "✏️ This shape was reshaped"
_reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_reshaped_note)
_vertex_label = Label.new()
_shape_section.add_child(_vertex_label)
_create_point_button = Button.new()
_create_point_button.text = "📍 Create Point"
_create_point_button.tooltip_text = "Add a point to the selected shape's edge."
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed)
_shape_section.add_child(_create_point_button)
_create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_create_point_hint)
_delete_button = Button.new()
_delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed)
_shape_section.add_child(_delete_button)
# Add-shape section (hidden in Reshape mode, Round 3)
_add_section = VBoxContainer.new()
_add_section.add_theme_constant_override("separation", 8)
_inspector_list.add_child(_add_section)
var add_title := Label.new()
add_title.text = "Add Shape"
add_title.add_theme_font_size_override("font_size", header_size)
_add_section.add_child(add_title)
_primitive_option = OptionButton.new()
for label: String in PRIMITIVE_LABELS:
_primitive_option.add_item(label)
_primitive_option.custom_minimum_size.y = float(_theme.button_min_height())
_primitive_option.item_selected.connect(_on_primitive_selected)
_add_section.add_child(_primitive_option)
_params_box = VBoxContainer.new()
_params_box.add_theme_constant_override("separation", 4)
_add_section.add_child(_params_box)
_add_button = Button.new()
_add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button)
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
_build_tag_inspector()
_build_anchor_inspector()
_refresh_tag_controls()
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null:
return
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
if name_label != null:
name_label.text = "Artwork name:"
_prop_name_edit.placeholder_text = "Name this artwork"
_stage_place_button = Button.new()
_stage_place_button.text = "✨ Save & Place as Scenery"
_stage_place_button.tooltip_text = "Save this drawing and put a picture of it on your Stage."
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_stage_place_button.theme = _ui_theme
_editor_visual_theme.style_button_role(_stage_place_button, &"primary", &"prop")
_stage_place_button.pressed.connect(_request_stage_return)
top_bar.add_child(_stage_place_button)
func _apply_authoring_context() -> void:
if not _stage_authoring:
return
if _material_section != null:
_material_section.hide()
if _anchor_section != null:
_anchor_section.hide()
if _attach_tools != null:
_attach_tools.hide()
if _attach_button != null:
_attach_button.hide()
if _show_stickman_guide_check != null:
_show_stickman_guide_check.hide()
if _tag_section != null:
_tag_section.show()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_save_dialog.title = "Save Scenery Artwork"
func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new()
_tag_section.add_theme_constant_override("separation", 4)
_inspector_list.add_child(_tag_section)
var title := Label.new()
title.text = "Artwork Tags"
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var hint := Label.new()
hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tag_section.add_child(hint)
var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4)
_tag_section.add_child(rows)
for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new()
check.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height())
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
rows.add_child(check)
_tag_checks[tag_id] = check
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
if _updating_inspector:
return
if enabled and not _tags.has(tag_id):
_tags.append(tag_id)
elif not enabled:
_tags.erase(tag_id)
_tags = PropFileScript.normalize_tags(_tags)
_mark_dirty()
func _refresh_tag_controls() -> void:
if _tag_checks.is_empty():
return
_updating_inspector = true
for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
if check != null:
check.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false
func _build_anchor_inspector() -> void:
_anchor_section = VBoxContainer.new()
_anchor_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_anchor_section)
var title := Label.new(); title.text = "Anchors"; title.add_theme_font_size_override("font_size", _theme.font_header()); _anchor_section.add_child(title)
_anchor_list = ItemList.new(); _anchor_list.custom_minimum_size.y = 140.0; _anchor_list.item_selected.connect(func(index: int) -> void: _select_anchor(_anchor_list.get_item_text(index))); _anchor_section.add_child(_anchor_list)
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
var role: StringName = &"primary" if label == "Add Anchor" else (&"destructive" if label == "Delete" else &"secondary")
_editor_visual_theme.style_button_role(button, role, &"prop")
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
button.pressed.connect(_on_rename_anchor_pressed)
else:
_delete_anchor_button = button
button.pressed.connect(_on_delete_anchor_pressed)
_anchor_role = _anchor_option("Role", EDITABLE_ROLE_STRINGS, _on_anchor_role_selected)
_anchor_hand = _anchor_option("Allowed Hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS, _on_anchor_hand_selected)
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Held prop layer", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var anchor_layer_hint := Label.new()
anchor_layer_hint.text = "Negative: behind · 0: normal order · Positive: farther in front."
anchor_layer_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
anchor_layer_hint.add_theme_font_size_override("font_size", _theme.font_status())
anchor_layer_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_anchor_section.add_child(anchor_layer_hint)
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.custom_minimum_size.y = float(_theme.button_min_height()); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
option.custom_minimum_size.y = float(_theme.button_min_height())
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
row.add_child(option)
return option
func _anchor_spin(label_text: String, minimum: float, maximum: float, callback: Callable) -> SpinBox:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var spin := SpinBox.new()
spin.min_value = minimum
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.custom_minimum_size.y = float(_theme.button_min_height())
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
func _refresh_anchor_inspector() -> void:
if _anchor_list == null: return
_updating_inspector = true; _anchor_list.clear()
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
for id: String in PropAnchorDataScript.editable_ids(_anchors): _anchor_list.add_item(id); if id == _selected_anchor_id: _anchor_list.select(_anchor_list.item_count - 1)
var anchor: Dictionary = _anchors.get(_selected_anchor_id, {})
var editable: bool = not anchor.is_empty()
if not anchor.is_empty():
var role_index: int = EDITABLE_ROLE_STRINGS.find(str(anchor.get("role", "custom")))
_anchor_role.select(role_index if role_index >= 0 else EDITABLE_ROLE_STRINGS.find("custom"))
_anchor_hand.select(PropAnchorDataScript.ALLOWED_HAND_STRINGS.find(str(anchor.get("allowed_hands", "both"))))
var pos: Vector2 = anchor.get("position", Vector2.ZERO); _anchor_x.value = pos.x; _anchor_y.value = pos.y; _anchor_rotation.value = float(anchor.get("rotation_deg", 0.0)); _anchor_z.value = int(anchor.get("z_index_offset", 0))
# OptionButton and Button inherit BaseButton and use `disabled`; SpinBox is a
# Range control and gates editing through `editable` in Godot 4.
for option: OptionButton in [_anchor_role, _anchor_hand]:
option.disabled = not editable
option.tooltip_text = "Add or select an anchor." if not editable else ""
for spin: SpinBox in [_anchor_x, _anchor_y, _anchor_rotation, _anchor_z]:
spin.editable = editable
spin.tooltip_text = "Add or select an anchor." if not editable else ""
if _rename_anchor_button != null:
_rename_anchor_button.disabled = not editable
if _delete_anchor_button != null:
_delete_anchor_button.disabled = not editable
_updating_inspector = false
func _select_anchor(id: String) -> void:
if id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(id): _selected_anchor_id = id; _canvas.select_anchor(id); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_add_anchor_pressed() -> void:
var id: String = PropAnchorDataScript.normalize_id("anchor", _anchors); var bounds: Rect2 = _canvas.get_shapes_bounds(); _anchors[id] = {"role":"custom", "position": bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO, "rotation_deg":0.0, "z_index_offset":0, "allowed_hands":"both"}; _selected_anchor_id = id; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector()
func _on_rename_anchor_pressed() -> void:
if _selected_anchor_id.is_empty() or not _anchors.has(_selected_anchor_id) or _rename_anchor_dialog == null:
return
_rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
_rename_anchor_edit.call_deferred("grab_focus")
_rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null:
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
_clear_pending_anchor_rename()
return
var remaining: Dictionary = _anchors.duplicate(true)
var payload: Dictionary = remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id: String = PropAnchorDataScript.normalize_id(_rename_anchor_edit.text, remaining)
var old_id: String = _rename_anchor_source_id
_clear_pending_anchor_rename()
if new_id == old_id:
return
remaining[new_id] = payload
_anchors = PropAnchorDataScript.normalize_anchors(remaining)
_selected_anchor_id = new_id
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(new_id)
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _clear_pending_anchor_rename() -> void:
_rename_anchor_source_id = ""
func _on_delete_anchor_pressed() -> void:
if _selected_anchor_id.is_empty(): return
var ids: Array[String] = PropAnchorDataScript.editable_ids(_anchors)
var old_index: int = ids.find(_selected_anchor_id)
_anchors.erase(_selected_anchor_id)
ids = PropAnchorDataScript.editable_ids(_anchors)
_selected_anchor_id = ids[clampi(old_index - 1, 0, ids.size() - 1)] if not ids.is_empty() else ""
_canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_anchor_role_selected(index: int) -> void:
if index >= 0 and index < EDITABLE_ROLE_STRINGS.size():
_set_anchor_field("role", EDITABLE_ROLE_STRINGS[index])
func _on_anchor_hand_selected(index: int) -> void: _set_anchor_field("allowed_hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS[index])
func _on_anchor_x_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(value, _anchor_y.value))
func _on_anchor_y_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(_anchor_x.value, value))
func _on_anchor_rotation_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("rotation_deg", value)
func _on_anchor_z_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("z_index_offset", int(value))
func _set_anchor_field(field: String, value: Variant) -> void:
if _updating_inspector or _selected_anchor_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_anchor_id].duplicate(true); anchor[field] = value; _anchors[_selected_anchor_id] = anchor; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_anchor_preset(preset: String) -> void: _anchors = PropAnchorDataScript.preset_layout(preset, _canvas.get_shapes_bounds()); _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors); _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_control_theme(node: Node) -> void:
if node is Control:
(node as Control).theme = _ui_theme
for child: Node in node.get_children():
_apply_control_theme(child)
func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Surface grip: %.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_label.text = "Bounce: %.2f" % mat.bounce
func _on_preset_selected(index: int) -> void:
_material_preset = clampi(index, 0, PRESET_LABELS.size() - 1)
_update_physics_labels()
_mark_dirty()
func _rebuild_primitive_params() -> void:
for child: Node in _params_box.get_children():
child.queue_free()
_param_spins.clear()
var primitive: String = PRIMITIVE_IDS[_primitive_option.selected]
match primitive:
"box":
_add_param_spin("Size X", _theme.primitive_default("box", "size_x"), 1.0, 2000.0, "size_x")
_add_param_spin("Size Y", _theme.primitive_default("box", "size_y"), 1.0, 2000.0, "size_y")
"ball":
_add_param_spin("Radius", _theme.primitive_default("ball", "radius"), 1.0, 1000.0, "radius")
"plank":
_add_param_spin("Length", _theme.primitive_default("plank", "length"), 1.0, 4000.0, "length")
_add_param_spin("Thickness", _theme.primitive_default("plank", "thickness"), 1.0, 1000.0, "thickness")
"triangle":
_add_param_spin("Base", _theme.primitive_default("triangle", "base"), 1.0, 2000.0, "base")
_add_param_spin("Height", _theme.primitive_default("triangle", "height"), 1.0, 2000.0, "height")
"line":
_add_param_spin("Length", _theme.primitive_default("line", "length"), 1.0, 4000.0, "length")
"circle":
_add_param_spin("Radius", _theme.primitive_default("circle", "radius"), 1.0, 1000.0, "radius")
func _add_param_spin(label_text: String, default_value: float, minimum: float, maximum: float, key: String) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size.y = float(_theme.row_min_height())
_params_box.add_child(row)
var label := Label.new()
label.text = label_text
label.custom_minimum_size = Vector2(90.0, 0.0)
row.add_child(label)
var spin := SpinBox.new()
spin.min_value = minimum
spin.max_value = maximum
spin.value = default_value
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.custom_minimum_size.y = float(_theme.button_min_height())
row.add_child(spin)
_param_spins[key] = spin
func _on_primitive_selected(_index: int) -> void:
_rebuild_primitive_params()
func _on_add_pressed() -> void:
var primitive: String = PRIMITIVE_IDS[_primitive_option.selected]
var params: Dictionary = {}
for key: String in _param_spins:
params[key] = (_param_spins[key] as SpinBox).value
params["fill_color"] = _theme.material_tint(_material_preset, "fill").to_html()
params["outline_color"] = _theme.material_tint(_material_preset, "outline").to_html()
params["outline_width"] = _theme.outline_width_default()
var center: Vector2 = _canvas.global_to_world(_canvas.get_global_rect().get_center())
_canvas.add_primitive(primitive, center, params)
_mark_dirty()
func _on_fill_color_changed(color: Color) -> void:
if _updating_inspector:
return
var idx := _canvas.get_selected_index()
if idx >= 0:
_canvas.set_shape_fill_color(idx, color.to_html())
func _on_outline_color_changed(color: Color) -> void:
if _updating_inspector:
return
var idx := _canvas.get_selected_index()
if idx >= 0:
_canvas.set_shape_outline_color(idx, color.to_html())
func _on_width_changed(value: float) -> void:
if _updating_inspector:
return
var idx := _canvas.get_selected_index()
if idx >= 0:
_canvas.set_shape_outline_width(idx, value)
func _on_radius_changed(value: float) -> void:
if _updating_inspector:
return
var idx := _canvas.get_selected_index()
if idx >= 0:
_canvas.set_shape_radius(idx, value)
func _on_delete_shape_pressed() -> void:
_canvas.delete_selected()
func _refresh_inspector_from_selection() -> void:
_updating_inspector = true
var idx := _canvas.get_selected_index()
_shape_section.visible = idx >= 0 and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_edit_mode() != PropCanvasScript.EditMode.ATTACH
if idx >= 0 and idx < _canvas.shapes.size():
var sd: Dictionary = _canvas.shapes[idx]
_fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY)
_outline_button.color = Color.from_string(str(sd.get("outline_color", "")), Color.WHITE)
_width_spin.value = float(sd.get("outline_width", 2.0))
var is_circle: bool = str(sd.get("shape_type", "")) == "circle"
var reshaped: bool = bool(sd.get("reshaped", false))
_radius_row.visible = is_circle and not reshaped
_reshaped_note.visible = reshaped
if is_circle:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
var radius: float = float(sd.get("radius", 24.0))
if not pts.is_empty():
var centroid := Vector2.ZERO
for pt: Vector2 in pts:
centroid += pt
centroid /= float(pts.size())
var total: float = 0.0
for pt: Vector2 in pts:
total += centroid.distance_to(pt)
radius = maxf(total / float(pts.size()), 0.001)
_radius_spin.value = radius
_vertex_label.text = "Vertices: %d" % (sd.get("points", PackedVector2Array()) as PackedVector2Array).size()
_updating_inspector = false
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
func _on_canvas_anchors_changed(anchors: Dictionary) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(anchors)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _canvas.get_selected_anchor_id())
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _on_canvas_anchor_selected(anchor_id: String) -> void:
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
func _sync_attach_preview() -> void:
if _attach_preview != null and _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _refresh_center_prop_button() -> void:
if _center_prop_button == null: return
var bounds: Rect2 = _canvas.get_shapes_bounds()
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
_refresh_scale_buttons()
func _refresh_scale_buttons() -> void:
if _size_for_stage_button == null:
return
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
_size_for_stage_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
_size_for_stage_button.tooltip_text = "Add visible artwork to use Size for Stage." if _size_for_stage_button.disabled else "Compare this drawing with a standing stickman."
if _whole_prop_resize_button != null:
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
func _scale_document(factor: float) -> bool:
if not _canvas.can_scale_all_shapes(factor):
return false
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_anchors, factor)
if not bool(anchor_result.get("ok", false)):
return false
var scaled_anchors: Dictionary = anchor_result.get("anchors", {})
var original_anchors: Dictionary = _anchors
_anchors = scaled_anchors
_canvas.set_anchor_data(_anchors)
if not _canvas.scale_all_shapes(factor):
_anchors = original_anchors
_canvas.set_anchor_data(_anchors)
return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_refresh_scale_buttons()
_sync_attach_preview()
return true
func _translate_document(delta: Vector2) -> bool:
if not _canvas.can_translate_all_shapes(delta): return false
var result: Dictionary = PropAnchorDataScript.try_translate_non_origin(_anchors, delta)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _rotate_document(angle_rad: float) -> bool:
if not _canvas.can_rotate_all_shapes(angle_rad): return false
var result: Dictionary = PropAnchorDataScript.try_rotate_non_origin(_anchors, angle_rad)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.rotate_all_shapes(angle_rad): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _center_prop_on_origin(focus_view: bool = true) -> bool:
if not _canvas.has_shape_geometry(): return false
var bounds: Rect2 = _canvas.get_shapes_bounds()
var delta: Vector2 = -bounds.get_center()
if not is_finite(delta.x) or not is_finite(delta.y): return false
if delta.is_zero_approx():
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return false
_anchors = PropAnchorDataScript.translated_non_origin(_anchors, delta)
_canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return true
func _on_canvas_shape_selected(_idx: int) -> void:
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
func _on_create_point_pressed() -> void:
if _canvas.get_selected_index() < 0:
return
_canvas.arm_create_point()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_update_create_point_feedback()
func _update_create_point_feedback() -> void:
if _create_point_button == null or _create_point_hint == null or _canvas == null:
return
var armed: bool = _canvas.is_create_point_armed()
_create_point_button.text = "Click an Edge to Place Point" if armed else "📍 Create Point"
_create_point_button.tooltip_text = "Press Esc to cancel." if armed else "Add a point to the selected shape's edge."
_create_point_hint.text = "Click near an edge to place the point. Press Esc to cancel." if armed else "Adds a point where you click on the selected edge."
func _on_canvas_copy_requested(shape: Dictionary) -> void:
_clipboard = shape.duplicate(true)
_canvas.set_has_clipboard(true)
func _on_canvas_paste_requested(world_pos: Vector2) -> void:
if _clipboard.is_empty():
return
_canvas.paste_shape(_clipboard, world_pos)
func _on_name_changed(_text: String) -> void:
_mark_dirty()
# ---------------------------------------------------------------------------
# Dirty tracking + autosave (Round 3)
# ---------------------------------------------------------------------------
func _mark_dirty() -> void:
_dirty = true
_update_save_status()
_autosave_remaining = AUTOSAVE_DEBOUNCE
func _write_autosave() -> void:
if not _dirty:
return
var data: Dictionary = _build_json_data()
var meta: Dictionary = data.get("metadata", {})
meta["autosave"] = true
meta["source_path"] = ContentPathsScript.canonicalize(_current_path)
meta["modified_unix"] = Time.get_unix_time_from_system()
data["metadata"] = meta
PropFileScript.save_safe(AUTOSAVE_PATH, data)
func _clear_autosave() -> void:
_autosave_remaining = 0.0
if _stage_authoring:
return
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
func _check_autosave_recovery() -> void:
if not FileAccess.file_exists(AUTOSAVE_PATH):
return
var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH)
if raw.is_empty():
_clear_autosave()
return
var raw_meta: Variant = raw.get("metadata", {})
var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {}
if not bool(metadata.get("autosave", false)):
_clear_autosave()
return
var source_path: String = ContentPathsScript.canonicalize(String(metadata.get("source_path", "")))
# Stale if the saved source exists and is newer-or-equal than the autosave.
if not source_path.is_empty() and FileAccess.file_exists(source_path) \
and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
_clear_autosave()
return
var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
add_child(dialog)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
_current_path = source_path
_update_save_status()
dialog.queue_free())
dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"discard":
_clear_autosave()
dialog.queue_free())
dialog.popup_centered()
# ---------------------------------------------------------------------------
# Save / load / clear
# ---------------------------------------------------------------------------
func _build_json_data() -> Dictionary:
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _tags)
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
var model: Dictionary = PropFileScript.from_file_dict(json)
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
_prop_name_edit.text = str(model.get("prop_name", ""))
_tags = model.get("tags", []) as Array[String]
_refresh_tag_controls()
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
if _preset_option != null:
_preset_option.selected = _material_preset
_update_physics_labels()
var shapes: Array = model.get("shapes", [])
_canvas.set_shape_data(shapes)
_anchors = PropAnchorDataScript.normalize_anchors(model.get("anchors", {}))
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_dirty = is_recovery
_autosave_remaining = 0.0
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_canvas.call_deferred("reset_view")
_update_save_status()
return ""
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return false
path = ContentPathsScript.canonicalize(path)
var ok: bool = PropFileScript.save(path, _build_json_data())
if not ok:
_show_error("Could not save the prop to '%s'." % path)
return false
_current_path = path
_dirty = false
if not _stage_authoring:
_clear_autosave()
_push_recent(path)
_update_save_status()
if with_thumbnail:
await _save_prop_thumbnail()
return true
func _save_prop_thumbnail() -> void:
if _thumb == null or _current_path.is_empty():
return
var payload: Dictionary = PropFileScript.flatten_to_payload(_canvas.get_shape_data())
if payload.is_empty():
return
var tex: Texture2D = await _thumb.render(payload, _material_preset)
if tex == null:
return
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path))
func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
_off_origin_save_dialog.popup_centered()
return
await _execute_pending_save(false)
func _execute_pending_save(center_first: bool) -> void:
var path: String = _pending_save_path
var with_thumbnail: bool = _pending_save_with_thumbnail
if path.is_empty():
_cancel_pending_save_request()
return
if center_first: _center_prop_on_origin(true)
var ok: bool = await _save_to(path, with_thumbnail)
if not ok:
_cancel_pending_save_request()
return
_pending_save_path = ""
_pending_save_with_thumbnail = true
if _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
elif _stage_return_pending:
_stage_return_pending = false
artwork_saved.emit(_current_path)
return_requested.emit()
func _cancel_pending_save_request() -> void:
_pending_save_path = ""
_pending_save_with_thumbnail = true
_pending_quit = false
_pending_new = false
_stage_return_pending = false
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
_begin_save_request(path, not (_pending_quit or _pending_new or _stage_return_pending))
func _load_from(path: String) -> void:
_cancel_stage_size_preview()
path = ContentPathsScript.canonicalize(path)
var raw: Dictionary = PropFileScript.load(path)
if raw.is_empty():
_show_error("Could not load '%s'. The file is missing or not valid JSON." % path)
return
var error_text: String = _apply_json_data(raw)
if not error_text.is_empty():
_show_error(error_text)
return
_current_path = path
_clear_autosave()
_push_recent(path)
_update_save_status()
func _on_load_file_selected(path: String) -> void:
_load_from(path)
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()