Add unique identifier for test_phase3c_walk_recovery.gd
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user