- 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.
1948 lines
79 KiB
GDScript
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()
|