Files

322 lines
9.6 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 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 "?"