Files

398 lines
12 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)