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
+252
View File
@@ -0,0 +1,252 @@
class_name ActionEditor
extends PopupPanel
## ActionEditor - Single-action property editor popup (Phase 3c).
##
## Used for adding a new action and for editing an existing one (queue or rule).
## The type dropdown and the param fields are generated from ActionRegistry, so
## new action types appear automatically. For `walk_to`, the target position is
## captured on the stage: the editor emits `target_requested()` and the stage
## hides the editor, captures a click, then calls `set_walk_target()`.
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
signal committed(action: Dictionary)
signal cancelled()
signal target_requested()
var _mode: String = "new"
var _initial: Dictionary = {}
var _type_option: OptionButton = null
var _params_box: VBoxContainer = null
var _text_edit: LineEdit = null
var _duration_spin: SpinBox = null
var _target_label: Label = null
var _set_target_btn: Button = null
var _has_target: bool = false
var _target_pos: Vector2 = Vector2.ZERO
## 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 editor in "add" mode (empty, default type walk_to).
func open_new() -> void:
_mode = "new"
_initial = {}
_has_target = false
_target_pos = Vector2.ZERO
_select_type("walk_to")
_rebuild_params("walk_to")
title = "Add Action"
popup_centered()
## Opens the editor pre-filled from a flat queue action.
func open_edit(action: Dictionary) -> void:
_mode = "edit"
_initial = action.duplicate(true)
var type := String(action.get("type", "walk_to"))
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_select_type(type)
_rebuild_params(type)
title = "Edit Action"
popup_centered()
## Called by the stage after a walk-target capture click.
func set_walk_target(pos: Vector2) -> void:
_has_target = true
_target_pos = pos
_update_walk_target_label()
popup_centered()
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)
var type_row := HBoxContainer.new()
vbox.add_child(type_row)
var type_label := Label.new()
type_label.text = "Type:"
type_row.add_child(type_label)
_type_option = OptionButton.new()
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for type: String in ACTION_REGISTRY.types():
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
_type_option.item_selected.connect(_on_type_selected)
type_row.add_child(_type_option)
_params_box = VBoxContainer.new()
_params_box.add_theme_constant_override("separation", 6)
vbox.add_child(_params_box)
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(340, 200)
## 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("action_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 := ACTION_REGISTRY.types().find(type)
if idx < 0:
idx = 0
_type_option.select(idx)
func _current_type() -> String:
var types := ACTION_REGISTRY.types()
var idx := _type_option.selected
if idx < 0 or idx >= types.size():
return "walk_to"
return types[idx]
func _on_type_selected(_index: int) -> void:
_rebuild_params(_current_type())
## Rebuilds the param controls for the given action type.
func _rebuild_params(type: String) -> void:
for child: Node in _params_box.get_children():
child.queue_free()
_text_edit = null
_duration_spin = null
_target_label = null
_set_target_btn = null
match type:
"walk_to":
var trow := HBoxContainer.new()
_params_box.add_child(trow)
_target_label = Label.new()
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
trow.add_child(_target_label)
_set_target_btn = Button.new()
_set_target_btn.text = "🎯 Click target…"
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
trow.add_child(_set_target_btn)
_update_walk_target_label()
"speak":
var tlabel := Label.new()
tlabel.text = "Text:"
_params_box.add_child(tlabel)
_text_edit = LineEdit.new()
_text_edit.placeholder_text = "Say something…"
_text_edit.text = String(_initial.get("text", ""))
_params_box.add_child(_text_edit)
var dlabel := Label.new()
dlabel.text = "Duration (s):"
_params_box.add_child(dlabel)
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
_params_box.add_child(_duration_spin)
"wait":
var wlabel := Label.new()
wlabel.text = "Duration (s):"
_params_box.add_child(wlabel)
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
_params_box.add_child(_duration_spin)
_:
# ragdoll / recover (and any future no-param action) need no fields.
pass
_apply_font_recursive(_params_box, _base_font_size)
func _make_duration_spin(value: float) -> SpinBox:
var spin := SpinBox.new()
spin.min_value = 0.1
spin.max_value = 3600.0
spin.step = 0.1
spin.value = value
spin.custom_minimum_size = Vector2(120.0, 0.0)
return spin
func _update_walk_target_label() -> void:
if _target_label == null:
return
if _has_target:
_target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))]
else:
_target_label.text = "Target: not set"
func _on_ok_pressed() -> void:
var type := _current_type()
var action: Dictionary = { "type": type }
match type:
"walk_to":
if not _has_target:
# Ask the stage to capture the target before committing.
target_requested.emit()
return
action["target"] = _target_pos
"speak":
action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
"wait":
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
committed.emit(action)
+1
View File
@@ -0,0 +1 @@
uid://d3nsgtnt1cvh5
+133
View File
@@ -0,0 +1,133 @@
class_name ActionRegistry
extends RefCounted
## ActionRegistry - Registry of director action templates (Phase 3c).
##
## Single source of truth for the action types the Director Tool and the event
## system understand. Adding a new action type is just appending an entry here;
## the ActionEditor and the panels generate their UI from this registry, so no
## other code changes are required.
const ACTION_TEMPLATES := {
"walk_to": {
"label": "Walk To",
"icon": "🚶",
"params": [
{ "key": "target", "type": "position", "required": true },
],
},
"speak": {
"label": "Speak",
"icon": "💬",
"params": [
{ "key": "text", "type": "text", "required": true },
{ "key": "duration", "type": "float", "default": 2.0 },
],
},
"wait": {
"label": "Wait",
"icon": "",
"params": [
{ "key": "duration", "type": "float", "required": true },
],
},
"ragdoll": {
"label": "Ragdoll",
"icon": "💥",
"params": [],
},
"recover": {
"label": "Recover",
"icon": "🔄",
"params": [],
},
}
static func types() -> Array[String]:
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
# typed Array[String] so callers can store it in typed locals (see
# ActionEditor._current_type / RuleEditor._select_action_type).
var out: Array[String] = []
out.assign(ACTION_TEMPLATES.keys())
return out
static func has_type(type: String) -> bool:
return ACTION_TEMPLATES.has(type)
static func label(type: String) -> String:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return String(tpl.get("label", type))
static func icon(type: String) -> String:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return String(tpl.get("icon", ""))
## Converts a flat queue action into a rule-action shape (adds `target` + nests
## params). `target_id` is the acting stickman's instance id.
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
var params: Dictionary = {}
match String(action.get("type", "")):
"walk_to":
params["target"] = action.get("target", Vector2.ZERO)
"speak":
params["text"] = String(action.get("text", ""))
params["duration"] = float(action.get("duration", 2.0))
"wait":
params["duration"] = float(action.get("duration", 0.0))
return {
"type": String(action.get("type", "")),
"target": target_id,
"params": params,
}
## Converts a rule-action shape back into a flat queue action (drops `target`).
static func from_rule_action(rule_action: Dictionary) -> Dictionary:
var params: Dictionary = rule_action.get("params", {})
match String(rule_action.get("type", "")):
"walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
"speak":
return {
"type": "speak",
"text": String(params.get("text", "")),
"duration": float(params.get("duration", 2.0)),
}
"wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
_:
return { "type": String(rule_action.get("type", "")) }
## Human-readable one-line summary for both flat queue actions and rule-action
## shapes (the get() fallbacks tolerate either key layout).
static func summarize(action: Dictionary) -> String:
var type := String(action.get("type", ""))
var params: Dictionary = action.get("params", {})
match type:
"walk_to":
var t: Vector2 = action.get("target", params.get("target", Vector2.ZERO))
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
"speak":
var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0)))
return "Speak \"%s\" (%ss)" % [text, _fmt_duration(dur)]
"wait":
var wd := float(action.get("duration", params.get("duration", 0.0)))
return "Wait %ss" % _fmt_duration(wd)
"ragdoll":
return "Ragdoll"
"recover":
return "Recover"
_:
return type
static func _fmt_duration(v: float) -> String:
if v == roundf(v):
return str(int(v))
return str(v)
+1
View File
@@ -0,0 +1 @@
uid://cncpyuy6cymwp
+5 -11
View File
@@ -9,7 +9,7 @@ extends EditorScript
## pose (a fixed first keyframe can never match an arbitrary rest pose). The
## baked stand_up is kept as an authored reference / manual-play animation.
const STAND_UP_DURATION := 0.8
const STAND_UP_DURATION := 2.0
## IK target marker node paths (relative to the rig root), keyed by marker name.
const POSE_PATHS: Dictionary = {
@@ -58,24 +58,18 @@ func _run() -> void:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
_generate_walk_animation(anim_player, "walk_right", false)
_generate_walk_animation(anim_player, "walk_left", true)
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool) -> void:
var anim = Animation.new()
anim.length = 0.8
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
var profile_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(profile_track, ".:facing_profile")
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
anim.track_insert_key(profile_track, 0.0, profile_enum)
# 2. Keyframe positions
# Keyframe positions
var raw_tracks = {
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
+3
View File
@@ -254,6 +254,9 @@ func _spawn_rig() -> void:
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
return
rig.position = RIG_SPAWN_POSITION
# Manual recovery only ("Recover Now"): auto-recover is left off so the
# ragdoll stays down until the operator triggers it, matching sandbox PLAY.
rig.auto_recover = false
add_child(rig)
_rig = rig
rig.state_changed.connect(_on_rig_state_changed)
+286
View File
@@ -0,0 +1,286 @@
class_name QueuePanel
extends PopupPanel
## QueuePanel - Action Queue editor popup (Phase 3c.2).
##
## Shows all queued actions for one stickman, in order, with edit / delete /
## drag-reorder controls. Mutations go through the StickmanRig queue API
## (queue_action / remove_action / insert_action / clear_queue), which already
## emits `queue_changed` and redraws the director overlay. Editing and adding
## delegate to the stage via signals so this panel stays decoupled.
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
signal edit_requested(index: int)
signal delete_requested(index: int)
signal add_requested()
signal clear_requested()
var rig: StickmanRig = null
var _title_label: Label = null
var _list: VBoxContainer = null
var _empty_label: Label = null
var _rows: Array[PanelContainer] = []
## Drag-reorder state.
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()
## Attaches a rig and rebuilds the row list.
func setup(r: StickmanRig) -> void:
rig = r
refresh()
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
var queue: Array[Dictionary] = []
if rig != null and is_instance_valid(rig):
queue = rig.get_queue()
_title_label.text = "Stickman: %s" % (String(rig.name) if rig != null and is_instance_valid(rig) else "?")
_empty_label.visible = queue.is_empty()
for i: int in queue.size():
_rows.append(_make_row(i, queue[i]))
func _build_ui() -> void:
title = "Action Queue"
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 = "Stickman: ?"
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 = "Queue is empty."
_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 Action"
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(460, 360)
func _make_row(index: int, action: 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 summary := Label.new()
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
row.add_child(summary)
var edit := Button.new()
edit.text = ""
edit.tooltip_text = "Edit"
edit.pressed.connect(func() -> void: edit_requested.emit(index))
row.add_child(edit)
var remove := Button.new()
remove.text = ""
remove.tooltip_text = "Delete"
remove.pressed.connect(func() -> void: delete_requested.emit(index))
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. `sizes` carries the
## parsed per-widget size / title / row sizes, the panel-title bold flag, and the
## resolved bold/italic Font variants.
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("queue_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 _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:
return
_move_action(from, to)
refresh()
## Moves the action at `from` to `to` in the rig's queue (indices in the
## pre-removal space), using the existing remove/insert API.
func _move_action(from: int, to: int) -> void:
if rig == null or not is_instance_valid(rig):
return
var queue := rig.get_queue()
if from < 0 or from >= queue.size() or to < 0 or to >= queue.size():
return
var action: Dictionary = queue[from]
rig.remove_action(from)
var target := to
if to > from:
target -= 1
rig.insert_action(target, action)
+1
View File
@@ -0,0 +1 @@
uid://bkkscjlbhfmdq
+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)
+1
View File
@@ -0,0 +1 @@
uid://8hgf3j8xc6tr
+321
View File
@@ -0,0 +1,321 @@
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 "?"
+1
View File
@@ -0,0 +1 @@
uid://dmiropsijs8g2
+897 -32
View File
File diff suppressed because it is too large Load Diff
+72 -9
View File
@@ -45,11 +45,33 @@ var badge_radius: float = RULE_BADGE_RADIUS_PX
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
var emoji_font: Font = null
## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is
## realised via ui_font_bold / FontVariation; null falls back to ui_font.
var ui_font: Font = null
var bold_font: Font = null
var italic_font: Font = null
var rule_label_bold: bool = false
var badge_bold: bool = true
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
var rules: Array[Dictionary] = []
var _rule_hit_regions: Array[Dictionary] = []
## Phase 3c: the walk_to waypoint currently being visually edited (blinking
## highlight). Vector2.INF when none.
var _edit_waypoint: Vector2 = Vector2.INF
## Highlights a waypoint while its walk target is being re-placed on the stage.
func set_edit_waypoint(pos: Vector2) -> void:
_edit_waypoint = pos
mark_dirty()
func clear_edit_waypoint() -> void:
_edit_waypoint = Vector2.INF
mark_dirty()
func set_enabled(value: bool) -> void:
enabled = value
visible = value
@@ -68,6 +90,8 @@ func set_style(cfg: Dictionary) -> void:
badge_number_size = float(fonts.get("assignment_badge_font_size", NUMBER_FONT_SIZE_PX))
badge_radius = float(fonts.get("assignment_badge_radius", RULE_BADGE_RADIUS_PX))
rule_label_font_size = float(fonts.get("rule_label_font_size", RULE_LABEL_FONT_SIZE_PX))
rule_label_bold = bool(fonts.get("rule_label_bold", false))
badge_bold = bool(fonts.get("badge_bold", true))
mark_dirty()
@@ -88,18 +112,40 @@ func hit_test_rule(world_pos: Vector2) -> Dictionary:
## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing
## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss.
func hit_test_waypoint(world_pos: Vector2) -> Vector2:
var hit := hit_test_waypoint_action(world_pos)
if hit.is_empty():
return Vector2.INF
return hit["pos"]
## Like hit_test_waypoint but also returns the owning rig and queue index:
## {"rig": StickmanRig, "index": int, "pos": Vector2}, or {} on miss. Used by
## the Phase 3c waypoint context menu to locate the exact walk action.
func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
var radius := WAYPOINT_HIT_RADIUS_PX / _zoom()
var best := Vector2.INF
var best: Dictionary = {}
var best_dist := radius
for wp: Vector2 in _collect_waypoints():
var d := world_pos.distance_to(wp)
if d <= best_dist:
best_dist = d
best = wp
for rig: StickmanRig in _collect_rigs():
var queue := rig.get_queue()
if queue.is_empty():
continue
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
var d := world_pos.distance_to(target)
if d <= best_dist:
best_dist = d
best = { "rig": rig, "index": i, "pos": target }
current = target
return best
func _process(_delta: float) -> void:
if _dirty:
# Redraw every frame while a waypoint is being edited (the blink is
# time-animated); otherwise only on the dirty flag.
if _dirty or (_edit_waypoint.is_finite() and enabled):
_dirty = false
queue_redraw()
@@ -163,6 +209,10 @@ func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
draw_circle(pos, radius, WAYPOINT_COLOR)
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
# Phase 3c: pulsing highlight ring while this waypoint is being edited.
if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5:
var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0)
draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true)
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
@@ -241,7 +291,7 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
# Label at the line midpoint on a dark rounded rect.
var summary := rule_summary(rule)
var mid := (trigger_anchor + action_anchor) * 0.5
var font := ThemeDB.fallback_font
var font := _rule_label_font()
var font_size := int(rule_label_font_size / zoom)
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var padding := Vector2(6.0, 4.0) / zoom
@@ -274,7 +324,20 @@ func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color)
## Badge glyph font: the configured emoji font when set, else the fallback font.
func _badge_font() -> Font:
return emoji_font if emoji_font != null else ThemeDB.fallback_font
if badge_bold and bold_font != null:
return bold_font
if emoji_font != null:
return emoji_font
return ThemeDB.fallback_font
## Rule-label font: bold variant when enabled, else ui_font, else fallback.
func _rule_label_font() -> Font:
if rule_label_bold and bold_font != null:
return bold_font
if ui_font != null:
return ui_font
return ThemeDB.fallback_font
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
+158 -22
View File
@@ -120,7 +120,7 @@ const REST_LINEAR_THRESHOLD := 5.0
const REST_ANGULAR_THRESHOLD := 0.1
## Duration of the stand-up tween (captured pose -> STAND_POSE).
const STAND_UP_DURATION := 0.8
const STAND_UP_DURATION := 2.0
## Extra hold after rest is detected before recovery captures the pose.
const STABILIZATION_DELAY := 0.1
@@ -169,6 +169,9 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
@@ -290,6 +293,8 @@ var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _torso_bone: Bone2D = null
var _head_bone: Bone2D = null
var _head_look_at: SkeletonModification2DLookAt = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
@@ -314,6 +319,8 @@ var _cached_angular_velocity: float = 0.0
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
@@ -369,6 +376,9 @@ func _ready() -> void:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if _torso_bone == null:
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if _head_bone == null:
push_warning("StickmanRig: missing 'Torso/Head' bone in Skeleton2D.")
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null:
@@ -381,6 +391,7 @@ func _ready() -> void:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
pass
else:
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
@@ -421,6 +432,25 @@ func _physics_process(delta: float) -> void:
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
_pin_mirrored_head_rotation()
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
## facing LEFT with the kinematic puppet live (ANIMATED or RECOVERING). The
## LookAt modification is disabled in that state (_apply_head_lookat_mirror_mode)
## so nothing else rewrites the bone; the per-frame pin covers recovery, where
## the IK stack is re-enabled mid-tween.
func _pin_mirrored_head_rotation() -> void:
if facing_profile != FacingProfile.LEFT or state == RigState.RAGDOLL:
return
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
if _head_look_at.enabled:
_head_look_at.enabled = false
if _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = 0
func _track_momentum(delta: float) -> void:
@@ -562,6 +592,7 @@ func snap_to_standing() -> void:
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
@@ -613,8 +644,12 @@ func _resolve_bend_modifications() -> void:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
_head_look_at = null
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
_head_look_at = mod as SkeletonModification2DLookAt
continue
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
@@ -638,21 +673,39 @@ func _apply_profile() -> void:
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
_apply_body_z_order()
_apply_head_flip()
# Whole-rig Y-axis mirror (spec §9a): facing LEFT mirrors the entire figure
# -- head + body + IK targets + mounted Body/* geometry -- so the head AND
# body face the correct direction together. RIGHT/FORWARD keep an identity
# scale. An X-mirror does not affect depth (draw order), so
# Z_ORDER_BY_PROFILE is unchanged.
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
_apply_head_lookat_mirror_mode()
facing_profile_changed.emit(int(facing_profile))
func _apply_head_flip() -> void:
var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D
var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D
if pivot != null:
var is_left := (facing_profile == FacingProfile.LEFT)
if is_left:
# Mirror local X and invert double the bone's rotation to mirror in world space
pivot.scale = Vector2(-1.0, 1.0)
pivot.rotation = -1.0 * head.rotation
else:
pivot.scale = Vector2(1.0, 1.0)
pivot.rotation = 0.0
## The SkeletonModification2DLookAt that aims the Head bone is NOT
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
## bone rotation 180 degrees off the FORWARD aim (PI -> 0 headless). Because the
## mounted head geometry is offset from Body/Head's local origin (the chin sits
## at the neck and the head extends away from it), that 180-degree error flips
## the head to hang BELOW the neck instead of sitting above it -- the whole-rig
## mirror then displaces the head ~2x its mount offset relative to the torso.
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
## FORWARD canonical aim rotation (PI), so the head is a rigid mirror of the
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
## because re-enabling the IK stack during recovery would otherwise let the
## LookAt flip the bone again.
func _apply_head_lookat_mirror_mode() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
var mirrored := facing_profile == FacingProfile.LEFT
_head_look_at.enabled = false#not mirrored
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = PI
elif not mirrored:
_head_bone.rotation = 0
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
@@ -717,6 +770,8 @@ func _enter_ragdoll() -> void:
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
@@ -726,17 +781,46 @@ func _capture_ragdoll_pose() -> void:
"rot": body.global_rotation - global_rotation,
"half": float(body.get_meta("half_height", 0.0)),
}
# Record the ragdoll torso's world center + its ground-contact line so
# recovery can re-anchor the root's feet onto wherever the ragdoll landed.
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = torso.global_position.y + RAGDOLL_TORSO_RADIUS
func _start_recovery() -> void:
if state != RigState.RAGDOLL:
return
_capture_ragdoll_pose()
_destroy_ragdoll()
_reanchor_root_to_landing()
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_play_stand_up()
## Re-anchors the rig root so the standing figure's feet sit on the ground at
## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not
## the rig), so the rig root never moved while it fell; without this the
## stand-up tween would drag the figure back to the root's pre-ragdoll world
## position.
func _reanchor_root_to_landing() -> void:
if not _captured_pose.has("torso"):
return
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
var old_root := global_position
var new_root := feet_world + FOOT_OFFSET
global_position = new_root
# Re-base the captured (rig-local) pose onto the re-anchored root so the
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one.
var shift := old_root - new_root
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + shift
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate.
func _cancel_recovery() -> void:
@@ -745,6 +829,59 @@ func _cancel_recovery() -> void:
_stand_up_tween = null
## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to
## resume solving. A disabled->enabled toggle alone can leave the stack's
## internal solve state stale (the classic Godot disable->enable quirk), so we
## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D
## processes internally (required for the stack to execute), and re-assert the
## Torso marker's RemoteTransform2D update flags so the hip bone keeps
## following IK_Targets/Torso through the stand-up tween.
func _rearm_ik_stack() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
if not stack.get_is_setup():
stack.setup()
stack.enabled = true
_skeleton.set_process_internal(true)
# Torso marker -> Torso bone driver: ensure it forwards position/rotation.
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
if driver != null:
driver.update_position = true
driver.update_rotation = true
driver.update_scale = true
_recovery_dbg()
## Logs the IK stack + bone-following state during recovery (off by default).
func _recovery_dbg() -> void:
if not DEBUG_RECOVERY:
return
var stack: SkeletonModificationStack2D = null
var stack_enabled := false
var stack_setup := false
var internal := false
if _skeleton != null and is_instance_valid(_skeleton):
stack = _skeleton.modification_stack
stack_enabled = stack != null and stack.enabled
stack_setup = stack != null and stack.get_is_setup()
internal = _skeleton.is_processing_internal()
var torso_bone_pos := Vector2.ZERO
if _torso_bone != null and is_instance_valid(_torso_bone):
torso_bone_pos = _torso_bone.global_position
var torso_marker := _get_ik_marker("Torso")
var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO
var limb := _bend_joint_bones.get("LeftLeg") as Bone2D
var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO
print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [
str(stack_enabled), str(stack_setup), str(internal),
str(torso_bone_pos), str(torso_marker_pos), str(limb_pos),
])
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
## markers (NOT the Torso Bone2D, which is slaved to its marker via
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
@@ -786,8 +923,7 @@ func _snap_skeleton_to_pose() -> void:
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
_rearm_ik_stack()
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
@@ -814,7 +950,7 @@ func _get_ik_marker(name: String) -> Marker2D:
## (sine ease-in-out). No baked animation — a fixed first keyframe can never
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
## snap left the markers.
func _play_stand_up() -> void:
func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
if _stand_up_tween != null:
_stand_up_tween.finished.connect(_on_stand_up_finished)
@@ -842,6 +978,7 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
func _on_stand_up_finished() -> void:
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
pass
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
@@ -1052,15 +1189,14 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
# Always play the canonical walk_right clip. For LEFT the rig root is
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
# walk_left is no longer used at runtime.
var anim_name := "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true
+71
View File
@@ -0,0 +1,71 @@
class_name TriggerRegistry
extends RefCounted
## TriggerRegistry - Registry of rule trigger templates (Phase 3c).
##
## Single source of truth for the trigger types the event system understands.
## Adding a new trigger type is just appending an entry here; the RuleEditor and
## the panels generate their UI from this registry.
const TRIGGER_TEMPLATES := {
"arrived_at_waypoint": {
"label": "Arrives at waypoint",
"icon": "📍",
"target_type": "waypoint",
},
"action_finished": {
"label": "Completes any action",
"icon": "",
"target_type": "action_type",
},
"speech_finished": {
"label": "Finishes speaking",
"icon": "💬",
"target_type": "none",
},
"entered_area": {
"label": "Enters trigger area",
"icon": "🎯",
"target_type": "area",
},
"collided": {
"label": "Collides with something",
"icon": "💥",
"target_type": "prop",
},
}
static func types() -> Array[String]:
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
# typed Array[String] so callers can store it in typed locals (see
# RuleEditor._current_type).
var out: Array[String] = []
out.assign(TRIGGER_TEMPLATES.keys())
return out
static func has_type(type: String) -> bool:
return TRIGGER_TEMPLATES.has(type)
static func label(type: String) -> String:
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
return String(tpl.get("label", type))
static func icon(type: String) -> String:
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
return String(tpl.get("icon", ""))
## Which kind of stage target a trigger needs: "waypoint", "action_type",
## "area", "prop", or "none".
static func target_type(type: String) -> String:
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
return String(tpl.get("target_type", "none"))
## Human-readable summary of a trigger, e.g. "Arrives at waypoint".
static func summarize(trigger: Dictionary) -> String:
var type := String(trigger.get("type", ""))
return "%s %s" % [icon(type), label(type)]
+1
View File
@@ -0,0 +1 @@
uid://cv1ng0wekc4gc
+35
View File
@@ -0,0 +1,35 @@
class_name WaypointContext
extends PopupMenu
## WaypointContext - Right-click context menu for a walk_to waypoint (Phase 3c.3).
##
## Provides Edit/Delete/Insert actions for the waypoint's walk action plus a
## "Edit Trigger Rules" entry (shown only when rules target this waypoint).
const EDIT_WALK := 0
const DELETE_WALK := 1
const INSERT_BEFORE := 2
const INSERT_AFTER := 3
const EDIT_TRIGGER_RULES := 4
const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after a separator at 4)
func _init() -> void:
# _init runs before the node is in the tree; item order is fixed here.
add_item("✎ Edit this Walk", EDIT_WALK)
add_item("✕ Delete this Walk", DELETE_WALK)
add_item("⬆ Insert action before", INSERT_BEFORE)
add_item("⬇ Insert action after", INSERT_AFTER)
add_separator()
add_item("⚡ Edit Trigger Rules", EDIT_TRIGGER_RULES)
## Updates the trigger-rules entry (count / enabled) and pops up at `rect`.
func popup_for(rect: Rect2i, trigger_rule_count: int) -> void:
if trigger_rule_count > 0:
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules (%d rule%s)" % [trigger_rule_count, "" if trigger_rule_count == 1 else "s"])
set_item_disabled(_EDIT_TRIGGER_IDX, false)
else:
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules")
set_item_disabled(_EDIT_TRIGGER_IDX, true)
popup(rect)
+1
View File
@@ -0,0 +1 @@
uid://chrtaky4j2w25