feat: Implement Live Director System for interactive simulation control
- 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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
name = "architect"
|
||||
description = "Evaluates architectural choices, proposes Godot 4 best practices, asks interactive clarification questions, and writes implementation specs."
|
||||
model = "gpt-5.6-sol"
|
||||
model = "gpt-6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name = "developer"
|
||||
description = "Implements GDScript logic and scene adjustments."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
model = "gpt-6-luna"
|
||||
model_reasoning_effort = "max"
|
||||
sandbox_mode = "workspace-write"
|
||||
|
||||
developer_instructions = """
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "writer"
|
||||
description = "Drafts and updates technical documentation, GDScript APIs, and architecture specs."
|
||||
model = "gpt-5.6-luna"
|
||||
model = "gpt-6-luna"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
developer_instructions = """
|
||||
|
||||
@@ -34,6 +34,24 @@ No CLI build, test, or lint commands are used. Run the project from the Godot ed
|
||||
|
||||
The main scene is configured as `run/main_scene="res://scenes/stickman_editor.tscn"` in `project.godot`.
|
||||
|
||||
### Sandbox Stage (F6)
|
||||
|
||||
Run `res://scenes/sandbox_stage.tscn` with **F6** to build and direct a scene. **EDIT** is for
|
||||
placing and transforming stage objects. **DIRECT** is for authoring stickman actions and event
|
||||
rules. Press **Space** or **Play** to start a run; press Space or **Direct** to freeze it.
|
||||
Space acts on each key press, so holding it does not repeatedly toggle the run. **Play** or Space
|
||||
resumes a paused run from its frozen pose and time. The stage clock is visible in the top bar as
|
||||
minutes, seconds, and centiseconds; it shows `00:00.00` in EDIT and planning DIRECT.
|
||||
|
||||
While a run is paused in DIRECT, you can add or edit stickman actions and event rules against the
|
||||
paused scene. Pending actions can be reordered among themselves; the active action stays fixed.
|
||||
Completed or interrupted actions can be deleted, with deletion taking effect on the next run from
|
||||
`t = 0`. Event rules are not reordered. Objects cannot be moved or placed while paused; return to
|
||||
EDIT for object changes. **Reset** or entering EDIT restores authored object transforms and time
|
||||
zero while keeping action and rule edits. Paused edits autosave after a short debounce. A paused
|
||||
walk or recovery resumes with its animation/tween phase intact; recovery does not start a second
|
||||
ragdoll, and recovered feet keep their IK targets aligned instead of crossing.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a body part
|
||||
|
||||
@@ -55,6 +55,11 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 43 | **Phase 10 thumbnail invalidation relies on path plus mtime** — custom cache keys will hash the full canonical path and include file modification time. | Low | Planned | This prevents equal basenames in different folders from sharing a PNG, while retaining cheap refresh checks. A filesystem with coarse timestamp resolution can miss two same-tick edits; content hashing is deferred unless observed. Cleanup must not remove built-in or unrelated external thumbnails. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
|
||||
| 44 | **Phase 10a Sandbox prop scaling depends on rigid-body collision scale behavior** — the approved corner gesture changes `PropBlock.scale`, so visual and physics bounds must agree for built-in circles/polygons and custom one-hull props. | Medium | Resolved | `PropBlock.begin_play_instance_scale()` transfers authored scale into child visuals and collision geometry during PLAY, including custom one-hull props and the nonuniform circle fallback, then `end_play_instance_scale()` restores the authored root scale in EDIT. `tests/test_phase10a_prop_scaling.gd` verifies scaled polygon and circle extents, rotated polygon contacts, dynamic PLAY contacts, and legacy nonuniform circle ellipse contacts. (2026-09-22) |
|
||||
| 45 | **Phase 10a geometric normalization excludes stroke and physical mass distribution** — Normalize Size fits the union of shape points to 96 px on its longest axis, scaling about the fixed prop origin while leaving outline width unchanged. | Low | Planned | This preserves anchor offsets and current Center Prop AABB semantics; an off-origin prop remains proportionally off-origin. A future visual-stroke fit or center-of-mass sizing tool would require a separate contract. Existing #39 tracks AABB versus physical center; #37 tracks missing document-wide undo. See `specs/phase_10a_prop_scaling.md`. (2026-09-21) |
|
||||
| 46 | **Phase 12 live queue editing needs session-only action identity and history** — authored indices shift during edits, and a reactive takeover replaces the runner's unconsumed runtime slice. | Medium | ✅ Resolved | Implemented stable session action IDs and a separate COMPLETED/INTERRUPTED ledger. Reactive consequences remain transient; a canceled primary action stays INTERRUPTED and runs only on the next `t = 0` run. Pending actions alone can reorder, active remains fixed, and consumed entries are delete-only. IDs are not serialized. Verified during Phase 12 editor verification. (2026-09-24) |
|
||||
| 47 | **Phase 12 pause requires explicit ownership of physics and procedural clocks** — ragdoll bodies, the runner, locomotion AnimationPlayer, and recovery tween have independent clocks. | Medium | ✅ Resolved | Pause/resume now snapshots and restores dynamic prop/ragdoll state, pauses runner and recovery-tween clocks, and resumes only locomotion animation that Phase 12 paused. A recovery already in progress stays in `RECOVERING` and completes after Resume; pausing does not rebuild a ragdoll. Stable ragdoll ownership supports multiple rigs. User verified the recovery-pause path and visible recovery result. (2026-09-24) |
|
||||
| 48 | **Phase 12 paused paths use one representative ground anchor** — the nearest terrain ray below live feet or ragdoll bounds supplies the pending path origin. | Low | ✅ Resolved | Implemented the approved representative ground anchor, with a live-feet fallback when the probe finds no surface. Multi-contact path planning across a sprawled ragdoll and uneven terrain remains outside Phase 12, but the current anchor is stable and inspectable for paused queue authoring. (2026-09-24) |
|
||||
| 49 | **Phase 12 event edges must be rebaselined without replay** — freezing bodies may emit contact exits/entries, and the stage polls area overlap before child rig movement. | Medium | ✅ Resolved | Event dispatch remains gated until a silent contact/overlap baseline is established on Resume. Existing overlaps do not refire because of the pause transition, and rules authored while paused apply to future events only. Reset clears runtime event edges. (2026-09-24) |
|
||||
| 50 | **Phase 12 paused-edit autosave stores authored state, not the frozen snapshot** — queue/rule edits occur at `t > 0` while stage bodies are at temporary physical transforms. | Medium | ✅ Resolved | A 1.0 s debounce writes the existing `.scene` schema using authored object transforms plus current queues/rules. The timer can finish after Resume; runtime pose, velocities, reactive consequences, and action history are session-only. Paused edits therefore autosave without turning the live pose into the authored starting layout. (2026-09-24) |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,6 +111,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 2026-09-22 | Phase 10a whole-prop drag amendment approved (`specs/phase_10a_prop_scaling.md` §3.4). In Arrange, a visible **Resize Whole Prop** toggle displays one union-bounds outline with large handles; dragging previews all shape points, circle radii, and non-origin anchors uniformly about canonical `(0,0)` from a captured snapshot. Release calls the existing document-scale transaction once; Esc, mode switch, toggle OFF, New, Load, and Clear discard a live preview. The preview stays outside authoritative save/autosave state, and the toggle adds no `.prop` field or fourth primary mode. Existing per-shape Arrange behavior remains when the toggle is OFF. Document-wide undo stays deferred under #37; this drag should become one future undo command. |
|
||||
| 2026-09-22 | Phase 10a whole-prop transform extension approved and implemented (`specs/phase_10a_prop_scaling.md` §3.4). The prior Resize Whole Prop toggle becomes one **Transform Whole Prop** tool that mirrors Arrange: drag visible geometry to move the whole document, drag the union rotation dot to rotate it about canonical `(0,0)`, and drag a corner for the existing uniform origin-based resize. Move translates every point and non-origin anchor (snapping the baseline delta when Snap is ON); rotate transforms every point/anchor position and adds the angle to editable anchor orientations; circle radius changes only on resize. All gestures preview from snapshots outside authoritative save state and commit once on release; Esc cancellation, selection restoration, mode lifecycle, `.prop` v1.0, and deferred document undo remain unchanged. |
|
||||
| 2026-09-22 | Phase 10a implementation and final UI were user verified. The Arrange top bar is ordered **Transform Whole Prop**, **Normalize Size**, **Center Prop**, **Show Stickman Guide**; the numeric scale textbox and Apply Scale button are removed. Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while Center Prop and Show Stickman Guide remain available. Whole-prop move, 15-degree Ctrl rotation, uniform resize, anchor following, Snap movement, and Esc cancellation are documented in `docs/prop_editor_user_guide.md`; focused tests and the Prop Editor startup smoke test passed. |
|
||||
| 2026-09-24 | Phase 12 Live Director implemented and editor-verified. PLAY/DIRECT pause and resume preserve simulation time and live state; the top-bar clock is visible, Space toggles on key press, and paused DIRECT supports action/event editing without object transforms. Pending-only reorder, consumed-action deletion, interrupted-action history, contact rebaseline, and authored-state autosave are documented in the README and spec. Follow-up fixes make locomotion animation resume reliably, preserve a paused recovery tween instead of re-ragdolling, and clear stale foot-contact locks so recovery does not cross the feet. Rows #46–#50 resolved; no `.scene` format change. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"scene_name": "Untitled",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 240.183868408203,
|
||||
"y": -357.797393798828
|
||||
},
|
||||
"zoom": 0.341506749391556
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1344.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1152.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_12",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_13",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_14",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1152.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_15",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1344.0,
|
||||
"y": 192.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -1116.0,
|
||||
"y": -313.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://stickmen/break.stk",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_queue": [
|
||||
{
|
||||
"type": "ragdoll"
|
||||
},
|
||||
{
|
||||
"type": "recover"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_1",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": -108.0,
|
||||
"y": -1152.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://props/briefcase.prop",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1790221912,
|
||||
"modified_unix": 1790268751.405,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Architecture Summary: Live Director System
|
||||
|
||||
The **Live Director System** transitions the sandbox from a static "plan-then-watch" tool into an interactive **Director Loop** ($\text{Play} \leftrightarrow \text{Pause/Direct} \leftrightarrow \text{Resume}$). Users can freeze the simulation mid-flight at any timestamp $t$, inspect current states, append or modify action paths relative to stickmen's live positions, and resume simulation seamlessly.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core State & Control Model
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ EDIT Mode (Authored t=0) │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ Play (from t=0)
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ PLAY Mode (Simulating) │
|
||||
└──────────┬────────────────▲──────────┘
|
||||
Pause / Direct │ │ Resume Sim
|
||||
(Freeze state at t) ▼ │
|
||||
┌──────────────────────────────────────┐
|
||||
│ DIRECT Mode (Mid-Sim State at t) │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ Stop / Reset
|
||||
▼
|
||||
Restores Authored t=0
|
||||
|
||||
```
|
||||
|
||||
- **Unified Pause Alias**: Pressing **`Spacebar`**, clicking **`⏸️ Pause`**, or selecting **`🎬 Direct`** while in `PLAY` mode triggers the exact same transition: freezing simulation at timestamp $t$ and opening Direct Mode editing.
|
||||
- **Resume**: Pressing **`Spacebar`** or clicking **`▶️ Play`** unfreezes physics and resumes simulation.
|
||||
- **Reset**: Clicking **`↺ Reset`** or selecting **`✏️ Edit`** returns the stage and props to $t=0$ authored positions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Physics, Timing, & State Freeze Mechanics
|
||||
|
||||
- **Multi-Part Ragdoll Velocity Caching**: Transitioning to Direct Mode iterates over all **10 individual bone `RigidBody2D` parts** in `StickmanRagdoll` as well as dynamic stage props. Linear and angular velocities are saved to node metadata (`set_meta`), and nodes are set to `freeze = true` (`FREEZE_MODE_KINEMATIC`). Momentum is restored per body upon unfreezing.
|
||||
- **Collision Penetration Protection**: Body transforms are recorded on pause and synced via `PhysicsServer2D` prior to waking up physics bodies on resume to prevent force ejection glitches.
|
||||
- **Tween & Process Clock Freezing**: Active procedural `Tween`s (`tween.pause()`), speech bubble timers, and runner process clocks freeze during pause.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Chrome & Visual Hierarchy
|
||||
|
||||
- **Live Timestamp Display (`TimestampBadge`)**:
|
||||
- **Location**: Top-right corner of the viewport (`PRESET_TOP_RIGHT`).
|
||||
- **Format**: Displays `⏱️ MM:SS.ms` (e.g. `00:04.25`).
|
||||
- **Theming**: The display's size and font should be dictated by the **sandbox_theme.json** file.
|
||||
- **Lifecycle**: Ticks continuously in `PLAY`, freezes on the exact paused frame in `DIRECT`, and resets to `00:00.00` in `EDIT`.
|
||||
|
||||
- **Viewport Path Visuals**:
|
||||
- **Completed History ($0 \rightarrow t$)**: Displayed in a **faded ghost style (20% alpha / desaturated grey)** or hidden to eliminate clutter.
|
||||
- **Pending Futures ($t \rightarrow \infty$)**: Displayed at **100% full opacity** with bright accent vectors originating at the stickman's live feet position ($P_{\text{live}}$) at time $t$.
|
||||
|
||||
- **Airborne & Ragdoll Spatial Anchoring**:
|
||||
- For airborne or ragdolled stickmen, a vertical down-raycast locates the nearest ground point ($P_{\text{ground}}$).
|
||||
- A vertical drop-line connects the character to $P_{\text{ground}}$, and new path vectors anchor directly to $P_{\text{ground}}$.
|
||||
|
||||
---
|
||||
|
||||
## 4. Action Execution & Edge Case Handling
|
||||
|
||||
- **Auto-Recovery**: Assigning a `walk_to` movement action to a ragdolled stickman automatically prepends a `recover` action so they stand up from their current location before navigating.
|
||||
- **Landing Barrier Compliance**: Actions appended while paused during a landing crouch compression (`LANDING_IMPACT`) stay queued behind `_is_action_queue_blocked()` until landing animation compression completes upon resuming.
|
||||
- **Active Action Cancellation**: Editing or deleting an action currently in progress at timestamp $t$ calls `_cancel_current_primary()` on `StickmanActionRunner` to clear active IK paths and speech overlays cleanly.
|
||||
- **Ragdoll Hitbox Selection**: Selection clicks in Direct Mode evaluate all child bone collision shapes in `RagdollBodyContainer` to ensure reliable clicking regardless of pose.
|
||||
- **Queue Persistence**: Actions added mid-sim ($t > 0$) append sequentially into the stickman's master queue. Resetting to $t=0$ restores stage layout but retains all newly authored queue actions for playback from the start.
|
||||
|
||||
---
|
||||
|
||||
## 5. Explicitly Out of Scope
|
||||
|
||||
- **Stage & Action Undo/Redo (`Ctrl+Z` / `Ctrl+Y`)**: Stage-wide and action queue undo/redo stacks remain explicitly **OUT OF SCOPE** for this implementation phase. Misplaced items or actions are modified or removed manually via viewport gizmos and selection menus.
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"global": { "font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8 },
|
||||
"fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
|
||||
"fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "timestamp_badge":22, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
|
||||
"mode_colors": { "edit":"#22C6FF", "direct":"#FFB300", "play":"#33DD77", "guide":"#22C6FF" }, "grid": { "snap_size":15.0 },
|
||||
"chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
|
||||
"chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "timestamp_badge":{"corner_radius":10,"padding":8,"bg_color":"#171B24F2"}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
|
||||
"popups": { "style_defaults":{"bg_color":"#202633FA","border_color":"#FFFFFF33","corner_radius":12,"padding":12}, "action_menu":{"font_size":24}, "dialog":{}, "asset_selector":{"backdrop_alpha":0.55,"cell_size":[190,220],"thumbnail_size":[156,156]} },
|
||||
"palette": { "style_defaults":{"bg_color":"#252B38FA","corner_radius":10,"padding":6}, "dock_width":116, "card_size":[96,94], "icon_size":30, "drag_threshold":8, "selected_border_width":3 },
|
||||
"overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
|
||||
"overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "completed_path_alpha":0.20, "completed_path_color":"#959EAD", "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
|
||||
"widgets": { "swatches":["#FFFFFF","#111111","#22C6FF","#FFB300","#33DD77","#FF4D4D"], "size_presets":[{"id":"small","label":"Small","value":20},{"id":"medium","label":"Medium","value":28},{"id":"huge","label":"HUGE","value":40}], "emoji":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
|
||||
}
|
||||
|
||||
+29
-12
@@ -10,6 +10,7 @@ extends PopupPanel
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
|
||||
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
|
||||
|
||||
signal edit_requested(index: int)
|
||||
signal delete_requested(index: int)
|
||||
@@ -65,14 +66,15 @@ func refresh() -> void:
|
||||
_drag_index = -1
|
||||
_drag_target = -1
|
||||
|
||||
var queue: Array[Dictionary] = []
|
||||
var queue_view: Array[Dictionary] = []
|
||||
if rig != null and is_instance_valid(rig):
|
||||
queue = rig.get_queue()
|
||||
queue_view = rig.get_queue_view()
|
||||
_title_label.text = "Stickman: %s" % (String(rig.name) if rig != null and is_instance_valid(rig) else "?")
|
||||
_empty_label.visible = queue.is_empty()
|
||||
_empty_label.visible = queue_view.is_empty()
|
||||
|
||||
for i: int in queue.size():
|
||||
_rows.append(_make_row(i, queue[i]))
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
_rows.append(_make_row(i, entry.get("action", {}), int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))))
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
@@ -129,7 +131,7 @@ func _build_ui() -> void:
|
||||
min_size = Vector2i(460, 360)
|
||||
|
||||
|
||||
func _make_row(index: int, action: Dictionary) -> PanelContainer:
|
||||
func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||||
|
||||
@@ -145,10 +147,15 @@ func _make_row(index: int, action: Dictionary) -> PanelContainer:
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
|
||||
row.add_child(summary)
|
||||
var status_label := Label.new()
|
||||
status_label.text = _status_label(status)
|
||||
status_label.modulate = Color(1.0, 1.0, 1.0, 0.65) if status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED else Color.WHITE
|
||||
row.add_child(status_label)
|
||||
|
||||
var edit := Button.new()
|
||||
edit.text = "✎"
|
||||
edit.tooltip_text = "Edit"
|
||||
edit.disabled = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
edit.pressed.connect(func() -> void: edit_requested.emit(index))
|
||||
row.add_child(edit)
|
||||
|
||||
@@ -161,6 +168,7 @@ func _make_row(index: int, action: Dictionary) -> PanelContainer:
|
||||
var drag := Button.new()
|
||||
drag.text = "≡"
|
||||
drag.tooltip_text = "Drag to reorder"
|
||||
drag.disabled = status == ACTION_RUNNER.QueueStatus.ACTIVE or status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||||
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||||
row.add_child(drag)
|
||||
@@ -226,6 +234,20 @@ func _row_style(bg: Color) -> StyleBoxFlat:
|
||||
return sb
|
||||
|
||||
|
||||
func _status_label(status: int) -> String:
|
||||
match status:
|
||||
ACTION_RUNNER.QueueStatus.COMPLETED:
|
||||
return "✓ Done"
|
||||
ACTION_RUNNER.QueueStatus.INTERRUPTED:
|
||||
return "↯ Interrupted"
|
||||
ACTION_RUNNER.QueueStatus.ACTIVE:
|
||||
return "▶ Active"
|
||||
ACTION_RUNNER.QueueStatus.PENDING:
|
||||
return "• Pending"
|
||||
_:
|
||||
return ""
|
||||
|
||||
|
||||
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||||
if (event as InputEventMouseButton).pressed:
|
||||
@@ -281,9 +303,4 @@ func _move_action(from: int, to: int) -> void:
|
||||
var queue := rig.get_queue()
|
||||
if from < 0 or from >= queue.size() or to < 0 or to >= queue.size():
|
||||
return
|
||||
var action: Dictionary = queue[from]
|
||||
rig.remove_action(from)
|
||||
var target := to
|
||||
if to > from:
|
||||
target -= 1
|
||||
rig.insert_action(target, action)
|
||||
rig.move_action(from, to)
|
||||
|
||||
+450
-74
@@ -46,6 +46,7 @@ const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholde
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum StageMode { EDIT, DIRECT, PLAY }
|
||||
enum RunState { STOPPED, RUNNING, PAUSED }
|
||||
|
||||
## Phase 4 rule-builder state machine steps.
|
||||
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
|
||||
@@ -63,6 +64,9 @@ signal object_placed(node: Node2D)
|
||||
signal object_selected(nodes: Array[Node2D])
|
||||
signal object_deselected()
|
||||
signal object_deleted(nodes: Array[Node2D])
|
||||
signal simulation_paused(elapsed_seconds: float)
|
||||
signal simulation_resumed(elapsed_seconds: float)
|
||||
signal simulation_reset
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -150,6 +154,13 @@ func _stage_dbg(msg: String) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var current_mode: StageMode = StageMode.EDIT
|
||||
var _run_state: RunState = RunState.STOPPED
|
||||
var _elapsed_seconds: float = 0.0
|
||||
var _event_dispatch_ready: bool = false
|
||||
var _resume_baseline_frames: int = 0
|
||||
var _prop_pause_states: Dictionary = {}
|
||||
var _suppressed_contact_pairs: Dictionary = {}
|
||||
var _paused_edit_autosave_remaining: float = -1.0
|
||||
|
||||
var _spawner: StageSpawner
|
||||
var _selection: StageSelection
|
||||
@@ -183,6 +194,12 @@ var _direct_hint_label: Label
|
||||
var _mode_frame: Panel
|
||||
var _mode_badge: PanelContainer
|
||||
var _mode_badge_label: Label
|
||||
var _timestamp_badge: PanelContainer = null
|
||||
var _timestamp_label: Label = null
|
||||
var _run_controls: HBoxContainer = null
|
||||
var _pause_button: Button = null
|
||||
var _resume_button: Button = null
|
||||
var _reset_button: Button = null
|
||||
var _tooltip: Label
|
||||
var _grid_check: CheckBox
|
||||
var _snap_check: CheckBox
|
||||
@@ -212,6 +229,8 @@ var _cli_startup: bool = false
|
||||
|
||||
var _panning: bool = false
|
||||
var _pan_last: Vector2 = Vector2.ZERO
|
||||
var _space_key_down: bool = false
|
||||
var _space_press_owned: bool = false
|
||||
|
||||
var _box_selecting: bool = false
|
||||
var _box_start: Vector2 = Vector2.ZERO
|
||||
@@ -366,6 +385,7 @@ var _panel_rig: StickmanRig = null
|
||||
## Waypoint right-click context (which rig/queue index/position was clicked).
|
||||
var _ctx_waypoint_rig: StickmanRig = null
|
||||
var _ctx_waypoint_index: int = -1
|
||||
var _ctx_waypoint_action_id: int = -1
|
||||
var _ctx_waypoint_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
## Rule-panel filter: source id (>= 0) OR waypoint (finite); never both.
|
||||
@@ -383,12 +403,16 @@ var _capture_cancel: Callable = Callable()
|
||||
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
|
||||
var _walk_edit_rig: StickmanRig = null
|
||||
var _walk_edit_index: int = -1
|
||||
var _walk_edit_action_id: int = -1
|
||||
var _walk_edit_from_panel: bool = false
|
||||
|
||||
## ActionEditor context for queue/rule add/edit flows.
|
||||
var _action_editor_kind: String = ""
|
||||
var _action_editor_index: int = -1
|
||||
var _action_editor_rig: StickmanRig = null
|
||||
var _action_editor_action_id: int = -1
|
||||
var _action_editor_target_action_id: int = -1
|
||||
var _action_editor_insert_after: bool = false
|
||||
var _action_editor_actor_id: int = -1
|
||||
var _action_editor_restore_queue: bool = false
|
||||
var _pending_rule_action_flat: Dictionary = {}
|
||||
@@ -433,6 +457,8 @@ func _ready() -> void:
|
||||
_apply_mode_frame()
|
||||
_sync_mode_buttons()
|
||||
_refresh_mode_badge()
|
||||
_set_timestamp(0.0)
|
||||
_sync_run_controls()
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
_update_scene_chrome()
|
||||
@@ -457,14 +483,28 @@ func _process(delta: float) -> void:
|
||||
if _toast_timer <= 0.0:
|
||||
_toast_text = ""
|
||||
_refresh_status()
|
||||
if _paused_edit_autosave_remaining >= 0.0:
|
||||
_paused_edit_autosave_remaining -= delta
|
||||
if _paused_edit_autosave_remaining <= 0.0:
|
||||
_paused_edit_autosave_remaining = -1.0
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
||||
_dirty = true
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _run_state == RunState.RUNNING:
|
||||
_elapsed_seconds += delta
|
||||
_set_timestamp(_elapsed_seconds)
|
||||
_sync_pending_editable_physics_transforms()
|
||||
if _restore_frames_left > 0:
|
||||
_restore_frames_left -= 1
|
||||
_restore_authored_state()
|
||||
if current_mode == StageMode.PLAY:
|
||||
if _resume_baseline_frames > 0:
|
||||
_refresh_event_edge_baseline()
|
||||
_resume_baseline_frames -= 1
|
||||
if _resume_baseline_frames == 0:
|
||||
call_deferred("_finish_resume_baseline")
|
||||
if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING:
|
||||
_update_area_entry()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -472,6 +512,9 @@ func _physics_process(_delta: float) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE:
|
||||
_handle_space_key_event(event as InputEventKey)
|
||||
return
|
||||
if not _palette_drag_payload.is_empty():
|
||||
if event is InputEventMouseMotion:
|
||||
_update_palette_drag((event as InputEventMouseMotion).position)
|
||||
@@ -525,13 +568,62 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
elif _selector_open:
|
||||
_on_selector_cancelled()
|
||||
elif current_mode == StageMode.DIRECT:
|
||||
set_mode(StageMode.EDIT)
|
||||
if _action_popup != null and _action_popup.visible:
|
||||
_action_popup.hide()
|
||||
elif _queue_panel != null and _queue_panel.visible:
|
||||
_queue_panel.hide()
|
||||
elif _rule_panel != null and _rule_panel.visible:
|
||||
_rule_panel.hide()
|
||||
elif _action_editor != null and _action_editor.visible:
|
||||
_action_editor.hide()
|
||||
elif _rule_editor != null and _rule_editor.visible:
|
||||
_rule_editor.hide()
|
||||
elif _placement_id != "":
|
||||
set_placement_mode("")
|
||||
elif not _selection.get_selected().is_empty():
|
||||
_selection.clear_selection()
|
||||
|
||||
|
||||
func _handle_space_key_event(event: InputEventKey) -> void:
|
||||
if not event.pressed:
|
||||
if _space_press_owned:
|
||||
get_viewport().set_input_as_handled()
|
||||
_space_key_down = false
|
||||
_space_press_owned = false
|
||||
return
|
||||
if _space_key_down:
|
||||
if _space_press_owned:
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
_space_key_down = true
|
||||
if event.echo or (current_mode != StageMode.PLAY and current_mode != StageMode.DIRECT):
|
||||
return
|
||||
if _space_should_pass_to_gui():
|
||||
return
|
||||
_space_press_owned = true
|
||||
if not _pause_toggle_blocked():
|
||||
if current_mode == StageMode.PLAY:
|
||||
set_mode(StageMode.DIRECT)
|
||||
else:
|
||||
set_mode(StageMode.PLAY)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _space_should_pass_to_gui() -> bool:
|
||||
var focus: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
|
||||
return true
|
||||
for window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
|
||||
if window != null and window.visible:
|
||||
return true
|
||||
if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible):
|
||||
return true
|
||||
for popup: PopupMenu in [_action_popup, _trigger_popup, _rule_action_popup, _rule_more_popup, _waypoint_context, _rig_context_popup]:
|
||||
if popup != null and popup.visible:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||||
match mb.button_index:
|
||||
MOUSE_BUTTON_WHEEL_UP:
|
||||
@@ -646,7 +738,7 @@ func set_mode(mode: StageMode) -> void:
|
||||
if mode == current_mode:
|
||||
return
|
||||
var previous_mode: StageMode = current_mode
|
||||
if mode == StageMode.PLAY and not _cli_startup:
|
||||
if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup:
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
||||
push_warning("SandboxStage: autosave before Play failed.")
|
||||
current_mode = mode
|
||||
@@ -663,52 +755,24 @@ func set_mode(mode: StageMode) -> void:
|
||||
_apply_grid_settings()
|
||||
_apply_mode_frame()
|
||||
_refresh_mode_badge()
|
||||
_sync_run_controls()
|
||||
_apply_cursor()
|
||||
mode_changed.emit(int(mode))
|
||||
_refresh_status()
|
||||
|
||||
|
||||
func _enter_edit_shared() -> void:
|
||||
# Restore stickmen to their authored spawn/ghost pose (no stand-up tween
|
||||
# glide), stopping any director queue first.
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.stop_queue()
|
||||
rig.set_terrain_following_enabled(false)
|
||||
rig.snap_to_standing()
|
||||
rig.clear_reactive_actions()
|
||||
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
||||
# any object that may not be in the authored map. freeze_mode is set BEFORE
|
||||
# freeze so the body freezes directly as kinematic, never via the static
|
||||
# layer (whose transform sync can drop a subsequent position/rotation set).
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).end_play_instance_scale()
|
||||
_restore_authored_state()
|
||||
# The freeze/teleport can take a physics frame or two to settle in the engine,
|
||||
# so re-assert the authored transform on the next few physics frames.
|
||||
_restore_frames_left = 3
|
||||
_gizmos.set_enabled(true)
|
||||
_director_visuals.set_enabled(true)
|
||||
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
_reset_run()
|
||||
|
||||
|
||||
func _enter_edit_mode(previous_mode: StageMode) -> void:
|
||||
# EDIT and DIRECT share the same frozen world. PLAY still needs the complete
|
||||
# simulation cleanup and authored-pose restore before editing resumes.
|
||||
if previous_mode != StageMode.DIRECT:
|
||||
func _enter_edit_mode(_previous_mode: StageMode) -> void:
|
||||
if _run_state != RunState.STOPPED:
|
||||
_enter_edit_shared()
|
||||
_clear_director_pending()
|
||||
_set_edit_controls_visible(true)
|
||||
_set_direct_controls_visible(false)
|
||||
_gizmos.set_enabled(true)
|
||||
_director_visuals.set_enabled(true)
|
||||
|
||||
|
||||
## Cancels any in-progress director/rule-builder click flows when leaving a mode
|
||||
@@ -741,16 +805,16 @@ func _clear_director_pending() -> void:
|
||||
## but with the spawner/grid controls hidden, the director hint shown, and any
|
||||
## placement/selection/pending-target cleared.
|
||||
func _enter_direct_mode(previous_mode: StageMode) -> void:
|
||||
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
|
||||
# placed stickman pose between them; a transition from PLAY still needs the
|
||||
# full cleanup and authored-pose restore.
|
||||
if previous_mode != StageMode.EDIT:
|
||||
_enter_edit_shared()
|
||||
if previous_mode == StageMode.PLAY and _run_state == RunState.RUNNING:
|
||||
_pause_run()
|
||||
set_placement_mode("")
|
||||
_selection.clear_selection()
|
||||
_clear_director_pending()
|
||||
_set_edit_controls_visible(false)
|
||||
_set_direct_controls_visible(true)
|
||||
_gizmos.set_enabled(false)
|
||||
_director_visuals.set_enabled(true)
|
||||
_director_visuals.set_live_session(_run_state == RunState.PAUSED)
|
||||
|
||||
|
||||
func _enter_play_mode() -> void:
|
||||
@@ -763,24 +827,219 @@ func _enter_play_mode() -> void:
|
||||
_director_visuals.set_enabled(false)
|
||||
_set_edit_controls_visible(false)
|
||||
_set_direct_controls_visible(false)
|
||||
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
||||
if _run_state == RunState.PAUSED:
|
||||
_resume_run()
|
||||
else:
|
||||
_start_run()
|
||||
_gizmos.set_enabled(false)
|
||||
_director_visuals.set_enabled(false)
|
||||
_director_visuals.set_live_session(false)
|
||||
_set_direct_controls_visible(false)
|
||||
_stage_dbg("PLAY run_state=%s" % RunState.keys()[int(_run_state)])
|
||||
|
||||
|
||||
func _start_run() -> void:
|
||||
_elapsed_seconds = 0.0
|
||||
_set_timestamp(0.0)
|
||||
_run_state = RunState.RUNNING
|
||||
_event_dispatch_ready = true
|
||||
_resume_baseline_frames = 0
|
||||
_prop_pause_states.clear()
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
_suppressed_contact_pairs.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).begin_play_instance_scale()
|
||||
(node as RigidBody2D).freeze = false
|
||||
var rig_count := 0
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
var rig := node as StickmanRig
|
||||
rig.reset_simulation_pause()
|
||||
rig.stop_queue()
|
||||
rig.clear_reactive_actions()
|
||||
rig.auto_recover = false
|
||||
rig.begin_terrain_fall()
|
||||
rig.start_queue()
|
||||
rig_count += 1
|
||||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||||
_sync_run_controls()
|
||||
|
||||
|
||||
func _pause_run() -> void:
|
||||
if _run_state != RunState.RUNNING:
|
||||
return
|
||||
_event_dispatch_ready = false
|
||||
_run_state = RunState.PAUSED
|
||||
_resume_baseline_frames = 0
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
(node as StickmanRig).pause_simulation()
|
||||
elif node is RigidBody2D:
|
||||
_capture_prop_pause_state(node as RigidBody2D)
|
||||
_refresh_event_edge_baseline()
|
||||
_gizmos.set_enabled(false)
|
||||
_selection.clear_selection()
|
||||
_director_visuals.set_live_session(true)
|
||||
_director_visuals.set_enabled(true)
|
||||
_set_direct_controls_visible(true)
|
||||
_sync_run_controls()
|
||||
simulation_paused.emit(_elapsed_seconds)
|
||||
|
||||
|
||||
func _resume_run() -> void:
|
||||
if _run_state != RunState.PAUSED:
|
||||
return
|
||||
_clear_director_pending()
|
||||
for body_id: int in _prop_pause_states.keys():
|
||||
var snapshot: Dictionary = _prop_pause_states[body_id]
|
||||
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
|
||||
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
|
||||
continue
|
||||
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
body.freeze = bool(snapshot.get("freeze", false))
|
||||
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
|
||||
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
|
||||
body.sleeping = bool(snapshot.get("sleeping", false))
|
||||
_prop_pause_states.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
(node as StickmanRig).resume_simulation()
|
||||
_run_state = RunState.RUNNING
|
||||
_event_dispatch_ready = false
|
||||
_resume_baseline_frames = 2
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
_suppressed_contact_pairs.clear()
|
||||
_sync_run_controls()
|
||||
simulation_resumed.emit(_elapsed_seconds)
|
||||
|
||||
|
||||
func _reset_run() -> void:
|
||||
_event_dispatch_ready = false
|
||||
_run_state = RunState.STOPPED
|
||||
_resume_baseline_frames = 0
|
||||
_paused_edit_autosave_remaining = -1.0
|
||||
_prop_pause_states.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as StickmanRig
|
||||
rig.stop_queue()
|
||||
rig.set_terrain_following_enabled(false)
|
||||
rig.snap_to_standing()
|
||||
rig.clear_reactive_actions()
|
||||
rig.reset_simulation_pause()
|
||||
elif node is RigidBody2D:
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
body.linear_velocity = Vector2.ZERO
|
||||
body.angular_velocity = 0.0
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).end_play_instance_scale()
|
||||
_restore_authored_state()
|
||||
_restore_frames_left = 3
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
_suppressed_contact_pairs.clear()
|
||||
_elapsed_seconds = 0.0
|
||||
_set_timestamp(0.0)
|
||||
_director_visuals.set_live_session(false)
|
||||
_sync_run_controls()
|
||||
simulation_reset.emit()
|
||||
|
||||
|
||||
func _capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
|
||||
var snapshot: Dictionary = {"body": body, "transform": body.global_transform, "linear_velocity": body.linear_velocity, "angular_velocity": body.angular_velocity, "sleeping": body.sleeping, "freeze": body.freeze, "freeze_mode": body.freeze_mode}
|
||||
_prop_pause_states[body.get_instance_id()] = snapshot
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
return snapshot
|
||||
|
||||
|
||||
func _set_timestamp(seconds: float) -> void:
|
||||
if _timestamp_label == null:
|
||||
return
|
||||
var safe_seconds: float = maxf(seconds, 0.0)
|
||||
var minutes: int = int(floor(safe_seconds / 60.0))
|
||||
var remainder: float = fmod(safe_seconds, 60.0)
|
||||
var centiseconds: int = int(floor(remainder * 100.0))
|
||||
_timestamp_label.text = "⏱️ %02d:%02d.%02d" % [minutes, int(centiseconds / 100), centiseconds % 100]
|
||||
|
||||
|
||||
func _sync_run_controls() -> void:
|
||||
var running: bool = _run_state == RunState.RUNNING
|
||||
var paused: bool = _run_state == RunState.PAUSED
|
||||
if _pause_button != null:
|
||||
_pause_button.visible = running and current_mode == StageMode.PLAY
|
||||
if _resume_button != null:
|
||||
_resume_button.visible = paused and current_mode == StageMode.DIRECT
|
||||
if _reset_button != null:
|
||||
_reset_button.visible = running or paused
|
||||
if _timestamp_badge != null:
|
||||
_timestamp_badge.visible = true
|
||||
|
||||
|
||||
func _finish_resume_baseline() -> void:
|
||||
if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY:
|
||||
_event_dispatch_ready = true
|
||||
|
||||
|
||||
func _pause_toggle_blocked() -> bool:
|
||||
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _gizmos.is_dragging() or _selector_open:
|
||||
return true
|
||||
var focus: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
|
||||
return true
|
||||
for dialog: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
|
||||
if dialog != null and dialog.visible:
|
||||
return true
|
||||
return (_queue_panel != null and _queue_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible)
|
||||
|
||||
|
||||
func _refresh_event_edge_baseline() -> void:
|
||||
var areas: Array[TriggerArea] = []
|
||||
for child: Node in _world.get_children():
|
||||
if child is TriggerArea:
|
||||
areas.append(child as TriggerArea)
|
||||
var movables: Array[Node2D] = []
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG and (node as StickmanRig).state == StickmanRig.RigState.ANIMATED:
|
||||
movables.append(node)
|
||||
elif node is PropBlock:
|
||||
movables.append(node)
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
for node: Node2D in movables:
|
||||
var node_id: int = node.get_instance_id()
|
||||
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
|
||||
_area_previous_bounds[node_id] = bounds
|
||||
for area: TriggerArea in areas:
|
||||
var key: String = "%d:%d" % [area.get_instance_id(), node_id]
|
||||
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
|
||||
_area_overlap[key] = bounds.intersects(area_rect, true)
|
||||
_collision_pairs.clear()
|
||||
_suppressed_contact_pairs.clear()
|
||||
for child: Node2D in _world_children_selectable():
|
||||
if not child is PropBlock:
|
||||
continue
|
||||
var prop := child as PropBlock
|
||||
for other: Node in prop.get_colliding_bodies():
|
||||
if other == null or not is_instance_valid(other):
|
||||
continue
|
||||
_suppressed_contact_pairs["%d:%d" % [prop.get_instance_id(), other.get_instance_id()]] = true
|
||||
if not other.has_meta("stickman_rig"):
|
||||
continue
|
||||
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
continue
|
||||
var key: String = "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||||
var pair: Dictionary = _collision_pairs.get(key, {"rig_id": rig.get_instance_id(), "prop_id": prop.get_instance_id(), "bodies": {}})
|
||||
var body_ids: Dictionary = pair.get("bodies", {})
|
||||
body_ids[other.get_instance_id()] = true
|
||||
pair["bodies"] = body_ids
|
||||
_collision_pairs[key] = pair
|
||||
|
||||
|
||||
func _save_object_state(node: Node2D) -> void:
|
||||
@@ -1258,6 +1517,8 @@ func delete_selected() -> void:
|
||||
func _mark_scene_dirty() -> void:
|
||||
if not _suppress_dirty:
|
||||
_set_scene_dirty(true)
|
||||
if _run_state == RunState.PAUSED:
|
||||
_paused_edit_autosave_remaining = 1.0
|
||||
|
||||
|
||||
func _set_scene_dirty(value: bool) -> void:
|
||||
@@ -1267,6 +1528,8 @@ func _set_scene_dirty(value: bool) -> void:
|
||||
|
||||
func _on_rig_queue_changed() -> void:
|
||||
_director_visuals.mark_dirty()
|
||||
if _queue_panel != null and _panel_rig != null and is_instance_valid(_panel_rig):
|
||||
_queue_panel.refresh()
|
||||
_mark_scene_dirty()
|
||||
|
||||
|
||||
@@ -1327,6 +1590,7 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
|
||||
if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE
|
||||
return false
|
||||
if not is_autosave:
|
||||
_paused_edit_autosave_remaining = -1.0
|
||||
_current_scene_path = path
|
||||
if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system()
|
||||
_set_scene_dirty(false); _add_recent_scene(path)
|
||||
@@ -1561,6 +1825,9 @@ func _show_cli_error_dialog(message: String) -> void:
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_APPLICATION_FOCUS_OUT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
_space_key_down = false
|
||||
_space_press_owned = false
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
_request_pending_file_action(PendingFileAction.QUIT)
|
||||
|
||||
@@ -1989,6 +2256,55 @@ func _build_ui() -> void:
|
||||
_mode_badge.add_child(_mode_badge_label)
|
||||
ui.add_child(_mode_badge)
|
||||
|
||||
_run_controls = HBoxContainer.new()
|
||||
_run_controls.name = "RunControls"
|
||||
_run_controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||
_run_controls.offset_top = TOP_BAR_HEIGHT + 8.0
|
||||
_run_controls.offset_right = -12.0
|
||||
_run_controls.add_theme_constant_override("separation", 6)
|
||||
ui.add_child(_run_controls)
|
||||
_pause_button = Button.new()
|
||||
_pause_button.text = "⏸️ Pause"
|
||||
_pause_button.pressed.connect(func() -> void: set_mode(StageMode.DIRECT))
|
||||
_run_controls.add_child(_pause_button)
|
||||
_resume_button = Button.new()
|
||||
_resume_button.text = "▶️ Resume"
|
||||
_resume_button.pressed.connect(func() -> void: set_mode(StageMode.PLAY))
|
||||
_run_controls.add_child(_resume_button)
|
||||
_reset_button = Button.new()
|
||||
_reset_button.text = "↺ Reset"
|
||||
_reset_button.pressed.connect(func() -> void: set_mode(StageMode.EDIT))
|
||||
_run_controls.add_child(_reset_button)
|
||||
for button: Button in [_pause_button, _resume_button, _reset_button]:
|
||||
button.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
|
||||
|
||||
_timestamp_badge = PanelContainer.new()
|
||||
_timestamp_badge.name = "TimestampBadge"
|
||||
_timestamp_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_timestamp_badge.offset_left = -220.0
|
||||
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
|
||||
_timestamp_badge.offset_right = -12.0
|
||||
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
|
||||
var timestamp_style := StyleBoxFlat.new()
|
||||
timestamp_style.bg_color = _sandbox_theme.color("chrome.timestamp_badge.bg_color", Color("#171B24F2")) if _sandbox_theme != null else Color("#171B24F2")
|
||||
timestamp_style.set_corner_radius_all(_sandbox_theme.integer("chrome.timestamp_badge.corner_radius", 10, 0, 48) if _sandbox_theme != null else 10)
|
||||
var timestamp_padding: float = float(_sandbox_theme.integer("chrome.timestamp_badge.padding", 8, 0, 48)) if _sandbox_theme != null else 8.0
|
||||
timestamp_style.content_margin_left = timestamp_padding
|
||||
timestamp_style.content_margin_right = timestamp_padding
|
||||
timestamp_style.content_margin_top = timestamp_padding * 0.5
|
||||
timestamp_style.content_margin_bottom = timestamp_padding * 0.5
|
||||
_timestamp_badge.add_theme_stylebox_override("panel", timestamp_style)
|
||||
_timestamp_label = Label.new()
|
||||
_timestamp_label.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.timestamp_badge", 22, 12, 48) if _sandbox_theme != null else 22)
|
||||
_timestamp_label.add_theme_color_override("font_color", _sandbox_theme.color("global.text_color", Color("#F5F7FA")) if _sandbox_theme != null else Color("#F5F7FA"))
|
||||
_timestamp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_timestamp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_timestamp_label.custom_minimum_size = Vector2(192.0, 30.0)
|
||||
_apply_ui_font(_timestamp_label)
|
||||
_timestamp_badge.add_child(_timestamp_label)
|
||||
ui.add_child(_timestamp_badge)
|
||||
|
||||
# Action tooltip (cursor-attached, hidden unless a click-awaiting step is active).
|
||||
_tooltip = Label.new()
|
||||
_tooltip.name = "ActionTooltip"
|
||||
@@ -2008,6 +2324,8 @@ func _build_ui() -> void:
|
||||
_tooltip.add_theme_color_override("font_color", Color.WHITE)
|
||||
_apply_ui_font(_tooltip)
|
||||
ui.add_child(_tooltip)
|
||||
_sync_run_controls()
|
||||
_set_timestamp(0.0)
|
||||
|
||||
_action_popup = PopupMenu.new()
|
||||
_action_popup.add_item("🚶 Walk To", ACT_WALK)
|
||||
@@ -3305,10 +3623,14 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
|
||||
# Physics contacts can be delivered one frame after PLAY is stopped. Never
|
||||
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
|
||||
# authoritative snap_to_standing() cleanup has run.
|
||||
if current_mode != StageMode.PLAY:
|
||||
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
|
||||
return
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
var contact_key: String = "%d:%d" % [prop.get_instance_id(), other.get_instance_id()]
|
||||
if _suppressed_contact_pairs.has(contact_key):
|
||||
return
|
||||
_suppressed_contact_pairs[contact_key] = true
|
||||
if bool(other.get_meta("stickman_ragdoll_body", false)):
|
||||
return
|
||||
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
|
||||
@@ -3341,8 +3663,11 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
|
||||
|
||||
|
||||
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
|
||||
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
|
||||
return
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
_suppressed_contact_pairs.erase("%d:%d" % [prop.get_instance_id(), other.get_instance_id()])
|
||||
if not other.has_meta("stickman_rig"):
|
||||
return
|
||||
var rig := other.get_meta("stickman_rig") as StickmanRig
|
||||
@@ -3376,6 +3701,8 @@ func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
|
||||
|
||||
## Iterates every rule in order; every matching rule executes (no short-circuit).
|
||||
func _handle_event(event: Dictionary) -> void:
|
||||
if _run_state != RunState.RUNNING or not _event_dispatch_ready:
|
||||
return
|
||||
for rule: Dictionary in _event_rules:
|
||||
if _rule_matches(rule, event):
|
||||
_execute_rule_actions(rule)
|
||||
@@ -3552,10 +3879,11 @@ func _handle_right_click() -> void:
|
||||
func _open_waypoint_context(world_pos: Vector2, wp_hit: Dictionary) -> void:
|
||||
_ctx_waypoint_rig = wp_hit["rig"]
|
||||
_ctx_waypoint_index = int(wp_hit["index"])
|
||||
_ctx_waypoint_action_id = int(wp_hit.get("action_id", _ctx_waypoint_rig.action_id_at(_ctx_waypoint_index)))
|
||||
_ctx_waypoint_pos = wp_hit["pos"]
|
||||
var count := _count_rules_for_waypoint(_ctx_waypoint_pos)
|
||||
var screen := _world_to_screen(world_pos)
|
||||
_waypoint_context.popup_for(Rect2i(Vector2i(screen), Vector2i.ZERO), count)
|
||||
_waypoint_context.popup_for(Rect2i(Vector2i(screen), Vector2i.ZERO), count, int(wp_hit.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED)))
|
||||
|
||||
|
||||
func _open_rig_context(world_pos: Vector2, rig: StickmanRig) -> void:
|
||||
@@ -3566,19 +3894,28 @@ func _open_rig_context(world_pos: Vector2, rig: StickmanRig) -> void:
|
||||
|
||||
func _on_waypoint_context_id_pressed(id: int) -> void:
|
||||
var rig := _ctx_waypoint_rig
|
||||
var index := _ctx_waypoint_index
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
return
|
||||
var index: int = rig.index_for_action_id(_ctx_waypoint_action_id)
|
||||
if index < 0:
|
||||
_show_toast("That action is no longer in the queue")
|
||||
return
|
||||
var status: int = rig.get_action_status(_ctx_waypoint_action_id)
|
||||
match id:
|
||||
WaypointContext.EDIT_WALK:
|
||||
if _queue_action_consumed(status): return
|
||||
_begin_walk_edit(rig, index, false)
|
||||
WaypointContext.DELETE_WALK:
|
||||
rig.remove_action(index)
|
||||
_show_toast("Walk deleted")
|
||||
WaypointContext.INSERT_BEFORE:
|
||||
_open_action_editor("queue_insert", {}, index, rig, -1, false)
|
||||
if _queue_action_consumed(status) or status == StickmanActionRunner.QueueStatus.ACTIVE:
|
||||
_show_toast("Insert after the active action")
|
||||
return
|
||||
_open_action_editor("queue_insert", {}, index, rig, -1, false, _ctx_waypoint_action_id, false)
|
||||
WaypointContext.INSERT_AFTER:
|
||||
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false)
|
||||
if _queue_action_consumed(status): return
|
||||
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false, _ctx_waypoint_action_id, true)
|
||||
WaypointContext.EDIT_TRIGGER_RULES:
|
||||
_open_trigger_rules_panel(_ctx_waypoint_pos)
|
||||
|
||||
@@ -3670,8 +4007,11 @@ func _resolve_capture(world_pos: Vector2) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
|
||||
if index < 0 or index >= rig.queue_size():
|
||||
return
|
||||
_walk_edit_rig = rig
|
||||
_walk_edit_index = index
|
||||
_walk_edit_action_id = rig.action_id_at(index)
|
||||
_walk_edit_from_panel = from_panel
|
||||
var queue := rig.get_queue()
|
||||
var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
|
||||
@@ -3681,21 +4021,25 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
|
||||
|
||||
func _on_walk_edit_captured(value: Vector2) -> void:
|
||||
var rig := _walk_edit_rig
|
||||
var index := _walk_edit_index
|
||||
var action_id: int = _walk_edit_action_id
|
||||
var from_panel := _walk_edit_from_panel
|
||||
_walk_edit_rig = null
|
||||
_walk_edit_index = -1
|
||||
_walk_edit_action_id = -1
|
||||
_walk_edit_from_panel = false
|
||||
if _director_visuals != null:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
if rig != null and is_instance_valid(rig):
|
||||
var queue := rig.get_queue()
|
||||
if index >= 0 and index < queue.size():
|
||||
var index: int = rig.index_for_action_id(action_id)
|
||||
var status: int = rig.get_action_status(action_id)
|
||||
var queue: Array[Dictionary] = rig.get_queue()
|
||||
if index >= 0 and index < queue.size() and not _queue_action_consumed(status):
|
||||
var action: Dictionary = queue[index]
|
||||
action["target"] = value
|
||||
rig.remove_action(index)
|
||||
rig.insert_action(index, action)
|
||||
if rig.replace_action(index, action):
|
||||
_show_toast("Waypoint moved")
|
||||
else:
|
||||
_show_toast("That action changed while the waypoint was open")
|
||||
if from_panel and _queue_panel != null:
|
||||
_queue_panel.refresh()
|
||||
_queue_panel.popup_centered()
|
||||
@@ -3705,6 +4049,7 @@ func _cb_walk_edit_cancel() -> void:
|
||||
var from_panel := _walk_edit_from_panel
|
||||
_walk_edit_rig = null
|
||||
_walk_edit_index = -1
|
||||
_walk_edit_action_id = -1
|
||||
_walk_edit_from_panel = false
|
||||
if _director_visuals != null:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
@@ -3728,6 +4073,9 @@ func _on_queue_panel_edit_requested(index: int) -> void:
|
||||
var queue := _panel_rig.get_queue()
|
||||
if index < 0 or index >= queue.size():
|
||||
return
|
||||
if _queue_action_consumed(_panel_rig.get_action_status(_panel_rig.action_id_at(index))):
|
||||
_show_toast("Completed actions can only be deleted")
|
||||
return
|
||||
var action: Dictionary = queue[index]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
_queue_panel.hide()
|
||||
@@ -3739,12 +4087,15 @@ func _on_queue_panel_edit_requested(index: int) -> void:
|
||||
func _on_queue_panel_delete_requested(index: int) -> void:
|
||||
if _panel_rig == null or not is_instance_valid(_panel_rig):
|
||||
return
|
||||
var queue := _panel_rig.get_queue()
|
||||
if index < 0 or index >= queue.size():
|
||||
var target_rig: StickmanRig = _panel_rig
|
||||
var action_id: int = target_rig.action_id_at(index)
|
||||
if action_id < 0:
|
||||
return
|
||||
_ask_confirm("Delete Action", "Delete this action?", func() -> void:
|
||||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||||
_panel_rig.remove_action(index)
|
||||
if target_rig != null and is_instance_valid(target_rig):
|
||||
var current_index: int = target_rig.index_for_action_id(action_id)
|
||||
if current_index >= 0:
|
||||
target_rig.remove_action(current_index)
|
||||
if _queue_panel != null:
|
||||
_queue_panel.refresh()
|
||||
)
|
||||
@@ -3759,14 +4110,19 @@ func _on_queue_panel_add_requested() -> void:
|
||||
func _on_queue_panel_clear_requested() -> void:
|
||||
if _panel_rig == null or not is_instance_valid(_panel_rig):
|
||||
return
|
||||
_ask_confirm("Clear Queue", "Remove all actions for %s?" % _panel_rig.name, func() -> void:
|
||||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||||
_panel_rig.clear_queue()
|
||||
var target_rig: StickmanRig = _panel_rig
|
||||
_ask_confirm("Clear Queue", "Remove all actions for %s?" % target_rig.name, func() -> void:
|
||||
if target_rig != null and is_instance_valid(target_rig):
|
||||
target_rig.clear_queue()
|
||||
if _queue_panel != null:
|
||||
_queue_panel.refresh()
|
||||
)
|
||||
|
||||
|
||||
func _queue_action_consumed(status: int) -> bool:
|
||||
return status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3c.4 Rule Panel
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3863,6 +4219,10 @@ func _on_rule_panel_clear_requested() -> void:
|
||||
|
||||
|
||||
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
|
||||
if _run_state == RunState.PAUSED:
|
||||
_show_toast("Event rules stay in order during a paused run")
|
||||
_refresh_rule_panel()
|
||||
return
|
||||
_reorder_filtered_rules(ordered_ids)
|
||||
_director_visuals.set_rules(_event_rules)
|
||||
_mark_scene_dirty()
|
||||
@@ -4002,10 +4362,13 @@ func _cb_rule_actor_cancel() -> void:
|
||||
# Phase 3c Action Editor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool) -> void:
|
||||
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool, target_action_id: int = -1, insert_after: bool = false) -> void:
|
||||
_action_editor_kind = kind
|
||||
_action_editor_index = index
|
||||
_action_editor_rig = rig
|
||||
_action_editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1
|
||||
_action_editor_target_action_id = target_action_id
|
||||
_action_editor_insert_after = insert_after
|
||||
_action_editor_actor_id = actor_id
|
||||
_action_editor_restore_queue = hide_queue
|
||||
if hide_queue and _queue_panel != null:
|
||||
@@ -4027,16 +4390,27 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
|
||||
_show_toast("Action added")
|
||||
"queue_edit":
|
||||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||||
var idx := _action_editor_index
|
||||
var q := _action_editor_rig.get_queue()
|
||||
if idx >= 0 and idx < q.size():
|
||||
_action_editor_rig.remove_action(idx)
|
||||
_action_editor_rig.insert_action(idx, flat)
|
||||
var idx: int = _action_editor_rig.index_for_action_id(_action_editor_action_id)
|
||||
var status: int = _action_editor_rig.get_action_status(_action_editor_action_id)
|
||||
if idx >= 0 and not _queue_action_consumed(status) and _action_editor_rig.replace_action(idx, flat):
|
||||
_show_toast("Action updated")
|
||||
else:
|
||||
_show_toast("That action is no longer editable")
|
||||
"queue_insert":
|
||||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||||
_action_editor_rig.insert_action(_action_editor_index, flat)
|
||||
var insert_index: int = _action_editor_index
|
||||
if _action_editor_target_action_id >= 0:
|
||||
insert_index = _action_editor_rig.index_for_action_id(_action_editor_target_action_id)
|
||||
var target_status: int = _action_editor_rig.get_action_status(_action_editor_target_action_id)
|
||||
if insert_index < 0 or _queue_action_consumed(target_status) or (target_status == StickmanActionRunner.QueueStatus.ACTIVE and not _action_editor_insert_after):
|
||||
insert_index = -1
|
||||
else:
|
||||
insert_index += 1 if _action_editor_insert_after else 0
|
||||
if insert_index >= 0:
|
||||
_action_editor_rig.insert_action(insert_index, flat)
|
||||
_show_toast("Action inserted")
|
||||
else:
|
||||
_show_toast("That action can no longer be used as an insert point")
|
||||
"rule_add":
|
||||
_pending_rule_action_flat = flat
|
||||
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
|
||||
@@ -4165,7 +4539,7 @@ func _world_children_selectable() -> Array[Node2D]:
|
||||
var node := child as Node2D
|
||||
if node == null:
|
||||
continue
|
||||
if node.name == RAGDOLL_CONTAINER_NAME:
|
||||
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
|
||||
continue
|
||||
result.append(node)
|
||||
return result
|
||||
@@ -4311,6 +4685,8 @@ func _load_font(path: String) -> Font:
|
||||
func _apply_theme_to_visuals() -> void:
|
||||
if _director_visuals != null:
|
||||
_director_visuals.set_style(_theme)
|
||||
_director_visuals.completed_path_alpha = _sandbox_theme.number("overlays.completed_path_alpha", 0.20, 0.0, 1.0) if _sandbox_theme != null else 0.20
|
||||
_director_visuals.completed_path_color = _sandbox_theme.color("overlays.completed_path_color", Color("#959EAD")) if _sandbox_theme != null else Color("#959EAD")
|
||||
_director_visuals.emoji_font = _emoji_font
|
||||
_director_visuals.ui_font = _ui_font
|
||||
_director_visuals.bold_font = _ui_font_bold
|
||||
|
||||
@@ -8,13 +8,13 @@ const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
||||
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
||||
"grid": {"snap_size": 15.0},
|
||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
||||
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
||||
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
||||
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
||||
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ extends Node2D
|
||||
## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY.
|
||||
|
||||
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
|
||||
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
|
||||
|
||||
const WAYPOINT_RADIUS_PX := 6.0
|
||||
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
|
||||
@@ -42,6 +43,8 @@ var _dirty: bool = true
|
||||
var badge_icon_size: float = ICON_SIZE_PX
|
||||
var badge_number_size: float = NUMBER_FONT_SIZE_PX
|
||||
var badge_radius: float = RULE_BADGE_RADIUS_PX
|
||||
var completed_path_alpha: float = 0.20
|
||||
var completed_path_color: Color = Color(0.58, 0.62, 0.68)
|
||||
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
|
||||
var emoji_font: Font = null
|
||||
|
||||
@@ -61,6 +64,7 @@ var _rule_hit_regions: Array[Dictionary] = []
|
||||
## Phase 3c: the walk_to waypoint currently being visually edited (blinking
|
||||
## highlight). Vector2.INF when none.
|
||||
var _edit_waypoint: Vector2 = Vector2.INF
|
||||
var _live_session: bool = false
|
||||
|
||||
## Highlights a waypoint while its walk target is being re-placed on the stage.
|
||||
func set_edit_waypoint(pos: Vector2) -> void:
|
||||
@@ -77,6 +81,11 @@ func set_enabled(value: bool) -> void:
|
||||
visible = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_live_session(paused: bool) -> void:
|
||||
_live_session = paused
|
||||
mark_dirty()
|
||||
|
||||
func mark_dirty() -> void:
|
||||
_dirty = true
|
||||
|
||||
@@ -141,18 +150,20 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
|
||||
var best: Dictionary = {}
|
||||
var best_dist := radius
|
||||
for rig: StickmanRig in _collect_rigs():
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
var queue_view: Array[Dictionary] = rig.get_queue_view()
|
||||
if queue_view.is_empty():
|
||||
continue
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
var anchor: Dictionary = _queue_anchor(rig)
|
||||
var current: Vector2 = anchor.get("ground", anchor.get("feet", rig.global_position - StickmanLocomotion.FOOT_OFFSET))
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
var action: Dictionary = entry.get("action", {})
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
var target: Vector2 = action.get("target", current)
|
||||
var d := world_pos.distance_to(target)
|
||||
if d <= best_dist:
|
||||
best_dist = d
|
||||
best = { "rig": rig, "index": i, "pos": target }
|
||||
best = { "rig": rig, "index": int(entry.get("authored_index", i)), "action_id": int(entry.get("action_id", -1)), "status": int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED)), "pos": target }
|
||||
current = target
|
||||
return best
|
||||
|
||||
@@ -188,26 +199,56 @@ func _draw() -> void:
|
||||
_draw_rules(zoom)
|
||||
|
||||
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
var queue_view: Array[Dictionary] = rig.get_queue_view()
|
||||
if queue_view.is_empty():
|
||||
return
|
||||
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var anchor: Dictionary = _queue_anchor(rig)
|
||||
var authored_current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var has_authored_cursor: bool = not _live_session
|
||||
var live_current: Vector2 = anchor.get("feet", authored_current)
|
||||
if _live_session:
|
||||
live_current = anchor.get("ground", live_current) if bool(anchor.get("has_ground", false)) else anchor.get("feet", live_current)
|
||||
if bool(anchor.get("airborne", false)) and bool(anchor.get("has_ground", false)):
|
||||
draw_line(anchor.get("feet", live_current), anchor.get("ground", live_current), Color(0.8, 0.8, 0.8, 0.35), 1.5 / zoom, true)
|
||||
var stack := 0
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
var action: Dictionary = entry.get("action", {})
|
||||
var status: int = int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))
|
||||
var tint: Color = _status_color(status)
|
||||
var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
var target: Vector2 = action.get("target", current)
|
||||
_draw_dashed(current, target, zoom)
|
||||
_draw_waypoint(target, zoom, str(i + 1))
|
||||
current = target
|
||||
if not (_live_session and consumed and not has_authored_cursor):
|
||||
_draw_dashed(current, target, zoom, tint)
|
||||
_draw_waypoint(target, zoom, str(i + 1), tint)
|
||||
if _live_session and consumed:
|
||||
authored_current = target
|
||||
has_authored_cursor = true
|
||||
else:
|
||||
live_current = target
|
||||
authored_current = target
|
||||
stack = 0
|
||||
else:
|
||||
var anchor := current + BADGE_STACK_STEP * float(stack) / zoom
|
||||
_draw_badge(anchor, String(action.get("type", "")), zoom, str(i + 1))
|
||||
var badge_anchor: Vector2 = current + BADGE_STACK_STEP * float(stack) / zoom
|
||||
_draw_badge(badge_anchor, String(action.get("type", "")), zoom, str(i + 1), tint)
|
||||
stack += 1
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
|
||||
|
||||
func _queue_anchor(rig: StickmanRig) -> Dictionary:
|
||||
if _live_session:
|
||||
return rig.get_director_anchor()
|
||||
var feet: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
return {"feet": feet, "ground": feet, "has_ground": false, "airborne": false}
|
||||
|
||||
|
||||
func _status_color(status: int) -> Color:
|
||||
if _live_session and (status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED):
|
||||
return Color(completed_path_color.r, completed_path_color.g, completed_path_color.b, completed_path_alpha)
|
||||
return Color(1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, zoom: float, tint: Color = DASH_COLOR) -> void:
|
||||
var dash := DASH_LENGTH_PX / zoom
|
||||
var gap := DASH_GAP_PX / zoom
|
||||
var dir := from.direction_to(to)
|
||||
@@ -216,48 +257,52 @@ func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
|
||||
while dist < total:
|
||||
var start := from + dir * dist
|
||||
var len := minf(dash, total - dist)
|
||||
draw_line(start, start + dir * len, DASH_COLOR, DASH_WIDTH_PX / zoom, true)
|
||||
var line_color: Color = DASH_COLOR if tint == Color.WHITE else tint
|
||||
line_color.a *= DASH_COLOR.a
|
||||
draw_line(start, start + dir * len, line_color, DASH_WIDTH_PX / zoom, true)
|
||||
dist += dash + gap
|
||||
|
||||
func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
|
||||
func _draw_waypoint(pos: Vector2, zoom: float, number: String, tint: Color = WAYPOINT_COLOR) -> void:
|
||||
var radius := WAYPOINT_RADIUS_PX / zoom
|
||||
draw_circle(pos, radius, WAYPOINT_COLOR)
|
||||
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
|
||||
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
|
||||
draw_circle(pos, radius, tint)
|
||||
var outline: Color = Color.WHITE
|
||||
outline.a = tint.a
|
||||
draw_arc(pos, radius, 0.0, TAU, 32, outline, 2.0 / zoom, true)
|
||||
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom, outline)
|
||||
# Phase 3c: pulsing highlight ring while this waypoint is being edited.
|
||||
if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5:
|
||||
var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0)
|
||||
draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true)
|
||||
|
||||
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
|
||||
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
|
||||
func _draw_number(pos: Vector2, number: String, zoom: float, tint: Color = NUMBER_COLOR) -> void:
|
||||
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), tint)
|
||||
|
||||
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
|
||||
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String, tint: Color = ICON_COLOR) -> void:
|
||||
var s := badge_icon_size / zoom
|
||||
match type:
|
||||
"speak":
|
||||
var bw := s * 1.6
|
||||
var bh := s * 1.1
|
||||
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), ICON_COLOR, true)
|
||||
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), tint, true)
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
anchor + Vector2(-s * 0.25, bh * 0.5),
|
||||
anchor + Vector2(s * 0.25, bh * 0.5),
|
||||
anchor + Vector2(0.0, bh * 0.5 + s * 0.5),
|
||||
]), ICON_COLOR)
|
||||
]), tint)
|
||||
"wait":
|
||||
draw_arc(anchor, s, 0.0, TAU, 32, ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_arc(anchor, s, 0.0, TAU, 32, tint, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), tint, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), tint, 2.0 / zoom, true)
|
||||
"ragdoll":
|
||||
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, tint, 2.0 / zoom, true)
|
||||
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, tint, 2.0 / zoom, true)
|
||||
"recover":
|
||||
var base := anchor + Vector2(0.0, s * 0.6)
|
||||
var tip := anchor + Vector2(0.0, -s * 0.6)
|
||||
draw_line(base, tip, ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
|
||||
_draw_number(anchor + Vector2(s, -s), number, zoom)
|
||||
draw_line(base, tip, tint, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
|
||||
_draw_number(anchor + Vector2(s, -s), number, zoom, tint)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule rendering (Phase 4)
|
||||
|
||||
@@ -194,14 +194,38 @@ func _is_selectable(node: Node) -> bool:
|
||||
return false
|
||||
if node.get_parent() != _world:
|
||||
return false
|
||||
if node.name == RAGDOLL_CONTAINER_NAME:
|
||||
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
||||
if _world == null or not is_instance_valid(_world):
|
||||
return null
|
||||
var state: PhysicsDirectSpaceState2D = _world.get_world_2d().direct_space_state
|
||||
var query := PhysicsPointQueryParameters2D.new()
|
||||
query.position = world_pos
|
||||
query.collision_mask = 0x7FFFFFFF
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
var hits: Array[Dictionary] = state.intersect_point(query, 32)
|
||||
for hit: Dictionary in hits:
|
||||
var body: Object = hit.get("collider") as Object
|
||||
if body == null or not body is Node or not (body as Node).has_meta("stickman_ragdoll_body"):
|
||||
continue
|
||||
var rig: Node2D = (body as Node).get_meta("stickman_rig") as Node2D
|
||||
if rig == null or not is_instance_valid(rig) or rig.is_queued_for_deletion() or rig.get_parent() != _world:
|
||||
continue
|
||||
return rig
|
||||
return null
|
||||
|
||||
|
||||
## Highest World child index wins; on equal area the frontmost (first hit in
|
||||
## reverse iteration) stays.
|
||||
func _frontmost_at(world_pos: Vector2) -> Node2D:
|
||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
|
||||
if ragdoll_hit != null:
|
||||
return ragdoll_hit
|
||||
var children := _world.get_children()
|
||||
var best: Node2D = null
|
||||
var best_area := INF
|
||||
|
||||
@@ -10,6 +10,7 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
||||
|
||||
enum RunnerState { IDLE, EXECUTING }
|
||||
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
|
||||
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
|
||||
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
@@ -18,8 +19,14 @@ signal queue_changed
|
||||
signal speech_finished
|
||||
signal ragdoll_requested(enabled: bool)
|
||||
signal recovery_requested
|
||||
signal active_action_cancelled(action: Dictionary)
|
||||
|
||||
var action_queue: Array[Dictionary] = []
|
||||
var _authored_queue: Array[Dictionary] = []
|
||||
var action_queue: Array[Dictionary]:
|
||||
get:
|
||||
return _authored_queue.duplicate(true)
|
||||
set(value):
|
||||
set_authored_queue(value)
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _runner_state: RunnerState = RunnerState.IDLE
|
||||
@@ -32,6 +39,13 @@ var _phase_timer: float = 0.0
|
||||
var _primary_speech: Dictionary = {}
|
||||
var _overlay_speech: Dictionary = {}
|
||||
var _speech_bubble: Node2D = null
|
||||
var _action_ids: Array[int] = []
|
||||
var _next_action_id: int = 1
|
||||
var _current_action_id: int = -1
|
||||
var _completed_ids: Dictionary = {}
|
||||
var _interrupted_ids: Dictionary = {}
|
||||
var _paused: bool = false
|
||||
var _session_started: bool = false
|
||||
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
@@ -40,6 +54,8 @@ func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
|
||||
|
||||
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
if _paused:
|
||||
return
|
||||
_update_speech(delta)
|
||||
if _runner_state != RunnerState.EXECUTING or _is_blocked():
|
||||
return
|
||||
@@ -86,13 +102,31 @@ func sync_facing(profile: int) -> void:
|
||||
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
|
||||
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
action_queue.append(action)
|
||||
func set_authored_queue(actions: Array[Dictionary]) -> bool:
|
||||
if _session_started:
|
||||
return false
|
||||
_authored_queue = actions.duplicate(true)
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
insert_action(action_queue.size(), action)
|
||||
|
||||
|
||||
func clear_queue() -> void:
|
||||
action_queue.clear()
|
||||
_authored_queue.clear()
|
||||
_action_ids.clear()
|
||||
if not _current_run_item.is_empty():
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_run_queue.clear()
|
||||
_run_cursor = 0
|
||||
if _session_started:
|
||||
_runner_state = RunnerState.IDLE
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
@@ -104,15 +138,109 @@ func remove_action(index: int) -> void:
|
||||
if index < 0 or index >= action_queue.size():
|
||||
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
|
||||
return
|
||||
action_queue.remove_at(index)
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue.remove_at(index)
|
||||
if index < _action_ids.size():
|
||||
_action_ids.remove_at(index)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func insert_action(index: int, action: Dictionary) -> void:
|
||||
action_queue.insert(clampi(index, 0, action_queue.size()), action)
|
||||
var target: int = clampi(index, 0, _authored_queue.size())
|
||||
if _session_started and _paused:
|
||||
if target <= _paused_history_floor():
|
||||
return
|
||||
var action_id: int = _allocate_action_id()
|
||||
_authored_queue.insert(target, action.duplicate(true))
|
||||
_action_ids.insert(target, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func replace_action(index: int, action: Dictionary) -> bool:
|
||||
if index < 0 or index >= _authored_queue.size():
|
||||
return false
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED):
|
||||
return false
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue[index] = action.duplicate(true)
|
||||
if _session_started and _paused:
|
||||
_completed_ids.erase(action_id)
|
||||
_interrupted_ids.erase(action_id)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func move_action(from_index: int, to_index: int) -> bool:
|
||||
if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size():
|
||||
return false
|
||||
if _session_started and _paused:
|
||||
var id: int = _action_ids[from_index]
|
||||
if _status_for_action_id(id) != QueueStatus.PENDING:
|
||||
return false
|
||||
if to_index <= _paused_history_floor():
|
||||
return false
|
||||
if from_index == to_index:
|
||||
return true
|
||||
var action: Dictionary = _authored_queue.pop_at(from_index)
|
||||
var action_id: int = _action_ids.pop_at(from_index)
|
||||
_authored_queue.insert(to_index, action)
|
||||
_action_ids.insert(to_index, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func get_queue_view() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id})
|
||||
return result
|
||||
|
||||
|
||||
func action_id_at(index: int) -> int:
|
||||
return _action_ids[index] if index >= 0 and index < _action_ids.size() else -1
|
||||
|
||||
|
||||
func index_for_action_id(action_id: int) -> int:
|
||||
return _index_for_action_id(action_id)
|
||||
|
||||
|
||||
func get_action_status(action_id: int) -> int:
|
||||
return _status_for_action_id(action_id)
|
||||
|
||||
|
||||
func pause_simulation() -> void:
|
||||
_paused = true
|
||||
|
||||
|
||||
func resume_simulation() -> void:
|
||||
_paused = false
|
||||
|
||||
|
||||
func clear_pause_state() -> void:
|
||||
_paused = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_current_action_id = -1
|
||||
_session_started = false
|
||||
|
||||
|
||||
func queue_size() -> int:
|
||||
return action_queue.size()
|
||||
|
||||
@@ -120,10 +248,10 @@ func queue_size() -> int:
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if actions.is_empty():
|
||||
return
|
||||
if not _session_started:
|
||||
_start_session_runtime()
|
||||
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
|
||||
for index: int in action_queue.size():
|
||||
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
|
||||
_run_cursor = 0
|
||||
_rebuild_pending_runtime_queue()
|
||||
var primary: Array[Dictionary] = []
|
||||
var takeover_seen: bool = false
|
||||
for action: Dictionary in actions:
|
||||
@@ -134,7 +262,10 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
||||
if not takeover_seen:
|
||||
takeover_seen = true
|
||||
_cancel_current_primary()
|
||||
if _current_action_id >= 0:
|
||||
_interrupted_ids[_current_action_id] = true
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_overlay_speech.clear()
|
||||
hide_speech()
|
||||
var kept: Array[Dictionary] = []
|
||||
@@ -147,8 +278,6 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
primary.append(action.duplicate(true))
|
||||
if primary.is_empty():
|
||||
return
|
||||
if not takeover_seen:
|
||||
_cancel_current_primary()
|
||||
var injected: Array[Dictionary] = []
|
||||
for action: Dictionary in primary:
|
||||
injected.append({"action": action, "queue_index": -1, "reactive": true})
|
||||
@@ -166,27 +295,30 @@ func clear_reactive_actions() -> void:
|
||||
_run_queue = runtime_kept
|
||||
_run_cursor = mini(_run_cursor, _run_queue.size())
|
||||
var kept: Array[Dictionary] = []
|
||||
for action: Dictionary in action_queue:
|
||||
for action: Dictionary in _authored_queue:
|
||||
if not bool(action.get("reactive", false)):
|
||||
kept.append(action)
|
||||
if kept.size() != action_queue.size():
|
||||
action_queue = kept
|
||||
if kept.size() != _authored_queue.size():
|
||||
_authored_queue = kept
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func start_queue() -> void:
|
||||
if _runner_state == RunnerState.EXECUTING:
|
||||
if _runner_state == RunnerState.EXECUTING and _session_started:
|
||||
return
|
||||
if action_queue.is_empty():
|
||||
if not _session_started:
|
||||
_start_session_runtime()
|
||||
_paused = false
|
||||
if _authored_queue.is_empty():
|
||||
queue_finished.emit()
|
||||
return
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_run_queue.clear()
|
||||
for index: int in action_queue.size():
|
||||
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
|
||||
_run_cursor = 0
|
||||
_rebuild_pending_runtime_queue()
|
||||
_current_run_item.clear()
|
||||
|
||||
|
||||
@@ -198,6 +330,11 @@ func stop_queue() -> void:
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
_paused = false
|
||||
_session_started = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
@@ -217,8 +354,24 @@ func _advance_to_next_action() -> void:
|
||||
return
|
||||
_current_run_item = _run_queue[_run_cursor]
|
||||
_run_cursor += 1
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
_current_action_id = int(_current_run_item.get("action_id", -1))
|
||||
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
if String(action.get("type", "")) == "walk_to" and _rig != null:
|
||||
if _rig.state == StickmanRig.RigState.RECOVERING:
|
||||
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
||||
_current_run_item.clear()
|
||||
_current_action_id = -1
|
||||
_current_index = -1
|
||||
return
|
||||
if _rig.is_ragdolled():
|
||||
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
||||
_run_queue.insert(_run_cursor, {"action": {"type": "recover"}, "queue_index": -1, "action_id": -1, "reactive": true})
|
||||
_current_run_item.clear()
|
||||
_current_action_id = -1
|
||||
_current_index = -1
|
||||
return
|
||||
action_started.emit(action, _current_index)
|
||||
_begin_action(action)
|
||||
|
||||
@@ -249,14 +402,22 @@ func _begin_action(action: Dictionary) -> void:
|
||||
func _finish_action() -> void:
|
||||
var finished: Dictionary = _current_run_item.get("action", {})
|
||||
var index: int = int(_current_run_item.get("queue_index", -1))
|
||||
var action_id: int = int(_current_run_item.get("action_id", -1))
|
||||
if action_id >= 0:
|
||||
_completed_ids[action_id] = true
|
||||
index = _index_for_action_id(action_id) if action_id >= 0 else -1
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
|
||||
|
||||
func _cancel_current_primary() -> void:
|
||||
func _cancel_current_primary(notify: bool = false) -> void:
|
||||
if notify and not _current_run_item.is_empty():
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
active_action_cancelled.emit(action.duplicate(true))
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(not _is_blocked())
|
||||
_primary_speech.clear()
|
||||
@@ -264,10 +425,75 @@ func _cancel_current_primary() -> void:
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
|
||||
func _allocate_action_id() -> int:
|
||||
var result: int = _next_action_id
|
||||
_next_action_id += 1
|
||||
return result
|
||||
|
||||
|
||||
func _start_session_runtime() -> void:
|
||||
_session_started = true
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
|
||||
|
||||
func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary:
|
||||
return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false}
|
||||
|
||||
|
||||
func _rebuild_pending_runtime_queue() -> void:
|
||||
var rebuilt: Array[Dictionary] = []
|
||||
var retained_reactive: Array[Dictionary] = []
|
||||
for runtime_index: int in range(_run_cursor, _run_queue.size()):
|
||||
var runtime_item: Dictionary = _run_queue[runtime_index]
|
||||
if bool(runtime_item.get("reactive", false)):
|
||||
retained_reactive.append(runtime_item.duplicate(true))
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED:
|
||||
rebuilt.append(_runtime_item(action_id, _authored_queue[index], index))
|
||||
_run_queue = retained_reactive + rebuilt
|
||||
_run_cursor = 0
|
||||
if not _run_queue.is_empty() and _session_started:
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
|
||||
|
||||
func _paused_history_floor() -> int:
|
||||
var floor_index: int = -1
|
||||
for index: int in _authored_queue.size():
|
||||
var status: int = _status_for_action_id(_action_ids[index])
|
||||
if status != QueueStatus.PENDING and status != QueueStatus.NOT_STARTED:
|
||||
floor_index = index
|
||||
return floor_index
|
||||
|
||||
|
||||
func _index_for_action_id(action_id: int) -> int:
|
||||
return _action_ids.find(action_id)
|
||||
|
||||
|
||||
func _status_for_action_id(action_id: int) -> int:
|
||||
if action_id < 0:
|
||||
return QueueStatus.NOT_STARTED
|
||||
if _completed_ids.has(action_id):
|
||||
return QueueStatus.COMPLETED
|
||||
if _interrupted_ids.has(action_id):
|
||||
return QueueStatus.INTERRUPTED
|
||||
if action_id == _current_action_id:
|
||||
return QueueStatus.ACTIVE
|
||||
if _session_started:
|
||||
return QueueStatus.PENDING
|
||||
return QueueStatus.NOT_STARTED
|
||||
|
||||
|
||||
func _is_blocked() -> bool:
|
||||
return _locomotion != null and _locomotion.is_action_blocked()
|
||||
|
||||
|
||||
@@ -366,6 +366,22 @@ func cancel_walk(restore_idle_pose: bool = true) -> void:
|
||||
func is_walking() -> bool:
|
||||
return _walking
|
||||
|
||||
|
||||
func ensure_grounded_walk_animation() -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or not _walking:
|
||||
return
|
||||
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
|
||||
return
|
||||
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation("walk_right"):
|
||||
return
|
||||
if _terrain_paused_walk_animation:
|
||||
if _rig._anim_player.is_playing():
|
||||
_terrain_paused_walk_animation = false
|
||||
else:
|
||||
_resume_walk_animation()
|
||||
elif not _rig._anim_player.is_playing():
|
||||
_rig._anim_player.play("walk_right")
|
||||
|
||||
func get_foot_offset() -> Vector2:
|
||||
return FOOT_OFFSET
|
||||
|
||||
@@ -479,6 +495,7 @@ func reset_for_ragdoll_handoff() -> void:
|
||||
air_fall_distance = 0.0
|
||||
terrain_motion = TerrainMotion.DISABLED
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
_reset_foot_contacts()
|
||||
|
||||
func is_action_blocked() -> bool:
|
||||
return terrain_motion == TerrainMotion.LANDING_IMPACT
|
||||
|
||||
@@ -117,6 +117,9 @@ var _probe_bottom_y := 0.0
|
||||
var _probe_exclude: Array[RID] = []
|
||||
var _spawn_pose: Dictionary = {}
|
||||
var _spawn_limb_rotations: Dictionary = {}
|
||||
var _simulation_paused: bool = false
|
||||
var _paused_body_states: Dictionary = {}
|
||||
var _was_recovery_tween_running: bool = false
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
@@ -150,9 +153,97 @@ func initialize(skeleton: Skeleton2D) -> void:
|
||||
## straight knee cross its angular limit during the step; correcting here keeps
|
||||
## that invalid intermediate pose from ever reaching the rendered frame.
|
||||
func _process(_delta: float) -> void:
|
||||
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
if not _simulation_paused and _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
_enforce_side_profile_leg_bends()
|
||||
|
||||
|
||||
func pause_simulation() -> void:
|
||||
if _simulation_paused:
|
||||
return
|
||||
_simulation_paused = true
|
||||
_paused_body_states.clear()
|
||||
_was_recovery_tween_running = _stand_up_tween != null and _stand_up_tween.is_valid() and _stand_up_tween.is_running()
|
||||
if _was_recovery_tween_running:
|
||||
_stand_up_tween.pause()
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body == null or not is_instance_valid(body):
|
||||
continue
|
||||
var body_id: int = body.get_instance_id()
|
||||
_paused_body_states[body_id] = {"body": body, "transform": body.global_transform, "linear_velocity": body.linear_velocity, "angular_velocity": body.angular_velocity, "sleeping": body.sleeping, "freeze": body.freeze, "freeze_mode": body.freeze_mode}
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
|
||||
|
||||
func resume_simulation() -> void:
|
||||
if not _simulation_paused:
|
||||
return
|
||||
for body_id: int in _paused_body_states.keys():
|
||||
var snapshot: Dictionary = _paused_body_states[body_id]
|
||||
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
|
||||
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
|
||||
continue
|
||||
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
body.freeze = bool(snapshot.get("freeze", false))
|
||||
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
|
||||
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
|
||||
body.sleeping = bool(snapshot.get("sleeping", false))
|
||||
_paused_body_states.clear()
|
||||
_simulation_paused = false
|
||||
if _was_recovery_tween_running and _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.play()
|
||||
_was_recovery_tween_running = false
|
||||
|
||||
|
||||
func clear_pause_snapshot() -> void:
|
||||
_paused_body_states.clear()
|
||||
_simulation_paused = false
|
||||
_was_recovery_tween_running = false
|
||||
|
||||
|
||||
func get_body_nodes() -> Array[RigidBody2D]:
|
||||
var result: Array[RigidBody2D] = []
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body != null and is_instance_valid(body):
|
||||
result.append(body)
|
||||
return result
|
||||
|
||||
|
||||
func get_body_rids() -> Array[RID]:
|
||||
var result: Array[RID] = []
|
||||
for body: RigidBody2D in get_body_nodes():
|
||||
var rid: RID = body.get_rid()
|
||||
if rid.is_valid():
|
||||
result.append(rid)
|
||||
return result
|
||||
|
||||
|
||||
func get_world_body_bounds() -> Rect2:
|
||||
var bounds := Rect2()
|
||||
var has_bounds: bool = false
|
||||
for body: RigidBody2D in get_body_nodes():
|
||||
var extent: float = float(body.get_meta("radius", LIMB_RADIUS)) + float(body.get_meta("half_height", 0.0))
|
||||
var body_rect := Rect2(body.global_position - Vector2(extent, extent), Vector2(extent * 2.0, extent * 2.0))
|
||||
if not has_bounds:
|
||||
bounds = body_rect
|
||||
has_bounds = true
|
||||
else:
|
||||
bounds = bounds.merge(body_rect)
|
||||
return bounds if has_bounds else Rect2()
|
||||
|
||||
|
||||
func interrupt_recovery_to_ragdoll() -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.RECOVERING:
|
||||
return
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
_stand_up_generation += 1
|
||||
if enter_ragdoll():
|
||||
state_transition_requested.emit(StickmanRig.RigState.RAGDOLL)
|
||||
if _simulation_paused:
|
||||
_simulation_paused = false
|
||||
pause_simulation()
|
||||
|
||||
func _resolve_limb_bones() -> void:
|
||||
_limb_bones.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
@@ -186,7 +277,7 @@ func build() -> bool:
|
||||
var parent: Node = _rig.get_parent()
|
||||
if parent == null: parent = _rig.get_tree().current_scene
|
||||
if parent == null: return false
|
||||
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
|
||||
_root = Node2D.new(); _root.name = CONTAINER_NAME; _root.set_meta("stickman_ragdoll_container", true); _root.add_to_group("stickman_ragdoll_container"); _root.set_meta("stickman_rig", _rig); parent.add_child(_root); _bodies.clear()
|
||||
for entry: Dictionary in BODIES: _build_body(entry)
|
||||
_stabilize_side_profile_leg_bends()
|
||||
for entry: Dictionary in JOINTS: _build_joint(entry)
|
||||
|
||||
+172
-6
@@ -213,9 +213,9 @@ var action_queue: Array[Dictionary]:
|
||||
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
|
||||
set(value):
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.action_queue = value
|
||||
_action_runner_component.set_authored_queue(value)
|
||||
else:
|
||||
_pending_action_queue = value
|
||||
_pending_action_queue = value.duplicate(true)
|
||||
|
||||
## Original .stk asset path, assigned by StageSpawner for scene persistence.
|
||||
var source_stk_path: String = ""
|
||||
@@ -225,6 +225,10 @@ var source_stk_path: String = ""
|
||||
var _locomotion_component: StickmanLocomotion = null
|
||||
var _ragdoll_component: StickmanRagdoll = null
|
||||
var _action_runner_component: StickmanActionRunner = null
|
||||
var _simulation_paused: bool = false
|
||||
var _phase12_paused_animation: bool = false
|
||||
var _phase12_paused_animation_name: StringName = &""
|
||||
var _phase12_paused_animation_position: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -257,6 +261,7 @@ func _ready() -> void:
|
||||
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
||||
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
||||
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
_action_runner_component.active_action_cancelled.connect(_on_active_action_cancelled)
|
||||
# Resolve all runtime node references.
|
||||
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||
if _skeleton == null:
|
||||
@@ -304,6 +309,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _simulation_paused:
|
||||
return
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
|
||||
if _locomotion_component != null:
|
||||
@@ -485,6 +492,12 @@ func request_recovery() -> void:
|
||||
func snap_to_standing() -> void:
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.snap_to_standing(int(state))
|
||||
_simulation_paused = false
|
||||
_phase12_paused_animation = false
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.clear_pause_state()
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.clear_pause_snapshot()
|
||||
|
||||
|
||||
## Restores the spawn-marker pose through the ragdoll component. Kept on the
|
||||
@@ -751,11 +764,17 @@ func _sync_speech_bubble_mirror() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
if _action_runner_component != null: _action_runner_component.queue_action(action)
|
||||
if _action_runner_component != null:
|
||||
insert_action(queue_size(), action)
|
||||
else:
|
||||
_pending_action_queue.append(action.duplicate(true))
|
||||
|
||||
|
||||
func clear_queue() -> void:
|
||||
if _action_runner_component != null: _action_runner_component.clear_queue()
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.clear_queue()
|
||||
else:
|
||||
_pending_action_queue.clear()
|
||||
|
||||
|
||||
func get_queue() -> Array[Dictionary]:
|
||||
@@ -763,11 +782,158 @@ func get_queue() -> Array[Dictionary]:
|
||||
|
||||
|
||||
func remove_action(index: int) -> void:
|
||||
if _action_runner_component != null: _action_runner_component.remove_action(index)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.remove_action(index)
|
||||
elif index >= 0 and index < _pending_action_queue.size():
|
||||
_pending_action_queue.remove_at(index)
|
||||
|
||||
|
||||
func insert_action(index: int, action: Dictionary) -> void:
|
||||
if _action_runner_component != null: _action_runner_component.insert_action(index, action)
|
||||
if _action_runner_component != null:
|
||||
var normalized: Dictionary = _with_recover_barrier(action)
|
||||
if is_ragdolled() and String(normalized.get("type", "")) == "walk_to":
|
||||
var queue: Array[Dictionary] = get_queue()
|
||||
var at: int = clampi(index, 0, queue.size())
|
||||
var has_recover_barrier: bool = false
|
||||
if at > 0 and String(queue[at - 1].get("type", "")) == "recover":
|
||||
var previous_status: int = get_action_status(action_id_at(at - 1))
|
||||
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
|
||||
if not has_recover_barrier:
|
||||
_action_runner_component.insert_action(at, {"type": "recover"})
|
||||
at += 1
|
||||
_action_runner_component.insert_action(at, normalized)
|
||||
else:
|
||||
_action_runner_component.insert_action(index, normalized)
|
||||
else:
|
||||
_pending_action_queue.insert(clampi(index, 0, _pending_action_queue.size()), action.duplicate(true))
|
||||
|
||||
|
||||
func replace_action(index: int, action: Dictionary) -> bool:
|
||||
if _action_runner_component == null:
|
||||
return false
|
||||
if is_ragdolled() and String(action.get("type", "")) == "walk_to":
|
||||
var queue: Array[Dictionary] = get_queue()
|
||||
var has_recover_barrier: bool = false
|
||||
if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover":
|
||||
var previous_status: int = get_action_status(action_id_at(index - 1))
|
||||
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
|
||||
if has_recover_barrier:
|
||||
return _action_runner_component.replace_action(index, action.duplicate(true))
|
||||
var replaced: bool = _action_runner_component.replace_action(index, {"type": "recover"})
|
||||
if not replaced:
|
||||
return false
|
||||
_action_runner_component.insert_action(index + 1, action.duplicate(true))
|
||||
return true
|
||||
return _action_runner_component.replace_action(index, _with_recover_barrier(action))
|
||||
|
||||
|
||||
func move_action(from_index: int, to_index: int) -> bool:
|
||||
return _action_runner_component.move_action(from_index, to_index) if _action_runner_component != null else false
|
||||
|
||||
|
||||
func get_queue_view() -> Array[Dictionary]:
|
||||
return _action_runner_component.get_queue_view() if _action_runner_component != null else []
|
||||
|
||||
|
||||
func action_id_at(index: int) -> int:
|
||||
return _action_runner_component.action_id_at(index) if _action_runner_component != null else -1
|
||||
|
||||
|
||||
func index_for_action_id(action_id: int) -> int:
|
||||
return _action_runner_component.index_for_action_id(action_id) if _action_runner_component != null else -1
|
||||
|
||||
|
||||
func get_action_status(action_id: int) -> int:
|
||||
return _action_runner_component.get_action_status(action_id) if _action_runner_component != null else StickmanActionRunner.QueueStatus.NOT_STARTED
|
||||
|
||||
|
||||
func pause_simulation() -> void:
|
||||
if _simulation_paused:
|
||||
return
|
||||
_simulation_paused = true
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.pause_simulation()
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.pause_simulation()
|
||||
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.is_playing():
|
||||
_phase12_paused_animation_name = _anim_player.current_animation
|
||||
_phase12_paused_animation_position = _anim_player.get_current_animation_position()
|
||||
_phase12_paused_animation = true
|
||||
_anim_player.pause()
|
||||
|
||||
|
||||
func resume_simulation() -> void:
|
||||
if not _simulation_paused:
|
||||
return
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.resume_simulation()
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.resume_simulation()
|
||||
_simulation_paused = false
|
||||
if _phase12_paused_animation and _anim_player != null and is_instance_valid(_anim_player):
|
||||
if _anim_player.current_animation == _phase12_paused_animation_name:
|
||||
_anim_player.play(_phase12_paused_animation_name)
|
||||
_anim_player.seek(_phase12_paused_animation_position, true)
|
||||
_phase12_paused_animation = false
|
||||
_phase12_paused_animation_name = &""
|
||||
_phase12_paused_animation_position = 0.0
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.ensure_grounded_walk_animation()
|
||||
|
||||
|
||||
func reset_simulation_pause() -> void:
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.clear_pause_snapshot()
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.clear_pause_state()
|
||||
_simulation_paused = false
|
||||
_phase12_paused_animation = false
|
||||
_phase12_paused_animation_name = &""
|
||||
_phase12_paused_animation_position = 0.0
|
||||
|
||||
|
||||
func get_director_anchor() -> Dictionary:
|
||||
var bounds: Rect2 = _ragdoll_component.get_world_body_bounds() if _ragdoll_component != null and state == RigState.RAGDOLL else Rect2()
|
||||
var feet: Vector2 = global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var airborne: bool = _locomotion_component != null and _locomotion_component.terrain_motion == StickmanLocomotion.TerrainMotion.AIRBORNE
|
||||
if state == RigState.RAGDOLL and not bounds.size.is_zero_approx():
|
||||
feet = Vector2(bounds.get_center().x, bounds.end.y)
|
||||
var probe_origin: Vector2 = Vector2(bounds.get_center().x, feet.y) if state == RigState.RAGDOLL and not bounds.size.is_zero_approx() else feet
|
||||
var query := PhysicsRayQueryParameters2D.create(probe_origin + Vector2.UP * 40.0, probe_origin + Vector2.DOWN * 1800.0)
|
||||
query.collision_mask = 0x7FFFFFFF
|
||||
query.collide_with_areas = false
|
||||
var excluded: Array[RID] = []
|
||||
_collect_nonterrain_rids(get_parent(), excluded)
|
||||
query.exclude = excluded
|
||||
var hit: Dictionary = get_world_2d().direct_space_state.intersect_ray(query)
|
||||
var has_ground: bool = not hit.is_empty()
|
||||
return {"feet": feet, "ground": hit.get("position", feet), "has_ground": has_ground, "airborne": airborne or state == RigState.RAGDOLL}
|
||||
|
||||
|
||||
func is_ragdolled() -> bool:
|
||||
return state == RigState.RAGDOLL
|
||||
|
||||
|
||||
func _on_active_action_cancelled(action: Dictionary) -> void:
|
||||
if String(action.get("type", "")) == "recover" and state == RigState.RECOVERING and _ragdoll_component != null:
|
||||
_ragdoll_component.interrupt_recovery_to_ragdoll()
|
||||
|
||||
|
||||
func _with_recover_barrier(action: Dictionary) -> Dictionary:
|
||||
return action.duplicate(true)
|
||||
|
||||
|
||||
func _collect_nonterrain_rids(node: Node, out: Array[RID]) -> void:
|
||||
if node == null:
|
||||
return
|
||||
if node is TerrainBlock:
|
||||
return
|
||||
if node is CollisionObject2D:
|
||||
var rid: RID = (node as CollisionObject2D).get_rid()
|
||||
if rid.is_valid() and not out.has(rid):
|
||||
out.append(rid)
|
||||
for child: Node in node.get_children():
|
||||
_collect_nonterrain_rids(child, out)
|
||||
|
||||
|
||||
func queue_size() -> int:
|
||||
|
||||
@@ -10,6 +10,7 @@ const DELETE_WALK := 1
|
||||
const INSERT_BEFORE := 2
|
||||
const INSERT_AFTER := 3
|
||||
const EDIT_TRIGGER_RULES := 4
|
||||
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
|
||||
|
||||
const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after a separator at 4)
|
||||
|
||||
@@ -25,7 +26,11 @@ func _init() -> void:
|
||||
|
||||
|
||||
## Updates the trigger-rules entry (count / enabled) and pops up at `rect`.
|
||||
func popup_for(rect: Rect2i, trigger_rule_count: int) -> void:
|
||||
func popup_for(rect: Rect2i, trigger_rule_count: int, queue_status: int = ACTION_RUNNER.QueueStatus.NOT_STARTED) -> void:
|
||||
var consumed: bool = queue_status == ACTION_RUNNER.QueueStatus.COMPLETED or queue_status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
set_item_disabled(get_item_index(EDIT_WALK), consumed)
|
||||
set_item_disabled(get_item_index(INSERT_BEFORE), consumed or queue_status == ACTION_RUNNER.QueueStatus.ACTIVE)
|
||||
set_item_disabled(get_item_index(INSERT_AFTER), consumed)
|
||||
if trigger_rule_count > 0:
|
||||
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules (%d rule%s)" % [trigger_rule_count, "" if trigger_rule_count == 1 else "s"])
|
||||
set_item_disabled(_EDIT_TRIGGER_IDX, false)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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`.
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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 `AnimationPlayer`s 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:
|
||||
|
||||
1. **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.
|
||||
2. **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 from `t = 0`.
|
||||
3. **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 `recover` barrier. 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 assigned `walk_to` while the rig is ragdolled, insert `recover` immediately 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_IMPACT` remains authoritative through `StickmanLocomotion.is_action_blocked()`; pausing does not clear its compression timer or advance the queue.
|
||||
4. **Rule changes** use the existing `_event_rules` collection, IDs, editor, and `StageSceneIO` format. 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 by `arrived`, `action_finished`, or `speech_finished` signals. 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:
|
||||
|
||||
```gdscript
|
||||
[
|
||||
{"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:
|
||||
|
||||
1. 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.
|
||||
2. 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 = 0` run does. Delayed popup commits target IDs, not stale indices.
|
||||
3. 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.
|
||||
4. 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.
|
||||
5. 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 `.scene` format.
|
||||
- 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.paused` switch 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 `.scene` persistence 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.
|
||||
Reference in New Issue
Block a user