class_name RuleEditor extends PopupPanel ## RuleEditor - Rule property editor popup (Phase 3c). ## ## Two modes: ## * "full" - trigger type + target + actions are all editable. ## * "consequence" - trigger is read-only; only the actions are editable. ## ## Trigger targets and action actors are captured on the stage via the ## `trigger_target_requested` / `action_add_requested` / `action_edit_requested` ## signals; the stage hides this popup, captures a click, then calls ## `set_trigger_target()` / `set_action()` and re-pops it. const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd") signal committed(rule: Dictionary) signal cancelled() signal trigger_target_requested(trigger_type: String) signal action_add_requested() signal action_edit_requested(index: int) var _mode: String = "full" var _rule_id: int = -1 var _trigger: Dictionary = {} var _actions: Array[Dictionary] = [] var _trigger_box: VBoxContainer = null var _type_option: OptionButton = null var _target_row: HBoxContainer = null var _target_label: Label = null var _set_target_btn: Button = null var _action_type_option: OptionButton = null var _trigger_readonly_label: Label = null var _actions_box: VBoxContainer = null var _empty_actions_label: Label = 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 full editor pre-filled from a stored rule. func open_full(rule: Dictionary) -> void: _mode = "full" _load_rule(rule) title = "Edit Rule" popup_centered() ## Opens the consequence-only editor (trigger read-only) from a stored rule. func open_consequence(rule: Dictionary) -> void: _mode = "consequence" _load_rule(rule) title = "Edit Rule" popup_centered() ## Called by the stage after a trigger-target capture click. `target_id` is the ## instance id (or -1 for waypoint triggers), `params` holds type-specific data. func set_trigger_target(target_id: int, params: Dictionary) -> void: _trigger["target"] = target_id _trigger["params"] = params _refresh_trigger() popup_centered() ## Called by the stage after an action is collected. `index` < 0 appends. func set_action(index: int, action: Dictionary) -> void: if index < 0: _actions.append(action) else: _actions[index] = action _refresh_actions() popup_centered() ## Returns the rule-action at `index` (or {} when out of range). Used by the ## stage to pre-fill the ActionEditor when editing an existing rule action. func get_action(index: int) -> Dictionary: if index < 0 or index >= _actions.size(): return {} return _actions[index].duplicate(true) func _load_rule(rule: Dictionary) -> void: _rule_id = int(rule.get("id", -1)) _trigger = (rule.get("trigger", {}) as Dictionary).duplicate(true) _actions.clear() for a: Dictionary in rule.get("actions", []): _actions.append(a.duplicate(true)) var type := String(_trigger.get("type", "arrived_at_waypoint")) if not TRIGGER_REGISTRY.has_type(type): type = "arrived_at_waypoint" _select_type(type) _refresh_trigger() _refresh_actions() 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) # Trigger section. var trigger_header := Label.new() trigger_header.text = "Trigger:" vbox.add_child(trigger_header) _trigger_box = VBoxContainer.new() _trigger_box.add_theme_constant_override("separation", 6) vbox.add_child(_trigger_box) _trigger_readonly_label = Label.new() _trigger_readonly_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _trigger_box.add_child(_trigger_readonly_label) var type_row := HBoxContainer.new() _trigger_box.add_child(type_row) var type_label := Label.new() type_label.text = "When:" type_row.add_child(type_label) _type_option = OptionButton.new() _type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL for type: String in TRIGGER_REGISTRY.types(): _type_option.add_item("%s %s" % [TRIGGER_REGISTRY.icon(type), TRIGGER_REGISTRY.label(type)]) _type_option.item_selected.connect(_on_type_selected) type_row.add_child(_type_option) _target_row = HBoxContainer.new() _trigger_box.add_child(_target_row) _target_label = Label.new() _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _target_row.add_child(_target_label) _set_target_btn = Button.new() _set_target_btn.text = "🎯 Click target…" _set_target_btn.pressed.connect(_on_set_trigger_target) _target_row.add_child(_set_target_btn) _action_type_option = OptionButton.new() _action_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL _action_type_option.add_item("Any action", 0) for type: String in ACTION_REGISTRY.types(): _action_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)]) _target_row.add_child(_action_type_option) # Actions section. var actions_header := Label.new() actions_header.text = "Actions:" vbox.add_child(actions_header) _actions_box = VBoxContainer.new() _actions_box.add_theme_constant_override("separation", 4) vbox.add_child(_actions_box) _empty_actions_label = Label.new() _empty_actions_label.text = "No actions yet." _empty_actions_label.modulate = Color(1.0, 1.0, 1.0, 0.5) _actions_box.add_child(_empty_actions_label) var add_action := Button.new() add_action.text = "➕ Add Action" add_action.pressed.connect(func() -> void: action_add_requested.emit()) vbox.add_child(add_action) # Footer buttons. 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(420, 320) ## 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("rule_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 := TRIGGER_REGISTRY.types().find(type) if idx < 0: idx = 0 _type_option.select(idx) func _current_type() -> String: var types := TRIGGER_REGISTRY.types() var idx := _type_option.selected if idx < 0 or idx >= types.size(): return "arrived_at_waypoint" return types[idx] func _on_type_selected(_index: int) -> void: var type := _current_type() _trigger["type"] = type _trigger["target"] = -1 _trigger["params"] = {} _refresh_trigger() func _on_set_trigger_target() -> void: trigger_target_requested.emit(_current_type()) ## Refreshes the trigger controls to match the current mode + type. func _refresh_trigger() -> void: if _type_option == null: return var type := String(_trigger.get("type", "arrived_at_waypoint")) var full: bool = _mode == "full" _trigger_readonly_label.visible = not full _type_option.visible = full _target_row.visible = full if not full: _trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)] return # Show only the target control relevant to this trigger type. _set_target_btn.visible = false _action_type_option.visible = false _target_label.text = "" match TRIGGER_REGISTRY.target_type(type): "waypoint": var params: Dictionary = _trigger.get("params", {}) var wp: Vector2 = params.get("waypoint_pos", Vector2.INF) _target_label.text = "Target: waypoint (%d, %d)" % [int(roundf(wp.x)), int(roundf(wp.y))] if wp.is_finite() else "Target: waypoint (not set)" _set_target_btn.visible = true "action_type": var ap: Dictionary = _trigger.get("params", {}) var want := String(ap.get("action_type", "")) _action_type_option.visible = true _select_action_type(want) "area": _target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1))) _set_target_btn.visible = true "prop": _target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1))) _set_target_btn.visible = true _: pass func _select_action_type(want: String) -> void: if want.is_empty(): _action_type_option.select(0) return var types := ACTION_REGISTRY.types() var idx := types.find(want) _action_type_option.select(idx + 1 if idx >= 0 else 0) ## Reads the action_type dropdown value back into the trigger params. func _sync_action_type() -> void: var sel := _action_type_option.selected var params: Dictionary = _trigger.get("params", {}) if sel <= 0: params.erase("action_type") else: var types := ACTION_REGISTRY.types() if sel - 1 < types.size(): params["action_type"] = types[sel - 1] _trigger["params"] = params func _refresh_actions() -> void: if _actions_box == null: return # Remove previous rows, keeping the empty-state label. for child: Node in _actions_box.get_children(): if child != _empty_actions_label: child.queue_free() _empty_actions_label.visible = _actions.is_empty() for i: int in _actions.size(): _actions_box.add_child(_make_action_row(i, _actions[i])) _apply_font_recursive(_actions_box, _base_font_size) func _make_action_row(index: int, action: Dictionary) -> Control: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) var number := Label.new() number.text = str(index + 1) row.add_child(number) var summary := Label.new() summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL summary.text = "%s %s %s" % [ ACTION_REGISTRY.icon(String(action.get("type", ""))), _actor_name(int(action.get("target", -1))), ACTION_REGISTRY.summarize(action), ] row.add_child(summary) var edit := Button.new() edit.text = "✎" edit.pressed.connect(func() -> void: action_edit_requested.emit(index)) row.add_child(edit) var remove := Button.new() remove.text = "✕" remove.pressed.connect(func() -> void: _remove_action(index)) row.add_child(remove) return row func _remove_action(index: int) -> void: if index < 0 or index >= _actions.size(): return _actions.remove_at(index) _refresh_actions() func _on_ok_pressed() -> void: if _mode == "full": if _current_type() == "action_finished": _sync_action_type() _trigger["type"] = _current_type() var rule := { "id": _rule_id, "trigger": _trigger.duplicate(true), "actions": _actions.duplicate(true), } committed.emit(rule) func _actor_name(id: int) -> String: if id <= 0: # -1 is the "no source / no actor" sentinel; instance_from_id(-1) would # spam an engine error, so resolve the display name only for real ids. return "?" var node := instance_from_id(id) if node is Node and is_instance_valid(node): return String((node as Node).name) return "?" func _node_name(id: int) -> String: return _actor_name(id)