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 speech_requested(initial: Dictionary) 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 _set_target_btn: Button = null var _prop_target_label: Label = null var _set_prop_target_btn: Button = null var _hand_option: OptionButton = null var _stop_current_check: CheckBox = null var _show_reactive_options: bool = false var _has_target: bool = false var _target_pos: Vector2 = Vector2.ZERO var _prop_target_id: String = "" var _has_throw_target: bool = false var _throw_target_pos: Vector2 = Vector2.ZERO ## 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 _mode = "new" _initial = {} _has_target = false _target_pos = Vector2.ZERO _prop_target_id = "" _has_throw_target = false _throw_target_pos = Vector2.ZERO _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 _mode = "edit" _initial = action.duplicate(true) var type := String(action.get("type", "walk_to")) if type == "speak": speech_requested.emit(_initial.duplicate(true)) return _has_target = action.has("target") and action["target"] is Vector2 _target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO _prop_target_id = String(action.get("target_prop_id", "")) _has_throw_target = action.has("target_position") and action["target_position"] is Vector2 _throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO _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_id: String) -> void: _prop_target_id = prop_id _update_prop_target_label() popup_centered() func set_throw_target(pos: Vector2) -> void: _has_throw_target = true _throw_target_pos = pos _update_throw_target_label() popup_centered() func current_action_type() -> String: return _current_type() 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 ACTION_REGISTRY.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 := ACTION_REGISTRY.types().find(type) if idx < 0: idx = 0 _type_option.select(idx) func _current_type() -> String: var types := ACTION_REGISTRY.types() var idx := _type_option.selected if idx < 0 or idx >= types.size(): return "walk_to" return types[idx] func _on_type_selected(_index: int) -> void: if _current_type() == "speak": speech_requested.emit(_initial.duplicate(true)) return _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 _set_target_btn = null _prop_target_label = null _set_prop_target_btn = null _hand_option = null _stop_current_check = 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() "pick_up_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) _set_prop_target_btn = Button.new() _set_prop_target_btn.text = "🎯 Click prop…" _set_prop_target_btn.pressed.connect(func() -> void: target_requested.emit()) prop_row.add_child(_set_prop_target_btn) _update_prop_target_label() var hand_row := HBoxContainer.new() _params_box.add_child(hand_row) var hand_label := Label.new() hand_label.text = "Hand:" hand_row.add_child(hand_label) _hand_option = OptionButton.new() for label: String in ["Auto", "Left", "Right"]: _hand_option.add_item(label) var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto"))) _hand_option.select(maxi(hand_idx, 0)) hand_row.add_child(_hand_option) "throw_prop": var throw_row := HBoxContainer.new() _params_box.add_child(throw_row) _target_label = Label.new() _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL throw_row.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()) throw_row.add_child(_set_target_btn) _update_throw_target_label() "speak": var speech_label := Label.new() speech_label.text = "Opening the comic speech composer…" _params_box.add_child(speech_label) "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) _: # 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: _prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set") func _update_throw_target_label() -> void: if _target_label == null: return if _has_throw_target: _target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))] else: _target_label.text = "Target: not set" 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 "pick_up_prop": if _prop_target_id.is_empty(): target_requested.emit() return var hands: Array[String] = ["auto", "left", "right"] action["target_prop_id"] = _prop_target_id action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto" "throw_prop": if not _has_throw_target: target_requested.emit() return action["target_position"] = _throw_target_pos "speak": speech_requested.emit(_initial.duplicate(true)) return "wait": action["duration"] = _duration_spin.value if _duration_spin != null else 1.0 committed.emit(action)