- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions. - Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates. - Add terrain drag-painting tests to verify correct block placement behavior and conflict handling. - Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
32 KiB
Phase 3c — Editor Tools: Action & Rule Editing (Spec)
Status: SPEC (pending implementation)
Related plan: plans/PHASE_3c_EDITOR.md
Target: Godot 4.7 (project.godot:19 declares config/features=PackedStringArray("4.7", "Forward Plus")).
1. Overview & Scope
Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules
fully editable (edit / delete / reorder / insert), replacing the current
append-only + create/delete-only behavior. It is built on two registry-driven tables
(action + trigger) so the UI is generated from data rather than hard-coded match
statements, matching the existing StageSpawner._registry / PropLibrary patterns.
In scope
- Action + trigger registries (
action_registry.gd,trigger_registry.gd). - Action Queue Panel — view / edit / delete / reorder / add / clear a stickman's queue.
- Action visual editing — waypoint right-click context menu (edit walk / delete walk / insert before / insert after).
- Rule Panel — view / edit / delete / reorder / add / clear rules (filtered by source stickman).
- Rule Editor — full edit (trigger type + target + actions) and consequence-only edit (actions only, trigger read-only).
- Rule visual editing — rule-label click → consequence-only editor (already partially
present via
_begin_edit_rule); waypoint → "Edit Trigger Rules".
Out of scope / untouched (must not regress)
- StickmanRig runner execution semantics — the action runner (
_begin_action,_update_runner, the 5 action phases) is not changed. Editing mutates queue/rule data; the runner already consumes any of those shapes. - Event engine matching (
_rule_matches,_update_area_entry,_update_stickman_prop_collision) — unchanged. Rule reordering changes evaluation order (array order) but not the per-rule matching logic. - Navigation / walk steering (
_update_walking, mode latch) — unchanged. - Terrain drag-painting, selection, gizmos, placement ghost, asset selector — unchanged.
- No
.stk/settings.jsonformat changes. Queues and rules remain session-only (persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the Phase 3a/4 decision. - No new action types / trigger types are implemented (only the machinery to add them
cleanly). The registry is the extension point; wiring a genuinely new action still requires
a runner case in
StickmanRig._begin_action(see §6 and §13).
2. Recorded Decisions
- Registries are static tables, not singletons.
action_registry.gd/trigger_registry.gdareclass_name-less-optional,RefCountedscripts exposingstaticconst tables +static funcaccessors (mirroringPropLibrary).sandbox_stage.gdpreloads them like the otherpreloadconsts. No autoload, no instance state. - All new UI is code-built; no
.tscnfiles. The plan listsqueue_panel.tscn,rule_panel.tscn,action_editor.tscn,rule_editor.tscn. The codebase builds all stage UI in code inside_build_ui()(top bar, popups, dialogs); onlyAssetSelectoruses a.tscnshell, and it has authored content. These panels/editors are dynamic (row lists change every mutation), so they arePopupPanel-based controller scripts constructed in code. Dropped:action_editor.gd/action_editor.tscn(action param editing reuses the existing_speak_dialog/_wait_dialog+ pending-target machinery) andwaypoint_context.gd(the waypoint menu is a code-builtPopupMenu, exactly like_trigger_popup). See §7 file list. - Reorder UX = Move Up / Move Down buttons, not drag. A
[≡]drag handle in aPopupPanelrequires hand-rolled_gui_inputdrag/reorder hit-testing; up/down buttons are simpler, keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable headlessly. Each row shows⬆/⬇(disabled at the ends) plus✎/✕. Drag reorder is logged as deferred (tech debt §13). - Waypoint → action mapping returns
(rig, index, pos), not just a position.StageDirectorVisuals.hit_test_waypoint()currently returns only the nearestVector2(used by the rule builder forarrived_at_waypoint). A newhit_test_waypoint_action()returns{rig, index, pos}so the context menu knows which rig's queue and whichwalk_toaction index to edit/delete/insert around. The existing position-only method is kept for the rule builder (rules reference waypoint positions, not indices). - Insert before/after maps directly to
StickmanRig.insert_action(index, action). "Before waypoint i" →insert_action(i, action); "after" →insert_action(i + 1, action).iis the queue index of thewalk_toaction (equal to the waypoint ordinal − 1 becausewalk_tois the only waypoint-producing action).insert_actionalready clamps to[0, size]. - Edit-walk visual flow reuses the pending target-capture machinery. A new edit state
(
_pending_walk_edit_index >= 0) reuses_pending_walk_target's cursor/hint/trajectory and the_handle_direct_click/_handle_world_clickrouting; the next stage click replaces the existingwalk_to.targetinstead of appending. The edited waypoint is highlighted in the director visuals. - Rule reorder = array order;
idis immutable and preserved._event_rulesis iterated in order by_handle_event(all matching rules execute — no short-circuit). Reordering swaps array entries; editing preservesid(already the case in_finalize_rule)._next_rule_idstays monotonic (no id reuse on delete). - Rule Panel filter =
trigger.source. "Edit Rules" on a stickman shows rules whosetrigger.sourceequals that stickman's instance id. The waypoint "Edit Trigger Rules" filter matches byparams.waypoint_posproximity (WAYPOINT_MATCH_EPSILON), because rules store waypoint positions, not queue indices. - Delete confirmation policy (deliberately asymmetric). Panel-initiated
✕deletes and "Clear All" (queue and rules) confirm viaConfirmationDialog. The on-stage rule-label✕and the waypoint "Delete this Walk" stay immediate (current low-friction behavior, not regressed). Documented in §13. - Ragdoll/Recover have no editable parameters. Their
✎is hidden in the panel and their "edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens confirmation dialog" is corrected — there is nothing to edit.) - The full rule editor drives the existing
RuleStepstate machine. Trigger-type change re-entersSELECT_TRIGGER; trigger-target change re-entersTRIGGER_TARGET; "Add Action" uses the existingSELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-moreflow. The editor panel is a view;sandbox_stage.gdremains the controller owning_rule_builder/_rule_step/_event_rules.
3. Discrepancies: Plan vs. Actual Code
| # | Plan claim | Reality (verified) | Resolution |
|---|---|---|---|
| 1 | Panels/editors are new .tscn scenes (queue_panel.tscn, rule_panel.tscn, action_editor.tscn, rule_editor.tscn, waypoint_context.gd). |
Stage UI is built entirely in code (_build_ui); only AssetSelector has a .tscn shell. |
Code-built PopupPanel scripts (§2.2); drop action_editor.gd + waypoint_context.gd (§2.2). |
| 2 | Registry params with key/type/default drives the editor generically. |
Action data lives in two shapes: queue action (walk_to.target top-level; speak.text/.duration top-level; wait.duration top-level) vs. rule action ({type, target, params:{...}}). No generic param model exists. |
Registry describes logical params; get_fields/make_action normalize the two shapes (§5.3). |
| 3 | "New action type = append to registry; no code changes" (§10.1, §11.8). | StickmanRig._begin_action hard-codes the 5 types; a new type also needs a runner case + _action_for_rig + _action_desc. |
Corrected: registry removes UI changes; runner changes still required (§6, §12.7). |
| 4 | hit_test_waypoint() returns an index. |
It returns only Vector2 (nearest waypoint position); no rig/index identity. |
Add hit_test_waypoint_action() returning {rig, index, pos} (§2.4, §9.2). |
| 5 | "Rule label click → consequence-only edit already exists" (implied complete). | _begin_edit_rule(id) pre-populates and jumps straight to the add-action popup (SELECT_ACTION), preserving the trigger but offering no actions list / remove / edit and no trigger readout UI. |
New RuleEditor (consequence-only mode) formalizes this; _begin_edit_rule routes to it (§11.4). |
| 6 | action_finished trigger has a target_type: "action_type" (registry). |
The builder does not expose an action-type selector for action_finished (only "completes any action"); _rule_matches reads optional params.action_type. |
Registry marks action_type as optional; the full editor exposes it only if cheap — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13). |
| 7 | Rule Panel "shows all rules for a source stickman" and reorders. | _event_rules is a flat stage-level array (no per-rig grouping); source is trigger.source (instance id). |
Filter by trigger.source (§2.8); reorder operates on the flat array (§2.7). |
| 8 | §11 acceptance criteria formatting: stray backticks and - prefixes. |
Cosmetic markdown errors in the plan. | Rewritten cleanly in §12. |
| 9 | Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." | Ragdoll/recover are param-less; editing is meaningless. | Corrected (§2.10). |
| 10 | Plan §11.8 "unknown keys displayed as read-only / editable." | No generic editor exists; the runner ignores unknown keys. | Corrected: unknown keys are preserved (round-tripped) on edit, never dropped; no generic widget (§12.7). |
| 11 | Waypoint context menu / stickman right-click are new. | Right-click is currently fully consumed by the EDIT placement-cancel branch (_handle_world_click returns on RMB). |
Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1). |
| 12 | Godot "4.4" (task prompt). | project.godot:19 and the test runner reference 4.7. |
Spec targets 4.7. |
4. New Files
| File | class_name / extends |
Responsibility |
|---|---|---|
res://scripts/action_registry.gd |
ActionRegistry / RefCounted |
Static action template table + static helpers (labels/icons/params/describe/normalize). |
res://scripts/trigger_registry.gd |
TriggerRegistry / RefCounted |
Static trigger template table + static helpers. |
res://scripts/queue_panel.gd |
QueuePanel / PopupPanel |
Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals. |
res://scripts/rule_panel.gd |
RulePanel / PopupPanel |
Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals. |
res://scripts/rule_editor.gd |
RuleEditor / PopupPanel |
Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals. |
No .tscn files are added (decision 2). action_editor.gd and waypoint_context.gd from the
plan are not created — their responsibilities are absorbed into sandbox_stage.gd (existing
dialogs + a code-built PopupMenu).
5. Data Contracts
5.1 Queue action dict (consumed by StickmanRig runner — top-level keys)
{ "type": "walk_to", "target": Vector2 } # target = feet/ground world position
{ "type": "speak", "text": String, "duration": float }
{ "type": "wait", "duration": float }
{ "type": "ragdoll" }
{ "type": "recover" }
# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected)
5.2 Rule dict (stored in SandboxStage._event_rules)
{
"id": int, # immutable, monotonic from _next_rule_id
"trigger": {
"type": String, # arrived_at_waypoint | action_finished |
# speech_finished | entered_area | collided
"source": int, # instance id of the triggering stickman
"target": int, # instance id (entered_area area | collided prop);
# -1 otherwise
"params": {
# arrived_at_waypoint -> { "waypoint_pos": Vector2 }
# action_finished -> { "action_type": String } (optional; "" == any)
# else -> {}
},
},
"actions": [ { "type": String, "target": int, "params": {...} } ],
}
Rule action params:
walk_to -> { "target": Vector2 } # destination (the action's `target` = walking stickman id)
speak -> { "text": String, "duration": float }
wait -> { "duration": float }
ragdoll / recover -> {}
Key asymmetry (documented): in a queue action
walk_to,targetis the destination. In a rule action,targetis the stickman instance id and the destination isparams.target. The registry normalizes this viaget_fields/make_action(§5.3).
5.3 ActionRegistry API (action_registry.gd)
static func get_types() -> Array[String]
# ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order)
static func get_label(type: String) -> String # "Walk To", "Speak", "Wait", "Ragdoll", "Recover"
static func get_icon(type: String) -> String # "🚶","💬","⏳","💥","🔄"
static func get_params(type: String) -> Array[Dictionary]
# [{ "key": "target", "kind": "position", "required": true }]
# [{ "key": "text", "kind": "text", "required": true },
# { "key": "duration", "kind": "float", "default": 2.0 }]
# [{ "key": "duration", "kind": "float", "required": true }]
# [] for ragdoll/recover
static func has_params(type: String) -> bool
static func get_fields(action: Dictionary) -> Dictionary
# reads each param key from action top-level, falling back to action.params
# (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v})
static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary
# for_rule=false -> { "type": type, ...top-level keys }
# for_rule=true -> { "type": type, "target": -1, "params": {...keys} }
static func describe(action: Dictionary) -> String
# "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers"
# (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home)
static func row_summary(action: Dictionary) -> String
# panel row text: "<icon> <label> <param summary>"
5.4 TriggerRegistry API (trigger_registry.gd)
static func get_types() -> Array[String]
# ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"]
static func get_label(type: String) -> String
static func get_icon(type: String) -> String
static func get_target_kind(type: String) -> String
# "waypoint" | "action_type" | "none" | "area" | "prop"
static func needs_target(type: String) -> bool
# true for arrived_at_waypoint (waypoint), entered_area (area), collided (prop)
static func describe(trigger: Dictionary) -> String
# "arrives", "completes an action", "finishes speaking", "enters area", "collides"
speech_finished(none) andaction_finished(action_type) are "no world target" triggers.action_finished's optionalparams.action_typefilter is not surfaced in the Phase 3c editor (the builder's current "completes any action" behavior is preserved) — deferred §13.
6. Extensibility Contract (corrected from plan §10)
- New action type = (1) append a template to
ActionRegistry(label/icon/params), (2) add a_begin_actioncase + (if it has params) a_action_for_rigmapping inStickmanRig, (3) add a params dialog if it has new param kinds. The popups/panels/rows then auto-generate. The runner case is not optional (plan §11.8 "no code changes" is false). - New trigger type = (1) append a template to
TriggerRegistry, (2) emit a matching event inSandboxStageand add a_rule_matchescase, (3) add a target-capture branch if it has a world target. UI auto-generates from the registry. - New action property = add a param entry + a widget; the editor must be taught the widget. Unknown keys on load/edit are preserved (round-tripped), never dropped.
7. File-by-File Change List
7.1 scripts/stickman_rig.gd (modified — additive only)
func move_action(from: int, to: int) -> void— bounds-check both (push_warning+ return on out-of-range), remove-at + insert-at (reusingremove_action/insert_actionsemantics but emittingqueue_changedonce). Used by reorder up/down.func replace_action(index: int, action: Dictionary) -> void— bounds-check,action_queue[index] = action, emitqueue_changed. Used by edit (speak/wait/walk-retarget/ragdoll/recover).
No runner-phase changes; get_queue(), clear_queue(), insert_action, remove_action,
enqueue_reactive, clear_reactive_actions are reused as-is.
7.2 scripts/stage_director_visuals.gd (modified — additive)
func _collect_waypoint_refs() -> Array[Dictionary]— mirror_collect_waypointsexactly but return{ "rig": StickmanRig, "index": int, "pos": Vector2 }(index = queue index of thewalk_toaction). Same world-child iteration order.func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary— nearest ref withinWAYPOINT_HIT_RADIUS_PX / _zoom(); returns{}on miss. Keepshit_test_waypoint()(still used by the rule builder).var edit_waypoint: Vector2 = Vector2.INF+func set_edit_waypoint(pos: Vector2) -> void(mark_dirty()). While finite,_draw_waypointdraws that dot with an accent outline/ring (blinking is optional — a static accent + thicker ring is sufficient for the acceptance test; a_process-driven blink is deferred). Cleared by the stage when edit-capture ends.
7.3 scripts/sandbox_stage.gd (modified — the controller)
New consts:
const ACT_EDIT_QUEUE := 7 # item ids added to _action_popup (after ACT_WHEN=5 / RULE_ACTION_DONE=6)
const ACT_EDIT_RULES := 8
const QUEUE_ADD_WALK := ACT_WALK # reuse 0..4 for the add-action popup
# ... (ACT_SPEAK/WAIT/RAGDOLL/RECOVER reused)
const WP_EDIT_WALK := 0
const WP_DELETE_WALK := 1
const WP_INSERT_BEFORE := 2
const WP_INSERT_AFTER := 3
const WP_EDIT_RULES := 4
const RIG_EDIT_QUEUE := 0
const RIG_EDIT_RULES := 1
New state:
var _pending_walk_edit_index: int = -1 # >=0 while retargeting an existing walk_to
var _editing_action_index: int = -1 # queue index being edited via speak/wait dialog
var _queue_add_context: StickmanRig = null # target rig for "Add Action" from the queue panel
var _waypoint_context: Dictionary = {} # {rig, index} for the open waypoint context menu
var _queue_panel: QueuePanel = null
var _rule_panel: RulePanel = null
var _rule_editor: RuleEditor = null
var _rig_context_popup: PopupMenu = null
var _waypoint_context_popup: PopupMenu = null
var _queue_add_popup: PopupMenu = null
var _confirm_dialog: ConfirmationDialog = null # shared, repurposed per action (with a pending closure)
var _confirm_action: Callable = Callable() # what to run on confirm
New signal wiring in _build_ui():
- Add
_rig_context_popup(PopupMenu: "📋 Edit Queue…", "📋 Edit Rules…"),_waypoint_context_popup(PopupMenu: "✎ Edit this Walk", "✕ Delete this Walk", "⬆ Insert action before", "⬇ Insert action after", "⚡ Edit Trigger Rules (N)"),_queue_add_popup(PopupMenu: the 5 action types), and a shared_confirmation_dialog. Apply_apply_popup_themeto the popups. - Append
_action_popup.add_separator()+add_item("📋 Edit Queue…", ACT_EDIT_QUEUE)+add_item("📋 Edit Rules…", ACT_EDIT_RULES). - Instantiate
_queue_panel,_rule_panel,_rule_editor(code-built; add to theuiCanvasLayer) and connect their signals (below). Apply_ui_font/_emoji_fontvia theirapply_fontmethods.
Handlers (new/changed):
_handle_world_clickright-click branch (before placement-cancel): when_rule_step == IDLEand mode is EDIT or DIRECT, hit-test waypoint →_waypoint_context_popup; else_selection.hit_testaStickmanRig→_rig_context_popup. Both record_popup_anchorfrom the click's screen rect._on_action_popup_id_pressed: newACT_EDIT_QUEUE/ACT_EDIT_RULEScases → open panels for_context_rig.- Queue-panel signal handlers:
_queue_edit(index),_queue_delete(index)(confirm),_queue_move(index, dir),_queue_add(),_queue_clear()(confirm). - Rule-panel signal handlers:
_rule_edit(id),_rule_delete(id)(confirm),_rule_move(index, dir),_rule_add(),_rules_clear()(confirm). - Waypoint-context handlers:
_waypoint_edit_walk(),_waypoint_delete_walk(),_waypoint_insert(before: bool),_waypoint_edit_rules(). - Rig-context handlers:
_rig_edit_queue(),_rig_edit_rules(). _pending_walk_edit_indexconsumed in_handle_direct_click(and the EDIT left-click path): when>= 0, replace_context_rig's queue action at that index with the newwalk_totarget (viareplace_action), clear the edit state +edit_waypoint, refresh cursor/status/visuals._move_rule(from: int, to: int)helper (bounds-check, swap,_director_visuals.set_rules)._open_rule_editor(id, consequence_only: bool)— populateRuleEditorand show;RuleEditordrives_rule_builder/_rule_stepthrough signals back to the stage.
Esc priority (_unhandled_key_input): insert _rule_editor/_queue_panel/_rule_panel close
and _pending_walk_edit_index cancel into the existing chain (above placement/selection clear).
8. New File Detail
8.1 scripts/queue_panel.gd — QueuePanel extends PopupPanel
Signals (all emitted to the stage, which mutates data):
signal edit_requested(index: int)
signal delete_requested(index: int)
signal move_requested(index: int, dir: int) # dir: -1 up, +1 down
signal add_requested()
signal clear_requested()
signal closed()
API:
func open_for(rig_name: String, queue: Array[Dictionary]) -> void # store + _rebuild + popup_centered()
func refresh(queue: Array[Dictionary]) -> void # re-render after a mutation
func apply_font(ui_font: Font, emoji_font: Font) -> void
Rows (one per action, in order): order number, ActionRegistry.row_summary(action), then
✎ (hidden for ragdoll/recover), ✕, ⬆, ⬇ (disabled at ends). Footer: [Add Action]
[Clear All] [Close]. exclusive = true; Esc → closed.emit(). Rebuilt on every refresh.
8.2 scripts/rule_panel.gd — RulePanel extends PopupPanel
Signals:
signal edit_requested(id: int)
signal delete_requested(id: int)
signal move_requested(index: int, dir: int)
signal add_requested()
signal clear_requested()
signal closed()
API:
func open_for(source_name: String, rules: Array[Dictionary]) -> void
func refresh(rules: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
Rows (one per rule): order number + StageDirectorVisuals.rule_summary(rule) (or
TriggerRegistry.describe + action summaries), then ✎, ✕, ⬆, ⬇. Footer:
[Add Rule] [Clear All] [Close].
8.3 scripts/rule_editor.gd — RuleEditor extends PopupPanel
Signals:
signal trigger_type_changed(type: String)
signal trigger_target_requested()
signal action_add_requested()
signal action_edit_requested(index: int)
signal action_remove_requested(index: int)
signal done_requested()
signal cancelled()
API:
func open_full(trigger: Dictionary, actions: Array[Dictionary], trigger_types: Array[String]) -> void
func open_consequence(trigger_summary: String, actions: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
- Full mode: trigger
OptionButton(registry types) +[Set target…](hidden forspeech_finished/action_finished) + target readout; actions list (summary +✎✕);[Add Action];[Cancel][OK]. - Consequence-only mode: read-only trigger line; actions list;
[Add Action];[Done][Cancel].
9. UI Flows
9.1 Action Queue Panel
Entry: Direct-click stickman → action popup → "📋 Edit Queue…"; or right-click stickman →
"Edit Queue…". Panel lists the queue (order numbers, type, params). ✎ routes to the
existing edit machinery (§9.2); ✕/Clear All confirm; ⬆/⬇ call move_action;
[Add Action] opens _queue_add_popup (appends via the same per-type flow, targeting the
queue's rig).
9.2 Action editing (reuses existing dialogs)
- walk_to —
_pending_walk_edit_index = index;edit_waypointhighlights the dot; next stage click →replace_action(index, {type:"walk_to", target: new_pos}). - speak — pre-fill
_speak_edit.text/duration; confirm (with_editing_action_index >= 0) →replace_action(index, {type:"speak", text, duration}). - wait — pre-fill
_wait_spin.value; confirm →replace_action. - ragdoll/recover — no
✎; nothing to edit.
_on_speak_confirmed/_on_wait_confirmed gain an _editing_action_index >= 0 branch
(prefixed above the existing _rule_step == PARAMS branch).
9.3 Waypoint context menu (EDIT/DIRECT, right-click on a dot)
Items: ✎ Edit this Walk, ✕ Delete this Walk, ⬆ Insert action before, ⬇ Insert action after, ⚡ Edit Trigger Rules (N) (N = count of rules referencing that waypoint position; hidden
when 0). Edit → §9.2 walk flow; delete → remove_action(index); insert → _queue_add_popup then
insert_action(index | index+1, action); Edit Trigger Rules → RulePanel filtered by
params.waypoint_pos proximity.
9.4 Rule Panel
Entry: right-click stickman → "Edit Rules…" (filter trigger.source == rig id); or action popup
→ "Edit Rules…". Rows show rule_summary; ✎ → full RuleEditor; ✕/Clear All confirm;
⬆/⬇ → _move_rule; [Add Rule] → fresh build (ACT_WHEN flow, source = panel rig).
9.5 Rule Editor
Full (✎ in panel): trigger dropdown + target button + actions list (✎/✕) + [Add Action] +
[OK]. Consequence-only (rule-label click on stage): trigger read-only + actions list +
[Add Action] + [Done]. OK/Done → _finalize_rule() (preserves id); Cancel →
_cancel_rule_build().
10. Entry-Point Integration (existing code hooks)
- Action popup items appended in
_build_uiafter theACT_WHENseparator (_action_popupcurrently ends atACT_WHEN). - Right-click routing in
_handle_world_click(RMB branch) gains waypoint → stickman hit tests before the EDIT placement-cancel, gated on_rule_step == IDLEand mode != PLAY. - Rule-label click (
_handle_world_clickLMB path already calls_begin_edit_rule) now routes to_open_rule_editor(id, true)instead of the bare add-action popup. _clear_director_pendingalso resets_pending_walk_edit_index,_editing_action_index,edit_waypoint, and hides the new panels/popups (mode exit must cancel edit flows).
11. Acceptance Criteria (corrected from plan §11)
11.1 Action Queue Panel
- "Edit Queue…" opens the panel from the action popup and from stickman right-click.
- Panel lists every action in queue order with an order number + type + parameter summary.
✕deletes an action after a confirmation dialog.✎re-opens the action editor with values pre-filled (hidden for ragdoll/recover).⬆/⬇move an action (order numbers and waypoint connectors update; first/last disabled).- "Add Action" appends via the add-action popup.
- "Clear All" removes all actions after a confirmation dialog.
11.2 Edit Action
- walk_to: edit enters target-capture mode; clicking a new spot moves the waypoint (dot + dashed connector update; order numbers unchanged).
- speak: text + duration pre-filled; confirm updates the action + badge summary.
- wait: duration pre-filled; confirm updates.
- ragdoll/recover: no edit (param-less);
✎hidden.
11.3 Waypoint Context Menu
- Right-click a waypoint dot opens the context menu (EDIT and DIRECT).
- "Edit this Walk" enters target placement (highlighted dot) and moves the waypoint.
- "Delete this Walk" removes the walk action (immediate).
- "Insert action before/after" inserts at
index/index + 1(connectors reconnect, order numbers renumber).
11.4 Rule Panel
- "Edit Rules…" opens the panel filtered to rules whose
trigger.sourceis the stickman. - Each rule shows a summary (trigger verb + action summaries) + order number.
✕deletes after confirmation;⬆/⬇reorders; "Add Rule" starts the rule builder; "Clear All" confirms and clears.
11.5 Rule Editor
- Full: trigger type dropdown works; trigger target can be re-clicked; action list supports
add/remove; parameters (text/duration/walk target) editable;
OKupdates the rule preservingid. - Consequence-only: trigger read-only; actions editable;
Doneupdates the rule preservingid. - Visual connectors update immediately on save.
11.6 Visual Updates
- Waypoint dots move on walk edit; speech badge text + wait duration reflect edits; dashed connectors re-render after insert/delete/reorder; rule labels refresh; rule connectors re-target on trigger/action target edits.
11.7 Backward Compatibility
- Existing queues/rules load and display unchanged.
- Editing preserves action types and unknown keys; editing a rule preserves
id. - Deleting a rule/action leaves no dangling references (
_cleanup_rules_for_nodeson node delete already covers object deletion; queue/rule edits only mutate their own data).
11.8 Extensibility (corrected)
- New action UI requires only a registry entry; new action runtime still needs a
_begin_actioncase (documented in §6). - New trigger UI requires only a registry entry; new trigger runtime needs an event
emission +
_rule_matchescase. - Unknown action keys are preserved on edit (not dropped); no generic unknown-key widget.
12. Implementation Order (matches the 5 sub-phases)
- 3c.1 Foundation —
action_registry.gd+trigger_registry.gd;StickmanRig.move_action/replace_action;StageDirectorVisuals._collect_waypoint_refs/hit_test_waypoint_action/edit_waypoint. (No behavior change yet — existing popups may optionally read labels/icons from the registry, but the plan allows keeping the hard-coded popup strings until 3c.2+.) - 3c.2 Action Queue Panel —
queue_panel.gd;_action_popupitems;_rig_context_popup; queue edit/delete/move/add/clear handlers;_confirmation_dialog. - 3c.3 Action Visual Editing —
_waypoint_context_popup;_pending_walk_edit_index+edit_waypointhighlight; insert before/after; edit-walk target capture. - 3c.4 Rule Panel —
rule_panel.gd;rule_editor.gd(full + consequence-only);_move_rule; rewire_begin_edit_rule→ consequence-only editor; rule add/delete/clear. - 3c.5 Rule Visual Editing — waypoint "Edit Trigger Rules (N)" filter; waypoint→rule
proximity matching; final Esc-priority /
_clear_director_pendingcleanup.
Each sub-phase is independently testable via the headless SceneTree suite pattern
(tests/test_phase4b_*.gd: instantiate sandbox_stage.tscn, drive handlers directly, assert on
_event_rules / rig.get_queue() / popup visibility).
13. Tech-Debt / Deferred
- Drag reorder — up/down buttons chosen over
[≡]drag (decision 3). Revisit if a touch/pointer drag reorder is wanted. - Generic param-field auto-generation —
ActionRegistry.get_paramsdescribes params, but widgets (LineEdit/SpinBox/position click) remain hand-built per type. A registry→widget factory is future work. action_finishedtype filter — the full editor does not exposeparams.action_type; the trigger remains "completes any action" (§3.6).StageDirectorVisuals.rule_summary/_action_descduplication — the registry'sdescribenow duplicatesrule_summary's summary logic; a follow-up can makerule_summarydelegate to the registry to remove the copy.- Asymmetric delete confirmation — panel deletes confirm; on-stage rule
✕and waypoint "Delete this Walk" stay immediate (decision 9).