Phase 14 - Organized directories.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
# Command-Line `.scene` Loader + PLAY-on-Launch
|
||||
|
||||
Status: proposed — awaiting approval before implementation.
|
||||
|
||||
## 1. Goal and boundaries
|
||||
|
||||
After Phase 5, a tester must be able to launch the standalone Sandbox Stage from the command line with a `.scene` file, have it load that scene, and (optionally) enter PLAY immediately so the scene "just plays" for automated/manual testing.
|
||||
|
||||
Four hard requirements:
|
||||
|
||||
1. A command-line `.scene` loader.
|
||||
2. A flag to start "Stickman Studio" in PLAY mode.
|
||||
3. PLAY mode is invalid unless a `.scene` is specified.
|
||||
4. On scene-load error, show the user a dialog and default to a new (blank) scene.
|
||||
|
||||
This plan targets `res://scenes/sandbox_stage.tscn` (run standalone), its root script `scripts/sandbox_stage.gd`, a new pure parser, and a new headless test. It does **not** change the stickman editor, `main.tscn`, `project.godot`'s `run/main_scene`, or the `.scene` v1.0 JSON schema.
|
||||
|
||||
Constraints that shape the design:
|
||||
|
||||
- There is currently **no** command-line argument parsing anywhere in the repo.
|
||||
- `SandboxStage._ready()` ends with `_update_scene_chrome()` then `_check_autosave_recovery()`. Startup integration must slot in there.
|
||||
- `set_mode(StageMode.PLAY)` (line ~607) writes an autosave via `_save_scene_to_path(AUTOSAVE_PATH, true)` **before** `_enter_play_mode()`. A CLI PLAY transition must not create that autosave.
|
||||
- `_load_scene_from_path(path, is_recovery=false)` surfaces read failures with a **toast**, not a dialog (line ~1284). The CLI path needs a dialog-bearing variant.
|
||||
- `StageSceneIO.read_file(path)` returns `{ok, data, error, warnings}`; `error` is a `String`.
|
||||
- `_show_missing_assets_dialog(paths)` (line ~1383) is the existing `AcceptDialog` pattern to reuse.
|
||||
- The stage starts as a blank Untitled document (`_scene_name = "Untitled"`, `_current_scene_path = ""`, default camera), `current_mode` starts `StageMode.EDIT`.
|
||||
- Tests live in `tests/*.gd`, extend `SceneTree`, instantiate `sandbox_stage.tscn`, and drive internals directly (see `tests/test_phase5_file_features.gd`). No test autoload exists.
|
||||
|
||||
## 2. Command-line contract
|
||||
|
||||
### 2.1 Flags
|
||||
|
||||
| Flag | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--play` | boolean | After a successful `--scene` load, enter `StageMode.PLAY`. |
|
||||
| `--scene <path>` | value (space-separated) | The `.scene` file to load. |
|
||||
| `--scene=<path>` | value (equals form) | Same, `=` separated. |
|
||||
|
||||
A **bare positional** token that is not `--`-prefixed is accepted as a convenience scene path, but **only when `--scene` is absent**. Supplying both `--scene` and a positional path is a parse error (avoid silent ambiguity).
|
||||
|
||||
### 2.2 Godot separator mechanics
|
||||
|
||||
Godot consumes its own arguments up to a `--` separator; everything **after** `--` is exposed to the script via `OS.get_cmdline_user_args()`. To also run the sandbox stage instead of the project main scene, the stage scene is passed **positionally before** `--`:
|
||||
|
||||
```
|
||||
Godot --path <project> res://scenes/sandbox_stage.tscn -- <user args>
|
||||
```
|
||||
|
||||
`OS.get_cmdline_user_args()` then returns exactly `["--play", "--scene", "/abs/foo.scene"]` (the positional scene path is **not** in the user-args array — it is a Godot argument).
|
||||
|
||||
### 2.3 Exact launch commands (macOS binary per project facts)
|
||||
|
||||
Primary tester flow — open and PLAY:
|
||||
|
||||
```bash
|
||||
/Applications/Godot.app/Contents/MacOS/Godot \
|
||||
--path /Users/ryan/Projects/stickman \
|
||||
res://scenes/sandbox_stage.tscn \
|
||||
-- --play --scene /Users/ryan/Projects/stickman/stickmen/test.scene
|
||||
```
|
||||
|
||||
Equivalent forms:
|
||||
|
||||
```bash
|
||||
# --scene= form
|
||||
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play --scene=res://my_playground.scene
|
||||
|
||||
# bare positional scene path (no --scene)
|
||||
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play /Users/ryan/Projects/stickman/my_playground.scene
|
||||
|
||||
# load into EDIT (no PLAY)
|
||||
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --scene res://my_playground.scene
|
||||
```
|
||||
|
||||
Windows equivalent (same tokens, project's established console binary):
|
||||
|
||||
```powershell
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --path "C:\Godot4\stickman" res://scenes/sandbox_stage.tscn -- --play --scene "C:\Godot4\stickman\stickmen\test.scene"
|
||||
```
|
||||
|
||||
### 2.4 Path handling
|
||||
|
||||
- `res://…`, `user://…`, and absolute paths are passed through unchanged; `StageSceneIO.read_file` / `FileAccess` already handle all three (the stage already stores both `res://` and absolute paths in recents).
|
||||
- The parser performs **no** scheme normalization. A bare relative path (no scheme, not absolute) is resolved by Godot against the process CWD and is therefore fragile; the documented and recommended forms are `res://` (project-relative) or absolute. This keeps the parser pure and testable.
|
||||
|
||||
## 3. Files and classes
|
||||
|
||||
| File | Change | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `scripts/cli_scene_launcher.gd` | **new** `class_name CliSceneLauncher`, `extends RefCounted` | Pure argument parser; no scene-tree or OS access. Returns a structured result. |
|
||||
| `scripts/sandbox_stage.gd` | modified | Startup integration, the CLI load/error-dialog path, the autosave-suppress guard, and the new error dialog in `_build_ui()`. |
|
||||
| `tests/test_cli_scene.gd` | **new** | Headless parser + startup-integration coverage. |
|
||||
|
||||
No changes to `StageSceneIO`, `stickman_editor.tscn`, `main.tscn`, `project.godot`, or the `.scene` v1.0 schema. No authored `.tscn` node changes in `sandbox_stage.tscn` (the error dialog is code-built like the other file dialogs).
|
||||
|
||||
### 3.1 `CliSceneLauncher` API contract
|
||||
|
||||
```gdscript
|
||||
class_name CliSceneLauncher
|
||||
extends RefCounted
|
||||
|
||||
static func parse(args: Array) -> Dictionary
|
||||
# -> { "scene_path": String, "play": bool, "error": String }
|
||||
# scene_path = "" when no scene requested
|
||||
# play = false unless --play was present
|
||||
# error = "" when valid, else a human-readable message
|
||||
```
|
||||
|
||||
Parsing rules (documented so tests assert them):
|
||||
|
||||
1. Scan tokens left to right.
|
||||
2. `--play` → `play = true`.
|
||||
3. `--scene` → consume the next token as the scene path; missing/empty value → error.
|
||||
4. `--scene=<path>` → scene path; empty value → error.
|
||||
5. A non-`--`-prefixed token → positional scene path; a second positional → error.
|
||||
6. Both `--scene` and a positional path → error (`"Specify the scene with either --scene or a single positional path."`).
|
||||
7. Any other `--`-prefixed token → error (`"Unknown option: …"`).
|
||||
8. After the scan: `play == true` with `scene_path == ""` → error (`"--play requires --scene <path>."`).
|
||||
|
||||
The parser enforces requirement 3 (PLAY requires a scene) so the stage can treat any `error` uniformly.
|
||||
|
||||
### 3.2 `sandbox_stage.gd` additions
|
||||
|
||||
```gdscript
|
||||
var _cli_error_dialog: AcceptDialog = null # code-built in _build_ui()
|
||||
var _cli_startup: bool = false # scoped suppress of autosave-on-Play
|
||||
|
||||
func _handle_cli_startup(args: Array) -> void # entry point from _ready()
|
||||
func _load_cli_scene(path: String) -> Dictionary # -> { "ok": bool, "error": String }
|
||||
func _show_cli_error_dialog(message: String) -> void
|
||||
```
|
||||
|
||||
`_load_cli_scene` reuses `StageSceneIO.read_file` + `_apply_scene_data(data, path, false)` (the ordinary clean-load path), but returns the read error instead of toasting, so the caller can surface it in a dialog:
|
||||
|
||||
```gdscript
|
||||
func _load_cli_scene(path: String) -> Dictionary:
|
||||
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
|
||||
if not bool(read.get("ok", false)):
|
||||
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
|
||||
if not _apply_scene_data(read["data"], path, false):
|
||||
return {"ok": false, "error": "Could not apply scene data."}
|
||||
return {"ok": true, "error": ""}
|
||||
```
|
||||
|
||||
The existing `_load_scene_from_path` toast path is left untouched for interactive Open.
|
||||
|
||||
## 4. Startup flow integration into `_ready()`
|
||||
|
||||
Replace the final `_check_autosave_recovery()` line in `_ready()` with a single `_handle_cli_startup(OS.get_cmdline_user_args())` call (which internally owns recovery for the no-`--scene` case):
|
||||
|
||||
```gdscript
|
||||
# _ready() tail (current -> new)
|
||||
_update_scene_chrome()
|
||||
_check_autosave_recovery() # removed from _ready() body
|
||||
# becomes:
|
||||
_update_scene_chrome()
|
||||
_handle_cli_startup(OS.get_cmdline_user_args())
|
||||
```
|
||||
|
||||
`_handle_cli_startup(args)` flow:
|
||||
|
||||
```text
|
||||
result := CliSceneLauncher.parse(args)
|
||||
|
||||
if result.error != "":
|
||||
_show_cli_error_dialog(result.error)
|
||||
_check_autosave_recovery() # invalid CLI -> normal blank-session recovery
|
||||
return # stay EDIT / blank
|
||||
|
||||
if result.scene_path.is_empty():
|
||||
_check_autosave_recovery() # no --scene -> unchanged behavior
|
||||
return
|
||||
|
||||
# Explicit --scene: retire autosave and skip recovery (explicit file intent wins).
|
||||
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||||
|
||||
load := _load_cli_scene(result.scene_path)
|
||||
if not load.ok:
|
||||
_show_cli_error_dialog(load.error)
|
||||
return # blank/Untitled stage remains
|
||||
|
||||
if result.play:
|
||||
_cli_startup = true # suppress the autosave-on-Play write
|
||||
set_mode(StageMode.PLAY)
|
||||
_cli_startup = false
|
||||
```
|
||||
|
||||
### 4.1 Ordering with autosave recovery (decision)
|
||||
|
||||
When `--scene` is explicitly given, `_check_autosave_recovery()` is **skipped** and the autosave file is **retired (deleted)** before the CLI load. Justification:
|
||||
|
||||
- Explicit user intent ("open *this* file, and play it") must win over an offer to recover a previous interrupted session; popping the recovery dialog over a freshly CLI-loaded scene is both wrong and confusing.
|
||||
- `_apply_scene_data(..., is_recovery=false)` already deletes the autosave on success, but the **failure** path never reaches that line; deleting upfront covers both outcomes so a stale autosave cannot prompt on a later launch.
|
||||
- When no `--scene` is given (including the invalid `--play`-only case), recovery is unchanged: `_check_autosave_recovery()` still runs, preserving the existing crash-recovery lifecycle for ordinary launches.
|
||||
|
||||
### 4.2 `set_mode` autosave guard
|
||||
|
||||
```gdscript
|
||||
# set_mode(), current:
|
||||
if mode == StageMode.PLAY:
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
|
||||
# becomes:
|
||||
if mode == StageMode.PLAY and not _cli_startup:
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
|
||||
```
|
||||
|
||||
`_cli_startup` is a scoped boolean set only around the CLI `set_mode(StageMode.PLAY)` call. This prevents the CLI PLAY transition from writing `user://sandbox_autosave.scene` (which would otherwise be newer than its own source file and trigger a false recovery prompt on the next launch). It does not change the interactive Play button path, and it still routes the transition through the public `set_mode()` so mode bookkeeping (`_sync_mode_buttons`, `_apply_mode_frame`, `_refresh_mode_badge`, `mode_changed`) runs exactly as for a manual Play press.
|
||||
|
||||
## 5. Error-dialog and fallback-to-new-scene behavior
|
||||
|
||||
A new `AcceptDialog` `_cli_error_dialog` is built in `_build_ui()` alongside `_missing_assets_dialog`:
|
||||
|
||||
```text
|
||||
SandboxStage/UI
|
||||
└── CliErrorDialog (AcceptDialog)
|
||||
title: "Could Not Load Scene"
|
||||
name: "CliErrorDialog"
|
||||
```
|
||||
|
||||
`_show_cli_error_dialog(message)` sets `dialog_text = message` and calls `popup_centered()`, mirroring `_show_missing_assets_dialog`. It is a **blocking** dialog (not the transient toast), satisfying requirement 4.
|
||||
|
||||
After the dialog is dismissed, the stage is left exactly as a brand-new document: `Untitled`, empty `World`, default camera, `current_mode == EDIT`. No PLAY is entered. This is the "default to a new (blank) scene" guarantee.
|
||||
|
||||
The error dialog is also used for the `--play`-without-`--scene` case (requirement 3), with the parser's error text (e.g. `"--play requires --scene <path>."`).
|
||||
|
||||
## 6. `--play` validity rules
|
||||
|
||||
| CLI input | Behavior |
|
||||
| --- | --- |
|
||||
| `--scene <valid>` | Load the scene, remain EDIT. Title bar shows the loaded scene. |
|
||||
| `--play --scene <valid>` | Load the scene, then `set_mode(StageMode.PLAY)` (autosave suppressed). |
|
||||
| `--play` (no `--scene`) | Parser error → `CliErrorDialog`, remain EDIT/blank. **Never** auto-enter PLAY. |
|
||||
| `--scene <missing/corrupt>` | `CliErrorDialog` with the read error, remain EDIT/blank. |
|
||||
| unknown flag / two positional paths / `--scene`+positional | Parser error → `CliErrorDialog`, remain EDIT/blank. |
|
||||
|
||||
## 7. Dirty / recent state
|
||||
|
||||
A CLI load uses the **normal clean-load** semantics (`_apply_scene_data(..., is_recovery=false)`): it adopts `_current_scene_path`, clears `_dirty`, adds the path to recents (`_add_recent_scene`), removes the autosave, and updates the title. This is what makes the title bar show `Scene Name — <file>.scene — Stickman Studio` and keeps the Open Recent menu consistent with the launched file. No special "read-only" or "dirty-on-load" CLI state is introduced.
|
||||
|
||||
## 8. Verification strategy
|
||||
|
||||
### 8.1 Headless test — `tests/test_cli_scene.gd`
|
||||
|
||||
Run with the project's established headless form (macOS):
|
||||
|
||||
```bash
|
||||
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/ryan/Projects/stickman --script res://tests/test_cli_scene.gd
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
|
||||
1. **Parser (pure, no scene tree):** `CliSceneLauncher.parse([])` → `{scene_path:"", play:false, error:""}`; `["--play","--scene","res://x.scene"]` → `{scene_path:"res://x.scene", play:true, error:""}`; `["--scene=res://x.scene"]` → scene path set; bare positional `["/abs/x.scene","--play"]` → scene path + play; `["--play"]` → error; `["--scene"]` (missing value) → error; `["--bogus"]` → error; two positionals → error; `--scene` + positional → error.
|
||||
2. **Load:** instantiate `sandbox_stage.tscn`, write a small valid `.scene` fixture to `user://`, call `_handle_cli_startup(["--scene", fixture])`, assert `_current_scene_path == fixture`, `World` child count matches, `_dirty == false`, `_recent_scenes[0] == fixture`, title contains the scene basename, and the autosave file is absent.
|
||||
3. **PLAY:** `_handle_cli_startup(["--play","--scene", fixture])` → `current_mode == StageMode.PLAY` and `user://sandbox_autosave.scene` does **not** exist afterward (autosave-on-Play suppressed).
|
||||
4. **Error → blank:** `_handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])` → `_cli_error_dialog` visible with non-empty `dialog_text`, `World.get_child_count() == 0`, `current_mode == StageMode.EDIT`.
|
||||
5. **`--play` without scene:** `_handle_cli_startup(["--play"])` → `_cli_error_dialog` visible, `current_mode == StageMode.EDIT`, never PLAY.
|
||||
6. **Recovery skipped:** pre-write a valid `user://sandbox_autosave.scene` (autosave metadata), call `_handle_cli_startup(["--scene", fixture])`, assert the autosave file is removed and no `AutosaveRecoveryDialog` was created.
|
||||
|
||||
Note the repo gotcha (`docs/tech_debt_and_optimizations.md` #22): a newly added `class_name CliSceneLauncher` is not resolvable by `--check-only --script` until an `--editor` pass regenerates `.godot/global_script_class_cache.cfg`. Run once first:
|
||||
|
||||
```bash
|
||||
/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path /Users/ryan/Projects/stickman --quit
|
||||
```
|
||||
|
||||
Then a parse check:
|
||||
|
||||
```bash
|
||||
/Applications/Godot.app/Contents/MacOS/Godot --headless --check-only --script res://scripts/cli_scene_launcher.gd --path /Users/ryan/Projects/stickman --quit
|
||||
```
|
||||
|
||||
After the focused suite passes, re-run every existing script under `tests/` using its documented headless command and report suite names/results.
|
||||
|
||||
### 8.2 Manual Godot Editor verification
|
||||
|
||||
Run the exact launch commands from §2.3 in a terminal (not F6) and confirm:
|
||||
|
||||
- `--scene res://<file>.scene` opens the scene in EDIT with the correct title and Open Recent entry.
|
||||
- `--play --scene res://<file>.scene` opens and immediately plays; the Play mode badge shows `▶️ SIMULATING`; no recovery prompt appears on the following ordinary launch.
|
||||
- `--play` alone shows the "Could Not Load Scene" dialog with `"--play requires --scene <path>."`, leaves a blank Untitled stage, and does not enter PLAY.
|
||||
- `--scene /nonexistent.scene` shows the dialog with the read error and leaves a blank Untitled stage.
|
||||
- A pre-existing `user://sandbox_autosave.scene` is removed when launching with `--scene` (explicit file intent overrides recovery).
|
||||
|
||||
## 9. Approval gate: decisions to accept before implementation
|
||||
|
||||
The architect recommends approving these as a set:
|
||||
|
||||
1. **Flag syntax** — `--play` (boolean) and `--scene <path>` (with `--scene=<path>` and a single bare-positional convenience), parsed from `OS.get_cmdline_user_args()`; the parser enforces "PLAY requires a scene" as an error. Paths must be `res://`/`user://`/absolute (no scheme guessing).
|
||||
2. **Autosave policy** — an explicit `--scene` skips `_check_autosave_recovery()` and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped `_cli_startup` guard (leaving the interactive Play button untouched).
|
||||
3. **Failure semantics** — on any CLI parse error or `--scene` load failure, show a blocking `AcceptDialog` (`CliErrorDialog`) and remain on a blank EDIT/Untitled stage; never auto-enter PLAY. A successful CLI load uses normal clean-load semantics (adopts path, clears dirty, updates recents/title).
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Changes to `main.tscn`, `stickman_editor.tscn`, `project.godot` `run/main_scene`, or the `.scene` v1.0 JSON schema.
|
||||
- Reusing the CLI loader to launch the stickman **editor** (`.stk`) — this targets the Sandbox Stage only.
|
||||
- `--headless`-only automation mode, output/logging flags, or a `--quit-after` flag.
|
||||
- Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Specification: Dynamic Vector Props (`PropBlock`)
|
||||
|
||||
**Objective:** Create a reusable, dynamic physics prop framework (`PropBlock`) using `RigidBody2D` with resolution-independent vector visuals (`Polygon2D`, `Line2D`) and configurable physics material profiles (mass, friction, bounce) that interact with `TerrainBlock` instances.
|
||||
|
||||
---
|
||||
|
||||
## 1. Reusable `PropBlock` Component (`RigidBody2D`)
|
||||
|
||||
- **Node Hierarchy:** A root `RigidBody2D` with three direct children: `Polygon2D` for interior visual fill, `Line2D` for crisp vector borders, and a physics collision node (`CollisionPolygon2D` for custom polygons or `CollisionShape2D` with a `CircleShape2D` for spheres).
|
||||
- **Script Architecture:** `class_name PropBlock`, `extends RigidBody2D` equipped with `@tool` mode for live editor previewing.
|
||||
- **Exported Properties:**
|
||||
- **Shape Parameters:** Shape type selector (Polygon vs. Circle), `polygon_points` (`PackedVector2Array`), and `radius` (`float`).
|
||||
- **Visual Styling:** `fill_color`, `outline_color`, and `outline_width` matching the stroke aesthetic of `TerrainBlock`.
|
||||
- **Physics Presets:** A preset selector or exported `PhysicsMaterial` handle for quick material assignment (e.g., _Wood Crate_, _Bouncy Rubber_, _Light Cardboard_, _Heavy Metal_).
|
||||
- **Live Update Setter:** A unified property setter that updates visual fill and border geometry dynamically:
|
||||
- **Polygons:** Assigns `polygon_points` to `Polygon2D` and `CollisionPolygon2D` (`BUILD_SOLIDS`), closing the `Line2D` outline by appending the first vertex to the end with rounded line joints and caps.
|
||||
- **Circles:** Generates a smooth radial point loop for `Polygon2D` and `Line2D` while updating the radius of a `CircleShape2D` to maintain fast, accurate spherical collision.
|
||||
|
||||
---
|
||||
|
||||
## 2. `PropUtils` Factory & Preset Helper
|
||||
|
||||
- **Class Setup:** `class_name PropUtils`, `extends RefCounted` providing static utility methods for spawning dynamic props.
|
||||
- **Primitive Generators:** Helper functions to programmatically build common prop primitives (e.g., `create_box()`, `create_ball()`, `create_plank()`, `create_triangle()`) with default vector dimensions and color themes.
|
||||
- **Physics Material Presets:** Internal factory definitions for pre-configured `PhysicsMaterial` resources:
|
||||
- _Wood:_ Moderate mass, medium friction, low bounce.
|
||||
- _Rubber:_ Low mass, high friction, high restitution/bounce ($0.8+$).
|
||||
- _Metal:_ High mass, high friction, zero bounce.
|
||||
- **Point Sanitization:** Any custom polygonal points passed to `PropUtils` should run through `TerrainUtils.sanitize_points()` to guarantee clockwise vertex order and strip collinear points before creating the prop.
|
||||
- **Spawner Factory (`spawn_prop`):** Static function taking a target parent container, position, shape payload, and material preset to instantiate a `PropBlock`, set its initial velocity, and add it to the scene.
|
||||
|
||||
---
|
||||
|
||||
## 3. `PhysicsTestHarness` Staging Integration
|
||||
|
||||
- **Controls & Spawning:** Update `scripts/physics_test_harness.gd` to bind key inputs (or mouse clicks) for spawning dynamic props directly into the test world (e.g., Press `1` for Wood Crate, `2` for Bouncy Ball, `3` for Heavy Plank).
|
||||
- **Physics Verification Checklist:**
|
||||
- **Vector Scaling & Zoom:** Ensure stroke weights and line join quality on dynamic props remain crisp and visually coherent with static terrain across camera zoom levels (0.25x to 3.0x).
|
||||
- **Collision Accuracy:** Verify dynamic props roll smoothly down angled ramps, bounce predictably off step ledges, and stack stably on flat terrain without clipping or falling through `TerrainBlock` geometry.
|
||||
- **Rig Interaction:** Confirm dynamic props collide accurately with the standing `MasterRig` instance on the ground.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Ragdoll ↔ Kinematic Blending & Recovery
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Extend the existing kinematic-to-ragdoll system with two major features:
|
||||
|
||||
- **Blended Transition:** A smooth, visually appealing fade between the kinematic puppet and the ragdoll, eliminating the abrupt "pop" when switching modes.
|
||||
- **Ragdoll Recovery:** The ability for the stickman to autonomously stand back up after falling, transitioning from ragdoll back to animated mode with a "get up" animation.
|
||||
|
||||
These features are essential for a director-driven sandbox where characters can fall, recover, and continue performing actions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature 1: Ragdoll ↔ Kinematic Blending (Soft Transition)
|
||||
|
||||
### 2.1. Objective
|
||||
|
||||
Replace the instant mode switch with a gradual transition that blends the visual appearance and physical behavior over a configurable duration (e.g., 0.5–1.0 seconds). This avoids jarring pops and creates a more polished, film‑like effect.
|
||||
|
||||
### 2.2. Approach
|
||||
|
||||
#### 2.2.1. Visual Blending (Opacity Crossfade)
|
||||
|
||||
- During the transition, both the kinematic `Body/*` nodes and the ragdoll `RigidBody2D` bodies are visible.
|
||||
- The kinematic nodes start at full opacity and fade out; the ragdoll bodies start at zero opacity and fade in.
|
||||
- Use a `Tween` or `_process` lerp to drive the `modulate.a` of all relevant nodes over the transition duration.
|
||||
|
||||
#### 2.2.2. Physical Blending (Joint Stiffness Ramp)
|
||||
|
||||
- When entering ragdoll, start with the `PinJoint2D` stiffness (`softness`/`bias`) at a high value (near‑rigid).
|
||||
- Gradually reduce stiffness over the transition period so the limbs become floppy.
|
||||
- Conversely, when exiting ragdoll, ramp stiffness from floppy to rigid before freezing the pose.
|
||||
|
||||
#### 2.2.3. Implementation Outline
|
||||
|
||||
- `StickmanRig` gains a `transition_duration` property (export, default 0.6s).
|
||||
- `_enter_ragdoll()` spawns the ragdoll bodies with `modulate.a = 0.0` and joints at high stiffness.
|
||||
- A `_transition_process(delta)` runs during the blend, updating opacities and joint properties.
|
||||
- Upon completion, the kinematic nodes are hidden (or vice versa) and the system settles into the target state.
|
||||
|
||||
### 2.3. Acceptance Criteria
|
||||
|
||||
- No visible pop when switching modes.
|
||||
- The transition duration is configurable (tunable per director preference).
|
||||
- Both visual and physical blending are synchronized.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature 2: Ragdoll Recovery (Getting Back Up)
|
||||
|
||||
### 3.1. Objective
|
||||
|
||||
Allow the ragdoll to automatically stand up after it has come to rest. This involves detecting rest, capturing the ragdoll’s final pose, applying that pose to the kinematic skeleton, playing a "stand up" animation, and transitioning back to animated mode.
|
||||
|
||||
### 3.2. Core Components
|
||||
|
||||
#### 3.2.1. Rest Detection
|
||||
|
||||
- Monitor the ragdoll Torso body’s linear and angular velocities.
|
||||
- When both remain below a small threshold (e.g., `0.1 m/s` and `0.1 rad/s`) for a continuous **timeout** (set by the director), trigger recovery.
|
||||
- The timeout must be configurable per character (or globally).
|
||||
|
||||
#### 3.2.2. Pose Capture
|
||||
|
||||
- After rest is detected, read the global positions and rotations of all 10 `RigidBody2D` bodies.
|
||||
- Convert these into local transforms relative to the `StickmanRig` root (or the `Skeleton2D` root).
|
||||
- This captured pose becomes the target for the kinematic bones.
|
||||
|
||||
#### 3.2.3. Kinematic Snap
|
||||
|
||||
- Temporarily disable IK (`SkeletonModificationStack2D.enabled = false`).
|
||||
- Set each `Bone2D` node’s global position and rotation to match the captured ragdoll pose.
|
||||
- This ensures the skeleton matches the ragdoll’s final resting posture.
|
||||
|
||||
#### 3.2.4. Stand-Up Animation
|
||||
|
||||
- Play a "stand up" animation (e.g., `stand_up`) that transitions the skeleton from the captured pose to a neutral standing pose.
|
||||
- The animation should be authored/generated to work from any reasonable rest pose.
|
||||
- Once the animation finishes, re‑enable IK and set the rig back to `ANIMATED` mode.
|
||||
|
||||
### 3.3. Implementation Outline
|
||||
|
||||
- Add a new state `RECOVERING` to `RigState`.
|
||||
- In `_physics_process`, when in `RAGDOLL` mode, track the Torso’s velocity and a rest timer.
|
||||
- When rest timer exceeds `rest_timeout`, call `_start_recovery()`.
|
||||
- `_start_recovery()`:
|
||||
1. Capture ragdoll pose.
|
||||
2. Delete ragdoll bodies (or hide them).
|
||||
3. Snap kinematic skeleton to captured pose.
|
||||
4. Start the `AnimationPlayer` with the `stand_up` animation.
|
||||
5. On animation end, re‑enable IK, show `Body/*`, transition to `ANIMATED`.
|
||||
- The recovery process should be interruptible (e.g., if the director toggles back to ragdoll during recovery).
|
||||
|
||||
### 3.4. Acceptance Criteria
|
||||
|
||||
- Ragdoll automatically stands up after resting for the configured timeout.
|
||||
- The stand‑up motion is smooth and visually convincing.
|
||||
- The character resumes animated behavior after recovery.
|
||||
|
||||
---
|
||||
|
||||
## 4. Animation Generation for Recovery
|
||||
|
||||
### 4.1. Current State
|
||||
|
||||
You have a `create_walk.gd` editor script that generates `walk_left` and `walk_right` animations by keyframing IK target positions.
|
||||
|
||||
### 4.2. Proposed Enhancement: `create_animations.gd`
|
||||
|
||||
Refactor the animation generation into a unified script that can generate:
|
||||
|
||||
- **Walk cycles** (already done)
|
||||
- **Stand‑up animation** (from a "down" pose to standing)
|
||||
- **Idle / breathing** (optional)
|
||||
|
||||
#### 4.2.1. Architecture
|
||||
|
||||
- The script should define a set of **pose templates** (e.g., `POSE_DOWN`, `POSE_STANDING`).
|
||||
- Each template maps IK target names to positions (relative to the rig root).
|
||||
- The stand‑up animation is a blend between the captured pose (first frame) and the standing pose (last frame), with intermediate frames interpolated using a curve.
|
||||
|
||||
#### 4.2.2. Integration
|
||||
|
||||
- The recovery logic will reference a pre‑generated `stand_up` animation stored in the `AnimationPlayer`.
|
||||
- The same animation library (`""`) holds all animations.
|
||||
- The generator script can be run once at authoring time to create the default animations.
|
||||
|
||||
#### 4.2.3. Future‑Proofing
|
||||
|
||||
- The generator could also accept parameters (e.g., `animation_name`, `profile`, `duration`) to make it reusable.
|
||||
|
||||
### 4.3. Acceptance Criteria
|
||||
|
||||
- A single script (`create_animations.gd`) generates `walk_left`, `walk_right`, and `stand_up`.
|
||||
- The `stand_up` animation works from any reasonable rest pose (i.e., it starts from the current skeleton pose, not a fixed start).
|
||||
|
||||
---
|
||||
|
||||
## 5. Director‑Controlled Rest Timeout
|
||||
|
||||
### 5.1. Requirement
|
||||
|
||||
The director (user) should be able to adjust how long the ragdoll stays on the ground before attempting recovery. This is crucial for storytelling—some scenes need a quick recovery, others need a long pause.
|
||||
|
||||
### 5.2. Implementation
|
||||
|
||||
- `StickmanRig` gains an `@export var rest_timeout: float = 2.0` (seconds).
|
||||
- The `PhysicsTestHarness` UI (and ultimately the director UI) will provide a slider or spinbox to modify this value on the selected rig.
|
||||
- The value is read during `_physics_process` to determine when to start recovery.
|
||||
|
||||
### 5.3. Acceptance Criteria
|
||||
|
||||
- The rest timeout is editable in the inspector (or via a UI control).
|
||||
- Changes take effect immediately (no need to reload the rig).
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration Timeline (Suggested Order)
|
||||
|
||||
| Step | Task | Notes |
|
||||
| ---- | ---------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| 1 | Add `transition_duration` and `rest_timeout` exports to `StickmanRig`. | Low risk, sets foundation. |
|
||||
| 2 | Implement rest detection timer in `_physics_process`. | Test by printing when rest is detected. |
|
||||
| 3 | Implement pose capture and kinematic snap. | Manual trigger for testing. |
|
||||
| 4 | Create `create_animations.gd` with a `stand_up` placeholder. | Even a simple interpolation is fine for first pass. |
|
||||
| 5 | Integrate recovery flow (snap → play animation → re‑enable IK). | End‑to‑end test. |
|
||||
| 6 | Implement visual blending (opacity crossfade). | Polish. |
|
||||
| 7 | Implement physical blending (joint stiffness ramp). | Advanced polish. |
|
||||
| 8 | Add UI control for `rest_timeout` in the harness. | Director‑facing. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Recovery animation looks unnatural because the starting pose varies. | Use a generic "push‑up" style animation that starts from a prone position; blend the first frame from the captured pose to the animation’s first keyframe. |
|
||||
| Kinematic snap may cause jitter if the ragdoll pose is unstable. | Add a small stabilization delay (0.1s) after rest detection before capturing. |
|
||||
| Blending physics (joint stiffness) may cause limbs to twitch. | Use a smooth interpolation curve (ease‑in‑out) rather than linear. |
|
||||
| Multiple rigs in the scene may compete for recovery timers. | Each `StickmanRig` manages its own state independently. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of New/Modified Files
|
||||
|
||||
| File | Action |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `scripts/stickman_rig.gd` | Add transition logic, rest detection, pose capture, recovery state machine. |
|
||||
| `scripts/create_animations.gd` | New file: unified animation generator for walk and stand‑up. |
|
||||
| `scenes/physics_test_harness.tscn` | Add UI controls (slider/spinbox) for `rest_timeout`. |
|
||||
| `scripts/physics_test_harness.gd` | Connect UI to the rig’s `rest_timeout` property. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance Criteria (Full Feature Set)
|
||||
|
||||
- Switching between animated and ragdoll modes is visually smooth (crossfade).
|
||||
- The ragdoll automatically stands up after resting for the director‑defined duration.
|
||||
- The stand‑up animation is generated procedurally and plays seamlessly.
|
||||
- The director can adjust the rest timeout at runtime.
|
||||
- The system is robust and does not produce orphaned nodes or crashes.
|
||||
|
||||
---
|
||||
|
||||
_End of Plan_
|
||||
@@ -0,0 +1,72 @@
|
||||
# Kinematic-to-Ragdoll Translation
|
||||
|
||||
## 1. Objective
|
||||
|
||||
We want dynamic state-switching system that transitions the `master_rig.tscn` stickman from a kinematic, IK-driven puppet (`ANIMATED` mode) into a fully physical, ragdoll-driven entity (`RAGDOLL` mode). This enables natural falling, tumbling, and collision responses with the physics environment (terrain and props) while preserving the stickman’s current momentum.
|
||||
|
||||
## 2. Scope & Impact
|
||||
|
||||
- **Primary Target:** `scripts/stickman_rig.gd` (the runtime controller of `master_rig.tscn`).
|
||||
- **Secondary Target:** `scripts/physics_test_harness.gd` (adds trigger method).
|
||||
- **Scene Impact:** The `master_rig.tscn` scene file will **not** be modified. All ragdoll nodes (bodies and joints) will be spawned procedurally in code, keeping the scene file clean and maintaining the separation between visual authoring and runtime physics.
|
||||
|
||||
## 3. Core Architecture / Behavior
|
||||
|
||||
### 3.1. State Management
|
||||
|
||||
`StickmanRig` gains a new `state` property with two modes: `ANIMATED` and `RAGDOLL`.
|
||||
|
||||
- **`ANIMATED` (Default):** The skeleton and visual `Body/*` nodes are visible and driven by the `AnimationPlayer` and `SkeletonModificationStack2D` (IK). The script tracks the root node’s velocity every frame to cache momentum.
|
||||
- **`RAGDOLL`:** The kinematic rig is frozen (IK disabled, `AnimationPlayer` stopped), the `Body/*` visual nodes are hidden, and a new network of `RigidBody2D` nodes (with matching collision shapes) is spawned and reparented to the world root. Physical `PinJoint2D` nodes connect these bodies, simulating the bone hierarchy.
|
||||
|
||||
### 3.2. Velocity Handoff (Momentum Preservation)
|
||||
|
||||
To avoid unnatural "freeze" effects when switching, the system captures the `linear_velocity` and `angular_velocity` of the rig root in `ANIMATED` mode (using `_process`). Upon entering `RAGDOLL` mode, these cached velocities are applied directly to the ragdoll’s **Torso** body, ensuring the stickman continues its current motion (falling, sliding, etc.) seamlessly.
|
||||
|
||||
## 4. Implementation Breakdown
|
||||
|
||||
### 4.1. Ragdoll Construction
|
||||
|
||||
A dedicated builder function iterates over a predefined set of `Bone2D` nodes from the `Skeleton2D` hierarchy. For each bone, it spawns a `RigidBody2D` with the following specifications:
|
||||
|
||||
- **Position:** The midpoint of the bone (calculated from `global_position` and `bone.length`).
|
||||
- **Rotation:** Matches the `global_rotation` of the bone.
|
||||
- **Collision Shapes:**
|
||||
- **Head:** Uses a `CircleShape2D` with a radius of **100px** (matching the visual head graphic).
|
||||
- **Other Bones (Limbs & Torso):** Uses a `CapsuleShape2D`. The height matches the bone’s `.length` property, and the radius matches the visual line width (approximately **14px**).
|
||||
- **Mass Distribution:** The Torso is given a significantly higher mass (e.g., `8.0`) compared to limbs (e.g., `1.5`) to create realistic inertia and prevent limbs from dragging the body into orbit upon collision.
|
||||
- **Damping:** `Linear` and `Angular` damping are applied to prevent excessively bouncy or "spaghetti-like" behavior.
|
||||
|
||||
### 4.2. Jointing (Parent-Child Constraints)
|
||||
|
||||
For every child bone that has a valid parent bone, the system spawns a `PinJoint2D`.
|
||||
|
||||
- **Positioning:** The joint is placed at the **bottom of the parent bone**, which aligns with the **top of the child bone**.
|
||||
- **Rotation Limits (Critical for realism):**
|
||||
- **Elbows and Knees:** Angular limits are enforced (strictly restricted to prevent backward bending/hyperextension).
|
||||
- **Head/Neck:** Loose limits or free rotation to allow natural lolling.
|
||||
- **Torso/Shoulders/Hips:** Moderate limits to maintain structural integrity while allowing dynamic twisting.
|
||||
- **Stiffness:** The `softness` and `bias` properties are tuned to prevent limb separation (stretching) under stress.
|
||||
|
||||
### 4.3. Cleanup & Reversion
|
||||
|
||||
The `RAGDOLL` mode is designed to be reversible:
|
||||
|
||||
- When exiting `RAGDOLL` (or reloading the rig), all dynamically spawned `RigidBody2D` and `PinJoint2D` nodes are queued for deletion.
|
||||
- The kinematic `Body/*` nodes are made visible again.
|
||||
- The `SkeletonModificationStack2D` (IK) is re-enabled, and the `AnimationPlayer` is reset to a neutral state.
|
||||
|
||||
## 5. Integration with Test Harness
|
||||
|
||||
The existing `PhysicsTestHarness` scene will be updated to include a user trigger:
|
||||
|
||||
- **Key Binding:** Pressing the **R** key will call the `toggle_ragdoll()` method.
|
||||
- **Collision Proxy Management:** The harness currently places a `RigCollisionProxy` (a large static box) around the rig to allow props to interact with it. When entering `RAGDOLL` mode, this proxy **must be destroyed** immediately; otherwise, the physical ragdoll will spawn inside the box and float unrealistically. The ragdoll will instead collide directly with the actual terrain geometry.
|
||||
|
||||
## 6. Acceptance Criteria (For QA/Testing)
|
||||
|
||||
- **Transition Seamlessness:** Switching to ragdoll mid-walk or mid-air applies the correct linear/angular momentum so the stickman continues the trajectory naturally.
|
||||
- **Terrain Interaction:** The ragdoll must rest, slide, or tumble naturally on flat ground, ramps, and stairs (colliding with the `TerrainBlock` static bodies).
|
||||
- **Structural Integrity:** Limbs should not stretch or detach under gravity or moderate impact. Elbows and knees must not bend backwards.
|
||||
- **Visual Fidelity:** The physical collision shapes should align visually with the hidden `Line2D` bones (no floating collision boxes).
|
||||
- **Cleanup:** Rapid switching between modes (spawning/deleting ragdolls) does not cause memory leaks or orphaned nodes.
|
||||
@@ -0,0 +1,429 @@
|
||||
# Kinematic-to-Ragdoll Translation — Implementation Spec
|
||||
|
||||
> Status: implementation-ready. This spec replaces the plan's open questions with
|
||||
> **verified** facts from the codebase and Godot 4.4 source. Everything below is
|
||||
> checked against the actual files; corrections to the plan are called out in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Add a reversible `ANIMATED ⇄ RAGDOLL` state switch to the runtime rig
|
||||
(`master_rig.tscn` + `scripts/stickman_rig.gd`). In `RAGDOLL` mode the kinematic
|
||||
`skeleton + IK` puppet is frozen and hidden, and a procedurally-built network of
|
||||
`RigidBody2D` + `PinJoint2D` nodes (spawned in code, never in the scene file)
|
||||
takes over, so the figure tumbles/falls against the terrain and props. The
|
||||
`physics_test_harness` scene gets an **R** key trigger.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verified codebase facts (read these as ground truth)
|
||||
|
||||
### 2.1 Scene tree of `master_rig.tscn` (root = `Master`, script `StickmanRig`)
|
||||
|
||||
```
|
||||
Master (Node2D, script = stickman_rig.gd, class StickmanRig)
|
||||
├── Body (Node2D — sibling of Skeleton2D)
|
||||
│ ├── Body (Line2D 0→400, width 16 — TORSO visual)
|
||||
│ ├── LeftUpperLeg (Line2D 0→200 w16)
|
||||
│ ├── RightUpperLeg (Line2D 0→200 w16)
|
||||
│ ├── LeftLowerLeg (Line2D 0→200 w16)
|
||||
│ ├── RightLowerLeg (Line2D 0→200 w16)
|
||||
│ ├── LeftUpperArm (Line2D 0→175 w16)
|
||||
│ ├── RightUpperArm (Line2D 0→175 w16)
|
||||
│ ├── LeftLowerArm (Line2D 0→200 w16)
|
||||
│ ├── RightLowerArm (Line2D 0→200 w16)
|
||||
│ └── Head (Node2D + inline @tool circle script, radius 100)
|
||||
├── Skeleton2D (modification_stack assigned)
|
||||
│ ├── Torso (Bone2D, root — NO length)
|
||||
│ │ ├── Head (Bone2D, pos (0,-391.5) rel Torso, length 90, bone_angle -90)
|
||||
│ │ │ ├── RayCast_Aim
|
||||
│ │ │ └── Pivot → RemoteTransform2D (→ Body/Head)
|
||||
│ │ ├── LeftUpperArm (Bone2D pos (0,-248), length 168)
|
||||
│ │ │ ├── LeftLowerArm (Bone2D pos (-168,0), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerArm)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperArm)
|
||||
│ │ ├── RightUpperArm (Bone2D pos (0,-248), length 168)
|
||||
│ │ │ ├── RightLowerArm (Bone2D pos (168,0), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerArm)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/RightUpperArm)
|
||||
│ │ ├── LeftUpperLeg (Bone2D pos (0,0), length 200)
|
||||
│ │ │ ├── LeftLowerLeg (Bone2D pos (0,200), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerLeg)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperLeg)
|
||||
│ │ ├── RightUpperLeg (Bone2D, length 200) ← NOTE: current scene is 200,
|
||||
│ │ │ ├── RightLowerLeg (Bone2D pos (0,200), length 200) not the stale "90"
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerLeg) mentioned in old docs
|
||||
│ │ │ └── RemoteTransform2D (→ Body/RightUpperLeg)
|
||||
│ │ └── RemoteTransform2D (→ Body/Body, torso driver, rotation π)
|
||||
├── RayCast_Ground
|
||||
├── IK_Targets (Right_Hand, Left_Hand, Right_Leg, Left_Leg, Head, Torso→RT2D, RayCasts)
|
||||
├── AnimationPlayer (libraries: RESET / walk_left / walk_right)
|
||||
└── AnimationTree (active=false, placeholder — out of scope)
|
||||
```
|
||||
|
||||
**Key answers to "where things live":**
|
||||
- `Body/*` visual nodes are **siblings of `Skeleton2D`**, both children of `Master`.
|
||||
They are **not** children of the bones. Each `Body/*` node is driven by a
|
||||
`RemoteTransform2D` that is a **child of the matching bone** (via `remote_path`).
|
||||
- The `RemoteTransform2D` drivers live under the bones, not on the `Body/*` nodes.
|
||||
- The **torso visual** is named `Body/Body` (not `Body/Torso`). The **torso bone**
|
||||
(`Skeleton2D/Torso`) has **no `length`** — it is the hip joint root. The actual
|
||||
spine segment runs from the Torso bone origin (hips) to the Head bone origin
|
||||
(neck), distance ≈ 391.5 px.
|
||||
|
||||
### 2.2 Bone semantics (for ragdoll geometry)
|
||||
|
||||
`Bone2D` extends along its **local +X** axis by `length`. So for any bone:
|
||||
- **origin** = `bone.global_position`
|
||||
- **tip** (far end / child joint) = `bone.to_global(Vector2(bone.length, 0.0))`
|
||||
- **midpoint** = `(origin + tip) / 2`
|
||||
- **rotation** = `bone.global_rotation` (the bone points along +X locally)
|
||||
|
||||
The `Body/*` `Line2D`s are authored along local +Y and their `RemoteTransform2D`
|
||||
drivers add `±π/2` rotation to align them with the bone's +X. This is why
|
||||
`Body/LeftUpperArm` is 175 px but its bone `length` is 168 — **use the Bone2D
|
||||
`length` for collision, not the Line2D points length**.
|
||||
|
||||
### 2.3 Harness integration points (`physics_test_harness.gd`)
|
||||
|
||||
- Scene root `PhysicsTestHarness (Node2D)` at world origin, children `Camera2D`,
|
||||
`Environment` (empty Node2D). Rig is spawned in **code**, not the scene file.
|
||||
- `_spawn_rig()` (line ~146): `RIG_SCENE.instantiate()` → `rig.position =
|
||||
RIG_SPAWN_POSITION (0,-385)` → `add_child(rig)` → `_add_rig_collision_proxy()`.
|
||||
**It does not store the rig reference** — a `_rig` member must be added.
|
||||
- `_add_rig_collision_proxy()` (line ~191): creates `StaticBody2D` named
|
||||
**`"RigCollisionProxy"`** as a direct child of the harness root, `position =
|
||||
RIG_PROXY_CENTER`, with a `RectangleShape2D` child (240×1000). No removal
|
||||
function exists yet — add `_remove_rig_collision_proxy()`.
|
||||
- Key handling: `_input()` → `_handle_key(InputEventKey)` (line ~76) already
|
||||
matches `KEY_1/KEY_2/KEY_3` (guarding `key.pressed` and `key.echo`). **R goes
|
||||
here** as a new `match` arm. `R` is currently unused — no conflict.
|
||||
- `PhysicsTestHarness` does **not** play any animation; the rig stands in rest
|
||||
pose with the modification stack enabled. Momentum at toggle will therefore be
|
||||
≈ 0 in this harness (the momentum path is generic and still specified).
|
||||
|
||||
### 2.4 `StickmanRig` (`scripts/stickman_rig.gd`) internals to reuse
|
||||
|
||||
Existing fields/methods (do not rename): `_nodes_ready`, `_skeleton: Skeleton2D`,
|
||||
`_body_container: Node2D`, `_bend_joint_bones`, `_bend_modifications`;
|
||||
`_ready()` enables `_skeleton.modification_stack.enabled = true`; `_apply_profile()`
|
||||
rewrites IK flags + `Body/*` z-order; `get_bend_joint_global_position()`.
|
||||
The ragdoll system adds **new** members and does not change these.
|
||||
|
||||
### 2.5 AnimationPlayer
|
||||
|
||||
Owned by the rig root (`Master`), direct child, named **`"AnimationPlayer"`.
|
||||
`test_harness.gd` confirms `const ANIMATION_PLAYER_PATH := "AnimationPlayer"`.
|
||||
Ragdoll code resolves it as `get_node_or_null(NodePath("AnimationPlayer"))`.
|
||||
|
||||
### 2.6 Collision layers
|
||||
|
||||
No script sets `collision_layer`/`collision_mask`. `TerrainBlock` (StaticBody2D),
|
||||
`PropBlock` (RigidBody2D), and `RigCollisionProxy` (StaticBody2D) all use Godot
|
||||
defaults: **layer 1, mask 1**. Ragdoll bodies must therefore also use **layer 1,
|
||||
mask 1** so they collide with terrain and props (and props still bounce off them).
|
||||
|
||||
---
|
||||
|
||||
## 3. Plan corrections (verified against Godot 4.4 source)
|
||||
|
||||
1. **`PinJoint2D` DOES have angle limits in Godot 4.4** — the plan's "no limits,
|
||||
must build a custom joint" assumption is wrong. Verified properties (4.4 docs
|
||||
+ `scene/2d/physics/joints/pin_joint_2d.{h,cpp}`):
|
||||
- `softness: float` (default 0)
|
||||
- `angular_limit_enabled: bool` (default false)
|
||||
- `angular_limit_lower: float` (radians, default 0, hint range −180°..180°)
|
||||
- `angular_limit_upper: float` (radians, default 0, hint range −180°..180°)
|
||||
- `motor_enabled: bool`, `motor_target_velocity: float` (rad/s)
|
||||
- Inherited from `Joint2D`: `node_a`, `node_b`, `bias`, `disable_collision`
|
||||
(default **true** — connected bodies won't self-collide, which is what we want).
|
||||
- The pin point = the joint node's **global position**
|
||||
(`joint_make_pin(joint, get_global_position(), …)`).
|
||||
|
||||
**Angle-limit semantics (critical, read carefully)** — from
|
||||
`modules/godot_physics_2d/godot_joints_2d.cpp`:
|
||||
- On joint construction the solver stores
|
||||
`initial_angle = angle_from(parent_body_origin → child_body_origin)` (world
|
||||
space, captured **once**).
|
||||
- Each step it computes
|
||||
`dist = angle( (child_origin − parent_origin).rotated(−initial_angle) )` and
|
||||
clamps `dist` to `[angular_limit_lower, angular_limit_upper]`.
|
||||
- **Therefore the limits are measured in WORLD space**, relative to the
|
||||
spawn-time direction of the parent→child center vector, and **do not follow
|
||||
the parent body's own rotation**.
|
||||
- Practical consequences (documented, accepted for v1):
|
||||
* The limit is an *approximation* of the child's swing angle (it uses body
|
||||
centers, so there is a ~parent_length/2 parallax — monotonic and fine).
|
||||
* Because it is world-frame, the "no backward bend" guarantee holds at the
|
||||
spawn orientation but degrades as the whole figure tumbles. This is the
|
||||
standard ragdoll trade-off; a local-frame custom joint is listed as a
|
||||
**deferred enhancement**, not v1.
|
||||
* The per-limb fold sign (+/−) depends on limb side and facing — the
|
||||
implementer must do a one-time visual check and swap/normalize the
|
||||
`lower`/`upper` pair per joint (the data table in §5 makes this a one-line
|
||||
edit).
|
||||
|
||||
2. **`CapsuleShape2D` semantics** (verified in `modules/godot_physics_2d/godot_shape_2d.cpp`):
|
||||
`height` = **total** capsule height (tip to tip), `radius` = cap radius; the
|
||||
straight section length = `height − 2·radius`; AABB spans local Y
|
||||
`[−height/2, +height/2]`. **Recommendation: `height = bone.length`, `radius =
|
||||
8.0`** so the capsule spans the bone exactly tip-to-tip (rounded caps at the
|
||||
joints), matching the Line2D's round caps. (`width 16 → radius 8`. The plan's
|
||||
"≈14" is a chunkier stability alternative; expose as a named constant.)
|
||||
|
||||
3. **Engine version discrepancy**: `AGENTS.md` says "Godot 4.4" but
|
||||
`project.godot` has `config/features=PackedStringArray("4.7", "Forward Plus")`
|
||||
(project last saved with 4.7). Disk has `Godot_v4.4-stable_win64{,_console}.exe`
|
||||
and `Godot_v4.7.1-stable_win64{,_console}.exe`. The angle-limit API verified
|
||||
here exists in 4.4 and later. Recommend the implementer confirm which binary is
|
||||
canonical (default to **4.4** per AGENTS/task, flag the 4.7 features string).
|
||||
|
||||
4. **`RightUpperLeg.length` is already 200** in the current `master_rig.tscn`
|
||||
(the "90" value in old docs/AGENTS is stale). The ragdoll builder reads
|
||||
`bone.length` live, so this is moot, but do not assume 90 anywhere.
|
||||
|
||||
5. **Momentum**: `StickmanRig` is a `Node2D` — it has no built-in velocity and
|
||||
no angular velocity. Track both from per-frame deltas (see §6.3). In the
|
||||
current harness the rig never moves, so values are ≈ 0; the mechanism is
|
||||
generic for future use.
|
||||
|
||||
---
|
||||
|
||||
## 4. Public API on `StickmanRig`
|
||||
|
||||
```gdscript
|
||||
enum RigState { ANIMATED, RAGDOLL }
|
||||
|
||||
signal state_changed(new_state: int) # emits RigState value
|
||||
|
||||
var state: RigState = RigState.ANIMATED # read-only outside; only setters change it
|
||||
|
||||
func is_in_ragdoll() -> bool # state == RigState.RAGDOLL
|
||||
func set_ragdoll(enabled: bool) -> void # enter if enabled & !in_ragdoll; exit if !enabled & in_ragdoll
|
||||
func toggle_ragdoll() -> void # set_ragdoll(not is_in_ragdoll())
|
||||
```
|
||||
|
||||
`set_ragdoll()` / `toggle_ragdoll()` are the only external entry points. The
|
||||
harness calls `rig.toggle_ragdoll()`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ragdoll data tables
|
||||
|
||||
### 5.1 Body definitions (10 bodies)
|
||||
|
||||
Ordered so parents are built before children. `bone_path` is relative to the rig
|
||||
root. The torso and head are special-cased in the builder (see §6.2).
|
||||
|
||||
| key | bone_path (Skeleton2D-relative) | parent | shape | length source | radius | mass | lin damp | ang damp |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `torso` | `Skeleton2D/Torso` | — | capsule | dist(Torso, Head) | 12 | 8.0 | 1.0 | 4.0 |
|
||||
| `head` | `Body/Head` (visual center) | `torso` | circle | radius 100 | 100| 2.0 | 0.5 | 2.0 |
|
||||
| `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `left_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
|
||||
| `right_upper_arm`| `Skeleton2D/Torso/RightUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `right_lower_arm`| `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `right_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
|
||||
| `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
|
||||
| `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `left_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `right_upper_leg`| `Skeleton2D/Torso/RightUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
|
||||
| `right_lower_leg`| `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `right_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
|
||||
|
||||
Named constants: `RAGDOLL_LIMB_RADIUS := 8.0`, `RAGDOLL_TORSO_RADIUS := 12.0`,
|
||||
`RAGDOLL_HEAD_RADIUS := 100.0`. (Tune `RAGDOLL_LIMB_RADIUS` up to ~14 if limbs
|
||||
tunnel at high speed.)
|
||||
|
||||
### 5.2 Joint definitions (9 joints)
|
||||
|
||||
One `PinJoint2D` per non-root body. `pin_point` = the **child bone's origin**
|
||||
(`child_bone.global_position`), which coincides with the parent bone's tip.
|
||||
`node_a` = parent body, `node_b` = child body.
|
||||
|
||||
| joint | child | parent | pin_point (world) | limits |
|
||||
|---|---|---|---|---|
|
||||
| neck | `head` | `torso` | `Head.global_position` | **free** (`angular_limit_enabled=false`) |
|
||||
| left_shoulder | `left_upper_arm` | `torso` | `LeftUpperArm.global_position` | shoulder/hip band |
|
||||
| left_elbow | `left_lower_arm` | `left_upper_arm` | `LeftLowerArm.global_position` | elbow/knee band |
|
||||
| right_shoulder | `right_upper_arm`| `torso` | `RightUpperArm.global_position` | shoulder/hip band |
|
||||
| right_elbow | `right_lower_arm`| `right_upper_arm`| `RightLowerArm.global_position` | elbow/knee band |
|
||||
| left_hip | `left_upper_leg` | `torso` | `LeftUpperLeg.global_position` | shoulder/hip band |
|
||||
| left_knee | `left_lower_leg` | `left_upper_leg` | `LeftLowerLeg.global_position` | elbow/knee band |
|
||||
| right_hip | `right_upper_leg`| `torso` | `RightUpperLeg.global_position` | shoulder/hip band |
|
||||
| right_knee | `right_lower_leg`| `right_upper_leg`| `RightLowerLeg.global_position` | elbow/knee band |
|
||||
|
||||
Limit bands (radians, relative to spawn angle; **sign to be eyeballed per limb**):
|
||||
|
||||
| band | angular_limit_enabled | lower | upper |
|
||||
|---|---|---|---|
|
||||
| elbow/knee | true | `-deg_to_rad(5)` | `+deg_to_rad(150)` |
|
||||
| shoulder/hip| true | `-deg_to_rad(160)` | `+deg_to_rad(160)` |
|
||||
| neck | false | (n/a) | (n/a) |
|
||||
|
||||
If a joint folds the wrong way in testing, swap its `lower`/`upper` (or negate
|
||||
both) in the table. For a stricter "no hyperextension" at rest, lower
|
||||
elbow/knee to `-deg_to_rad(2)`.
|
||||
|
||||
All joints: `softness = 0.0` (stiff; raise to 0.05–0.2 only if jitter/stretch),
|
||||
`bias` left at default 0 (uses project `default_constraint_bias`),
|
||||
`disable_collision` left `true` (default).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation design
|
||||
|
||||
### 6.1 State & lifecycle
|
||||
|
||||
- New members: `state: RigState`, `_ragdoll_root: Node2D` (container),
|
||||
`_ragdoll_bodies: Dictionary` (key → `RigidBody2D`), `_prev_global_pos:
|
||||
Vector2`, `_prev_global_rot: float`, `_cached_linear_velocity: Vector2`,
|
||||
`_cached_angular_velocity: float`, `_anim_player: AnimationPlayer`.
|
||||
- `_physics_process(delta)`: `_track_momentum(delta)` (only meaningful while
|
||||
`ANIMATED`; harmless otherwise).
|
||||
|
||||
### 6.2 `_enter_ragdoll()`
|
||||
|
||||
1. Freeze kinematic rig: `_skeleton.modification_stack.enabled = false`;
|
||||
`_anim_player.stop()` (null-guarded); `_body_container.visible = false`.
|
||||
(Leave `RemoteTransform2D` drivers as-is — hidden visuals, zero visual cost.)
|
||||
2. `_build_ragdoll()`:
|
||||
- Create `_ragdoll_root = Node2D.new()`, name `"RagdollBodyContainer"`.
|
||||
- **Reparent to world space**: add as child of `get_parent()` (the harness
|
||||
root, at world origin). Guard: if `get_parent() == null`, fall back to
|
||||
`get_tree().current_scene`. Bodies/joints are placed in **world** coords
|
||||
(their `position == global_position` under an origin parent). Do NOT parent
|
||||
the container under the rig — the rig carries `RIG_SPAWN_POSITION` offset.
|
||||
- For each entry in the body table (parent-before-child order):
|
||||
* Resolve the `Bone2D` via `_skeleton.get_node_or_null` (torso/limbs) or the
|
||||
`Body/Head` visual via `get_node_or_null`. Null → `push_warning` + skip
|
||||
that body (and any joints that reference it).
|
||||
* Compute origin/tip/midpoint/rotation:
|
||||
- limbs: origin=`bone.global_position`, tip=`bone.to_global(Vector2(bone.length,0))`.
|
||||
- torso: origin=`Torso.global_position`, tip=`Head.global_position`
|
||||
(length = that distance).
|
||||
- head: center=`Body/Head.global_position` (the circle center).
|
||||
* Build `RigidBody2D` (name `"Ragdoll_" + key`):
|
||||
- `mass`, `linear_damp`, `angular_damp` from table.
|
||||
- `gravity_scale = 1.0`, `lock_rotation = false`, `freeze = false`.
|
||||
- `collision_layer = 1`, `collision_mask = 1` (defaults; explicit for clarity).
|
||||
- `CollisionShape2D` child: `CapsuleShape2D(height=length, radius=r)` for
|
||||
capsules, or `CircleShape2D(radius=100)` for head.
|
||||
- Position body at midpoint, rotation = bone `global_rotation`
|
||||
(capsules); head rotation irrelevant (circle).
|
||||
- `_ragdoll_root.add_child(body)`; record in `_ragdoll_bodies[key]`.
|
||||
- For each joint entry (skip if either body missing):
|
||||
* `var pin := PinJoint2D.new()`, name `"RagdollPin_" + child_key`.
|
||||
* `pin.position = pin_point` (world). Add to `_ragdoll_root`.
|
||||
* `pin.node_a = pin.get_path_to(parent_body)`; `pin.node_b =
|
||||
pin.get_path_to(child_body)`.
|
||||
* Apply limit band + `softness`. (Set `node_a`/`node_b` **after** adding to
|
||||
the tree so `initial_angle` captures the rest pose — bodies are already
|
||||
positioned, so the reference is correct.)
|
||||
- Momentum handoff: `_ragdoll_bodies["torso"].linear_velocity =
|
||||
_cached_linear_velocity`; `.angular_velocity = _cached_angular_velocity`
|
||||
(set **after** `add_child`).
|
||||
3. `state = RigState.RAGDOLL`; `state_changed.emit(int(state))`.
|
||||
|
||||
### 6.3 `_track_momentum(delta)`
|
||||
|
||||
```gdscript
|
||||
if delta > 0.0:
|
||||
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
|
||||
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
```
|
||||
Initialize `_prev_*` in `_ready()`.
|
||||
|
||||
### 6.4 `_exit_ragdoll()`
|
||||
|
||||
1. `_destroy_ragdoll()`: if `_ragdoll_root != null && is_instance_valid`,
|
||||
`_ragdoll_root.queue_free()`; clear `_ragdoll_bodies`, `_ragdoll_root = null`.
|
||||
2. Re-enable kinematic rig: `_body_container.visible = true`;
|
||||
`_skeleton.modification_stack.enabled = true`; `_anim_player.stop()` (leave
|
||||
pose as-is; do not attempt to restore a specific frame — the skeleton keeps
|
||||
its last pose, which for the harness is the rest/IK pose).
|
||||
3. `state = RigState.ANIMATED`; `state_changed.emit(int(state))`.
|
||||
|
||||
### 6.5 `set_ragdoll(enabled)`
|
||||
|
||||
Idempotent guards: entering while already `RAGDOLL` (or exiting while `ANIMATED`)
|
||||
is a no-op. Null-guard `_skeleton`/`_body_container` before use (consistent with
|
||||
the file's `push_warning` style).
|
||||
|
||||
---
|
||||
|
||||
## 7. `physics_test_harness.gd` changes (exact)
|
||||
|
||||
1. Add member: `var _rig: StickmanRig = null`.
|
||||
2. In `_spawn_rig()`, change `as Node2D` → `as StickmanRig`, store `_rig = rig`.
|
||||
3. Add a `match` arm in `_handle_key`:
|
||||
```gdscript
|
||||
KEY_R:
|
||||
if _rig != null:
|
||||
_rig.toggle_ragdoll()
|
||||
if _rig.is_in_ragdoll():
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
```
|
||||
4. Add `_remove_rig_collision_proxy()`: find the direct child named
|
||||
`"RigCollisionProxy"` (iterate `get_children()` matching `name`), `queue_free()`
|
||||
if present. Make `_add_rig_collision_proxy()` idempotent: skip if a child named
|
||||
`"RigCollisionProxy"` already exists (prevents duplicates on rapid toggling).
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- **Toggle**: pressing **R** hides the stick figure, removes `RigCollisionProxy`,
|
||||
and spawns a physics ragdoll at the same world pose; pressing **R** again frees
|
||||
the ragdoll, restores the figure, and re-adds the proxy.
|
||||
- **Terrain**: ragdoll rests/slides/tumbles on flat ground, ramp, and stairs
|
||||
(collides with `TerrainBlock` bodies); props still bounce off it.
|
||||
- **Integrity**: connected limbs never separate/detach (pin joints hold under
|
||||
gravity); elbows/knees resist hyperextension and full 360° rotation at the
|
||||
spawn orientation (native world-frame limits — see caveat in §3.1).
|
||||
- **Fidelity**: collision capsules/circle line up with the hidden bones (no
|
||||
floating/offset shapes).
|
||||
- **Cleanup**: rapid R toggling leaves no orphaned nodes (proxy + ragdoll
|
||||
container are freed each cycle); no leaked `RigidBody2D`/`PinJoint2D`.
|
||||
- **No scene edits**: `master_rig.tscn` and `physics_test_harness.tscn` are
|
||||
unchanged; everything is code-driven.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification commands (for the tester phase)
|
||||
|
||||
Godot binaries live in `C:\Godot4\` (`Godot_v4.4-stable_win64_console.exe`,
|
||||
`Godot_v4.7.1-stable_win64_console.exe`). No shell tool is available to the
|
||||
Architect; the Tester runs these:
|
||||
|
||||
```bat
|
||||
:: 1. Whole-project parse/compile check (reports script errors, then quits)
|
||||
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --editor --path C:\Godot4\stickman --quit
|
||||
|
||||
:: 2. Headless runtime smoke test of the harness scene (runs N frames, then quits)
|
||||
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --path C:\Godot4\stickman res://scenes/physics_test_harness.tscn --quit-after 120
|
||||
```
|
||||
|
||||
- Command 1 catches GDScript syntax/type errors across all scripts.
|
||||
- Command 2 exercises `_ready()` + `_spawn_rig()` + `_add_rig_collision_proxy()`.
|
||||
Headless cannot send the **R** keypress, so the toggle itself must be verified
|
||||
**interactively** via F6 on `physics_test_harness.tscn` (project convention):
|
||||
load scene, press R, observe ragdoll fall; press R again; confirm figure
|
||||
restored and props still collide. Rapid-tap R to check for orphans/leaks
|
||||
(use the editor's remote scene tree).
|
||||
|
||||
---
|
||||
|
||||
## 10. Deferred / out of scope
|
||||
|
||||
- **Local-frame angular limits** (limits that follow the parent's rotation) —
|
||||
would require a custom `Joint2D` or per-frame correction; only pursue if QA
|
||||
deems the world-frame limits insufficient for tumbling realism.
|
||||
- **Disabling `RemoteTransform2D` drivers** while in RAGDOLL — skipped (hidden
|
||||
visuals, negligible cost).
|
||||
- **`AnimationTree`** — untouched placeholder (per existing convention).
|
||||
- **Ragdoll self-collision tuning** — v1 relies on `disable_collision=true` for
|
||||
joint-connected pairs + thin capsules; a dedicated collision layer for
|
||||
intra-ragdoll exclusions is a follow-up if jitter appears.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Architectural Plan: Dynamic `.prop` Loading & Sandbox Integration
|
||||
|
||||
This document outlines the architectural plan to transition `PropLibrary` from a static registry into a dynamic filesystem-backed library (matching `StickmanLibrary`) and integrate user-created `.prop` files into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture Overview
|
||||
|
||||
The system bridges custom prop files produced by the Prop Editor into the sandbox physics simulation through four primary layers:
|
||||
|
||||
1. **Library & File Scanning (`PropLibrary`):** Scans `res://props/` for `*.prop` files, deserializes them via `PropFile`, flattens multi-shape hierarchies into collision payloads, and merges them with built-in templates.
|
||||
|
||||
2. **Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`):** Computes cache keys using file modification timestamps (`mtime`), renders visual previews offscreen, and streams textures to the selector grid.
|
||||
|
||||
3. **UI Integration (`AssetSelector` & `SandboxStage`):** Presents custom props alongside built-in presets in a unified grid, offering folder scanning, ad-hoc file browsing, and palette live-refresh.
|
||||
|
||||
4. **World Spawning & Physics Lifecycle (`StageSpawner` & `PropBlock`):** Instantiates `RigidBody2D` nodes configured with custom collision geometry and material physical properties (mass, friction, bounce), tracking authored positions across sandbox mode transitions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Specifications
|
||||
|
||||
### A. Library Layer (`res://scripts/prop_library.gd`)
|
||||
|
||||
- **Dual-Source Registry:** Maintain built-in templates (Crate, Ball, Plank, Triangle) as default presets while scanning `res://props/*.prop` on demand.
|
||||
|
||||
- **Entry Model Schema:** Standardize entry dictionaries returned to the UI:
|
||||
- **Identifier (`id`):** Resource path (`res://props/chair.prop`) for custom files, or string key (`crate`) for templates.
|
||||
|
||||
- **Display Name (`name`):** Extracted from `.prop` metadata, with fallback to capitalized file basename.
|
||||
|
||||
- **Material Preset & Label:** Physical properties index (Wood, Metal, Rubber, Cardboard) and human-readable label.
|
||||
|
||||
- **Payload (`payload`):** Flattened shape and collision dictionary generated via `PropFile.flatten_to_payload()`.
|
||||
|
||||
- **Provenance Flags:** Boolean flag indicating whether the entry is custom and string path reference for scene serialization.
|
||||
|
||||
- **Error Handling:** Skip invalid or corrupted `.prop` files gracefully while outputting descriptive warnings.
|
||||
|
||||
### B. Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`)
|
||||
|
||||
- **Dynamic Keying Strategy:**
|
||||
- Built-in prop templates compute keys using internal version constants.
|
||||
|
||||
- Custom `.prop` files compute keys combining file basename and filesystem modification timestamp (`FileAccess.get_modified_time`).
|
||||
|
||||
- **Renderer Extension (`PropThumbnail`):** Extend offscreen rendering to support composite shapes and custom vector styling (fill color, outline width, polygons, circles) rather than single primitive shapes.
|
||||
|
||||
- **Stale Cache Cleanup:** Automatically purge superseded PNG thumbnails when custom `.prop` files are edited or deleted.
|
||||
|
||||
### C. World Spawning Pipeline (`StageSpawner`)
|
||||
|
||||
- **Payload Forwarding:** Pass the entry payload directly to `PropUtils.spawn_prop()`, which generates the corresponding `PropBlock` node with `CollisionPolygon2D` or `CollisionShape2D` nodes.
|
||||
|
||||
- **Provenance Tagging:** Store `spawn_id` and source path metadata on spawned instances to support scene serialization and reloading.
|
||||
|
||||
- **Authored Pose Capture:** Save placement positions to instance metadata (`_authored_position`, `_authored_rotation`) during **Edit** mode.
|
||||
|
||||
### D. UI & Sandbox Integration (`AssetSelector` & `SandboxStage`)
|
||||
|
||||
- **Grid Population:** Trigger `PropLibrary.scan()` when opening the prop selector, populating grid cells with thumbnails and display names.
|
||||
|
||||
- **Browse & Refresh Integration:** Connect the "Browse…" button to a file dialog for arbitrary `*.prop` selection, and the "Refresh" button to re-scan `res://props/`.
|
||||
|
||||
- **Lazy Thumbnail Processing:** Process offscreen thumbnail generation non-blockingly during stage frame processing to keep UI interactions responsive.
|
||||
|
||||
---
|
||||
|
||||
## 3. Physics & Sandbox Lifecycle Integration
|
||||
|
||||
Custom `.prop` instances must follow the existing 3-mode sandbox state machine:
|
||||
|
||||
| Stage Mode | Prop Physics State | Interaction Capabilities | Reset Behavior |
|
||||
| ---------- | -------------------------------------------------- | ------------------------ | -------------- |
|
||||
| **Edit** | RigidBody frozen (`freeze = true`, kinematic mode) |
|
||||
|
||||
| Move, rotate, scale, or delete via selection gizmos
|
||||
|
||||
| N/A (Authored state recorded on movement)
|
||||
|
||||
|
|
||||
| **Direct** | RigidBody frozen (`freeze = true`, kinematic mode)
|
||||
|
||||
| Gizmos active for rule/trigger assignment; spawner palette hidden
|
||||
|
||||
| N/A
|
||||
|
||||
|
|
||||
| **Play** | RigidBody unfrozen (`freeze = false`)
|
||||
|
||||
| Full physical simulation, collisions with terrain/stickmen/props
|
||||
|
||||
| Mode exit restores initial authored position/rotation and zeroes linear/angular velocity
|
||||
|
||||
|
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
```
|
||||
[ Phase 1: Library & Data Layer ]
|
||||
└─ Extend PropLibrary (res://props/*.prop scanning + PropFile parsing)
|
||||
└─ Define unified Prop Entry Dictionary schema
|
||||
|
||||
[ Phase 2: Thumbnail Pipeline ]
|
||||
└─ Update PropThumbnail to render multi-shape custom payloads
|
||||
└─ Implement modification timestamp cache invalidation in ThumbnailCache
|
||||
|
||||
[ Phase 3: Spawning & UI Integration ]
|
||||
└─ Update StageSpawner to tag custom prop provenance
|
||||
└─ Wire AssetSelector grid refresh and file dialog browsing
|
||||
|
||||
[ Phase 4: Stage Lifecycle & Verification ]
|
||||
└─ Verify Edit -> Play -> Edit position resets for custom props
|
||||
└─ Validate scene saving/loading persistence
|
||||
|
||||
```
|
||||
|
||||
### Milestone Checklist for Architect Agent
|
||||
|
||||
1. **Directory Scanning:** `PropLibrary` reads `res://props/` and extracts payloads via `PropFile`.
|
||||
|
||||
2. **Visual Previews:** `PropThumbnail` renders multi-polygon custom vector props to textures.
|
||||
|
||||
3. **Palette Display:** `AssetSelector` displays merged built-in templates and `.prop` files.
|
||||
|
||||
4. **Physics Interaction:** Custom props unfreeze during **Play** mode, participating in physics simulation and collision events.
|
||||
|
||||
5. **State Reset:** Props return to their original authored transforms when switching from **Play** back to **Edit** mode.
|
||||
@@ -0,0 +1,118 @@
|
||||
Here is the revised architectural specification incorporating your exact adjustments for your architect agent:
|
||||
|
||||
---
|
||||
|
||||
# Architectural Spec: Dual-Layer Prop Scaling System (Revised)
|
||||
|
||||
Status: **implemented and user verified** (2026-09-22).
|
||||
|
||||
This specification outlines the architecture for supporting prop scaling across two layers: permanent template scaling in the **Prop Editor** and contextual instance scaling in the **Sandbox Stage**.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture & Division of Responsibilities
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PROP EDITOR (Phase 9) │
|
||||
│ • Permanent template geometry mutation │
|
||||
│ • Vertex & radius coordinate transformations │
|
||||
│ • Anchor offset auto-scaling │
|
||||
│ • Stickman guide silhouette (Default: OFF) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│ Saves .prop file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SANDBOX STAGE (Phase 10) │
|
||||
│ • Contextual instance scaling via corner gizmos only │
|
||||
│ • Locked aspect ratio (uniform scaling) │
|
||||
│ • Physics mass decoupled from visual scale │
|
||||
└────────────────────────────┴────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
| Layer | Target Entity | Operation Type | Scaling Behavior | Data Persistence |
|
||||
| ----------------- | ----------------------------- | --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| **Prop Editor** | Template Asset (`.prop`) | Permanent geometry mutation | Bakes scale into raw vector vertices, radii, and anchor offsets. | Persists into `.prop` file. |
|
||||
| **Sandbox Stage** | Object Instance (`PropBlock`) | Node transform scaling | Pure gizmo scaling with **locked aspect ratio**. | Persists into `.scene` file (`scale: { x, y }`). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Prop Editor Implementation (Template Sizing)
|
||||
|
||||
### A. Document Geometry Scale Tool
|
||||
|
||||
- **Scope:** Modifies raw geometry arrays (`points` for polygons/lines, `radius` for circles) in the shape stack.
|
||||
|
||||
- **Anchor Recalculation:** All registered anchor offset positions (`x`, `y`) automatically multiply by the scale factor so grip, pivot, and emitter points maintain exact relative alignment.
|
||||
|
||||
- **Normalize Size Action:** Adds a canvas action (📏 **Normalize Size**) alongside 🎯 **Center Prop** that computes the bounding box union of all shapes and uniformly scales the document to fit within a standard $96 \times 96\text{ px}$ canvas box.
|
||||
|
||||
### B. Stickman Visual Reference Silhouette
|
||||
|
||||
- **Overlay Guide:** Renders a semi-transparent 1:1 Stickman figure silhouette on the canvas background for scale context against full character rigs ($\approx 336 \times 940\text{ px}$).
|
||||
|
||||
- **Default State:** Toggleable via the UI / canvas controls and **defaults to OFF**.
|
||||
|
||||
- **View Aid Only:** Non-selectable, non-interactive visual guide that is excluded from `.prop` file serialization.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sandbox Stage Implementation (Instance Sizing)
|
||||
|
||||
### A. Gizmo-Driven Scaling (No UI SpinBoxes / No Presets)
|
||||
|
||||
- **Corner Scale Handles:** Extends `StageGizmos` in `EDIT` mode to render corner cross handles on selected prop bounding boxes.
|
||||
|
||||
- **Aspect Ratio Locking:** Dragging any corner handle scales the object while strictly **locking the aspect ratio** (uniform scaling).
|
||||
|
||||
- **Streamlined UI:** No numeric scale SpinBoxes or preset buttons (`S`/`M`/`L`) are added to the stage UI; all scaling is performed intuitively directly on the canvas via gizmo manipulation.
|
||||
|
||||
### B. Mode Lifecycle & Physics Rules
|
||||
|
||||
- **Decoupled Mass Contract:** Scaling a prop adjusts node visual geometry (`Polygon2D`, `Line2D`) and collision bounds (`CollisionPolygon2D`, `CollisionShape2D`). RigidBody `mass` remains independent, governed strictly by the assigned Material Preset (Wood, Metal, Rubber, Cardboard).
|
||||
|
||||
- **Lifecycle State Handling (`PLAY` $\rightarrow$ `EDIT`):** Transitioning from `PLAY` back to `EDIT` mode restores the authored `position` and `rotation` and zeroes linear/angular velocity, but **does not reset or alter the node scale**. The authored scale set during editing is retained as-is.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
1. **Phase A (Prop Editor Tooling):**
|
||||
|
||||
- Implement document-wide geometry and anchor scaling logic in `PropEditor`.
|
||||
|
||||
- Add the toggleable Stickman Reference Silhouette (**default OFF**) and "Normalize Size" canvas button.
|
||||
|
||||
2. **Phase B (Sandbox Gizmo Scaling):**
|
||||
|
||||
- Extend `StageGizmos` with corner scale handles that lock aspect ratio during drag operations.
|
||||
|
||||
- Ensure `StageSelection` AABB bounding boxes recalculate accurately to account for scaled instances.
|
||||
|
||||
3. **Phase C (Persistence & Verification):**
|
||||
|
||||
- Verify scene save/load serialization of scaled `PropBlock` instances.
|
||||
|
||||
- Confirm that `EDIT` $\rightarrow$ `PLAY` $\rightarrow$ `EDIT` mode switches restore authored position/rotation while keeping object scale intact.
|
||||
|
||||
## 5. Verified final Prop Editor controls
|
||||
|
||||
The Arrange mode top bar presents these controls in order:
|
||||
|
||||
1. **Transform Whole Prop**
|
||||
2. **Normalize Size**
|
||||
3. **Center Prop**
|
||||
4. **Show Stickman Guide**
|
||||
|
||||
The numeric scale textbox and Apply Scale button were removed. Transform Whole
|
||||
Prop previews and commits whole-document move, rotation, and uniform resize
|
||||
gestures around the canonical origin. Grid Snap rounds move deltas, Ctrl snaps
|
||||
rotation to 15-degree increments, editable anchors follow the geometry, and Esc
|
||||
cancels an active preview. Normalize Size fits the shape-point bounds to a 96 px
|
||||
maximum dimension. Transform Whole Prop and Normalize Size are hidden in Reshape
|
||||
and Attach; Center Prop and Show Stickman Guide remain available in all modes.
|
||||
|
||||
Sandbox instance scaling remains a separate prop-only uniform corner-gizmo
|
||||
operation. It retains the authored scene scale, preset-governed mass, and
|
||||
PLAY-to-EDIT position/rotation restoration behavior.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Architecture Specification: Interactive Prop System & Event Engine
|
||||
|
||||
This specification details the technical architecture for bringing interactive prop capabilities—including auto-approach, IK socket binding, parabolic throwing, ragdoll disarming, and "When..." rule triggers—into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview & Layer Interactions
|
||||
|
||||
Prop interactions bridge five core subsystems across the runtime stack:
|
||||
|
||||
```
|
||||
[ Director UI / Target Selector ]
|
||||
│
|
||||
▼
|
||||
[ StickmanActionRunner ] ──(Auto-Approach)──► [ StickmanLocomotion ]
|
||||
│
|
||||
▼
|
||||
[ StickmanRig IK ] ──(Socket Binding)──► [ PropBlock Physics ]
|
||||
│
|
||||
▼
|
||||
[ StageEventRegistry ] ──("When..." Rules)──► [ Reactive Actions ]
|
||||
|
||||
```
|
||||
|
||||
1. **Director UI & Target Selection:** Captures spatial throw targets and enqueues prop interaction commands.
|
||||
|
||||
2. **Action Runner & Locomotion:** Evaluates spatial reach; automatically inserts `walk_to` sub-goals when props are out of arm's reach.
|
||||
|
||||
3. **IK & Bone Socketing:** Extends arm IK targets toward prop `grip` anchors and manages `RemoteTransform2D` binding.
|
||||
|
||||
4. **Physics Lifecycle:** Unfreezes/freezes `PropBlock` bodies and inherits velocity during release, throw, or ragdoll disarm transitions.
|
||||
|
||||
5. **Event Registry:** Emits and evaluates edge-triggered prop rules within the stage's "When → Then" engine.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Action Queue & Auto-Approach (`walk_to`)
|
||||
|
||||
To ensure smooth director interaction, the action queue automatically resolves spatial gaps between the stickman and target props.
|
||||
|
||||
### Action Payload Schemas
|
||||
|
||||
- **`pick_up_prop`**: `{ "type": "pick_up_prop", "target_prop_id": String, "hand": "auto" | "left" | "right" }`
|
||||
|
||||
- **`drop_prop`**: `{ "type": "drop_prop" }`
|
||||
|
||||
- **`throw_prop`**: `{ "type": "throw_prop", "target_position": Vector2, "force": float }`
|
||||
|
||||
### Smart Approach Execution
|
||||
|
||||
When `StickmanActionRunner` receives a `pick_up_prop` payload:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ pick_up_prop Action Start │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ Distance = |rig.position - prop.position| │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
Is Distance > Reach Radius (e.g. 40px)?
|
||||
┌───────────────────────┴───────────────────────┐
|
||||
│ │
|
||||
[ YES ] [ NO ]
|
||||
│ │
|
||||
┌─────────────────▼─────────────────┐ ┌─────────────────▼─────────────────┐
|
||||
│ 1. Pause pick_up_prop sequence │ │ 1. Skip locomotion step │
|
||||
│ 2. Prepend walk_to(prop_offset) │ │ 2. Trigger arm IK reach animation │
|
||||
│ 3. Resume pickup on arrival │ └───────────────────────────────────┘
|
||||
└───────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Distance Check:** Evaluates global horizontal/spatial distance between the stickman root and prop `origin` pivot.
|
||||
|
||||
2. **Sub-Goal Injection:** If outside reach radius, `StickmanActionRunner` pauses the pickup sequence and internally issues a `walk_to` command to a stand point adjacent to the prop.
|
||||
|
||||
3. **Sequence Resume:** Upon receiving `StickmanLocomotion.arrived_at_waypoint`, the runner resumes the pickup sequence and triggers arm IK extension.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pickup, Drop, and Throw Pipelines
|
||||
|
||||
### A. Prop Pickup Sequence & IK Socketing
|
||||
|
||||
1. **IK Reach Phase (150 ms):** `TwoBoneIK` targets for the selected arm interpolate toward the world position of the prop's `grip` anchor.
|
||||
|
||||
**Edit:** Torso position should also move up/down based on grip anchor. The stickman should look like it is bending or extending legs to make pickup look more realistic.
|
||||
|
||||
2. **Socket Attachment:**
|
||||
|
||||
- Set `PropBlock.freeze = true` (kinematic mode) to suspend environmental physics forces.
|
||||
|
||||
- Instantiate or activate a `RemoteTransform2D` node attached to the lower arm / wrist joint, targeting the `PropBlock`.
|
||||
|
||||
- Apply anchor local offsets (`x, y, rotation_deg, z_index_offset`) to align the prop grip directly within the hand geometry.
|
||||
|
||||
3. **Holding Stance Latch:** Lock upper limb IK targets into a carried stance during subsequent walk cycles.
|
||||
|
||||
### B. Prop Drop Sequence
|
||||
|
||||
1. **Unbinding:** Clear `RemoteTransform2D` target and reparent `PropBlock` back to the stage world root.
|
||||
|
||||
2. **Physics Activation:** Set `PropBlock.freeze = false` to re-enable rigid body simulation, gravity, and collision shapes.
|
||||
|
||||
3. **Inertia Inheritance:** Set `PropBlock.linear_velocity` equal to the stickman's current movement velocity (`_last_grounded_walk_velocity_x`) so dropped items retain forward momentum.
|
||||
|
||||
4. **Arm Recovery:** Blend wrist IK targets smoothly back to standard locomotion/idle rest positions.
|
||||
|
||||
### C. Prop Throw Sequence & Trajectory Selection
|
||||
|
||||
#### Director Target UI Flow
|
||||
|
||||
- **Targeting Mode:** Selecting "Throw Prop" in Director/Play mode activates an aim reticle cursor and dynamic arc preview.
|
||||
|
||||
**EDIT:** When the user clicks the stickman in DIRECT mode, 3 new additions should appear in the action context menu
|
||||
|
||||
1. Pick up
|
||||
2. Drop
|
||||
3. Throw
|
||||
[Give these proper emoji]
|
||||
|
||||
- If Drop is added to the queue, and the action is run when the stickman holds nothing, the action is skipped (marked as done)
|
||||
- If Throw is added to the queue, and the action is run when the stickman holds nothing, the action is skipped (marked as done)
|
||||
- These 3 actions should also be selectable when handling a 'When' event
|
||||
|
||||
- **Trajectory Math:** Given hand release point $P_0$, target landing point $P_t$, base speed $v_0$, and gravity $g$:
|
||||
|
||||
$$\theta = \arctan\left(\frac{v_0^2 \pm \sqrt{v_0^4 - g(g \cdot x^2 + 2 y \cdot v_0^2)}}{g \cdot x}\right)$$
|
||||
|
||||
Calculates exact initial launch velocity components $V_{\text{launch}} = (v_0 \cos\theta, v_0 \sin\theta)$.
|
||||
|
||||
#### Keyframe Throw Animation Arc
|
||||
|
||||
```
|
||||
[ Windup: 100ms ] ──► [ Release Frame: 150ms ] ──► [ Follow-Through: 50ms ]
|
||||
- Torso tilts -15° - Arm sweeps to angle - Arm extends along arc
|
||||
- Arm pulls backward - Prop unfreezes + impulse - Blends back to idle
|
||||
|
||||
```
|
||||
|
||||
1. **Windup (100 ms):** Torso rotates backward opposite the launch vector while the arm pulls back.
|
||||
2. **Release Frame (150 ms):**
|
||||
|
||||
- Arm sweeps rapidly along the calculated launch angle $\theta$.
|
||||
**EDIT:** The arm should go overhead - not an underhand throw.
|
||||
- Unbind `RemoteTransform2D` and reparent prop to world root.
|
||||
|
||||
- Set `PropBlock.freeze = false` and execute `apply_central_impulse(V_launch)`.
|
||||
|
||||
- Apply rotational torque proportional to the distance between hand grip and prop center-of-mass.
|
||||
|
||||
3. **Follow-Through (50 ms):** Arm extends toward launch vector, then smoothly dampens back to default idle/walking pose.
|
||||
|
||||
---
|
||||
|
||||
## 4. Physics & State Machine Lifecycle (Ragdoll Disarm Handoff)
|
||||
|
||||
When `StickmanRig.set_ragdoll(true)` is executed (via impact, command, or keypress), any held prop must be immediately disarmed before kinematic nodes are hidden and ragdoll bodies are built.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ StickmanRig.set_ragdoll(true) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 1. Pre-Ragdoll Intercept │
|
||||
│ Query if held_prop != null │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 2. Execute Disarm Handoff │
|
||||
│ - Clear RemoteTransform2D binding │
|
||||
│ - Reparent PropBlock to world root │
|
||||
│ - Set prop.freeze = false │
|
||||
│ - Apply cached momentum (_track_momentum) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 3. Build Physics Ragdoll │
|
||||
│ Hide animated body, build RigidBody2D │
|
||||
│ network at current solved bone positions │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Pre-Ragdoll Intercept:** Prior to clearing collision proxies or hiding body meshes, check for an active `held_prop` binding.
|
||||
|
||||
2. **Physics Unbind:**
|
||||
|
||||
- Detach the `RemoteTransform2D` socket.
|
||||
|
||||
- Reparent prop to the world root and set `PropBlock.freeze = false`.
|
||||
|
||||
- Transfer cached rig momentum (`_track_momentum()`) directly to `PropBlock.linear_velocity` and `angular_velocity` so the prop tumbles naturally alongside the falling figure.
|
||||
|
||||
3. **Ragdoll Construction:** Proceed with standard `_build_ragdoll()` processing at current solved bone transforms.
|
||||
|
||||
---
|
||||
|
||||
## 5. Event System Integration ("When..." Triggers)
|
||||
|
||||
Three new edge-triggered prop event types are added to `StageEventRegistry` (`sandbox_stage.gd`) to support the director's "When → Then" mental model.
|
||||
|
||||
### Prop Trigger Specifications
|
||||
|
||||
| Trigger Type | Source Object | Target Object | Firing Condition |
|
||||
| ---------------- | --------------- | --------------- | ------------------------------------------------------------------------------- |
|
||||
| `prop_picked_up` | Stickman / Prop | Prop / Stickman | Emitted as soon as IK arm reach completes and `RemoteTransform2D` socket locks. |
|
||||
|
||||
|
|
||||
| `prop_dropped` | Stickman / Prop | Prop / Stickman | Emitted when a stickman drops a prop manually or via ragdoll disarm.
|
||||
|
|
||||
| `prop_thrown` | Stickman / Prop | Prop / Stickman | Emitted at the exact release frame of a throw animation arc.
|
||||
|
|
||||
|
||||
### Signal Routing & Evaluation
|
||||
|
||||
1. `StickmanActionRunner` emits `prop_action_executed(action_type, stickman, prop)` upon completing or releasing an anchor attachment.
|
||||
|
||||
2. `StageEventRegistry` matches active stage rules where `rule.trigger.type` equals `prop_picked_up`, `prop_dropped`, or `prop_thrown`.
|
||||
|
||||
3. Matching rules trigger reactive actions on target stickmen or props (e.g., _When Stickman A picks up Sword X $\rightarrow$ Stickman B ragdolls_).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Roadmap for Architect Agent
|
||||
|
||||
```
|
||||
[ Phase 1: Action Queue & Locomotion ]
|
||||
├─ Add pick_up_prop, drop_prop, and throw_prop to StickmanActionRunner
|
||||
└─ Implement distance evaluation & auto-enqueueing of walk_to sub-goals
|
||||
|
||||
[ Phase 2: Socket Attachment & IK Handshake ]
|
||||
├─ Implement RemoteTransform2D socketing to wrist joint with grip offsets
|
||||
└─ Implement holding stance arm pose blending
|
||||
|
||||
[ Phase 3: Throw Mechanics & Trajectory UI ]
|
||||
├─ Add trajectory solver & reticle target selector to Director UI
|
||||
└─ Build throw animation timeline (windup -> release impulse -> follow-through)
|
||||
|
||||
[ Phase 4: State Machine Integration & Disarm ]
|
||||
├─ Intercept held props during set_ragdoll(true) transitions
|
||||
└─ Transfer cached linear/angular momentum to disarmed prop physics body
|
||||
|
||||
[ Phase 5: Event System Expansion ]
|
||||
├─ Register prop_picked_up, prop_dropped, prop_thrown triggers in StageEventRegistry
|
||||
└─ Wire action signals to trigger "When -> Then" rule evaluations
|
||||
|
||||
```
|
||||
|
||||
## 7. Design Questions / Rules
|
||||
|
||||
1. When creating a Throw action, the user must create an arc defining the throw. However, when the scene is playing, the stickman might be in a whole different position for when the throw must happen. Should we define a new arc and make a best effort to throw the prop towards the (x,y) location selected or should we use the same arc, but translate the throw endpoint relevant to the stickman's current position.
|
||||
2. The stickman profile direction should change to whichever direction the prop will be thrown.
|
||||
|
||||
### Architectural Acceptance Criteria Checklist
|
||||
|
||||
- [ ] **Auto-Approach:** Requesting pickup of a distant prop automatically causes the stickman to walk to the prop before reaching out.
|
||||
|
||||
- [ ] **Grip Alignment:** Hand attaches precisely to the prop's authored `grip` anchor, respecting local position, rotation, and Z-index offsets.
|
||||
|
||||
- [ ] **Trajectory Accuracy:** Thrown props follow the calculated parabolic arc toward the director's targeted ground point.
|
||||
|
||||
- [ ] **Ragdoll Disarm:** Entering ragdoll mode immediately detaches held props and applies rig momentum so items tumble realistically.
|
||||
|
||||
- [ ] **Event Rule Engine:** Rules configured with `When prop_picked_up`, `When prop_dropped`, or `When prop_thrown` execute downstream actions reliably.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,157 @@
|
||||
## Summary of Speech & Speech Bubble Features
|
||||
|
||||
The goal is to expand the existing single-bubble `SpeechBubble` system into a comic-book movie engine designed for young directors.
|
||||
|
||||
### 1. Data Contract & Theme Integration
|
||||
|
||||
- **Expanded Action Payload**: Extend the `speak` action payload to support explicit visual parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "speak",
|
||||
"text": "Look out for the [shake]MONSTER![/shake]",
|
||||
"duration": 2.5,
|
||||
"stop_current": false,
|
||||
"visual": {
|
||||
"bubble_style": "yell",
|
||||
"font_preset": "comic",
|
||||
"size_preset": "huge",
|
||||
"color": "#FF3333",
|
||||
"anim_mode": "typewriter"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Theme Fallbacks**: Route missing keys through `sandbox_theme.json` defaults $\rightarrow$ Global Theme $\rightarrow$ Godot `ThemeDB.fallback_font`.
|
||||
|
||||
### 2. Rich Text & Visual Styles
|
||||
|
||||
- **`RichTextLabel` Migration**: Shift text rendering from raw `draw_string()` calls to an internal `RichTextLabel` child node to enable full BBCode support (`[wave]`, `[shake]`, bolding, inline colors).
|
||||
|
||||
- **Shape Presets (`BubbleStyle`)**:
|
||||
- **Standard**: Classic rounded rectangle + downward tail triangle.
|
||||
|
||||
- **Yell / Shout**: Multi-pointed starburst polygon.
|
||||
- **Thought**: Arc/circle cluster cloud boundary + descending bubble chain dots.
|
||||
- **Whisper**: Semi-transparent fill with dashed border lines (`draw_dashed_line()`).
|
||||
- **Comic SFX**: Borderless, high-contrast banner box for sound effect popups ("BOOM!", "POW!").
|
||||
|
||||
### 3. Dynamic Text & Bubble Animations
|
||||
|
||||
- **Typewriter & Word Reveals**: Animate `visible_characters` via Godot `Tween` for letter-by-letter or word-by-word reveals.
|
||||
- **Elastic Entrances & Transitions**: Scale bubble from $0 \rightarrow 1$ using `TRANS_ELASTIC` on pop-in; morph bounding sizes with smooth squish-and-stretch tweens when swapping text lines back-to-back.
|
||||
- **Word-Specific Shake & Wave**: Support inline animated text effects wrapped in BBCode tags.
|
||||
|
||||
### 4. Advanced Layout & Stagger Stacking
|
||||
|
||||
- **Comic Stagger Stacking**: Allow multiple speech lines to chain into vertical bubble stacks. The bottom bubble anchors to the stickman's head via the main tail, while higher bubbles stack upward connected by bridge geometry (`draw_colored_polygon()`).
|
||||
|
||||
- **Mirroring & Viewport Clamping**: Keep X-scale counter-mirroring active (`_sync_speech_bubble_mirror`). Clamp bubble bounds inside `get_viewport_rect()` so bubbles flip tail anchors or offset position to remain on-screen.
|
||||
|
||||
### 5. Director UI Upgrades
|
||||
|
||||
- **Kid-Friendly Controls**: Color swatch buttons, size toggles (**Small**, **Medium**, **HUGE**), font style pickers, and an emoji grid picker inside the `Speak` parameter dialog.
|
||||
|
||||
- **Text Highlight Formatting Bar**: Selecting text in the dialogue input shows quick-action buttons (🫨 **Shake**, 🌊 **Wave**, 🔥 **Bounce**) that automatically inject BBCode tags.
|
||||
- **Auto-Duration Calculator**: Default `duration` automatically calculates based on word count ($1.0\text{s base} + 0.2\text{s/word}$).
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Plan
|
||||
|
||||
### Phase 1: Engine Migration & Data Foundation
|
||||
|
||||
**Goal:** Upgrade `SpeechBubble` to use `RichTextLabel` and expand data contracts without breaking existing scene functionality.
|
||||
|
||||
1. **Update Action Data Pipeline**:
|
||||
|
||||
- Update `ActionRegistry` and `StageSceneIO` to preserve the optional `visual` dictionary payload on `speak` actions.
|
||||
|
||||
- Ensure `StickmanRig.speak()` parses both flat strings and full visual dictionaries.
|
||||
|
||||
2. **Refactor `SpeechBubble` Node Structure (`scripts/stickman_speech_bubble.gd`)**:
|
||||
|
||||
- Add a child `RichTextLabel` node to `SpeechBubble`.
|
||||
|
||||
- Set `RichTextLabel.bbcode_enabled = true` and configure autowrap.
|
||||
- Modify `_draw()`: Calculate background rectangle bounds dynamically using `RichTextLabel.get_content_height()` and text size metrics, then draw the background polygon/rect and tail under the text.
|
||||
|
||||
### Phase 2: Animated Reveals & Line Transitions
|
||||
|
||||
**Goal:** Implement typewriter effects, word reveals, and smooth line-swapping transitions.
|
||||
|
||||
1. **Typewriter & Word Reveal Engine**:
|
||||
|
||||
- Add `_play_typewriter(text: String, duration: float, mode: String)` to `SpeechBubble`.
|
||||
- Use a Godot `Tween` targeting `RichTextLabel.visible_characters` from `0` to total character length over the target duration.
|
||||
- Implement word-by-word step incrementing by parsing space/punctuation indices in the input string.
|
||||
|
||||
2. **Line Swap & Elastic Transitions**:
|
||||
|
||||
- Update `show_text()`: If a bubble is already visible when a new `speak` command arrives:
|
||||
|
||||
- Scale container down ($1.0 \rightarrow 0.8$) via `EASE_IN` tween.
|
||||
- Swap payload and recalculate bounds.
|
||||
- Scale container back out ($0.8 \rightarrow 1.0$) using `TRANS_ELASTIC` / `EASE_OUT`.
|
||||
|
||||
- Add squish-and-stretch size morphing when text length changes between consecutive lines.
|
||||
|
||||
### Phase 3: Polygon Shape Styles & Comic Geometry
|
||||
|
||||
**Goal:** Generate dynamic background shapes for distinct bubble styles.
|
||||
|
||||
1. **Shape Draw Functions**:
|
||||
|
||||
- **`_draw_standard_bubble(rect)`**: Existing rounded rect + downward tail triangle.
|
||||
|
||||
- **`_draw_yell_bubble(rect)`**: Generate an outer jagged polygon by iterating around `rect` perimeter and alternating vertex radii with random/sine noise offsets.
|
||||
- **`_draw_thought_bubble(rect)`**: Draw overlapping circles along the bounding box edges using `draw_circle()`, followed by 2–3 smaller descending circles pointing to origin.
|
||||
- **`_draw_whisper_bubble(rect)`**: Draw semi-transparent background fill; outline using `draw_dashed_line()` around the perimeter.
|
||||
|
||||
2. **Viewport Boundary Protection**:
|
||||
|
||||
- Calculate global screen position of bubble rect in `_process()`.
|
||||
- If top or side edges exit `get_viewport_rect()`, offset the bubble box downward/inward and flip tail geometry anchor accordingly.
|
||||
|
||||
### Phase 4: Staggered Multi-Bubble Stacking
|
||||
|
||||
**Goal:** Support chained comic bubbles stacking upward from a single character.
|
||||
|
||||
1. **Stack Container Logic**:
|
||||
|
||||
- Extend `SpeechBubble` to manage an array of child `BubbleItem` records.
|
||||
- When a multi-line or stacked speech payload is received, instantiate child bubble slots.
|
||||
|
||||
2. **Stack Geometry & Bridge Rendering**:
|
||||
|
||||
- Anchor the bottom-most bubble to `SPEECH_BUBBLE_OFFSET`.
|
||||
|
||||
- Position preceding bubbles vertically above it with vertical spacing gaps.
|
||||
- In `_draw()`, calculate bridge vertices between `Bubble N` bottom-center and `Bubble N+1` top-center, drawing connecting polygons using `draw_colored_polygon()`.
|
||||
- Restrict the downward head tail exclusively to the bottom bubble in the stack.
|
||||
|
||||
### Phase 5: Director UI & Formatting Controls
|
||||
|
||||
**Goal:** Expose all visual styling tools to kids in the editor UI.
|
||||
|
||||
1. **Integrate Theme Controls into `Speak` Parameter Dialog**:
|
||||
|
||||
- Add `ColorSwatchGrid` popover hooked into theme colors.
|
||||
|
||||
- Add `SizeToggle` segmented control (**Small**, **Medium**, **HUGE**) mapping to font scale metrics.
|
||||
|
||||
- Add `StylePicker` icon buttons (**Standard**, **Yell**, **Thought**, **Whisper**).
|
||||
- Embed `EmojiPicker` grid widget.
|
||||
|
||||
2. **Implement Selection Highlight Formatting Bar**:
|
||||
|
||||
- Attach a floating mini-toolbar above the dialogue `LineEdit` / `TextEdit` field in the `Speak` dialog.
|
||||
- Detect text selection (`get_selected_text()`). When text is highlighted, show mini-buttons:
|
||||
- 🫨 **Shake** $\rightarrow$ wraps selection in `[shake rate=20.0 level=10.0]...[/shake]`
|
||||
- 🌊 **Wave** $\rightarrow$ wraps selection in `[wave amp=50.0 freq=5.0]...[/wave]`
|
||||
- 🔥 **Bounce** $\rightarrow$ wraps selection in `[bounce]...[/bounce]`
|
||||
|
||||
3. **Auto-Duration & Stagger Helpers**:
|
||||
|
||||
- Add auto-duration calculation checkbox/toggle in dialog.
|
||||
- Add **"+ Add Stacked Bubble"** button in the speech dialog to split longer dialogue into chained stack items easily.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Phase 3a: Core Director Functionality
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Phase 3a enables directors to click a stickman, choose actions from a popup menu, and watch those actions execute in sequence when Play is pressed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Milestone 1: Navigation Foundation
|
||||
|
||||
**Goal:** The stickman can walk to a target position on the stage.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `NavigationAgent2D` added as a child of `StickmanRig`
|
||||
- `walk_to(target: Vector2)` method
|
||||
- `is_walking()` method
|
||||
- `arrived` signal
|
||||
- `walk_speed` export variable (default 300)
|
||||
|
||||
**Test:**
|
||||
|
||||
1. Place a stickman on the stage.
|
||||
2. Call `stickman.walk_to(Vector2(200, -300))`.
|
||||
3. Stickman walks to the target.
|
||||
|
||||
---
|
||||
|
||||
## 3. Milestone 2: Action Queue Data Model
|
||||
|
||||
**Goal:** Store and manage a list of actions per stickman.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `action_queue: Array[Dictionary]` stored in `StickmanRig`
|
||||
- `queue_action(action: Dictionary)`
|
||||
- `clear_queue()`
|
||||
- `get_queue()`
|
||||
- `remove_action(index: int)`
|
||||
- `insert_action(index: int, action: Dictionary)`
|
||||
- `queue_size()`
|
||||
|
||||
**Action Types:**
|
||||
|
||||
```gdscript
|
||||
{ "type": "walk_to", "target": Vector2, "speed": 300.0 }
|
||||
{ "type": "speak", "text": "", "duration": 2.0 }
|
||||
{ "type": "wait", "duration": 1.0 }
|
||||
{ "type": "ragdoll" }
|
||||
{ "type": "recover" }
|
||||
```
|
||||
|
||||
**Test:**
|
||||
|
||||
Manually create a queue: Walk → Wait → Speak.
|
||||
|
||||
stickman.get_queue() returns the list.
|
||||
|
||||
## 4. Milestone 3: Director Tool UI
|
||||
|
||||
**Goal:** Click a stickman → popup menu → choose an action.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- "Direct" tool button in the SandboxStage palette
|
||||
- Click detection on stickmen in Edit mode
|
||||
- Action popup menu (Walk To, Speak, Wait, Ragdoll, Recover)
|
||||
- "Walk To" → click stage → waypoint appears
|
||||
- "Speak" → text input dialog
|
||||
- "Wait" → duration input dialog
|
||||
|
||||
**Popup Options:**
|
||||
|
||||
- 🚶 Walk To (enter target mode)
|
||||
- 💬 Speak (open text dialog)
|
||||
- ⏳ Wait (open duration dialog)
|
||||
- 💥 Ragdoll (add immediately)
|
||||
- 🔄 Recover (add immediately)
|
||||
|
||||
**Test:**
|
||||
|
||||
- Click "Direct" tool.
|
||||
- Click a stickman → popup appears.
|
||||
- Click "Walk To" → click stage → waypoint appears.
|
||||
- Action is appended to the queue.
|
||||
|
||||
## 5. Milestone 4: Waypoint Visual System
|
||||
|
||||
**Goal:** Show the director's script visually on the stage in Edit mode.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- Waypoint markers (colored dots at walk targets)
|
||||
- Dotted lines connecting waypoints in order
|
||||
- Action icons (speech bubble, clock, ragdoll)
|
||||
- Order numbers (1, 2, 3…)
|
||||
- Hide on Play, Show on Edit
|
||||
|
||||
**Visual Design:**
|
||||
|
||||
- Waypoint: Blue dot with white outline
|
||||
|
||||
- Dotted line: Dashed white line (alpha 0.6)
|
||||
|
||||
- Speech icon: Small speech bubble
|
||||
|
||||
- Wait icon: Small clock symbol
|
||||
|
||||
- Ragdoll icon: Small "X" symbol
|
||||
|
||||
**Test:**
|
||||
|
||||
- Direct: Walk → Wait → Speak → Walk.
|
||||
- Switch to Edit mode.
|
||||
- Waypoints, dotted lines, icons, and numbers appear in order.
|
||||
|
||||
## 6. Milestone 5: Action Runner
|
||||
|
||||
**Goal:** Execute the action queue in sequence when Play is pressed.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `start_queue()`
|
||||
- `stop_queue()`
|
||||
- `is_queue_running()`
|
||||
- `action_started signal`
|
||||
- `action_finished signal`
|
||||
- `queue_finished signal`
|
||||
|
||||
_State Machine:_
|
||||
|
||||
```
|
||||
IDLE → start_queue() → EXECUTING → action completes → next action
|
||||
→ queue empty → emit queue_finished
|
||||
→ stop_queue() → IDLE
|
||||
```
|
||||
|
||||
**_Action Execution:_**
|
||||
|
||||
- walk_to → NavigationAgent2D → wait for arrived
|
||||
|
||||
- speak → Show speech bubble → wait for duration
|
||||
|
||||
- wait → Wait for duration
|
||||
|
||||
- ragdoll → set_ragdoll(true) → wait for rest
|
||||
|
||||
- recover → request_recovery() → wait for finish
|
||||
|
||||
**_Test:_**
|
||||
|
||||
- Create queue: Walk → Wait 2s → Speak "Hello!" → Walk.
|
||||
|
||||
- Press Play.
|
||||
|
||||
- Stickman walks → waits → speaks → walks.
|
||||
|
||||
- All signals emit at correct times.
|
||||
|
||||
## 7. Acceptance Criteria
|
||||
|
||||
- [ ] Stickman walks to a target when directed.
|
||||
- [ ] Action queue stores and manages actions.
|
||||
- [ ] Director Tool UI allows action selection via click popup.
|
||||
- [ ] Waypoints and dotted lines visible in Edit mode.
|
||||
- [ ] Action Runner executes queue in Play mode.
|
||||
- [ ] No user input is accepted during Play.
|
||||
- [ ] Multiple stickmen can act simultaneously.
|
||||
|
||||
## 8. File Changes
|
||||
|
||||
File Changes
|
||||
|
||||
- scripts/stickman_rig.gd Add NavigationAgent2D, action queue, action runner
|
||||
- scripts/sandbox_stage.gd Add "Direct" tool, click detection, waypoint visualization
|
||||
- scripts/stage_gizmos.gd Add waypoint markers and dotted line rendering
|
||||
- scenes/sandbox_stage.tscn Add "Direct" tool button
|
||||
@@ -0,0 +1,330 @@
|
||||
# Phase 3b: Asset Library — Stickman + Prop Selector Grids
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Phase 3b adds a **visual asset library** to the Sandbox Stage Builder. Currently, placing a stickman always spawns the hard-coded `test.stk` figure, and props are limited to the palette buttons (Crate, Ball, Plank). This phase replaces those limitations with **visual selector grids** that let the user choose from all available assets.
|
||||
|
||||
## 2. Core Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ASSET LIBRARY WORKFLOW │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ EDIT MODE │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ User clicks "Stickman" palette button │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Stickman Selector Grid opens (popup overlay) │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Choose Your Stickman [×] Close │ │ │
|
||||
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │
|
||||
│ │ │ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ │ │
|
||||
│ │ │ │ Bob │ │ Sally│ │ John │ │ Joe │ │ │ │
|
||||
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │
|
||||
│ │ │ ┌──────┐ ┌──────┐ │ │ │
|
||||
│ │ │ │ 📷 │ │ 📷 │ │ │ │
|
||||
│ │ │ │ Sue │ │ Tom │ │ │ │
|
||||
│ │ │ └──────┘ └──────┘ │ │ │
|
||||
│ │ │ [Prev] Page 1/2 [Next] [Browse...] [Refresh] │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ User clicks a cell → grid closes │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Click the stage → spawns the selected stickman │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
## 3. What's Being Replaced
|
||||
|
||||
| Before | After |
|
||||
| ------------------------------------------- | ------------------------------------------------- |
|
||||
| "Stickman" palette button spawns `test.stk` | "Stickman" palette button opens the selector grid |
|
||||
| Separate "Crate", "Ball", "Plank" buttons | Single "Prop" button opens the prop selector grid |
|
||||
| No visual preview | Thumbnail preview in each grid cell |
|
||||
| No choice | Choose from all available assets |
|
||||
| Hard-coded file path | Dynamic library from scanned files |
|
||||
|
||||
## 4. Feature Breakdown
|
||||
|
||||
### 4.1. Stickman Selector Grid
|
||||
|
||||
| Feature | Description |
|
||||
| --------------- | ------------------------------------------------------------------------ |
|
||||
| **Source** | Scans `res://stickmen/` for `.stk` files |
|
||||
| **Thumbnails** | Renders each stickman to a texture (cached to `user://thumbnails/`) |
|
||||
| **Display** | Grid cells: thumbnail + `stickman_name` (or filename if no name) |
|
||||
| **Pagination** | 12 items per page (3 rows × 4 columns) with Prev/Next buttons |
|
||||
| **Selection** | Click a cell → selected stickman is cached as the default |
|
||||
| **Browse** | "Browse..." button opens a `FileDialog` to select a `.stk` from anywhere |
|
||||
| **Refresh** | "Refresh" button rescans the `stickmen/` folder |
|
||||
| **Empty state** | "No stickmen found! Create one in the editor first." |
|
||||
|
||||
### 4.2. Prop Selector Grid
|
||||
|
||||
| Feature | Description |
|
||||
| -------------- | ------------------------------------------------------ |
|
||||
| **Source** | Built-in prop templates (Crate, Ball, Plank, Triangle) |
|
||||
| **Thumbnails** | Renders each prop with its material/color |
|
||||
| **Display** | Grid cells: thumbnail + prop name + material badge |
|
||||
| **Pagination** | 12 items per page with Prev/Next buttons |
|
||||
| **Selection** | Click a cell → selected prop is cached as the default |
|
||||
| **Future** | Custom props (`.prp` files) will appear here |
|
||||
|
||||
### 4.3. Single Palette Buttons
|
||||
|
||||
| Before | After |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| "Stickman" (hard-coded) | "Stickman" (opens grid) |
|
||||
| "Crate" (hard-coded) | Removed |
|
||||
| "Ball" (hard-coded) | Removed |
|
||||
| "Plank" (hard-coded) | Removed |
|
||||
| (None) | **"Prop"** (opens prop grid) |
|
||||
|
||||
## 5. Visual Design
|
||||
|
||||
### 5.1. Grid Cell Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [THUMBNAIL] │ │ ← 150×150 px preview
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Bob │ │ ← Name (bold, centered)
|
||||
│ │ 🪵 Wood │ │ ← Material badge (props only)
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
**Cell size:** ~160×220 px (thumbnail 150×150, name area 60px)
|
||||
|
||||
### 5.2. Grid Popup Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Choose Your Stickman [×] Close │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
|
||||
│ │ Bob │ │Sally │ │ John │ │ Joe │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
|
||||
│ │ Sue │ │ Tom │ │Alex │ │Jess │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
|
||||
│ │Sam │ │Ella │ │Max │ │Mia │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ │
|
||||
│ [← Prev] Page 1/3 [Next →] [Browse...] [Refresh] │
|
||||
│ │
|
||||
│ Selected: Bob │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
### 5.3. Visual States
|
||||
|
||||
| State | Appearance |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| **Default cell** | Light background, subtle border |
|
||||
| **Hover** | Border highlight, slight scale (1.05) |
|
||||
| **Selected** | Blue/cyan border, checkmark overlay |
|
||||
| **Loading thumbnail** | Spinner or placeholder icon |
|
||||
| **Empty cell** | "No preview available" placeholder |
|
||||
|
||||
## 6. Data Flow
|
||||
|
||||
### 6.1. Stickman Library Indexer
|
||||
|
||||
```gdscript
|
||||
# stickman_library.gd (NEW)
|
||||
class_name StickmanLibrary
|
||||
extends RefCounted
|
||||
|
||||
# Scans res://stickmen/*.stk
|
||||
# Returns Array[Dictionary] with:
|
||||
# {
|
||||
# "path": "res://stickmen/bob.stk",
|
||||
# "name": "Bob",
|
||||
# "thumbnail": Texture2D,
|
||||
# "data": Dictionary # parsed .stk data (optional)
|
||||
# }
|
||||
```
|
||||
|
||||
### 6.2. Prop Library Registry
|
||||
|
||||
```gdscript
|
||||
# prop_library.gd (NEW)
|
||||
class_name PropLibrary
|
||||
extends RefCounted
|
||||
|
||||
# Built-in prop templates:
|
||||
# - Crate (Wood)
|
||||
# - Ball (Rubber)
|
||||
# - Plank (Metal)
|
||||
# - Triangle (Cardboard)
|
||||
|
||||
# Returns Array[Dictionary] with:
|
||||
# {
|
||||
# "id": "crate",
|
||||
# "name": "Crate",
|
||||
# "factory": Callable (returns shape payload),
|
||||
# "material_preset": PropBlock.MaterialPreset.WOOD,
|
||||
# "thumbnail": Texture2D
|
||||
# }
|
||||
```
|
||||
|
||||
### 6.3. Thumbnail Generation
|
||||
|
||||
#### Stickman Thumbnails:
|
||||
|
||||
- Parse the .stk file to get body_parts and part_order.
|
||||
- Create a temporary SubViewport (200×200 px).
|
||||
- Render the stickman using a simplified version of WholeStickmanPreview logic.
|
||||
- Capture the viewport as a Texture2D.
|
||||
- Cache to user://thumbnails/stickmen/<hash>.png.
|
||||
|
||||
#### Prop Thumbnails:
|
||||
|
||||
- Instantiate a temporary PropBlock in a SubViewport.
|
||||
- Apply the material preset and geometry.
|
||||
- Capture the viewport as a Texture2D.
|
||||
- Cache to user://thumbnails/props/<id>.png.
|
||||
|
||||
#### Caching Strategy:
|
||||
|
||||
- Thumbnails are generated once per file and cached to disk.
|
||||
- On subsequent runs, load the cached thumbnail if it exists and the source file hasn't been modified.
|
||||
- If the source file is modified, regenerate the thumbnail.
|
||||
|
||||
## 7. UI Integration
|
||||
|
||||
### 7.1. Palette Button Changes
|
||||
|
||||
#### Button Behavior
|
||||
|
||||
- "Stickman" Opens the Stickman Selector Grid
|
||||
- "Prop" Opens the Prop Selector Grid
|
||||
|
||||
### 7.2. Selected Asset Persistence
|
||||
|
||||
- The last selected stickman is stored in StageSpawner.selected_stickman_path.
|
||||
- The last selected prop is stored in StageSpawner.selected_prop_id.
|
||||
- These persist across mode toggles and scene reloads (in memory only — no disk save in Phase 3b).
|
||||
|
||||
### 7.3. Placement Flow
|
||||
|
||||
```
|
||||
User clicks "Stickman" palette button
|
||||
↓
|
||||
Stickman Selector Grid opens
|
||||
↓
|
||||
User clicks a stickman cell
|
||||
↓
|
||||
Grid closes → selected stickman is cached
|
||||
↓
|
||||
Stage enters placement mode (ghost appears)
|
||||
↓
|
||||
User clicks the stage → spawns the selected stickman
|
||||
```
|
||||
|
||||
## 8. File Structure
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------- | ------------------------------------------------------ |
|
||||
| res://scripts/stickman_library.gd | Scans .stk files, manages thumbnails |
|
||||
| res://scripts/prop_library.gd | Registry of prop templates |
|
||||
| res://scripts/asset_selector.gd | Grid UI controller (shared between stickmen and props) |
|
||||
| res://scenes/asset_selector.tscn | Grid popup scene |
|
||||
| res://scripts/thumbnails/stickman_thumbnail.gd | Renders stickman to texture |
|
||||
| res://scripts/thumbnails/prop_thumbnail.gd | Renders prop to texture |
|
||||
| res://user://thumbnails/ | Cached thumbnails (generated at runtime) |
|
||||
|
||||
## 9. Acceptance Criteria
|
||||
|
||||
### 9.1. Stickman Selector Grid
|
||||
|
||||
- [ ] Clicking "Stickman" palette button opens the grid.
|
||||
- [ ] Grid displays all .stk files in res://stickmen/.
|
||||
- [ ] Each cell shows a thumbnail preview of the stickman.
|
||||
- [ ] Each cell shows the stickman's stickman_name (or filename if no name).
|
||||
- [ ] Clicking a cell selects that stickman and closes the grid.
|
||||
- [ ] The selected stickman persists for future placements.
|
||||
- [ ] Pagination works when more than 12 stickmen exist.
|
||||
- [ ] "Browse..." button opens a FileDialog to select any .stk.
|
||||
- [ ] "Refresh" button rescans the stickmen/ folder.
|
||||
- [ ] If no .stk files exist, shows "No stickmen found! Create one in the editor first."
|
||||
- [ ] If only one .stk exists, the grid is skipped and the stickman is selected directly.
|
||||
- [ ] Thumbnails are cached to user://thumbnails/ and reused.
|
||||
- [ ] Modified .stk files regenerate their thumbnails.
|
||||
|
||||
### 9.2. Prop Selector Grid
|
||||
|
||||
- [ ] Clicking "Prop" palette button opens the grid.
|
||||
- [ ] Grid displays all built-in prop templates.
|
||||
- [ ] Each cell shows a thumbnail preview of the prop.
|
||||
- [ ] Each cell shows the prop name and material badge.
|
||||
- [ ] Clicking a cell selects that prop and closes the grid.
|
||||
- [ ] The selected prop persists for future placements.
|
||||
- [ ] Pagination works when more than 12 props exist.
|
||||
- [ ] Thumbnails are cached to user://thumbnails/props/.
|
||||
|
||||
### 9.3. Palette Integration
|
||||
|
||||
- [ ] The old "Crate", "Ball", and "Plank" buttons are removed.
|
||||
- [ ] A single "Prop" button replaces them.
|
||||
- [ ] The "Stickman" button now opens the grid (instead of spawning test.stk).
|
||||
|
||||
### 9.4. Performance
|
||||
|
||||
- [ ] Scanning 50+ .stk files does not stall the UI.
|
||||
- [ ] Thumbnail generation is non-blocking (or uses a loading state).
|
||||
- [ ] Grid opens and paginates smoothly.
|
||||
|
||||
## 10. Implementation Order
|
||||
|
||||
| Step | Task | Dependencies |
|
||||
| ---- | ---------------------------------------------------------- | ------------ |
|
||||
| 1 | Create stickman_library.gd (file scanner + entry model) | None |
|
||||
| 2 | Create stickman_thumbnail.gd (renders stickman to texture) | Step 1 |
|
||||
| 3 | Create asset_selector.gd / asset_selector.tscn (grid UI) | Steps 1-2 |
|
||||
| 4 | Integrate selector with "Stickman" palette button | Step 3 |
|
||||
| 5 | Create prop_library.gd (built-in prop registry) | None |
|
||||
| 6 | Create prop_thumbnail.gd (renders prop to texture) | Step 5 |
|
||||
| 7 | Integrate selector with "Prop" palette button | Steps 5-6 |
|
||||
| 8 | Remove old "Crate", "Ball", "Plank" buttons | Step 7 |
|
||||
| 9 | Implement thumbnail caching | Steps 2, 6 |
|
||||
|
||||
## 11. Edge Cases
|
||||
|
||||
| Edge Case | Handling |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| No .stk files exist | Show "No stickmen found! Create one in the editor first." |
|
||||
| Only one .stk exists | Skip the grid, select it directly |
|
||||
| Thumbnail generation fails | Show placeholder icon + regenerate on next run |
|
||||
| File is corrupted/invalid | Skip the file, log a warning |
|
||||
| Stickman has no name | Use the filename (without .stk) |
|
||||
| Many files (50+) | Pagination keeps the UI responsive |
|
||||
| Grid size changes | Rebuild the grid layout on resize |
|
||||
| Selected file is deleted | Reselect the first available file (or show empty state) |
|
||||
|
||||
## 12. Summary
|
||||
|
||||
| Before | After |
|
||||
| --------------------------------- | ------------------------------------- |
|
||||
| Hard-coded test.stk | Visual grid of all .stk files |
|
||||
| Separate Crate/Ball/Plank buttons | Single "Prop" button with visual grid |
|
||||
| No preview | Thumbnail preview in every cell |
|
||||
| No choice | Choose from all available assets |
|
||||
| No caching | Thumbnails cached to user:// |
|
||||
|
||||
This is the final piece connecting the Stickman Editor to the Sandbox Stage. Users can now create stickmen in the editor, save them as .stk, and pick them from the grid when placing actors on the stage.
|
||||
@@ -0,0 +1,593 @@
|
||||
# Phase 3c: Editor Tools — Action & Rule Editing
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Phase 3c adds **full editing capabilities** for both the Director Tool's action queues and the Event System's rules. Currently, directors can only **append** actions and **create/delete** rules. They cannot fix mistakes, change order, or modify existing items. This phase makes the entire script **fully editable**.
|
||||
|
||||
The plan combines action editing and rule editing into a single, cohesive implementation with a logical progression from simpler to more complex features.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Principles
|
||||
|
||||
### 2.1. Extensibility First
|
||||
|
||||
The system is built to accommodate future growth:
|
||||
|
||||
| Future Addition | How It's Supported |
|
||||
| ---------------------- | --------------------------------------------------------- |
|
||||
| New action types | Registry pattern — add action template, UI auto-generates |
|
||||
| New rule trigger types | Registry pattern — add trigger type, UI auto-generates |
|
||||
| New action properties | Action data model is a Dictionary — add new keys freely |
|
||||
| New rule properties | Rule data model is a Dictionary — add new keys freely |
|
||||
| New visual styles | Theme JSON already supports font/color overrides |
|
||||
|
||||
### 2.2. Consistency
|
||||
|
||||
The UI patterns for action editing and rule editing are **identical**:
|
||||
|
||||
| Pattern | Action Edition | Rule Edition |
|
||||
| ------------ | ----------------------------------- | ---------------------------------- |
|
||||
| Entry points | Popup menu + Right-click + Waypoint | Rule label + Stickman context menu |
|
||||
| Panel UI | Action Queue Panel | Rule Panel |
|
||||
| Editor Popup | Action Editor | Rule Editor |
|
||||
| Controls | [✎] Edit, [✕] Delete, [≡] Reorder | Same |
|
||||
| Drag handles | Reorder actions | Reorder rules |
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Order
|
||||
|
||||
The plan is organized into **5 phases**, each building on the previous:
|
||||
|
||||
| Phase | Focus | Deliverables |
|
||||
| -------------- | ------------------------- | -------------------------------------------- |
|
||||
| **Phase 3c.1** | **Foundation** | Shared UI components, extensible data models |
|
||||
| **Phase 3c.2** | **Action Queue Panel** | View, edit, delete, reorder actions |
|
||||
| **Phase 3c.3** | **Action Visual Editing** | Waypoint context menu, visual walk editing |
|
||||
| **Phase 3c.4** | **Rule Panel** | View, edit, delete, reorder rules |
|
||||
| **Phase 3c.5** | **Rule Visual Editing** | Rule label click, waypoint trigger editing |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3c.1: Foundation
|
||||
|
||||
### 4.1. Extensible Action Registry
|
||||
|
||||
The action system should be registry-driven to support future action types:
|
||||
|
||||
```gdscript
|
||||
|
||||
# action_registry.gd (NEW)
|
||||
const ACTION_TEMPLATES = {
|
||||
"walk_to": {
|
||||
"label": "Walk To",
|
||||
"icon": "🚶",
|
||||
"params": [
|
||||
{ "key": "target", "type": "position", "required": true }
|
||||
]
|
||||
},
|
||||
"speak": {
|
||||
"label": "Speak",
|
||||
"icon": "💬",
|
||||
"params": [
|
||||
{ "key": "text", "type": "text", "required": true },
|
||||
{ "key": "duration", "type": "float", "default": 2.0 }
|
||||
]
|
||||
},
|
||||
"wait": {
|
||||
"label": "Wait",
|
||||
"icon": "⏳",
|
||||
"params": [
|
||||
{ "key": "duration", "type": "float", "required": true }
|
||||
]
|
||||
},
|
||||
"ragdoll": {
|
||||
"label": "Ragdoll",
|
||||
"icon": "💥",
|
||||
"params": []
|
||||
},
|
||||
"recover": {
|
||||
"label": "Recover",
|
||||
"icon": "🔄",
|
||||
"params": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Extensibility:** Adding a new action type = appending to `ACTION_TEMPLATES`. No other code changes required.
|
||||
|
||||
### 4.2. Extensible Trigger Registry
|
||||
|
||||
Similarly, trigger types are registry-driven:
|
||||
|
||||
```gdscript
|
||||
|
||||
# trigger_registry.gd (NEW)
|
||||
const TRIGGER_TEMPLATES = {
|
||||
"arrived_at_waypoint": {
|
||||
"label": "Arrives at waypoint",
|
||||
"icon": "📍",
|
||||
"target_type": "waypoint"
|
||||
},
|
||||
"action_finished": {
|
||||
"label": "Completes any action",
|
||||
"icon": "✅",
|
||||
"target_type": "action_type"
|
||||
},
|
||||
"speech_finished": {
|
||||
"label": "Finishes speaking",
|
||||
"icon": "💬",
|
||||
"target_type": "none"
|
||||
},
|
||||
"entered_area": {
|
||||
"label": "Enters trigger area",
|
||||
"icon": "🎯",
|
||||
"target_type": "area"
|
||||
},
|
||||
"collided": {
|
||||
"label": "Collides with something",
|
||||
"icon": "💥",
|
||||
"target_type": "prop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Shared UI Components
|
||||
|
||||
| Component | Purpose | Reused By |
|
||||
| ----------------------- | ----------------------------- | ------------------------------ |
|
||||
| **Panel Container** | Scrollable list of items | Action Queue Panel, Rule Panel |
|
||||
| **Editor Popup** | Edit single item's properties | Action Editor, Rule Editor |
|
||||
| **Drag Handle** | Reorder items | Both panels |
|
||||
| **Delete Confirmation** | Confirm before deletion | Both panels |
|
||||
|
||||
### 4.4. Extensible Action Properties
|
||||
|
||||
Actions are stored as Dictionaries, so future properties can be added without breaking existing code:
|
||||
|
||||
```gdscript
|
||||
|
||||
# Current action
|
||||
{ "type": "speak", "text": "Hello", "duration": 2.0 }
|
||||
# Future action (with text color)
|
||||
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
|
||||
```
|
||||
|
||||
**Extensibility:** New properties are just new keys in the Dictionary. The editor should display editable fields for all known keys and gracefully ignore unknown ones.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 3c.2: Action Queue Panel
|
||||
|
||||
### 5.1. Feature Breakdown
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------- | ------------------------------------------- |
|
||||
| **Queue Panel** | Popup showing all actions for a stickman |
|
||||
| **Edit Action** | Re-open action popup with pre-filled values |
|
||||
| **Delete Action** | Remove action from queue (confirmation) |
|
||||
| **Reorder Actions** | Drag handle to reorder |
|
||||
| **Add Action** | Append new action from panel |
|
||||
| **Clear All** | Remove all actions (confirmation) |
|
||||
|
||||
### 5.2. Entry Points
|
||||
|
||||
| Entry Point | When to Use | How it Works |
|
||||
| --------------- | -------------------------------- | ------------------------------------------- |
|
||||
| **Popup** | Edit queue for selected stickman | Click stickman → "Edit Queue" → panel opens |
|
||||
| **Right-click** | Quick access | Right-click stickman → "Edit Queue" |
|
||||
|
||||
### 5.3. UI Layout
|
||||
|
||||
```
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ACTION QUEUE PANEL │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Stickman: Bob [×] Close │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 1 🚶 Walk to Crate [✎] [✕] [≡] │ │
|
||||
│ │ 2 💬 Speak "Hello!" [✎] [✕] [≡] │ │
|
||||
│ │ 3 ⏳ Wait 2.0s [✎] [✕] [≡] │ │
|
||||
│ │ 4 💥 Ragdoll [✎] [✕] [≡] │ │
|
||||
│ │ 5 🔄 Recover [✎] [✕] [≡] │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Add Action] [Clear All] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.4. Edit Action Flow
|
||||
|
||||
1. User clicks **[✎]** on an action.
|
||||
2. The **same action popup** appears, with current values pre-filled.
|
||||
3. User makes changes.
|
||||
4. Click **OK** → action is updated in the queue.
|
||||
5. Visuals update immediately.
|
||||
|
||||
| Action Type | Pre-filled Values |
|
||||
| ----------- | -------------------------------------------------- |
|
||||
| **Walk To** | Current target position (waypoint dot highlighted) |
|
||||
| **Speak** | Current text and duration |
|
||||
| **Wait** | Current duration |
|
||||
| **Ragdoll** | (No parameters) |
|
||||
| **Recover** | (No parameters) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 3c.3: Action Visual Editing
|
||||
|
||||
### 6.1. Feature Breakdown
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| **Waypoint Context Menu** | Right-click waypoint → Edit/Delete/Insert |
|
||||
| **Edit Walk (Visual)** | Click a new position → waypoint moves |
|
||||
| **Insert Action** | Insert before/after a specific waypoint |
|
||||
|
||||
### 6.2. Waypoint Context Menu
|
||||
|
||||
In Edit mode, right-clicking a waypoint dot opens:
|
||||
|
||||
```
|
||||
|
||||
Right-click Waypoint 3:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ✎ Edit this Walk │
|
||||
│ ✕ Delete this Walk │
|
||||
│ ⬆ Insert action before │
|
||||
│ ⬇ Insert action after │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Action | Behavior |
|
||||
| ------------------------ | -------------------------------------------------------------- |
|
||||
| **Edit this Walk** | Enters target placement mode → click new spot → waypoint moves |
|
||||
| **Delete this Walk** | Removes the walk action from the queue |
|
||||
| **Insert action before** | Opens action popup → inserts new action before this one |
|
||||
| **Insert action after** | Opens action popup → inserts new action after this one |
|
||||
|
||||
### 6.3. Visual Update Flow
|
||||
|
||||
```
|
||||
User right-clicks waypoint
|
||||
↓
|
||||
Context menu appears
|
||||
↓
|
||||
User clicks "Edit this Walk"
|
||||
↓
|
||||
Stage enters target placement mode
|
||||
↓
|
||||
Current waypoint is highlighted (blinking)
|
||||
↓
|
||||
User clicks new position on stage
|
||||
↓
|
||||
Old waypoint removed, new waypoint appears
|
||||
↓
|
||||
walk_to action's target is updated
|
||||
↓
|
||||
Dotted lines reconnect
|
||||
↓
|
||||
Order numbers remain the same
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3c.4: Rule Panel
|
||||
|
||||
### 7.1. Feature Breakdown
|
||||
|
||||
| Feature | Description |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| **Rule Panel** | Popup showing all rules for a source stickman |
|
||||
| **Edit Rule (Full)** | Edit trigger type, trigger target, action(s) |
|
||||
| **Edit Rule (Consequence-Only)** | Quick edit of actions only |
|
||||
| **Delete Rule** | Remove rule from registry (confirmation) |
|
||||
| **Reorder Rules** | Drag handle to reorder evaluation order |
|
||||
| **Add Action to Rule** | Add another action to an existing rule |
|
||||
| **Remove Action from Rule** | Delete an action from a rule |
|
||||
| **Clear All** | Remove all rules (confirmation) |
|
||||
|
||||
### 7.2. Entry Points
|
||||
|
||||
| Entry Point | When to Use | How it Works |
|
||||
| -------------------- | ----------------------------- | ------------------------------------------------- |
|
||||
| **Rule Label** | Edit a specific rule | Click the rule label (dashed line) → editor opens |
|
||||
| **Stickman Context** | View all rules for a stickman | Right-click stickman → "Edit Rules" → panel opens |
|
||||
|
||||
### 7.3. Rule Panel UI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ RULE PANEL │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Source: Stickman A [×] Close │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 1 📍 When A arrives at Waypoint 3 │ │
|
||||
│ │ → B speaks "Hello there!" [✎] [✕] [≡] │ │
|
||||
│ │ │ │
|
||||
│ │ 2 📍 When A arrives at Waypoint 5 │ │
|
||||
│ │ → C walks to crate [✎] [✕] [≡] │ │
|
||||
│ │ │ │
|
||||
│ │ 3 🎯 When A enters Area │ │
|
||||
│ │ → All stickmen ragdoll [✎] [✕] [≡] │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Add Rule] [Clear All] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4. Rule Editor (Full)
|
||||
|
||||
Opens when user clicks **[✎]** on a rule in the panel:
|
||||
|
||||
```
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ EDIT RULE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Trigger: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [📍 Arrives at waypoint ▼] [Click target →] │ │
|
||||
│ │ Target: Waypoint 3 (on stage) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Actions: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
|
||||
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Add Action] [Cancel] [OK] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.5. Rule Editor (Consequence-Only)
|
||||
|
||||
Opens when user clicks a rule label on the stage:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ EDIT RULE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ When: 📍 A arrives at Waypoint 3 (read-only) │
|
||||
│ ────────────────────────────────────────────────────────────── │
|
||||
│ Then: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
|
||||
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Add Action] [Done] [Cancel] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Focus:** The trigger is displayed but **read-only**. For trigger editing, use the full rule editor.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 3c.5: Rule Visual Editing
|
||||
|
||||
### 8.1. Feature Breakdown
|
||||
|
||||
| Feature | Description |
|
||||
| ---------------------------- | ------------------------------------------- |
|
||||
| **Rule Label Click** | Click rule label → consequence-only editor |
|
||||
| **Waypoint → Rules** | Right-click waypoint → "Edit Trigger Rules" |
|
||||
| **Rule Reordering (Visual)** | Evaluation order shown on stage (optional) |
|
||||
|
||||
### 8.2. Waypoint → Rules
|
||||
|
||||
In Edit mode, right-clicking a waypoint dot that is used as a trigger target:
|
||||
|
||||
```
|
||||
Right-click Waypoint 3:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ✎ Edit this Walk (action) │
|
||||
│ ───────────────────────────────────────── │
|
||||
│ ⚡ Edit Trigger Rules (2 rules) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Clicking "Edit Trigger Rules" opens the Rule Panel filtered to rules using that waypoint.
|
||||
|
||||
---
|
||||
|
||||
## 9. File Structure
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------------------ |
|
||||
| `scripts/action_registry.gd` | Registry of action templates (extensible) |
|
||||
| `scripts/trigger_registry.gd` | Registry of trigger templates (extensible) |
|
||||
| `scripts/queue_panel.gd` | Action Queue Panel controller |
|
||||
| `scripts/queue_panel.tscn` | Action Queue Panel scene |
|
||||
| `scripts/rule_panel.gd` | Rule Panel controller |
|
||||
| `scripts/rule_panel.tscn` | Rule Panel scene |
|
||||
| `scripts/action_editor.gd` | Action Editor popup controller |
|
||||
| `scripts/action_editor.tscn` | Action Editor popup scene |
|
||||
| `scripts/rule_editor.gd` | Rule Editor popup controller (full + consequence-only) |
|
||||
| `scripts/rule_editor.tscn` | Rule Editor popup scene |
|
||||
| `scripts/waypoint_context.gd` | Waypoint right-click menu |
|
||||
| `scripts/sandbox_stage.gd` | Add "Edit Queue", "Edit Rules", integrate editors |
|
||||
| `scripts/stage_director_visuals.gd` | Waypoint hit-testing, rule label click → editor |
|
||||
|
||||
---
|
||||
|
||||
## 10. Extensibility Guide
|
||||
|
||||
### 10.1. Adding a New Action Type
|
||||
|
||||
```gdscript
|
||||
|
||||
# 1. Add to action_registry.gd
|
||||
const ACTION_TEMPLATES = {
|
||||
# ... existing actions ...
|
||||
"jump": {
|
||||
"label": "Jump",
|
||||
"icon": "🦘",
|
||||
"params": [
|
||||
{ "key": "height", "type": "float", "default": 100.0 },
|
||||
{ "key": "duration", "type": "float", "default": 0.5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 2. Implement the action in StickmanRig.\_process_queue()
|
||||
|
||||
# 3. Add the action to the popup (automatically from registry)
|
||||
|
||||
### 10.2. Adding a New Rule Trigger Type
|
||||
|
||||
```gdscript
|
||||
|
||||
# 1. Add to trigger_registry.gd
|
||||
const TRIGGER_TEMPLATES = {
|
||||
# ... existing triggers ...
|
||||
"variable_changed": {
|
||||
"label": "Variable changes",
|
||||
"icon": "📊",
|
||||
"target_type": "variable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 2. Emit the trigger signal from SandboxStage
|
||||
|
||||
# 3. Add the trigger to the rule builder (automatically from registry)
|
||||
|
||||
### 10.3. Adding a New Action Property
|
||||
|
||||
```gdscript
|
||||
|
||||
# 1. The action is stored as a Dictionary
|
||||
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
|
||||
# 2. The Action Editor reads all keys from params
|
||||
# 3. Unknown keys are displayed as read-only (or editable with generic control)
|
||||
# 4. The action runner reads the new key when executing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance Criteria
|
||||
|
||||
### 11.1. Action Queue Panel
|
||||
|
||||
-"Edit Queue" opens panel from popup and right-click.
|
||||
|
||||
-Panel shows all actions in order.
|
||||
|
||||
-Each action shows type, parameters, and order number.
|
||||
|
||||
-[✕] deletes action (confirmation dialog).
|
||||
|
||||
-[✎] opens edit popup with pre-filled values.
|
||||
|
||||
-[≡] reorders actions.
|
||||
|
||||
-"Clear All" removes all actions (confirmation).
|
||||
|
||||
-"Add Action" appends a new action.
|
||||
|
||||
### 11.2. Edit Action
|
||||
|
||||
- Walk To: Edit opens target placement mode; clicking new spot updates waypoint.
|
||||
- Speak: Edit opens text dialog with current text pre-filled.
|
||||
- Wait: Edit opens duration dialog with current value pre-filled.
|
||||
- Ragdoll/Recover: Edit opens confirmation dialog.
|
||||
|
||||
### 11.3. Waypoint Context Menu
|
||||
|
||||
- Right-click waypoint opens context menu.
|
||||
- "Edit this Walk" enters target placement mode.
|
||||
- "Delete this Walk" removes the action.
|
||||
- "Insert action before/after" inserts new action.
|
||||
|
||||
### 11.4. Rule Panel
|
||||
|
||||
- "Edit Rules" opens panel from stickman context menu.
|
||||
- Panel shows all rules where stickman is trigger source.
|
||||
- Each rule shows trigger type, target, and action(s).
|
||||
- `[✕] deletes rule (confirmation).
|
||||
- `[✎] opens rule editor with pre-filled values.
|
||||
- `[≡] reorders rules.
|
||||
- "Add Rule" opens rule builder.
|
||||
- "Clear All" removes all rules (confirmation).
|
||||
|
||||
### 11.5. Rule Editor
|
||||
|
||||
- Full editor: trigger type dropdown works, trigger target can be clicked.
|
||||
- Full editor: action type dropdown works, target can be clicked.
|
||||
- Full editor: parameters (text, duration) can be edited.
|
||||
- Consequence-only editor: trigger is read-only.
|
||||
- Consequence-only editor: actions can be edited.
|
||||
- Multi-action rules: add/remove actions.
|
||||
- Saving updates rule in event registry.
|
||||
- Visual connectors update immediately.
|
||||
|
||||
### 11.6. Visual Updates
|
||||
|
||||
- Waypoint dots move when Walk actions are edited.
|
||||
- Speech badge text updates when Speak actions are edited.
|
||||
- Wait duration updates.
|
||||
- Dotted lines reconnect to reflect new order.
|
||||
- Order numbers update after insert/delete/reorder.
|
||||
- Rule labels update with new summaries.
|
||||
- Dashed lines reconnect to new targets.
|
||||
|
||||
### 11.7. Backward Compatibility
|
||||
|
||||
- Existing queues load and display correctly.
|
||||
- Editing preserves action types and parameters.
|
||||
- Existing rules load and display correctly.
|
||||
- Editing a rule preserves the rule ID.
|
||||
- Deleting an action/rule cleans up all references.
|
||||
|
||||
### 11.8. Extensibility
|
||||
|
||||
- New action types can be added via registry (no code changes required).
|
||||
- New trigger types can be added via registry.
|
||||
- New action properties are supported (dictionary keys).
|
||||
- Editor gracefully handles unknown keys.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Order Summary
|
||||
|
||||
| Phase | Focus | Key Deliverables |
|
||||
| -------- | --------------------- | ------------------------------------------------------- |
|
||||
| **3c.1** | Foundation | Action Registry, Trigger Registry, Shared UI Components |
|
||||
| **3c.2** | Action Queue Panel | Queue panel, edit/delete/reorder actions |
|
||||
| **3c.3** | Action Visual Editing | Waypoint context menu, visual walk editing |
|
||||
| **3c.4** | Rule Panel | Rule panel, full/consequence-only edit, reorder |
|
||||
| **3c.5** | Rule Visual Editing | Rule label click, waypoint trigger rules |
|
||||
|
||||
---
|
||||
|
||||
## 13. Summary
|
||||
|
||||
| Before | After |
|
||||
| ------------------------------------ | --------------------------------------------- |
|
||||
| Actions can only be appended | Actions can be edited, deleted, and reordered |
|
||||
| Rules can only be created or deleted | Rules can be edited, deleted, and reordered |
|
||||
| No way to fix mistakes | Edit any parameter |
|
||||
| No way to change order | Drag to reorder |
|
||||
| No visual editing | Waypoint context menu + visual walk editing |
|
||||
| No chain reaction editing | Add/remove actions from rules |
|
||||
| Fixed evaluation order | Drag to reorder rules |
|
||||
| Tightly coupled code | Registry-driven, extensible architecture |
|
||||
|
||||
**This completes the Director Tool's full editing capabilities.** Directors can now create, edit, delete, and reorder both actions and rules with full control and extensibility for future development.
|
||||
@@ -0,0 +1,565 @@
|
||||
# Phase 3c — Editor Tools: Action & Rule Editing (Spec)
|
||||
|
||||
Status: SPEC (pending implementation)
|
||||
Related plan: `plans/PHASE_3c_EDITOR.md`
|
||||
Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", "Forward Plus")`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Scope
|
||||
|
||||
Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules
|
||||
**fully editable** (edit / delete / reorder / insert), replacing the current
|
||||
append-only + create/delete-only behavior. It is built on two registry-driven tables
|
||||
(action + trigger) so the UI is generated from data rather than hard-coded `match`
|
||||
statements, matching the existing `StageSpawner._registry` / `PropLibrary` patterns.
|
||||
|
||||
### In scope
|
||||
|
||||
- Action + trigger **registries** (`action_registry.gd`, `trigger_registry.gd`).
|
||||
- **Action Queue Panel** — view / edit / delete / reorder / add / clear a stickman's queue.
|
||||
- **Action visual editing** — waypoint right-click context menu (edit walk / delete walk /
|
||||
insert before / insert after).
|
||||
- **Rule Panel** — view / edit / delete / reorder / add / clear rules (filtered by source stickman).
|
||||
- **Rule Editor** — full edit (trigger type + target + actions) and consequence-only edit
|
||||
(actions only, trigger read-only).
|
||||
- **Rule visual editing** — rule-label click → consequence-only editor (already partially
|
||||
present via `_begin_edit_rule`); waypoint → "Edit Trigger Rules".
|
||||
|
||||
### Out of scope / untouched (must not regress)
|
||||
|
||||
- **StickmanRig runner execution semantics** — the action runner (`_begin_action`,
|
||||
`_update_runner`, the 5 action phases) is **not** changed. Editing mutates queue/rule
|
||||
*data*; the runner already consumes any of those shapes.
|
||||
- **Event engine matching** (`_rule_matches`, `_update_area_entry`,
|
||||
`_update_stickman_prop_collision`) — unchanged. Rule *reordering* changes evaluation
|
||||
order (array order) but not the per-rule matching logic.
|
||||
- **Navigation / walk steering** (`_update_walking`, mode latch) — unchanged.
|
||||
- **Terrain drag-painting, selection, gizmos, placement ghost, asset selector** — unchanged.
|
||||
- **No `.stk` / `settings.json` format changes.** Queues and rules remain session-only
|
||||
(persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the
|
||||
Phase 3a/4 decision.
|
||||
- **No new action types / trigger types** are implemented (only the *machinery* to add them
|
||||
cleanly). The registry is the extension point; wiring a genuinely new action still requires
|
||||
a runner case in `StickmanRig._begin_action` (see §6 and §13).
|
||||
|
||||
---
|
||||
|
||||
## 2. Recorded Decisions
|
||||
|
||||
1. **Registries are static tables, not singletons.** `action_registry.gd` /
|
||||
`trigger_registry.gd` are `class_name`-less-optional, `RefCounted` scripts exposing
|
||||
`static` const tables + `static func` accessors (mirroring `PropLibrary`). `sandbox_stage.gd`
|
||||
preloads them like the other `preload` consts. No autoload, no instance state.
|
||||
2. **All new UI is code-built; no `.tscn` files.** The plan lists `queue_panel.tscn`,
|
||||
`rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`. The codebase builds all stage
|
||||
UI in code inside `_build_ui()` (top bar, popups, dialogs); only `AssetSelector` uses a
|
||||
`.tscn` shell, and it has authored content. These panels/editors are **dynamic** (row lists
|
||||
change every mutation), so they are `PopupPanel`-based controller scripts constructed in
|
||||
code. **Dropped**: `action_editor.gd`/`action_editor.tscn` (action param editing reuses the
|
||||
existing `_speak_dialog`/`_wait_dialog` + pending-target machinery) and
|
||||
`waypoint_context.gd` (the waypoint menu is a code-built `PopupMenu`, exactly like
|
||||
`_trigger_popup`). See §7 file list.
|
||||
3. **Reorder UX = Move Up / Move Down buttons, not drag.** A `[≡]` drag handle in a `PopupPanel`
|
||||
requires hand-rolled `_gui_input` drag/reorder hit-testing; up/down buttons are simpler,
|
||||
keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable
|
||||
headlessly. Each row shows `⬆`/`⬇` (disabled at the ends) plus `✎`/`✕`. Drag reorder is
|
||||
logged as deferred (tech debt §13).
|
||||
4. **Waypoint → action mapping returns `(rig, index, pos)`, not just a position.**
|
||||
`StageDirectorVisuals.hit_test_waypoint()` currently returns only the nearest `Vector2`
|
||||
(used by the rule builder for `arrived_at_waypoint`). A new `hit_test_waypoint_action()`
|
||||
returns `{rig, index, pos}` so the context menu knows **which rig's queue and which
|
||||
`walk_to` action index** to edit/delete/insert around. The existing position-only method is
|
||||
kept for the rule builder (rules reference waypoint *positions*, not indices).
|
||||
5. **Insert before/after maps directly to `StickmanRig.insert_action(index, action)`.**
|
||||
"Before waypoint *i*" → `insert_action(i, action)`; "after" → `insert_action(i + 1, action)`.
|
||||
`i` is the **queue index** of the `walk_to` action (equal to the waypoint ordinal − 1 because
|
||||
`walk_to` is the only waypoint-producing action). `insert_action` already clamps to `[0, size]`.
|
||||
6. **Edit-walk visual flow reuses the pending target-capture machinery.** A new edit state
|
||||
(`_pending_walk_edit_index >= 0`) reuses `_pending_walk_target`'s cursor/hint/trajectory and
|
||||
the `_handle_direct_click`/`_handle_world_click` routing; the next stage click **replaces**
|
||||
the existing `walk_to.target` instead of appending. The edited waypoint is highlighted in
|
||||
the director visuals.
|
||||
7. **Rule reorder = array order; `id` is immutable and preserved.** `_event_rules` is iterated
|
||||
in order by `_handle_event` (all matching rules execute — no short-circuit). Reordering swaps
|
||||
array entries; editing preserves `id` (already the case in `_finalize_rule`). `_next_rule_id`
|
||||
stays monotonic (no id reuse on delete).
|
||||
8. **Rule Panel filter = `trigger.source`.** "Edit Rules" on a stickman shows rules whose
|
||||
`trigger.source` equals that stickman's instance id. The waypoint "Edit Trigger Rules" filter
|
||||
matches by `params.waypoint_pos` proximity (`WAYPOINT_MATCH_EPSILON`), because rules store
|
||||
waypoint **positions**, not queue indices.
|
||||
9. **Delete confirmation policy (deliberately asymmetric).** Panel-initiated `✕` deletes and
|
||||
"Clear All" (queue and rules) confirm via `ConfirmationDialog`. The on-stage rule-label `✕`
|
||||
and the waypoint "Delete this Walk" stay **immediate** (current low-friction behavior,
|
||||
not regressed). Documented in §13.
|
||||
10. **Ragdoll/Recover have no editable parameters.** Their `✎` is hidden in the panel and their
|
||||
"edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens
|
||||
confirmation dialog" is corrected — there is nothing to edit.)
|
||||
11. **The full rule editor drives the existing `RuleStep` state machine.** Trigger-type change
|
||||
re-enters `SELECT_TRIGGER`; trigger-target change re-enters `TRIGGER_TARGET`; "Add Action"
|
||||
uses the existing `SELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-more`
|
||||
flow. The editor panel is a *view*; `sandbox_stage.gd` remains the *controller* owning
|
||||
`_rule_builder`/`_rule_step`/`_event_rules`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Discrepancies: Plan vs. Actual Code
|
||||
|
||||
| # | Plan claim | Reality (verified) | Resolution |
|
||||
|---|---|---|---|
|
||||
| 1 | Panels/editors are new `.tscn` scenes (`queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`, `waypoint_context.gd`). | Stage UI is built entirely in code (`_build_ui`); only `AssetSelector` has a `.tscn` shell. | Code-built `PopupPanel` scripts (§2.2); drop `action_editor.gd` + `waypoint_context.gd` (§2.2). |
|
||||
| 2 | Registry `params` with `key`/`type`/`default` drives the editor generically. | Action data lives in **two** shapes: queue action (`walk_to.target` top-level; `speak.text`/`.duration` top-level; `wait.duration` top-level) vs. rule action (`{type, target, params:{...}}`). No generic param model exists. | Registry describes *logical* params; `get_fields`/`make_action` normalize the two shapes (§5.3). |
|
||||
| 3 | "New action type = append to registry; **no code changes**" (§10.1, §11.8). | `StickmanRig._begin_action` hard-codes the 5 types; a new type also needs a runner case + `_action_for_rig` + `_action_desc`. | Corrected: registry removes *UI* changes; runner changes still required (§6, §12.7). |
|
||||
| 4 | `hit_test_waypoint()` returns an index. | It returns only `Vector2` (nearest waypoint position); no rig/index identity. | Add `hit_test_waypoint_action()` returning `{rig, index, pos}` (§2.4, §9.2). |
|
||||
| 5 | "Rule label click → consequence-only edit already exists" (implied complete). | `_begin_edit_rule(id)` pre-populates and jumps straight to the add-action popup (`SELECT_ACTION`), preserving the trigger but offering **no actions list / remove / edit** and no trigger readout UI. | New `RuleEditor` (consequence-only mode) formalizes this; `_begin_edit_rule` routes to it (§11.4). |
|
||||
| 6 | `action_finished` trigger has a `target_type: "action_type"` (registry). | The builder does **not** expose an action-type selector for `action_finished` (only "completes any action"); `_rule_matches` reads optional `params.action_type`. | Registry marks `action_type` as optional; the full editor exposes it **only if cheap** — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13). |
|
||||
| 7 | Rule Panel "shows all rules for a source stickman" and reorders. | `_event_rules` is a flat stage-level array (no per-rig grouping); source is `trigger.source` (instance id). | Filter by `trigger.source` (§2.8); reorder operates on the flat array (§2.7). |
|
||||
| 8 | §11 acceptance criteria formatting: stray backticks and `-` prefixes. | Cosmetic markdown errors in the plan. | Rewritten cleanly in §12. |
|
||||
| 9 | Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." | Ragdoll/recover are param-less; editing is meaningless. | Corrected (§2.10). |
|
||||
| 10 | Plan §11.8 "unknown keys displayed as read-only / editable." | No generic editor exists; the runner ignores unknown keys. | Corrected: unknown keys are **preserved** (round-tripped) on edit, never dropped; no generic widget (§12.7). |
|
||||
| 11 | Waypoint context menu / stickman right-click are new. | Right-click is currently fully consumed by the EDIT placement-cancel branch (`_handle_world_click` returns on RMB). | Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1). |
|
||||
| 12 | Godot "4.4" (task prompt). | `project.godot:19` and the test runner reference **4.7**. | Spec targets 4.7. |
|
||||
|
||||
---
|
||||
|
||||
## 4. New Files
|
||||
|
||||
| File | `class_name` / extends | Responsibility |
|
||||
|---|---|---|
|
||||
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Static action template table + `static` helpers (labels/icons/params/describe/normalize). |
|
||||
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Static trigger template table + `static` helpers. |
|
||||
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals. |
|
||||
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals. |
|
||||
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals. |
|
||||
|
||||
No `.tscn` files are added (decision 2). `action_editor.gd` and `waypoint_context.gd` from the
|
||||
plan are **not** created — their responsibilities are absorbed into `sandbox_stage.gd` (existing
|
||||
dialogs + a code-built `PopupMenu`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Contracts
|
||||
|
||||
### 5.1 Queue action dict (consumed by `StickmanRig` runner — top-level keys)
|
||||
|
||||
```gdscript
|
||||
{ "type": "walk_to", "target": Vector2 } # target = feet/ground world position
|
||||
{ "type": "speak", "text": String, "duration": float }
|
||||
{ "type": "wait", "duration": float }
|
||||
{ "type": "ragdoll" }
|
||||
{ "type": "recover" }
|
||||
# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected)
|
||||
```
|
||||
|
||||
### 5.2 Rule dict (stored in `SandboxStage._event_rules`)
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"id": int, # immutable, monotonic from _next_rule_id
|
||||
"trigger": {
|
||||
"type": String, # arrived_at_waypoint | action_finished |
|
||||
# speech_finished | entered_area | collided
|
||||
"source": int, # instance id of the triggering stickman
|
||||
"target": int, # instance id (entered_area area | collided prop);
|
||||
# -1 otherwise
|
||||
"params": {
|
||||
# arrived_at_waypoint -> { "waypoint_pos": Vector2 }
|
||||
# action_finished -> { "action_type": String } (optional; "" == any)
|
||||
# else -> {}
|
||||
},
|
||||
},
|
||||
"actions": [ { "type": String, "target": int, "params": {...} } ],
|
||||
}
|
||||
```
|
||||
|
||||
Rule action `params`:
|
||||
```gdscript
|
||||
walk_to -> { "target": Vector2 } # destination (the action's `target` = walking stickman id)
|
||||
speak -> { "text": String, "duration": float }
|
||||
wait -> { "duration": float }
|
||||
ragdoll / recover -> {}
|
||||
```
|
||||
|
||||
> **Key asymmetry (documented):** in a **queue** action `walk_to`, `target` is the destination.
|
||||
> In a **rule** action, `target` is the *stickman instance id* and the destination is
|
||||
> `params.target`. The registry normalizes this via `get_fields`/`make_action` (§5.3).
|
||||
|
||||
### 5.3 ActionRegistry API (`action_registry.gd`)
|
||||
|
||||
```gdscript
|
||||
static func get_types() -> Array[String]
|
||||
# ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order)
|
||||
static func get_label(type: String) -> String # "Walk To", "Speak", "Wait", "Ragdoll", "Recover"
|
||||
static func get_icon(type: String) -> String # "🚶","💬","⏳","💥","🔄"
|
||||
static func get_params(type: String) -> Array[Dictionary]
|
||||
# [{ "key": "target", "kind": "position", "required": true }]
|
||||
# [{ "key": "text", "kind": "text", "required": true },
|
||||
# { "key": "duration", "kind": "float", "default": 2.0 }]
|
||||
# [{ "key": "duration", "kind": "float", "required": true }]
|
||||
# [] for ragdoll/recover
|
||||
static func has_params(type: String) -> bool
|
||||
static func get_fields(action: Dictionary) -> Dictionary
|
||||
# reads each param key from action top-level, falling back to action.params
|
||||
# (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v})
|
||||
static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary
|
||||
# for_rule=false -> { "type": type, ...top-level keys }
|
||||
# for_rule=true -> { "type": type, "target": -1, "params": {...keys} }
|
||||
static func describe(action: Dictionary) -> String
|
||||
# "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers"
|
||||
# (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home)
|
||||
static func row_summary(action: Dictionary) -> String
|
||||
# panel row text: "<icon> <label> <param summary>"
|
||||
```
|
||||
|
||||
### 5.4 TriggerRegistry API (`trigger_registry.gd`)
|
||||
|
||||
```gdscript
|
||||
static func get_types() -> Array[String]
|
||||
# ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"]
|
||||
static func get_label(type: String) -> String
|
||||
static func get_icon(type: String) -> String
|
||||
static func get_target_kind(type: String) -> String
|
||||
# "waypoint" | "action_type" | "none" | "area" | "prop"
|
||||
static func needs_target(type: String) -> bool
|
||||
# true for arrived_at_waypoint (waypoint), entered_area (area), collided (prop)
|
||||
static func describe(trigger: Dictionary) -> String
|
||||
# "arrives", "completes an action", "finishes speaking", "enters area", "collides"
|
||||
```
|
||||
|
||||
> `speech_finished` (`none`) and `action_finished` (`action_type`) are "no world target" triggers.
|
||||
> `action_finished`'s optional `params.action_type` filter is **not** surfaced in the Phase 3c
|
||||
> editor (the builder's current "completes any action" behavior is preserved) — deferred §13.
|
||||
|
||||
---
|
||||
|
||||
## 6. Extensibility Contract (corrected from plan §10)
|
||||
|
||||
- **New action type** = (1) append a template to `ActionRegistry` (label/icon/params),
|
||||
(2) add a `_begin_action` case + (if it has params) a `_action_for_rig` mapping in
|
||||
`StickmanRig`, (3) add a params dialog if it has new param kinds. The popups/panels/rows
|
||||
then auto-generate. **The runner case is not optional** (plan §11.8 "no code changes" is false).
|
||||
- **New trigger type** = (1) append a template to `TriggerRegistry`, (2) emit a matching event in
|
||||
`SandboxStage` and add a `_rule_matches` case, (3) add a target-capture branch if it has a
|
||||
world target. UI auto-generates from the registry.
|
||||
- **New action property** = add a param entry + a widget; the editor must be taught the widget.
|
||||
Unknown keys on load/edit are **preserved** (round-tripped), never dropped.
|
||||
|
||||
---
|
||||
|
||||
## 7. File-by-File Change List
|
||||
|
||||
### 7.1 `scripts/stickman_rig.gd` (modified — additive only)
|
||||
|
||||
- `func move_action(from: int, to: int) -> void` — bounds-check both (`push_warning` + return on
|
||||
out-of-range), remove-at + insert-at (reusing `remove_action`/`insert_action` semantics but
|
||||
emitting `queue_changed` **once**). Used by reorder up/down.
|
||||
- `func replace_action(index: int, action: Dictionary) -> void` — bounds-check, `action_queue[index]
|
||||
= action`, emit `queue_changed`. Used by edit (speak/wait/walk-retarget/ragdoll/recover).
|
||||
|
||||
No runner-phase changes; `get_queue()`, `clear_queue()`, `insert_action`, `remove_action`,
|
||||
`enqueue_reactive`, `clear_reactive_actions` are reused as-is.
|
||||
|
||||
### 7.2 `scripts/stage_director_visuals.gd` (modified — additive)
|
||||
|
||||
- `func _collect_waypoint_refs() -> Array[Dictionary]` — mirror `_collect_waypoints` exactly but
|
||||
return `{ "rig": StickmanRig, "index": int, "pos": Vector2 }` (index = queue index of the
|
||||
`walk_to` action). Same world-child iteration order.
|
||||
- `func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary` — nearest ref within
|
||||
`WAYPOINT_HIT_RADIUS_PX / _zoom()`; returns `{}` on miss. Keeps `hit_test_waypoint()` (still
|
||||
used by the rule builder).
|
||||
- `var edit_waypoint: Vector2 = Vector2.INF` + `func set_edit_waypoint(pos: Vector2) -> void`
|
||||
(`mark_dirty()`). While finite, `_draw_waypoint` draws that dot with an accent outline/ring
|
||||
(blinking is optional — a static accent + thicker ring is sufficient for the acceptance test;
|
||||
a `_process`-driven blink is deferred). Cleared by the stage when edit-capture ends.
|
||||
|
||||
### 7.3 `scripts/sandbox_stage.gd` (modified — the controller)
|
||||
|
||||
New consts:
|
||||
```gdscript
|
||||
const ACT_EDIT_QUEUE := 7 # item ids added to _action_popup (after ACT_WHEN=5 / RULE_ACTION_DONE=6)
|
||||
const ACT_EDIT_RULES := 8
|
||||
const QUEUE_ADD_WALK := ACT_WALK # reuse 0..4 for the add-action popup
|
||||
# ... (ACT_SPEAK/WAIT/RAGDOLL/RECOVER reused)
|
||||
const WP_EDIT_WALK := 0
|
||||
const WP_DELETE_WALK := 1
|
||||
const WP_INSERT_BEFORE := 2
|
||||
const WP_INSERT_AFTER := 3
|
||||
const WP_EDIT_RULES := 4
|
||||
const RIG_EDIT_QUEUE := 0
|
||||
const RIG_EDIT_RULES := 1
|
||||
```
|
||||
|
||||
New state:
|
||||
```gdscript
|
||||
var _pending_walk_edit_index: int = -1 # >=0 while retargeting an existing walk_to
|
||||
var _editing_action_index: int = -1 # queue index being edited via speak/wait dialog
|
||||
var _queue_add_context: StickmanRig = null # target rig for "Add Action" from the queue panel
|
||||
var _waypoint_context: Dictionary = {} # {rig, index} for the open waypoint context menu
|
||||
var _queue_panel: QueuePanel = null
|
||||
var _rule_panel: RulePanel = null
|
||||
var _rule_editor: RuleEditor = null
|
||||
var _rig_context_popup: PopupMenu = null
|
||||
var _waypoint_context_popup: PopupMenu = null
|
||||
var _queue_add_popup: PopupMenu = null
|
||||
var _confirm_dialog: ConfirmationDialog = null # shared, repurposed per action (with a pending closure)
|
||||
var _confirm_action: Callable = Callable() # what to run on confirm
|
||||
```
|
||||
|
||||
New signal wiring in `_build_ui()`:
|
||||
- Add `_rig_context_popup` (`PopupMenu`: "📋 Edit Queue…", "📋 Edit Rules…"), `_waypoint_context_popup`
|
||||
(`PopupMenu`: "✎ Edit this Walk", "✕ Delete this Walk", "⬆ Insert action before",
|
||||
"⬇ Insert action after", "⚡ Edit Trigger Rules (N)"), `_queue_add_popup` (`PopupMenu`: the 5
|
||||
action types), and a shared `_confirmation_dialog`. Apply `_apply_popup_theme` to the popups.
|
||||
- Append `_action_popup.add_separator()` + `add_item("📋 Edit Queue…", ACT_EDIT_QUEUE)` +
|
||||
`add_item("📋 Edit Rules…", ACT_EDIT_RULES)`.
|
||||
- Instantiate `_queue_panel`, `_rule_panel`, `_rule_editor` (code-built; add to the `ui`
|
||||
CanvasLayer) and connect their signals (below). Apply `_ui_font`/`_emoji_font` via their
|
||||
`apply_font` methods.
|
||||
|
||||
Handlers (new/changed):
|
||||
- `_handle_world_click` right-click branch (before placement-cancel): when `_rule_step == IDLE`
|
||||
and mode is EDIT or DIRECT, hit-test waypoint → `_waypoint_context_popup`; else `_selection.hit_test`
|
||||
a `StickmanRig` → `_rig_context_popup`. Both record `_popup_anchor` from the click's screen rect.
|
||||
- `_on_action_popup_id_pressed`: new `ACT_EDIT_QUEUE`/`ACT_EDIT_RULES` cases → open panels for
|
||||
`_context_rig`.
|
||||
- Queue-panel signal handlers: `_queue_edit(index)`, `_queue_delete(index)` (confirm),
|
||||
`_queue_move(index, dir)`, `_queue_add()`, `_queue_clear()` (confirm).
|
||||
- Rule-panel signal handlers: `_rule_edit(id)`, `_rule_delete(id)` (confirm), `_rule_move(index,
|
||||
dir)`, `_rule_add()`, `_rules_clear()` (confirm).
|
||||
- Waypoint-context handlers: `_waypoint_edit_walk()`, `_waypoint_delete_walk()`,
|
||||
`_waypoint_insert(before: bool)`, `_waypoint_edit_rules()`.
|
||||
- Rig-context handlers: `_rig_edit_queue()`, `_rig_edit_rules()`.
|
||||
- `_pending_walk_edit_index` consumed in `_handle_direct_click` (and the EDIT left-click path):
|
||||
when `>= 0`, replace `_context_rig`'s queue action at that index with the new `walk_to` target
|
||||
(via `replace_action`), clear the edit state + `edit_waypoint`, refresh cursor/status/visuals.
|
||||
- `_move_rule(from: int, to: int)` helper (bounds-check, swap, `_director_visuals.set_rules`).
|
||||
- `_open_rule_editor(id, consequence_only: bool)` — populate `RuleEditor` and show; `RuleEditor`
|
||||
drives `_rule_builder`/`_rule_step` through signals back to the stage.
|
||||
|
||||
Esc priority (`_unhandled_key_input`): insert `_rule_editor`/`_queue_panel`/`_rule_panel` close
|
||||
and `_pending_walk_edit_index` cancel into the existing chain (above placement/selection clear).
|
||||
|
||||
---
|
||||
|
||||
## 8. New File Detail
|
||||
|
||||
### 8.1 `scripts/queue_panel.gd` — `QueuePanel extends PopupPanel`
|
||||
|
||||
Signals (all emitted to the stage, which mutates data):
|
||||
```gdscript
|
||||
signal edit_requested(index: int)
|
||||
signal delete_requested(index: int)
|
||||
signal move_requested(index: int, dir: int) # dir: -1 up, +1 down
|
||||
signal add_requested()
|
||||
signal clear_requested()
|
||||
signal closed()
|
||||
```
|
||||
|
||||
API:
|
||||
```gdscript
|
||||
func open_for(rig_name: String, queue: Array[Dictionary]) -> void # store + _rebuild + popup_centered()
|
||||
func refresh(queue: Array[Dictionary]) -> void # re-render after a mutation
|
||||
func apply_font(ui_font: Font, emoji_font: Font) -> void
|
||||
```
|
||||
|
||||
Rows (one per action, in order): order number, `ActionRegistry.row_summary(action)`, then
|
||||
`✎` (hidden for ragdoll/recover), `✕`, `⬆`, `⬇` (disabled at ends). Footer: `[Add Action]`
|
||||
`[Clear All]` `[Close]`. `exclusive = true`; Esc → `closed.emit()`. Rebuilt on every `refresh`.
|
||||
|
||||
### 8.2 `scripts/rule_panel.gd` — `RulePanel extends PopupPanel`
|
||||
|
||||
Signals:
|
||||
```gdscript
|
||||
signal edit_requested(id: int)
|
||||
signal delete_requested(id: int)
|
||||
signal move_requested(index: int, dir: int)
|
||||
signal add_requested()
|
||||
signal clear_requested()
|
||||
signal closed()
|
||||
```
|
||||
|
||||
API:
|
||||
```gdscript
|
||||
func open_for(source_name: String, rules: Array[Dictionary]) -> void
|
||||
func refresh(rules: Array[Dictionary]) -> void
|
||||
func apply_font(ui_font: Font, emoji_font: Font) -> void
|
||||
```
|
||||
|
||||
Rows (one per rule): order number + `StageDirectorVisuals.rule_summary(rule)` (or
|
||||
`TriggerRegistry.describe` + action summaries), then `✎`, `✕`, `⬆`, `⬇`. Footer:
|
||||
`[Add Rule]` `[Clear All]` `[Close]`.
|
||||
|
||||
### 8.3 `scripts/rule_editor.gd` — `RuleEditor extends PopupPanel`
|
||||
|
||||
Signals:
|
||||
```gdscript
|
||||
signal trigger_type_changed(type: String)
|
||||
signal trigger_target_requested()
|
||||
signal action_add_requested()
|
||||
signal action_edit_requested(index: int)
|
||||
signal action_remove_requested(index: int)
|
||||
signal done_requested()
|
||||
signal cancelled()
|
||||
```
|
||||
|
||||
API:
|
||||
```gdscript
|
||||
func open_full(trigger: Dictionary, actions: Array[Dictionary], trigger_types: Array[String]) -> void
|
||||
func open_consequence(trigger_summary: String, actions: Array[Dictionary]) -> void
|
||||
func apply_font(ui_font: Font, emoji_font: Font) -> void
|
||||
```
|
||||
|
||||
- **Full mode:** trigger `OptionButton` (registry types) + `[Set target…]` (hidden for
|
||||
`speech_finished`/`action_finished`) + target readout; actions list (summary + `✎` `✕`);
|
||||
`[Add Action]`; `[Cancel]` `[OK]`.
|
||||
- **Consequence-only mode:** read-only trigger line; actions list; `[Add Action]`; `[Done]`
|
||||
`[Cancel]`.
|
||||
|
||||
---
|
||||
|
||||
## 9. UI Flows
|
||||
|
||||
### 9.1 Action Queue Panel
|
||||
Entry: Direct-click stickman → action popup → **"📋 Edit Queue…"**; or right-click stickman →
|
||||
**"Edit Queue…"**. Panel lists the queue (order numbers, type, params). `✎` routes to the
|
||||
existing edit machinery (§9.2); `✕`/Clear All confirm; `⬆`/`⬇` call `move_action`;
|
||||
`[Add Action]` opens `_queue_add_popup` (appends via the same per-type flow, targeting the
|
||||
queue's rig).
|
||||
|
||||
### 9.2 Action editing (reuses existing dialogs)
|
||||
- **walk_to** — `_pending_walk_edit_index = index`; `edit_waypoint` highlights the dot; next
|
||||
stage click → `replace_action(index, {type:"walk_to", target: new_pos})`.
|
||||
- **speak** — pre-fill `_speak_edit.text`/duration; confirm (with `_editing_action_index >= 0`)
|
||||
→ `replace_action(index, {type:"speak", text, duration})`.
|
||||
- **wait** — pre-fill `_wait_spin.value`; confirm → `replace_action`.
|
||||
- **ragdoll/recover** — no `✎`; nothing to edit.
|
||||
|
||||
`_on_speak_confirmed`/`_on_wait_confirmed` gain an `_editing_action_index >= 0` branch
|
||||
(prefixed above the existing `_rule_step == PARAMS` branch).
|
||||
|
||||
### 9.3 Waypoint context menu (EDIT/DIRECT, right-click on a dot)
|
||||
Items: `✎ Edit this Walk`, `✕ Delete this Walk`, `⬆ Insert action before`, `⬇ Insert action
|
||||
after`, `⚡ Edit Trigger Rules (N)` (N = count of rules referencing that waypoint position; hidden
|
||||
when 0). Edit → §9.2 walk flow; delete → `remove_action(index)`; insert → `_queue_add_popup` then
|
||||
`insert_action(index | index+1, action)`; Edit Trigger Rules → `RulePanel` filtered by
|
||||
`params.waypoint_pos` proximity.
|
||||
|
||||
### 9.4 Rule Panel
|
||||
Entry: right-click stickman → "Edit Rules…" (filter `trigger.source == rig id`); or action popup
|
||||
→ "Edit Rules…". Rows show `rule_summary`; `✎` → full `RuleEditor`; `✕`/Clear All confirm;
|
||||
`⬆`/`⬇` → `_move_rule`; `[Add Rule]` → fresh build (`ACT_WHEN` flow, source = panel rig).
|
||||
|
||||
### 9.5 Rule Editor
|
||||
Full (`✎` in panel): trigger dropdown + target button + actions list (`✎`/`✕`) + `[Add Action]` +
|
||||
`[OK]`. Consequence-only (rule-label click on stage): trigger read-only + actions list +
|
||||
`[Add Action]` + `[Done]`. `OK`/`Done` → `_finalize_rule()` (preserves `id`); `Cancel` →
|
||||
`_cancel_rule_build()`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Entry-Point Integration (existing code hooks)
|
||||
|
||||
- **Action popup items** appended in `_build_ui` after the `ACT_WHEN` separator
|
||||
(`_action_popup` currently ends at `ACT_WHEN`).
|
||||
- **Right-click routing** in `_handle_world_click` (RMB branch) gains waypoint → stickman hit
|
||||
tests **before** the EDIT placement-cancel, gated on `_rule_step == IDLE` and mode != PLAY.
|
||||
- **Rule-label click** (`_handle_world_click` LMB path already calls `_begin_edit_rule`) now
|
||||
routes to `_open_rule_editor(id, true)` instead of the bare add-action popup.
|
||||
- **`_clear_director_pending`** also resets `_pending_walk_edit_index`, `_editing_action_index`,
|
||||
`edit_waypoint`, and hides the new panels/popups (mode exit must cancel edit flows).
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance Criteria (corrected from plan §11)
|
||||
|
||||
### 11.1 Action Queue Panel
|
||||
- "Edit Queue…" opens the panel from the action popup and from stickman right-click.
|
||||
- Panel lists every action in queue order with an order number + type + parameter summary.
|
||||
- `✕` deletes an action after a confirmation dialog.
|
||||
- `✎` re-opens the action editor with values pre-filled (hidden for ragdoll/recover).
|
||||
- `⬆`/`⬇` move an action (order numbers and waypoint connectors update; first/last disabled).
|
||||
- "Add Action" appends via the add-action popup.
|
||||
- "Clear All" removes all actions after a confirmation dialog.
|
||||
|
||||
### 11.2 Edit Action
|
||||
- walk_to: edit enters target-capture mode; clicking a new spot moves the waypoint (dot +
|
||||
dashed connector update; order numbers unchanged).
|
||||
- speak: text + duration pre-filled; confirm updates the action + badge summary.
|
||||
- wait: duration pre-filled; confirm updates.
|
||||
- ragdoll/recover: no edit (param-less); `✎` hidden.
|
||||
|
||||
### 11.3 Waypoint Context Menu
|
||||
- Right-click a waypoint dot opens the context menu (EDIT and DIRECT).
|
||||
- "Edit this Walk" enters target placement (highlighted dot) and moves the waypoint.
|
||||
- "Delete this Walk" removes the walk action (immediate).
|
||||
- "Insert action before/after" inserts at `index` / `index + 1` (connectors reconnect, order
|
||||
numbers renumber).
|
||||
|
||||
### 11.4 Rule Panel
|
||||
- "Edit Rules…" opens the panel filtered to rules whose `trigger.source` is the stickman.
|
||||
- Each rule shows a summary (trigger verb + action summaries) + order number.
|
||||
- `✕` deletes after confirmation; `⬆`/`⬇` reorders; "Add Rule" starts the rule builder;
|
||||
"Clear All" confirms and clears.
|
||||
|
||||
### 11.5 Rule Editor
|
||||
- Full: trigger type dropdown works; trigger target can be re-clicked; action list supports
|
||||
add/remove; parameters (text/duration/walk target) editable; `OK` updates the rule preserving
|
||||
`id`.
|
||||
- Consequence-only: trigger read-only; actions editable; `Done` updates the rule preserving `id`.
|
||||
- Visual connectors update immediately on save.
|
||||
|
||||
### 11.6 Visual Updates
|
||||
- Waypoint dots move on walk edit; speech badge text + wait duration reflect edits; dashed
|
||||
connectors re-render after insert/delete/reorder; rule labels refresh; rule connectors
|
||||
re-target on trigger/action target edits.
|
||||
|
||||
### 11.7 Backward Compatibility
|
||||
- Existing queues/rules load and display unchanged.
|
||||
- Editing preserves action types and unknown keys; editing a rule preserves `id`.
|
||||
- Deleting a rule/action leaves no dangling references (`_cleanup_rules_for_nodes` on node delete
|
||||
already covers object deletion; queue/rule edits only mutate their own data).
|
||||
|
||||
### 11.8 Extensibility (corrected)
|
||||
- New action **UI** requires only a registry entry; new action **runtime** still needs a
|
||||
`_begin_action` case (documented in §6).
|
||||
- New trigger **UI** requires only a registry entry; new trigger **runtime** needs an event
|
||||
emission + `_rule_matches` case.
|
||||
- Unknown action keys are preserved on edit (not dropped); no generic unknown-key widget.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Order (matches the 5 sub-phases)
|
||||
|
||||
1. **3c.1 Foundation** — `action_registry.gd` + `trigger_registry.gd`; `StickmanRig.move_action`
|
||||
/ `replace_action`; `StageDirectorVisuals._collect_waypoint_refs` / `hit_test_waypoint_action` /
|
||||
`edit_waypoint`. (No behavior change yet — existing popups may optionally read labels/icons
|
||||
from the registry, but the plan allows keeping the hard-coded popup strings until 3c.2+.)
|
||||
2. **3c.2 Action Queue Panel** — `queue_panel.gd`; `_action_popup` items; `_rig_context_popup`;
|
||||
queue edit/delete/move/add/clear handlers; `_confirmation_dialog`.
|
||||
3. **3c.3 Action Visual Editing** — `_waypoint_context_popup`; `_pending_walk_edit_index` +
|
||||
`edit_waypoint` highlight; insert before/after; edit-walk target capture.
|
||||
4. **3c.4 Rule Panel** — `rule_panel.gd`; `rule_editor.gd` (full + consequence-only);
|
||||
`_move_rule`; rewire `_begin_edit_rule` → consequence-only editor; rule add/delete/clear.
|
||||
5. **3c.5 Rule Visual Editing** — waypoint "Edit Trigger Rules (N)" filter; waypoint→rule
|
||||
proximity matching; final Esc-priority / `_clear_director_pending` cleanup.
|
||||
|
||||
Each sub-phase is independently testable via the headless `SceneTree` suite pattern
|
||||
(`tests/test_phase4b_*.gd`: instantiate `sandbox_stage.tscn`, drive handlers directly, assert on
|
||||
`_event_rules` / `rig.get_queue()` / popup visibility).
|
||||
|
||||
---
|
||||
|
||||
## 13. Tech-Debt / Deferred
|
||||
|
||||
- **Drag reorder** — up/down buttons chosen over `[≡]` drag (decision 3). Revisit if a
|
||||
touch/pointer drag reorder is wanted.
|
||||
- **Generic param-field auto-generation** — `ActionRegistry.get_params` describes params, but
|
||||
widgets (LineEdit/SpinBox/position click) remain hand-built per type. A registry→widget factory
|
||||
is future work.
|
||||
- **`action_finished` type filter** — the full editor does not expose `params.action_type`;
|
||||
the trigger remains "completes any action" (§3.6).
|
||||
- **`StageDirectorVisuals.rule_summary`/`_action_desc` duplication** — the registry's `describe`
|
||||
now duplicates `rule_summary`'s summary logic; a follow-up can make `rule_summary` delegate to
|
||||
the registry to remove the copy.
|
||||
- **Asymmetric delete confirmation** — panel deletes confirm; on-stage rule `✕` and waypoint
|
||||
"Delete this Walk" stay immediate (decision 9).
|
||||
@@ -0,0 +1,329 @@
|
||||
# Phase 4.5a — Per-Part Collision Bodies, Physics-Based Prop Events, and Reaction System
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This phase gives the ANIMATED stickman a physical presence and makes stickman-prop interaction feel real. Three outcomes:
|
||||
|
||||
1. **Props bounce off the animated stickman** — a set of per-part kinematic collision bodies so props physically collide with the figure instead of passing through it.
|
||||
2. **Collision events fire from real physics contact** — replace the feet-point geometric test with the contact signal from the prop's `RigidBody2D`, so a `collided` trigger fires whenever a prop visibly touches the stickman.
|
||||
3. **The stickman visibly reacts to hits** — when a prop hits a limb, that limb briefly flinches away from the impact, then springs back to its animation.
|
||||
|
||||
The impact direction comes from the **physics contact normal** reported by `body_entered` on the prop side, not from the prop's velocity. This is the physically accurate direction the prop is pushing the stickman.
|
||||
|
||||
**Not in scope:** `explode_prop`, `remove_prop`, `spawn_prop`, `prop_impulse`, or any prop-targeted actions. Those belong to a future phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
| In Scope | Out of Scope |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| 10 `AnimatableBody2D` + `CollisionShape2D` per stickman (one per body part) | Prop-targeted actions |
|
||||
| Physics-based `collided` events | Ragdoll-prop reaction (ragdoll bodies handle physics, no rule fires during ragdoll) |
|
||||
| Collision normal as impact direction | Continuous contact debouncing |
|
||||
| Transient bone reaction (flinch-and-recover) | Precise polygon geometry (capsules and circles only) |
|
||||
| RECOVERING keeps collision bodies enabled | Limb animation-driven collision shape resizing |
|
||||
| LEFT mirror tolerance (option A) | Reaction for ragdoll or recovering state |
|
||||
|
||||
---
|
||||
|
||||
## 3. Changes to `StickmanRig`
|
||||
|
||||
### 3.1 Build collision bodies
|
||||
|
||||
In `_ready()`, after resolving the `Body/*` part nodes, create one `AnimatableBody2D` + `CollisionShape2D` per visual part, parented to the `Body/*` node. The `Body/*` node is already driven by its `RemoteTransform2D`, so each collision body follows the visual automatically.
|
||||
|
||||
```
|
||||
Body/LeftUpperArm (Node2D, driven by RemoteTransform2D)
|
||||
├── (visual: Line2D or Polygon2D mounted by the adapter)
|
||||
└── CollisionBody (AnimatableBody2D, collision_layer = 1, collision_mask = 1)
|
||||
└── CollisionShape2D (CapsuleShape2D)
|
||||
```
|
||||
|
||||
Name the collision body `"CollisionBody"` for consistency with `test_harness` conventions.
|
||||
|
||||
**Metadata:** On creation, call `body.set_meta("stickman_rig", self)` and `body.set_meta("part_name", "<part_key>")` (e.g. `"left_upper_arm"`). The stage uses these to resolve which rig was hit and which part.
|
||||
|
||||
Store the 10 collision bodies in a dictionary keyed by part name: `_collision_bodies: Dictionary[String, AnimatableBody2D]`.
|
||||
|
||||
### 3.2 Shape sizing
|
||||
|
||||
Each collision shape sits in `Body/*` local coordinates, matching the "hanging convention" the adapter uses (joint at local origin, far end along local +Y).
|
||||
|
||||
| Part key | Shape | Height | Radius | Local position |
|
||||
| ----------------------------------- | ---------------- | ------------------ | -------------------------------- | -------------------------------------------------------- |
|
||||
| `head` | `CircleShape2D` | — | `HEAD_COLLISION_RADIUS := 90.0` | `(0, 0)` — head visual is already centered on its origin |
|
||||
| `torso` | `CapsuleShape2D` | `torso_length` | `TORSO_COLLISION_RADIUS := 20.0` | `(0, torso_length / 2)` |
|
||||
| `left_upper_arm`, `right_upper_arm` | `CapsuleShape2D` | `upper_arm_length` | `ARM_COLLISION_RADIUS := 12.0` | `(0, upper_arm_length / 2)` |
|
||||
| `left_lower_arm`, `right_lower_arm` | `CapsuleShape2D` | `lower_arm_length` | `ARM_COLLISION_RADIUS` | `(0, lower_arm_length / 2)` |
|
||||
| `left_upper_leg`, `right_upper_leg` | `CapsuleShape2D` | `upper_leg_length` | `LEG_COLLISION_RADIUS := 14.0` | `(0, upper_leg_length / 2)` |
|
||||
| `left_lower_leg`, `right_lower_leg` | `CapsuleShape2D` | `lower_leg_length` | `LEG_COLLISION_RADIUS` | `(0, lower_leg_length / 2)` |
|
||||
|
||||
**Bone lengths** come from the fitted rig (already applied by `StkRigAdapter`). Store them as `_collision_lengths: Dictionary[String, float]` in `_ready()` by reading each `Bone2D`'s `length` property. Constants for radii and the head radius live at the top of the script alongside the existing constants.
|
||||
|
||||
**Head sizing note:** the head visual is a full geometry (circle, rectangle, etc.), not a bone. A fixed `CircleShape2D` of radius 90 (matching the pose-guide circle) is a reasonable approximation. Future refinement: read the mounted head's local bbox.
|
||||
|
||||
**LEFT mirror (option A):** the parent `Master.scale.x = -1` applies to the collision shapes. Since capsules and circles are symmetric, mirroring has no visual effect. Godot may emit a warning about non-uniform scale on `CollisionShape2D` — this is expected and harmless. Do not attempt to counter-mirror.
|
||||
|
||||
### 3.3 State management
|
||||
|
||||
| State | Collision bodies |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
| `ANIMATED` | Enabled (`collision_layer = 1`, `collision_mask = 1`) |
|
||||
| `RECOVERING` | Enabled — props continue to bounce off the recovering figure |
|
||||
| `RAGDOLL` | Disabled (`collision_layer = 0`, `collision_mask = 0`) — ragdoll bodies take over |
|
||||
|
||||
Implement a helper `_set_collision_bodies_enabled(enabled: bool)` that iterates `_collision_bodies` and toggles both `collision_layer` and `collision_mask`.
|
||||
|
||||
Call `_set_collision_bodies_enabled(false)` at the start of `_enter_ragdoll()`.
|
||||
Call `_set_collision_bodies_enabled(true)` at the end of `_on_stand_up_finished()`.
|
||||
Do **not** toggle during `RECOVERING` — the bodies stay enabled.
|
||||
|
||||
**Contact settling (optional):** if recovering stickmen re-enable bodies that contain resting props and the resulting `body_entered` spam feels wrong, add a `_contact_settling_timer` of 0.2 s during which reaction dispatches are suppressed but physics contacts still resolve. Start without this; add only if it's needed.
|
||||
|
||||
### 3.4 Reaction system
|
||||
|
||||
The rig maintains a per-joint transient reaction offset applied additively after the IK stack solves each frame.
|
||||
|
||||
**State:**
|
||||
\_impact_reactions: Dictionary = {}
|
||||
|
||||
```
|
||||
key: joint name (e.g. "LeftUpperArm", "LeftLowerArm", "Head", "Torso")
|
||||
value: { "angle": float, "strength": float, "decay": float }
|
||||
```
|
||||
|
||||
**Public entry point:**
|
||||
|
||||
```
|
||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float) -> void
|
||||
```
|
||||
|
||||
The method:
|
||||
|
||||
1. Maps the hit part to affected joints (see §6.1).
|
||||
2. Converts `impact_direction` and `impact_speed` into an angle offset per joint (see §6.2).
|
||||
3. Merges into `_impact_reactions` (if an entry exists, take the stronger angle and reset strength to 1.0).
|
||||
|
||||
**Apply pass:** call `_apply_impact_reactions(delta)` from `_physics_process()` **after** `_pin_mirrored_head_rotation()` (matching the existing pattern that runs after the IK stack). For each entry:
|
||||
|
||||
```
|
||||
joint_bone.rotation += reaction["angle"] * reaction["strength"]
|
||||
reaction["strength"] -= delta / reaction["decay"]
|
||||
if reaction["strength"] <= 0.0:
|
||||
_impact_reactions.erase(joint)
|
||||
```
|
||||
|
||||
**Timing note:** the codebase already relies on `_pin_mirrored_head_rotation()` being called from `_physics_process` _after_ the modification stack solves. If for any reason reaction offsets don't show (i.e. they're being overwritten before render), the fallback is to move both the pin and the reaction pass into a `_process_internal` override on the rig. Do not change the existing pin behavior when adding the reaction pass — match it.
|
||||
|
||||
**Interaction with ragdoll:** clear `_impact_reactions` at the start of `_enter_ragdoll()`. Do not apply reactions during `RAGDOLL` or during the `RECOVERING` tween.
|
||||
|
||||
---
|
||||
|
||||
## 4. Changes to `PropBlock`
|
||||
|
||||
### 4.1 Extended `collided` signal
|
||||
|
||||
Change the existing signal signature:
|
||||
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
||||
|
||||
text
|
||||
|
||||
Existing listeners must be updated (the stage is the only one).
|
||||
|
||||
### 4.2 Contact normal extraction
|
||||
|
||||
In `_on_body_entered(body)`:
|
||||
|
||||
1. Query the body state: `var state := PhysicsServer2D.body_get_direct_state(get_rid())`.
|
||||
2. If the state is valid, iterate `state.get_contact_count()` and find the contact whose collider matches `body`.
|
||||
3. Read `state.get_contact_local_normal(i)` and `state.get_contact_local_position(i)` — these are the contact normal and position in the prop's local frame.
|
||||
4. Convert to world space via `global_transform` before emitting.
|
||||
5. If no matching contact is found (timing edge case), fall back to:
|
||||
- `contact_normal` = `(body.global_position - global_position).normalized()` (rough approximation)
|
||||
- `contact_position` = `global_position`
|
||||
|
||||
Emit `collided.emit(body, contact_normal, contact_position)`.
|
||||
|
||||
**Why not `body_entered` alone:** the `body_entered` signal gives the colliding body but not the contact normal. `PhysicsServer2D.body_get_direct_state()` exposes the contact list at the moment the signal fires, which is when we need it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Changes to `SandboxStage`
|
||||
|
||||
### 5.1 Remove the geometric feet-point test
|
||||
|
||||
Delete `_update_stickman_prop_collision()` and its call from `_physics_process()`. The physics-based path fully replaces it.
|
||||
|
||||
### 5.2 Event routing
|
||||
|
||||
When a stickman is placed (`_place_at()` → after spawning), connect its rig's `PropBlock`-related contacts. Since props are spawned independently and can collide with any stickman, the cleaner approach is:
|
||||
|
||||
- On `object_placed(node)`, if `node is StickmanRig`, iterate all existing `PropBlock` children of `World` and connect their `collided` signal to `_on_prop_collided(stickman_rig, prop, other, normal, position)` (via `.bind()`).
|
||||
- When a `PropBlock` is placed, iterate all existing `StickmanRig` children of `World` and connect the prop's `collided` to the same handler for each rig.
|
||||
|
||||
Alternatively, use a global registry: `PropBlock.collided` is a signal on the prop, and the stage listens to _all_ props. But the prop doesn't know about stickmen — the stage needs to dispatch based on `other`.
|
||||
|
||||
Simplest implementation:
|
||||
|
||||
- The stage maintains `_prop_blocks: Array[PropBlock]` (populated on place, cleaned on delete).
|
||||
- Every `PropBlock.collided(other, normal, pos)` fires → stage handler `_on_prop_collided(prop, other, normal, pos)`.
|
||||
- The handler resolves `other`:
|
||||
- If `other.has_meta("stickman_rig")` → `var rig = other.get_meta("stickman_rig")`, `var part = other.get_meta("part_name")`.
|
||||
- Otherwise → the collision is prop-vs-prop; **keep the existing prop-vs-prop behavior** (whatever the current system does with `collided` — probably a rule trigger with `collided` trigger type where the source and target are both props). Do not regress this path.
|
||||
|
||||
### 5.3 Stickman-prop event dispatch
|
||||
|
||||
When the handler resolves a stickman:
|
||||
|
||||
1. Compute a stable pair key: `"%d_%d" % [rig.get_instance_id(), prop.get_instance_id()]`.
|
||||
2. Check `_stickman_prop_collisions: Dictionary` (edge-trigger state). If the key is already present, do nothing (the pair is already "touching").
|
||||
3. Otherwise, mark the key, then fire the `collided` trigger for any rule whose `trigger.type == "collided"` and whose `trigger.source`/`trigger.target` match `(rig, prop)` or `(prop, rig)` (either order).
|
||||
4. Clear the key when the prop separates. Use `PropBlock.body_exited` if the prop has it — add a `body_exited` signal mirroring `body_entered` — OR poll the contact count each frame. Cleaner: extend `PropBlock` with `signal separated(other: Node)` fired from `body_exited`.
|
||||
5. **Also** dispatch the reaction: `rig.apply_collision_reaction(part, normal, prop.linear_velocity.length())`.
|
||||
|
||||
### 5.4 Reaction dispatch
|
||||
|
||||
The `apply_collision_reaction` call is on the stickman, not the prop. Pass:
|
||||
|
||||
- `part_name` (from metadata on the collision body)
|
||||
- `impact_direction` = `contact_normal` (world-space, from the prop's body state)
|
||||
- `impact_speed` = `prop.linear_velocity.length()` (used as a magnitude scale)
|
||||
|
||||
The rig maps these to bone offsets (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 6. Reaction Details
|
||||
|
||||
### 6.1 Part → affected joints
|
||||
|
||||
| Hit part | Primary joint | Secondary joint |
|
||||
| ----------------- | -------------------------- | ------------------------------- |
|
||||
| `head` | `Head` (bone) | — |
|
||||
| `torso` | `Torso` (bone, small tilt) | — |
|
||||
| `left_upper_arm` | `LeftUpperArm` | — |
|
||||
| `left_lower_arm` | `LeftLowerArm` | `LeftUpperArm` (half strength) |
|
||||
| `right_upper_arm` | `RightUpperArm` | — |
|
||||
| `right_lower_arm` | `RightLowerArm` | `RightUpperArm` (half strength) |
|
||||
| `left_upper_leg` | `LeftUpperLeg` | — |
|
||||
| `left_lower_leg` | `LeftLowerLeg` | `LeftUpperLeg` (half strength) |
|
||||
| `right_upper_leg` | `RightUpperLeg` | — |
|
||||
| `right_lower_leg` | `RightLowerLeg` | `RightUpperLeg` (half strength) |
|
||||
|
||||
The secondary joint gets the same angle offset but a **weaker magnitude** (e.g. 0.4×) and a **shorter decay** (e.g. 0.25 s vs 0.4 s).
|
||||
|
||||
### 6.2 Impact direction → angle offset
|
||||
|
||||
The contact normal points from the prop toward the stickman's surface (or vice versa — sign depends on Godot's convention, verify during implementation).
|
||||
|
||||
Compute the angle offset per joint as a function of the impact normal in the joint's local frame:
|
||||
|
||||
1. Convert the world-space impact direction into the joint bone's local frame: `local_dir = bone.global_transform.basis_xform_inv(normal)`.
|
||||
2. The rotation direction is determined by `sign(local_dir.x)` — the sign of the direction the prop is pushing in the limb's local X.
|
||||
3. `angle_offset = REACTION_BASE_ANGLE * sign(local_dir.x) * (impact_speed / REACTION_REFERENCE_SPEED)` clamped to a max.
|
||||
|
||||
Constants:
|
||||
|
||||
- `REACTION_BASE_ANGLE := 0.35` (radians, ~20°) — the angle offset for a reference-speed impact.
|
||||
- `REACTION_REFERENCE_SPEED := 400.0` (px/s) — the impact speed that produces the base angle.
|
||||
- `REACTION_MAX_ANGLE := 0.6` (radians, ~34°) — cap.
|
||||
|
||||
If `local_dir.x` is near zero (a head-on impact along the bone axis), pick a default direction based on the joint side (left limbs push left, right limbs push right).
|
||||
|
||||
### 6.3 Decay tuning
|
||||
|
||||
| Joint | Decay (s) |
|
||||
| -------------------- | --------- |
|
||||
| Head | 0.5 |
|
||||
| Torso | 0.4 |
|
||||
| Upper limbs | 0.4 |
|
||||
| Lower limbs | 0.35 |
|
||||
| Secondary (adjacent) | 0.25 |
|
||||
|
||||
Decay is the time for `strength` to fall from 1.0 to 0.0. Shorter = snappier recovery.
|
||||
|
||||
### 6.4 What the reaction _is not_
|
||||
|
||||
- Not a physics force — it doesn't move the rig root, doesn't push the prop.
|
||||
- Not persistent — it fully decays.
|
||||
- Not additive over multiple impacts beyond `max(angle)` — a new impact replaces the existing angle if stronger, else takes the max and resets `strength = 1.0`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prop rests on stickman's head | `body_entered` fires once; head recoils briefly; prop rolls off |
|
||||
| Prop hits two stickman parts in the same frame | Each contact fires separately; the edge-trigger dict dedupes to one `collided` rule fire per `(stickman, prop)` pair; reaction applies to both hit parts |
|
||||
| Walking stickman shoves a resting prop | `AnimatableBody2D` pushes the prop; contact fires once; both react |
|
||||
| Prop hits stickman during RECOVERING | Physics still bounces (bodies enabled); reaction is skipped (state is not `ANIMATED`) |
|
||||
| Prop is deleted | Existing cleanup runs; the edge-trigger entry is orphaned but harmless (key includes the prop's instance ID, which won't recur) |
|
||||
| Stickman is deleted | All 10 collision bodies freed with the rig; no orphaned listeners |
|
||||
| Recovering stickman re-enables bodies over a resting prop | `body_entered` fires — edge-trigger dedupes; optional settling timer if this feels wrong |
|
||||
| Left-facing stickman (`Master.scale.x = -1`) | Capsules and circles are symmetric; mirroring is a no-op visually; Godot may warn about non-uniform scale — tolerated |
|
||||
| Ragdoll | All 10 bodies disabled; ragdoll bodies handle physics; no reaction (reactions are an ANIMATED-only feature) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria
|
||||
|
||||
### 8.1 Physical bounce
|
||||
|
||||
- [ ] A prop thrown at an ANIMATED stickman **bounces off** and lands on the ground.
|
||||
- [ ] A prop thrown at the stickman's **arm, leg, torso, and head** individually bounces off each part.
|
||||
- [ ] A prop resting on the ground next to an ANIMATED stickman stays put.
|
||||
- [ ] A walking stickman **shoves** a resting prop out of the way.
|
||||
- [ ] In RAGDOLL mode, props no longer collide with the collision bodies; ragdoll bodies handle physics.
|
||||
- [ ] In RECOVERING mode, props still bounce off the recovering figure.
|
||||
|
||||
### 8.2 Collision event
|
||||
|
||||
- [ ] A prop visibly touching any part of the stickman fires the `collided` trigger.
|
||||
- [ ] The rule `When [Stickman A] collides with [Prop X] → [Action]` fires once per contact.
|
||||
- [ ] A prop bouncing off and re-colliding fires again.
|
||||
- [ ] Prop-vs-prop collisions still fire the `collided` trigger through the existing path.
|
||||
- [ ] No console errors on any collision.
|
||||
|
||||
### 8.3 Reaction
|
||||
|
||||
- [ ] A prop hitting the stickman's **lower arm** makes that arm flinch away from the impact and recover within ~0.4 s.
|
||||
- [ ] A prop hitting the **head** makes the head recoil and recover.
|
||||
- [ ] The flinch direction is **away from the impact** (matches the contact normal).
|
||||
- [ ] A gentle prop (slow speed) produces a **small** flinch; a fast prop produces a **large** flinch (capped at `REACTION_MAX_ANGLE`).
|
||||
- [ ] The walk animation continues uninterrupted — the limb is momentarily offset, then catches up.
|
||||
- [ ] Multiple hits in quick succession don't accumulate beyond the cap.
|
||||
- [ ] Reactions do **not** fire during `RAGDOLL` or `RECOVERING`.
|
||||
|
||||
### 8.4 Regression
|
||||
|
||||
- [ ] Existing rules (`arrived_at_waypoint`, `entered_area`, `action_finished`, `speech_finished`) still work.
|
||||
- [ ] Sequential action queues still run.
|
||||
- [ ] Ragdoll entry/exit and stand-up recovery still work.
|
||||
- [ ] No `.stk` format change.
|
||||
- [ ] No new warnings during normal play.
|
||||
- [ ] Performance remains acceptable with 5 stickmen and 10 props.
|
||||
|
||||
---
|
||||
|
||||
## 9. Files Changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scripts/stickman_rig.gd` | Add 10 collision bodies + shapes; add `_set_collision_bodies_enabled()`; add `apply_collision_reaction()`; add `_apply_impact_reactions()`; hook the state toggles in `_enter_ragdoll()` / `_on_stand_up_finished()`. |
|
||||
| `scripts/prop_block.gd` | Extend `collided(other)` → `collided(other, normal, position)`; add `separated(other)` signal (from `body_exited`); extract contact normal via `PhysicsServer2D.body_get_direct_state()`. |
|
||||
| `scripts/sandbox_stage.gd` | Remove `_update_stickman_prop_collision()`; add `_on_prop_collided()` and `_on_prop_separated()` handlers; add `_stickman_prop_collisions` edge dict; dispatch rule trigger + `apply_collision_reaction()`. |
|
||||
|
||||
No new scenes. No `.tscn` changes. No `.stk` format change.
|
||||
|
||||
---
|
||||
|
||||
## 10. Notes for the Developer
|
||||
|
||||
- **Timing for reactions:** match the existing `_pin_mirrored_head_rotation()` pattern (called from `_physics_process`). If the offsets don't visibly appear, the fallback is `_process_internal` — but try the existing slot first; do not change the existing head-pin behavior.
|
||||
- **Contact normal sign:** Godot's `get_contact_local_normal(i)` returns the normal in the prop's local frame, pointing toward the collision surface. Verify the direction empirically — a push test with a resting prop should have the stickman limb flinch _away_ from the prop. Invert the sign if it goes the wrong way.
|
||||
- **`AnimatableBody2D` vs `StaticBody2D`:** `AnimatableBody2D` correctly transfers the kinematic body's velocity to RigidBody2D props. `StaticBody2D` does not — a walking stickman would let props clip through. Do not substitute.
|
||||
- **Left-mirror warning:** Godot 4 may emit "Non-uniform scale is not supported on CollisionShape2D" when `Master.scale.x = -1`. This is expected and does not affect correctness. Do not silence or work around it.
|
||||
- **Reaction scope:** reactions are a visual garnish, not a physics force. Keep them additive and decaying. Do not feed them back into the IK solver or the ragdoll.
|
||||
- **Reaction key:** use the rig's bone names (`"LeftUpperArm"`, `"Head"`, etc. — the ones in `BEND_JOINT_BONE_PATHS` and `Z_ORDER_BY_PROFILE`) for consistency.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Phase 4 Bug Fixes — High-Level Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This plan addresses eight defects discovered after Phase 4 shipped. Bug 1 requires a design decision (reactive-action interrupt policy); Bugs 2–8 are diagnosed defects with clear fix directions.
|
||||
|
||||
The plan is a design brief for the architect. Implementation details belong in the resulting spec, not here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bug 1 — Reactive Event Interrupt Policy
|
||||
|
||||
### 2.1. Problem Statement
|
||||
|
||||
A walk is in progress; a rule fires (`When [Stickman] collides with [Prop] → Ragdoll`). The reactive action is appended to the queue tail and only executes after the walk finishes. The user expects immediate response.
|
||||
|
||||
### 2.2. Design Decision — Three Interruption Roles
|
||||
|
||||
Every action — whether in the sequential queue or arriving reactively — fits one of three **roles**:
|
||||
|
||||
| Role | Behavior when triggered reactively |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **Sequential** | Cancel the current action; run this action; resume the queue after |
|
||||
| **Overlay** | Run alongside the current action; the queue is not touched |
|
||||
| **Takeover** | Cancel everything (including overlays); take over the body state |
|
||||
|
||||
### 2.3. Role Assignment Per Action Type
|
||||
|
||||
| Action | Role |
|
||||
| --------- | --------------------------------------------------------------------------------- |
|
||||
| `walk_to` | Sequential |
|
||||
| `wait` | Sequential |
|
||||
| `ragdoll` | Takeover |
|
||||
| `recover` | Takeover |
|
||||
| `speak` | **Overlay by default** — director can promote to Sequential via a per-rule toggle |
|
||||
|
||||
### 2.4. Director-Facing Behavior
|
||||
|
||||
The rule builder shows a single checkbox on **speak** actions only:
|
||||
|
||||
> **"Stop what he's doing?"** (default OFF)
|
||||
|
||||
- **Unchecked (default)** → speak is an Overlay. The stickman keeps walking / waiting / whatever he was doing. The speech plays alongside.
|
||||
- **Checked** → speak becomes Sequential. The stickman stops what he's doing, says his line, then resumes the queue.
|
||||
|
||||
No checkbox on `walk_to`, `wait`, `ragdoll`, or `recover` — their roles are fixed and unambiguous.
|
||||
|
||||
### 2.5. Why This Model
|
||||
|
||||
- **Covers every combination** — walk + speak, speak + walk, walk + ragdoll, speak + speak, wait + walk, etc. Each reduces to a role pairing.
|
||||
- **Kid-friendly** — one checkbox with plain-language wording on the only action where both behaviors are meaningful.
|
||||
- **Chain reactions handle "both at once"** — a director who wants "say 'Ouch!' AND walk away" adds two actions to the rule: overlay speak + sequential walk.
|
||||
- **Forward compatible** — the registry gains an `overlay_eligible` flag on action templates. Today only `speak` is marked overlay-eligible. Future actions like `gesture`, `emote`, or `aim` can be added later without changing the architecture.
|
||||
|
||||
### 2.6. Overlay Conflict Rule
|
||||
|
||||
When two overlay speaks arrive in the same window (e.g., two props hit at once), the new speech **replaces** the old — no queue pileup, one bubble at a time.
|
||||
|
||||
### 2.7. Takeover Cleanup
|
||||
|
||||
`ragdoll` and `recover` (as Takeover actions) explicitly cancel any active overlay speech on entry. The bubble hides and the queue pauses until the takeover state resolves.
|
||||
|
||||
### 2.8. Acceptance Criteria (Bug 1)
|
||||
|
||||
- [ ] `Walk → collides → Ragdoll` — ragdoll fires **on contact**, walk is cancelled
|
||||
- [ ] `Walk → collides → Speak "Ouch!"` (checkbox OFF) — walk continues, speech plays
|
||||
- [ ] `Walk → collides → Speak "Ouch!"` (checkbox ON) — walk stops, speech plays, then queue resumes
|
||||
- [ ] `Speak → collides → Walk` — speak cancels, walk starts
|
||||
- [ ] `Walk → collides → [Speak (overlay), Walk]` — speech and walk fire together
|
||||
- [ ] `Walk → collides → Ragdoll` while a speak overlay is active — speech cancels, ragdoll fires
|
||||
- [ ] Sequential queue resumes after every interrupt
|
||||
- [ ] `clear_reactive_actions()` still strips reactive actions on Edit-mode return
|
||||
|
||||
---
|
||||
|
||||
## 3. Bug 2 — Default Pose Changes on Placement
|
||||
|
||||
**Problem:** The stickman's default pose (both arms up) changes to arms-down after placement.
|
||||
|
||||
**Suspected area:** Placement path differences in IK target state, modification stack enabled timing, or animation player auto-start behavior between the reference rig and the spawned rig.
|
||||
|
||||
**Fix direction:** Ensure a newly placed stickman's pose matches the reference/default pose exactly at spawn time. Diagnose the delta between the reference rig and a spawned rig on the same frame.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bug 3 — Animation Continues After Scene Stop
|
||||
|
||||
**Problem:** When the user switches from PLAY to DIRECT/EDIT mid-flinch, the head visibly tweens back to its resting position instead of snapping.
|
||||
|
||||
**Suspected area:** Reaction-decay tweens are not being cancelled on mode exit.
|
||||
|
||||
**Fix direction:** All in-flight pose tweens (reaction decay, stand-up, etc.) must be cancelled and the affected bones set directly to their captured base rotations on mode exit. No animation should play during state restoration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bug 4 — Props Cannot Be Moved or Rotated
|
||||
|
||||
**Problem:** A placed prop can be selected (rotate gizmo appears), but neither drag nor rotate works.
|
||||
|
||||
**Suspected area:** The edit-mode freeze state (`FREEZE_MODE_KINEMATIC`) may be blocking transform writes, or the drag/rotate input path is not routing to props, or another system is resetting the transform after the drag.
|
||||
|
||||
**Fix direction:** Audit the prop drag/rotate input path end-to-end. Verify the frozen state permits `global_position`/`global_rotation` writes for edit-time manipulation, and that nothing re-sets the transform after commit.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bug 5 — Ghost Pose ≠ Placed Pose
|
||||
|
||||
**Problem:** The placement ghost shows the stickman with bent legs. The placed stickman has straight legs.
|
||||
|
||||
**Suspected area:** Shared root cause with Bug 2 — the ghost is a static render (modification stack disabled), while the placed rig is live and the IK solver drives a different pose.
|
||||
|
||||
**Fix direction:** The placed stickman's pose must match the ghost's pose at spawn. This is likely resolved by fixing Bug 2.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bug 6 — Speech Bubble Mirrors with LEFT Profile
|
||||
|
||||
**Problem:** When facing LEFT (`Master.scale.x = -1`), the speech bubble and its text render mirrored — backwards text.
|
||||
|
||||
**Suspected area:** The speech bubble is a child of the rig root; the whole-rig X-mirror propagates into its `_draw()`.
|
||||
|
||||
**Fix direction:** Isolate the speech bubble from the rig's mirror. Either move it out of the mirrored subtree (world-space child of the stage, following the head bone) or apply a counter-mirror inside its draw. UI must not inherit anatomy mirroring.
|
||||
|
||||
---
|
||||
|
||||
## 8. Bug 7 — Snap Uses Object Size Instead of Grid
|
||||
|
||||
**Problem:** With snap enabled, dragging objects (steps, etc.) snaps at intervals equal to the object's own width/height, not the grid.
|
||||
|
||||
**Suspected area:** The drag-snap function is quantizing to the dragged object's AABB dimensions rather than the stage grid — likely residual block-unit paint stride logic applied incorrectly to the general drag path.
|
||||
|
||||
**Fix direction:** Object drags must snap to the stage grid (`_grid_size`) only. Block-unit stride is exclusive to terrain drag-painting; it must not leak into the general drag path.
|
||||
|
||||
---
|
||||
|
||||
## 9. Bug 8 — Single Terrain Placement Uses Block-Unit Snapping
|
||||
|
||||
**Problem:** Placing a single terrain block (one click) snaps to the block's own width/height intervals even when Snap is OFF.
|
||||
|
||||
**Suspected area:** Same as Bug 7 — the single-placement path reuses the block-unit paint stride instead of the grid or no-snap.
|
||||
|
||||
**Fix direction:** Single terrain placement must follow the standard object snapping rules:
|
||||
|
||||
- Snap ON → snap to grid
|
||||
- Snap OFF → no snapping; place at cursor exactly
|
||||
|
||||
Block-unit stride is for drag-painting only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bug Grouping
|
||||
|
||||
| Group | Bugs | Theme |
|
||||
| ------------------------ | ---- | --------------------------------------------- |
|
||||
| **A — Reactive model** | 1 | Design decision + runner/action role refactor |
|
||||
| **B — Pose consistency** | 2, 5 | Placement pose must match default/ghost |
|
||||
| **C — Animation stop** | 3 | Snap pose on mode exit, no tween |
|
||||
| **D — Prop interaction** | 4 | Restore drag/rotate in Edit |
|
||||
| **E — Mirror isolation** | 6 | UI must not inherit rig mirror |
|
||||
| **F — Snapping source** | 7, 8 | Grid vs. block-unit stride confusion |
|
||||
|
||||
---
|
||||
|
||||
## 11. Suggested Implementation Order
|
||||
|
||||
| Order | Group | Rationale |
|
||||
| ----- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **B (#2, #5)** | Likely one root cause; visible, high-impact on first-use impression |
|
||||
| 2 | **E (#6)** | Localized, low-risk, easy to verify |
|
||||
| 3 | **F (#7, #8)** | Likely one root cause; snapping is a daily-use frustration |
|
||||
| 4 | **D (#4)** | May need investigation; blocks prop interaction |
|
||||
| 5 | **C (#3)** | Interacts with reaction system; do after A's mechanism is understood |
|
||||
| 6 | **A (#1)** | Requires the reactive-role refactor; touches the runner, the registry, the rule builder, and the reaction system |
|
||||
|
||||
Bug 1 is last because it is the largest and touches the most systems. It also benefits from the clean foundation the other fixes restore.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions for the Architect
|
||||
|
||||
1. **Bug 1 — Overlay speak mechanics:** Should the overlay speak still respect the `_speech_active` state, or should the state be generalized to a list of active overlays? (Recommendation: single active overlay; new replaces old.)
|
||||
2. **Bug 1 — Chain reactions with mixed roles:** When a rule contains `[overlay speak, sequential walk]`, the overlay speak should fire first and the sequential walk should interrupt it. Confirm the ordering.
|
||||
3. **Bug 2/5 — Ghost vs. placed rig:** Should the ghost be a live rig (always matches placed) or a static preview (placed rig is initialized to match)? Live ghost is more accurate but heavier.
|
||||
4. **Bug 4 — Prop freeze:** Confirm whether the freeze state is the actual blocker, or whether the input path is broken independently.
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance Summary
|
||||
|
||||
- Every bug has a clear repro, a fix direction, and verifiable acceptance criteria.
|
||||
- Bug 1's design is locked in: three roles (Sequential, Overlay, Takeover), one director-facing checkbox on speak, forward-compatible registry flag.
|
||||
- Bugs 2–8 are diagnosed defects with localized fixes.
|
||||
- Suggested order minimizes risk: local fixes first, cross-cutting refactor last.
|
||||
|
||||
## 14. Final implementation record
|
||||
|
||||
The approved implementation is complete. The runtime behavior is:
|
||||
|
||||
| Area | Final behavior |
|
||||
| --- | --- |
|
||||
| Reactive actions | `StickmanRig` keeps authored actions in the editor queue and runs rule actions in a private lane. Overlay speech runs concurrently in one replaceable slot; Sequential actions cancel the current primary action and continue with the authored future; Takeover actions cancel primary and overlay work before taking control. |
|
||||
| Prop transforms | `SandboxStage` routes the exact mouse event position through `_viewport_to_world()`. `StageGizmos` writes translation/rotation continuously, and frozen `RigidBody2D` props are synchronized through `PhysicsServer2D` during the drag and for three post-release settle frames. |
|
||||
| Grid snapping | Translation snap is based only on the configured grid size. The drag delta is accumulated from the original press, so sub-grid mouse events remain smooth instead of requiring an object-length drag. |
|
||||
| Walk completion | LEFT-facing walks stop on the exact current animation marker/bone frame when the queue reaches the end; no factory-pose restore occurs at arrival. Explicit stage reset (`stop_queue()` + `snap_to_standing()`) restores the captured factory spawn pose. |
|
||||
| Stop/flinch cleanup | Mode exit cancels reaction and recovery tweens, restores captured base rotations immediately, and collision dispatch is PLAY-only so delayed contacts cannot recreate a flinch after STOP. |
|
||||
| Walk To summary | Rule summaries read the waypoint from `params.target` and keep the actor instance id in the top-level `target`, with type guards for malformed/legacy payloads. |
|
||||
|
||||
Validation recorded for this hand-off:
|
||||
|
||||
- Focused Phase 4 final-bugs regression: **81/81 passed**.
|
||||
- Grid regression: **7/7 passed**.
|
||||
- `scenes/sandbox_stage.tscn` headless smoke test: **passed**.
|
||||
|
||||
---
|
||||
|
||||
**End of Phase 4 Bug Fixes — High-Level Plan**
|
||||
@@ -0,0 +1,326 @@
|
||||
# Phase 4: Triggers & Event System — Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Phase 4 adds **reactive storytelling** to the Sandbox Stage. While Phase 3a gave directors _sequential_ control (actions in a fixed order), Phase 4 gives them _reactive_ control — **"When X happens, do Y."**
|
||||
|
||||
This unlocks:
|
||||
|
||||
- **Cross-stickman communication** (Stickman A arrives → Stickman B speaks)
|
||||
- **Environmental reactions** (Stickman reaches crate → crate explodes)
|
||||
- **Branching narratives** (Arrive at different waypoints → different reactions)
|
||||
|
||||
**Crucially:** Phase 4 **does not** replace or duplicate the Phase 3a Action Queue. Sequential actions for a single stickman (Walk → Wait → Speak → Walk) remain unchanged. Phase 4 adds **cross-object reactivity**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Distinction
|
||||
|
||||
| Feature | Phase 3a (Already Works) | Phase 4 (New) |
|
||||
| ----------------------------------------------- | ------------------------ | -------------- |
|
||||
| Walk → Wait → Speak → Walk | ✅ Action Queue | ✅ (unchanged) |
|
||||
| Waypoint → next action | ✅ Action Runner | ✅ (unchanged) |
|
||||
| **Stickman A arrives → Stickman B speaks** | ❌ | ✅ NEW |
|
||||
| **Stickman collides with prop → prop explodes** | ❌ | ✅ NEW |
|
||||
| **Stickman enters area → all stickmen react** | ❌ | ✅ NEW |
|
||||
| **Multiple reactions per event** | ❌ | ✅ NEW |
|
||||
|
||||
---
|
||||
|
||||
## 3. The User's Mental Model
|
||||
|
||||
The director should think of events as **"When → Then"** rules that exist _alongside_ the action queues.
|
||||
|
||||
- **Action Queue:** "Stickman A walks to the crate, waits, then speaks."
|
||||
- **Event Rule:** "When Stickman A reaches the crate, Stickman B says 'Welcome!'"
|
||||
|
||||
The event rule is **not** part of Stickman A's queue. It's a separate rule that watches Stickman A and reacts independently.
|
||||
|
||||
---
|
||||
|
||||
## 4. Milestone Breakdown
|
||||
|
||||
| # | Milestone | Description |
|
||||
| --- | --------------------------- | ------------------------------------------------------- |
|
||||
| 4.1 | **Event Data Model** | Define rule structure and storage. |
|
||||
| 4.2 | **Trigger System** | Detect and emit events from stickmen, props, and areas. |
|
||||
| 4.3 | **Event Rule UI** | Visual rule builder (When → Then workflow). |
|
||||
| 4.4 | **Trigger Areas** | Placeable `Area2D` sensors. |
|
||||
| 4.5 | **Prop Interaction Events** | Prop collisions emit events. |
|
||||
| 4.6 | **Chain Reactions** | Multiple actions per rule, executed in sequence. |
|
||||
| 4.7 | **Integration & Polish** | Full end-to-end testing, bug fixes, UI polish. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Milestone 4.3: Event Rule UI
|
||||
|
||||
### 5.1. The "When..." Option
|
||||
|
||||
```
|
||||
The existing action popup is extended with a new section:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 🚶 Walk To │
|
||||
│ 💬 Speak │
|
||||
│ ⏳ Wait │
|
||||
│ 💥 Ragdoll │
|
||||
│ 🔄 Recover │
|
||||
│ ───────────────────────────────────────── │
|
||||
│ ⚡ When... (opens trigger sub-menu) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Design note:** The "When..." option is visually separated (line divider) to signal it creates a _rule_, not a direct action.
|
||||
|
||||
### 5.2. Trigger Sub-Menu
|
||||
|
||||
```
|
||||
When "When..." is clicked, the popup expands/replaces with trigger types:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 📍 Arrives at a waypoint │
|
||||
│ ✅ Completes any action │
|
||||
│ 💬 Finishes speaking │
|
||||
│ 🎯 Enters trigger area │
|
||||
│ 💥 Collides with something │
|
||||
│ ⬅ Back to actions │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each trigger type corresponds to a signal emitted by stickmen or the stage.
|
||||
|
||||
### 5.3. The Rule Building Flow (6 Stages)
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ STAGE 1 │ → User clicks stickman → popup opens
|
||||
│ IDLE │
|
||||
└──────┬───────┘
|
||||
│ Click "When..."
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 2 │ → User selects trigger type
|
||||
│ SELECT_TRIGGER│
|
||||
└──────┬───────┘
|
||||
│ (If trigger needs a target: waypoint, area, prop)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 3 │ → User clicks the specific target
|
||||
│ TARGET_ENTRY │ (e.g., a waypoint dot on the stage)
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 4 │ → User selects the action (Speak, Walk, etc.)
|
||||
│ SELECT_ACTION│
|
||||
└──────┬───────┘
|
||||
│ (If action needs a target: a stickman or prop)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 5 │ → User clicks the target stickman/prop
|
||||
│ TARGET_ENTRY2│
|
||||
└──────┬───────┘
|
||||
│ (If action needs params: text, duration, etc.)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 6 │ → User fills in params
|
||||
│ PARAMS │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ STAGE 7 │ → Rule is stored. Visuals update.
|
||||
│ DONE │
|
||||
└──────────────┘
|
||||
|
||||
```
|
||||
|
||||
**Escape** at any stage cancels rule creation and returns to IDLE.
|
||||
|
||||
---
|
||||
|
||||
## 6. Complete UI Walkthrough: "A Reaches Waypoint → B Speaks"
|
||||
|
||||
### Step 0: Prerequisites (Edit Mode)
|
||||
|
||||
- Stickman A and Stickman B are placed on the stage.
|
||||
- Stickman A already has a **"Walk To"** action queued (with a visible waypoint marker on the stage), OR the user is about to create one.
|
||||
|
||||
### Step 1: Create Stickman A's Walk (If Not Done)
|
||||
|
||||
| # | User Action | System Response |
|
||||
| --- | -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 1 | Click the **"Direct"** tool button in the palette. | Cursor changes to crosshair. |
|
||||
| 2 | Click **Stickman A** on the stage. | Action popup appears. |
|
||||
| 3 | Click **"🚶 Walk To"** in the popup. | Popup closes. Stage enters target placement mode. |
|
||||
| 4 | Click a spot on the stage. | Blue waypoint dot appears. `walk_to` action added to Stickman A's queue. |
|
||||
|
||||
### Step 2: Create the Reactive Event Rule
|
||||
|
||||
| # | User Action | System Response |
|
||||
| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 5 | Click the **"Direct"** tool again (if it was deselected). | Cursor changes to crosshair. |
|
||||
| 6 | Click **Stickman A** on the stage. | Action popup appears with new "When..." option. |
|
||||
| 7 | Click **"⚡ When..."** | Trigger sub-menu opens. |
|
||||
| 8 | Click **"📍 Arrives at a waypoint"** | Popup closes. Cursor changes to target selector. Status bar: _"Click the waypoint you want to trigger on."_ |
|
||||
| 9 | Click **the blue waypoint dot** on the stage. | Waypoint glows green briefly. Popup reappears with trigger confirmed. |
|
||||
| 10 | Click **"💬 Speak"** | Popup closes. Cursor changes to target selector. Status bar: _"Click the stickman who will speak."_ |
|
||||
| 11 | Click **Stickman B** on the stage. | Stickman B glows yellow. Text dialog appears. |
|
||||
| 12 | Type **"Hello there!"** and click **[OK]** | Dialog closes. Toast: _"Rule created!"_ |
|
||||
|
||||
### Step 3: Visual Feedback (Edit Mode)
|
||||
|
||||
| Element | Appearance | Description |
|
||||
| ---------------- | ------------------------------------------- | --------------------------------------- |
|
||||
| **Dashed line** | White dashed line | Connects waypoint to Stickman B. |
|
||||
| **Label** | _"When arrives → Speak 'Hello there!'"_ | Centered on the line. |
|
||||
| **Color coding** | Green waypoint + orange badge on Stickman B | Shows trigger source and action target. |
|
||||
| **Rule badge** | ⚡ icon on the label | Hover shows full rule details. |
|
||||
| **Delete icon** | ✕ on the label | Click to remove rule. |
|
||||
|
||||
### Step 4: Test in Play Mode
|
||||
|
||||
| # | User Action | System Response |
|
||||
| --- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| 13 | Click **"Play"** button. | All queues start executing. |
|
||||
| 14 | Stickman A walks to waypoint. | Normal Phase 3a behavior. |
|
||||
| 15 | When Stickman A arrives, event fires. | Stickman B says **"Hello there!"** (speech bubble appears). |
|
||||
|
||||
---
|
||||
|
||||
## 7. Visual Feedback (Edit Mode Only)
|
||||
|
||||
### 7.1. Rule Visualization Elements
|
||||
|
||||
| Element | Color/Style | Position |
|
||||
| ------------------- | ----------------------------- | ---------------------- |
|
||||
| **Trigger badge** | Green, small, "⚡" | Near source object. |
|
||||
| **Action badge** | Orange, small, "→" | Near target object. |
|
||||
| **Connection line** | Dashed white, alpha 0.6 | From source to target. |
|
||||
| **Label** | White text on dark background | Centered on line. |
|
||||
| **Waypoint glow** | Green (if trigger source) | On the waypoint dot. |
|
||||
|
||||
### 7.2. Rule Interaction
|
||||
|
||||
| Action | Behavior |
|
||||
| --------------------- | ----------------------------------------- |
|
||||
| **Click rule label** | Opens edit popup for that rule. |
|
||||
| **Click delete icon** | Removes the rule (confirmation optional). |
|
||||
| **Hover rule label** | Shows tooltip with full rule details. |
|
||||
|
||||
### 7.3. What the User Sees on the Stage
|
||||
|
||||
```
|
||||
Stickman A Stickman B
|
||||
┌──────┐ ┌──────┐
|
||||
│ 🔵 │ │ 🔵 │
|
||||
│ (A) │ │ (B) │
|
||||
└──┬───┘ └──┬───┘
|
||||
│ │
|
||||
│ ════════════╗ │
|
||||
│ (When arrives) │ │
|
||||
│ ════════════╝ │
|
||||
│ ── ── ── ── ── ── ── ── →│
|
||||
│ (Then Speak "Got it!") │
|
||||
│ │
|
||||
┌──┴──────────────────────────────────┐│
|
||||
│ 📍 Waypoint 3 ││
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Event Data Model
|
||||
|
||||
### 8.1. Rule Structure
|
||||
|
||||
Rules are stored in the stage's event registry and have three parts:
|
||||
|
||||
**Trigger** — The "When" part.
|
||||
|
||||
| Trigger Type | Source | Target | Description |
|
||||
| --------------------- | ------------- | ------------ | ------------------------------------ |
|
||||
| `arrived_at_waypoint` | Stickman | Waypoint | Stickman completes a walk. |
|
||||
| `action_finished` | Stickman | Action type | Stickman finishes any action. |
|
||||
| `speech_finished` | Stickman | (none) | Stickman finishes speaking. |
|
||||
| `entered_area` | Stickman/Prop | Trigger Area | Object enters a trigger area. |
|
||||
| `collided` | Stickman/Prop | Object | Object collides with another object. |
|
||||
|
||||
**Action** — The "Then" part.
|
||||
|
||||
| Action Type | Target | Params |
|
||||
| -------------- | -------- | ----------------------------------- |
|
||||
| `walk_to` | Stickman | `target` (Vector2) |
|
||||
| `speak` | Stickman | `text` (String), `duration` (float) |
|
||||
| `wait` | Stickman | `duration` (float) |
|
||||
| `ragdoll` | Stickman | (none) |
|
||||
| `recover` | Stickman | (none) |
|
||||
| `explode_prop` | Prop | (none) — future |
|
||||
| `spawn_prop` | Stage | `type` (String) — future |
|
||||
|
||||
### 8.2. Rule Storage
|
||||
|
||||
Rules are stored in `SandboxStage._event_rules` and persist across mode toggles (Play ↔ Edit). They are **not** saved to disk in Phase 4 (deferred to Phase 5).
|
||||
|
||||
### 8.3. Rule Evaluation
|
||||
|
||||
Rules are evaluated **in order** when an event occurs. If a rule's trigger matches the event, its actions are executed.
|
||||
|
||||
---
|
||||
|
||||
## 9. What's NOT in Phase 4
|
||||
|
||||
| Feature | Why Excluded |
|
||||
| ----------------------------------------------------------- | -------------------------------------------------- |
|
||||
| "When Stickman A arrives → Stickman A speaks" | This is just a sequential queue action (Phase 3a). |
|
||||
| "When Stickman A finishes walking → Stickman A walks again" | Same as above. |
|
||||
| Conditions (AND/OR logic) | Too complex for Phase 4; deferred to future. |
|
||||
| Variables / counters | Deferred to future. |
|
||||
| Save/Load rules to disk | Phase 5. |
|
||||
| Edit/Delete actions | Phase 3c. |
|
||||
| Stickman/Prop selector grids | Phase 3b. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Acceptance Criteria
|
||||
|
||||
### 10.1. Cross-Stickman Events
|
||||
|
||||
- [ ] **"When..." option appears** in the action popup.
|
||||
- [ ] **Trigger sub-menu opens** when "When..." is clicked.
|
||||
- [ ] **Waypoint trigger works:** A arrives → B speaks.
|
||||
- [ ] **Action completion trigger works:** A finishes speaking → B walks.
|
||||
- [ ] **Speech completion trigger works:** A finishes speaking → B speaks.
|
||||
- [ ] **Multiple actions per rule work:** Trigger → B speaks AND C walks.
|
||||
|
||||
### 10.2. Cross-Object Events
|
||||
|
||||
- [ ] **Area trigger works:** A enters area → B speaks.
|
||||
- [ ] **Prop collision trigger works:** A hits crate → crate explodes.
|
||||
- [ ] **Prop reaction works:** Trigger → prop explodes (disappears + particles).
|
||||
|
||||
### 10.3. UI & Visuals
|
||||
|
||||
- [ ] **Rule label shows summary:** "When arrives → Speak" text appears.
|
||||
- [ ] **Delete icon works:** Click ✕ → rule removed.
|
||||
- [ ] **Clicking rule label opens edit popup:** Click label → popup re-opens.
|
||||
- [ ] **Rules persist across mode toggles:** Create rule → Play → Edit → rule still there.
|
||||
- [ ] **Rules are cleaned up** when source/target objects are deleted.
|
||||
|
||||
### 10.4. Backward Compatibility
|
||||
|
||||
- [ ] **Sequential actions still work:** Walk → Wait → Speak → Walk works.
|
||||
- [ ] **Existing queues unchanged:** Any Phase 3a scene loads and runs.
|
||||
|
||||
---
|
||||
|
||||
## 11. File Changes Summary
|
||||
|
||||
| File | Changes |
|
||||
| ----------------------------------- | ------------------------------------------------------------- |
|
||||
| `scripts/sandbox_stage.gd` | Add `_event_rules`, event evaluation, rule UI state machine. |
|
||||
| `scripts/stickman_rig.gd` | Emit `arrived`, `action_finished`, `speech_finished` signals. |
|
||||
| `scripts/stage_director_visuals.gd` | Draw rule lines, labels, and badges. |
|
||||
| `scripts/trigger_area.gd` | NEW: Placeable `Area2D` sensor. |
|
||||
| `scripts/prop_block.gd` | Emit `collided` signal. |
|
||||
|
||||
---
|
||||
@@ -0,0 +1,141 @@
|
||||
# Phase 4: Triggers & Event System — Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
We have a good start with feature implementation, we want to polish some of the features and fix bugs before the next phase. We don't want too much tech debt
|
||||
|
||||
## 2. UI
|
||||
|
||||
1. There should be a status bar across the bottom of the screen that can hold information. As of now, that status bar should hold the current coordinates of the mouse at the right side of the bar. Similar to what is in the stickman editor.
|
||||
2. We need a better way to distinguish between EDIT and DIRECT modes. Let's change to the following
|
||||
|
||||
- **Unified Mode Switcher (Segmented Control):** Move mode toggles into a single visual group [ ✏️ Edit | 🎬 Direct | ▶️ Play ] at the far left of the toolbar. Currently, "Direct" sits between object spawners (Area and Grid), making it look like a spawnable object rather than a mode toggle.
|
||||
- **Contextual Toolbars:** Swap toolbar options based on active mode to eliminate clutter:
|
||||
- **Edit:** Show spawner buttons (Ground, Ramp, Crate, Stickman), transform tools, and Grid/Snap settings.
|
||||
- **Direct:** Hide spawner buttons; show director controls like action queue triggers, sequence prompts, target selectors, and event rules (when planning and implementation is complete).
|
||||
- **Play:** Collapse layout tools entirely, keeping only playback controls (Pause, Restart). This is already complete!
|
||||
|
||||
3. **Viewport Color Frame / Canvas Background:** Use distinct visual cues directly on the canvas so you don't need to look at text:
|
||||
|
||||
- **Edit Mode:** Keep the active construction grid visible.
|
||||
- **Direct Mode:** Add a subtle amber/gold viewport border frame (like a camera viewfinder overlay) or change gizmo colors to gold.
|
||||
- **Play Mode:** Fade out the grid background for a clean preview, accompanied by a subtle green glow around the Play control.
|
||||
|
||||
4. **Status Badge (High-Contrast Pill):** Replace the plain text Mode: EDIT with a prominent badge in the top-left or top-right of the viewport:
|
||||
|
||||
- ✏️ EDIT (Cyan / Blue Accent)
|
||||
- 🎬 DIRECTING (Amber / Gold Accent)
|
||||
- ▶️ SIMULATING (Green Accent)
|
||||
|
||||
5. **Cursor Feedback:** Change the mouse cursor per mode (e.g., standard pointer/crosshair in Edit, a target ring or director wand in Direct, and a default game pointer in Play).
|
||||
|
||||
## Control and Response
|
||||
|
||||
Creating a stage should be a little like 'drawing'. The user should have a very easy time building.
|
||||
|
||||
### Terrain Placement (Edit Mode)
|
||||
|
||||
1. **Input Handling & Drag Trajectory**
|
||||
|
||||
- **Anchor & Drag Trajectory:** Pressing the left mouse button sets a fixed grid anchor coordinate. Moving the mouse updates a dynamic target grid coordinate.
|
||||
- **Cardinal Angle Lock (Shift Key):**
|
||||
- When Shift is active, calculate the dominant delta component ($\vert{}dx\vert{}$ vs $\vert{}dy\vert{}$) from anchor to target.
|
||||
- Force the target coordinate to lock to the dominant axis, restricting the placement trajectory strictly to 0°, 90°, 180°, or 270°.
|
||||
- Non-dominant delta is overridden to 0 relative to the anchor point, suppressing diagonal step generation completely.
|
||||
- **High-Contrast Guide Line:**
|
||||
- Render a primary vector guide line from the center of the anchor cell to the center of the locked target cell.
|
||||
- Line style uses a high-contrast accent (e.g., bright cyan or gold) with dashed styling to ensure visibility over any terrain color.
|
||||
|
||||
2. **Pathing & Ghost Preview System**
|
||||
|
||||
- **Staircase Pathing Engine:**
|
||||
- When unrestrained by Shift, run Bresenham's line algorithm between anchor and target to yield an ordered array of target grid cell coordinates.
|
||||
- This array forms a continuous, non-overlapping sequence of horizontal, vertical, and diagonal staircase steps.
|
||||
- **Ghost Rendering Pipeline:**
|
||||
- For each cell coordinate in the path array, project a semi-transparent preview block (matching the active terrain block's dimensions and sprite).
|
||||
- Preview blocks update dynamically every frame as the mouse moves.
|
||||
|
||||
3. **Overlap & Connection Handling (Existing Blocks)**
|
||||
To support connecting new lines to existing structures (such as extending a 3-block row with 4 additional blocks), use a three-state grid query for every cell along the drag path:
|
||||
|
||||
- **Cell State 1: Empty Cell (Valid Placement)**
|
||||
- **Visual:** Standard semi-transparent green ghost.
|
||||
- **Action on Release:** Instantiate and place a new block node at this coordinate.
|
||||
- **Cell State 2: Occupied by Same Block Type (Overlap Skip)**
|
||||
- **Scenario:** Dragging across or starting from an existing ground block.
|
||||
- **Visual:** Neutral outline ghost or fully transparent preview (signifying no double-creation).
|
||||
- **Action on Release:** Skip instantiation for this cell. The tool treats existing identical blocks as valid structural connections without duplicating physics nodes or causing rendering z-fighting.
|
||||
- **Cell State 3: Occupied by Different/Conflicting Object (Blocked)**
|
||||
- **Scenario:** Dragging across an obstacle (e.g., crate, hazard, stickman spawn).
|
||||
- **Visual:** Muted red ghost.
|
||||
- **Action on Release:** Skip instantiation for this specific cell while preserving valid placements along the rest of the line.
|
||||
- **Connecting Workflow Example:**
|
||||
- **Scenario:** Existing blocks at positions (1,0), (2,0), (3,0).
|
||||
- **Case A (Seamless Extension):** User clicks empty cell (4,0) and drags to (7,0). Ghost blocks display at 4, 5, 6, 7. On release, 4 new blocks are created cleanly adjacent to block 3.
|
||||
- **Case B (Overlapping Extension):** User clicks existing block (3,0) and drags through to (7,0). Position 3 is flagged as Occupied (Skip), while 4, 5, 6, 7 flag as Empty. On release, block 3 remains untouched and blocks 4, 5, 6, 7 are instantiated.
|
||||
|
||||
4. **Atomic Transaction & State Management**
|
||||
|
||||
- **Batch Commit:** On mouse release, process the path array in a single frame. Collect all newly instantiated block nodes into a single batch array.
|
||||
- **Grid Spatial Dictionary:** Update the stage’s centralized grid data structure with references to the newly spawned blocks, marking those cell coordinates as occupied for subsequent editing tasks.
|
||||
|
||||
### General UI
|
||||
|
||||
1. **Controls**
|
||||
|
||||
- **Build Cancelling:**
|
||||
When in edit mode and an object is selected to be created (terrain, prop, stickman, etc) - if the use clicks the LMB we end draw/placement mode (the object button gets toggled) and the cursor returns to normal.
|
||||
|
||||
- **Moving Trigger Objects**
|
||||
When a trigger object (such as an 'Area') gets moved, and it has a current action binded with it, the action display with the dashed line connecting to it does not follow the shape. The action line should redraw once the shape is moved so the use can see it updated.
|
||||
|
||||
### Styling
|
||||
|
||||
1. When the user clicks a stickman / object in direct mode and a context menu pops up, the font and emoji icons are too small. The font size should be increased for that.
|
||||
2. When a stickman or object has an assignment, there is an emoji/icon shown under the stickman/object that represents the action. That should also be larger.
|
||||
3. A styling/theme JSON configuration file should be implemented so the user can edit it manually and change font sizes for both points 1 and 2.
|
||||
4. The JSON configuration file should also have an entry for grid snap size (so that can persist)
|
||||
5. It would also be good if the JSON file can also have font names/types configured so the user can change the font used in the sandbox UI.
|
||||
|
||||
### Actions
|
||||
|
||||
- **Scenario:** When in direct mode and the stickman is selected and an action is picked (walk to waypoint for example), the top corner of the display tells me to click where i want the waypoint to be. I don't think this is very visible to a user (especially a kid.)
|
||||
- **Solutions:**
|
||||
1. **Cursor-Attached Floating Tooltip**: Instead of forcing the eye to look up at the top bar, attach a floating instruction pill right next to the mouse pointer.
|
||||
- **Visual:** A small, rounded badge with a high-contrast background (e.g., bright orange or cyan) that follows the mouse cursor.
|
||||
- **Content:** 🚩 Click to set walk target with a small [Esc to cancel] tag.
|
||||
- **Why it works:** Kids follow the mouse pointer with their eyes; placing instructions directly at the cursor ensures zero cognitive friction.
|
||||
2. **Rubber-Band Trajectory Line & Ghost Marker**
|
||||
Provide continuous visual context on the canvas connecting the Stickman to the target location while picking a point. - **Trajectory Line:** Draw a dashed or pulsing line from the Stickman’s feet directly to the mouse cursor. - **Ghost Target Icon:** Render a semi-transparent target pin, flag, or footprint icon under the cursor that snaps to the stage floor or grid. - **Color Feedback:** Highlight the line green if the ground path is valid, or red if the target point is out of reach or inside solid terrain.
|
||||
3. **Custom Action Cursor**
|
||||
Change the default arrow mouse cursor into a context-specific tool icon when waiting for a click. - **Target Reticle / Flag:** Swap the cursor to a animated targeting reticle or a flag icon. - **Floor Ring:** Project a pulsing circle onto the stage terrain under the cursor to indicate the drop location.
|
||||
4. **Prominent Top-Center Banner (If keeping text)**
|
||||
If you keep UI text, move it out of the debug/status bar and convert it into a dedicated floating action banner. - **Position:** Top-center of the viewport, styled like a floating notification card. - **Design:** Bright accent color border, a clear icon (🎯 or 🚩), bold text, and a clickable [ X Cancel ] button so users don't have to rely on knowing the Esc key.
|
||||
- **Chosen Solution:** Combined Workflow
|
||||
|
||||
```
|
||||
[ Stickman ] ─────── (Dashed Trajectory Line) ───────> [ 🚩 Floating Target Marker ]
|
||||
└─ [ Pill Tooltip: "Click to walk here!" ]
|
||||
```
|
||||
|
||||
When the action is selected:
|
||||
|
||||
1. The mouse cursor changes to a Flag Marker.
|
||||
2. A dashed rubber-band line draws from the Stickman to the cursor.
|
||||
3. A floating badge next to the cursor reads "Click to walk here!".
|
||||
|
||||
Similar behavior should be taken when the user selectes "When... Trigger Area" and "When... Collision"
|
||||
|
||||
## Bugs
|
||||
|
||||
1. Walking to waypoint causes jitter when waypoint is reached.
|
||||
|
||||
- **Scenario:** When creating a waypoint for a stickman - playing the scene causes the stickman to jitter rapidly up and down when the waypoint is reached.
|
||||
- **Reproduce:**
|
||||
1. Place a stickman
|
||||
2. Go into direct mode
|
||||
3. Create a walking waypoint for the stickman.
|
||||
4. Enter Play mode
|
||||
5. Observe the stickman walking to the waypoint.
|
||||
- **Results:** The stickman walks to the waypoint, and jitters rapidly but slightly up and down.
|
||||
- **Expected:** The stickman stops - no more movement.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Phase 4: Triggers & Event System — Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
We have a good start with feature implementation, we want to polish some of the features and fix bugs before the next phase. We don't want too much tech debt
|
||||
|
||||
## 2. UI
|
||||
|
||||
1. There should be a status bar across the bottom of the screen that can hold information. As of now, that status bar should hold the current coordinates of the mouse at the right side of the bar. Similar to what is in the stickman editor.
|
||||
2. We need a better way to distinguish between EDIT and DIRECT modes. Let's change to the following
|
||||
|
||||
- **Unified Mode Switcher (Segmented Control):** Move mode toggles into a single visual group [ ✏️ Edit | 🎬 Direct | ▶️ Play ] at the far left of the toolbar. Currently, "Direct" sits between object spawners (Area and Grid), making it look like a spawnable object rather than a mode toggle.
|
||||
- **Contextual Toolbars:** Swap toolbar options based on active mode to eliminate clutter:
|
||||
- **Edit:** Show spawner buttons (Ground, Ramp, Crate, Stickman), transform tools, and Grid/Snap settings.
|
||||
- **Direct:** Hide spawner buttons; show director controls like action queue triggers, sequence prompts, target selectors, and event rules (when planning and implementation is complete).
|
||||
- **Play:** Collapse layout tools entirely, keeping only playback controls (Pause, Restart). This is already complete!
|
||||
|
||||
3. **Viewport Color Frame / Canvas Background:** Use distinct visual cues directly on the canvas so you don't need to look at text:
|
||||
|
||||
- **Edit Mode:** Keep the active construction grid visible.
|
||||
- **Direct Mode:** Add a subtle amber/gold viewport border frame (like a camera viewfinder overlay) or change gizmo colors to gold.
|
||||
- **Play Mode:** Fade out the grid background for a clean preview, accompanied by a subtle green glow around the Play control.
|
||||
|
||||
4. **Status Badge (High-Contrast Pill):** Replace the plain text Mode: EDIT with a prominent badge in the top-left or top-right of the viewport:
|
||||
|
||||
- ✏️ EDIT (Cyan / Blue Accent)
|
||||
- 🎬 DIRECTING (Amber / Gold Accent)
|
||||
- ▶️ SIMULATING (Green Accent)
|
||||
|
||||
5. **Cursor Feedback:** Change the mouse cursor per mode (e.g., standard pointer/crosshair in Edit, a target ring or director wand in Direct, and a default game pointer in Play).
|
||||
|
||||
## Control and Response
|
||||
|
||||
Creating a stage should be a little like 'drawing'. The user should have a very easy time building.
|
||||
|
||||
### Terrain Placement (Edit Mode)
|
||||
|
||||
1. **Input Handling & Drag Trajectory**
|
||||
|
||||
- **Anchor & Drag Trajectory:** Pressing the left mouse button sets a fixed grid anchor coordinate. Moving the mouse updates a dynamic target grid coordinate.
|
||||
- **Cardinal Angle Lock (Shift Key):**
|
||||
- When Shift is active, calculate the dominant delta component ($\vert{}dx\vert{}$ vs $\vert{}dy\vert{}$) from anchor to target.
|
||||
- Force the target coordinate to lock to the dominant axis, restricting the placement trajectory strictly to 0°, 90°, 180°, or 270°.
|
||||
- Non-dominant delta is overridden to 0 relative to the anchor point, suppressing diagonal step generation completely.
|
||||
- **High-Contrast Guide Line:**
|
||||
- Render a primary vector guide line from the center of the anchor cell to the center of the locked target cell.
|
||||
- Line style uses a high-contrast accent (e.g., bright cyan or gold) with dashed styling to ensure visibility over any terrain color.
|
||||
|
||||
2. **Pathing & Ghost Preview System**
|
||||
|
||||
- **Staircase Pathing Engine:**
|
||||
- When unrestrained by Shift, run Bresenham's line algorithm between anchor and target to yield an ordered array of target grid cell coordinates.
|
||||
- This array forms a continuous, non-overlapping sequence of horizontal, vertical, and diagonal staircase steps.
|
||||
- **Ghost Rendering Pipeline:**
|
||||
- For each cell coordinate in the path array, project a semi-transparent preview block (matching the active terrain block's dimensions and sprite).
|
||||
- Preview blocks update dynamically every frame as the mouse moves.
|
||||
|
||||
3. **Overlap & Connection Handling (Existing Blocks)**
|
||||
To support connecting new lines to existing structures (such as extending a 3-block row with 4 additional blocks), use a three-state grid query for every cell along the drag path:
|
||||
|
||||
- **Cell State 1: Empty Cell (Valid Placement)**
|
||||
- **Visual:** Standard semi-transparent green ghost.
|
||||
- **Action on Release:** Instantiate and place a new block node at this coordinate.
|
||||
- **Cell State 2: Occupied by Same Block Type (Overlap Skip)**
|
||||
- **Scenario:** Dragging across or starting from an existing ground block.
|
||||
- **Visual:** Neutral outline ghost or fully transparent preview (signifying no double-creation).
|
||||
- **Action on Release:** Skip instantiation for this cell. The tool treats existing identical blocks as valid structural connections without duplicating physics nodes or causing rendering z-fighting.
|
||||
- **Cell State 3: Occupied by Different/Conflicting Object (Blocked)**
|
||||
- **Scenario:** Dragging across an obstacle (e.g., crate, hazard, stickman spawn).
|
||||
- **Visual:** Muted red ghost.
|
||||
- **Action on Release:** Skip instantiation for this specific cell while preserving valid placements along the rest of the line.
|
||||
- **Connecting Workflow Example:**
|
||||
- **Scenario:** Existing blocks at positions (1,0), (2,0), (3,0).
|
||||
- **Case A (Seamless Extension):** User clicks empty cell (4,0) and drags to (7,0). Ghost blocks display at 4, 5, 6, 7. On release, 4 new blocks are created cleanly adjacent to block 3.
|
||||
- **Case B (Overlapping Extension):** User clicks existing block (3,0) and drags through to (7,0). Position 3 is flagged as Occupied (Skip), while 4, 5, 6, 7 flag as Empty. On release, block 3 remains untouched and blocks 4, 5, 6, 7 are instantiated.
|
||||
|
||||
4. **Atomic Transaction & State Management**
|
||||
|
||||
- **Batch Commit:** On mouse release, process the path array in a single frame. Collect all newly instantiated block nodes into a single batch array.
|
||||
- **Grid Spatial Dictionary:** Update the stage’s centralized grid data structure with references to the newly spawned blocks, marking those cell coordinates as occupied for subsequent editing tasks.
|
||||
|
||||
5. **Controls**
|
||||
|
||||
- **Build Cancelling:**
|
||||
When in edit mode and an object is selected to be created (terrain, prop, stickman, etc) - if the use clicks the LMB we end draw/placement mode (the object button gets toggled) and the cursor returns to normal.
|
||||
|
||||
- **Moving Trigger Objects**
|
||||
When a trigger object (such as an 'Area') gets moved, and it has a current action binded with it, the action display with the dashed line connecting to it does not follow the shape. The action line should redraw once the shape is moved so the use can see it updated.
|
||||
|
||||
## Bugs
|
||||
|
||||
### Director Issues
|
||||
|
||||
1. Area trigger not working
|
||||
|
||||
- **Scenario:** Area triggers do not see to do anything.
|
||||
- **Reproduce:**
|
||||
1. Place a stickman
|
||||
2. Place an area
|
||||
3. Go into direct mode
|
||||
4. Create a walking waypoint for the stickman that moves through the Area
|
||||
5. Assign a 'When' action to the stickman - When Enters Trigger Area - Ragdoll
|
||||
6. Enter Play mode
|
||||
- **Results:** The stickman walks to the waypoint, but nothing happens when it passes through the area.
|
||||
- **Expected:** The stickman triggers the area and activates ragdoll.
|
||||
|
||||
2. Collisions are not working
|
||||
|
||||
- **Scenario:** When assigning a collision trigger, nothing happens
|
||||
- **Reproduce:**
|
||||
1. Place a stickman
|
||||
2. Place a ground where the crate will be on top.
|
||||
3. Place a crate on top of the ground
|
||||
4. Go into direct mode
|
||||
5. Create a walking waypoint for the stickman that moves through the crate
|
||||
6. Assign a 'When' action to the stickman - When Enters Collides (with crate) - Ragdoll
|
||||
7. Enter Play mode
|
||||
- **Results:** The stickman walks to the waypoint, but nothing happens when it collides with the prop.
|
||||
- **Expected:** The stickman collides with the prop, and the prop moves, etc....
|
||||
@@ -0,0 +1,459 @@
|
||||
# Phase 4b Polish — Implementation Specification
|
||||
|
||||
> **Status:** Draft for implementation (tester/developer will refine unit tests).
|
||||
> **Source plan:** `plans/PHASE_4b_POLISH.md`
|
||||
> **Scope:** Sandbox Stage Builder (`scenes/sandbox_stage.tscn` + `scripts/sandbox_stage.gd` and its supporting scripts). **Not** wired into the main editor. Run standalone via **F6**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Phase 4b is a **polish + bugfix** pass over the Sandbox Stage Builder (Phases 2–4). It does **not** add new gameplay systems; it (a) restructures the top-bar UI into a unified 3-segment mode switcher with contextual toolbars, a bottom status bar, a mode badge, per-mode viewport framing, and per-mode cursors; (b) upgrades terrain placement from single-click into a drag-to-paint "drawing" workflow with Bresenham staircase pathing, a 3-state occupancy query, ghost previews, and atomic batch commit backed by a new grid spatial dictionary; (c) makes the director tool's "pick a target" flows kid-friendly with cursor-attached tooltips, rubber-band trajectory lines, and a custom action cursor; (d) introduces a hand-editable theme JSON (fonts/sizes/colors/grid default); and (e) fixes the walk-waypoint arrival jitter.
|
||||
|
||||
The codebase state verified for this spec:
|
||||
|
||||
- The sandbox stage is `scripts/sandbox_stage.gd` (`class_name SandboxStage`), root of `scenes/sandbox_stage.tscn` (which is a minimal `Node2D` + `Camera2D` + empty `World`; **all UI is built in code** in `_build_ui()`).
|
||||
- There are currently **two** modes (`enum StageMode { EDIT, PLAY }`, `sandbox_stage.gd:27`); "Direct" is a **separate bool** `_direct_mode` toggled by a `_direct_button` (`:153`, `:660-664`) that sits *between* the spawner buttons and the Grid/Snap controls.
|
||||
- The status readout is a **right-aligned `Label` in the top bar** (`_status_label`, `:691-694`), not a bottom bar, and there is **no mouse-coordinate readout**.
|
||||
- Terrain placement is **single-click**: `_place_at()` (`:467`) spawns one node per click; there is no drag trajectory, no Bresenham pathing, no occupancy query, and **no grid spatial dictionary** (`_rebake_navigation()` at `:798` iterates `World` children each bake).
|
||||
- `StageDirectorVisuals` recomputes rule connector anchors **live** from `instance_from_id(...).global_position` on every `_draw()` (`stage_director_visuals.gd:257-287`), but it only redraws when `_dirty` is set (`:80-83`), and `_on_transform_committed()` (`sandbox_stage.gd:875-879`) does **not** call `mark_dirty()` — this is the "moving a TriggerArea does not update its rule connector" bug (§8).
|
||||
- `walk_left`/`walk_right` bake a vertical body bob: `IK_Targets/Torso:position` is keyed `(0,10) → (0,-15) → (0,10) → (0,-15) → (0,10)` (`scripts/create_animations.gd:80`; baked into `master_rig.tscn`) — relevant to the jitter bug (§11).
|
||||
- There is **no `res://assets/` directory, no `.ttf`, no `.theme`/`.tres`** in the repo; all drawing uses `ThemeDB.fallback_font`. The only existing sandbox config is the runtime `user://sandbox_settings.json` (`sandbox_stage.gd:1500-1527`, keys `version`/`grid_size`/`snap_to_grid`/`show_grid`).
|
||||
- The established headless test pattern is `Godot_v4.4-stable_win64_console.exe --headless --script res://tests/<name>.gd --path .` (see `tests/test_text_baseline_fix.gd:13-16`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature-by-feature breakdown
|
||||
|
||||
Each subsection: **Behavior**, **Affected files/functions**, **Data/format changes**, **Edge cases**.
|
||||
|
||||
---
|
||||
|
||||
### 2.1 Bottom status bar with mouse coordinates
|
||||
|
||||
**Behavior.** Add a bottom-anchored status bar (mirroring the stickman editor's `%StatusBar` pattern). Left side may carry the existing `Mode: … | Objects: N | Selected: …` text (or that moves to a toast); **right side** shows the live mouse world coordinates as `X: ### Y: ###`, exactly like the editor's `_process()` cursor readout (`scripts/stickman_editor.gd:147-162`, which polls `get_global_mouse_position()` each frame and writes `_status_cursor_coords.text`).
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `_build_ui()` (`:633`) — replace the top-bar status `Label` with a bottom bar. Build a `PanelContainer` + `HBoxContainer` anchored `Control.PRESET_BOTTOM_WIDE` (height ~28 px, mirroring the editor's 28 px `StatusBar`), containing a left `Label` (`_status_label`, expands) and a right `Label` (`_status_cursor_coords`). The top bar (`top_bar`, `offset_bottom = 40.0`, `:638-641`) must have its bottom reduced so the new bar does not overlap (`offset_bottom` stays 40 for the top bar; the bottom bar is a separate Control).
|
||||
- Add `_process()` coordinate polling (extend the existing `_process` at `:219`): compute `_camera.get_global_mouse_position()`, write `X: %d Y: %d` into `_status_cursor_coords` (world space, integer-rounded, consistent with the editor). This is world-space (not screen-space) so it pans/zooms correctly.
|
||||
- `_refresh_status()` (`:607`) keeps writing the left label.
|
||||
|
||||
**Edge cases.** When the mouse is over the top bar/bottom bar/popups (`_is_mouse_over_ui()` at `:1450`), the coordinate readout should still update (world position under a UI hover is still meaningful) — decide and document: the editor hides coords when not over a drawing surface, but the sandbox stage is a single full-screen viewport, so **always show** world coords. Handle `Camera2D` null (should not happen; it is `@onready`).
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Unified mode switcher + contextual toolbars
|
||||
|
||||
**Behavior.** Replace the current `_mode_button` ("Edit"/"Play") + `_direct_button` ("Direct") with a single **3-segment control** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` at the far left of the top bar. The three modes have contextual toolbars:
|
||||
|
||||
- **Edit:** spawner buttons (Ground/Ramp/Step/Crate/Ball/Stickman/Area), Grid/Snap/Size controls. *(No separate "transform tool" buttons exist today — translation/rotation are direct-drag/ring gizmos, not toolbar tools.)*
|
||||
- **Direct:** spawner buttons + grid/snap controls hidden; the toolbar shows director affordances (currently the director is entirely click/popup-driven, so this segment may initially show only a hint label; see Open Questions).
|
||||
- **Play:** layout tools hidden; toolbar shows only the mode switcher (+ playback controls if added — see Open Questions).
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `enum StageMode { EDIT, PLAY }` (`:27`) → extend to `enum StageMode { EDIT, DIRECT, PLAY }`. **Recommend values EDIT=0, DIRECT=1, PLAY=2** (segmented order). `mode_changed(mode: int)` (`:36`) now carries 3 values.
|
||||
- Fold `_direct_mode: bool` (`:153`) into `current_mode`. Delete `_direct_button` (`:154`) and `_mode_button` (`:119`); introduce a small array of 3 `Button` (toggle_mode) built from a `const MODES = [{id, label}, …]` in `_build_ui()`.
|
||||
- `set_mode(mode)` (`:354`) → route to `_enter_edit_mode()` / **new `_enter_direct_mode()`** / `_enter_play_mode()`. `_enter_direct_mode()` = enter EDIT-side state (freeze props, stand stickmen, gizmos enabled) **without** clearing selection-as-placement, set `_direct_mode` behavior, show director visuals, hide spawner/grid controls.
|
||||
- `_enter_edit_mode()` (`:366`) must clear direct state (currently `_enter_play_mode()` clears `_direct_mode` at `:404`; edit must also reset it).
|
||||
- `_on_direct_toggled` (`:885`), `_on_mode_toggled` (`:830`) — replaced by `_on_mode_segment_toggled(pressed, mode)`.
|
||||
- `_set_build_controls_visible(visible)` (`:1469`) — split into `_set_edit_controls_visible(bool)` (spawners + grid/snap/size) and `_set_direct_controls_visible(bool)`; call from the three mode-enter functions.
|
||||
- `_handle_world_click` (`:298`) — the `current_mode != StageMode.EDIT` early return (`:301`) must now accept `DIRECT` for the `_handle_direct_click` path; rule-builder and rule-label hit-testing still need to work in Direct mode.
|
||||
- `_unhandled_key_input` Esc ordering (`:269-282`) — add a `DIRECT` branch (exit Direct → Edit) consistent with the current `_direct_mode` branch (`:276-278`).
|
||||
- `_handle_mouse_motion` (`:335`) — the `current_mode != StageMode.EDIT` guard (`:339`) must allow Direct-mode hover only if needed (Direct currently does not hover; keep hover Edit-only, but permit Direct click routing).
|
||||
|
||||
**Data/format changes.** None.
|
||||
|
||||
**Edge cases.** `set_mode()` no-ops when `mode == current_mode` (`:355`) — keep. Entering PLAY from DIRECT must clear `_direct_mode` state and the pending-walk-target/rules popup (already handled in `_enter_play_mode` `:396-412`; verify after folding the bool). The old `mode_changed` consumers (only the stage itself) must be re-audited for the new enum values.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Viewport color frame / canvas background per mode
|
||||
|
||||
**Behavior.** Distinct canvas cues per mode:
|
||||
|
||||
- **Edit:** construction grid visible (current behavior).
|
||||
- **Direct:** amber/gold viewport **border frame** (a thin full-screen border overlay, "camera viewfinder" feel) and/or gold-tinted gizmos. **Grid hidden entirely** (user decision — no dimmed grid).
|
||||
- **Play:** grid faded out; green glow on the Play segment.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `_build_ui()` — add a full-screen `Panel` (or `ColorRect`) Control in the `CanvasLayer` named e.g. `ModeFrame`, `mouse_filter = MOUSE_FILTER_IGNORE`, drawn **behind** the top bar and **over** the viewport, with a `StyleBoxFlat` that has transparent `bg_color` and a `border_color`/`border_width` from the theme JSON. Toggle `visible`/`border_color` in the three mode-enter functions.
|
||||
- `_apply_grid_settings()` (`:1460-1467`) — the grid visibility line `_grid.visible = _show_grid and current_mode == StageMode.EDIT` (`:1464`) must change: `EDIT` → visible; `DIRECT` → **hidden**; `PLAY` → hidden (fade).
|
||||
- `_refresh_status()` (`:607`) — the Play segment glow is a button theme override applied on mode change.
|
||||
- `scripts/stage_grid.gd` — no change required (Direct hides the grid; the optional `alpha` dim knob is **not** needed).
|
||||
|
||||
**Edge cases.** The frame overlay must never intercept input (`MOUSE_FILTER_IGNORE`). Border width is screen-constant (not zoom-dependent).
|
||||
|
||||
---
|
||||
|
||||
### 2.4 High-contrast status badge pill
|
||||
|
||||
**Behavior.** A prominent pill in a viewport corner showing the mode: `✏️ EDIT` (cyan/blue), `🎬 DIRECTING` (amber/gold), `▶️ SIMULATING` (green). Replaces the "Mode:" portion of the status text as the primary mode indicator.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `_build_ui()` — build a `PanelContainer` (`StyleBoxFlat` with accent `bg_color`, rounded corners, padding) containing a `Label`, anchored top-left (or top-right) of the viewport, `mouse_filter = MOUSE_FILTER_IGNORE`.
|
||||
- New `_refresh_mode_badge()` called from the three mode-enter functions and `_refresh_status()`; text + `StyleBoxFlat.bg_color` + label color sourced from the theme JSON mode colors (§2.9).
|
||||
- `_refresh_status()` (`:607`) — drop `Mode: …` from the status text (now redundant) or keep it; the badge is authoritative.
|
||||
|
||||
**Edge cases.** Badge must float above the world but below the toolbar; ensure it doesn't overlap the top bar when the window is short.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Cursor feedback per mode
|
||||
|
||||
**Behavior.** `Input.set_default_cursor_shape()` per mode: Edit → `CURSOR_CROSS`, Direct → `CURSOR_CROSS` (or a custom reticle), Play → `CURSOR_ARROW`. The action-pick cursor (§2.10) overrides this with a custom flag/reticle.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- New `_apply_cursor()` called from the mode-enter functions and on `_pending_walk_target`/rule-builder step transitions. Use `Input.set_default_cursor_shape(Input.CURSOR_CROSS)` etc. Custom cursors (flag/reticle) require `Input.set_custom_mouse_cursor(texture, shape, hotspot)` — note this needs an image asset (none exists yet; a `res://assets/` addition or a runtime-generated `Image`/`ImageTexture` via `Image.create()` is acceptable).
|
||||
|
||||
**Edge cases.** Restore the default cursor when returning to Play/Edit and when the rule builder or pending-target is cancelled (Esc). A custom cursor must be cleared with `Input.set_custom_mouse_cursor(null)`.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Terrain placement improvements (Edit mode)
|
||||
|
||||
This is the largest workstream. Current state: `set_placement_mode()` spawns **one** ghost (`_spawn_ghost` `:495`) and `_place_at()` spawns **one** node per click (`:467`). There is **no** drag painting and **no** occupancy tracking.
|
||||
|
||||
**2.6.1 Anchor & drag trajectory.**
|
||||
|
||||
- Pressing LMB (in Edit, with a terrain placement id active) sets a fixed **anchor grid cell**; moving updates a **target grid cell** (`_snap_to_grid` `:1454`).
|
||||
- **Shift** locks the trajectory to a cardinal axis: compute `dx`/`dy` from anchor→target; if `|dx| >= |dy|` zero the y-delta, else zero the x-delta (0°/90°/180°/270°). Re-evaluate per motion event.
|
||||
|
||||
**2.6.2 High-contrast dashed guide line.**
|
||||
|
||||
- Draw a dashed line from the anchor cell center to the locked target cell center. High-contrast accent (cyan/gold). Implemented either in a new overlay `Node2D` (sibling of `StageGrid`/`StageGizmos`, e.g. `PlacementOverlay`) or as a draw method in `StageGizmos`; recommend a **new lightweight overlay** to keep `StageGizmos` focused on selection.
|
||||
|
||||
**2.6.3 Bresenham staircase pathing + ghost pipeline.**
|
||||
|
||||
- Compute an ordered cell array from anchor to target using **Bresenham's line algorithm** (grid-cell space). Cells are `Vector2i`/`Vector2` at `cell * grid_size`.
|
||||
- Replace the single `_ghost` with a **ghost array**: one translucent `TerrainBlock` per path cell (matching the active template). Update each frame in `_process()`/motion handling. Reuse `StageSpawner.spawn()` + reparent out of `World` into `_ghost_holder` (`:495-529`), `modulate.a ≈ 0.5`, collision disabled (already done for StaticBody2D ghost at `:513-516`). Free the whole ghost array on release/cancel.
|
||||
|
||||
**2.6.4 Three-state grid query (per cell).**
|
||||
|
||||
For every cell in the path, classify against a **grid spatial dictionary** (below):
|
||||
|
||||
1. **Empty** → green ghost → instantiate on release.
|
||||
2. **Occupied by same block type** (same registry id, e.g. another `ground`) → neutral/transparent ghost → **skip** on release (no double-create, no z-fight).
|
||||
3. **Occupied by a different/conflicting object** (crate/ball/stickman/area/different terrain) → muted-red ghost → **skip** on release.
|
||||
|
||||
Matching "same block type" requires knowing which registry id produced an existing `TerrainBlock`. Since `TerrainBlock` does not store its template id today, add a `set_meta("spawn_id", id)` in `_place_at()`/`_spawn_terrain()` (or a `spawn_id` property on `TerrainBlock`). Ground/Ramp/Step are distinct ids so only identical-template overlaps skip.
|
||||
|
||||
**2.6.5 Atomic batch commit + grid spatial dictionary.**
|
||||
|
||||
- **Grid spatial dictionary (new):** a `Dictionary` on `SandboxStage` keyed by grid-cell `Vector2i` → `Array[Node2D]` (nodes whose footprint overlaps that cell). Populated on spawn/load, updated on move/rotate/delete, cleared/rebuilt when grid size changes. Used by (a) the 3-state query, (b) optionally to accelerate `_rebake_navigation()`/event-engine broadphase (ties into tech-debt #14).
|
||||
- Because `TerrainBlock` footprints can be larger than one cell (Ground is 200×32 at `TERRAIN_GRID_SIZE=16`, i.e. ~13×2 cells; Ramp/Step larger), occupancy must mark **all cells covered by the node's world AABB**, not just the anchor cell. For terrain the template is grid-aligned; for props/stickmen/areas use `StageSelection.get_world_aabb()` (`stage_selection.gd:129`) rasterized to cells. This is the one non-trivial piece — spec a helper `_rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]`.
|
||||
- **Batch commit:** on release, collect all "empty" cells into one batch, spawn all nodes in one frame (loop `_spawner.spawn()`), then add them to the dictionary and mark `_nav_dirty = true` once (not per node). Emit `object_placed` per node (or add an `objects_placed(nodes)` signal; keep `object_placed` for compat). `_save_object_state()` per node (for authored restore).
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`: `set_placement_mode` (`:456`), `_place_at` (`:467`), `_spawn_ghost`/`_free_ghost`/`_update_ghost_position` (`:495-544`) → replaced/extended by drag placement; `_handle_world_click` (`:298`) and `_handle_mouse_motion` (`:335`) gain drag-placement branches; `_on_transform_committed` (`:875`) and `delete_selected` (`:584`) must update the dictionary; `_rebake_navigation` (`:798`) optionally consumes it.
|
||||
- `scripts/terrain_block.gd`: add a `spawn_id` `String` (or use `set_meta`) so same-type overlap is detectable.
|
||||
- `scripts/stage_spawner.gd`: `_spawn_terrain` (`:202`) already centers the template; expose the template extent (e.g. a `get_template_aabb(id)`) for ghost sizing and cell rasterization. `TERRAIN_GRID_SIZE` (`:31`) is the cell size.
|
||||
- **New file** `scripts/stage_placement_overlay.gd` (recommended): draws the dashed guide line + per-cell ghost tint state (green/neutral/red) — or fold into the ghost array directly.
|
||||
|
||||
**Data/format changes.** Grid spatial dictionary is runtime-only (not persisted). No `.stk`/settings format change.
|
||||
|
||||
**Edge cases.**
|
||||
|
||||
- **Case A (seamless extension):** blocks at (1,0),(2,0),(3,0); drag (4,0)→(7,0) → 4 new blocks.
|
||||
- **Case B (overlap extension):** start on existing block (3,0), drag to (7,0) → cell 3 is "same-type skip", 4–7 spawn.
|
||||
- **Case C:** drag across a crate → that cell red-skipped, neighbors still spawn.
|
||||
- **Shift lock** suppresses diagonals entirely (Bresenham produces a pure horizontal/vertical run under cardinal lock).
|
||||
- Grid-size change while a drag is active: recompute the dictionary or cancel the drag (simplest: cancel drag + free ghosts on `_on_grid_size_changed`).
|
||||
- Terrain `spawn_id` on nodes placed before this phase (none, since the dictionary is new) — but a rebuild from scratch in `_ready()` must scan existing `World` children; since the stage starts empty, this is trivial.
|
||||
- Batch spawn of e.g. 50 cells must not stall: reuse the existing per-node `_save_object_state` and single `_nav_dirty` coalescing (nav already coalesces via `_process` `:219-222`).
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Build cancelling (RMB ends placement)
|
||||
|
||||
**Behavior.** When in Edit with a palette object selected (terrain/prop/stickman/area), **right-clicking** ends draw/placement mode — the palette button toggles off and the cursor returns to normal. **LMB keeps the existing Phase 2 repeated-placement behavior** (each LMB click/drag commits one placement and the tool stays active); RMB is the explicit "put the tool down" gesture.
|
||||
|
||||
> **Decision (user):** the plan's "LMB ends placement" was a typo — **RMB activates build cancelling**. This preserves Phase 2 repeated placement.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `_unhandled_input` / `_gui_input` / world input path — add an RMB branch: when Edit mode + placement id active (or an active terrain drag), cancel the drag (free ghosts, keep already-placed cells if the drag committed), call `set_placement_mode("")` (frees the ghost and un-toggles the palette button via `set_placement_mode` `:456-464`), restore the cursor.
|
||||
- Keep **Esc** cancel (`_unhandled_key_input` `:279-280`) for cancelling before committing (same code path).
|
||||
- Verify RMB is not currently bound to another action in Edit mode (direct-mode context menus are DIRECT-only, so no conflict).
|
||||
|
||||
**Edge cases.** RMB during an in-progress terrain drag: cancel the drag — decide and document whether cells already painted in the current drag stay (commit-on-release semantics) or the whole drag is aborted; **recommend abort-the-drag** (nothing placed until release; RMB before release = clean cancel). RMB with no placement active: no-op (do not interfere with gizmo/context behavior). Blocked-cell-only drags (all red) simply place nothing on release; the tool stays active for another drag until RMB/Esc.
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Moving a TriggerArea refreshes its rule connector
|
||||
|
||||
**Root cause (verified).** `StageDirectorVisuals` rule anchors are computed live each draw from `instance_from_id(...).global_position` (`stage_director_visuals.gd:257-287`), so a redraw **would** follow a moved area — but `_draw()` only runs when `_dirty` is set (`:80-83`), and `SandboxStage._on_transform_committed()` (`:875-879`) saves object state + marks nav dirty for terrain but **never calls `_director_visuals.mark_dirty()`**. Translating a `TriggerArea` via the gizmos (`StageGizmos.drag_to` → `end_drag` → `transform_committed`) therefore leaves the dashed connector at the stale position.
|
||||
|
||||
**Fix.** In `_on_transform_committed()` (`:875`), call `_director_visuals.mark_dirty()` whenever any moved node is a `TriggerArea` (or, simpler and cheap: unconditionally, since a moved stickman/prop also anchors rule lines/badges). Optionally also mark dirty **during** the drag for live-follow: add a lightweight `transform_dragged`/`transform_changed` signal or have `SandboxStage._handle_mouse_motion` (`:335`) call `mark_dirty()` while `_gizmos.is_dragging()`.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`: `_on_transform_committed` (`:875`).
|
||||
- `scripts/stage_director_visuals.gd`: no change required (already recomputes live); optionally remove the `_dirty` gate and `queue_redraw()` every frame in `_process` for simplicity, but keep the gate (cheaper) and drive it via `mark_dirty`.
|
||||
|
||||
**Edge cases.** Rotating (not translating) an area also repositions its corners but `get_area_rect()` is axis-aligned around the node origin, so the connector anchor (`area.global_position`, `:265`) is unchanged by rotation — acceptable. Deleting a referenced area already triggers `_cleanup_rules_for_nodes` → `set_rules` → `mark_dirty` (`:1273-1278`).
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Styling & theme JSON
|
||||
|
||||
**Behavior.** A single, hand-editable JSON config file drives: (1) the Direct-mode action/trigger popup font + emoji size (currently too small), (2) the assignment badge emoji size under stickmen/objects, (3) the grid snap size, and (4) configurable font names for the sandbox UI. All defaults live in the file; the stage loads it at `_ready()` and falls back to built-in constants if missing/malformed.
|
||||
|
||||
**Data/format changes (NEW file).** `res://sandbox_theme.json` (committed asset, editable in the editor or by hand). Concrete schema with defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"action_popup_font_size": 24,
|
||||
"action_popup_emoji_size": 22,
|
||||
"assignment_badge_font_size": 20,
|
||||
"assignment_badge_radius": 9,
|
||||
"rule_label_font_size": 16,
|
||||
"status_pill_font_size": 16,
|
||||
"tooltip_font_size": 18
|
||||
},
|
||||
"grid": {
|
||||
"snap_size": 15.0
|
||||
},
|
||||
"mode_colors": {
|
||||
"edit_accent": "#22c6ff",
|
||||
"direct_accent": "#ffb300",
|
||||
"play_accent": "#33dd77",
|
||||
"guide_line": "#22c6ff"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `fonts.ui_font` / `fonts.emoji_font`: `res://` paths (e.g. `res://assets/fonts/...`). Empty string = `ThemeDB.fallback_font`. **No font assets exist yet**; loading an empty path or a missing file falls back to `ThemeDB.fallback_font` with a single `push_warning`.
|
||||
- `action_popup_font_size` / `action_popup_emoji_size` → applied via `PopupMenu.add_theme_font_size_override("font_size", n)` on `_action_popup`, `_trigger_popup`, `_rule_action_popup`, `_rule_more_popup` (`sandbox_stage.gd:696-731`). Emoji size is the font size too (emoji render at the same size); if a dedicated `emoji_font` is set, apply `add_theme_font_override("font", emoji_font)`.
|
||||
- `assignment_badge_font_size` / `assignment_badge_radius` → replaces `StageDirectorVisuals.ICON_SIZE_PX` (12.0) / `RULE_BADGE_RADIUS_PX` (7.0) and, for the order numbers, `NUMBER_FONT_SIZE_PX` (16.0) (`stage_director_visuals.gd:19-33`).
|
||||
- `rule_label_font_size` → replaces `RULE_LABEL_FONT_SIZE_PX` (14.0) (`:30`).
|
||||
- `status_pill_font_size` / `tooltip_font_size` → new badge/tooltip labels.
|
||||
- `grid.snap_size` → the **default** grid size. Persistence model: keep the live user value in `user://sandbox_settings.json` (`grid_size`, `:1500-1527`) as the runtime source of truth; `sandbox_theme.json` supplies the **initial default** (and the clamp min/max stay `MIN_GRID_SIZE`/`MAX_GRID_SIZE`, `:52-53`). On first run (no `user://` file), seed `_grid_size` from the theme.
|
||||
- `mode_colors` → drive the status pill (§2.4), the mode frame (§2.3), and the dashed guide line (§2.6.2). Parse via `Color(html_string)` / `Color.from_string` guarded with fallback constants.
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- **New file** `res://sandbox_theme.json` (the schema above).
|
||||
- `scripts/sandbox_stage.gd`: new `const THEME_PATH := "res://sandbox_theme.json"`; `_load_theme()` called in `_ready()` (`:200`) before `_build_ui()`; store `_theme: Dictionary`; apply popup overrides in `_build_ui()`; push relevant values to `_director_visuals` and the new overlay.
|
||||
- `scripts/stage_director_visuals.gd`: replace hardcoded font-size constants with instance vars set via a new `set_style(cfg: Dictionary)` (defaults = current constants), keeping the existing names as defaults so existing behavior is unchanged when no theme is present.
|
||||
- `scripts/stage_grid.gd` (optional): `grid_alpha` for Direct-mode dimming.
|
||||
|
||||
**Edge cases.** Missing/malformed JSON → log once, use all defaults, do **not** crash. A referenced font file that doesn't exist → `push_warning` + fallback font. Unknown extra keys are ignored (forward-compatible). `Color.from_string` failures → fallback color.
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Actions UX (cursor-attached tooltip, trajectory, ghost marker, action cursor)
|
||||
|
||||
**Behavior.** When a click-awaiting director step is active (`_pending_walk_target` for Walk To; the `RuleStep` steps `TRIGGER_TARGET`/`ACTION_TARGET`/`ACTION_POSITION` for "When… Trigger Area"/"When… Collision" and other rule steps), show the **combined workflow**:
|
||||
|
||||
1. **Custom action cursor** — swap to a flag/reticle cursor (see §2.5).
|
||||
2. **Rubber-band dashed trajectory line** — from the stickman's feet (`rig.global_position - StickmanRig.FOOT_OFFSET`) to the cursor; green when the target is valid/reachable, red when invalid (off-reach / inside solid terrain). For "When…" flows the origin is the trigger/action anchor instead of the stickman.
|
||||
3. **Ghost target marker** — a semi-transparent flag/reticle/footprint at the cursor, snapped to grid when Snap is on; optionally a pulsing floor ring.
|
||||
4. **Cursor-attached floating tooltip pill** — a rounded high-contrast badge (orange/cyan) following the cursor, reading e.g. `🚩 Click to set walk target` + `[Esc to cancel]`, and for the rule steps `🎯 Click the trigger area` / `💥 Click the prop` etc. (reuse the existing `_rule_hint` strings at `sandbox_stage.gd:999-1003`, `:1019`, `:1141`).
|
||||
|
||||
**Affected files/functions.**
|
||||
|
||||
- `scripts/sandbox_stage.gd`:
|
||||
- `_pending_walk_target` (`:157`), `_rule_step`/`_rule_hint` (`:176-180`), `_handle_direct_click` (`:895`), `_handle_rule_click`/`_handle_trigger_target_click`/`_handle_action_target_click`/`_handle_action_position_click` (`:1080-1169`).
|
||||
- New overlay drawing + a code-built tooltip `PanelContainer`+`Label` in the `CanvasLayer`, positioned each frame in `_process()` at `get_viewport().get_mouse_position() + offset`, hidden unless a click-awaiting step is active. **Replace** the top-bar `"Click stage for walk target"` hint (`:621-622`) with the tooltip (or keep the bar text as a fallback).
|
||||
- New helpers: `_is_awaiting_click() -> bool`, `_action_hint_text() -> String`, `_action_origin() -> Vector2`, `_is_target_valid(pos) -> bool`.
|
||||
- `scripts/stage_director_visuals.gd` (or the new overlay): draw the trajectory line + ghost marker; needs the active pending target state, so **prefer a new overlay** owned by `SandboxStage` rather than overloading the director visuals.
|
||||
|
||||
**Edge cases.** Tooltip must not cover the cursor (offset ~16–24 px right/up, flipping near screen edges). Esc must clear the tooltip, cursor, and line in one place (already centralized in `_unhandled_key_input` `:269-282`). The "valid/invalid" color requires a reachability check: reuse `StickmanRig.is_target_reachable` semantics but without a live agent — simplest is: green always during picking, red only when the point is inside a `TerrainBlock` AABB (testable via the grid dictionary / `StageSelection.get_world_aabb`); full nav reachability preview is deferred.
|
||||
|
||||
---
|
||||
|
||||
### 2.11 Walk-waypoint arrival jitter (bugfix)
|
||||
|
||||
Detailed analysis below (§3).
|
||||
|
||||
---
|
||||
|
||||
## 3. Walk-waypoint jitter — root-cause analysis & fix
|
||||
|
||||
### 3.1 Reproduce (from plan)
|
||||
|
||||
Place stickman → Direct → create a walk waypoint → Play → the stickman walks, then **jitters rapidly but slightly up/down at the waypoint** instead of stopping.
|
||||
|
||||
### 3.2 Verified code facts
|
||||
|
||||
- `StickmanRig.walk_to(target, speed)` (`stickman_rig.gd:1032-1060`) sets `_walk_target_feet`, plays `walk_left`/`walk_right`, `_walking = true`, `_walk_done = false`.
|
||||
- `_update_walking(delta)` (`:1067-1118`) — per physics frame:
|
||||
1. Map-sync guard: `NavigationServer2D.map_get_iteration_id(...) == 0 → return` (`:1076`).
|
||||
2. `next_feet = _nav_agent.get_next_path_position()` (forces path update) (`:1084`).
|
||||
3. `if _nav_agent.is_target_reachable():` → **nav** branch: `if is_navigation_finished(): _finish_walk("finished"); return` else `root_target = next_feet + FOOT_OFFSET` (`:1086-1092`).
|
||||
4. `else:` → **direct** branch: `root_target = _walk_target_feet + FOOT_OFFSET` (`:1093-1099`).
|
||||
5. `global_position = global_position.move_toward(root_target, _walk_speed_current * delta)` (`:1100`).
|
||||
6. `if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE: _finish_walk("arrive"); return` (`:1101-1103`).
|
||||
- Constants: `ARRIVE_DISTANCE = 8.0` (root-space) (`:160`), `NAV_PATH_DESIRED_DISTANCE = 8.0`, `NAV_TARGET_DESIRED_DISTANCE = 12.0` (feet-space) (`:161-162`), `FOOT_OFFSET = (0,-385)` (`:156`).
|
||||
- `_finish_walk(reason)` (`:1121-1138`) stops the animation, `_restore_standing_markers()`, `_walking = false`, `_walk_done = true`, emits `arrived(_walk_target_feet)`.
|
||||
- `_walk_mode` (`"nav"|"direct"`) is **recomputed every frame** from `is_target_reachable()` (`:337`, `:1086-1099`); nothing latches it.
|
||||
- `walk_left`/`walk_right` key **`IK_Targets/Torso:position`** between `(0,10)` and `(0,-15)` — a ±12.5 px **vertical body bob** (`scripts/create_animations.gd:80`; baked in `master_rig.tscn` tracks `IK_Targets/Torso:position`, and the `.:facing_profile` track).
|
||||
|
||||
### 3.3 Root-cause hypothesis (ranked; confirm at runtime with `DEBUG_WALK`)
|
||||
|
||||
1. **Mode-flip oscillation (primary).** Because `_walk_mode` is re-evaluated every frame and a clicked waypoint frequently sits **at/near the nav-mesh boundary** (the nav mesh is only the placed terrain polygons — `_rebake_navigation` `:798` — so a waypoint clicked in open space or just off an edge is borderline), `is_target_reachable()` can flip between `true` and `false` across consecutive frames while the agent moves. Each flip swaps `root_target` between:
|
||||
- nav: `next_feet + FOOT_OFFSET` (clamped to the terrain-surface Y), and
|
||||
- direct: `_walk_target_feet + FOOT_OFFSET` (the raw clicked Y).
|
||||
Two targets with a small **vertical** offset → the rig visibly jitters up/down until the `ARRIVE_DISTANCE` guard finally trips.
|
||||
|
||||
2. **Arrival-radius mismatch + intermediate-point re-targeting (secondary).** `is_navigation_finished()` triggers at `NAV_TARGET_DESIRED_DISTANCE` (12 px, feet-space) while the hard arrival guard is `ARRIVE_DISTANCE` (8 px, root-space). Near the destination `get_next_path_position()` can return a point at/behind the agent, so `move_toward` steps can reverse direction (micro-oscillation). The two different radii mean the walk can terminate early ("finished" at 12 px) *or* chase a now-behind `next_feet` point.
|
||||
|
||||
3. **Body-bob frame interaction (visual, tertiary).** The walk animation bobs `IK_Targets/Torso` ±12.5 px each cycle. If the arrival frame's `_anim_player.stop()` (keep_state=false resets to the walk animation's first keyframe) races `_restore_standing_markers()` (writes exact `STAND_POSE`), there can be a brief vertical pop — small-amplitude and "up/down", matching the report.
|
||||
|
||||
### 3.4 Concrete fix approach
|
||||
|
||||
All three are addressed with small, contained changes to `stickman_rig.gd`:
|
||||
|
||||
1. **Latch the mode once per walk.** In `walk_to()` (or on the first post-sync frame), compute `_walk_mode` **once** (`is_target_reachable()`), store it, and stop re-evaluating per frame in `_update_walking`. Optionally allow a one-way upgrade nav→direct only (never direct→nav) to keep steering robust if the path later empties.
|
||||
2. **Unified arrival radius against the FINAL target.** In both branches, once `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE`, call `_finish_walk("arrive")` **and** snap `global_position = _walk_target_feet + FOOT_OFFSET` before restoring markers (removes any residual offset). Drop the premature `_finish_walk("finished")` early-return, or keep it only when the rig is *also* within `ARRIVE_DISTANCE`.
|
||||
3. **Steer to the final target when close.** When within e.g. `2 * ARRIVE_DISTANCE` of the final target, ignore `next_feet` and move directly toward `_walk_target_feet + FOOT_OFFSET` (prevents chasing a behind-path point).
|
||||
4. **Make stop/restore atomic.** In `_finish_walk`, call `_anim_player.stop()` then `_restore_standing_markers()` (already ordered correctly), and add a one-frame re-assert (`_restore_standing_markers()` again next physics frame if `_walk_done`) if the trace shows a residual bob. Keep `DEBUG_WALK` (`:167`) prints to capture `mode`, `dist`, `next`, `final` at arrival.
|
||||
|
||||
**Verification:** enable `DEBUG_WALK` and `DEBUG_STAGE`, reproduce with (a) a waypoint on flat ground, (b) a waypoint in open space off the terrain, (c) a waypoint exactly on a terrain edge. The fix is verified when `mode` stays constant for the whole walk and exactly one `arrive` fires with no post-arrival position change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation order / checklist (grouped by workstream)
|
||||
|
||||
> Order is dependency-aware; each workstream is independently testable.
|
||||
|
||||
**WS0 — Theme JSON foundation (do first; everything else reads it).**
|
||||
- [ ] Add `res://sandbox_theme.json` (§2.9 schema).
|
||||
- [ ] `SandboxStage._load_theme()` + `_theme` var; call in `_ready()` before `_build_ui()`.
|
||||
- [ ] `StageDirectorVisuals.set_style(cfg)` (defaults = current constants).
|
||||
- [ ] Headless test: missing file / malformed JSON → defaults, no crash.
|
||||
|
||||
**WS1 — UI chrome.**
|
||||
- [ ] Bottom status bar + `_status_cursor_coords` + `_process` polling (§2.1).
|
||||
- [ ] `StageMode { EDIT, DIRECT, PLAY }` refactor + 3-segment control + `_enter_direct_mode()` (§2.2).
|
||||
- [ ] `_set_edit_controls_visible` / `_set_direct_controls_visible` split (§2.2).
|
||||
- [ ] Viewport mode frame + grid visibility per mode (§2.3).
|
||||
- [ ] Mode badge pill + `_refresh_mode_badge()` (§2.4).
|
||||
- [ ] Per-mode cursor `_apply_cursor()` (§2.5).
|
||||
|
||||
**WS2 — Terrain placement.**
|
||||
- [ ] Grid spatial dictionary (`_grid_cells`, `_rasterize_aabb_to_cells`, populate/update/delete paths) (§2.6.5).
|
||||
- [ ] `TerrainBlock` spawn-id tagging + `StageSpawner.get_template_aabb(id)` (§2.6.4).
|
||||
- [ ] Drag anchor/target + Shift cardinal lock (§2.6.1).
|
||||
- [ ] Bresenham path + per-cell ghost array + 3-state tint (§2.6.2–2.6.4).
|
||||
- [ ] Batch commit + single `_nav_dirty` (§2.6.5).
|
||||
- [ ] RMB ends placement; LMB keeps repeated placement (§2.7).
|
||||
- [ ] Guide-line overlay (new `scripts/stage_placement_overlay.gd`).
|
||||
|
||||
**WS3 — Director UX.**
|
||||
- [ ] `_on_transform_committed` → `_director_visuals.mark_dirty()` (+ live-drag dirty) (§2.8).
|
||||
- [ ] Cursor-attached tooltip pill + `_is_awaiting_click`/`_action_hint_text` (§2.10).
|
||||
- [ ] Rubber-band trajectory + ghost marker + green/red validity (§2.10).
|
||||
- [ ] Flag/reticle action cursor (§2.10, shares §2.5 infra).
|
||||
- [ ] Apply popup font/emoji overrides from theme (§2.9).
|
||||
|
||||
**WS4 — Bugfix.**
|
||||
- [ ] `StickmanRig._update_walking` mode latch + unified arrival + snap-on-arrive + atomic stop (§3.4).
|
||||
- [ ] `DEBUG_WALK` capture for the tester.
|
||||
|
||||
**WS5 — Docs.**
|
||||
- [ ] Update `README.md` §18–20 (mode switcher, status bar, theme JSON, terrain painting, jitter fix).
|
||||
- [ ] Append entries to `docs/tech_debt_and_optimizations.md` (grid dictionary addresses #14; walk-mode latch note).
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing plan
|
||||
|
||||
There is **no CLI build/test/lint**; the project is run in the Godot editor (F5/F6). The established **headless assertion pattern** (verified in `tests/test_text_baseline_fix.gd`) is:
|
||||
|
||||
```
|
||||
& "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/<name>.gd --path .
|
||||
```
|
||||
|
||||
The script `extends SceneTree`, prints `PASS/FAIL` per assertion, and `quit(0/1)`. The tester agent will author such scripts under `tests/`. Manual F6 verification on `res://scenes/sandbox_stage.tscn` is also required for visual/interaction features.
|
||||
|
||||
**Behaviors that must be verified:**
|
||||
|
||||
1. **Theme JSON** — missing file → defaults; malformed → defaults + single warning; overridden `action_popup_font_size` actually changes `PopupMenu` font size; `grid.snap_size` seeds `_grid_size` on first run.
|
||||
2. **Status bar** — `_status_cursor_coords` updates each frame with world coords; pans/zooms reflect in the numbers.
|
||||
3. **Mode switcher** — 3 segments; Edit shows spawners+grid, Direct hides them, Play hides them; `mode_changed` emits 0/1/2; entering Direct from Play and vice-versa clears pending state; Esc from Direct → Edit.
|
||||
4. **Mode frame/badge/cursor** — correct accent per mode; frame never intercepts input; cursor shape correct per mode and restored after cancel.
|
||||
5. **Terrain painting** — Case A/B/C from §2.6.4 produce the expected block counts; Shift lock yields axis-aligned runs with no diagonals; batch commit spawns all blocks in one frame (nav baked once); same-type overlap skips; conflicting-object cells are red and skipped; LMB ends placement and toggles the button off.
|
||||
6. **Grid dictionary** — moving/deleting a block updates occupancy; querying a cell returns correct classification; no stale entries after delete.
|
||||
7. **Trigger-area move** — translating an area whose rule exists moves the dashed connector (and `⚡` badge) to the new position on drag end (and, if implemented, live during drag).
|
||||
8. **Director tooltip/trajectory** — pending walk target shows tooltip + dashed line + ghost marker; Esc clears all three; rule-step hints use the tooltip; green/red validity for in-terrain target.
|
||||
9. **Walk jitter** — with `DEBUG_WALK` on, `mode` stays constant for the whole walk; exactly one `arrived` fires; rig `global_position` is unchanged after arrival for 60+ physics frames; body-bob stops (Torso marker at `(0,10)`).
|
||||
10. **Regression** — sequential queues, reactive rules (`entered_area`, `collided`), ragdoll/recover, and prop unfreeze still work (Phase 3a/4 acceptance).
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks & open questions
|
||||
|
||||
### Risks
|
||||
|
||||
1. **Mode refactor scope.** Folding `_direct_mode` into a 3-value `StageMode` touches input routing (`_handle_world_click`, `_handle_mouse_motion`, Esc ordering), status, and the visuals enable/disable paths. Low logic risk but broad; mitigate by keeping Direct internally an "Edit-with-direct" state (a thin `_enter_direct_mode()` that reuses `_enter_edit_mode()` side effects).
|
||||
2. **Grid spatial dictionary correctness.** Rasterizing arbitrary AABBs (rotated/oversized props/areas) to cells is the main new algorithm; a wrong rasterization causes wrong 3-state tints. Keep the dictionary **advisory** (visual tint + skip) and never authoritative for physics; always re-derive from `World` when in doubt.
|
||||
3. **Custom cursor asset.** No image assets exist. Generating a reticle/flag via `Image.create()`/`ImageTexture` at runtime avoids asset dependency; otherwise a `res://assets/` addition is required.
|
||||
4. **Bresenham in world vs cell space.** Terrain cells are `TERRAIN_GRID_SIZE=16` but the stage grid is `_grid_size` (default 15). Decide which grid drives terrain painting (see Open Questions) — mixing them produces misaligned ghosts.
|
||||
|
||||
### Open questions (resolved by user 2026-09-02)
|
||||
|
||||
1. **Terrain painting grid.** → **Terrain grid size 16** (`StageSpawner.TERRAIN_GRID_SIZE`). Terrain cells quantize to 16, matching terrain template dimensions so blocks align edge-to-edge; the user stage grid (`_grid_size`) remains a separate visual/snap aid.
|
||||
2. **"Build cancelling" vs repeated placement.** → **RMB activates build cancelling** (the plan's "LMB" was a typo). LMB keeps Phase 2 repeated placement; RMB/Esc put the tool down. §2.7 updated accordingly.
|
||||
3. **Play toolbar "Pause/Restart".** → **Out of scope for Phase 4b.** Play keeps only the mode switcher; note as future work.
|
||||
4. **Direct toolbar contents.** → **Hint label only.** The director stays click/popup-driven; the Direct toolbar segment shows a brief instruction hint.
|
||||
5. **Grid visibility in Direct mode.** → **Hide the grid entirely** in Direct (amber frame only). §2.3 updated accordingly.
|
||||
6. **Theme JSON location/persistence split.** → **Accepted:** `res://sandbox_theme.json` = hand-editable styling defaults; `user://sandbox_settings.json` = live persisted values (grid size, snap, etc.).
|
||||
7. **Jitter fix confirmation.** → **Yes** — developer may enable `DEBUG_WALK`/`DEBUG_STAGE` during the fix and must revert both to OFF before merge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 4b.1 bugfix decisions (2026-09-02, post-implementation triage)
|
||||
|
||||
> Recorded per the fix-pipeline triage of 5 user-reported bugs. Bug 1 was a **Spec/Design
|
||||
> Defect** — the resolved open-question #1 encoded a false premise. The other four are
|
||||
> Implementation Defects and need no spec change beyond this record.
|
||||
|
||||
### Decision D1 — Terrain paint stride = template extent (Bug 1)
|
||||
|
||||
The earlier decision ("terrain cells quantize to `TERRAIN_GRID_SIZE` 16, matching terrain
|
||||
template dimensions") is **factually wrong**: templates are Ground 200×32, Ramp 192×128,
|
||||
Step 256×256 px, so stamping one full block per 16-px Bresenham cell causes massive
|
||||
overlap.
|
||||
|
||||
**New rule (supersedes open-question #1):** terrain drag-painting quantizes to **block
|
||||
units** whose stride is the active template's AABB extent per axis
|
||||
(`StageSpawner.get_template_aabb(id).size`, e.g. Ground → `(200, 32)`). Cell centers are
|
||||
`block_cell * stride`; Bresenham runs over block units.
|
||||
|
||||
- **Horizontal/vertical runs (incl. Shift-locked):** blocks tile edge-to-edge, no overlap,
|
||||
no gaps.
|
||||
- **Free diagonals:** blocks tile corner-to-corner (adjacent diagonal blocks share exactly
|
||||
a corner point — zero overlap, visually acceptable corner gaps).
|
||||
- The 16-px `_grid_cells` dictionary remains **advisory only** (§2.6.5) for the 3-state
|
||||
occupancy query and already rasterizes real AABBs.
|
||||
- In-drag same-type self-overlap: mark freshly painted block cells in the dictionary (or a
|
||||
transient in-drag set) so a drag crossing its own path skips re-stamping (§2.6.4 Case B).
|
||||
|
||||
### Bug 2/4/5 decisions (implementation only, no design change)
|
||||
|
||||
- Bug 2 (guide line persists after release) and Bug 4 (single-placement guide circles) are
|
||||
overlay redraw/state defects in `stage_placement_overlay.gd` + `_update_terrain_drag` —
|
||||
fix: `queue_redraw()` on clear; suppress the guide when `target == anchor` (single click).
|
||||
- Bug 5 (no cursor-following terrain ghost) is a Phase 2 regression — `_spawn_ghost()`
|
||||
must allow terrain again; drag start frees the single ghost, drag end re-spawns it
|
||||
(LMB-repeated placement preserved).
|
||||
- Bug 3 (residual waypoint jitter) extends §3.4 without changing its intent: add a
|
||||
nav-termination condition (`is_navigation_finished()` gated on
|
||||
`dist_to_final <= 2*ARRIVE_DISTANCE`) and remove vertical drift on final approach.
|
||||
@@ -0,0 +1,176 @@
|
||||
Phase 4.4 Diagnostic — Trigger Areas Not Firing
|
||||
markdown
|
||||
|
||||
# Phase 4.4 Diagnostic — Trigger Areas Not Firing
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AGENTS.md documentation claims `_update_area_entry()` exists and evaluates `entered_area` rules, but the user reports **nothing happens when a rule is applied to an area**. This plan is a diagnostic + fix procedure to isolate and repair the break.
|
||||
|
||||
## 2. Suspected Root Causes (Ranked)
|
||||
|
||||
| # | Suspected Cause | Symptom |
|
||||
| --- | ---------------------------------------------------------- | ------------------------------------------ |
|
||||
| 1 | `_update_area_entry()` never called in PLAY | No events fire at all |
|
||||
| 2 | Rule's `trigger.target` reference goes stale (bad weakref) | Event fires but action target invalid |
|
||||
| 3 | Edge-trigger dictionary uses wrong key | Event fires once, then never re-fires |
|
||||
| 4 | Area rect coordinates wrong (local vs. global) | Stickman visually inside, geometry says no |
|
||||
| 5 | `TriggerArea` not a child of `World` | Detection loop never sees the area |
|
||||
| 6 | Rule builder doesn't actually save `entered_area` rules | Rule appears created, `_event_rules` empty |
|
||||
| 7 | Stickman's feet anchor wrong | Uses bone position instead of feet |
|
||||
|
||||
## 3. Diagnostic Procedure (Do First)
|
||||
|
||||
Ask the developer to add **4 temporary debug prints** and run a test in PLAY mode. Report which prints appear.
|
||||
|
||||
### Print 1 — Top of `_update_area_entry()`
|
||||
|
||||
```gdscript
|
||||
print("[AREA] checking ", _world.get_child_count(), " world children")
|
||||
```
|
||||
|
||||
If this never prints: \_update_area_entry() is not called from \_physics_process in PLAY. Check the call site.
|
||||
|
||||
Print 2 — Before rule matching
|
||||
|
||||
```gdscript
|
||||
print("[AREA] area=", area.name, " rect=", area.get_area_rect(), " movable=", movable.name, " pos=", pos)
|
||||
```
|
||||
|
||||
If this never prints: the detection loop isn't iterating areas, or the area isn't a TriggerArea child of World.
|
||||
|
||||
Print 3 — When a rule matches
|
||||
|
||||
```gdscript
|
||||
print("[RULE] matched rule id=", rule.id, " for area=", area.name)
|
||||
```
|
||||
|
||||
If this never prints but Print 2 does: the rule's trigger.target.get_ref() doesn't equal area. Reference is stale or wrong type.
|
||||
|
||||
Print 4 — Before action executes
|
||||
|
||||
```gdscript
|
||||
print("[ACTION] executing ", action.type, " on target=", action.target.get_ref())
|
||||
```
|
||||
|
||||
If this never prints but Print 3 does: the rule matched but actions array is empty, or execution is guarded out.
|
||||
|
||||
4. Fix Plan (Per Root Cause)
|
||||
Fix 1 — Missing \_physics_process Call
|
||||
The event engine must run only in PLAY mode:
|
||||
|
||||
```gdscript
|
||||
func _physics_process(delta: float) -> void:
|
||||
if current_mode != StageMode.PLAY:
|
||||
return
|
||||
_update_area_entry()
|
||||
_update_stickman_prop_collision()
|
||||
```
|
||||
|
||||
Verify the call exists and is guarded by the PLAY check.
|
||||
|
||||
Fix 2 — Stale trigger.target Reference
|
||||
Rules must store a live weakref to the TriggerArea node, not a serialized position or instance ID. At rule creation:
|
||||
|
||||
```gdscript
|
||||
rule.trigger.target = weakref(area_node)
|
||||
```
|
||||
|
||||
At evaluation:
|
||||
|
||||
```gdscript
|
||||
var target = rule.trigger.target.get_ref()
|
||||
if target == null:
|
||||
continue # skip stale rule
|
||||
if target != area:
|
||||
continue
|
||||
```
|
||||
|
||||
If the reference is stored as anything else, rewrite the rule builder to use weakref().
|
||||
|
||||
Fix 3 — Wrong Edge-Trigger Key
|
||||
The edge-trigger dictionary must key on BOTH the area ID and the movable ID:
|
||||
|
||||
```gdscript
|
||||
var edge_key := str(area.get_instance_id()) + "_" + str(movable.get_instance_id())
|
||||
if _area_fired_events.has(edge_key):
|
||||
continue
|
||||
_area_fired_events[edge_key] = true
|
||||
# fire rules...
|
||||
```
|
||||
|
||||
Using only the area ID means only one stickman can fire the event. Using only the movable ID means the same stickman can't trigger two different areas.
|
||||
|
||||
Fix 4 — Wrong Area Coordinates
|
||||
TriggerArea.get_area_rect() must return a world-space Rect2:
|
||||
|
||||
```gdscript
|
||||
func get_area_rect() -> Rect2:
|
||||
return Rect2(global_position - size * 0.5, size)
|
||||
```
|
||||
|
||||
Do not use position (local) — the area may be a child of a moved parent.
|
||||
|
||||
For the movable's position, use the stickman's feet:
|
||||
|
||||
```gdscript
|
||||
var pos := stickman.global_position + StickmanRig.FOOT_OFFSET
|
||||
```
|
||||
|
||||
Fix 5 — Area Not in World
|
||||
The palette spawner must add the TriggerArea as a direct child of World, not \_ghost_holder or any other intermediate node. The spawner's commit path (\_place_at) should call:
|
||||
|
||||
```gdscript
|
||||
_world.add_child(area)
|
||||
```
|
||||
|
||||
Verify the placement path — the ghost is spawned first (into \_ghost_holder), then the actual object is added to World on commit. If the commit path is broken for areas specifically, fix \_spawn_area().
|
||||
|
||||
Fix 6 — Rule Builder Doesn't Save entered_area
|
||||
The "⚡ When..." flow must actually append a rule to \_event_rules with:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"id": rule_id,
|
||||
"trigger": {
|
||||
"type": "entered_area",
|
||||
"target": weakref(area_node),
|
||||
},
|
||||
"actions": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Add a debug print in \_finalize_rule() to verify the rule is committed with the correct shape.
|
||||
|
||||
Fix 7 — Wrong Feet Anchor
|
||||
Use stickman.global_position + StickmanRig.FOOT_OFFSET for the "position" tested against the area rect. Do not use bone positions or the rig root alone.
|
||||
|
||||
5. Acceptance Criteria
|
||||
|
||||
- [ ] Placing an Area via the palette creates a TriggerArea in World.
|
||||
- [ ] A rule with an entered_area trigger can be created (targeting a specific area).
|
||||
- [ ] Walking a stickman into the area in PLAY fires the rule.
|
||||
- [ ] The rule's action executes on the correct target.
|
||||
- [ ] The event fires once per entry (edge-triggered).
|
||||
- [ ] Leaving and re-entering the area re-fires the event.
|
||||
- [ ] Multiple areas + multiple stickmen work independently.
|
||||
- [ ] No console warnings/errors when Play starts.
|
||||
|
||||
6. Test Cases
|
||||
|
||||
| # | Test | Expected |
|
||||
| --- | ---------------------------------------------- | --------------------------- |
|
||||
| 1 | Stickman walks into area speaks | Speech bubble appears once |
|
||||
| 2 | Stickman walks out and back in speaks again | Speech bubble appears twice |
|
||||
| 3 | Stickman stands still in area for 5s no repeat | Speech bubble appears once |
|
||||
| 4 | Two stickmen enter same area each fires | Two events fire |
|
||||
| 5 | Stickman enters area with no rule | Nothing happens, no errors |
|
||||
| 6 | Rule targets deleted area | No crash warning logged |
|
||||
| 7 | Area moved in Edit | Play New position used |
|
||||
| 8 | 5 areas + 5 stickmen + 5 rules | All fire independently |
|
||||
|
||||
7. Deliverable
|
||||
A fix commit that resolves whichever of the 7 root causes the 4 debug prints isolate. Include a brief note in the commit message describing which root cause was found and what was changed.
|
||||
|
||||
8. Out of Scope
|
||||
New trigger types (e.g., exited_area, stay_in_area).
|
||||
@@ -0,0 +1,506 @@
|
||||
# Phase 5 — Sandbox Scene File Features
|
||||
|
||||
Status: implemented and verified in Godot/headless Phase 5 coverage.
|
||||
|
||||
## 1. Goal and boundaries
|
||||
|
||||
Phase 5 gives the standalone Sandbox Stage Builder durable, versioned scene files. It adds New, Open, Open Recent, Save, Save As, Rename Scene, dirty-state prompts, an autosave-on-Play recovery path, and tolerant handling of missing `.stk` assets.
|
||||
|
||||
This plan targets `res://scenes/sandbox_stage.tscn`, which is run separately with F6. It does not change the stickman editor's existing `.stk` Save/Load flow or the project's main scene.
|
||||
|
||||
Current architecture facts that constrain the design:
|
||||
|
||||
- `SandboxStage` builds its entire UI in `scripts/sandbox_stage.gd`; the `.tscn` contains only `SandboxStage/Camera2D` and `SandboxStage/World`.
|
||||
- There is no existing sandbox File menu or Clear command. Phase 5 creates the menu; it does not reuse a Clear flow.
|
||||
- All authored objects are direct children of `World`. `NavigationRegion2D`, gizmos, overlays, ragdoll bodies, and collision proxies are not scene-file objects.
|
||||
- `_authored` is the source of the edit pose restored after Play. Saving must use that authored transform rather than a prop's or rig's transient physics transform. Currently `_on_transform_committed()` refreshes `_authored` only for `RigidBody2D`; Phase 5 must move `_save_object_state(node)` outside that type branch so transformed terrain, areas, and stickmen are also authoritative.
|
||||
- Runtime rules store `Object.get_instance_id()` integers in `trigger.source`, `trigger.target`, and rule-action `target`. Those IDs cannot be written as persistent references.
|
||||
- Queue and rule dictionaries contain `Vector2` values. They require an explicit JSON conversion; raw runtime dictionaries are not the on-disk contract.
|
||||
- `TerrainBlock.spawn_id` records its source template. `PropBlock` does not currently record its prop-library ID, and `StickmanRig` does not record its source `.stk` path. Phase 5 must add that provenance at spawn time.
|
||||
|
||||
## 2. Files and classes
|
||||
|
||||
| File | Planned responsibility |
|
||||
| --- | --- |
|
||||
| `scripts/stage_scene_io.gd` (new `class_name StageSceneIO`, `extends RefCounted`) | Own the v1.0 JSON contract, JSON-safe action/rule conversion, validation, migration dispatch, and failure-safe disk reads/writes. It does not mutate the live tree. |
|
||||
| `scripts/sandbox_stage.gd` | Own file-menu/dialog flow, dirty state, pending destructive actions, live-tree collection/application, runtime-ID mapping, recents, autosave lifecycle, title/status updates, and one final navigation rebuild. |
|
||||
| `scripts/stage_spawner.gd` | Record asset provenance and expose load-safe spawn methods whose positions are actual object origins, not placement-cursor coordinates. |
|
||||
| `scripts/prop_block.gd` | Add `var spawn_id: String = ""`. |
|
||||
| `scripts/stickman_rig.gd` | Add `var source_stk_path: String = ""`. This is provenance only and does not change runtime rig behavior. |
|
||||
| `scripts/missing_stickman_placeholder.gd` (new `MissingStickmanPlaceholder`, `extends Node2D`) | Draw and expose selection bounds for a missing-stickman marker while retaining the original stickman file entry. |
|
||||
| `tests/test_phase5_file_features.gd` (new) | Headless codec, round-trip, mapping, dirty-state, missing-asset, and autosave lifecycle coverage. |
|
||||
|
||||
No authored node changes are required in `scenes/sandbox_stage.tscn`; the new controls remain code-built to match the current stage.
|
||||
|
||||
## 3. Versioned `.scene` contract
|
||||
|
||||
The extension is `.scene`. The content is UTF-8, tab-indented JSON. v1.0 uses only JSON primitives, arrays, and dictionaries.
|
||||
|
||||
### 3.1 Root schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"scene_name": "Playground",
|
||||
"camera": {
|
||||
"position": { "x": 0.0, "y": -400.0 },
|
||||
"zoom": 0.5
|
||||
},
|
||||
"objects": [],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1789420000,
|
||||
"modified_unix": 1789420300,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `version` is required and must equal a supported version after migration.
|
||||
- `scene_name` is a non-empty string after trimming; invalid or empty input becomes `"Untitled"`.
|
||||
- `camera` is optional. Missing/invalid values use position `(0, -400)` and uniform zoom `0.5`; loaded zoom is clamped to the stage's `min_zoom`/`max_zoom`.
|
||||
- `objects` and `event_rules` are required arrays. Unknown root and entry keys are ignored and are not preserved on the next save.
|
||||
- `created_unix` is retained across manual saves. `modified_unix` is refreshed for each written snapshot. Both are UTC Unix seconds.
|
||||
- Manual files use `autosave: false` and an empty `source_path`. An autosave uses `autosave: true` and stores the manual scene path, if any, in `source_path`.
|
||||
|
||||
All positions are stage-world coordinates. All rotations are degrees in the file and radians at runtime. All vectors use `{ "x": number, "y": number }`.
|
||||
|
||||
### 3.2 Object IDs and ordering
|
||||
|
||||
Each entry has a required, non-empty `file_id` string. IDs must be unique across the whole `objects` array; a duplicate entry is rejected before anything is spawned or inserted into the reference map. On each save, objects are walked in current `World` child order and assigned readable IDs with per-type counters: `terrain_1`, `prop_1`, `stickman_1`, `area_1`. IDs are stable within that snapshot, not permanent node identities.
|
||||
|
||||
The encoder simultaneously builds `runtime instance ID -> file_id`. The loader spawns objects in saved array order and builds `file_id -> runtime instance ID`. Navigation does not require terrain-first spawning, so preserving file order avoids an unnecessary World-order change; navigation is rebuilt once after all objects and rules are applied.
|
||||
|
||||
### 3.3 Object entries
|
||||
|
||||
Terrain:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"asset_id": "ground",
|
||||
"position": { "x": 0.0, "y": 0.0 },
|
||||
"rotation": 0.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 }
|
||||
}
|
||||
```
|
||||
|
||||
Prop:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "prop_1",
|
||||
"type": "prop",
|
||||
"asset_id": "crate",
|
||||
"position": { "x": 100.0, "y": -40.0 },
|
||||
"rotation": 12.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 },
|
||||
"material_preset": "WOOD"
|
||||
}
|
||||
```
|
||||
|
||||
Stickman:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"stk_path": "res://stickmen/test.stk",
|
||||
"position": { "x": 0.0, "y": -385.0 },
|
||||
"rotation": 0.0,
|
||||
"facing_profile": "FORWARD",
|
||||
"action_queue": [
|
||||
{ "type": "walk_to", "target": { "x": 300.0, "y": 0.0 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Trigger area:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "area_1",
|
||||
"type": "area",
|
||||
"position": { "x": 500.0, "y": 0.0 },
|
||||
"rotation": 0.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 },
|
||||
"size": { "x": 160.0, "y": 80.0 }
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Terrain `asset_id` comes from `TerrainBlock.spawn_id`.
|
||||
- Prop `asset_id` comes from the new `PropBlock.spawn_id`. `material_preset` is saved as the stable enum name `"NONE"`, `"WOOD"`, `"RUBBER"`, `"CARDBOARD"`, or `"METAL"` rather than its reorderable integer, even though current library templates also provide it.
|
||||
- `stk_path` is the exact normalized source used to spawn the rig: keep `res://` paths project-relative and keep external paths absolute. Scene files do not embed `.stk` data in Phase 5.
|
||||
- Stickman `position`, `rotation`, and `facing_profile` come from `_authored` when available, not the live Play pose. Root scale is intentionally not stored for stickmen because `StickmanRig.set_facing_profile(LEFT)` derives the root X mirror. Reapplying a serialized mirror would double-flip the rig.
|
||||
- Terrain, prop, and area scale are saved. The present gizmo does not edit scale, but transformed areas already exist in tests and must round-trip correctly.
|
||||
- Only known stage object classes are serialized. Ghosts, overlays, ragdoll bodies, navigation nodes, collision proxies, and unknown World children are skipped with a warning.
|
||||
|
||||
### 3.4 Queue-action schema
|
||||
|
||||
Queue actions preserve their existing flat runtime shape, with vectors converted to JSON dictionaries:
|
||||
|
||||
```text
|
||||
walk_to -> { "type": "walk_to", "target": {x, y} }
|
||||
speak -> { "type": "speak", "text": String, "duration": float, optional "stop_current": bool }
|
||||
wait -> { "type": "wait", "duration": float }
|
||||
ragdoll -> { "type": "ragdoll" }
|
||||
recover -> { "type": "recover" }
|
||||
```
|
||||
|
||||
Order is preserved. `StageSceneIO` validates types against `ActionRegistry`; malformed or unknown actions are skipped with warnings instead of reaching the runner.
|
||||
|
||||
### 3.5 Event-rule schema and reference boundary
|
||||
|
||||
The disk rule deliberately differs from the live rule at reference fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 4,
|
||||
"trigger": {
|
||||
"type": "entered_area",
|
||||
"source_id": "stickman_1",
|
||||
"target_id": "area_1",
|
||||
"params": {}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "speak",
|
||||
"target_id": "stickman_2",
|
||||
"params": { "text": "Hello", "duration": 2.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `source_id`/`target_id` are file-ID strings. A runtime `-1` wildcard is encoded as JSON `null` and decoded back to `-1`.
|
||||
- Trigger params are: `waypoint_pos: {x,y}` for `arrived_at_waypoint`, `action_type: String` for `action_finished`, and `{}` for the other current trigger types.
|
||||
- Rule-action params keep the existing nested runtime shape; `walk_to.params.target` is converted to/from `{x,y}`.
|
||||
- On load, a rule is installed only when every non-null object reference resolves to a compatible live object. One unresolved reference skips the complete rule and contributes to the final load warning.
|
||||
- Runtime rules remain unchanged: after decoding they again use integer instance IDs under `trigger.source`, `trigger.target`, and action `target`. Existing event matching, panels, and editors therefore need no data-model rewrite.
|
||||
- Loaded numeric rule IDs are retained when unique and non-negative. Duplicate/invalid IDs are reassigned in array order. `_next_rule_id` becomes `max(loaded IDs) + 1`.
|
||||
|
||||
This conversion boundary replaces the original assumption that the runtime rule dictionary can be serialized unchanged.
|
||||
|
||||
### 3.6 Validation and migration
|
||||
|
||||
`StageSceneIO` validates the complete root before the live stage is cleared. A missing file, I/O failure, invalid JSON, non-dictionary root, absent/unsupported version, or non-array `objects`/`event_rules` fails the load and leaves the current stage untouched.
|
||||
|
||||
Individual invalid object/action/rule entries are warn-and-skip. Every decoded numeric field must be finite (`is_finite()`); vectors require finite numeric `x` and `y`, rotations/durations/zoom reject NaN or infinity, and area size must be positive. Unknown keys are dropped. v1.0 is the first version, so `_migrate_to_current(data: Dictionary) -> Dictionary` has no transformations yet, but version dispatch must exist and reject newer unsupported versions cleanly.
|
||||
|
||||
## 4. `StageSceneIO` API contracts
|
||||
|
||||
```gdscript
|
||||
class_name StageSceneIO
|
||||
extends RefCounted
|
||||
|
||||
const FILE_VERSION: String = "1.0"
|
||||
|
||||
static func read_file(path: String) -> Dictionary
|
||||
# { "ok": bool, "data": Dictionary, "error": String, "warnings": Array[String] }
|
||||
|
||||
static func write_file_safe(path: String, data: Dictionary) -> Dictionary
|
||||
# { "ok": bool, "error": String }
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary
|
||||
# { "actions": Array[Dictionary], "warnings": Array[String] }
|
||||
|
||||
static func decode_queue(entries: Array) -> Dictionary
|
||||
# { "actions": Array[Dictionary], "warnings": Array[String] }
|
||||
|
||||
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary
|
||||
# { "rules": Array[Dictionary], "warnings": Array[String] }
|
||||
|
||||
static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary
|
||||
# { "rules": Array[Dictionary], "warnings": Array[String], "skipped": int }
|
||||
|
||||
static func vector_to_data(value: Vector2) -> Dictionary
|
||||
static func is_vector_data(value: Variant) -> bool
|
||||
static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2
|
||||
```
|
||||
|
||||
`write_file_safe` uses sibling `<target>.tmp` and `<target>.bak` files because Godot's `DirAccess.rename_absolute(temp, target)` is not a guaranteed overwrite operation when `target` already exists on Windows:
|
||||
|
||||
1. Resolve and verify the absolute target/temp/backup paths share the same parent directory.
|
||||
2. Write, flush, and close the complete temporary file.
|
||||
3. If a target exists, remove only a stale verified sibling backup, then rename the target to the backup.
|
||||
4. Rename the temporary file to the target.
|
||||
5. If step 4 fails, rename the backup back to the target and report failure; never report success until the new target exists.
|
||||
6. On success, remove the backup. On startup/read, if the target is absent but its backup exists, restore the backup before reading; if both target and backup exist, keep the target and remove the stale backup. Stale temporary files are safe to remove.
|
||||
|
||||
Each same-directory rename is atomic at the filesystem level; the backup and rollback protocol protects the prior destination across the multi-step replacement and makes an interrupted swap recoverable. No recursive filesystem operation is involved.
|
||||
|
||||
## 5. Spawn and provenance contracts
|
||||
|
||||
`StageSpawner.spawn(id, cursor_position)` remains the palette API, including the stickman's `STICKMAN_FOOT_OFFSET`. Add load-safe APIs that do not apply a second placement offset:
|
||||
|
||||
```gdscript
|
||||
func spawn_terrain_asset(asset_id: String, object_position: Vector2) -> TerrainBlock
|
||||
func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock
|
||||
func spawn_stickman_asset(stk_path: String, root_position: Vector2) -> StickmanRig
|
||||
func spawn_area(object_position: Vector2) -> TriggerArea
|
||||
```
|
||||
|
||||
The existing palette helpers delegate to these methods. On success:
|
||||
|
||||
- terrain sets `TerrainBlock.spawn_id = asset_id`;
|
||||
- prop sets `PropBlock.spawn_id = asset_id`;
|
||||
- stickman sets `StickmanRig.source_stk_path = normalized stk_path`.
|
||||
|
||||
Unknown terrain/prop IDs return `null` plus a warning. A missing or malformed `.stk` returns `null` and is handled by the stage as §11 describes. Load APIs must not mutate `selected_prop_id` or `selected_stickman_path`.
|
||||
|
||||
## 6. Sandbox stage state and internal APIs
|
||||
|
||||
Add:
|
||||
|
||||
```gdscript
|
||||
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
|
||||
|
||||
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
|
||||
const RECENT_SCENE_LIMIT: int = 5
|
||||
const DEFAULT_SCENE_NAME: String = "Untitled"
|
||||
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
|
||||
const DEFAULT_CAMERA_ZOOM: float = 0.5
|
||||
|
||||
var _current_scene_path: String = ""
|
||||
var _scene_name: String = DEFAULT_SCENE_NAME
|
||||
var _scene_created_unix: int = 0
|
||||
var _dirty: bool = false
|
||||
var _suppress_dirty: bool = false
|
||||
var _recent_scenes: Array[String] = []
|
||||
var _pending_file_action: PendingFileAction = PendingFileAction.NONE
|
||||
var _pending_open_path: String = ""
|
||||
```
|
||||
|
||||
Primary stage methods:
|
||||
|
||||
```gdscript
|
||||
func _mark_scene_dirty() -> void
|
||||
func _set_scene_dirty(value: bool) -> void
|
||||
func _collect_scene_data(is_autosave: bool = false) -> Dictionary
|
||||
func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool
|
||||
func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool
|
||||
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool
|
||||
func _reset_scene_content() -> void
|
||||
func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void
|
||||
func _continue_pending_file_action() -> void
|
||||
func _update_scene_chrome() -> void
|
||||
func _update_recent_menu() -> void
|
||||
```
|
||||
|
||||
`_suppress_dirty` is set with a scoped save/restore around load/New operations. It prevents spawn wiring and queue population from marking the new document dirty. It must be restored on every return path.
|
||||
|
||||
## 7. File menu, dialogs, and layout
|
||||
|
||||
Add a `MenuButton` as the first control in the existing code-built top-bar `HBoxContainer`. Give new dynamic controls explicit names so tests and future code can resolve them:
|
||||
|
||||
```text
|
||||
SandboxStage/UI (CanvasLayer)
|
||||
├── TopBar (PanelContainer)
|
||||
│ └── Content (HBoxContainer)
|
||||
│ └── FileMenu (MenuButton)
|
||||
├── SceneOpenDialog (FileDialog)
|
||||
├── SceneSaveDialog (FileDialog)
|
||||
├── RenameSceneDialog (ConfirmationDialog)
|
||||
│ └── SceneNameEdit (LineEdit)
|
||||
├── MissingAssetsDialog (AcceptDialog)
|
||||
├── UnsavedChangesDialog (ConfirmationDialog)
|
||||
└── AutosaveRecoveryDialog (ConfirmationDialog)
|
||||
```
|
||||
|
||||
The existing asset `BrowseDialog` remains dedicated to `.stk`. The existing generic `_confirm_dialog` remains dedicated to delete/clear confirmations; file flow uses separate dialogs and callbacks.
|
||||
|
||||
File menu items:
|
||||
|
||||
| Item | Contract |
|
||||
| --- | --- |
|
||||
| New | Dirty-check, then reset content/name/path/camera. |
|
||||
| Open… | Dirty-check, then show `SceneOpenDialog`. |
|
||||
| Open Recent | Submenu, most recent first; missing paths are pruned when rebuilt. |
|
||||
| Save | Save to `_current_scene_path`, or open Save As when empty. |
|
||||
| Save As… | Open `SceneSaveDialog`; append `.scene` if the selected path has another/no extension. |
|
||||
| Rename Scene… | Opens a wide `ConfirmationDialog` with Cancel, pre-fills `SceneNameEdit`, focuses it, and selects all text. The `LineEdit` uses the placeholder `Scene name`, which is visible only while the field is empty. A changed, non-empty confirmed name marks dirty. |
|
||||
|
||||
There is no separate Clear item: the live sandbox has no existing Clear command, and New already supplies the destructive blank-stage behavior.
|
||||
|
||||
The Open and Save dialogs use `ACCESS_FILESYSTEM` and filter `*.scene ; Stickman Scene Files`. Keyboard accelerators may be added for Ctrl+N/O/S and Ctrl+Shift+S, but are not required for Phase 5 acceptance.
|
||||
|
||||
`_update_scene_chrome()` sets:
|
||||
|
||||
- window title: `"%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]` through `get_window().title`, where `scene_file` is the current `.scene` basename or `Unsaved`;
|
||||
- status prefix: `"%s | Objects: ..."`, retaining the existing selection, pending-action, rule-hint, toast, and cursor behavior.
|
||||
|
||||
## 8. Dirty-state and unsaved-changes flow
|
||||
|
||||
Dirty becomes true on:
|
||||
|
||||
- successful object placement, terrain paint commit, transform commit, or deletion;
|
||||
- any rig `queue_changed` signal;
|
||||
- rule create/update/delete/clear/reorder;
|
||||
- a committed scene rename.
|
||||
|
||||
Camera pan/zoom, selection/hover, grid/snap settings, mode changes, Play physics, and opening/canceling an editor do not dirty the file.
|
||||
|
||||
Replace the current rig queue connection to `StageDirectorVisuals.mark_dirty` with a stage handler that calls both `mark_dirty()` and `_mark_scene_dirty()`.
|
||||
|
||||
Unsaved protection applies before New, Open, Open Recent, and window close. `UnsavedChangesDialog` exposes Save, Discard, and Cancel:
|
||||
|
||||
- Save performs Save or Save As. The pending action continues only after a successful write. Canceling Save As or a write failure cancels the pending action.
|
||||
- Discard hides the unsaved-changes dialog and continues the pending action without writing. New therefore clears the stage and closes the prompt in the same operation.
|
||||
- Cancel/window-close clears the pending action and changes nothing.
|
||||
|
||||
Only one pending action exists at a time. For close interception, set `get_tree().auto_accept_quit = false` in `_ready()` and handle `NOTIFICATION_WM_CLOSE_REQUEST`. A clean close, successful save-then-close, or explicit discard-then-close retires the autosave before `get_tree().quit()`.
|
||||
|
||||
Dirty is cleared only by successful manual Save/Save As, successful ordinary load, and New. Autosave never changes `_current_scene_path`, recents, timestamps held for the manual document, or `_dirty`.
|
||||
|
||||
## 9. New and load transaction
|
||||
|
||||
`_reset_scene_content()` first returns to Edit semantics so queues/ragdolls/physics are stopped, then closes transient popups and capture flows, clears selection and authored physics targets, removes all direct `World` children, and clears:
|
||||
|
||||
- `_authored`, `_event_rules`, `_next_rule_id`;
|
||||
- `_area_overlap`, `_collision_pairs`, terrain occupancy, director visuals;
|
||||
- selection/hover/gizmo targets and placement ghosts.
|
||||
|
||||
New additionally sets `Untitled`, empty current path, a new creation timestamp, the default camera, clean state, then rebuilds the grid/navigation/status once.
|
||||
|
||||
Ordinary load is transactional at the document boundary:
|
||||
|
||||
1. Read, parse, root-validate, and migrate before touching the live stage.
|
||||
2. If root validation fails, show one error and keep the current document unchanged.
|
||||
3. Reset the live content under `_suppress_dirty`.
|
||||
4. Spawn valid objects in saved array order. Apply transform/properties before calling the existing `_finalize_placed_node()` wiring.
|
||||
5. Build `file_id -> runtime instance ID`; track missing-stickman IDs separately.
|
||||
6. Decode/install only rules whose references resolve.
|
||||
7. Restore camera, name, creation timestamp, authored transforms, `_next_rule_id`, grid occupancy, and director visuals. Clear any queued `_nav_dirty` request, then rebuild navigation exactly once.
|
||||
8. Ordinary load adopts the normalized source path, clears dirty, adds it to recents, and updates title/status.
|
||||
|
||||
Object-level warnings are summarized in one toast after load; details also go to `push_warning`. A partially valid document therefore loads without turning one bad entry into total data loss.
|
||||
|
||||
## 10. Recent files and settings merge
|
||||
|
||||
Extend the existing `user://sandbox_settings.json` rather than creating another settings file:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"grid_size": 15.0,
|
||||
"snap_to_grid": false,
|
||||
"show_grid": true,
|
||||
"recent_scenes": ["C:/Scenes/playground.scene"]
|
||||
}
|
||||
```
|
||||
|
||||
`_load_settings()` type-checks the list, normalizes/deduplicates paths, removes missing files, and caps it at five. `_save_settings()` writes both existing grid fields and recent scenes so one settings change cannot erase the other feature's state.
|
||||
|
||||
A successful manual open or save moves that path to the front. Autosave is never added. The submenu rebuild removes newly missing paths and displays one disabled `(No recent scenes)` row when empty. If a recent file disappears between menu rebuild and click, show `File not found: <path>`, remove it, save settings, and leave the current document untouched.
|
||||
|
||||
## 11. Missing or corrupt `.stk` files
|
||||
|
||||
Add `MissingStickmanPlaceholder` with:
|
||||
|
||||
```gdscript
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_stk_path: String = ""
|
||||
|
||||
func configure(entry: Dictionary) -> void
|
||||
func get_area_rect() -> Rect2
|
||||
```
|
||||
|
||||
It draws a red rectangle labeled `Missing asset - <filename>` at local origin. `get_area_rect()` makes it selectable by the current duck-typed stage bounds path. It is a normal World child for movement, rotation, and deletion, but it has no rig/action/event behavior.
|
||||
|
||||
When a stickman asset is absent or fails JSON parsing:
|
||||
|
||||
1. Log the detailed failure.
|
||||
2. Spawn a placeholder at the saved authored transform.
|
||||
3. Store a deep copy of the original stickman entry, including its `stk_path`, facing, and action queue.
|
||||
4. Mark its `file_id` unavailable when decoding rules, so every rule that refers to that missing rig is skipped as a whole.
|
||||
5. Show one end-of-load toast with missing-stickman and skipped-rule counts.
|
||||
6. Show one `MissingAssetsDialog` listing each distinct missing asset filename and saved path, with a note that red markers identify their scene positions.
|
||||
|
||||
Saving a placeholder serializes it back as a `type: "stickman"` entry using `original_entry`, updated with the placeholder's current file ID and authored transform. Thus the missing path and queue survive and can recover automatically if the `.stk` later returns. The placeholder is not serialized as a new fifth object type. Deleting the placeholder and saving removes that stickman entry.
|
||||
|
||||
## 12. Autosave-on-Play and recovery lifecycle
|
||||
|
||||
Before `set_mode(StageMode.PLAY)` changes mode or unfreezes physics, write the authored scene snapshot to `user://sandbox_autosave.scene`. Failure shows a toast and logs a warning but does not block Play.
|
||||
|
||||
Autosave write rules:
|
||||
|
||||
- use the same v1.0 schema and failure-safe writer;
|
||||
- set `metadata.autosave = true`, `metadata.source_path = _current_scene_path`, and a fresh `modified_unix`;
|
||||
- do not clear dirty, update recents, adopt the autosave path, or change the manual document's creation/modified bookkeeping.
|
||||
|
||||
On `_ready()`, after UI construction and settings load, inspect the autosave. Offer recovery only when it is valid and either has no source path, its source is missing, or its `modified_unix` is newer than the source file's filesystem modification time. A stale/invalid autosave is removed with a warning.
|
||||
|
||||
- Recover applies the snapshot without adding the autosave path to recents. It restores `metadata.source_path` as `_current_scene_path` only when non-empty, and sets `_dirty = true` so the recovered snapshot must be manually saved. Keep the autosave until that manual save/New/Open/discard/clean shutdown retires it, protecting a second crash before save.
|
||||
- Decline deletes the autosave and keeps the new blank stage.
|
||||
- A successful manual save retires the autosave.
|
||||
- A normal confirmed shutdown retires it. A crash/forced process stop leaves it behind, which is what makes the next-launch prompt meaningful.
|
||||
|
||||
This lifecycle prevents a normal Play followed by an orderly exit from prompting on every next launch.
|
||||
|
||||
## 13. Signals and theme impact
|
||||
|
||||
No new public gameplay signal is required. Existing stage signals keep their current payloads and behavior. Loading should not emit `object_placed` once per reconstructed entry; it applies a document snapshot under `_suppress_dirty` and refreshes dependents once at the end.
|
||||
|
||||
The file controls use the existing stage font and popup-theme helpers. No new `sandbox_theme.json` key is required. Dialog buttons use Godot's standard dialog theme.
|
||||
|
||||
## 14. Verification strategy
|
||||
|
||||
Create `tests/test_phase5_file_features.gd` and run it headlessly with the project's established test form:
|
||||
|
||||
```powershell
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase5_file_features.gd"
|
||||
```
|
||||
|
||||
Required automated coverage:
|
||||
|
||||
1. Vector/action/rule codecs emit only JSON-safe types and round-trip every current action/trigger type.
|
||||
2. Runtime instance IDs encode to file IDs and decode to new live instance IDs, including wildcard `null <-> -1`.
|
||||
3. Terrain/prop/stickman/area provenance, authored position/rotation, non-rig scale, area size, named material/facing enums, queues, rule order, and World order survive save/load.
|
||||
4. A LEFT-facing rig loads with one mirror only; its derived root scale is not serialized.
|
||||
5. Save while/after Play uses `_authored`, not displaced physics state.
|
||||
6. Parse/root-validation failure does not clear the existing World.
|
||||
7. Empty/duplicate file IDs, unknown object IDs, non-finite numbers/vectors, and malformed entries skip safely; navigation is rebuilt once.
|
||||
8. Missing/corrupt `.stk` creates a selectable red labeled placeholder, shows a load-error dialog listing the missing asset(s), skips dependent rules, and re-save preserves the original stickman entry and queue.
|
||||
9. Place/transform/delete, queue edits, rule edits/reorder, and rename dirty the scene; camera/grid/selection/mode do not. Load-time reconstruction remains clean.
|
||||
10. Rename focuses and selects all text, exposes Cancel, and uses an empty-field-only placeholder hint. Save-via-Save-As continuation and Save/Discard/Cancel pending-action paths behave correctly, including canceled/failed saves and a closed prompt after Discard.
|
||||
11. Recents deduplicate, reorder, cap at five, prune missing paths, and never include autosave.
|
||||
12. Autosave does not clear dirty or adopt a path; stale autosaves are pruned, recovery is dirty, decline/manual save/orderly quit retire the file.
|
||||
13. Safe-write replacement, rollback, and interrupted-backup recovery leave the prior destination recoverable and report failures.
|
||||
|
||||
After the focused suite passes, run every existing script under `tests/` using its documented headless command; report suite names/results rather than relying on the stale aggregate assertion counts in older plans.
|
||||
|
||||
Manual Godot Editor verification:
|
||||
|
||||
- exercise File menu and all dialogs, including title `scene name * — scene-file.scene — Stickman Studio`, rename focus/select-all/placeholder behavior, cancel paths, and keyboard/window close;
|
||||
- create all four object types, actions, cross-object rules, transforms, save, close, reload, and Play;
|
||||
- rename/move a referenced `.stk`, verify the load-error dialog lists its filename/path and the scene shows a red `Missing asset - <filename>` rectangle, save, restore the `.stk`, and reload;
|
||||
- force-stop after Play to verify recovery, then exit normally after Play to verify no false recovery prompt.
|
||||
|
||||
## 15. Approval gate: decisions to accept before implementation
|
||||
|
||||
The architect recommends approving these as a set:
|
||||
|
||||
1. Keep `.scene` as the user-facing JSON extension and v1.0 as the first file version.
|
||||
2. Use per-save readable file IDs and a strict runtime-ID/file-ID conversion boundary; never persist Godot instance IDs.
|
||||
3. Add typed provenance fields to `PropBlock` and `StickmanRig`, plus load-safe `StageSpawner` APIs, instead of relying on mutable palette selections or ad-hoc node metadata.
|
||||
4. Omit a redundant Clear command; New is the blank-stage destructive action.
|
||||
5. Preserve missing stickmen as stickman entries through a selectable placeholder, but drop complete rules that reference an unavailable rig.
|
||||
6. Keep one autosave snapshot, write it on Play, retain it only across unclean exits/recovery, and mark recovered work dirty.
|
||||
7. Drop unknown keys on re-save and reject newer unsupported versions; do not attempt forward-compatible preservation in v1.0.
|
||||
8. Preserve saved World order and rebuild navigation once after load rather than grouping terrain first.
|
||||
|
||||
## 16. Out of scope
|
||||
|
||||
- Undo/redo
|
||||
- Scene thumbnails/library/browser/templates
|
||||
- Periodic or rotating autosave history
|
||||
- Bundling/embedding `.stk` contents into `.scene`
|
||||
- Relinking a missing `.stk` from the placeholder UI
|
||||
- Cloud sharing/collaboration
|
||||
- Video/GIF export
|
||||
|
||||
Implementation and focused headless validation are complete; the user confirmed the feature is verified and working, so this plan records the final behavior for handoff.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Phase 6 — Continuous Dynamic IK & Terrain-Following Locomotion
|
||||
|
||||
Status: implemented and verified in the Godot editor. This document records the shipped runtime behavior and supersedes the rejected authoring-time normalization design.
|
||||
|
||||
## 1. Goal and scope
|
||||
|
||||
During PLAY, every animated stickman continuously conforms its feet and pelvis to TerrainBlock ground. An unsupported rig enters a kinematic, gravity-driven free-fall; supported rigs independently sample both lower-leg endpoints, hold stance contacts, preserve lifted swing arcs, and compensate the root for ramps and steps. The lower-leg endpoints are the foot contacts: Phase 6 adds no foot/ankle bones or new visual geometry.
|
||||
|
||||
This is runtime locomotion. The phase does not change palette placement or stored walk-action coordinates; both remain existing feet-space values. Ragdoll physics and recovery stay independent from animated terrain contacts.
|
||||
|
||||
## 2. Constraints
|
||||
|
||||
- StickmanRig owns live IK_Targets and runs physics processing; SandboxStage owns the EDIT-to-PLAY handoff.
|
||||
- TerrainBlock is a solid StaticBody2D. Terrain, props, and rig collision proxies share layer 1, so probes accept only TerrainBlock hits with upward-facing normals.
|
||||
- The walk_right and legacy walk_left clips write the layered `AnimationTargets/*_Base` markers; terrain code is the sole runtime writer of the live leg IK targets.
|
||||
- NavigationAgentAnchor remains at (0, 385) and FOOT_OFFSET remains the sole feet-space to root-space conversion.
|
||||
- Navigation is baked from exposed upward-facing terrain edges as ribbons, with bounded bridges for valid steps; solid terrain interiors are not route surfaces.
|
||||
|
||||
## 3. Layered IK ownership
|
||||
|
||||
### 3.1 Scene structure
|
||||
|
||||
`master_rig.tscn` contains base-pose markers which are not read by the Skeleton modification stack:
|
||||
|
||||
Master (StickmanRig)
|
||||
├── IK_Targets
|
||||
│ ├── Left_Leg # final target; TwoBoneIK reads this
|
||||
│ └── Right_Leg # final target; TwoBoneIK reads this
|
||||
└── AnimationTargets
|
||||
├── Left_Leg_Base # authored walk animation writes here
|
||||
└── Right_Leg_Base # authored walk animation writes here
|
||||
|
||||
The two leg-position tracks in the walk clips write the `AnimationTargets` markers. Existing hand, torso, and head tracks keep their current targets. The rig is then the sole runtime writer of the live `IK_Targets/Left_Leg` and `IK_Targets/Right_Leg` positions.
|
||||
|
||||
### 3.2 Rig controller API and state
|
||||
|
||||
StickmanRig owns a private controller with these constants/state:
|
||||
|
||||
TERRAIN_PROBE_UP = 96
|
||||
TERRAIN_PROBE_DOWN = 700
|
||||
TERRAIN_NORMAL_MIN_Y = -0.15
|
||||
FOOT_CONTACT_EPSILON = 2
|
||||
FOOT_BLEND_RESPONSE = 14
|
||||
TERRAIN_SUPPORT_RANGE = 160
|
||||
TERRAIN_MAX_FOOT_SURFACE_DROP = 72
|
||||
TERRAIN_FOOT_GUARD_HEIGHT = 24
|
||||
PELVIS_MAX_OFFSET = 240
|
||||
PELVIS_RESPONSE = 10
|
||||
WALK_CYCLE_DURATION = 0.8
|
||||
|
||||
_leg_base_markers # Left_Leg/Right_Leg -> Marker2D
|
||||
_foot_contacts # leg -> {valid, world_pos, normal, locked}
|
||||
_terrain_pelvis_offset
|
||||
|
||||
New methods:
|
||||
|
||||
begin_terrain_fall() -> void
|
||||
snap_to_terrain() -> void # compatibility alias; arms the PLAY controller
|
||||
set_terrain_following_enabled(enabled: bool) -> void
|
||||
_update_terrain_following(delta) -> void
|
||||
_probe_terrain_below(world_point) -> Dictionary
|
||||
|
||||
`begin_terrain_fall()` is called on PLAY entry and is safe/idempotent: it enables the controller, clears contacts, resets fall velocity, and starts unsupported rigs in free-fall. `snap_to_terrain()` remains a compatibility alias. `set_terrain_following_enabled(false)` clears contacts, resets terrain state, and copies base markers back to live targets. Missing nodes/world state warn with a `StickmanRig:` prefix and skip safely.
|
||||
|
||||
### 3.3 Probe contract
|
||||
|
||||
The center support sample uses a three-ray horizontal fan at offsets `[-24, 0, +24]`. Each lower-leg endpoint uses its own three-ray fan at `[-4, 0, +4]`; each ray starts 96 px above its world X and ends 700 px below. A result is accepted only when:
|
||||
|
||||
1. its collider is a TerrainBlock;
|
||||
2. its normal has y <= TERRAIN_NORMAL_MIN_Y; and
|
||||
3. it is in the stated probe span.
|
||||
|
||||
The fan selects the highest valid surface (smallest world Y). Queries use layer 1, reject non-`TerrainBlock` colliders, reject normals below the upward-facing threshold, and exclude the rig's own collision-proxy RIDs. Invalid rays never invent a floor. World/global transforms preserve LEFT root mirroring.
|
||||
|
||||
## 4. Per-frame solve order
|
||||
|
||||
In `StickmanRig._physics_process()`, terrain following runs only while terrain following is enabled and `state == ANIMATED`, after walking moves the root and before collision proxies synchronize:
|
||||
|
||||
AnimationPlayer updates AnimationTargets base pose
|
||||
↓
|
||||
walking moves root along nav/direct baseline
|
||||
↓
|
||||
terrain controller:
|
||||
sample contacts → gait weights → foot locks → pelvis offset → final targets
|
||||
↓
|
||||
Skeleton TwoBoneIK solves final targets
|
||||
↓
|
||||
collision proxies synchronize
|
||||
|
||||
The shipped ordering reads the base markers, applies walking/root motion, solves terrain contacts, then synchronizes proxies. `RAGDOLL` and `RECOVERING` do not run the animated terrain controller or write the live leg targets.
|
||||
|
||||
RECOVERING and RAGDOLL are excluded because they own or hide the same targets; the terrain controller does not write them in those states.
|
||||
|
||||
## 5. Stance/swing blending
|
||||
|
||||
The current walk clips are 0.8 seconds and alternate legs. The controller computes complementary continuous cosine weights over that cycle:
|
||||
|
||||
| Cycle interval | Right leg | Left leg |
|
||||
| --- | --- | --- |
|
||||
| 0.00–0.40 | stance weight 1 → 0 | swing weight 0 → 1 |
|
||||
| 0.40–0.80 | swing weight 0 → 1 | stance weight 1 → 0 |
|
||||
|
||||
The complementary cosine weights provide a continuous transition; idle uses stance weight 1.0 for both legs.
|
||||
|
||||
- At stance entry, capture the valid terrain point as a world-space foot lock. While planted, transform the stable world point back to rig-local space; this prevents horizontal foot skating as the root moves.
|
||||
- In swing, retain the animation base target and apply only the terrain influence needed to prevent penetration. A 24 px predictive foot guard ramps that correction in before the endpoint reaches the surface.
|
||||
- Between phases, blend base and lock/contact targets with response limiting to avoid knee pops at step edges.
|
||||
- At idle both legs are stance. They can independently lock to a ramp or two different stair heights; the root does not rotate.
|
||||
|
||||
## 6. Pelvis/root compensation
|
||||
|
||||
Calculate a desired vertical correction from the centerline terrain fan, clamp it to `PELVIS_MAX_OFFSET` (240 px), and ease it with `PELVIS_RESPONSE`.
|
||||
|
||||
runtime root Y += terrain pelvis correction
|
||||
|
||||
Runtime root elevation accumulates directly as the controller advances across consecutive ramps and steps; the last-frame correction variable is cleared each frame and is not subtracted from the root. If the centerline has no supported TerrainBlock, the root free-falls with `TERRAIN_GRAVITY` up to `TERRAIN_MAX_FALL_SPEED`, and walk animation pauses until landing. Landing applies the full support correction to prevent tunneling. Feet too far below the center support surface are ignored so an overlapping lower floor cannot pull a leg through a ramp.
|
||||
|
||||
## 7. Play entry and walkable-surface navigation
|
||||
|
||||
On PLAY entry, after props unfreeze, `SandboxStage` calls `begin_terrain_fall()` for every rig before `start_queue()`. This is the only mode that enables terrain free-fall/following. EDIT and DIRECT call `set_terrain_following_enabled(false)` and restore the captured authored transform/standing pose; terrain following never mutates authored placement.
|
||||
|
||||
### 7.1 Walkable-surface ribbon
|
||||
|
||||
SandboxStage replaces the full-solid NavigationPolygon bake with a derived walkable-surface ribbon:
|
||||
|
||||
1. Transform every TerrainBlock polygon into stage-world coordinates.
|
||||
2. Extract only upward-facing boundary edges from the clockwise TerrainBlock polygons; discard vertical edges and undersides.
|
||||
3. Expand each accepted edge into a narrow, configurable navigation strip just above the collision surface.
|
||||
4. Bridge neighboring surface endpoints only when the horizontal gap is at most 64 px and the height difference is at most 96 px, connecting authored stair treads without making cliffs walkable.
|
||||
5. Bake the resulting strips as the NavigationPolygon. Large cliffs, terrain interiors, walls, undersides, and unsupported gaps remain disconnected.
|
||||
|
||||
The ribbon follows ramps continuously and connects valid stair treads while deliberately refusing impossible steps. Terrain collision remains authoritative for the final ankle probe; the ribbon supplies only route topology.
|
||||
|
||||
### 7.2 Walking alignment
|
||||
|
||||
Walking retains `NavigationAgentAnchor` at `(0, 385)` and synchronizes `NavigationAgent2D.max_speed` with the selected walk speed. Navigation supplies horizontal progress; terrain following owns vertical root motion. Arrival compares horizontal/root baseline distance and does not let terrain Y correction create waypoint jitter. On-mesh paths follow the ribbon; unreachable/off-mesh targets use latched direct steering.
|
||||
|
||||
### 7.3 Trigger and reactive-action behavior
|
||||
|
||||
`SandboxStage._update_area_entry()` tests the visible stickman's world AABB and unions it with the previous physics-frame AABB. This swept bound catches `entered_area` events during fast PLAY-only free-fall instead of relying on a single sampled pose. Rules still dispatch while the rig is `ANIMATED` and airborne.
|
||||
|
||||
Reactive takeover actions preserve their authored order within one rule batch. The first takeover cancels existing primary/overlay work; later takeover actions remain queued, so a `[ragdoll, recover]` rule executes ragdoll first, waits for rest, then recovers. The runtime terrain trace is gated by `DEBUG_TERRAIN := false` and is off by default.
|
||||
|
||||
## 8. Files and ownership
|
||||
|
||||
| File | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| scripts/stickman_rig.gd | Base-marker resolution, PLAY-only kinematic free-fall, center/per-foot terrain fans, contact locks, gait weights, predictive foot guard, accumulated root elevation, pelvis controller, and terrain-state cleanup. |
|
||||
| master_rig.tscn | `AnimationTargets/Left_Leg_Base` and `Right_Leg_Base` markers plus redirected leg animation tracks. |
|
||||
| scripts/sandbox_stage.gd | PLAY terrain-controller handoff; EDIT/DIRECT authored-state restoration; exposed-surface ribbon baking; swept trigger-area bounds; and reactive action dispatch. |
|
||||
| scripts/terrain_utils.gd | Add pure world-polygon edge classification/strip helpers if extracting that geometry keeps SandboxStage focused on lifecycle and orchestration. |
|
||||
| scripts/terrain_block.gd, scripts/stage_spawner.gd | No behavioral change. |
|
||||
|
||||
## 9. Milestones and acceptance
|
||||
|
||||
### 1. Play Snap
|
||||
|
||||
- [x] PLAY-only hovering rigs free-fall onto flat ground, ramps, and stair treads before/while queues run.
|
||||
- [x] EDIT/DIRECT disable terrain following and restore the authored transform.
|
||||
|
||||
### 2. Idle Slope IK
|
||||
|
||||
- [x] Independent lower-leg endpoint contacts visibly flex knees across ramps/stair edges.
|
||||
- [x] Probes accept only TerrainBlock upward surfaces and exclude rig proxies; RAGDOLL/RECOVERING are excluded.
|
||||
|
||||
### 3. Walk Cycle Blending
|
||||
|
||||
- [x] Stance feet remain planted while opposite feet follow lifted swing arcs with continuous weights and a predictive guard.
|
||||
- [x] Uphill/downhill and stairs avoid dragging, penetration, and one-frame knee/pelvis pops in editor verification.
|
||||
- [x] Cancel, ragdoll, and recovery safely clear controller state.
|
||||
|
||||
### 4. Navigation Sync
|
||||
|
||||
- [x] The navigation anchor stays (0, 385); navigation speed follows the selected walk speed and terrain code owns vertical motion.
|
||||
- [x] The baked navigation mesh covers exposed ribbons and valid connected steps, not solid terrain interiors or undersides.
|
||||
- [x] Flat, ramps, stairs, and off-mesh arrival remain deterministic without terrain-Y waypoint jitter.
|
||||
- [x] Collision proxies synchronize after final terrain IK.
|
||||
- [x] Swept visible bounds detect trigger-area entry during fast free-fall; reactive `[ragdoll, recover]` batches execute in order.
|
||||
|
||||
## 10. Verification record
|
||||
|
||||
Editor verification covered hovering PLAY entry, flat ground, ramps, consecutive ramps, steps, walking climbs, falling/landing, trigger-area entry during free-fall, ragdoll/recover reactive rules, and EDIT/DIRECT restoration. The shipped design confirms:
|
||||
|
||||
- the two-layer animation/final-IK target structure;
|
||||
- world-space stance locks with phase-weighted swing blending;
|
||||
- additive, clamped runtime pelvis correction without authored/nav-offset changes;
|
||||
- terrain-only contacts with center/per-foot fans and PLAY-only free-fall; and
|
||||
- a derived walkable-surface ribbon that replaces full-solid navigation baking.
|
||||
|
||||
The temporary terrain diagnostics remain available behind `DEBUG_TERRAIN`, but the gate is `false` in the shipped build.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase 6a Architecture Proposal: Airborne Momentum, Dynamic Air-Pose & Landing Impact Absorption
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Phase 6a extends the shipped Phase 6 continuous terrain locomotion system by replacing rigid vertical free-falls and instant landings with dynamic, momentum-driven airborne physics. It introduces horizontal air momentum decay, procedural wind-drag pose flailing during fall, and velocity-proportional landing impact compression that temporarily holds action queue execution.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Mechanics & State Integration
|
||||
|
||||
### 2.1 Airborne Horizontal Momentum Decay
|
||||
|
||||
- **Momentum Snapshot**: Upon detecting a loss of ground support during `ANIMATED` mode, `StickmanRig` caches the last horizontal velocity ($v_x$) from navigation or manual walking.
|
||||
|
||||
- **Parabolic Arc**: During free-fall frames, $v_x$ continues to shift root $X$ while applying exponential air drag ($\text{AIR\_DRAG\_X} = 4.0$). Vertical gravity (`TERRAIN_GRAVITY`) accelerates $Y$ down to `TERRAIN_MAX_FALL_SPEED`.
|
||||
|
||||
### 2.2 Procedural Air Pose & Wind-Drag Flail
|
||||
|
||||
- **Limb Displacement**: As downward velocity ($v_y$) increases, upper limb `IK_Targets` blend away from `AnimationTargets/*_Base` markers, pulling hands above the head to simulate wind resistance.
|
||||
|
||||
- **High-Frequency Flail**: Layer anti-phased sine-wave offsets ($\sim 12\text{ Hz}$) onto hand and foot IK targets, scaling amplitude with $v_y$ to create a realistic airborne fluttering effect.
|
||||
|
||||
### 2.3 Landing Impact Compression & Queue Hold
|
||||
|
||||
- **Velocity Capture**: On `_probe_ground_y()` contact detection, record the final impact fall speed ($v_{\text{impact}}$).
|
||||
|
||||
- **Impact Absorption**: If $v_{\text{impact}} \ge \text{MIN\_IMPACT\_VELOCITY}$, trigger a 2-phase pelvis squash tween:
|
||||
|
||||
1. **Compress**: Rapidly ease pelvis Y downward ($\sim 0.08\text{s}$) into a deep crouch, forcing leg `TwoBoneIK` solvers to flex while feet stay locked to ground contacts.
|
||||
|
||||
2. **Recover**: Spring pelvis Y back up to standing terrain height ($\sim 0.22\text{s}$) using a sine-out easing curve.
|
||||
|
||||
- **Queue Synchronization**: Set an internal `_is_landing_impact` flag during absorption to pause `action_queue` processing, resuming automatically once standing recovery completes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component & API Specification
|
||||
|
||||
| Component | Target System | Implementation Details |
|
||||
| --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Momentum Tracker** | `StickmanRig._physics_process()` | Snapshot $v_x$ at fall transition; apply $v_x \cdot \Delta t$ with exponential decay to root $X$ during free-fall. |
|
||||
| **Air Pose Solver** | `StickmanRig` IK Stack | Offset live arm/leg `IK_Targets` dynamically relative to base markers based on $v_y$. |
|
||||
| **Impact Absorption** | `StickmanRig` Terrain Controller | Run pelvis squash/spring tween on ground contact; hold `action_queue` until complete. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification & Acceptance Criteria
|
||||
|
||||
- **Ledge Ejection**: Walking off an elevated ledge projects the rig outward in a parabolic curve rather than dropping straight down.
|
||||
|
||||
- **Air Flail**: Arms flex upward and hands/feet flutter rhythmically during high-speed free-fall.
|
||||
|
||||
- **Impact Cushion**: Drops exceeding minimum velocity threshold exhibit a visible knee bend and pelvis compression before standing up.
|
||||
|
||||
- **Queue Order**: Action queue execution halts during landing impact recovery and resumes cleanly without dropping or skipping pending actions.
|
||||
|
||||
## 5. Open Questions and Edge Cases
|
||||
|
||||
To ensure the implementation runs smoothly, here are the key edge cases and technical questions your architect should clarify before finalizing Phase 6a:
|
||||
|
||||
- **Ragdoll Escalation Threshold**: If the fall velocity ($v_{\text{impact}}$) exceeds a extreme safety limit, should the stickman escalate into a full dynamic ragdoll collapse instead of executing a soft crouch-and-recover animation?
|
||||
- **Mid-Air Wall Collisions**: How should horizontal air momentum ($v_x$) react if the stickman drifts laterally into a vertical wall or prop before touching the floor? Should horizontal velocity immediately zero out on wall contact to prevent body clipping?
|
||||
- **Sloped Landing Compression**: How does pelvic squash behave when landing on an angled ramp or stair tread? We need to confirm whether left and right foot IK targets remain independently anchored to unequal terrain elevations while the pelvis squashes downward.
|
||||
|
||||
- **Action Queue Preemption**: If a dynamic trigger fires or a user issues a new direct action while the stickman is airborne or mid-landing crouch, should the incoming action cancel the landing recovery sequence or wait at the front of the queue?
|
||||
- **Custom Pose Overrides**: How should wind-drag flailing blend if the character goes airborne while performing a non-locomotion action (like holding an item or attacking)? Should flailing override all hand keyframes, or only apply during default walk/idle states?
|
||||
@@ -0,0 +1,134 @@
|
||||
# High-Level Architectural Plan: Graphical UI & Palette Polish Phase
|
||||
|
||||
## Objective
|
||||
|
||||
To overhaul the `SandboxStage` user interface into a polished, kid-friendly graphical experience, while establishing a flexible, schema-driven theme system that seamlessly supports present UI elements and future feature additions (such as dynamic speech bubble styling, sound customization, and extended rule editors).
|
||||
|
||||
---
|
||||
|
||||
## 1. Centralized Theme & Resource Infrastructure
|
||||
|
||||
### 1.1 JSON Theme Schema Expansion (`sandbox_theme.json`)
|
||||
|
||||
- **Unified Theme Specification**: Expand the `sandbox_theme.json` contract to serve as the single source of truth for typography, spatial grids, mode accent colors, and component-specific visual presets.
|
||||
|
||||
- **Subsystem Category Blocks**: Structure the schema into modular sub-dictionaries (e.g., `fonts`, `mode_colors`, `grid`, `popups`, `speech_bubbles`).
|
||||
|
||||
- **Graceful Fallback Pipeline**:
|
||||
|
||||
1. Specific component override value.
|
||||
|
||||
2. Subsystem block default (e.g., `speech_bubbles.style_defaults`).
|
||||
|
||||
3. Global theme default (`fonts.ui_font`).
|
||||
|
||||
4. System fallback (`ThemeDB.fallback_font`).
|
||||
|
||||
- **Resource Preloading & Validation**: Ensure the theme loader validates paths, gracefully handles missing assets with non-blocking warnings, and caches loaded `Font` and `StyleBox` objects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Graphical Stage Chrome & Mode Navigation
|
||||
|
||||
### 2.1 Mode Switcher & Viewport Frame
|
||||
|
||||
- **3-Segment Graphical Switcher**: Replace standard text toggles with a stylized 3-segment mode control (`[✏️ Edit | 🎬 Direct | ▶️ Play]`).
|
||||
|
||||
- **Viewport Frame Accent**: Render a screen-constant border frame (`_mode_frame`) around the viewport that dynamically tints according to the active mode accent color.
|
||||
|
||||
- **Mode Badge Pill**: Maintain a rounded, high-contrast status badge in the viewport corner displaying the active state with mode-specific icons and accent backgrounds.
|
||||
|
||||
### 2.2 Status Bar & Dynamic Cursor System
|
||||
|
||||
- **Status & Coordinate Bar**: Polish the bottom bar for scannability, hosting contextual operational hints and live spatial coordinates.
|
||||
|
||||
- **Contextual Cursors & Floating Tooltips**:
|
||||
- Swap between arrow/crosshair, placement cursors, and custom action reticles depending on mode and pending operations.
|
||||
|
||||
- Attach floating, high-contrast tooltip pills offset from the cursor during target selection workflows (e.g., direct mode commands or trigger mapping).
|
||||
|
||||
---
|
||||
|
||||
## 3. Icon-Based Palette & Thumbnail Engine
|
||||
|
||||
### 3.1 Draggable / Clickable Card Palette
|
||||
|
||||
- **Visual Asset Cards**: Transition spawner controls from text buttons to visual card tiles displaying item icons, labels, and category badges.
|
||||
|
||||
- **Placement Integration**: Support both click-to-select active placement and direct drag-and-drop actions onto the stage.
|
||||
|
||||
### 3.2 Modal Asset Selector & Thumbnail Pipeline
|
||||
|
||||
- **Asset Selector Modal**: Refine the modal popup and its dimming background backdrop for browsing extended stickmen and prop libraries.
|
||||
|
||||
- **Asynchronous Thumbnail Cache**:
|
||||
- Streamline off-screen thumbnail generation for custom props and stickmen.
|
||||
|
||||
- Cache generated preview textures to prevent UI stuttering during library scans.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visual Overlay & Placement Feedback Engine
|
||||
|
||||
### 4.1 Terrain Drag-Painting & Ghosting Feedback
|
||||
|
||||
- **Block-Unit Stride Alignment**: Align placement ghosts and Bresenham line painting to the active terrain template's AABB extent.
|
||||
|
||||
- **3-State Ghost Tinting**:
|
||||
- **Green**: Valid, empty space ready for placement.
|
||||
|
||||
- **Faint / Neutral**: Same-type block (skipped to avoid double-stamping).
|
||||
|
||||
- **Red**: Conflicting entity or invalid geometry.
|
||||
|
||||
### 4.2 Guidance & Target Trajectories
|
||||
|
||||
- **Axis-Aligned Guides**: Render high-contrast dashed lines along cardinal axes during Shift-locked terrain drags.
|
||||
|
||||
- **Rubber-Band Trajectories**: Draw contextual connection lines and target reticles from character origins to mouse targets during Direct mode actions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reusable Control Widgets & Forward-Compatible Systems
|
||||
|
||||
### 5.1 Standardized Kid-Friendly UI Components
|
||||
|
||||
Develop a suite of reusable, theme-aware control widgets that can be embedded across dialogs, action popups, and future parameter editors:
|
||||
|
||||
- **Color Swatch Selector**: Clickable circle palettes populated from theme swatch arrays or editor history.
|
||||
|
||||
- **Preset Size Toggles**: Segmented buttons for coarse adjustments (e.g., **Small**, **Medium**, **HUGE**) mapping to backend integer/scale metrics.
|
||||
|
||||
- **Preset Style / Font Pickers**: Visual icon or text buttons allowing selection between preset fonts (e.g., Standard, Comic, Pixel, Scary).
|
||||
|
||||
- **Emoji Quick Grid**: A dedicated grid widget for inserting expressive graphics directly into text fields.
|
||||
|
||||
### 5.2 Context Menus & Action Popups
|
||||
|
||||
- **Uniform Popup Styling**: Apply standardized theme rules, font sizes, and emoji scaling across action context menus, trigger submenus, and dialogs.
|
||||
|
||||
- **Clean Action Dialogs**: Align text input (Speak), duration (Wait), and rename popups with dark-mode sandbox styling.
|
||||
|
||||
### 5.3 Extension Blueprint: Speech Bubble Customization System
|
||||
|
||||
Architect the theme and component pipeline so that future speech bubble upgrades plug directly into the visual foundation without refactoring:
|
||||
|
||||
- **Data Contract**: Ensure the `speak` action payload accepts visual parameters (`font_preset`, `size_preset`, `color`, `bubble_style`) alongside text.
|
||||
|
||||
- **Style Engine Integration**: Speech bubble nodes consume geometry styles (Standard, Yell, Thought, Whisper) and theme font presets directly from `sandbox_theme.json`.
|
||||
|
||||
- **UI Reusability**: The standard Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets drop directly into the Direct mode `Speak` parameter dialog.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Sequence
|
||||
|
||||
1. **Phase 1: Theme Engine & Core Schema**: Extend `sandbox_theme.json` schema, build centralized theme loading/fallback pipeline, and integrate theme application hooks across stage UI.
|
||||
|
||||
2. **Phase 2: Chrome & Mode Navigation Polish**: Implement the 3-segment mode control, viewport frame accents, status badges, and cursor/tooltip overlay controllers.
|
||||
|
||||
3. **Phase 3: Reusable UI Component Library**: Build the Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets.
|
||||
|
||||
4. **Phase 4: Graphical Palette & Modal Overhaul**: Implement icon-based asset cards, refine the `AssetSelector` modal, and optimize asynchronous thumbnail caching.
|
||||
|
||||
5. **Phase 5: Context Menus & Overlay Polish**: Update action popups with uniform theme sizing and polish drag-painting ghost/guide overlays.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Architecture Plan: Rig Relocation & Component Modularization
|
||||
|
||||
### **Objective**
|
||||
|
||||
Relocate `master_rig.tscn` to `res://scenes/stickman_rig.tscn` and decouple `stickman_rig.gd` into dedicated, single-responsibility sub-components to improve maintainability, testability, and concern separation.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 1: File Operations & Scene Relocation**
|
||||
|
||||
1. Move `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn` within Godot to maintain internal UID mappings and resource links.
|
||||
|
||||
2. Verify sub-resource integrity after relocation.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 2: Script Modularization (`stickman_rig.gd`)**
|
||||
|
||||
Extract key responsibilities into sub-nodes or dedicated helper scripts attached to `stickman_rig.tscn`:
|
||||
|
||||
- **Ragdoll & Recovery Component (`StickmanRagdoll`)**
|
||||
- Move ragdoll body/joint tables (`RAGDOLL_BODIES`, `RAGDOLL_JOINTS`) and joint limit bands.
|
||||
|
||||
- Move procedural physics node instantiation (`RagdollBodyContainer`), pin joint setups, and impulse application (`apply_ragdoll_velocity_boost`).
|
||||
|
||||
- Extract rest detection polling (`REST_LINEAR_THRESHOLD`, `REST_ANGULAR_THRESHOLD`), rest timers, and stand-up recovery tweens (`request_recovery`, `snap_to_standing`).
|
||||
|
||||
- **Locomotion & Terrain Subsystem (`StickmanLocomotion`)**
|
||||
- Extract surface raycast probing (`_probe_ground_y`, `_probe_terrain_fan`) and root elevation adjustments (`_reanchor_root_to_landing`).
|
||||
|
||||
- Move airborne physics, horizontal drag calculations (`AIR_DRAG_X`), downward gravity acceleration, and procedural wind-drag pose flailing.
|
||||
|
||||
- Extract landing impact velocity capture and knee-compression crouch/spring recovery tweens.
|
||||
|
||||
- **Director & Action Runner (`StickmanActionRunner`)**
|
||||
- Extract the action queue data structure (`action_queue`) and execution state machines (`RunnerState`, `ActionPhase`).
|
||||
|
||||
- Move pathfinding coordination (`NavigationAgent2D`), kinematic `walk_to` execution, and `FOOT_OFFSET` alignment.
|
||||
|
||||
- Handle speech bubble triggers (`speak`), wait timers, and rule-injected reactive action clearing (`clear_reactive_actions`).
|
||||
|
||||
- **Core Rig Coordinator (`StickmanRig`)**
|
||||
- Retain core rig state management (`ANIMATED`, `RAGDOLL`, `RECOVERING`).
|
||||
|
||||
- Focus strictly on facing profiles (`FacingProfile`), TwoBoneIK bend direction toggles (`BendDirection`), and draw-order sorting (`Z_ORDER_BY_PROFILE`).
|
||||
|
||||
- Serve as the root coordinator relaying signals and state transitions to sub-components.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 3: Reference & Script Updates**
|
||||
|
||||
Update hardcoded string references from `"res://master_rig.tscn"` to `"res://scenes/stickman_rig.tscn"`:
|
||||
|
||||
- **`scripts/stickman_factory.gd`**: Update `spawn_from_data()` instantiation target.
|
||||
|
||||
- **`scripts/test_harness.gd`**, **`scripts/stk_rig_adapter.gd`**, **`scripts/create_animations.gd`**: Update script comments, tool headers, and path constants.
|
||||
|
||||
- **`tests/` Suite**: Search and replace hardcoded scene path string literals across all unit tests.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 4: Documentation Updates**
|
||||
|
||||
Update path references across project markdown files:
|
||||
|
||||
- `README.md`, `AGENTS.md`, `RIGGING.md`, and relevant files in `specs/` and `plans/`.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 5: Verification & Acceptance Criteria**
|
||||
|
||||
1. **Separation & Testability**: Confirm that locomotion raycasting and action runner execution can be tested independently without instantiating ragdoll physics.
|
||||
|
||||
2. **Factory Instantiation**: Verify `StickmanFactory.spawn()` loads `res://scenes/stickman_rig.tscn` cleanly without path errors.
|
||||
|
||||
3. **Test Harness Verification**: Run `res://scenes/test_harness.tscn` via **F6** to ensure walk, speak, wait, and ragdoll actions execute cleanly.
|
||||
|
||||
4. **Automated Suite**: Run the full project unit test suite in `tests/` to confirm all assertions pass.
|
||||
|
||||
---
|
||||
|
||||
### **Completion Record (verified implementation)**
|
||||
|
||||
Phase 8 is implemented. The relocated scene is `res://scenes/stickman_rig.tscn`; its root `Master` node is a `StickmanRig` facade over three authored component children. The facade remains the compatibility boundary for callers while each component owns one subsystem:
|
||||
|
||||
```text
|
||||
Master (StickmanRig)
|
||||
├── RigComponents
|
||||
│ ├── Locomotion (StickmanLocomotion)
|
||||
│ │ ├── terrain probing / ground following
|
||||
│ │ ├── walking and airborne motion
|
||||
│ │ └── collision proxies and landing reactions
|
||||
│ ├── Ragdoll (StickmanRagdoll)
|
||||
│ │ ├── procedural RigidBody2D + PinJoint2D graph
|
||||
│ │ ├── rest detection and momentum handoff
|
||||
│ │ └── capture, re-anchor, and stand-up recovery
|
||||
│ └── ActionRunner (StickmanActionRunner)
|
||||
│ ├── queued walk / speak / wait actions
|
||||
│ └── ragdoll and recovery requests
|
||||
├── Body/* mounted visuals, driven by RemoteTransform2D nodes
|
||||
├── Skeleton2D/Torso/* Bone2D chain and IK modifications
|
||||
├── IK_Targets/* six animation/recovery markers
|
||||
└── AnimationPlayer canonical walk clips and authored animation data
|
||||
```
|
||||
|
||||
`StickmanRig` owns cross-cutting state (`ANIMATED`, `RAGDOLL`, `RECOVERING`), facing profiles, bend flags, body z-order, and signal/API forwarding. `StickmanRagdoll` owns physics construction and recovery; `StickmanLocomotion` owns terrain/motion; `StickmanActionRunner` owns director queue execution. Component references are resolved and configured in `StickmanRig._ready()` and are null-guarded with warnings.
|
||||
|
||||
#### Ragdoll handoff and recovery
|
||||
|
||||
The runtime handoff preserves the current solved IK pose before physics takes ownership:
|
||||
|
||||
```text
|
||||
ANIMATED
|
||||
│ set_ragdoll(true)
|
||||
▼
|
||||
capture pre-ragdoll markers, bone rotations, facing, bend flags, knee sides
|
||||
│ build bodies from current bone endpoints; copy mounted Body/* visuals
|
||||
▼
|
||||
RAGDOLL ── rest + timeout ──▶ capture body pose + probe terrain
|
||||
│ destroy physics graph
|
||||
│ re-anchor root at captured landing feet
|
||||
▼
|
||||
RECOVERING ── tween markers/bones toward captured entry pose ──▶ ANIMATED
|
||||
```
|
||||
|
||||
The important dictionaries have these shapes (vector values are runtime `Vector2`s):
|
||||
|
||||
```gdscript
|
||||
_spawn_pose = {
|
||||
"Torso": {"pos": captured_torso_pos, "rot": captured_torso_rotation},
|
||||
"Head": {"pos": captured_head_tip, "rot": 0.0},
|
||||
"Left_Hand": {"pos": captured_left_wrist, "rot": 0.0},
|
||||
"Right_Hand": {"pos": captured_right_wrist, "rot": 0.0},
|
||||
"Left_Leg": {"pos": captured_left_ankle, "rot": 0.0},
|
||||
"Right_Leg": {"pos": captured_right_ankle, "rot": 0.0},
|
||||
}
|
||||
|
||||
_captured_pose["left_lower_leg"] = {
|
||||
"pos": rig_local_body_position,
|
||||
"rot": body_global_rotation - rig_global_rotation,
|
||||
"half": capsule_half_length,
|
||||
}
|
||||
```
|
||||
|
||||
`_spawn_pose` is initialized from the adapted skeleton by `StickmanFactory.spawn_from_data()` and is the placement ghost's authored pose; the values therefore depend on the loaded `.stk`, rather than on `STAND_POSE`. `_pre_ragdoll_state` is the recovery endpoint: it stores the six markers, limb/head rotations, facing profile, bend flags, head LookAt state, knee-side information, and chain-space offsets. `_captured_pose` is the fallen physics pose used to reconstruct the skeleton and landing position.
|
||||
|
||||
#### Stage mode flow and pose preservation
|
||||
|
||||
EDIT and DIRECT are two views of the same frozen, non-simulating world. The mode transition receives `previous_mode` so it can avoid an unnecessary reset:
|
||||
|
||||
```text
|
||||
EDIT ◄──────────────► DIRECT
|
||||
│ │
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
PLAY
|
||||
│
|
||||
set_mode(EDIT or DIRECT)
|
||||
│
|
||||
▼
|
||||
cleanup + restore spawn pose
|
||||
```
|
||||
|
||||
| Transition | Stage behavior | Rig pose behavior |
|
||||
|---|---|---|
|
||||
| EDIT → DIRECT | Hide edit controls; keep world frozen | Preserve exact live pose and IK markers |
|
||||
| DIRECT → EDIT | Restore edit controls; keep world frozen | Preserve exact live pose and IK markers |
|
||||
| EDIT/DIRECT → PLAY | Enable simulation, terrain following, and action queues | Begin from the current authored/editor pose |
|
||||
| PLAY → EDIT/DIRECT | Stop queues, disable terrain following, freeze physics, restore object transforms | `snap_to_standing()` restores the factory-captured `_spawn_pose` |
|
||||
|
||||
The root cause of the verified regression was that `snap_to_standing()` restored the legacy hardcoded `STAND_POSE`. That generic pose lowered the arms and rotated the head whenever PLAY cleanup ran. The central fix is to restore `_spawn_pose` when it exists, with `STAND_POSE` retained only as a compatibility fallback for rigs that have no captured spawn pose. Ragdoll recovery is separate: its stand-up tween still targets `_pre_ragdoll_state`, so recovery returns to the pose captured at ragdoll entry rather than the factory pose.
|
||||
|
||||
#### Facing, walk-phase bend stabilization, and head tracking
|
||||
|
||||
`FacingProfile.LEFT` is a whole-rig X mirror (`Master.scale.x = -1`); RIGHT and FORWARD use identity scale. Runtime walking always plays the canonical `walk_right` clip and mirrors it for LEFT. The profile still applies provisional per-joint bend flags and z-order tables. For side profiles, a walk keyframe can place a leg exactly straight, leaving PinJoint2D with no geometric knee side. The ragdoll component therefore:
|
||||
|
||||
- permits the straight hinge boundary (`0°`) and the intended fold range (`±150°`) for LEFT/RIGHT profiles;
|
||||
- adds only a small 3° profile-relative bias at the near-straight ragdoll handoff; and
|
||||
- checks each rendered RAGDOLL frame, correcting only an actual wrong-side knee crossing while preserving normal physical motion.
|
||||
|
||||
Head tracking is owned by the `SkeletonModification2DLookAt` targeting `IK_Targets/Head`. RIGHT/FORWARD enable it. LEFT disables LookAt and pins the canonical head rotation because the whole-rig mirror must remain mirror-invariant; this is intentional rather than a missing target update. The scene's authored head constraint is disabled so the active LookAt can follow moved markers without the old constraint interpretation pinning it.
|
||||
|
||||
#### Regression coverage
|
||||
|
||||
The completed implementation was checked with focused headless tests and the existing suites:
|
||||
|
||||
| Test | Result | Coverage |
|
||||
|---|---:|---|
|
||||
| `tests/test_phase8_rig_modularization.gd` | 23/23 | relocated scene, component ownership, head tracking, facing flags, ragdoll facade |
|
||||
| `tests/test_phase8_edit_direct_pose.gd` | 45/45 | EDIT↔DIRECT round trip and EDIT→PLAY→DIRECT pose/marker restoration |
|
||||
| `tests/test_phase8_ragdoll_walk_handoff.gd` | 80/80 | LEFT/RIGHT profiles across multiple walk animation phases and physics frames |
|
||||
| `tests/test_phase45_collision_bodies.gd` | 223/223 | collision proxies, reactions, and PLAY cleanup to authored pose |
|
||||
| `tests/test_phase3c_walk_recovery.gd` | 185/185 | walk, ragdoll, recovery, and action integration |
|
||||
| `tests/test_phase6a_airborne_landing.gd` | 24/24 | airborne pose, landing, and ragdoll cleanup |
|
||||
| `tests/test_phase4b_stage.gd` | 53/53 | stage mode UI/state transitions and pending-action cleanup |
|
||||
|
||||
The plan's original acceptance criteria remain the historical intent; this record captures the as-built ownership and the follow-up fixes verified after user testing.
|
||||
@@ -0,0 +1,151 @@
|
||||
**Phase 1: Prop Theme & Material Token Integration**
|
||||
|
||||
- **Task 1.1: Prop Design Token Schema**
|
||||
- Define a standalone JSON theme configuration (`prop_theme.json`) structuring design tokens specifically for prop editing (e.g., `outline_widths`, `material_tints`, `primitive_defaults`).
|
||||
|
||||
- Implement fallback hierarchy for default colors, line widths, and physics values.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- `prop_theme.json` parses successfully and loads default tokens on editor startup.
|
||||
|
||||
- Missing keys in custom prop files fall back gracefully without raising runtime errors.
|
||||
|
||||
- **Task 1.2: Material Preset Visual Utility**
|
||||
- Create helper logic mapping physics material presets (Wood, Rubber, Cardboard, Metal) directly to default mass, friction, bounce, and fill/outline color tints.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Selecting a material preset populates default mass, friction, restitution, and visual tint values.
|
||||
|
||||
- **Task 1.3: Prop Control Inspector Theme**
|
||||
- Apply standardized `.theme` styling to Prop Editor control widgets (`OptionButton`, `SpinBox`, `ColorPicker`, `Button`).
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Prop Editor controls match visual styling without mutating global GUI settings or sandbox scenes.
|
||||
|
||||
---
|
||||
|
||||
**Phase 2: Vector Geometry, Vertex Editing & Shape Manipulation**
|
||||
|
||||
- **Task 2.1: Multi-Shape Creation & Primitive Geometry Controls**
|
||||
- Implement UI controls to instantiate primitive shapes (Polygons, Circles, Lines) onto the active drawing surface.
|
||||
|
||||
- Support an internal shape stack array for multiple vector shapes per prop asset.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Adding a shape appends a new vector item to the shape stack array.
|
||||
|
||||
- Switching primitive modes exposes mode-specific geometry parameters (e.g., radius for circles, vertex lists for polygons).
|
||||
|
||||
- **Task 2.2: Direct Vertex Dragging & Reshaping**
|
||||
- Render interactive vertex handles on selected shapes.
|
||||
|
||||
- Implement left-click drag on vertex handles to reshape polygon geometry in real time.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Dragging a vertex handle updates the shape mesh visually during input.
|
||||
|
||||
- Releasing the mouse commits the updated vertex coordinates.
|
||||
|
||||
- **Task 2.3: Contextual Vertex Insertion & Deletion**
|
||||
- Right-clicking an outline segment displays a **"Create Point"** menu option to insert a vertex at the edge midpoint.
|
||||
|
||||
- Right-clicking an existing vertex handle displays a **"Remove Point"** option to delete the vertex.
|
||||
|
||||
- Handle degenerate edges gracefully.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Inserting a vertex adds a new point to the shape's point list at the targeted midpoint.
|
||||
|
||||
- Deleting a vertex removes the handle and closes the surrounding line segment.
|
||||
|
||||
- Shapes reduced below 2 vertices are removed from the canvas.
|
||||
|
||||
- **Task 2.4: Shape Selection, Context Menu & Z-Ordering**
|
||||
- Build a canvas hit-testing system to select shapes via left-click.
|
||||
|
||||
- Implement a right-click context menu offering Copy, Paste, Delete, Mirror X, Mirror Y, Bring Forward, and Send Back.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Left-clicking a shape highlights its bounding box and handles.
|
||||
|
||||
- Pressing `Delete`/`Backspace` or selecting "Delete" removes the targeted shape.
|
||||
|
||||
- "Bring Forward" and "Send Back" alter the target shape's draw order in the rendering stack.
|
||||
|
||||
- **Task 2.5: Translation, Rotation & Scaling Gizmos**
|
||||
- Implement direct click-and-drag translation handles on selected shape bounding boxes.
|
||||
|
||||
- Provide a rotation gizmo that snaps to **15-degree increments** when holding **Ctrl**.
|
||||
|
||||
- Provide corner scaling handles that lock **aspect ratio** uniform scaling while holding **Ctrl**.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Dragging the translation handle translates all vertices in unison.
|
||||
|
||||
- Holding **Ctrl** during rotation snaps rotation strictly to 15-degree steps.
|
||||
|
||||
- Holding **Ctrl** during scaling locks aspect ratio; releasing **Ctrl** allows non-uniform scaling.
|
||||
|
||||
- **Task 2.6: Shape Mirroring Engine**
|
||||
- Implement **Mirror X** and **Mirror Y** shape transformations relative to the shape's bounding-box center.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Triggering "Mirror X" inverts X-coordinates across the shape center; "Mirror Y" inverts Y-coordinates.
|
||||
|
||||
- **Task 2.7: In-Memory Shape Clipboard System**
|
||||
- Implement a dedicated shape clipboard module storing deep duplicates of shape property dictionaries.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Copying a shape stores its full parameter dictionary in memory.
|
||||
|
||||
- Pasting creates a duplicate shape offset from the original.
|
||||
|
||||
- The "Paste" context menu option is disabled when the clipboard is empty.
|
||||
|
||||
- **Task 2.8: Live Vector Canvas Synchronizer**
|
||||
- Wire UI input signals directly to `PropBlock` setters for real-time visual updates.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Modifying `fill_color`, `outline_color`, or `outline_width` reflects instantly on the active canvas.
|
||||
|
||||
- **Task 2.9: Point Sanitization Pipeline**
|
||||
- Pass edited polygon coordinates through `TerrainUtils.sanitize_points()`.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Committed geometry maintains clockwise vertex ordering and strips redundant collinear points.
|
||||
|
||||
---
|
||||
|
||||
**Phase 3: Physical Property Inspector & Styling Tools**
|
||||
|
||||
- **Task 3.1: Material Preset Picker**
|
||||
- Implement UI selectors for assigning `material_preset` values and displaying physics properties.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Changing material presets updates displayed physics values (mass, friction, restitution).
|
||||
|
||||
- **Task 3.2: Custom Color Swatch & Palette Pickers**
|
||||
- Build reusable visual color swatches for custom interior fill and outline stroke colors.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Selecting custom colors overrides preset colors and updates visual stroke/fill values.
|
||||
|
||||
---
|
||||
|
||||
**Phase 4: Prop Preview Rendering & Disk Caching**
|
||||
|
||||
- **Task 4.1: Prop SubViewport Thumbnail Generator**
|
||||
- Build an isolated `SubViewport` preview pipeline (`prop_thumbnail.gd`) to capture 2D textures.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- `prop_thumbnail.gd` renders off-screen prop geometry into a transparent 2D texture without interfering with main editor performance.
|
||||
|
||||
- **Task 4.2: Disk Caching Pipeline**
|
||||
- Implement local thumbnail export writing textures to `user://thumbnails/props/<id>.png`.
|
||||
|
||||
- Validate file timestamps to reload cached textures when up-to-date.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Saving a prop exports a 200x200 PNG thumbnail to `user://thumbnails/props/<id>.png`.
|
||||
|
||||
- The editor loads cached thumbnails from disk if source files are unchanged.
|
||||
@@ -0,0 +1,167 @@
|
||||
### Objective Summary
|
||||
|
||||
This revised plan details the addition of an **Attach** mode to the Prop Editor (`[ Arrange | Reshape | Attach ]`), introducing a flexible, **Role-Based Dynamic Anchor Architecture** for vector prop files (`.prop`). Instead of hardcoding weapon-centric anchors (like a fixed muzzle), anchors are defined as named dictionary entries assigned specific functional roles—such as `pivot`, `grip`, `emitter`, `impact`, or `mount`. Coupled with interactive canvas gizmos, a live semi-transparent Stickman Hand Preview silhouette, and `StickmanRig` IK attachment logic, this system allows any prop (from flashlights and magic staves to broadswords and rifles) to be held, flipped, and interacted with accurately across all facing profiles.
|
||||
|
||||
---
|
||||
|
||||
### 1. Dynamic Role-Based Anchor Data Architecture (`.prop` Schema)
|
||||
|
||||
The `.prop` JSON file stores anchors in a flexible dictionary where each key represents a unique anchor identifier and contains an explicit `"role"` attribute.
|
||||
|
||||
#### JSON Data Layout
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "Arcane Staff",
|
||||
"anchors": {
|
||||
"origin": {
|
||||
"role": "pivot",
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"primary_grip": {
|
||||
"role": "grip",
|
||||
"x": 0.0,
|
||||
"y": 18.0,
|
||||
"rotation_deg": 0.0,
|
||||
"z_index_offset": 1,
|
||||
"allowed_hands": "both"
|
||||
},
|
||||
"secondary_grip": {
|
||||
"role": "grip",
|
||||
"x": 0.0,
|
||||
"y": -12.0,
|
||||
"rotation_deg": 0.0,
|
||||
"z_index_offset": 1,
|
||||
"allowed_hands": "left_only"
|
||||
},
|
||||
"crystal_head": {
|
||||
"role": "emitter",
|
||||
"x": 0.0,
|
||||
"y": -48.0,
|
||||
"rotation_deg": -90.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Parameter Schema
|
||||
|
||||
- **`role`**: Enforced category enum (`pivot`, `grip`, `emitter`, `impact`, `mount`, `custom`) defining visual gizmo styling and runtime behavior.
|
||||
- **`x`, `y**`: Pixel offset relative to the prop’s root origin point (0,0).
|
||||
- **`rotation_deg`**: Angular rotation applied to the prop geometry when attached, aligning prop handles seamlessly with a stickman’s hand/wrist joint.
|
||||
- **`z_index_offset`**: Integer sorting shift (e.g., -1 or +1) controlling whether prop elements render behind or in front of the holding hand.
|
||||
- **`allowed_hands`**: Hand access permission (`both`, `left_only`, `right_only`) used during character interaction checks.
|
||||
|
||||
#### Standard Anchor Role Registry
|
||||
|
||||
- **`pivot`**: The mandatory root origin (0,0) used for spawning, ground collision, terrain placement, and linear/angular center-of-mass physics when thrown.
|
||||
- **`grip`**: Hand attachment targets for primary or secondary holding locations.
|
||||
- **`emitter`**: Action output coordinates for spell particles, flashlights/light cones, projectile spawns, spray nozzles, or laser beams.
|
||||
- **`impact`**: Point-of-contact coordinates for melee striking edges, spear points, or hammer heads.
|
||||
- **`mount`**: Auxiliary attachment locations for modular accessories, scopes, or vehicle mounts.
|
||||
|
||||
---
|
||||
|
||||
### 2. UI Integration & 'Attach' Mode Setup
|
||||
|
||||
#### Segmented Mode Switcher
|
||||
|
||||
- Expand the canvas toolbar mode control to three tabs: **`[ Arrange | Reshape | Attach ]`**.
|
||||
- Selecting **Attach** hides shape vertex handles and activates the anchor editing overlay.
|
||||
|
||||
#### Attach Mode Toolbar
|
||||
|
||||
- **Show Hand Overlay**: Toggle button to display/hide the semi-transparent stickman lower-arm and hand preview.
|
||||
- **Snap to Grid**: Toggle button for snapping anchor translation coordinates.
|
||||
- **Show Inactive Gizmos**: Toggle visibility for non-selected background anchors.
|
||||
|
||||
#### Anchor Management Panel (Sidebar Inspector)
|
||||
|
||||
- **Anchor List Control**:
|
||||
- Scrollable list showing all defined anchors with **Add Anchor**, **Delete**, and **Rename** buttons.
|
||||
- Ability to add dynamic custom anchors and assign them any standard role.
|
||||
|
||||
- **Property Fields**:
|
||||
- **Role Selector**: Dropdown menu (`Pivot`, `Grip`, `Emitter`, `Impact`, `Mount`, `Custom`).
|
||||
- **Position Inputs**: Numeric fields for X and Y coordinates with arrow incrementors.
|
||||
- **Rotation Slider**: Angle adjustment (-180° to +180°) with numeric spinbox.
|
||||
- **Z-Index Offset**: Integer field (-5 to +5) for layer sorting relative to hands.
|
||||
- **Allowed Hands**: Dropdown (`Both`, `Left Only`, `Right Only`).
|
||||
|
||||
- **Quick Preset Generator**: Buttons to instantly apply standard layouts (e.g., _One-Handed Grip_, _Two-Handed Weapon_, _Tool / Extinguisher Nozzle_, _Melee Edge_).
|
||||
|
||||
---
|
||||
|
||||
### 3. Canvas Visual Tools & Interactive Editing
|
||||
|
||||
#### Role-Based Gizmo Markers
|
||||
|
||||
Each anchor role renders with a distinct visual icon and color code on the canvas:
|
||||
|
||||
- **`pivot`**: Yellow Crosshair (locked to or editable near origin).
|
||||
- **`grip`**: Cyan / Orange Hand Icon (primary vs. secondary grips).
|
||||
- **`emitter`**: Red Target Reticle (shows direction vector arrow for particle/beam orientation).
|
||||
- **`impact`**: Bright Pink Diamond / Slash Marker (indicates tip or edge).
|
||||
- **`mount`**: Purple Ring Anchor.
|
||||
|
||||
#### Canvas Interaction Mechanics
|
||||
|
||||
- **Translation Dragging**: Click and drag any anchor gizmo across the viewport with full grid-snap support.
|
||||
- **Rotation Arc Ring**: Selecting a `grip` or `emitter` anchor displays a surrounding rotation ring handle. Dragging the ring dynamically dials in `rotation_deg` while updating the live inspector readout.
|
||||
|
||||
#### Stickman Hand Preview Silhouette Overlay
|
||||
|
||||
- **Visual Representation**: When enabled, draws a semi-transparent stickman lower-arm and hand silhouette attached directly to the currently selected `grip` anchor.
|
||||
- **Real-Time Alignment**:
|
||||
- The arm and hand rotate dynamically in response to `rotation_deg` adjustments.
|
||||
- The preview respects `z_index_offset`, rendering the hand over or under prop geometry to give immediate visual feedback.
|
||||
- Guarantees sword hilts, briefcase handles, and tool grips fit stickman hands before running gameplay tests.
|
||||
|
||||
---
|
||||
|
||||
### 4. Rig Compatibility & Runtime Behavior (`StickmanRig`)
|
||||
|
||||
- **IK Hand Attachment**:
|
||||
- `StickmanRig` queries the prop's `grip` anchors during pick-up or equip actions, snapping the wrist joint of `LeftLowerArm` or `RightLowerArm` to the exact anchor location.
|
||||
|
||||
- **Facing Profile Mirroring**:
|
||||
- When a character flips (e.g., facing left with `scale.x = -1`), the prop mirrors along with the rig.
|
||||
- Symmetrical anchor positioning ensures tools and weapons flip naturally without offset errors.
|
||||
|
||||
- **Action & Effect Dispatching**:
|
||||
- When firing, casting, or emitting light, runtime scripts search for anchors with the `emitter` role to instantiate particles, light nodes, or hitscan traces.
|
||||
- Melee collision scripts query `impact` anchors to detect high-velocity hit points.
|
||||
|
||||
- **Throwing Physics**:
|
||||
- Dropping or throwing a prop applies linear/angular velocity calculations relative to the `pivot` anchor position.
|
||||
|
||||
---
|
||||
|
||||
### 5. Implementation Roadmap
|
||||
|
||||
1. **Anchor Data Model & JSON Updates**:
|
||||
|
||||
- Refactor internal dictionary parsers to support role-based dynamic key schemas.
|
||||
- Update `.prop` saving and loading procedures to serialize custom anchor entries and their `role` tags.
|
||||
|
||||
2. **UI Switcher & Sidebar Panel**:
|
||||
|
||||
- Add the `Attach` tab to the primary mode control `[ Arrange | Reshape | Attach ]`.
|
||||
- Build the Anchor Management sidebar panel with role selectors, dynamic list controls, and property spinboxes.
|
||||
|
||||
3. **Canvas Rendering & Gizmo Input**:
|
||||
|
||||
- Implement canvas drawing routines for role-specific gizmos (`pivot`, `grip`, `emitter`, `impact`, `mount`).
|
||||
- Add gizmo click-picking, drag translation, and rotation ring interaction handles.
|
||||
|
||||
4. **Hand Preview Silhouette System**:
|
||||
|
||||
- Create the semi-transparent lower-arm and hand preview overlay attached to `grip` anchors.
|
||||
- Connect real-time rotation and Z-order sorting updates to the overlay renderer.
|
||||
|
||||
5. **Autosave, Thumbnail & Runtime Verification**:
|
||||
|
||||
- Hook anchor adjustments into editor dirty-state tracking, undo/redo stacks, and autosave routines.
|
||||
- Verify exported `.prop` files in the asset selector preview and validate runtime IK snapping on the test stage.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Ragdoll Cleanup — Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This plan addresses three specific issues with the stickman's ragdoll state:
|
||||
|
||||
| # | Issue | Impact |
|
||||
| --- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
| 1 | **Recovery Position Offset** | Stickman floats above the terrain when recovering from ragdoll. |
|
||||
| 2 | **Visual Identity Loss** | Custom colors/shapes disappear; replaced by gray bones. |
|
||||
| 3 | **Profile Persistence** | Joint bending doesn't respect LEFT/RIGHT/FORWARD profile in ragdoll. |
|
||||
|
||||
Each issue has a clear fix and verification steps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Issue 1: Recovery Position Offset (Floating)
|
||||
|
||||
### 2.1. The Problem
|
||||
|
||||
When the stickman recovers from ragdoll, it appears floating above the terrain at the landing position, rather than standing with feet on the ground.
|
||||
|
||||
**Root Cause:** `_reanchor_root_to_landing()` calculates the ground contact point using `_captured_ground_y = torso.center.y + RAGDOLL_TORSO_RADIUS`. This assumes the torso is lying flat on its side. If the ragdoll's torso is rotated (as it often is after a fall), the calculated ground point is incorrect.
|
||||
I
|
||||
|
||||
### 2.2. The Fix
|
||||
|
||||
Replace the torso-capsule ground calculation with a **downward raycast** from the ragdoll's center to find the actual terrain surface.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```gdscript
|
||||
# In _start_recovery() before destroying the ragdoll:
|
||||
func _get_ground_y_at_position(world_pos: Vector2) -> float:
|
||||
var space := get_world_2d().direct_space_state
|
||||
var query := PhysicsRayQueryParameters2D.create(
|
||||
world_pos + Vector2(0, -50), # Start slightly above
|
||||
world_pos + Vector2(0, 2000) # Downward
|
||||
)
|
||||
query.collision_mask = 1 # Terrain layer
|
||||
var result := space.intersect_ray(query)
|
||||
if result:
|
||||
return result.position.y
|
||||
return world_pos.y # Fallback to current Y
|
||||
```
|
||||
|
||||
Then use this to position the standing figure's feet:
|
||||
|
||||
```gdscript
|
||||
# In _reanchor_root_to_landing():
|
||||
var ground_y := _get_ground_y_at_position(_captured_landing_center)
|
||||
var feet := Vector2(_captured_landing_center.x, ground_y)
|
||||
var new_root := feet + FOOT_OFFSET
|
||||
global_position = new_root
|
||||
```
|
||||
|
||||
### 2.3. Acceptance Criteria
|
||||
|
||||
- [ ] Stickman recovers to a standing position with feet touching the terrain surface.
|
||||
- [ ] Works on flat ground.
|
||||
- [ ] Works on slopes and uneven terrain.
|
||||
- [ ] Works on ramp edges.
|
||||
- [ ] No floating or clipping into terrain.
|
||||
|
||||
## 3. Issue 2: Ragdoll Visual Identity
|
||||
|
||||
### 3.1. The Problem
|
||||
|
||||
When entering ragdoll mode, the stickman's custom colors and shapes are hidden, replaced by gray capsule "bone guide" visuals.
|
||||
|
||||
Root Cause: \_enter_ragdoll() hides Body/\* visuals and spawns plain RigidBody2D bodies with default capsule colors. The gray appearance is the default capsule color.
|
||||
|
||||
3.2. The Fix
|
||||
Ragdoll bodies should inherit the visual properties (colors, shapes) from the Body/\* nodes.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Read the geometry and color from each Body/\* part node (Line2D or Polygon2D).
|
||||
|
||||
Create matching visual children on each ragdoll rigidbody.
|
||||
|
||||
The ragdoll's collision shapes remain simple capsules for physics; visual shapes are cosmetic overlays.
|
||||
|
||||
Mapping:
|
||||
|
||||
```
|
||||
Body Node Ragdoll Body Visual Child
|
||||
Body/Head Head RigidBody CircleShape2D (color from visual)
|
||||
Body/Body Torso RigidBody CapsuleShape2D (color from visual)
|
||||
Body/LeftUpperArm LeftUpperArm RigidBody CapsuleShape2D (color from visual)
|
||||
etc. etc. etc.
|
||||
```
|
||||
|
||||
**Important:** The visual shapes are positioned/rotated to match the ragdoll body's orientation. They are cosmetic only — physics uses the capsule collision shapes underneath.
|
||||
|
||||
### 3.3. Acceptance Criteria
|
||||
|
||||
- [ ] Ragdoll bodies match the stickman's custom colors and shapes.
|
||||
- [ ] No gray "bone guide" visuals appear.
|
||||
- [ ] Visuals transition smoothly between ANIMATED and RAGDOLL.
|
||||
- [ ] Visuals stay consistent when exiting ragdoll.
|
||||
|
||||
## 4. Issue 3: Profile Persistence in Ragdoll
|
||||
|
||||
### 4.1. The Problem
|
||||
|
||||
The stickman's facing profile (LEFT/RIGHT/FORWARD) bone bending does not persist into ragdoll mode. Joints bend unrealistically when ragdolling.
|
||||
|
||||
Root Cause: The ragdoll's PinJoint2D angular limits are set once at build time using a generic rule. They don't account for the current facing_profile.
|
||||
|
||||
### 4.2. The Fix
|
||||
|
||||
The ragdoll build should read the current facing_profile and apply profile-specific joint limits.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
When building the ragdoll (\_build_ragdoll()), pass the current facing_profile to \_apply_ragdoll_joint_limits():
|
||||
|
||||
**FORWARD:** Use the existing default (elbows/knees fold toward +CW).
|
||||
|
||||
**LEFT:** Mirror the joint limits (swap the elbow/knee fold direction).
|
||||
|
||||
**RIGHT:** Use the same as FORWARD (or mirror appropriately).
|
||||
|
||||
**Using PROFILE_FLAGS:**
|
||||
|
||||
The existing PROFILE_FLAGS constants already map facing profiles to per-joint flip_bend_direction flags. Use these to determine joint limit mirroring:
|
||||
|
||||
````
|
||||
Joint FORWARD LEFT RIGHT
|
||||
LeftArm Normal Normal Inverted
|
||||
RightArm Inverted Normal Normal
|
||||
LeftLeg Inverted Inverted Normal
|
||||
RightLeg Normal Inverted Inverted
|
||||
``
|
||||
The flip_bend_direction flag determines which way the joint naturally bends. This should be applied to the ragdoll's PinJoint2D angular limits.
|
||||
|
||||
Example for elbow/knee joints:
|
||||
|
||||
```gdscript
|
||||
# If flipped = true, the joint bends toward -CW instead of +CW
|
||||
if flipped:
|
||||
joint.angular_limit_lower = -150.0
|
||||
joint.angular_limit_upper = 5.0
|
||||
else:
|
||||
joint.angular_limit_lower = -5.0
|
||||
joint.angular_limit_upper = 150.0
|
||||
````
|
||||
|
||||
### 4.3. Acceptance Criteria
|
||||
|
||||
- [ ] LEFT profile ragdoll joints bend the correct way.
|
||||
- [ ] RIGHT profile ragdoll joints bend the correct way.
|
||||
- [ ] FORWARD profile ragdoll joints bend the correct way.
|
||||
- [ ] Profile persists after recovery.
|
||||
- [ ] Joints do not hyperextend or bend backwards.
|
||||
|
||||
## 5. Implementation Order
|
||||
|
||||
| Step | Task | Dependencies |
|
||||
| ---- | ----------------------------------------------------------------------------------------- | ------------ |
|
||||
| 1 | Fix Recovery Position — Replace torso-capsule ground calculation with downward raycast. | None |
|
||||
| 2 | Fix Ragdoll Visuals — Ragdoll bodies inherit Body/\* geometry/color as cosmetic children. | None |
|
||||
| 3 | Fix Profile Persistence — Ragdoll joints respect facing_profile via PROFILE_FLAGS. | None |
|
||||
| 4 | Integration Test — Test all three fixes together in the Sandbox Stage. | Steps 1-3 |
|
||||
|
||||
## 6. File Changes Summary
|
||||
|
||||
| File | Changes |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| scripts/stickman_rig.gd | Fix \_reanchor_root_to_landing() with raycast; \_build_ragdoll() reads facing_profile; \_apply_ragdoll_joint_limits() uses profile flags for mirroring; \_enter_ragdoll() preserves visual geometry. |
|
||||
|
||||
## 7. Full Acceptance Criteria
|
||||
|
||||
### 7.1. Recovery Position
|
||||
|
||||
- [ ] Stickman recovers to a standing position with feet touching the terrain surface.
|
||||
- [ ] Works on flat ground, slopes, and uneven terrain.
|
||||
- [ ] No floating or clipping into terrain.
|
||||
|
||||
### 7.2. Ragdoll Visuals
|
||||
|
||||
- [ ] Ragdoll bodies match the stickman's custom colors and shapes.
|
||||
- [ ] No gray "bone guide" visuals appear.
|
||||
- [ ] Visuals transition smoothly between ANIMATED and RAGDOLL.
|
||||
- [ ] Visuals stay consistent when exiting ragdoll.
|
||||
|
||||
### 7.3. Profile Persistence
|
||||
|
||||
- [ ] LEFT profile ragdoll joints bend the correct way.
|
||||
- [ ] RIGHT profile ragdoll joints bend the correct way.
|
||||
- [ ] FORWARD profile ragdoll joints bend the correct way.
|
||||
- [ ] Profile persists after recovery.
|
||||
- [ ] Joints do not hyperextend or bend backwards.
|
||||
|
||||
### 7.4. Backward Compatibility
|
||||
|
||||
- [ ] Existing ragdoll behavior remains intact.
|
||||
- [ ] Existing profiles work as before.
|
||||
- [ ] No new errors or warnings in the console.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Sandbox Stage Builder
|
||||
## 1. Overview & Objective
|
||||
|
||||
**This Phase (Phase 2)** builds the foundation for the kid‑friendly director sandbox: a **visual stage** where users can place terrain, props, and stickmen using a simple palette, then switch between **Edit Mode** (building) and **Play Mode** (physics simulation). This phase focuses on the **"Stage Builder"** experience, setting the stage for later phases that add character actions and story logic.
|
||||
|
||||
The stage is designed to be **extendable** so that future phases (Action Queue, Triggers, Save/Load) can plug in without major rewrites.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### 2.1. What’s Included
|
||||
- **Mode Management:** Toggle between `EDIT` (placement/transform) and `PLAY` (physics simulation).
|
||||
- **Object Spawning:** Palette buttons for placing terrain blocks (Ground, Ramp, Step), dynamic props (Crate, Ball), and Stickman rigs.
|
||||
- **Selection & Hover:** Click to select, hover to highlight, and **box selection** for mass operations.
|
||||
- **Transform Gizmos:** Translate and rotate handles for selected objects (visible only in Edit Mode).
|
||||
- **Deletion:** Delete selected objects via the Delete/Backspace key.
|
||||
- **Camera:** Pan (middle‑mouse) and zoom (mouse wheel) with configurable zoom limits.
|
||||
- **Status Bar:** Shows current mode, object count, and selection info.
|
||||
- **Signals:** Core events (`mode_changed`, `object_placed`, `object_selected`, `object_deleted`) for future extension.
|
||||
|
||||
### 2.2. What’s NOT Included (Future Phases)
|
||||
- **Stickman navigation / pathfinding** (Phase 3 — Action Queue)
|
||||
- **Speech bubbles / dialogue** (Phase 3)
|
||||
- **Area triggers / sensors** (Phase 4)
|
||||
- **Scene save/load** (Phase 5)
|
||||
- **Graphical UI polish** (big toggle buttons, icon‑based palette — Phase 5)
|
||||
- **IK foot placement / slope alignment** (post‑Phase 3)
|
||||
|
||||
**Important:** Ramps and stairs can be **placed** and physics (props/ragdolls) will slide on them, but stickmen will **not** autonomously walk up/down them until Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1. Scene Hierarchy
|
||||
res://scenes/sandbox_stage.tscn
|
||||
│
|
||||
├── SandboxStage (Node2D) ← Root script (class_name SandboxStage)
|
||||
│ │
|
||||
│ ├── World (Node2D) ← Parent container for ALL spawned objects
|
||||
│ │ (TerrainBlocks, PropBlocks, StickmanRigs)
|
||||
│ │
|
||||
│ ├── GizmoLayer (Node2D) ← Draws selection outlines + transform handles
|
||||
│ │ (visible only in EDIT mode)
|
||||
│ │
|
||||
│ ├── Camera2D ← Pan/zoom camera
|
||||
│ │
|
||||
│ ├── UI (CanvasLayer) ← Overlay UI
|
||||
│ │ ├── TopBar (HBoxContainer)
|
||||
│ │ │ ├── ModeToggle (Button) ← "Edit" ↔ "Play" (basic text)
|
||||
│ │ │ ├── SpawnPalette (HBoxContainer)
|
||||
│ │ │ │ ├── "Ground" (Button)
|
||||
│ │ │ │ ├── "Ramp" (Button)
|
||||
│ │ │ │ ├── "Step" (Button)
|
||||
│ │ │ │ ├── "Crate" (Button)
|
||||
│ │ │ │ ├── "Ball" (Button)
|
||||
│ │ │ │ └── "Stickman" (Button)
|
||||
│ │ │ └── StatusBar (Label) ← "Mode: EDIT | Objects: 12 | Selected: Crate"
|
||||
│ │ └── (Future: Action Timeline, etc.)
|
||||
│ │
|
||||
│ └── Spawner (Node) ← Container for spawner logic (StageSpawner)
|
||||
|
||||
text
|
||||
|
||||
### 3.2. Scripts (Class Names)
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `sandbox_stage.gd` (`class_name SandboxStage`) | Root controller. Manages state, selection, mode, signals. |
|
||||
| `stage_spawner.gd` (`class_name StageSpawner`, `extends RefCounted`) | Registry of spawnable types + factory methods. Reuses `TerrainUtils`, `PropUtils`, `StickmanFactory`. |
|
||||
| `stage_selection.gd` (`class_name StageSelection`, `extends RefCounted`) | Handles hover, click selection, box selection, and selection signals. |
|
||||
| `stage_gizmos.gd` (`class_name StageGizmos`, `extends Node2D`) | Draws and handles translate/rotate gizmos. |
|
||||
|
||||
### 3.3. State Management
|
||||
|
||||
- `enum StageMode { EDIT, PLAY }`
|
||||
- `var current_mode: StageMode` (managed by root script)
|
||||
- `var selected_objects: Array[Node2D]` (primary selection is index 0 for gizmos)
|
||||
- **No global singleton** — all state is local to the `SandboxStage` instance, making it self‑contained and testable.
|
||||
|
||||
### 3.4. Signals
|
||||
|
||||
| Signal | Payload | Emitted When |
|
||||
|--------|---------|--------------|
|
||||
| `mode_changed(mode: int)` | `StageMode` enum | Mode toggle. |
|
||||
| `object_placed(node: Node2D)` | Reference to placed object | After successful spawn. |
|
||||
| `object_selected(nodes: Array[Node2D])` | Array of selected nodes | Selection changes. |
|
||||
| `object_deselected()` | (none) | Selection cleared. |
|
||||
| `object_deleted(nodes: Array[Node2D])` | Array of deleted nodes | After deletion. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Features & Design Notes
|
||||
|
||||
### 4.1. Mode Management
|
||||
- Toggle between **EDIT** (build) and **PLAY** (simulate).
|
||||
- In `EDIT`: physics is frozen (`RigidBody2D.freeze = true`), gizmos visible, selection active.
|
||||
- In `PLAY`: physics unfrozen, gizmos hidden, selection disabled.
|
||||
|
||||
### 4.2. Object Spawning
|
||||
- Uses a **registry dictionary** in `StageSpawner` — adding a new object type is as simple as appending an entry (no hard‑coded `match` statements).
|
||||
- **Placement mode:** Click a palette button → next click on the `World` spawns the object.
|
||||
- Repeated placement stays active until user presses **Escape** or clicks a different palette button.
|
||||
- Spawn position uses the mouse world position (projected from the camera).
|
||||
|
||||
### 4.3. Selection (EDIT Mode Only)
|
||||
- **Hover:** Subtle highlight/outline on the object under the mouse.
|
||||
- **Single click:** Selects an object (deselects previous).
|
||||
- **Box selection:** Click + drag on empty space draws a rectangle. Release selects all objects inside.
|
||||
- Hold **Shift** to add to current selection instead of replacing.
|
||||
- **Primary selection:** The first selected object (or the one clicked last) receives the transform gizmos.
|
||||
|
||||
### 4.4. Transform Gizmos (Primary Selection Only)
|
||||
- **Translate:** Cross/directional handle — drag to move the object in world space.
|
||||
- **Rotate:** Circular handle — drag to rotate the object around its center.
|
||||
- Gizmos are implemented as `Area2D` nodes so they intercept mouse events (prevents accidental deselection or spawning).
|
||||
- Gizmos are completely hidden in `PLAY` mode.
|
||||
|
||||
### 4.5. Deletion
|
||||
- Press **Delete** or **Backspace** key to remove all currently selected objects.
|
||||
- Deletion emits `object_deleted` with the list of removed nodes.
|
||||
- Objects are `queue_free()`d — no orphaned nodes.
|
||||
|
||||
### 4.6. Camera
|
||||
- **Pan:** Middle‑mouse drag.
|
||||
- **Zoom:** Mouse wheel.
|
||||
- Zoom min/max are `@export var` constants (`min_zoom = 0.1`, `max_zoom = 6.0`) in the root script, so they can be easily adjusted later.
|
||||
- Camera persists across mode toggles.
|
||||
|
||||
### 4.7. Status Bar
|
||||
- Shows: current mode, total object count, and selected object name/count.
|
||||
- Updates in real time on selection, spawning, deletion, and mode change.
|
||||
|
||||
### 4.8. UI Polish (Future Phase 5)
|
||||
- The Phase 2 UI uses **functional text buttons**.
|
||||
- In Phase 5, these will be replaced with:
|
||||
- A large, graphical **Mode Toggle** (slider/switch style).
|
||||
- An icon‑based **Spawn Palette** with draggable cards (drag‑and‑drop onto the stage).
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
### A. Mode Management
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| A1 | A **Mode Toggle Button** switches between `EDIT` and `PLAY`. | Click; label changes. |
|
||||
| A2 | In `EDIT` mode, **all RigidBody2D props are frozen**. | Place a PropBlock → PLAY → falls. Switch to EDIT → freezes. |
|
||||
| A3 | In `PLAY` mode, **gizmos are hidden** and **selection is disabled**. | Select object in EDIT → PLAY → no selection box; clicks do nothing. |
|
||||
| A4 | `mode_changed` signal is emitted on every toggle. | Connect a test print. |
|
||||
|
||||
---
|
||||
|
||||
### B. Object Spawning
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| B1 | Spawn Palette contains: **Ground**, **Ramp**, **Step**, **Crate**, **Ball**, **Stickman**. | UI shows all six. |
|
||||
| B2 | Clicking a palette button **enters placement mode**. | Click "Crate" → click world → crate appears. |
|
||||
| B3 | **Repeated placement** stays active until Esc or different button. | Click "Crate" → click twice → two crates. |
|
||||
| B4 | Uses existing factories: `TerrainUtils`, `PropUtils`, `StickmanFactory`. | Inspect spawned object properties. |
|
||||
| B5 | `object_placed` signal is emitted. | Connect a test print. |
|
||||
|
||||
---
|
||||
|
||||
### C. Selection (Hover, Click, Box‑Select) — EDIT Only
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| C1 | **Hover** highlights the object under the mouse. | Hover over a crate → it glows. |
|
||||
| C2 | **Single Click** selects an object (deselects previous). | Click a crate → selection box appears. |
|
||||
| C3 | **Deselect** by clicking empty space. | Click empty space → selection disappears. |
|
||||
| C4 | Only **one primary selection** for gizmos. | Select crate → click ball → only ball has gizmos. |
|
||||
| C5 | **Box Selection:** Click + drag draws a rectangle; release selects all objects inside. | Drag box around three crates → all selected. |
|
||||
| C6 | **Shift+Box** adds to selection (does not replace). | Select A (click) → Shift+Box-select B & C → A, B, C selected. |
|
||||
| C7 | `object_selected` and `object_deselected` signals are emitted. | Connect and verify. |
|
||||
| C8 | **StatusBar** updates with selection info. | Box-select three crates → status shows "Selected: 3 objects". |
|
||||
|
||||
---
|
||||
|
||||
### D. Transform Gizmos (EDIT Only — Primary Selection)
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| D1 | **Translate handle** appears around the selected object. | Select a crate → see move handle. |
|
||||
| D2 | Dragging translate handle moves the object smoothly. | Drag → crate follows mouse. |
|
||||
| D3 | **Rotate handle** appears around the selected object. | Select a crate → see rotation ring. |
|
||||
| D4 | Dragging rotate handle rotates the object around its center. | Drag ring → crate spins. |
|
||||
| D5 | Gizmos **do not appear** in PLAY mode. | Switch to PLAY → gizmos vanish. |
|
||||
| D6 | Gizmos are **hit‑testable** (clicking them does NOT deselect or spawn). | Click move handle → object stays selected. |
|
||||
|
||||
---
|
||||
|
||||
### E. Deletion (EDIT Only)
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| E1 | Pressing **Delete** or **Backspace** removes selected object(s). | Select a crate → press Delete → gone. |
|
||||
| E2 | **Multiple deletion** works with box selection. | Box-select three crates → Delete → all gone. |
|
||||
| E3 | `object_deleted` signal is emitted (array of nodes). | Connect and verify. |
|
||||
| E4 | No orphaned nodes remain (`queue_free()` called). | Check `World` child count. |
|
||||
|
||||
---
|
||||
|
||||
### F. Camera & Viewport
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| F1 | **Pan:** Middle‑mouse drag pans the view. | Drag → camera moves. |
|
||||
| F2 | **Zoom:** Mouse wheel zooms in/out within `min_zoom`/`max_zoom` (exported). | Scroll within bounds. Edit constants → bounds update. |
|
||||
| F3 | Camera does **not** reset on mode toggle. | Zoom/pan → toggle modes → view persists. |
|
||||
|
||||
---
|
||||
|
||||
### G. Status Bar
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| G1 | StatusBar displays **Mode**, **Object Count**, and **Selection Info**. | Place 5 objects → "Objects: 5". Select one → "Selected: Crate". |
|
||||
| G2 | StatusBar updates in real time. | Click, spawn, delete → label updates instantly. |
|
||||
|
||||
---
|
||||
|
||||
### H. Performance & Stability
|
||||
|
||||
| # | Criterion | How to Test |
|
||||
|---|-----------|-------------|
|
||||
| H1 | Spawning 50+ objects does not drop frames. | Click "Crate" 50 times → observe FPS. |
|
||||
| H2 | Rapid toggling between EDIT and PLAY does not crash. | Toggle quickly → no errors. |
|
||||
| H3 | No `push_warning` or errors in the console during normal use. | Monitor Output panel. |
|
||||
|
||||
---
|
||||
|
||||
### I. Extendability (Design)
|
||||
|
||||
| # | Criterion | Evidence |
|
||||
|---|-----------|----------|
|
||||
| I1 | Adding a new spawnable type requires **no modification** to `SandboxStage` — only a registry entry. | Code review: `StageSpawner` uses a Dictionary, not `match`. |
|
||||
| I2 | `World` can hold **any** `Node2D`‑derived object. | Place a custom node → works. |
|
||||
| I3 | Gizmos use `global_position`/`global_rotation` → work for any object. | Select a TerrainBlock → gizmos appear and move it. |
|
||||
| I4 | Signals provide hooks for future systems (Save/Load, Action Queue, Triggers). | Code review: signals exist for all major events. |
|
||||
| I5 | UI palette is built from data, not hard‑coded buttons. | Adding a button = appending to an array. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Future Considerations
|
||||
|
||||
### 6.1. Ramp / Stair Dynamics
|
||||
| Feature | Phase 2 | Phase 3+ |
|
||||
|---------|---------|----------|
|
||||
| Place ramp/step terrain | ✅ | ✅ |
|
||||
| Props roll/fall on ramps | ✅ (physics) | ✅ |
|
||||
| Ragdoll tumbles on ramps | ✅ (physics) | ✅ |
|
||||
| Stickman **walks up** ramp | ❌ | ✅ (NavigationAgent2D) |
|
||||
| Stickman **navigates** stairs | ❌ | ✅ (NavigationAgent2D + IK) |
|
||||
|
||||
### 6.2. UI Polish (Phase 5)
|
||||
- Replace the text‑based Mode Toggle with a **large, kid‑friendly graphical switch/slider**.
|
||||
- Replace text palette buttons with **icon‑based draggable cards** (drag‑and‑drop onto the stage).
|
||||
- Add tooltips and animations for feedback.
|
||||
|
||||
### 6.3. Box Selection Enhancements (Post‑Phase 2)
|
||||
- Option to **invert** selection (select all except those inside box).
|
||||
- **Lock** selected objects (prevent accidental moves).
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary of Deliverables
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `res://scenes/sandbox_stage.tscn` | The main stage scene. |
|
||||
| `res://scripts/sandbox_stage.gd` | Root controller (`class_name SandboxStage`). |
|
||||
| `res://scripts/stage_spawner.gd` | Spawn registry + factory (`class_name StageSpawner`). |
|
||||
| `res://scripts/stage_selection.gd` | Selection logic (`class_name StageSelection`). |
|
||||
| `res://scripts/stage_gizmos.gd` | Gizmo rendering + interaction (`class_name StageGizmos`). |
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Sign‑Off Checklist
|
||||
|
||||
- [ ] Mode toggle switches between EDIT and PLAY.
|
||||
- [ ] Physics freezes/unfreezes correctly.
|
||||
- [ ] All six spawnable types can be placed.
|
||||
- [ ] Repeated placement works.
|
||||
- [ ] Hover highlights objects.
|
||||
- [ ] Single‑click selection works.
|
||||
- [ ] Box selection works (with Shift add‑to‑selection).
|
||||
- [ ] Translate gizmo works.
|
||||
- [ ] Rotate gizmo works.
|
||||
- [ ] Delete key removes selected objects.
|
||||
- [ ] Camera pans and zooms within configurable limits.
|
||||
- [ ] StatusBar updates correctly.
|
||||
- [ ] All signals are emitted.
|
||||
- [ ] No errors/warnings in console.
|
||||
- [ ] Performance is acceptable (50+ objects).
|
||||
|
||||
---
|
||||
|
||||
*End of Phase 2 Plan*
|
||||
@@ -0,0 +1,23 @@
|
||||
## Vector Terrain System
|
||||
|
||||
Now that stickmen can be spawned, we need to implement a world for the character.
|
||||
We want the world to be based on vector graphics so that camera zooming and panning as smooth and crisp.
|
||||
According to ROADMAP.md, we want to start with vector terrains.
|
||||
|
||||
The following phases need work:
|
||||
|
||||
### Phase 1. Reusable TerrainBlock Component (StaticBody2D)
|
||||
|
||||
- Node Hierarchy: A root StaticBody2D with three direct children: Polygon2D for interior fill, Line2D for crisp vector borders, and CollisionPolygon2D for physics boundaries.
|
||||
- Property Exposure: An @tool script exposing exported properties for polygon_points (PackedVector2Array), fill_color, outline_color, and outline_width.
|
||||
- Auto-Update Logic: A unified property setter that pushes vertex changes directly to Polygon2D and CollisionPolygon2D (configured with BUILD_SOLIDS), while automatically appending the first vertex to the end of Line2D to close the border loop with rounded line joints.
|
||||
|
||||
### Phase 2. PhysicsTestHarness Test Scene
|
||||
|
||||
- Scene Layout: A standalone test root containing a Camera2D, an environment container with flat ground, angled ramps, and stepped TerrainBlock instances, plus an instantiated MasterRig.
|
||||
- Camera Verification: A simple input script on Camera2D that scales zoom levels between 0.25x and 3.0x on mouse wheel scroll to test resolution independence and vector outline thickness.
|
||||
|
||||
### Phase 3. TerrainUtils Geometry Engine
|
||||
|
||||
- Point Sanitization: A static utility class that processes raw input points by applying grid snapping, running Geometry2D.simplify_polyline() to remove redundant nodes, and enforcing clockwise vertex ordering via Geometry2D.is_polygon_clockwise() for solid collision detection.
|
||||
- Factory Spawner: A helper function that takes user input vectors, processes them through the sanitization pipeline, creates a new TerrainBlock instance, applies the cleaned points, and adds it to the target container.
|
||||
Reference in New Issue
Block a user