class_name ActionEditor extends PopupPanel ## ActionEditor - Single-action property editor popup (Phase 3c). ## ## Used for adding a new action and for editing an existing one (queue or rule). ## The type dropdown and the param fields are generated from ActionRegistry, so ## new action types appear automatically. For `walk_to`, the target position is ## captured on the stage: the editor emits `target_requested()` and the stage ## hides the editor, captures a click, then calls `set_walk_target()`. const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") signal committed(action: Dictionary) signal cancelled() signal target_requested() signal prop_requested() var _mode: String = "new" var _initial: Dictionary = {} var _type_option: OptionButton = null var _params_box: VBoxContainer = null var _text_edit: LineEdit = null var _duration_spin: SpinBox = null var _target_label: Label = null var _prop_target_label: Label = null var _target_prop_display_name: String = "" var _set_target_btn: Button = null var _stop_current_check: CheckBox = null var _show_reactive_options: bool = false var _allow_prop_actions: bool = true var _has_target: bool = false var _target_pos: Vector2 = Vector2.ZERO var _target_prop_id: int = -1 var _flight_time_spin: SpinBox = null var _hand_option: OptionButton = null ## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no ## override (engine default); Font null = no override. var _ui_font: Font = null var _emoji_font: Font = null var _base_font_size: int = 0 func _ready() -> void: exclusive = true popup_window = true _build_ui() func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if (event as InputEventKey).keycode == KEY_ESCAPE: cancelled.emit() ## Opens the editor in "add" mode (empty, default type walk_to). func open_new(show_reactive_options: bool = false) -> void: _show_reactive_options = show_reactive_options _allow_prop_actions = not show_reactive_options _rebuild_type_options() _mode = "new" _initial = {} _has_target = false _target_pos = Vector2.ZERO _target_prop_id = -1 _target_prop_display_name = "" _select_type("walk_to") _rebuild_params("walk_to") title = "Add Action" popup_centered() ## Opens the editor pre-filled from a flat queue action. func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void: _show_reactive_options = show_reactive_options _allow_prop_actions = not show_reactive_options _rebuild_type_options() _mode = "edit" _initial = action.duplicate(true) var type := String(action.get("type", "walk_to")) _has_target = (action.has("target") and action["target"] is Vector2) or (action.has("target_position") and action["target_position"] is Vector2) _target_pos = action.get("target_position", action.get("target", Vector2.ZERO)) if _has_target else Vector2.ZERO _target_prop_id = int(action.get("target_prop", -1)) _target_prop_display_name = "" _select_type(type) _rebuild_params(type) title = "Edit Action" popup_centered() ## Called by the stage after a walk-target capture click. func set_walk_target(pos: Vector2) -> void: _has_target = true _target_pos = pos _update_walk_target_label() popup_centered() func set_prop_target(prop: PropBlock, display_name: String = "") -> void: _target_prop_id = prop.get_instance_id() if prop != null and is_instance_valid(prop) else -1 _target_prop_display_name = display_name _update_prop_target_label() popup_centered() func set_throw_target(pos: Vector2) -> void: _has_target = true _target_pos = pos _update_walk_target_label() popup_centered() func _build_ui() -> void: var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 12) margin.add_theme_constant_override("margin_right", 12) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 12) add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 8) margin.add_child(vbox) var type_row := HBoxContainer.new() vbox.add_child(type_row) var type_label := Label.new() type_label.text = "Type:" type_row.add_child(type_label) _type_option = OptionButton.new() _type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL for type: String in _available_types(): _type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)]) _type_option.item_selected.connect(_on_type_selected) type_row.add_child(_type_option) _params_box = VBoxContainer.new() _params_box.add_theme_constant_override("separation", 6) vbox.add_child(_params_box) var buttons := HBoxContainer.new() buttons.alignment = BoxContainer.ALIGNMENT_END buttons.add_theme_constant_override("separation", 8) vbox.add_child(buttons) var cancel := Button.new() cancel.text = "Cancel" cancel.pressed.connect(func() -> void: cancelled.emit()) buttons.add_child(cancel) var ok := Button.new() ok.text = "OK" ok.pressed.connect(_on_ok_pressed) buttons.add_child(ok) min_size = Vector2i(340, 200) ## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by ## the stage after add_child so the editor's UI is already built. func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void: _ui_font = ui_font _emoji_font = emoji_font _base_font_size = int(sizes.get("action_editor", 18)) _apply_font_recursive(self, _base_font_size) func apply_theme(sandbox_theme: Variant) -> void: if sandbox_theme != null: self.theme = sandbox_theme.control_theme() func _apply_font_recursive(node: Node, size: int) -> void: for child: Node in node.get_children(): if child is Control: _apply_font_to(child as Control, size) _apply_font_recursive(child, size) func _apply_font_to(c: Control, size: int) -> void: if c == null: return if _ui_font != null: c.add_theme_font_override("font", _ui_font) elif _emoji_font != null: c.add_theme_font_override("font", _emoji_font) if size > 0: c.add_theme_font_size_override("font_size", size) func _select_type(type: String) -> void: var idx := _available_types().find(type) if idx < 0: idx = 0 _type_option.select(idx) func _current_type() -> String: var types := _available_types() var idx := _type_option.selected if idx < 0 or idx >= types.size(): return "walk_to" return types[idx] func get_action_type() -> String: return _current_type() func _available_types() -> Array[String]: var types: Array[String] = ACTION_REGISTRY.types() if not _allow_prop_actions: types.erase("pick_up_prop") types.erase("drop_prop") types.erase("throw_prop") return types func _rebuild_type_options() -> void: if _type_option == null: return _type_option.clear() for type: String in _available_types(): _type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)]) func get_target_prop_id() -> int: return _target_prop_id func get_flight_time() -> float: return _flight_time_spin.value if _flight_time_spin != null else 0.9 func _on_type_selected(_index: int) -> void: _rebuild_params(_current_type()) ## Rebuilds the param controls for the given action type. func _rebuild_params(type: String) -> void: for child: Node in _params_box.get_children(): child.queue_free() _text_edit = null _duration_spin = null _target_label = null _prop_target_label = null _set_target_btn = null _stop_current_check = null _flight_time_spin = null _hand_option = null match type: "walk_to": var trow := HBoxContainer.new() _params_box.add_child(trow) _target_label = Label.new() _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL trow.add_child(_target_label) _set_target_btn = Button.new() _set_target_btn.text = "🎯 Click target…" _set_target_btn.pressed.connect(func() -> void: target_requested.emit()) trow.add_child(_set_target_btn) _update_walk_target_label() "speak": var tlabel := Label.new() tlabel.text = "Text:" _params_box.add_child(tlabel) _text_edit = LineEdit.new() _text_edit.placeholder_text = "Say something…" _text_edit.text = String(_initial.get("text", "")) _params_box.add_child(_text_edit) var dlabel := Label.new() dlabel.text = "Duration (s):" _params_box.add_child(dlabel) _duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0))) _params_box.add_child(_duration_spin) if _show_reactive_options and ACTION_REGISTRY.is_overlay_eligible(type): _stop_current_check = CheckBox.new() _stop_current_check.text = "Stop what he's doing?" _stop_current_check.button_pressed = bool(_initial.get("stop_current", false)) _params_box.add_child(_stop_current_check) "wait": var wlabel := Label.new() wlabel.text = "Duration (s):" _params_box.add_child(wlabel) _duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0))) _params_box.add_child(_duration_spin) "pick_up_prop", "throw_prop": var prop_row := HBoxContainer.new(); _params_box.add_child(prop_row) _prop_target_label = Label.new(); _prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; prop_row.add_child(_prop_target_label) var prop_button := Button.new(); prop_button.text = "🧱 Click prop…"; prop_button.pressed.connect(func() -> void: prop_requested.emit()); prop_row.add_child(prop_button) _update_prop_target_label() if type == "pick_up_prop": var hand_label := Label.new(); hand_label.text = "Hand:"; _params_box.add_child(hand_label) _hand_option = OptionButton.new() for hand: String in ["auto", "left", "right"]: _hand_option.add_item(hand.capitalize()) _hand_option.select(maxi(0, ["auto", "left", "right"].find(String(_initial.get("hand", "auto"))))) _params_box.add_child(_hand_option) if type == "throw_prop": var target_row := HBoxContainer.new(); _params_box.add_child(target_row) _target_label = Label.new(); _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; target_row.add_child(_target_label) var target_button := Button.new(); target_button.text = "🎯 Click landing point…"; target_button.pressed.connect(func() -> void: target_requested.emit()); target_row.add_child(target_button) _update_walk_target_label() var flight_label := Label.new(); flight_label.text = "Flight (s):"; _params_box.add_child(flight_label) _flight_time_spin = _make_duration_spin(float(_initial.get("flight_time", 0.9))); _flight_time_spin.min_value = 0.35; _flight_time_spin.max_value = 1.5; _params_box.add_child(_flight_time_spin) _: # ragdoll / recover (and any future no-param action) need no fields. pass _apply_font_recursive(_params_box, _base_font_size) func _make_duration_spin(value: float) -> SpinBox: var spin := SpinBox.new() spin.min_value = 0.1 spin.max_value = 3600.0 spin.step = 0.1 spin.value = value spin.custom_minimum_size = Vector2(120.0, 0.0) return spin func _update_walk_target_label() -> void: if _target_label == null: return if _has_target: _target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))] else: _target_label.text = "Target: not set" func _update_prop_target_label() -> void: if _prop_target_label != null: if _target_prop_id < 0: _prop_target_label.text = "Prop: not set" elif not _target_prop_display_name.is_empty(): _prop_target_label.text = "Prop: %s" % _target_prop_display_name else: _prop_target_label.text = "Prop: selected" func _on_ok_pressed() -> void: var type := _current_type() var action: Dictionary = { "type": type } match type: "walk_to": if not _has_target: # Ask the stage to capture the target before committing. target_requested.emit() return action["target"] = _target_pos "speak": action["text"] = _text_edit.text if _text_edit != null else "" action["duration"] = _duration_spin.value if _duration_spin != null else 2.0 if _show_reactive_options and _stop_current_check != null: action["stop_current"] = _stop_current_check.button_pressed "wait": action["duration"] = _duration_spin.value if _duration_spin != null else 1.0 "pick_up_prop": if _target_prop_id < 0: prop_requested.emit(); return action["target_prop"] = _target_prop_id; action["hand"] = ["auto", "left", "right"][_hand_option.selected] if _hand_option != null else "auto" "throw_prop": if _target_prop_id < 0: prop_requested.emit(); return if not _has_target: target_requested.emit(); return action["target_prop"] = _target_prop_id; action["target_position"] = _target_pos; action["flight_time"] = _flight_time_spin.value if _flight_time_spin != null else 0.9 committed.emit(action)