Add initial draft for Phase 7 UI Polish plan

This commit is contained in:
2026-09-17 22:17:42 -04:00
parent 2071ee03b7
commit baa19a9341
2 changed files with 298 additions and 153 deletions
+164 -153
View File
@@ -1,11 +1,13 @@
# AGENTS.md — stickman (Godot 4.4) # AGENTS.md — stickman (Godot 4.7.1)
## Project type ## Project type
- **Godot 4.4** 2D/GUI project (Forward Plus renderer)
- **Godot 4.7.1** 2D/GUI project (Forward Plus renderer)
- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`. - Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`) - **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
## Command-line testing & debug playback ## Command-line testing & debug playback
- **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand: - **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand:
- macOS: `/Applications/Godot.app/Contents/MacOS/Godot` - macOS: `/Applications/Godot.app/Contents/MacOS/Godot`
- Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"` - Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"`
@@ -21,17 +23,28 @@
- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines. - **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines.
## Project overview ## Project overview
Stickman Studio is an editor tool for drawing and assembling stick figures. It is a
`Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a
are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and stick-figure editor. Its broader product direction is an approachable moviemaker where
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale. children can create and customize stickmen, build sets with floors/terrain and props,
arrange characters and objects, direct behavior with queued actions and event-triggered
rules, and play the resulting scenes.
The codebase currently has two primary workflows. The main `Control`-based stickman editor
authors body-part vector shapes and assembles them in the "Whole Stickman" preview. The
Sandbox Stage provides the wider scene workflow through EDIT/DIRECT/PLAY modes, terrain,
props, live stickman rigs, action queues, and events. The editor remains the configured
`run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools
that form the foundation of the broader moviemaking experience.
## Required addon ## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/` - **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
- Declared dependency for the project. The current editor UI does not instantiate it directly, - Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy `stick.tscn` rig. but keep it present — it is required for the legacy `stick.tscn` rig.
## Architecture ## Architecture
- `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar, - `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar,
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels. save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel
@@ -44,7 +57,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load, otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
recomputed on every save. recomputed on every save.
- **Phase 9 Round 5 guide-offset export:** each part's save dict also gains `"guide_offset": - **Phase 9 Round 5 guide-offset export:** each part's save dict also gains `"guide_offset":
{x, y}` = (part bbox center in preview space) − (guide joint in preview space), computed in {x, y}` = (part bbox center in preview space) − (guide joint in preview space), computed in
`_collect_all_shape_data()` as `(pos + pivot) - _whole_preview.get_guide_joint_preview(...)` — `_collect_all_shape_data()` as `(pos + pivot) - _whole_preview.get_guide_joint_preview(...)` —
a **pure master-space delta** (both points are preview-world coordinates, so panel-size terms a **pure master-space delta** (both points are preview-world coordinates, so panel-size terms
cancel). The part→joint map is `const GUIDE_JOINT_FOR_PART` (head→"Neck" — the head bone's cancel). The part→joint map is `const GUIDE_JOINT_FOR_PART` (head→"Neck" — the head bone's
@@ -124,7 +137,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
gizmos and drag highlight. The guide is **centered** at the default view and after gizmos and drag highlight. The guide is **centered** at the default view and after
`Reset Views`, computed at draw time from the live preview size via `Reset Views`, computed at draw time from the live preview size via
`_guide_to_preview()` = `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + `_guide_to_preview()` = `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE +
preview_area.size * 0.5`, so it also re-centers on window resize; it remains a preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
world-space fixture that moves with pan/zoom. Joint positions are **hardcoded world-space fixture that moves with pan/zoom. Joint positions are **hardcoded
constants** derived from the `master_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors constants** derived from the `master_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors
Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles; Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles;
@@ -149,7 +162,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
whole dict as a single shape (`shapes = [pd]`), so v1.0/v1.1 single-shape `.stk` files (e.g. whole dict as a single shape (`shapes = [pd]`), so v1.0/v1.1 single-shape `.stk` files (e.g.
`stickmen/test.stk`) mount as visible geometry instead of being cleared to nothing. `stickmen/test.stk`) mount as visible geometry instead of being cleared to nothing.
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y = - **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
-proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every -proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
other part — it clears the `Body/Head` node's inline `@tool` circle script via other part — it clears the `Body/Head` node's inline `@tool` circle script via
`set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers `set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers
`_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed. `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed.
@@ -220,7 +233,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(`j_prime = E(J_raw)` or `f_pt_prime = E(F_pt_raw)`) is **nearest the part's stored guide (`j_prime = E(J_raw)` or `f_pt_prime = E(F_pt_raw)`) is **nearest the part's stored guide
joint** (`center − guide_offset`): if `d_far < d_joint` (strict) the far end attaches joint** (`center − guide_offset`): if `d_far < d_joint` (strict) the far end attaches
(`anchor = f_pt_prime`, `v = −f_prime`), else the family end (`anchor = j_prime`, `v = (`anchor = f_pt_prime`, `v = −f_prime`), else the family end (`anchor = j_prime`, `v =
f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the
`guide_offset` case, fixing the **lower left leg** (knee now at the joint, was the ankle) `guide_offset` case, fixing the **lower left leg** (knee now at the joint, was the ankle)
and **lower right arm** (elbow now at the joint, was the wrist), both 180° off their bones and **lower right arm** (elbow now at the joint, was the wrist), both 180° off their bones
because the user's drawn-side conventions are inconsistent per part. The nearest-end rule because the user's drawn-side conventions are inconsistent per part. The nearest-end rule
@@ -230,8 +243,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
the key keep the previous family rules + flip heuristic exactly as before. `theta`, `s`, the the key keep the previous family rules + flip heuristic exactly as before. `theta`, `s`, the
Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume
`anchor`/`v` generically. `anchor`/`v` generically.
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node → Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
`push_warning` + skip, never crash). Consumed by a future runtime pipeline. `push_warning` + skip, never crash). Consumed by a future runtime pipeline.
- `scripts/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of - `scripts/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9 facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9
Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node
@@ -291,9 +304,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
intentionally static. intentionally static.
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL` - **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL, physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state: RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() -> int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the
ragdoll or cancels the recovery tween, sets the 6 IK targets directly to `STAND_POSE`, re-shows ragdoll or cancels the recovery tween, sets the 6 IK targets directly to `STAND_POSE`, re-shows
`Body/*`, re-enables IK, and returns to `ANIMATED` with no stand-up glide). `_physics_process()` `Body/*`, re-enables IK, and returns to `ANIMATED` with no stand-up glide). `_physics_process()`
→ `_track_momentum(delta)` caches the rig → `_track_momentum(delta)` caches the rig
@@ -304,89 +317,83 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
it first would revert the bones to the authored rest pose, popping the figure), then hides it first would revert the bones to the authored rest pose, popping the figure), then hides
`Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the `Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the
ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state = ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state =
RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D` RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
`get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10** `get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10**
`RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass
2.0, limb capsules radius 8 masses 1.0–2.0; `collision_layer`/`collision_mask` = 1, bodies 2.0, limb capsules radius 8 masses 1.0–2.0; `collision_layer`/`collision_mask` = 1, bodies
spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per
non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at
build). Each body clones the rig's own mounted visual geometry into the ragdoll body frame build). Each body clones the rig's own mounted visual geometry into the ragdoll body frame
via `_clone_part_visuals()` (reads the entry's `visual_path` `Body/*` node, copies each via `_clone_part_visuals()` (reads the entry's `visual_path` `Body/*` node, copies each
`Line2D`/`Polygon2D` child with points baked into the body's local space), falling back to a `Line2D`/`Polygon2D` child with points baked into the body's local space), falling back to a
gray capsule / white circle when the part node is missing or has no mounted shapes. Angular gray capsule / white circle when the part node is missing or has no mounted shapes. Angular
limits via `_apply_ragdoll_joint_limits()`: elbow/knee bands come from the editable limits via `_apply_ragdoll_joint_limits()`: elbow/knee bands come from the editable
`RAGDOLL_JOINT_BAND_LIMITS` table (per profile, per joint) — FORWARD arms `{-150°, +5°}`, `RAGDOLL_JOINT_BAND_LIMITS` table (per profile, per joint) — FORWARD arms `{-150°, +5°}`,
legs `{-5°, +150°}`; LEFT/RIGHT override both sides (e.g. locked `{60°, 100°}`); the legs `{-5°, +150°}`; LEFT/RIGHT override both sides (e.g. locked `{60°, 100°}`); the
`elbow_knee`/`elbow_knee_ccw` `limit` strings are only the fallback for joints missing from `elbow_knee`/`elbow_knee_ccw` `limit` strings are only the fallback for joints missing from
the table. Shoulder/hip ±160°, neck free. Cached the table. Shoulder/hip ±160°, neck free. Cached
momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the
same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the
harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is
**not** modified. **not** modified.
- **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state == - **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state ==
RAGDOLL`) RAGDOLL`)
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates `REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates
`_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` `_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover`
on it calls `_start_recovery()`. on it calls `_start_recovery()`. - **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
- **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`, which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone
which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head
rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry
LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs
offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs **no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation`
**no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation` decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a
decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a mid-walk pose; the animation is not resumed). - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
mid-walk pose; the animation is not resumed). unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local
- **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op `{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and
unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
`{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and (a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`,
probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe
(a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`, context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle
`GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()`
context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle (translates the rig root so the standing figure's **feet** sit on the ground at the
clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()` landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet +
(translates the rig root so the standing figure's **feet** sit on the ground at the FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local
landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives
FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local the LEFT X-mirror), sets `state = RECOVERING` + emits. - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives `to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`;
the LEFT X-mirror), sets `state = RECOVERING` + emits. the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and
- **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via
`to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`; `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into
the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would
`rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the
`chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also
`(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows
otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the `Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the
terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also stack reports `!get_is_setup()`, re-asserts `enabled = true` and
computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
`Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the `RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head
stack reports `!get_is_setup()`, re-asserts `enabled = true` and LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee
`Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). - **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`)
`RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the
LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee `_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use
side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). `_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower −
- **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`) bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space
of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven**
`_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay
`_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower − disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed
bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space first keyframe can never match an arbitrary ragdoll rest pose). - **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods,
toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the
disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk
first keyframe can never match an arbitrary ragdoll rest pose). poses). - `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
- **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll;
bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods, `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). Runtime diagnosis is
poses). gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces the
- `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible: stack + bone-following state — the temporary per-step debug prints have been stripped).
`set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll;
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). Runtime diagnosis is
gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces the
stack + bone-following state — the temporary per-step debug prints have been stripped).
- **Phase 4.5 collision bodies and reactions:** while ANIMATED, `_build_collision_bodies()` creates - **Phase 4.5 collision bodies and reactions:** while ANIMATED, `_build_collision_bodies()` creates
ten metadata-tagged `AnimatableBody2D` proxies (head, torso, and eight limbs), stored in ten metadata-tagged `AnimatableBody2D` proxies (head, torso, and eight limbs), stored in
`_collision_bodies`. Proxies are top-level relative to their `Body/*` visuals, `_collision_bodies`. Proxies are top-level relative to their `Body/*` visuals,
@@ -431,55 +438,55 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
assertions; the implementation is user-verified (2026-09-17). assertions; the implementation is user-verified (2026-09-17).
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a - **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE, queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)` WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
(feet → root; matches `StageSpawner.STICKMAN_FOOT_OFFSET`), `NAV_AGENT_LOCAL_POS := (0, 385)` (feet → root; matches `StageSpawner.STICKMAN_FOOT_OFFSET`), `NAV_AGENT_LOCAL_POS := (0, 385)`
(`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE` (`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE`
/ `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`, / `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`,
`action_finished(action, index)`, `queue_finished` (on completion, not stop), `queue_changed` `action_finished(action, index)`, `queue_finished` (on completion, not stop), `queue_changed`
(any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to( (any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to(
target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`, target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`,
`speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides `speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides
+ emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` / - emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` /
`remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the `remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the
runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a
`NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh; `NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh;
`avoidance_enabled = false`, `max_speed = walk_speed`, shared default nav map layer 1). A `avoidance_enabled = false`, `max_speed = walk_speed`, shared default nav map layer 1). A
`_physics_process` ordering of `_track_momentum` → `_update_rest_detection` → `_update_walking` `_physics_process` ordering of `_track_momentum` → `_update_rest_detection` → `_update_walking`
→ `_update_speech` → `_update_runner` drives the runner state machine per `ActionPhase` → `_update_speech` → `_update_runner` drives the runner state machine per `ActionPhase`
(`walk_to` → wait for `_walk_done`; `speak` → wait for `!_speech_active`; `wait` → `_phase_timer` (`walk_to` → wait for `_walk_done`; `speak` → wait for `!_speech_active`; `wait` → `_phase_timer`
countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` → countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` →
`request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public `request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public
query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking(false)` (no stale logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking(false)` (no stale
walk/path state while preserving the current solved pose for instant ragdoll handoff) and resets walk/path state while preserving the current solved pose for instant ragdoll handoff) and resets
`_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):** `_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):**
`_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0` `_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0`
(map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path / (map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path /
finished check (the read-only `get_current_navigation_path()` alone never triggers a finished check (the read-only `get_current_navigation_path()` alone never triggers a
repath), then branches on `is_target_reachable()`: an **on-mesh target** follows the nav path repath), then branches on `is_target_reachable()`: an **on-mesh target** follows the nav path
(`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct (`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct
straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target =
waypoint + `FOOT_OFFSET`) — a supported case with **no** `push_warning` (the old "warn + finish waypoint + `FOOT_OFFSET`) — a supported case with **no** `push_warning` (the old "warn + finish
in place" policy was replaced; the rig never stands still at a waypoint). There is **no** in place" policy was replaced; the rig never stands still at a waypoint). There is **no**
`_walk_path_grace` variable (the map-sync guard + forced path query replace it) and **no `_walk_path_grace` variable (the map-sync guard + forced path query replace it) and **no
unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default
diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage). diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage).
Walking is kinematic Walking is kinematic
(`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb (`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb
animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set
explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and
the animation `.:facing_profile` tracks are neutralized/removed). `_finish_walk()` captures the the animation `.:facing_profile` tracks are neutralized/removed). `_finish_walk()` captures the
eight current limb-bone rotations, calls `AnimationPlayer.stop(true)`, and leaves all six IK eight current limb-bone rotations, calls `AnimationPlayer.stop(true)`, and leaves all six IK
marker properties at their current values; no marker reset occurs at queue completion. LEFT idle marker properties at their current values; no marker reset occurs at queue completion. LEFT idle
disables mirrored TwoBoneIK while preserving that stopped marker/bone frame; it does not restore disables mirrored TwoBoneIK while preserving that stopped marker/bone frame; it does not restore
the factory pose. `snap_to_standing()` is the explicit stage-reset path that restores the the factory pose. `snap_to_standing()` is the explicit stage-reset path that restores the
factory-captured spawn pose. factory-captured spawn pose.
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits - **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new `_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
`enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a `enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a
private runtime lane without mutating `action_queue`: Overlay speech runs concurrently in one private runtime lane without mutating `action_queue`: Overlay speech runs concurrently in one
replaceable slot, Sequential actions cancel the current primary action while retaining authored replaceable slot, Sequential actions cancel the current primary action while retaining authored
future work, and Takeover actions cancel both primary and overlay work before taking control. future work, and Takeover actions cancel both primary and overlay work before taking control.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static - `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance: `.stk` file into a live, rigged `master_rig.tscn` instance:
@@ -584,7 +591,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`offset_top = 40.0`, `offset_right = -8.0`, `offset_left = -COORDS_PANEL_WIDTH` (320.0), `offset_top = 40.0`, `offset_right = -8.0`, `offset_left = -COORDS_PANEL_WIDTH` (320.0),
`grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN` `grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN`
(auto-height/width, grows left so text never runs off-screen), `mouse_filter = (auto-height/width, grows left so text never runs off-screen), `mouse_filter =
MOUSE_FILTER_IGNORE` on the panel (the label itself stays interactive for selection); MOUSE_FILTER_IGNORE` on the panel (the label itself stays interactive for selection);
StyleBoxFlat StyleBoxFlat
bg `Color(0,0,0,0.55)`, border `Color(1,1,1,0.12)` w1, corner radius 4, content margin 8. bg `Color(0,0,0,0.55)`, border `Color(1,1,1,0.12)` w1, corner radius 4, content margin 8.
Consts `COORDS_PANEL_WIDTH := 320.0` and `COORD_BONE_PATHS` (10 Skeleton2D-relative bone Consts `COORDS_PANEL_WIDTH := 320.0` and `COORD_BONE_PATHS` (10 Skeleton2D-relative bone
@@ -613,7 +620,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(`_apply_loop_mode()`); playback state is tracked by the enum (`_apply_loop_mode()`); playback state is tracked by the enum
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished` `PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`, playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
and state. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now and state. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now
set explicitly via `set_facing_profile()` / `walk_to()` (which sets LEFT/RIGHT from the walk set explicitly via `set_facing_profile()` / `walk_to()` (which sets LEFT/RIGHT from the walk
direction and root-mirrors for LEFT), so playing `walk_right` no longer flips the rig's profile direction and root-mirrors for LEFT), so playing `walk_right` no longer flips the rig's profile
@@ -647,7 +654,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(`BUILD_SOLIDS`) in `POLYGON` mode, or `CollisionShape2D` + `CircleShape2D` (48-segment (`BUILD_SOLIDS`) in `POLYGON` mode, or `CollisionShape2D` + `CircleShape2D` (48-segment
radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported
properties: `shape_type` (`@export_enum("Polygon","Circle")`), `polygon_points: properties: `shape_type` (`@export_enum("Polygon","Circle")`), `polygon_points:
PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and
`material_preset` (`@export_enum("None","Wood","Rubber","Cardboard","Metal")`). Presets set `material_preset` (`@export_enum("None","Wood","Rubber","Cardboard","Metal")`). Presets set
`mass` + `physics_material_override` via static `mass_for()`/`physics_material()`/`tint_for()` `mass` + `physics_material_override` via static `mass_for()`/`physics_material()`/`tint_for()`
(Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85; (Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85;
@@ -663,17 +670,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
stickman-prop contacts; the old geometric feet-point stickman test is removed. stickman-prop contacts; the old geometric feet-point stickman test is removed.
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor** - `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect() (Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill + -> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
dashed border). **No physics and no signals** — it is a pure geometric region evaluated by dashed border). **No physics and no signals** — it is a pure geometric region evaluated by
`sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules. `sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules.
- `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory - `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
(**not used by the editor**): (**not used by the editor**):
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` / - `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
`create_plank(length := 160.0, thickness := 16.0)` / `create_triangle(base := 56.0, height `create_plank(length := 160.0, thickness := 16.0)` / `create_triangle(base := 56.0, height
:= 48.0)` — primitive generators returning shape-payload dictionaries with default := 48.0)` — primitive generators returning shape-payload dictionaries with default
dimensions + color themes (wood/rubber/metal/cardboard). dimensions + color themes (wood/rubber/metal/cardboard).
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity - `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity
:= Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload := Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload
(`shape_type` + geometry + colors via `_apply_shape_payload()`, sanitizing polygon points (`shape_type` + geometry + colors via `_apply_shape_payload()`, sanitizing polygon points
through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only
when non-zero), and returns the spawned prop. when non-zero), and returns the spawned prop.
@@ -689,7 +696,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`(-80,0)`), Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort `(-80,0)`), Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
`StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the `StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the
rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)` rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)`
( `RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props ( `RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
bounce/rest against it; the proxy is a code-only stand-in, not part of the rig. bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.
- **Phase 10 ragdoll trigger:** the harness stores the spawned rig in `_rig: StickmanRig` - **Phase 10 ragdoll trigger:** the harness stores the spawned rig in `_rig: StickmanRig`
and shows a toggle-mode `Button` (`_ragdoll_toggle`) whose text flips and shows a toggle-mode `Button` (`_ragdoll_toggle`) whose text flips
@@ -701,7 +708,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
returns early if one exists, so rapid toggling leaves no duplicate proxies. returns early if one exists, so rapid toggling leaves no duplicate proxies.
- **Phase 10 external forces:** a **"Knock Up"** button (`_knock_up()`) tests impulses beyond - **Phase 10 external forces:** a **"Knock Up"** button (`_knock_up()`) tests impulses beyond
gravity: when the rig is in RAGDOLL mode it calls `StickmanRig.apply_ragdoll_velocity_boost( gravity: when the rig is in RAGDOLL mode it calls `StickmanRig.apply_ragdoll_velocity_boost(
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body, KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body,
preserving internal structure), and applies the same upward velocity delta to every dynamic preserving internal structure), and applies the same upward velocity delta to every dynamic
prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together. prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together.
- **Phase 11 recovery UI:** a `Label("Rest")` + `SpinBox` (`_rest_timeout_spinbox`, min 0.1 / - **Phase 11 recovery UI:** a `Label("Rest")` + `SpinBox` (`_rest_timeout_spinbox`, min 0.1 /
@@ -781,7 +788,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` → `node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY. `mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
- **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules: - **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules:
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`, Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase `action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode 3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY
@@ -832,7 +839,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
`_build_ui()` with theme/font overrides applied via `AssetSelector.apply_font`), backed by a `_build_ui()` with theme/font overrides applied via `AssetSelector.apply_font`), backed by a
dim backdrop `_selector_dim` (a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, `mouse_filter = dim backdrop `_selector_dim` (a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, `mouse_filter =
MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector
is open). State is open). State
`_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds `_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds
`_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s `_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s
@@ -927,7 +934,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
are `static func` and cannot run in a `const`). Public API: `static get_entries()` (builds once, are `static func` and cannot run in a `const`). Public API: `static get_entries()` (builds once,
returns the 4 templates), `static get_ids()`, `static get_entry(id) -> Dictionary` (`{}` if returns the 4 templates), `static get_ids()`, `static get_entry(id) -> Dictionary` (`{}` if
unknown), `static get_default_id() -> String` (`"crate"`). Template dicts carry `{id, name, unknown), `static get_default_id() -> String` (`"crate"`). Template dicts carry `{id, name,
material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber
(`create_ball()` + `RUBBER`), Plank/Metal (`create_plank()` + `METAL`), Triangle/Cardboard (`create_ball()` + `RUBBER`), Plank/Metal (`create_plank()` + `METAL`), Triangle/Cardboard
(`create_triangle()` + `CARDBOARD`) — generator color themes already match the presets. (`create_triangle()` + `CARDBOARD`) — generator color themes already match the presets.
- `scripts/thumbnails/thumbnail_cache.gd` — `class_name ThumbnailCache`, `extends RefCounted` - `scripts/thumbnails/thumbnail_cache.gd` — `class_name ThumbnailCache`, `extends RefCounted`
@@ -953,7 +960,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
identical to the rig actually placed on stage, not a re-implementation of the editor preview. identical to the rig actually placed on stage, not a re-implementation of the editor preview.
- `scripts/thumbnails/prop_thumbnail.gd` — `class_name PropThumbnail`, `extends Node` (Phase 3b, - `scripts/thumbnails/prop_thumbnail.gd` — `class_name PropThumbnail`, `extends Node` (Phase 3b,
**not used by the editor**). Renders a prop template into a `Texture2D`. `const SIZE := **not used by the editor**). Renders a prop template into a `Texture2D`. `const SIZE :=
Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as
`StickmanThumbnail`. `render(payload, material_preset) -> Texture2D` mirrors `PropBlock`'s `StickmanThumbnail`. `render(payload, material_preset) -> Texture2D` mirrors `PropBlock`'s
geometry into a plain `Node2D` with a `Polygon2D` (fill) + `Line2D` (closed loop, round geometry into a plain `Node2D` with a `Polygon2D` (fill) + `Line2D` (closed loop, round
joints/caps) — **not** a `RigidBody2D`, so nothing falls under gravity inside the viewport; joints/caps) — **not** a `RigidBody2D`, so nothing falls under gravity inside the viewport;
@@ -969,13 +976,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
stickmen, then `popup_centered()`), `set_entries(entries)` (used by Refresh — resets page + stickmen, then `popup_centered()`), `set_entries(entries)` (used by Refresh — resets page +
thumbnails), `set_thumbnail(entry, tex)` (lazy handoff from the stage's drain), `close()`, and thumbnails), `set_thumbnail(entry, tex)` (lazy handoff from the stage's drain), `close()`, and
`apply_font(ui_font, emoji_font)` (walks the authored controls). `_ready()` sets `exclusive = `apply_font(ui_font, emoji_font)` (walks the authored controls). `_ready()` sets `exclusive =
true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()` true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()`
(the Window's built-in Esc close is not relied on). Re-centers on window resize: the root's (the Window's built-in Esc close is not relied on). Re-centers on window resize: the root's
`size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build: `size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build:
`_rebuild()` frees the `%GridContainer` children, toggles the empty-state `%EmptyLabel`, `_rebuild()` frees the `%GridContainer` children, toggles the empty-state `%EmptyLabel`,
shows/hides Prev/Next/page label by page count, and slices the current page via the shows/hides Prev/Next/page label by page count, and slices the current page via the
**static pure** `page_bounds(total, page, page_size) -> Dictionary {start, end, total, **static pure** `page_bounds(total, page, page_size) -> Dictionary {start, end, total,
page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect` page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect`
(cached/placeholder texture) + a name `Label` + (prop only) a material-badge `Label`; cells emit (cached/placeholder texture) + a name `Label` + (prop only) a material-badge `Label`; cells emit
`item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling `item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling
was removed). The `.tscn` is a **minimal shell** was removed). The `.tscn` is a **minimal shell**
@@ -1014,9 +1021,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`. disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`.
- `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space - `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space
grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()` grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()`
+ viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled` - viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled`
are set by `SandboxStage`. Added as the first child of the stage root so it renders behind are set by `SandboxStage`. Added as the first child of the stage root so it renders behind
`World`; no hit-testing. `World`; no hit-testing.
- Scenes: - Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`, for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
@@ -1061,11 +1068,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
palette-button selector grids. palette-button selector grids.
### Body-part data model ### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`, - 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
`right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`, `right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`,
`right_upper_leg`, `right_lower_leg`. `right_upper_leg`, `right_lower_leg`.
- Shape dictionary: `{ "shape_type": String, "points": Array[{x,y}], "color": "#hex", - Shape dictionary: `{ "shape_type": String, "points": Array[{x,y}], "color": "#hex",
"closed": bool, "vertex_flags": Array[int] }`. "closed": bool, "vertex_flags": Array[int] }`.
- `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty). - `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty).
Descriptive tag in Phase 2 — rendering uses `closed`. Descriptive tag in Phase 2 — rendering uses `closed`.
- `closed`: `true` = filled + closed outline, `false` = open outline-only. - `closed`: `true` = filled + closed outline, `false` = open outline-only.
@@ -1073,7 +1081,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`1` = user-created via "Create Point" (hollow rectangle). `1` = user-created via "Create Point" (hollow rectangle).
- **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array - **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array
position (first = back, last = front). Per-part data includes `{shapes[], position, position (first = back, last = front). Per-part data includes `{shapes[], position,
rotation, scale}`. rotation, scale}`.
- **Phase 8:** per-part data adds `pivot` `{x, y}` (local bounding-box center = rotation - **Phase 8:** per-part data adds `pivot` `{x, y}` (local bounding-box center = rotation
origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the
`.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three `.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three
@@ -1086,6 +1094,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`"1.0"`–`"1.4"` files auto-migrate on load). `"1.0"`–`"1.4"` files auto-migrate on load).
### settings.json (Phase 6) ### settings.json (Phase 6)
- Persisted editor preferences written to `settings.json` via `_save_settings()` and - Persisted editor preferences written to `settings.json` via `_save_settings()` and
loaded in `_load_settings()`. loaded in `_load_settings()`.
- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`. - Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`.
@@ -1096,12 +1105,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
via `_broadcast_settings()`. via `_broadcast_settings()`.
### Legacy scene (do not delete) ### Legacy scene (do not delete)
- `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets + - `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
`RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head `RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head
circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for
future animation/rigging phases. future animation/rigging phases.
## Editor conventions ## Editor conventions
- `.godot/` is gitignored; never edit it manually. - `.godot/` is gitignored; never edit it manually.
- Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes. - Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes.
- UID references (Godot 4 native) exist in scenes; do not change them by hand. - UID references (Godot 4 native) exist in scenes; do not change them by hand.
+134
View File
@@ -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.