322 lines
9.6 KiB
GDScript
322 lines
9.6 KiB
GDScript
class_name RulePanel
|
||
extends PopupPanel
|
||
## RulePanel - Rule list editor popup (Phase 3c.4).
|
||
##
|
||
## Shows a list of rules (already filtered by the stage: by source stickman or
|
||
## by waypoint), with edit / delete / drag-reorder controls and Add Rule / Clear
|
||
## All. Mutations delegate to the stage via signals; reordering emits the new
|
||
## order of the *displayed* rules' ids, which the stage maps back onto its
|
||
## full `_event_rules` array (preserving un-filtered rules' positions).
|
||
|
||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
|
||
|
||
signal edit_requested(rule_id: int)
|
||
signal delete_requested(rule_id: int)
|
||
signal add_requested()
|
||
signal clear_requested()
|
||
signal reorder_requested(ordered_ids: Array[int])
|
||
|
||
var _title_label: Label = null
|
||
var _list: VBoxContainer = null
|
||
var _empty_label: Label = null
|
||
var _rows: Array[PanelContainer] = []
|
||
var _rules: Array[Dictionary] = []
|
||
|
||
var _drag_index: int = -1
|
||
var _drag_target: int = -1
|
||
|
||
## 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
|
||
var _row_font_size: int = 0
|
||
var _title_font_size: int = 0
|
||
var _title_font: Font = null
|
||
|
||
|
||
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:
|
||
hide()
|
||
|
||
|
||
## Updates the displayed rule list + title without popping up.
|
||
func set_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||
_rules = rules.duplicate(true)
|
||
_title_label.text = title_hint
|
||
refresh()
|
||
|
||
|
||
## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up.
|
||
func show_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||
set_rules(rules, title_hint)
|
||
popup_centered()
|
||
|
||
|
||
func refresh() -> void:
|
||
if _list == null:
|
||
return
|
||
for child: Node in _list.get_children():
|
||
if child != _empty_label:
|
||
child.queue_free()
|
||
_rows.clear()
|
||
_drag_index = -1
|
||
_drag_target = -1
|
||
_empty_label.visible = _rules.is_empty()
|
||
for i: int in _rules.size():
|
||
_rows.append(_make_row(i, _rules[i]))
|
||
|
||
|
||
func _build_ui() -> void:
|
||
title = "Rules"
|
||
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 title_bar := HBoxContainer.new()
|
||
vbox.add_child(title_bar)
|
||
_title_label = Label.new()
|
||
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_title_label.text = "Source: ?"
|
||
title_bar.add_child(_title_label)
|
||
var close_btn := Button.new()
|
||
close_btn.text = "× Close"
|
||
close_btn.pressed.connect(hide)
|
||
title_bar.add_child(close_btn)
|
||
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
scroll.custom_minimum_size = Vector2(0.0, 260.0)
|
||
vbox.add_child(scroll)
|
||
|
||
_list = VBoxContainer.new()
|
||
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_list.add_theme_constant_override("separation", 4)
|
||
scroll.add_child(_list)
|
||
|
||
_empty_label = Label.new()
|
||
_empty_label.text = "No rules."
|
||
_empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||
_list.add_child(_empty_label)
|
||
|
||
var footer := HBoxContainer.new()
|
||
footer.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(footer)
|
||
var add_btn := Button.new()
|
||
add_btn.text = "➕ Add Rule"
|
||
add_btn.pressed.connect(func() -> void: add_requested.emit())
|
||
footer.add_child(add_btn)
|
||
var clear_btn := Button.new()
|
||
clear_btn.text = "🗑 Clear All"
|
||
clear_btn.pressed.connect(func() -> void: clear_requested.emit())
|
||
footer.add_child(clear_btn)
|
||
|
||
min_size = Vector2i(520, 360)
|
||
|
||
|
||
func _make_row(index: int, rule: Dictionary) -> PanelContainer:
|
||
var panel := PanelContainer.new()
|
||
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 6)
|
||
panel.add_child(row)
|
||
|
||
var number := Label.new()
|
||
number.text = str(index + 1)
|
||
row.add_child(number)
|
||
|
||
var body := VBoxContainer.new()
|
||
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(body)
|
||
|
||
body.add_child(_make_label(_trigger_summary(rule)))
|
||
var actions := _action_summaries(rule)
|
||
if actions.is_empty():
|
||
body.add_child(_make_label("→ (no actions)", true))
|
||
else:
|
||
for text: String in actions:
|
||
body.add_child(_make_label(text, true))
|
||
|
||
var edit := Button.new()
|
||
edit.text = "✎"
|
||
edit.tooltip_text = "Edit"
|
||
edit.pressed.connect(func() -> void: edit_requested.emit(int(rule.get("id", -1))))
|
||
row.add_child(edit)
|
||
|
||
var remove := Button.new()
|
||
remove.text = "✕"
|
||
remove.tooltip_text = "Delete"
|
||
remove.pressed.connect(func() -> void: delete_requested.emit(int(rule.get("id", -1))))
|
||
row.add_child(remove)
|
||
|
||
var drag := Button.new()
|
||
drag.text = "≡"
|
||
drag.tooltip_text = "Drag to reorder"
|
||
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||
row.add_child(drag)
|
||
|
||
# Parent the row into the list and apply the theme font/size overrides. Without
|
||
# add_child the row never renders and drag-reorder has no geometry to work with.
|
||
_list.add_child(panel)
|
||
_apply_font_recursive(panel, _row_font_size)
|
||
return panel
|
||
|
||
|
||
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||
## the stage after add_child so the panel'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_panel", 18))
|
||
_row_font_size = int(sizes.get("panel_row", 16))
|
||
_title_font_size = int(sizes.get("panel_title", 18))
|
||
var title_bold := bool(sizes.get("panel_title_bold", true))
|
||
var bold_font: Font = sizes.get("bold_font", null)
|
||
_title_font = bold_font if title_bold and bold_font != null else ui_font
|
||
_apply_font_recursive(self, _base_font_size)
|
||
if _title_label != null:
|
||
_apply_font_to(_title_label, _title_font_size, _title_font)
|
||
if _empty_label != null:
|
||
_apply_font_to(_empty_label, _row_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, font: Font = null) -> void:
|
||
if c == null:
|
||
return
|
||
if font != null:
|
||
c.add_theme_font_override("font", font)
|
||
elif _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 _make_label(text: String, dim: bool = false) -> Label:
|
||
var lbl := Label.new()
|
||
lbl.text = text
|
||
if dim:
|
||
lbl.modulate = Color(1.0, 1.0, 1.0, 0.7)
|
||
return lbl
|
||
|
||
|
||
func _trigger_summary(rule: Dictionary) -> String:
|
||
var trigger: Dictionary = rule.get("trigger", {})
|
||
var type := String(trigger.get("type", ""))
|
||
return "%s %s %s" % [
|
||
TRIGGER_REGISTRY.icon(type),
|
||
_actor_name(int(trigger.get("source", -1))),
|
||
TRIGGER_REGISTRY.label(type),
|
||
]
|
||
|
||
|
||
func _action_summaries(rule: Dictionary) -> Array[String]:
|
||
var out: Array[String] = []
|
||
for a: Dictionary in rule.get("actions", []):
|
||
out.append("→ %s %s %s" % [
|
||
ACTION_REGISTRY.icon(String(a.get("type", ""))),
|
||
_actor_name(int(a.get("target", -1))),
|
||
ACTION_REGISTRY.summarize(a),
|
||
])
|
||
return out
|
||
|
||
|
||
func _row_style(bg: Color) -> StyleBoxFlat:
|
||
var sb := StyleBoxFlat.new()
|
||
sb.bg_color = bg
|
||
sb.set_corner_radius_all(4)
|
||
sb.content_margin_left = 6.0
|
||
sb.content_margin_right = 6.0
|
||
sb.content_margin_top = 2.0
|
||
sb.content_margin_bottom = 2.0
|
||
return sb
|
||
|
||
|
||
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||
if (event as InputEventMouseButton).pressed:
|
||
_drag_index = index
|
||
_drag_target = index
|
||
_refresh_drag_highlight()
|
||
else:
|
||
_commit_drag()
|
||
elif event is InputEventMouseMotion and _drag_index >= 0:
|
||
_update_drag_target()
|
||
|
||
|
||
func _update_drag_target() -> void:
|
||
var mouse_y := _list.get_local_mouse_position().y
|
||
var best := _drag_target
|
||
var best_d := INF
|
||
for i: int in _rows.size():
|
||
var row := _rows[i]
|
||
var d := absf((row.position.y + row.size.y * 0.5) - mouse_y)
|
||
if d < best_d:
|
||
best_d = d
|
||
best = i
|
||
if best != _drag_target:
|
||
_drag_target = best
|
||
_refresh_drag_highlight()
|
||
|
||
|
||
func _refresh_drag_highlight() -> void:
|
||
for i: int in _rows.size():
|
||
var bg := Color(0.0, 0.0, 0.0, 0.0)
|
||
if _drag_index >= 0 and i == _drag_target:
|
||
bg = Color(0.15, 0.4, 0.9, 0.4)
|
||
(_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg))
|
||
|
||
|
||
func _commit_drag() -> void:
|
||
var from := _drag_index
|
||
var to := _drag_target
|
||
_drag_index = -1
|
||
_drag_target = -1
|
||
_refresh_drag_highlight()
|
||
if from < 0 or to < 0 or from == to or from >= _rules.size() or to >= _rules.size():
|
||
return
|
||
var moved: Dictionary = _rules[from]
|
||
_rules.remove_at(from)
|
||
_rules.insert(to, moved)
|
||
var ordered: Array[int] = []
|
||
for r: Dictionary in _rules:
|
||
ordered.append(int(r.get("id", -1)))
|
||
refresh()
|
||
reorder_requested.emit(ordered)
|
||
|
||
|
||
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 "?"
|