Files
stickman/docs/phase_3c_editor_spec.md
T

38 KiB
Raw Blame History

Phase 3c — Editor Tools: Action & Rule Editing (Implementation Spec)

Status: IMPLEMENTED + TESTED (12 headless suites, 667 assertions; tests/test_phase3c_editor.gd alone: 253 assertions) Related plan: plans/PHASE_3c_EDITOR.md Target: Godot 4.7 (project.godot:19 declares config/features=PackedStringArray("4.7", ...); the Phase 3c test header names Godot_v4.7.1-stable_win64_console.exe).


1. Overview & Scope

Phase 3c adds full editing for the Sandbox Stage's two director-facing authoring artifacts that previously had no in-place editing:

  • Action queues (per-StickmanRig, Phase 3a): actions could only be appended. Phase 3c adds a Queue Panel (view / edit / delete / drag-reorder / add / clear-all) and a waypoint context menu with visual walk re-placement and insert-before/after.
  • Event rules (_event_rules on SandboxStage, Phase 4): rules could only be created or deleted. Phase 3c adds a Rule Panel (view / edit / delete / drag-reorder / add / clear-all), a full rule editor (trigger + target + actions) and a consequence-only rule editor (trigger read-only; actions editable).

Everything is registry-driven: two const registries (ActionRegistry, TriggerRegistry) are the single source of truth for the action/trigger templates, and the new editors + panels generate their UI from them. Adding a new action or trigger type is a one-entry registry append (plan §4/§10) — no other code changes.

In scope

  • scripts/action_registry.gd / trigger_registry.gd — extensible template registries.
  • scripts/queue_panel.gd + scenes/queue_panel.tscn — Action Queue panel.
  • scripts/rule_panel.gd + scenes/rule_panel.tscn — Rule list panel.
  • scripts/action_editor.gd + scenes/action_editor.tscn — single-action property editor.
  • scripts/rule_editor.gd + scenes/rule_editor.tscn — full / consequence-only rule editor.
  • scripts/waypoint_context.gd — waypoint right-click menu.
  • scripts/sandbox_stage.gd — Phase 3c wiring: "Edit Queue…"/"Edit Rules…" entry points, right-click context menus, the unified target-capture system (CaptureKind), the shared confirmation dialog, and consequence-only rule editing on rule-label click.
  • scripts/stage_director_visuals.gdhit_test_waypoint_action(), set/clear_edit_waypoint with the pulsing edit highlight, and rule-label → editor routing.
  • tests/test_phase3c_editor.gd — 253-assertion headless suite.

Out of scope / untouched (must not regress)

  • .stk format / the editor — no changes.
  • Queue / rule disk persistence — still in-memory across EDIT ⇄ DIRECT ⇄ PLAY toggles, reset on scene reload (matches Phase 3a/4 scope).
  • The queue runner (StickmanRig._process_queue, Phase 3a) and the event engine (Phase 4) — read only; they already consume the same action/rule dict shapes.
  • Rule-builder flows (Phase 4 RuleStep state machine) — unchanged; Phase 3c's editors are additive alongside them. The panel "Add Rule" reuses _begin_rule_build().

2. Recorded User / Implementation Decisions

  1. Registries are const dictionaries with static accessors. Each action/trigger template is one const dict entry. Because Dictionary.keys() is untyped at runtime, the registries expose static func types() -> Array[String] (a genuinely typed array) rather than exposing .keys() directly, so callers can store the type list in typed locals (ActionEditor._current_type, RuleEditor._select_action_type).
  2. Panels/editors are PopupPanels with exclusive = true + popup_window = true built in code. The four .tscn files are minimal shells (bare PopupPanel + root script); all UI is constructed in _ready() (consistent with the Phase 3b asset_selector.tscn pattern). Because they are exclusive, the stage must hide a host popup before entering a stage-click capture or opening a nested editor, and re-show it on resolve/cancel (see Known Limitations, §10 #19).
  3. Rule-action shape ↔ flat queue-action conversions live on the registry. A rule action nests params under params and adds target (the actor's instance id); a flat queue action (Phase 3a) inlines them. ActionRegistry.to_rule_action() / from_rule_action() convert, and ActionRegistry.summarize() / TriggerRegistry.summarize() produce the one-line labels used by both the panels and the editors (they get() with fallbacks tolerant of either key layout).
  4. Waypoint "Edit this Walk" is a visual stage edit (a POSITION capture), not a numeric dialog. The target waypoint is highlighted with a pulsing amber ring drawn by StageDirectorVisuals while the capture is pending.
  5. ragdoll / recover "Edit" = a paramless ActionEditor pre-fill. Plan §11.2 originally specified a separate confirmation dialog for editing these no-parameter actions; the implementation instead reuses the generic ActionEditor (open, then OK) — fewer special cases, and the type is unchanged unless the user changes it. This is a deliberate deviation from the plan, not a defect (recorded in the tech-debt Change Log only; no debt row).
  6. Confirmations use one shared ConfirmationDialog (_ask_confirm(title, message, cb)). Queue delete / clear-all and Rule delete / clear-all confirm before mutating.
  7. Reordering is nearest-row-center drop semantics (see Known Limitations, §10 #21): a drag targets the row whose vertical center is nearest the pointer.
  8. "Edit Queue…" / "Edit Rules…" entry points are content-gated. In the Direct action popup and the stickman right-click menu, "📋 Edit Queue…" is hidden (or disabled) when the stickman's queue is empty, and " Edit Rules…" is hidden (or disabled) when the stickman is the source of no rule in _event_rules. The waypoint menu's " Edit Trigger Rules" entry already follows this pattern — WaypointContext.popup_for(rect, count) enables it (and shows the count) only when count > 0. Rationale: a menu item that opens an empty panel is noise; gating it signals that there is nothing to edit.

3. New Files

File class_name / extends Responsibility
res://scripts/action_registry.gd ActionRegistry / RefCounted ACTION_TEMPLATES (5 actions), static accessors + queue⇄rule conversions + summaries
res://scripts/trigger_registry.gd TriggerRegistry / RefCounted TRIGGER_TEMPLATES (5 triggers), static accessors + summaries
res://scripts/queue_panel.gd QueuePanel / PopupPanel Action Queue editor popup (root of queue_panel.tscn)
res://scripts/rule_panel.gd RulePanel / PopupPanel Rule list editor popup (root of rule_panel.tscn)
res://scripts/action_editor.gd ActionEditor / PopupPanel Single-action add/edit editor (root of action_editor.tscn)
res://scripts/rule_editor.gd RuleEditor / PopupPanel Full + consequence-only rule editor (root of rule_editor.tscn)
res://scripts/waypoint_context.gd WaypointContext / PopupMenu Waypoint right-click menu
res://scenes/queue_panel.tscn / rule_panel.tscn / action_editor.tscn / rule_editor.tscn PopupPanel roots Minimal shells; UI built in code

4. Public API Signatures (GDScript)

4.1 ActionRegistry (res://scripts/action_registry.gd)

class_name ActionRegistry
extends RefCounted

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]           # genuinely typed (Dictionary.keys() is untyped)
static func has_type(type: String) -> bool
static func label(type: String) -> String      # template label, else the type itself
static func icon(type: String) -> String       # template icon, else ""
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary
    # flat queue action -> rule-action shape: { "type", "target": target_id,
    #   "params": { target | text+duration | duration } } (walk_to/speak/wait only; others -> no params)
static func from_rule_action(rule_action: Dictionary) -> Dictionary
    # rule-action shape -> flat queue action (drops target, inlines params)
static func summarize(action: Dictionary) -> String
    # one-line label, e.g. 'Walk To (123, 456)', 'Speak "Hello" (2s)', 'Wait 1s'; tolerates
    # both the flat and rule-action key layouts via get() fallbacks.

4.2 TriggerRegistry (res://scripts/trigger_registry.gd)

class_name TriggerRegistry
extends RefCounted

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]
static func has_type(type: String) -> bool
static func label(type: String) -> String
static func icon(type: String) -> String
static func target_type(type: String) -> String   # "waypoint" | "action_type" | "none" | "area" | "prop"
static func summarize(trigger: Dictionary) -> String   # "<icon> <label>", e.g. "📍 Arrives at waypoint"

4.3 QueuePanel (res://scripts/queue_panel.gd, root of queue_panel.tscn)

class_name QueuePanel
extends PopupPanel

signal edit_requested(index: int)      # ✎ on a row
signal delete_requested(index: int)    # ✕ on a row
signal add_requested()                 # " Add Action"
signal clear_requested()               # "🗑 Clear All"

var rig: StickmanRig = null            # attached via setup()

func setup(r: StickmanRig) -> void     # attach a rig + refresh()
func refresh() -> void                 # rebuild rows from rig.get_queue()

Notes: built in _ready() (exclusive = true, popup_window = true, min_size 460×360). Drag-reorder uses _on_drag_handle_gui_input (press = begin, motion = nearest-row-center target, release = commit). On commit it moves the action via the rig's existing remove_action(from) + insert_action(to[, to>from ? to-1 : to], action) API, so queue_changed fires and the director overlay redraws. The panel stays decoupled — it never mutates directly beyond the reorder helper, and add/edit/delete/clear are delegated to the stage via signals.

4.4 RulePanel (res://scripts/rule_panel.gd, root of rule_panel.tscn)

class_name RulePanel
extends PopupPanel

signal edit_requested(rule_id: int)      # ✎ on a row
signal delete_requested(rule_id: int)    # ✕ on a row
signal add_requested()                   # " Add Rule"
signal clear_requested()                 # "🗑 Clear All"
signal reorder_requested(ordered_ids: Array[int])  # new order of the DISPLAYED rules' ids

func set_rules(rules: Array[Dictionary], title_hint: String) -> void
    # update the list + title WITHOUT popping up (used by restore flows that re-pop afterwards)
func show_rules(rules: Array[Dictionary], title_hint: String) -> void
    # set_rules() then popup_centered()
func refresh() -> void                   # rebuild rows from the stored _rules

The stage pre-filters rules (by source stickman or by waypoint) before calling show_rules/set_rules. Rows show trigger summary (<icon> <actor?> <label>) plus one indented → ... line per action (<icon> <actor?> <summary>), an order number, and ✎ / ✕ / drag-reorder ≡. Reorder emits the new order of the displayed rule ids (see SandboxStage._reorder_filtered_rules, §6) so un-filtered rules keep their slots.

4.5 ActionEditor (res://scripts/action_editor.gd, root of action_editor.tscn)

class_name ActionEditor
extends PopupPanel

signal committed(action: Dictionary)   # flat queue action (type + inline params)
signal cancelled()
signal target_requested()              # walk_to needs a stage target -> stage captures it

func open_new() -> void                # "add" mode; empty, default type walk_to
func open_edit(action: Dictionary) -> void   # "edit" mode; pre-filled from a flat queue action
func set_walk_target(pos: Vector2) -> void   # stage calls after a POSITION capture; re-pops

Type dropdown + param fields are generated from ActionRegistry. Param fields: walk_to → a "🎯 Click target…" button + target label; speakLineEdit (text) + duration SpinBox (0.13600 s, step 0.1); wait → a duration SpinBox; ragdoll/recover (and any future no-param action) → no fields. Pressing OK on a walk_to with no target set emits target_requested() instead of committing; once a target is captured (via set_walk_target) OK emits committed(action). Esc emits cancelled().

4.6 RuleEditor (res://scripts/rule_editor.gd, root of rule_editor.tscn)

class_name RuleEditor
extends PopupPanel

signal committed(rule: Dictionary)              # { id, trigger, actions }
signal cancelled()
signal trigger_target_requested(trigger_type: String)  # stage captures the target by trigger type
signal action_add_requested()                   # stage opens ActionEditor to collect a new action
signal action_edit_requested(index: int)        # stage opens ActionEditor pre-filled for index

func open_full(rule: Dictionary) -> void             # trigger type + target + actions editable
func open_consequence(rule: Dictionary) -> void      # trigger READ-ONLY; actions editable
func set_trigger_target(target_id: int, params: Dictionary) -> void  # after a stage capture; re-pops
func set_action(index: int, action: Dictionary) -> void             # index < 0 appends; re-pops
func get_action(index: int) -> Dictionary       # copy of a rule-action ({} out of range); used to
                                                # pre-fill the ActionEditor when editing an action

Trigger controls are mode-sensitive: in consequence mode the trigger is shown via a read-only label ("When: <actor> <summary>") and the type dropdown / target row are hidden. In full mode the target control shown depends on TriggerRegistry.target_type(type): waypoint → a "🎯 Click target…" button (target label reads waypoint (x, y) / (not set)); action_type → a dropdown ("Any action" or a specific action type, synced into trigger.params.action_type only for action_finished); area / prop → a "🎯 Click target…" button + node name label; none / speech_finished → no target control. The Actions section lists editable rows with ✎ / ✕ and an " Add Action" button; ✕ removes locally (_remove_action). OK emits committed({id, trigger, actions}) preserving _rule_id. Esc emits cancelled().

4.7 WaypointContext (res://scripts/waypoint_context.gd)

class_name WaypointContext
extends PopupMenu

const EDIT_WALK := 0
const DELETE_WALK := 1
const INSERT_BEFORE := 2
const INSERT_AFTER := 3
const EDIT_TRIGGER_RULES := 4

func popup_for(rect: Rect2i, trigger_rule_count: int) -> void
    # sets the "⚡ Edit Trigger Rules" entry's text to include the count and enables it when
    # trigger_rule_count > 0 (else disables it), then popup(rect).

Item order is fixed in _init(): Edit this Walk / Delete this Walk / ⬆ Insert action before / ⬇ Insert action after / separator / Edit Trigger Rules.


5. SandboxStage Phase 3c State & Entry Points (scripts/sandbox_stage.gd)

5.1 New state

enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }

# Editor popups/panels (instantiated from their scenes in _build_ui()).
var _queue_panel: QueuePanel
var _rule_panel: RulePanel
var _action_editor: ActionEditor
var _rule_editor: RuleEditor
var _waypoint_context: WaypointContext
var _confirm_dialog: ConfirmationDialog
var _confirm_callback: Callable = Callable()

# Context for the currently open panel.
var _panel_rig: StickmanRig = null            # rig under the queue/rule panel
var _rule_panel_source_id: int = -1           # >= 0 => filtered by this source stickman's id
var _rule_panel_waypoint: Vector2 = Vector2.INF  # finite => filtered by this waypoint
var _rule_panel_title: String = ""
var _rule_panel_filter_ids: Array[int] = []   # ids of the rules currently shown (for clear-all)

# Unified stage-click target capture.
var _capture_kind: CaptureKind = CaptureKind.NONE
var _capture_hint: String = ""
var _capture_callback: Callable = Callable()
var _capture_cancel: Callable = Callable()

# ActionEditor routing.
var _action_editor_kind: String = ""          # "queue_add"|"queue_edit"|"queue_insert"|"rule_add"|"rule_edit"
var _action_editor_index: int = -1
var _action_editor_rig: StickmanRig = null
var _action_editor_actor_id: int = -1
var _action_editor_restore_queue: bool = false  # re-pop the queue panel on resolve/cancel

# RuleEditor routing.
var _rule_editor_from_panel: bool = false     # re-pop the rule panel on commit/cancel

# Visual walk edit.
var _walk_edit_rig: StickmanRig = null
var _walk_edit_index: int = -1
var _walk_edit_from_panel: bool = false

5.2 Entry-point ids

const ACT_EDIT_QUEUE := 7     # appended to the Direct action popup (after Phase 4's ACT_WHEN)
const ACT_EDIT_RULES := 8
const RIG_CTX_EDIT_QUEUE := 0  # stickman right-click context menu
const RIG_CTX_EDIT_RULES := 1

5.3 Entry-point routing (all EDIT/DIRECT, never PLAY)

Handler Trigger Action
_on_action_popup_id_pressed(ACT_EDIT_QUEUE) / _on_rig_context_id_pressed(RIG_CTX_EDIT_QUEUE) "📋 Edit Queue…" (hidden/disabled when rig.get_queue().is_empty()) _open_queue_panel(rig)
... ACT_EDIT_RULES / RIG_CTX_EDIT_RULES " Edit Rules…" (hidden/disabled when no rule has trigger.source == rig.get_instance_id()) _open_rules_panel_for_rig(rig)
_open_waypoint_context() (RMB hit via _director_visuals.hit_test_waypoint_action) waypoint menu _waypoint_context.popup_for(rect, _count_rules_for_waypoint(pos))
_on_waypoint_context_id_pressed(EDIT_WALK) "✎ Edit this Walk" _begin_walk_edit(rig, index, false)
... DELETE_WALK "✕ Delete this Walk" rig.remove_action(index)
... INSERT_BEFORE/AFTER "⬆/⬇ Insert …" _open_action_editor("queue_insert", {}, index(+1), rig, -1, false)
... EDIT_TRIGGER_RULES " Edit Trigger Rules" _open_trigger_rules_panel(pos)
_begin_edit_rule(id) (rule-label click → consequence) click a rule dashed label _open_rule_editor_consequence(rule)
_on_rule_panel_edit_requested(rule_id) rule ✎ in panel _open_rule_editor_full(rule, true)

Entry-point gating: both menus refresh their "📋 Edit Queue…" / " Edit Rules…" item visibility on about_to_popup via _refresh_action_popup_items() (the Direct action popup) and _refresh_rig_context_items() (the stickman right-click menu). "Edit Queue…" is hidden/disabled when the rig's queue is empty; "Edit Rules…" is hidden/disabled when the rig is the source of no rule in _event_rules (the same test as _rule_matches_panel_filter, §5.4). The waypoint menu's " Edit Trigger Rules" entry is already count-gated by WaypointContext.popup_for(rect, count).

_handle_right_click() precedence (EDIT/DIRECT): an active placement/drag RMB keeps its Phase 4b "cancel build" role; otherwise a waypoint hit (nearest within WAYPOINT_HIT_RADIUS_PX / zoom) opens the waypoint context menu; otherwise a stickman hit opens the rig context menu.

5.4 Rule-panel filtering

_rule_matches_panel_filter(rule):

  • _rule_panel_source_id >= 0trigger.source == _rule_panel_source_id.
  • else if _rule_panel_waypoint is finite → the rule's trigger is arrived_at_waypoint and its trigger.params.waypoint_pos is within WAYPOINT_MATCH_EPSILON of the panel waypoint.
  • else → all rules (unused fallback).

_show_rule_panel() / _refresh_rule_panel() compute the filtered list + _rule_panel_filter_ids then call RulePanel.show_rules/set_rules. The Add Rule button only works from a source-stickman panel (_rule_panel_source_id >= 0); from a waypoint-filtered panel it toasts "Select a stickman first" (see Known Limitations §10 #20). "Add Rule" hides the panel and reuses the Phase 4 _begin_rule_build(source_id, from_panel=true); on finish _restore_rule_build_panel() re-pops it.


6. Data Flow (representative paths)

Edit a non-walk queue action (queue_edit):

  1. Queue panel row ✎ → _on_queue_panel_edit_requested(index).
  2. _open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, hide_queue=true) → stores routing state, hides the queue panel, ActionEditor.open_edit(action).
  3. OK → _on_action_editor_committed(flat): remove_action(idx) + insert_action(idx, flat) (queue_changed → visuals redraw), then _restore_queue_panel() re-pops the refreshed panel. Cancel → _restore_queue_panel() with no mutation.

Edit a walk_to (visual): _on_queue_panel_edit_requested detects walk_to and routes to _begin_walk_edit(rig, index, from_panel=true) instead of the generic editor. _begin_walk_edit highlights the waypoint (_director_visuals.set_edit_waypoint(pos)) and begins a POSITION capture. On capture, _on_walk_edit_captured rewrites the walk action's target via remove_action+insert_action, clears the highlight, and (from a panel) re-pops it; cancel (_cb_walk_edit_cancel) just clears the highlight + re-pops.

Consequence-only rule edit (rule label click): _begin_edit_rule(id)_open_rule_editor_ consequence(rule)RuleEditor.open_consequence(rule). OK → _on_rule_editor_committed(rule) writes the rule back into _event_rules by id (or, for a missing id, appends as new), _director_visuals.set_rules(...), and calls _restore_rule_panel() (a no-op here since _rule_editor_from_panel == false).

Reordering rules in a filtered panel: RulePanel.reorder_requested(ordered_ids)_on_rule_panel_reorder_requested_reorder_filtered_rules(ordered_ids). This walks the full _event_rules, rewrites only the slots whose ids are in ordered_ids into the new order, and leaves every un-filtered rule's slot untouched; then _director_visuals.set_rules(_event_rules).


7. Unified Target-Capture System (CaptureKind)

Phase 3a/4 scattered several "pending target" flows (walk target, rule trigger target, rule action actor). Phase 3c consolidates them:

func _begin_capture(kind: CaptureKind, hint: String,
                    on_resolve: Callable, on_cancel: Callable = Callable()) -> void:
    # sets _capture_kind/_capture_hint/_capture_callback/_capture_cancel; applies the flag cursor
    # and refreshes the status bar.

func _resolve_capture(world_pos: Vector2) -> void:
    # match kind:
    #   WAYPOINT   -> _director_visuals.hit_test_waypoint(world_pos)   (Vector2 or INF-miss)
    #   AREA       -> _selection.hit_test(world_pos) is TriggerArea
    #   PROP       -> ... is PropBlock
    #   STICKMAN   -> ... is StickmanRig
    #   POSITION   -> _snap_to_grid(world_pos) when snap is on, else world_pos
    # On a valid hit: _end_capture() then on_resolve.call(value). On a miss: keep capturing.

func _cancel_capture() -> void:   # _end_capture() then on_cancel.call()  (Esc path)
func _end_capture() -> void:      # clears kind/hint/callbacks; restores cursor + status

Stage-click and Esc handling check _capture_kind != CaptureKind.NONE first, giving capture priority over placement/selection (matching the existing Esc chain). Each editor's capture cancel callback re-pops the editor that requested the capture (_cb_rule_trigger_cancel, _cb_rule_actor_cancel, _cb_editor_walk_target_cancel).


8. StageDirectorVisuals Extensions (scripts/stage_director_visuals.gd)

# Phase 3c: the walk_to waypoint currently being visually edited (blinking highlight).
var _edit_waypoint: Vector2 = Vector2.INF

func set_edit_waypoint(pos: Vector2) -> void     # highlight on + mark_dirty()
func clear_edit_waypoint() -> void               # highlight off + mark_dirty()

func hit_test_waypoint(world_pos: Vector2) -> Vector2
    # nearest waypoint dot within WAYPOINT_HIT_RADIUS_PX / zoom; Vector2.INF on miss
    # (thin wrapper over hit_test_waypoint_action)
func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary
    # {"rig": StickmanRig, "index": int, "pos": Vector2} for the nearest walk_to, or {}
    # on miss. Reuses the same anchor math as _draw_rig_queue (rig feet as the current point).
  • _process() redraws every frame while _edit_waypoint.is_finite() (the blink is time-animated) and otherwise only on the dirty flag, so an active visual walk edit keeps pulsing without a mark_dirty storm.
  • _draw_waypoint() renders a pulsing amber ring (radius + 6/zoom, Color(1.0, 0.8, 0.0, 0.5 + 0.5·sin(ticks/150))) around the waypoint when it is within 0.5 px of _edit_waypoint.
  • Rule-label click routing already existed via hit_test_rule() (Phase 4); Phase 3c connects the label part to SandboxStage._begin_edit_rule(id) (consequence editor).

9. Extensibility Guide

9.1 Add a new action type

# 1. action_registry.gd — append an entry (params drive the ActionEditor + summaries).
const ACTION_TEMPLATES := {
    # ... existing ...
    "jump": { "label": "Jump", "icon": "🦘",
              "params": [ { "key": "height", "type": "float", "default": 100.0 },
                          { "key": "duration", "type": "float", "default": 0.5 } ] },
}
# 2. Implement execution in StickmanRig._process_queue() (the queue runner).
# 3. (For rule actions) extend ActionRegistry.to_rule_action()/from_rule_action()/summarize()
#    with the new type's params. The ActionEditor dropdown + param fields + the panels' summaries
#    appear automatically from the registry + summarize().

9.2 Add a new rule trigger type

# 1. trigger_registry.gd — append an entry with the correct target_type.
const TRIGGER_TEMPLATES := {
    # ... existing ...
    "variable_changed": { "label": "Variable changes", "icon": "📊", "target_type": "variable" },
}
# 2. Emit the trigger from SandboxStage's event engine when it fires.
# 3. Add the trigger to the rule builder (auto from the registry); if it needs a *new* target
#    kind, add a CaptureKind + a _resolve_capture arm and a _refresh_trigger() target control.

9.3 Add a new action / rule property

Actions and rules remain Dictionarys. Add new keys freely; the ActionEditor/RuleEditor display editable fields for the registry params and gracefully ignore unknown keys, and the summaries use get() fallbacks so un-summarized keys do not crash.


10. Known Limitations

# Limitation Severity
1 Confirmation dialog over an exclusive panel. Queue delete / clear-all (_on_queue_panel_delete_requested, _on_queue_panel_clear_requested) and Rule delete / clear-all (_on_rule_panel_delete_requested, _on_rule_panel_clear_requested) call _ask_confirm(...) without hiding the exclusive QueuePanel/RulePanel first. Popping the shared ConfirmationDialog while an exclusive = true panel is visible produces a non-fatal engine warning and the confirmation may render non-modal over the panel. It still works (the panel is re-shown after the callback); it is cosmetic. (Related to #19's host-visibility discipline — this is the confirmation-dialog facet of it.)
2 ragdoll/recover "Edit" opens a paramless ActionEditor instead of a dedicated confirmation dialog (plan §11.2). Deliberate design decision (§2 decision 5); the type is changeable via the editor. Not a defect — recorded here + Change Log only.
3 Rule Panel "Add Rule" is unavailable in waypoint-filtered panels. _on_rule_panel_add_requested toasts "Select a stickman first" when _rule_panel_source_id < 0 (the waypoint-filtered "Edit Trigger Rules" state). A waypoint can be targeted by rules authored by several stickmen, so the source is ambiguous; a future pass could default the source to the waypoint's owning rig or open the builder in "any stickman" mode. (Tracked as tech-debt #20.)
4 Drag-reorder uses nearest-row-center, not an insertion point. Drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge). (Tracked as tech-debt #21.)
5 Panels/editors must hide their host before a capture or nested editor. Host-visibility discipline is spread across _open_action_editor, _on_action_editor_committed/_cancelled, _begin_walk_edit, and _on_rule_editor_trigger_target_requested, with one-off _restore_queue_panel/_restore_rule_panel/_restore_rule_build_panel helpers. Works but fragile — a popup-stack abstraction would make it impossible to forget. (Tracked as tech-debt #19.)

11. Acceptance Criteria

11.1 Action Queue Panel

  • "Edit Queue" opens the panel from the Direct action popup and the stickman right-click menu.
  • Panel shows all actions in order (number + icon + summary).
  • ✕ deletes an action behind a confirmation dialog.
  • ✎ opens the edit popup pre-filled (walk_to → visual edit, speak text/duration, wait duration).
  • ≡ drag-reorders actions (nearest-row-center).
  • "Clear All" confirms then clears; "Add Action" appends.
  • All mutations go through the rig queue API → visuals update.
  • "Edit Queue…" is hidden (or disabled) when the stickman's queue is empty (entry-point gating).

11.2 Edit Action

  • Walk To: edit enters visual target placement (waypoint pulsing highlight); a click moves it.
  • Speak: text + duration pre-filled. Wait: duration pre-filled.
  • Ragdoll/Recover: paramless ActionEditor pre-fill (deviation from plan's confirmation dialog).

11.3 Waypoint Context Menu

  • Right-click a waypoint opens the menu.
  • "Edit this Walk" enters visual placement; "Delete this Walk" removes the action; "Insert action before/after" opens the ActionEditor in queue_insert at the right index.
  • "Edit Trigger Rules" (enabled + count when rules target the waypoint) opens a filtered Rule Panel.

11.4 Rule Panel

  • "Edit Rules" opens the panel from the stickman context menu; "Edit Trigger Rules" opens it from the waypoint menu, filtered to that waypoint's arrived_at_waypoint rules.
  • Panel lists each rule's trigger + action(s); ✎ full editor; ✕ confirm-delete; ≡ drag-reorder (reorder preserved through _reorder_filtered_rules).
  • "Add Rule" (source-stickman panels only) reuses the rule builder; "Clear All" confirms.
  • "Edit Rules…" is hidden (or disabled) when the stickman is the source of no rule (entry-point gating).

11.5 Rule Editor

  • Full editor: trigger type dropdown + target capture per type (waypoint/action_type/area/prop).
  • Consequence-only editor: trigger read-only.
  • Multi-action rules: add / edit / remove actions; OK preserves the rule id; editing updates _event_rules and the visuals.

11.6 Visual Updates

  • Waypoint dots move when walk actions are edited; speech/action text updates; order numbers update after insert/delete/reorder; rule labels and dashed connectors update (mark_dirty).

11.7 Backward Compatibility

  • Existing queues and rules load + display through the new panels; editing preserves action types/params and rule ids; delete cleans up references. No queue/rule persistence change.

11.8 Extensibility

  • New action/trigger types = registry append; new properties = new dict keys; unknown keys ignored.

12. Verification Plan

12.1 Runner command (from tests/test_phase3c_editor.gd:28)

& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path .

12.2 New headless suite — tests/test_phase3c_editor.gd (extends SceneTree, 253 assertions)

Covers:

  1. Registries (ActionRegistry / TriggerRegistry): types(), label, icon, target_type, summarize() for all 5 actions + 5 triggers, and graceful unknown-type handling.
  2. Scene shells instantiate and build their UI (queue_panel/rule_panel/action_editor/ rule_editor/waypoint_context).
  3. ActionEditor: open_new/open_edit pre-fill (walk target, speak text/duration, wait duration), committed/cancelled/target_requested, and OK-without-target requests a stage target capture.
  4. RuleEditor: full vs consequence modes; trigger read-only in consequence; action add/edit/remove; get_action(); rule-id preservation on commit; action_finished dropdown sync.
  5. QueuePanel: setup/refresh shows the queue in order; stage add/edit/delete/clear/reorder flows mutate the rig queue via its API.
  6. RulePanel: filtered list by source stickman and by waypoint; edit/delete/add/clear/reorder.
  7. WaypointContext: item ids; trigger-rules entry enabled/disabled by rule count.
  8. StageDirectorVisuals.hit_test_waypoint_action() returns rig/index/pos.
  9. SandboxStage Phase 3c capture: CaptureKind begin/cancel/resolve + Esc priority; right-click waypoint/rig context entry points.
  10. Backward compatibility: pre-existing queues/rules display correctly; editing preserves action types/params and rule ids.

12.3 Static verification

Headless --editor --quit rescan (to register the new class_names), then per-script --check-only on every new/modified script.

12.4 Manual (F6)

res://scenes/sandbox_stage.tscn: queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor, rule-label click → consequence editor.


13. Summary

Before (Phase 3a/4) After (Phase 3c)
Actions can only be appended Actions can be edited, deleted, reordered, inserted before/after a waypoint
Rules can only be created/deleted Rules can be edited (full + consequence-only), deleted, reordered
No way to fix mistakes Edit any parameter (text, duration, walk target)
No visual editing Waypoint context menu + visual walk re-placement (pulsing highlight)
Tightly coupled per-flow pending states Unified CaptureKind stage-click capture system
Fixed evaluation order Drag-reorder rules (preserving un-filtered slots)
Hard-coded type lists Registry-driven, extensible (ActionRegistry / TriggerRegistry)

14. Theme & Font Configuration (sandbox_theme.json)

Phase 4b introduced res://sandbox_theme.json and its loader (SandboxStage._load_theme()). Phase 3c extends the fonts block so font styles (bold/italic) and per-widget sizes are configurable — not just the handful of sizes + font paths shipped originally.

14.1 Extended fonts schema

"fonts": {
  // Existing keys (unchanged, backward-compatible):
  "ui_font": "",                          // base UI font resource path ("" = engine default)
  "emoji_font": "",                       // emoji-capable font resource path
  "action_popup_font_size": 24,           // PopupMenu font size (action/trigger/rig/waypoint menus)
  "tooltip_font_size": 18,
  "status_pill_font_size": 16,
  "assignment_badge_font_size": 20,       // drawn by StageDirectorVisuals
  "assignment_badge_radius": 9,
  "rule_label_font_size": 16,             // drawn by StageDirectorVisuals

  // NEW — style variant resource paths (bold/italic realised as distinct Font
  // resources, or via FontVariation when a separate file is unavailable):
  "ui_font_bold": "",                     // fallback to ui_font when empty
  "ui_font_italic": "",                   // fallback to ui_font when empty

  // NEW — Phase 3c widget font sizes (fall back to action_popup_font_size):
  "queue_panel_font_size": 18,
  "rule_panel_font_size": 18,
  "action_editor_font_size": 18,
  "rule_editor_font_size": 18,
  "panel_row_font_size": 16,              // per-row summary/number labels
  "panel_title_font_size": 18,            // panel title labels

  // NEW — style flags (bold via ui_font_bold / FontVariation.embolden):
  "panel_title_bold": true,
  "rule_label_bold": false,
  "badge_bold": true,

  // NEW — optional per-widget object form; overrides the flat size/style keys
  // for that widget when present:
  "action_popup": { "size": 24, "bold": false, "italic": false }
}
  • A widget that has an object-form entry (e.g. action_popup) reads {size, bold, italic} from it, falling back to the flat *_font_size / *_bold keys, then to the engine default.
  • Bold/italic are realised via FontVariation (e.g. variation_embolden, or an OpenType slant) applied to ui_font; a dedicated ui_font_bold / ui_font_italic path is honoured first.
  • fonts.action_popup_emoji_size is a currently-dead key (shipped in sandbox_theme.json but never read). Decision: consume it — apply it as the popup menu's emoji-glyph font size alongside _apply_popup_theme() — rather than removing it: it is already shipped and removing it would invalidate any user theme that sets it.

14.2 apply_font contract for the Phase 3c widgets

QueuePanel, RulePanel, ActionEditor, and RuleEditor are currently added to the UI canvas with no font override (SandboxStage._build_ui()), so they render in the engine default font and ignore the configured ui_font / emoji_font. Each gains an apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) method mirroring AssetSelector.apply_font() (asset_selector.gd), which:

  1. walks every Control it built in _ready() and applies the ui_font override (and emoji_font for glyph/icon labels), and
  2. applies the per-widget *_font_size / title / row size overrides and the *_bold style flags (via the FontVariation-derived font).

SandboxStage._build_ui() calls each apply_font(...) after add_child(...) (the same after-add ordering used for _selector.apply_font(...), sandbox_stage.gd:1462), passing the parsed sizes from _load_theme(). The PopupMenus (_rig_context_popup, _waypoint_context) keep using _apply_popup_theme(), extended to honour action_popup_emoji_size and the action_popup object form.