diff --git a/AGENTS.md b/AGENTS.md index 29eaa6b..0d48b43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ -# AGENTS.md — stickman (Godot 4.4) +# AGENTS.md — stickman (Godot 4.7.1) ## 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`. - **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`) ## Command-line testing & debug playback + - **Godot binary (4.7.1) — resolve per platform.** Commands below use `` as shorthand: - macOS: `/Applications/Godot.app/Contents/MacOS/Godot` - 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. ## 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 -are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and -assembled in a "Whole Stickman" preview that supports translation, rotation, and scale. + +Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a +stick-figure editor. Its broader product direction is an approachable moviemaker where +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 + - **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, but keep it present — it is required for the legacy `stick.tscn` rig. ## Architecture + - `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. 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, recomputed on every save. - **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(...)` — 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 @@ -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 `Reset Views`, computed at draw time from the live preview size via `_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 constants** derived from the `master_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors 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. `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 = - -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 `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. @@ -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 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 = - 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) 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 @@ -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 Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume `anchor`/`v` generically. - 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. + 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. - `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 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. - **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `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: - 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 +RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state: +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 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()` → `_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 `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` + emits. `_build_ragdoll()` creates a `Node2D` +RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D` container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback `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 - 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 - 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 - 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 - 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 - `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 - `elbow_knee`/`elbow_knee_ccw` `limit` strings are only the fallback for joints missing from - the table. Shoulder/hip ±160°, neck free. Cached - 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 - harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is - **not** modified. + `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 + 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 + 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 + `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 + 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°}`, + 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 + the table. Shoulder/hip ±160°, neck free. Cached + 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 + harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is + **not** modified. - **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state == - RAGDOLL`) +RAGDOLL`) reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below `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` - on it calls `_start_recovery()`. - - **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 - 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 - 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` - 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 - 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 - probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` - (a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`, - `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe - context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle - 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 - 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 - shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives - the LEFT X-mirror), sets `state = RECOVERING` + emits. - - **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`; - 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 - `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into - `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would - otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the - terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also - computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows - `Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the - stack reports `!get_is_setup()`, re-asserts `enabled = true` and - `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's - `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 - 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`) - 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 - `_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 - 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 - disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed - first keyframe can never match an arbitrary ragdoll rest pose). - - **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, - 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 - poses). - - `_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(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). + on it calls `_start_recovery()`. - **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 + 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 + 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` + 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 + 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 + probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` + (a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`, + `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe + context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle + 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 + 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 + shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives + the LEFT X-mirror), sets `state = RECOVERING` + emits. - **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`; + 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 + `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into + `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would + otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the + terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also + computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows + `Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the + stack reports `!get_is_setup()`, re-asserts `enabled = true` and + `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's + `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 + 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`) + 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 + `_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 + 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 + disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed + first keyframe can never match an arbitrary ragdoll rest pose). - **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, + 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 + poses). - `_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(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 ten metadata-tagged `AnimatableBody2D` proxies (head, torso, and eight limbs), stored in `_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). - **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a 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)` (`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE` / `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`, `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( - 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 - + 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 - 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; - `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` - → `_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` - 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 - 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 - 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):** - `_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 / - 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 - (`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct - 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 - 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 - 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). - Walking is kinematic - (`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 - 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 - 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 - 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 - factory-captured spawn pose. + - 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 + 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; + `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` + → `_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` + 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 + 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 + 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):** + `_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 / + 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 + (`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct + 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 + 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 + 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). + Walking is kinematic + (`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 + 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 + 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 + 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 + factory-captured spawn pose. - **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 - `enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a - 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 - future work, and Takeover actions cancel both primary and overlay work before taking control. + `_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 + 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 + 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 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: @@ -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), `grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN` (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 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 @@ -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 `PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished` 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 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 @@ -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 radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported 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 `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; @@ -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. - `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() - -> 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 `sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules. - `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory (**not used by the editor**): - `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 - := 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). - `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 through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only 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 `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_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. - **Phase 10 ragdoll trigger:** the harness stores the spawned rig in `_rig: StickmanRig` 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. - **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( - 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 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 / @@ -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` → `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: - 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 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 @@ -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 `_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 = - 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 `_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds `_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, 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, - 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_triangle()` + `CARDBOARD`) — generator color themes already match the presets. - `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. - `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 := - 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 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; @@ -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 + 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 = - 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 `size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build: `_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 **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 `item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling 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)`. - `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()` - + 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 - `World`; no hit-testing. + - 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 + `World`; no hit-testing. - Scenes: - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used 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. ### Body-part data model + - 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_leg`, `right_lower_leg`. - 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). Descriptive tag in Phase 2 — rendering uses `closed`. - `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). - **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, - rotation, scale}`. +rotation, scale}`. - **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 `.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). ### settings.json (Phase 6) + - Persisted editor preferences written to `settings.json` via `_save_settings()` and loaded in `_load_settings()`. - 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()`. ### Legacy scene (do not delete) + - `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets + `RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for future animation/rigging phases. ## Editor conventions + - `.godot/` is gitignored; never edit it manually. - 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. diff --git a/plans/PHASE_7_UI_POLISH.md b/plans/PHASE_7_UI_POLISH.md new file mode 100644 index 0000000..f013d2c --- /dev/null +++ b/plans/PHASE_7_UI_POLISH.md @@ -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.