Add unique identifier for test_phase3c_walk_recovery.gd
This commit is contained in:
@@ -0,0 +1,700 @@
|
||||
# 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.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`)
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user