Files
stickman/docs/phase_3c_editor_spec.md

701 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.gd``hit_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 `PopupPanel`s 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`)
```gdscript
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`)
```gdscript
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`)
```gdscript
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`)
```gdscript
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`)
```gdscript
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.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`)
```gdscript
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`)
```gdscript
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
```gdscript
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
```gdscript
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_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:
```gdscript
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`)
```gdscript
# 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
```gdscript
# 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
```gdscript
# 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 `Dictionary`s. 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
- [x] "Edit Queue" opens the panel from the Direct action popup and the stickman right-click menu.
- [x] Panel shows all actions in order (number + icon + summary).
- [x] ✕ deletes an action behind a confirmation dialog.
- [x] ✎ opens the edit popup pre-filled (`walk_to` → visual edit, speak text/duration, wait duration).
- [x] ≡ drag-reorders actions (nearest-row-center).
- [x] "Clear All" confirms then clears; "Add Action" appends.
- [x] 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
- [x] Walk To: edit enters visual target placement (waypoint pulsing highlight); a click moves it.
- [x] Speak: text + duration pre-filled. Wait: duration pre-filled.
- [x] Ragdoll/Recover: paramless `ActionEditor` pre-fill (deviation from plan's confirmation dialog).
### 11.3 Waypoint Context Menu
- [x] Right-click a waypoint opens the menu.
- [x] "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.
- [x] "Edit Trigger Rules" (enabled + count when rules target the waypoint) opens a filtered Rule Panel.
### 11.4 Rule Panel
- [x] "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.
- [x] Panel lists each rule's trigger + action(s); ✎ full editor; ✕ confirm-delete;
≡ drag-reorder (reorder preserved through `_reorder_filtered_rules`).
- [x] "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
- [x] Full editor: trigger type dropdown + target capture per type (`waypoint`/`action_type`/`area`/`prop`).
- [x] Consequence-only editor: trigger read-only.
- [x] Multi-action rules: add / edit / remove actions; OK preserves the rule id; editing updates
`_event_rules` and the visuals.
### 11.6 Visual Updates
- [x] 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
- [x] 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
- [x] 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_name`s), 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
```jsonc
"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 `PopupMenu`s (`_rig_context_popup`, `_waypoint_context`)
keep using `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the
`action_popup` object form.