- Added waypoint context updates to handle action queue statuses in `waypoint_context.gd`. - Introduced new scene file `manwalk.scene` with terrain and stickman setup. - Created architecture summary for the Live Director System detailing core state transitions, physics mechanics, UI elements, and action execution. - Specified class contracts and interfaces for the Live Director implementation in `phase_12_live_director.md`. - Defined state transitions, input handling, and queue editing mechanics for the Live Director feature.
27 KiB
Phase 12 — Live Director
Status: specification for user sign-off. Source: plans/PHASE_12_LIVE_DIRECTOR.md and the Phase 1 clarification. Implementation begins only after this spec is approved.
1. Scope and decisions
The Sandbox Stage gains a resumable simulation session. PLAY → DIRECT freezes the exact live pose and clocks; DIRECT → PLAY resumes that session; Reset or entering EDIT restores the authored t = 0 scene. DIRECT entered from EDIT remains the existing planning surface at t = 0; its first Play starts a new run.
During a paused run, Direct permits action-queue and event-rule authoring against the scene snapshot at pause time. It permits selecting stickmen, including their ragdoll bodies, and selecting existing props/areas as rule targets. It does not permit moving, rotating, scaling, placing, or deleting stage objects. Those operations remain in EDIT. Newly authored actions/rules persist after Reset; their positions and targets are absolute world-space values captured from the paused scene. Replay from t = 0 follows those saved absolute values, so a later run may approach a waypoint from a different position.
The existing .scene version 1.0, StageSceneIO encoding, action dictionaries, and event-rule schema remain unchanged. Runtime action IDs, physics snapshots, timestamp, and action history are session-only. Stage/action undo and redo remain out of scope.
2. Existing seams and target hierarchy
SandboxStage.set_mode() currently funnels PLAY→DIRECT through _enter_edit_shared(), which calls stop_queue(), snap_to_standing(), freezes props, restores authored transforms, and clears event-edge caches. _enter_play_mode() restarts each queue. These calls must be split into start, pause, resume, and reset transitions; a paused run must never traverse the authored-state reset path.
StickmanActionRunner.start_queue() copies action_queue into _run_queue; the existing remove_action() + insert_action() edit pattern and QueuePanel drag reorder therefore do not update live execution safely. Reactive takeover can replace _run_queue with its unconsumed slice, so completed/interrupted history needs its own ledger. StickmanRagdoll creates ten RigidBody2D parts and nine joints under a sibling container in World; multiple rigs may receive auto-renamed sibling containers. Stage traversal must identify those containers by owner/group or through each rig's ragdoll API, never by the requested node name. StageDirectorVisuals currently draws every authored waypoint starting from rig.global_position - StickmanLocomotion.FOOT_OFFSET.
SandboxStage (Node2D; mode, session clock, event rules)
├── World (Node2D)
│ ├── StickmanRig (Node2D)
│ │ └── RigComponents
│ │ ├── Locomotion (StickmanLocomotion)
│ │ ├── ActionRunner (StickmanActionRunner)
│ │ └── Ragdoll (StickmanRagdoll)
│ ├── PropBlock / TerrainBlock / TriggerArea
│ └── RagdollBodyContainer (created while ragdolled)
│ ├── Ragdoll_* (10 RigidBody2D with CollisionShape2D)
│ └── RagdollPin_* (9 PinJoint2D)
├── DirectorVisualsLayer (StageDirectorVisuals)
└── UI (timestamp, Pause, Reset, existing mode buttons and editors)
3. Class contracts and typed interfaces
| Class | Required API and ownership |
|---|---|
class_name SandboxStage extends Node2D (scripts/sandbox_stage.gd) |
Own enum RunState { STOPPED, RUNNING, PAUSED }, _run_state: RunState, _elapsed_seconds: float, _prop_pause_states: Dictionary keyed by instance ID, and a paused-edit autosave debounce. Keep StageMode { EDIT, DIRECT, PLAY } for UI. Add _start_run() -> void, _pause_run() -> void, _resume_run() -> void, _reset_run() -> void, _set_timestamp(seconds: float) -> void, _capture_prop_pause_state(body: RigidBody2D) -> Dictionary, and a silent event-edge baseline refresh. set_mode(mode: StageMode) -> void dispatches by current mode and run state. _handle_event() accepts events only in RUNNING after the resume baseline is ready. |
class_name StickmanRig extends Node2D (scripts/stickman_rig.gd) |
Facade: pause_simulation() -> void, resume_simulation() -> void, reset_simulation_pause() -> void, get_director_anchor() -> Dictionary, get_queue_view() -> Array[Dictionary], and typed queue edit wrappers (replace_action, move_action, existing add/insert/remove/clear). Its physics tick returns while paused; it delegates physics/tween handling to components. Capture whether its locomotion AnimationPlayer was playing, pause it, and resume only playback that Phase 12 paused; preserve a prior airborne/landing pause. Route whole action_queue property assignment through the runner's validated setter. |
class_name StickmanRagdoll extends Node (scripts/stickman_ragdoll.gd) |
Own pause_simulation() -> void, resume_simulation() -> void, clear_pause_snapshot() -> void, get_world_body_bounds() -> Rect2, get_body_rids() -> Array[RID], and typed access to its owned body nodes/container. Snapshot each existing body’s global_transform, linear_velocity, angular_velocity, sleeping, prior freeze, and prior freeze_mode by body instance ID. Pause _stand_up_tween when valid; resume it only if it was playing when paused. Gate _process pose correction while paused. Expose the live ragdoll bounds and ground-probe origin through the rig facade. |
class_name StickmanActionRunner extends Node (scripts/stickman_action_runner.gd) |
Own session-only stable authored action IDs, an independent completed/interrupted ledger, and transient reactive runtime entries. Add set_authored_queue(actions: Array[Dictionary]) -> bool, replace_action(index: int, action: Dictionary) -> bool, move_action(from_index: int, to_index: int) -> bool, get_queue_view() -> Array[Dictionary], pause_simulation() -> void, resume_simulation() -> void, and clear_pause_state() -> void. Existing add/insert/remove/clear reconcile the live pending list when paused. Whole-queue replacement is accepted only while STOPPED for setup/load and rebuilds IDs; it is rejected in RUNNING and PAUSED. Reconciled queue APIs are the only run-session edit path. |
class_name StageSelection extends RefCounted (scripts/stage_selection.gd) |
hit_test(world_pos: Vector2) -> Node2D maps hits on ragdoll bone CollisionShape2D children to their stickman_rig metadata. Return the rig, never the body container or an individual limb. Exclude all ragdoll containers by ownership metadata/group, not an exact name; keep frontmost selection behavior for other objects. |
class_name StageDirectorVisuals extends Node2D (scripts/stage_director_visuals.gd) |
Add set_live_session(paused: bool) -> void. Draw queue views with completed/interrupted/active/pending statuses and start pending paths at StickmanRig.get_director_anchor(). hit_test_waypoint_action() returns action_id, current authored index, and status; consumed waypoints open delete-only controls. The same computed anchors feed both rendering and hit testing. |
class_name QueuePanel extends PopupPanel (scripts/queue_panel.gd) |
Render the rig's queue view and status. During a paused run, allow drag reorder only among PENDING actions; keep the ACTIVE action fixed. COMPLETED and INTERRUPTED actions cannot be edited or moved, but retain Delete. Delegate reorder through a single rig move_action() call, not remove+insert. Capture action IDs for delayed edits/deletes and emit the existing add/edit/delete/clear signals for stage routing. |
class_name WaypointContext extends PopupMenu (scripts/waypoint_context.gd) |
Accept the waypoint's queue status at popup time; disable Edit/Insert for COMPLETED or INTERRUPTED waypoints while retaining Delete and existing trigger-rule inspection. The stage stores the action_id and resolves its current index when a delayed menu choice or capture commits. |
Existing action_started(action: Dictionary, index: int), action_finished(action: Dictionary, index: int), queue_changed, mode_changed(mode: int), StageSelection.selection_changed(nodes: Array[Node2D]), and ActionEditor.committed(action: Dictionary) signals remain. Add simulation_paused(elapsed_seconds: float), simulation_resumed(elapsed_seconds: float), and simulation_reset() on SandboxStage for timestamp/chrome refresh and future consumers. Emit each only after its transition has completed. A runtime queue view is an Array[Dictionary]; each authored entry has { "action": Dictionary, "authored_index": int, "status": int, "action_id": int }. status uses a typed runner enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }. Reactive consequences carry authored_index = -1 in separate transient runtime entries and have no editable authored ID. No session ID is serialized.
4. State transitions, clock, and input
stateDiagram-v2
[*] --> EDIT
EDIT --> DIRECT: Direct (planning at t=0)
EDIT --> PLAY: Play / start at t=0
DIRECT --> PLAY: Play or Space / start or resume
PLAY --> DIRECT: Direct, Pause, or Space / freeze at t
DIRECT --> EDIT: Edit or Reset / restore t=0
PLAY --> EDIT: Edit or Reset / restore t=0
RunState.STOPPED applies in EDIT and planning DIRECT. RUNNING applies in PLAY. PAUSED applies in mid-run DIRECT. In _physics_process(delta: float), add delta to _elapsed_seconds only in RUNNING; all runtime event polling remains PLAY-only. The timestamp is scene simulation time, not wall clock. The last completed physics frame supplies the paused timestamp. Format by truncating to centiseconds: ⏱️ %02d:%05.2f equivalent to ⏱️ 00:04.25; clamp to nonnegative, handle minutes beyond 99 without rollover. Show 00:00.00 in EDIT and planning DIRECT.
Space routes through the same methods as the Pause/Play buttons. Handle it once in _unhandled_key_input, ignoring key repeat, active text-entry focus, modal editor dialogs, target-capture flows, and an in-progress director/rule operation. PLAY requests pause; DIRECT requests start/resume. The centered Direct mode button also pauses from PLAY. The centered Play button resumes a paused run. Reset is a distinct visible button in the top chrome and works from PLAY or DIRECT; Edit has the same reset semantics when a run exists. In paused DIRECT, Escape first cancels the active popup/capture/editor; a bare Escape stays paused. Only Reset or Edit discards the live run. Do not run the existing pre-Play autosave for every resume. Save/autosave uses _collect_scene_data() so it writes authored object transforms plus current action queues and rules, never transient physics positions.
On pause: switch the stage's event-dispatch gate off first, stop runner/locomotion/ragdoll component clocks before exposing Direct input, pause recovery tweens and only locomotion AnimationPlayers that were playing, snapshot every live ragdoll body and dynamic prop, and set each RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC before freeze = true. Record the live area-overlap/contact baseline without firing rules. Ignore freeze-induced PropBlock.separated and collided callbacks while PAUSED so they do not erase real contact history. _handle_event() itself also rejects any callback unless RUNNING and the resume baseline is ready.
On resume: close or cancel unfinished Direct captures, restore each saved body's freeze mode/state and velocity, restore sleeping state deliberately, resume component clocks, tweens, and only locomotion animation playback paused by Phase 12. Before reopening event dispatch, silently rebaseline area overlap and prop contacts (both existing rig/prop pair bookkeeping and generic prop/body contacts) against the resumed physics state, including any delayed freeze/unfreeze callbacks. Suppress event dispatch until that first resume baseline is complete; the stage's parent-first _physics_process ordering must not treat a pre-pause movement as a new entry. An overlap/contact already present at pause does not emit a new event solely because of Resume. If a body was moved by an explicitly supported paused operation, synchronize its transform before releasing it; Phase 12 permits no object manipulation, so ordinary pause/resume must not teleport or call PhysicsServer2D unnecessarily. Drop invalid/freed body snapshots safely.
On Reset/enter EDIT: cancel active actions and tweens, clear transient reactive actions, destroy ragdoll bodies via existing snap_to_standing(), restore _authored transforms and prop instance scale through the established EDIT path, clear physics snapshots and edge-trigger runtime caches, set time to zero, and keep the latest authored queues and _event_rules. A new Play rebuilds the runtime queue from those retained edits and restarts rules from a clean event state. Existing New/Open scene actions still clear/replace content as they do today.
Paused queue/rule mutations mark the scene dirty and arm a 1.0 s debounced autosave in the stage's still-running UI _process. The write uses StageSceneIO.write_file_safe(AUTOSAVE_PATH, _collect_scene_data(true)) through the existing safe save path. _collect_scene_data() reads object position/rotation/scale from _authored and current authored queues/rules; the pause pose, dynamic velocities, transient reactive consequences, and action history are never serialized. Further edits restart the debounce. Successful explicit Save, New/Open, or discard cancels the pending timer using the existing autosave lifecycle. A pending debounce may finish after Resume because it still writes authored data. Autosave failures leave the scene dirty and report through the existing save-error path.
5. Live queue and event editing
The runner assigns an increasing session action ID to each authored entry and carries it in _run_queue. This identity survives insertions and reorders; it is regenerated on scene load/new run and never enters .scene. StickmanRig.action_queue and its pre-ready _pending_action_queue route through set_authored_queue(); that method accepts whole-queue replacement and rebuilds aligned IDs only while STOPPED. It rejects replacement in RUNNING and PAUSED. Reconciled add/insert/replace/move/remove/clear APIs are the only run-session edit path; avoid direct writes to the runner's array. An atomic replace_action() retains the action ID, updates the authored queue, emits queue_changed once, and avoids the current remove+insert sequence. During a paused run, move_action() may reorder PENDING entries only; ACTIVE, COMPLETED, and INTERRUPTED entries remain fixed. It updates the authored order and pending runtime order once. Recompute signal-facing index from the ID's current authored position; a deleted consumed runtime item reports -1 if a history record needs it.
Keep a separate session ledger of completed and interrupted authored IDs. The ledger must survive enqueue_reactive() replacing _run_queue with unconsumed entries. A rule's TAKEOVER consequence marks the authored primary action it cancels as INTERRUPTED; it never emits action_finished for that action and never automatically resumes it. That action remains in the authored queue and runs again only after the next Reset and Play from t = 0. Reactive consequence entries are transient and noneditable; OVERLAY speech remains an overlay, while primary reactive entries execute ahead of retained authored future actions. If the runner is idle when a rule injects a consequence, use its actual unconsumed authored IDs rather than rebuilding the whole authored queue; no completed or interrupted action may replay in the current run. In the paused UI, an INTERRUPTED authored row is consumed history like a COMPLETED row: Delete is allowed for the next run, while Edit, Insert-before/after, and reorder are disabled.
During PAUSED:
- Append/insert and reorder the future update the authored queue and current run. Insertions before a COMPLETED, INTERRUPTED, or ACTIVE action are refused with a clear UI message; insertion immediately after active or into later pending actions is allowed. Reorder is limited to pending actions: the active action remains fixed, and consumed actions cannot be moved. An idle runner with new pending actions becomes ready to execute them on Resume without replaying consumed items. Pending order remains authored order, except existing rule-injected reactive items keep their takeover/overlay priority.
- Replace active cancels its transient effects while frozen (walk IK/navigation target, primary speech, timer) and schedules the replacement as the next action to start on Resume from the current live pose. The active action does not emit a misleading
action_finished. Deleting active cancels it and advances to the next pending action. Editing/deleting a pending action reconciles its runtime copy immediately. A COMPLETED or INTERRUPTED action cannot be edited or reordered in this run; deleting it removes it from the authored queue but leaves its consumed runtime history and rule outcomes untouched. The deletion takes effect on the next start fromt = 0. - Ragdoll/recovery actions require state-aware cancellation. Cancelling an active ragdoll action leaves the captured ragdoll pose and velocities frozen; a subsequent walk first runs a
recoverbarrier. Cancelling an active recovery first snapshots the current visible skeleton/IK pose, kills its tween, builds ragdoll bodies from that exact pose, freezes and registers all new bodies in the same transition, and only then returns to paused Direct input. The new bodies must not receive an intervening physics step. For a newly assignedwalk_towhile the rig is ragdolled, insertrecoverimmediately before it in the authored queue unless an adjacent pending recover already provides that barrier. The runner also guards walk start so a rig still ragdolled at execution time recovers before navigation.LANDING_IMPACTremains authoritative throughStickmanLocomotion.is_action_blocked(); pausing does not clear its compression timer or advance the queue. - Rule changes use the existing
_event_rulescollection, IDs, editor, andStageSceneIOformat. Event rules are not reordered during a paused run. A rule added or edited while paused is active for events emitted only after Resume; it does not retroactively fire for an overlap, arrival, or speech event that already occurred._handle_event()checks RUNNING and the completed resume baseline even when called byarrived,action_finished, orspeech_finishedsignals. Preserve/rebaseline area and contact edges across resume as in §4. Reset clears those caches while retaining edited rules. Capture props/areas/actors from the paused scene and serialize their existing file IDs. A rule whose target is later removed in EDIT follows the existing missing-target validation behavior.
QueuePanel Clear All removes all authored entries for the next run, cancels the active action, and removes current pending entries; consumed history stays session-only until Reset. Guard every stage entry point (QueuePanel, ActionEditor commit, waypoint context, direct popup, and drag reorder) with the same status policy. Capture action_id when opening a delayed popup, confirmation, or waypoint target capture; resolve its current authored index at commit and reject an action that vanished or became COMPLETED/INTERRUPTED. This replaces the current remove+insert path in _on_action_editor_committed() and _on_walk_edit_captured(). Never let an alternate context menu bypass consumed-action restrictions. Keep direct simple queue_action() calls routed through runner reconciliation, so the small popup and the full QueuePanel behave alike.
Example paused edit
At t = 4.25, action ID 12 (walk_to A) is active, ID 13 (wait) is pending, and the stickman is at live feet position (420, 650). Replacing ID 12 with walk_to B cancels navigation now and updates the authored queue. The queue view becomes:
[
{"action_id": 12, "authored_index": 0, "status": QueueStatus.PENDING,
"action": {"type": "walk_to", "target": Vector2(800, 650)}},
{"action_id": 13, "authored_index": 1, "status": QueueStatus.PENDING,
"action": {"type": "wait", "duration": 1.0}},
]
The pending path begins at (420, 650) while paused. On Resume, ID 12 starts from that position. After Reset, the same authored queue starts at the stickman's original t = 0 position.
For a reactive takeover, suppose authored ID 12 is ACTIVE when a rule injects a reactive speak followed by walk_to, with authored ID 13 still PENDING. The takeover cancels ID 12 and records it as INTERRUPTED. The two reactive entries have no authored IDs and execute first; ID 13 follows. ID 12 does not resume during this run. Reset clears the session ledger, so the next Play starts ID 12 again from t = 0 unless it was deleted while paused.
6. Spatial anchors, selection, and path overlay
StickmanRig.get_director_anchor() returns { "feet": Vector2, "ground": Vector2, "has_ground": bool, "airborne": bool } in world space. For animated grounded rigs, feet is the current root minus StickmanLocomotion.FOOT_OFFSET; for animated airborne rigs, derive the live feet from the existing locomotion/proxy pose. For ragdolls, derive feet from the lowest lower-leg/body extent and use the torso/bounds center as the probe's horizontal origin; do not use the stale rig root. Cast downward against terrain only, excluding the rig's animated collision proxy RIDs and every ragdoll body RID. Use the nearest valid ground hit below the live figure. If no ground exists within the configured range, set has_ground = false and use live feet as the pending path origin; do not invent a target at a fixed floor height. Draw a muted vertical drop line only when airborne or ragdolled and ground was found.
In paused DIRECT, render COMPLETED and INTERRUPTED path/badges at 20% alpha and desaturated grey, and active/pending path/badges at full configured accent opacity. A transient reactive primary may have its own active indicator but never an editable authored waypoint. The first authored future segment begins at the live ground anchor; subsequent walk_to targets chain normally. A newly queued walk target remains the absolute clicked world point. The queue view and anchor result are the only data sources for both _draw_rig_queue() and hit_test_waypoint_action(), so visual hit regions and status decisions agree. Planning DIRECT at t = 0 retains the current full-opacity authored path preview.
For ragdoll selection, query point hits against live bone collision shapes/RIDs and map the hit body’s stickman_rig metadata to its StickmanRig. Validate the rig is still in World and the metadata target is alive. Mark every procedural ragdoll container with explicit owner metadata/group and exclude it from _world_children_selectable() and StageSelection._is_selectable() regardless of its auto-renamed node name; StickmanRagdoll supplies the actual body nodes for pause/reset. Keep props and terrain selectable as rule targets, but never enable StageGizmos or placement actions in DIRECT. A body hit should take precedence over the rig's old standing AABB when the ragdoll is spread out; ordinary non-ragdoll frontmost selection stays unchanged.
7. Layout and theme
Create a TimestampBadge PanelContainer under the existing UI canvas, anchored with PRESET_TOP_RIGHT, offset below the 64 px top bar and inside a 12 px viewport margin so it does not overlap the centered mode switcher or right-side top controls. Its child Label uses the local Sandbox theme font. Add ⏸️ Pause and ↺ Reset buttons to the existing top chrome: Pause is available in PLAY; Play and Reset are available while paused; Reset is disabled or hidden at t = 0 as appropriate. Retain the existing Edit/Direct/Play centered segments and color accents.
Add fonts.sizes.timestamp_badge and chrome.timestamp_badge (corner_radius, padding, bg_color) to both sandbox_theme.json and SandboxTheme.DEFAULT_CONFIG; use the existing validated font/number/color accessors and stylebox pattern. Apply theme changes through the stage's current local theme refresh. Keep the font and badge size comfortably legible at supported stage widths. Use existing queue/waypoint theme tokens for future vectors; add overlays.completed_path_alpha default 0.20 and a completed grey token only if the overlay cannot derive them from existing colors. No global GUI theme mutation.
8. Implementation checks and handoff
The developer phase must update the named scripts/theme using targeted diffs, then run the spec-pipeline's Godot 4.7.1 headless --check-only --script gate on each modified GDScript and fix any parse errors. Do not update README or user documentation in Phase 2. User verification in the Godot Editor should cover these outcomes:
- Pause while walking, airborne, landing, ragdolled, recovering, speaking, and while a dynamic prop is moving. Pose, velocities, walk animation phase, timer, speech, and recovery tween hold during Direct and continue on Resume without a restart or jump. A walk animation already paused by airborne/landing logic remains paused until that logic resumes it.
- Queue append, insert, active replace/delete, completed/interrupted delete, reorder of pending actions, and an idle runner receiving a new action behave as in §5. A rule-triggered takeover marks the canceled authored action INTERRUPTED and does not replay it in the same run; a new
t = 0run does. Delayed popup commits target IDs, not stale indices. - Adding an event rule while paused fires only on a later matching event; an existing area overlap or prop contact does not trigger again solely because of Resume. Reset clears old edge history.
- Ragdoll limb clicks select the owning stickman, including scenes with multiple simultaneous ragdolls. Airborne/ragdoll drop lines and future vectors originate at the current scene snapshot. No stage object moves in DIRECT.
- Space, Pause, Direct, Play, Reset, Edit, bare Escape, file save/autosave, and scene reload follow the transition and authored-state contracts above. The timestamp freezes and resets at the intended moments. A paused queue/rule edit writes a debounced autosave containing authored transforms and current queues/rules, with no runtime pose.
9. Trade-offs
- Stable runtime action IDs and an independent consumed-action ledger add small parallel bookkeeping structures, but make live edits, reactive interruption, completed deletion, and queue-panel reorder deterministic without changing
.sceneformat. - Pausing component clocks, locomotion AnimationPlayer playback, and body physics keeps the editor UI responsive. It requires explicit ownership of each runtime clock; a global
SceneTree.pausedswitch would also suspend editor controls and obscure those ownership rules. - Ground anchoring uses a terrain ray and a feet fallback. A ragdoll across uneven terrain may show one representative anchor; multi-contact path planning remains deferred.
- Paused scene edits are intentionally limited to action queues and rules. The authored object snapshot stays stable, allowing Reset and
.scenepersistence to remain predictable. - The debounced paused-edit autosave serializes only authored state, so it can preserve queue/rule changes without treating a temporary physical pose as a new starting layout.