- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
1062 lines
36 KiB
GDScript
1062 lines
36 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/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()
|