38 KiB
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_rulesonSandboxStage, 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.gd—hit_test_waypoint_action(),set/clear_edit_waypointwith 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)
.stkformat / the editor — no changes.- Queue / rule disk persistence — still in-memory across
EDIT ⇄ DIRECT ⇄ PLAYtoggles, 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
RuleStepstate machine) — unchanged; Phase 3c's editors are additive alongside them. The panel "Add Rule" reuses_begin_rule_build().
2. Recorded User / Implementation Decisions
- 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 exposestatic 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). - Panels/editors are
PopupPanels withexclusive = true+popup_window = truebuilt in code. The four.tscnfiles are minimal shells (barePopupPanel+ root script); all UI is constructed in_ready()(consistent with the Phase 3basset_selector.tscnpattern). 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). - Rule-action shape ↔ flat queue-action conversions live on the registry. A rule action
nests params under
paramsand addstarget(the actor's instance id); a flat queue action (Phase 3a) inlines them.ActionRegistry.to_rule_action()/from_rule_action()convert, andActionRegistry.summarize()/TriggerRegistry.summarize()produce the one-line labels used by both the panels and the editors (theyget()with fallbacks tolerant of either key layout). - Waypoint "Edit this Walk" is a visual stage edit (a
POSITIONcapture), not a numeric dialog. The target waypoint is highlighted with a pulsing amber ring drawn byStageDirectorVisualswhile the capture is pending. ragdoll/recover"Edit" = a paramlessActionEditorpre-fill. Plan §11.2 originally specified a separate confirmation dialog for editing these no-parameter actions; the implementation instead reuses the genericActionEditor(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).- Confirmations use one shared
ConfirmationDialog(_ask_confirm(title, message, cb)). Queue delete / clear-all and Rule delete / clear-all confirm before mutating. - Reordering is nearest-row-center drop semantics (see Known Limitations, §10 #21): a drag targets the row whose vertical center is nearest the pointer.
- "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 whencount > 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; speak → LineEdit (text) + duration SpinBox
(0.1–3600 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 >= 0→trigger.source == _rule_panel_source_id.- else if
_rule_panel_waypointis finite → the rule's trigger isarrived_at_waypointand itstrigger.params.waypoint_posis withinWAYPOINT_MATCH_EPSILONof 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):
- Queue panel row ✎ →
_on_queue_panel_edit_requested(index). _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).- 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 amark_dirtystorm._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 toSandboxStage._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
ActionEditorpre-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_insertat 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_waypointrules. - 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_rulesand 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:
- Registries (
ActionRegistry/TriggerRegistry):types(),label,icon,target_type,summarize()for all 5 actions + 5 triggers, and graceful unknown-type handling. - Scene shells instantiate and build their UI (
queue_panel/rule_panel/action_editor/rule_editor/waypoint_context). ActionEditor:open_new/open_editpre-fill (walk target, speak text/duration, wait duration),committed/cancelled/target_requested, and OK-without-target requests a stage target capture.RuleEditor: full vs consequence modes; trigger read-only in consequence; action add/edit/remove;get_action(); rule-id preservation on commit;action_finisheddropdown sync.QueuePanel: setup/refresh shows the queue in order; stage add/edit/delete/clear/reorder flows mutate the rig queue via its API.RulePanel: filtered list by source stickman and by waypoint; edit/delete/add/clear/reorder.WaypointContext: item ids; trigger-rules entry enabled/disabled by rule count.StageDirectorVisuals.hit_test_waypoint_action()returnsrig/index/pos.SandboxStagePhase 3c capture:CaptureKindbegin/cancel/resolve + Esc priority; right-click waypoint/rig context entry points.- 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/*_boldkeys, then to the engine default. - Bold/italic are realised via
FontVariation(e.g.variation_embolden, or an OpenType slant) applied toui_font; a dedicatedui_font_bold/ui_font_italicpath is honoured first. fonts.action_popup_emoji_sizeis a currently-dead key (shipped insandbox_theme.jsonbut 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:
- walks every
Controlit built in_ready()and applies theui_fontoverride (andemoji_fontfor glyph/icon labels), and - applies the per-widget
*_font_size/ title / row size overrides and the*_boldstyle flags (via theFontVariation-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.