Add unique identifier for test_phase3c_walk_recovery.gd
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3nsgtnt1cvh5
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cncpyuy6cymwp
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkkscjlbhfmdq
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8hgf3j8xc6tr
|
||||
@@ -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 "?"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmiropsijs8g2
|
||||
+897
-32
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cv1ng0wekc4gc
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://chrtaky4j2w25
|
||||
Reference in New Issue
Block a user