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
+58 -47
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
@@ -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,7 +317,7 @@ 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
@@ -325,20 +338,18 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
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). mid-walk pose; the animation is not resumed). - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
- **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local
`{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and `{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and
probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
@@ -348,10 +359,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()` clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()`
(translates the rig root so the standing figure's **feet** sit on the ground at the (translates the rig root so the standing figure's **feet** sit on the ground at the
landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet +
FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local
shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives
the LEFT X-mirror), sets `state = RECOVERING` + emits. the LEFT X-mirror), sets `state = RECOVERING` + emits. - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
- **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
`to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`; `to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`;
the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and
`rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via
@@ -365,22 +375,19 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
`RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head `RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head
LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee
side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). 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`)
- **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`)
of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the
`_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use `_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use
`_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower − `_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower −
bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space
and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven**
toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay
disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed
first keyframe can never match an arbitrary ragdoll rest pose). first keyframe can never match an arbitrary ragdoll rest pose). - **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
- **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods, bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods,
re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the
pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk
poses). poses). - `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
- `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
`set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `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(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
@@ -431,15 +438,15 @@ 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;
@@ -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
@@ -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.
@@ -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,7 +1021,7 @@ 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:
@@ -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.