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() 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 _has_target: bool = false var _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() -> void: _mode = "new" _initial = {} _has_target = false _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) -> void: _mode = "edit" _initial = action.duplicate(true) var type := String(action.get("type", "walk_to")) _has_target = action.has("target") and action["target"] is Vector2 _target_pos = action.get("target", Vector2.ZERO) if _has_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 _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_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: _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 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) "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 _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 "wait": action["duration"] = _duration_spin.value if _duration_spin != null else 1.0 committed.emit(action)