Add unique identifier for test_phase3c_walk_recovery.gd

This commit is contained in:
2026-09-06 13:09:37 -04:00
parent 1f91f3d2e5
commit 80595a0273
53 changed files with 6806 additions and 471 deletions
+397
View File
@@ -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)