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/prop_file.gd") const PropThemeScript := preload("res://scripts/prop_theme.gd") const PropBlockScript := preload("res://scripts/prop_block.gd") const PropCanvasScript := preload("res://scripts/prop_canvas.gd") const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd") const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.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"] # Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode. const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] 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 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: var result: Array = [] result.append(path) for entry: Variant in list: if str(entry) != path: result.append(str(entry)) if result.size() > max_count: result.resize(max_count) return result # --------------------------------------------------------------------------- # On-ready node references # --------------------------------------------------------------------------- @onready var _menu_bar: MenuBar = %MenuBar @onready var _top_right: HBoxContainer = %TopRightControls @onready var _prop_name_edit: LineEdit = %PropNameEdit @onready var _canvas: PropCanvas = %PropCanvas @onready var _mode_bar: HBoxContainer = %ModeBar @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 _material_preset: int = PropBlockScript.MaterialPreset.NONE var _current_path: String = "" var _dirty: bool = false var _clipboard: Dictionary = {} var _thumb: PropThumbnail var _thumb_cache: ThumbnailCache var _ui_theme: Theme # Inspector controls var _preset_option: OptionButton 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 _shape_section: VBoxContainer var _primitive_option: OptionButton var _params_box: VBoxContainer var _param_spins: Dictionary = {} var _add_button: Button var _updating_inspector: bool = false # Grid settings dialog (removed Round 4 - replaced by top-right controls) var _grid_spin: SpinBox var _snap_check: CheckBox # Edit-mode toolbar (Round 2/3) var _mode_group: ButtonGroup var _move_button: Button var _points_button: Button # Add-shape inspector section (hidden in Reshape mode, Round 3) var _add_section: VBoxContainer # Exit/new dialog + autosave (Round 3/4) var _exit_dialog: ConfirmationDialog var _pending_quit: bool = false var _pending_new: bool = false var _autosave_remaining: float = 0.0 # Recent props (Round 5) var _recent_props: Array[String] = [] # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: get_tree().auto_accept_quit = false _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_inspector() _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.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()) _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 = BoxContainer.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() _check_autosave_recovery() func _process(delta: float) -> void: _update_cursor_coords() if _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_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 suffix: String = " • Modified" if _dirty else "" _save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix] _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) _move_button.pressed.connect(_on_move_mode_pressed) _points_button.pressed.connect(_on_points_mode_pressed) _move_button.theme = _ui_theme _points_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()) # No keyboard focus: the focus stylebox otherwise draws over the pressed # stylebox, hiding the selected (accent-bordered) state until the cursor # moves away (Round 4 fix). button.focus_mode = Control.FOCUS_NONE _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_canvas_mode_changed(mode: int) -> void: _sync_mode_buttons(mode) _sync_mode_controls(mode) func _sync_mode_buttons(mode: int) -> void: if _move_button == null or _points_button == null: return _move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM) _points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS) 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) func _sync_mode_controls(mode: int) -> void: if _add_section != null: _add_section.visible = mode != PropCanvasScript.EditMode.POINTS if _delete_button != null: _delete_button.visible = mode != PropCanvasScript.EditMode.POINTS # --------------------------------------------------------------------------- # Theme (Task 1.3 - local inspector theme, no global mutation) # --------------------------------------------------------------------------- func _build_control_theme() -> void: _ui_theme = Theme.new() var bg: Color = _theme.canvas_background_color() var panel := StyleBoxFlat.new() panel.bg_color = Color(0.12, 0.12, 0.12, 1.0) panel.set_corner_radius_all(6) panel.content_margin_left = 8 panel.content_margin_right = 8 panel.content_margin_top = 6 panel.content_margin_bottom = 6 panel.border_color = Color(1.0, 1.0, 1.0, 0.12) panel.set_border_width_all(1) var panel_dark := StyleBoxFlat.new() panel_dark.bg_color = bg.darkened(0.25) panel_dark.set_corner_radius_all(6) panel_dark.content_margin_left = 8 panel_dark.content_margin_right = 8 panel_dark.content_margin_top = 6 panel_dark.content_margin_bottom = 6 panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12) panel_dark.set_border_width_all(1) var panel_pressed := StyleBoxFlat.new() panel_pressed.bg_color = bg.darkened(0.25) panel_pressed.set_corner_radius_all(6) panel_pressed.content_margin_left = 8 panel_pressed.content_margin_right = 8 panel_pressed.content_margin_top = 6 panel_pressed.content_margin_bottom = 6 panel_pressed.border_color = _theme.accent_color() panel_pressed.set_border_width_all(2) _ui_theme.set_stylebox("normal", "Button", panel) _ui_theme.set_stylebox("hover", "Button", panel_dark) _ui_theme.set_stylebox("pressed", "Button", panel_pressed) _ui_theme.set_stylebox("focus", "Button", panel) _ui_theme.set_stylebox("normal", "OptionButton", panel) _ui_theme.set_stylebox("hover", "OptionButton", panel_dark) _ui_theme.set_stylebox("pressed", "OptionButton", panel_dark) _ui_theme.set_stylebox("normal", "SpinBox", panel_dark) _ui_theme.set_stylebox("normal", "LineEdit", panel_dark) _ui_theme.set_stylebox("normal", "ColorPickerButton", panel) _ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark) _ui_theme.set_stylebox("normal", "PopupMenu", panel_dark) _ui_theme.set_stylebox("panel", "MenuBar", panel_dark) _ui_theme.set_stylebox("normal", "CheckBox", panel) _ui_theme.set_stylebox("panel", "PanelContainer", panel_dark) _ui_theme.set_color("font_color", "Label", _theme.text_color()) _ui_theme.set_color("font_color", "Button", _theme.text_color()) _ui_theme.set_color("font_hover_color", "Button", Color.WHITE) _ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color()) _ui_theme.set_font_size("font_size", "Label", _theme.font_body()) _ui_theme.set_font_size("font_size", "Button", _theme.font_body()) _ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body()) _ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body()) _ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body()) _ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu()) _ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar()) _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: if FileAccess.file_exists(path): existing.append(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 := _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: 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() 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 var raw_list: Variant = (parsed as Dictionary).get("recent_props", []) if raw_list is Array: for entry: Variant in raw_list as Array: if entry is String and not (entry as String).is_empty(): _recent_props.append(entry as String) func _save_settings() -> void: var data := {"version": 1, "recent_props": _recent_props} var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) if file == null: return file.store_string(JSON.stringify(data)) file.close() func _setup_dialogs() -> void: _save_dialog.file_selected.connect(_on_save_file_selected) _load_dialog.file_selected.connect(_on_load_file_selected) _build_exit_dialog() 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.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.toggled.connect(_on_top_snap_toggled) _top_right.add_child(_snap_check) 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 _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" _exit_dialog.add_button("🗑️ Discard", false, "discard") _exit_dialog.get_cancel_button().text = "❌ Cancel" _exit_dialog.theme = _ui_theme 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) func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST: _request_close() func _request_close() -> void: 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: 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 _on_exit_save() -> void: _exit_dialog.hide() if _current_path.is_empty(): _save_dialog.popup_centered() return await _save_and_finish(_current_path) func _save_and_finish(path: String) -> void: var ok: bool = await _save_to(path, false) if not ok: return if _pending_quit: _pending_quit = false get_tree().quit() elif _pending_new: _pending_new = false _do_new() func _on_exit_custom_action(action: StringName) -> void: if action == &"discard": _exit_dialog.hide() _clear_autosave() if _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 func _do_new() -> void: _prop_name_edit.text = "" _material_preset = PropBlockScript.MaterialPreset.NONE _preset_option.selected = _material_preset _update_physics_labels() _canvas.clear() _clipboard = {} _canvas.set_has_clipboard(false) _current_path = "" _dirty = false _clear_autosave() _refresh_inspector_from_selection() _update_save_status() func _on_menu_id_pressed(id: int) -> void: match id: MENU_NEW: _request_new() MENU_LOAD: _load_dialog.popup_centered() MENU_SAVE: if _current_path.is_empty(): _save_dialog.popup_centered() else: await _save_to(_current_path) MENU_SAVE_AS: _save_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 var material_title := Label.new() material_title.text = "Material" material_title.add_theme_font_size_override("font_size", header_size) _inspector_list.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) _inspector_list.add_child(_preset_option) _mass_label = Label.new() _friction_label = Label.new() _bounce_label = Label.new() _inspector_list.add_child(_mass_label) _inspector_list.add_child(_friction_label) _inspector_list.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) _delete_button = Button.new() _delete_button.text = "Erase Shape" _delete_button.custom_minimum_size.y = float(_theme.button_min_height()) _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()) _add_button.pressed.connect(_on_add_pressed) _add_section.add_child(_add_button) _apply_control_theme(_inspector_list) _rebuild_primitive_params() 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 = "Friction: %.2f" % mat.friction _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() _update_save_status() 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 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() func _on_canvas_shape_selected(_idx: int) -> void: _refresh_inspector_from_selection() 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"] = _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 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(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" dialog.add_button("🗑️ Discard", false, "discard") dialog.theme = _ui_theme 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()) func _apply_json_data(json: Variant, is_recovery: bool = false) -> String: 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", "")) _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) _dirty = is_recovery _autosave_remaining = 0.0 _refresh_inspector_from_selection() _update_save_status() return "" func _save_to(path: String, with_thumbnail: bool = true) -> bool: 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 _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 _on_save_file_selected(path: String) -> void: if path.get_extension().to_lower() != "prop": path = path.get_basename() + ".prop" var ok: bool = await _save_to(path, not (_pending_quit or _pending_new)) if ok and _pending_quit: _pending_quit = false get_tree().quit() elif ok and _pending_new: _pending_new = false _do_new() func _load_from(path: String) -> void: 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()