diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md index 6402197..13c7a49 100644 --- a/.opencode/agents/developer.md +++ b/.opencode/agents/developer.md @@ -3,7 +3,7 @@ name: Developer description: Implements core application features across Godot. mode: subagent model: "deepseek/deepseek-v4-pro" -maxSteps: 50 +steps: 60 permission: edit: allow bash: allow @@ -58,7 +58,10 @@ You are an expert Godot 4 game developer and code reviewer. Your purpose is to a - Enforce static typing wherever possible: `var health: int = 100` or `func take_damage(amount: float) -> void:`. - Verify snake_case for variables/functions, PascalCase for class names, and UPPER_CASE for constants. - Check for proper use of `@export` annotations for inspector variables. -- Verify syntax using '..\Godot_v4.7.1-stable_win64_console.exe" . --check-only' +- Verify syntax using the project's Godot 4.7 console binary: + `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --quit` + (project-wide parse/import check). For a single script: + `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"` ## 5. Response Output Format diff --git a/AGENTS.md b/AGENTS.md index ba2796f..3fbadb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -616,7 +616,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and `_enter_play_mode()` (the mode toggle and status label stay visible). - **Phase 3a director tool:** adds a **"Direct"** palette toggle button (mutually exclusive with placement) and a `PopupMenu` (`_action_popup`, items `Walk To`/`Speak`/`Wait`/`Ragdoll`/`Recover`, - ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); speak/wait + ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); the popup is + positioned at the clicked stickman's world position converted to screen (`_world_to_screen(world_pos)`) + offset 24 px right, not at the mouse cursor (this first menu also records the session popup anchor + for the Phase 4 rule-builder child menus); speak/wait `AcceptDialog`s append `speak`/`wait` actions; `Walk To` enters a pending target-capture mode whose next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc priority: pending target → exit direct mode → existing placement clears). Builds a code-built @@ -645,6 +648,47 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and → "Add another action / Done" popup. **Esc** has highest priority; status-bar hints + toast messages guide the flow. Rule **label click** → consequence-only edit (replaces the rule, same `id`); **✕** deletes; `_cleanup_rules_for_nodes` auto-removes rules referencing deleted objects. + All rule-builder context popups are **session-anchored**: the first popup in a session records + its screen position (`_popup_anchor` / `_popup_anchor_set`; the Direct first menu = right of the + clicked stickman, the rule-label edit entry = the click position), and every child popup in the + session — "⚡ When…" trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another + action" — reuses that recorded position via `_set_popup_anchor(rect)` / `_popup_anchor_rect()`, + so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on + confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit + (`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path + intentionally reuses it). + - **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no + longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`, + 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 + is open). State + `_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds + `_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s + (`_stickman_thumb`/`_prop_thumb`, added as children so they can `await`), plus a Browse + `_browse_dialog` `FileDialog` (`*.stk`). `_on_palette_toggled` routes `stickman`/`prop` presses + to `_open_selector(id)` (which forces the palette button pressed, rescans entries, performs the + **single-item skip** for a lone stickman file, and — when the selected path is absent from the + scan — reselects the first entry); its un-press branch closes a matching open selector. + `_on_asset_selected(entry)` writes the selection to `StageSpawner` (`selected_stickman_path` / + `selected_prop_id`), closes the selector, and calls `set_placement_mode(kind)`; + `_close_selector()` also clears `_thumbnail_queue` (it flips `_selector_open` off before hiding + the popup). `_on_selector_cancelled()` closes + exits + placement (un-presses the palette button); the selector's `popup_hide` signal is also routed to + `_on_selector_cancelled()` (idempotency-guarded), so an outside-click close likewise un-presses + the palette button. `_on_browse_file_selected` builds an ad-hoc entry via + `StickmanLibrary.make_entry(path)` (toast on failure); `_on_refresh_requested` rescans + + re-enqueues. **Lazy thumbnail drain:** `_load_or_enqueue_thumbnails` seeds the selector from + any cached PNG and enqueues the rest; `_process` → `_drain_thumbnail_queue()` renders **one per + frame** (awaits the renderer, `save_png`s it, hands the texture back via + `_selector.set_thumbnail` while the selector is open). The selector re-centers on window resize + (the root `AssetSelector` re-runs `popup_centered()` on `size_changed` while visible). **Esc + priority** inserts the selector + before DIRECT/placement in `_unhandled_key_input`; `_handle_world_click` / `_handle_mouse_motion` + early-return while `_selector_open` (belt-and-suspenders over the modal popup). Selection is + **session-only** — persists across EDIT/DIRECT/PLAY toggles, resets on scene reload, **no disk + save** (`_save_settings` is untouched). - `scripts/stickman_speech_bubble.gd` — `class_name SpeechBubble`, `extends Node2D`; a **world-space speech bubble** drawn in `_draw()` (Phase 3a, **not used by the editor**). Child of a `StickmanRig` at `SPEECH_BUBBLE_OFFSET` (above the head), so it follows the figure and scales with the camera. @@ -677,13 +721,98 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and `PropUtils.spawn_prop`, `StickmanFactory.spawn_from_data` via `preload` consts. Terrain entries store origin-relative point templates (ground/ramp/step); `_spawn_terrain` centers the template bbox on its local origin then sets `block.position` to the cursor, so `global_rotation` = - "rotate about center". The Stickman entry caches `res://stickmen/test.stk` once (`load_stk` in - `_init`) and applies `STICKMAN_FOOT_OFFSET (0, -385)` so feet land at the cursor. Also exposes - a `static get_world_aabb(node)` helper (for a stickman it unions the mounted `Body/*` shape - geometry via a recursive `_collect_visual_points()` so the box is centered head-to-feet). + "rotate about center". Also exposes a `static get_world_aabb(node)` helper (for a stickman it + unions the mounted `Body/*` shape geometry via a recursive `_collect_visual_points()` so the box + is centered head-to-feet). - **Phase 4 area palette entry:** a new `"area"` registry entry (label "Area") → `_spawn_area()` instantiates a `TriggerArea`; `get_world_aabb()` gains a **duck-typed** `get_area_rect` AABB branch (if the node responds to `get_area_rect()`, use its `Rect2` as the world bounds). + - **Phase 3b asset library (selected-asset spawner):** the registry entries are now + `ground/ramp/step` (terrain) + `prop` + `stickman` + `area` — the separate `crate`/`ball` + entries were **removed** and a single `"prop"` entry (label "Prop", kind `"prop"`) added, so + `get_spawnable_ids()` == `["ground","ramp","step","prop","stickman","area"]`. Session state: + `selected_stickman_path` (default `DEFAULT_STICKMAN_PATH` = `res://stickmen/test.stk`) and + `selected_prop_id` (default `"crate"`), read/written by the sandbox selector (Phase 3b) — they + are **session-only** (no disk save). `_stickman_cache: Dictionary` (path → parsed data) seeds + the default path in `_init` and lazily loads+parses any newly selected path on first spawn. + `_spawn_stickman` spawns from the **selected** path (applies `STICKMAN_FOOT_OFFSET (0, -385)` + so feet land at the cursor); `_spawn_prop` looks the **selected** prop id up via + `PropLibrary.get_entry(id)` and spawns its payload (`PropUtils.spawn_prop`) + material preset. + New getters `get_selected_stickman_path()` / `get_selected_prop_id()` (status + tests). +- `scripts/stickman_library.gd` — `class_name StickmanLibrary`, `extends RefCounted` (Phase 3b, + **not used by the editor**). Scans `res://stickmen/*.stk` into entry models for the asset + selector. `const STICKMEN_DIR := "res://stickmen"`; `var entries: Array[Dictionary]`. Public API: + `scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]` (via `DirAccess` + per-file + `StickmanFactory.load_stk`; a corrupt or missing-`body_parts` file is **skipped** with a + `push_warning` — never `{}`-as-entry; the display `name` = `stickman_name` (stripped) if + non-empty else the filename basename; results sorted by name, then path, cached on `self` and + returned), `get_entries() -> Array[Dictionary]` (returns the last scan, does **not** rescan), + `find_by_path(path) -> Dictionary` (`{}` if absent), and `make_entry(path) -> Dictionary` + (ad-hoc single-file entry for **Browse**: `load_stk` + name fallback; `{}` on load failure or + missing `body_parts`). Entry shape `{ "path", "name", "data" }`; thumbnails are **not** embedded + in entries — resolved via `ThumbnailCache` at display time by the selector. +- `scripts/prop_library.gd` — `class_name PropLibrary`, `extends RefCounted` (Phase 3b, **not used + by the editor**). A **static** registry of the 4 built-in prop templates for the asset selector. + `static var _templates: Array[Dictionary]` (lazily built on first access — `PropUtils.create_*()` + 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 + (`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` + (Phase 3b, **not used by the editor**). Disk PNG cache under `user://thumbnails/`. Consts + `STICKMEN_DIR := "user://thumbnails/stickmen"`, `PROP_DIR := "user://thumbnails/props"`, + `PROP_VERSION := 1`. Public API: `stickman_key(path)` → `"_"` + (an mtime change yields a new key → missing PNG → regenerate); `stickman_png(key)` / + `prop_png(id)` (`"_v.png"` — bump `PROP_VERSION` to invalidate all prop + thumbnails); `load_png(png_path) -> Texture2D` (`Image.load_from_file` + `ImageTexture`, `null` + if missing/unloadable); `save_png(tex, png_path) -> Error` (creates the parent dir); `ensure_dir`; + `clean_stale_stickmen(valid_keys)` (deletes `basename_*` PNGs not in the valid key set). +- `scripts/thumbnails/stickman_thumbnail.gd` — `class_name StickmanThumbnail`, `extends Node` + (Phase 3b, **not used by the editor**). Renders a parsed `.stk` into a `Texture2D` for the asset + selector by spawning the **real rig**. `const SIZE := Vector2i(200, 200)`. `_ready()` builds one + persistent offscreen `SubViewport` (`transparent_bg`, `render_target_update_mode = UPDATE_ALWAYS`) + with an enabled in-viewport `Camera2D` (`make_current()`). `render(stk_data) -> Texture2D`: + frees prior children, `StickmanFactory.spawn_from_data(stk_data)`s the rig into the viewport at + `Vector2.ZERO`, frames it via `StageSpawner.get_world_aabb(rig)` (a degenerate/no-area bbox falls + back to a fixed `(120×500)` rect), `_frame_camera` fits it with a 12 px margin, `await`s + `RenderingServer.frame_post_draw` **twice** (standard offscreen capture recipe), grabs the viewport + texture, frees the rig, and returns an `ImageTexture` — or `null` (a placeholder) when the capture + is blank/empty (the **headless degrade**; pixel rendering is manual/F6 verification only). Output is + 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 + `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; + circle payloads are re-expanded via `PropBlock.CIRCLE_SEGMENTS`; non-`NONE` presets tint fill via + `PropBlock.tint_for` and darken the outline; then frames + double-`frame_post_draw` captures and + returns an `ImageTexture` or `null` (blank → placeholder). +- `scripts/asset_selector.gd` — `class_name AssetSelector`, `extends PopupPanel` (Phase 3b, **not + used by the editor**); root script of `scenes/asset_selector.tscn`. A **grid popup controller**. + Signals `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`. + `const COLUMNS := 4`, `ROWS := 3`, `PAGE_SIZE := 12`, `CELL_MIN_SIZE`, `THUMB_SIZE`. `var kind` + (`"stickman"` | `"prop"`). Public API: `open(kind, entries)` (sets + the title "Choose Your Stickman"/"Choose a Prop", shows **Browse…**/**Refresh** only for + 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()` + (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` + (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** + (title bar, empty grid, footer as authored unique-name nodes `%TitleLabel`/`%GridContainer`/ + `%EmptyLabel`/`%PageLabel`/`%PrevButton`/`%NextButton`/`%BrowseButton`/`%RefreshButton`/ + `%CloseButton`); all dynamic per-cell content is built in code at runtime because the entry set is + dynamic. - `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/ box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb` unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively @@ -745,7 +874,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and root `Node2D` + script, `Camera2D` at position `(0, -400)` zoom `(0.5, 0.5)`, and an empty `World` (Node2D) container; the `GridLayer` (`StageGrid`), `GizmoLayer` (`StageGizmos`), `PlacementGhost` holder, and the CanvasLayer top-bar UI (mode toggle + six palette buttons + - Grid/Snap/Size controls + status label) are built in code at `_ready()`. + Grid/Snap/Size controls + status label) are built in code at `_ready()`. Phase 3b instantiates + the `AssetSelector` grid popup (see below) into the UI `CanvasLayer`. + - `scenes/asset_selector.tscn` — **standalone asset-selector grid popup** (Phase 3b, not wired + into the editor). Rooted at `AssetSelector` (`PopupPanel`, `scripts/asset_selector.gd`); a + **minimal shell/layout skeleton** — authored title bar, empty `GridContainer`, empty-state + `Label`, and a footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`), all as + unique-name nodes (`%TitleLabel`, `%GridContainer`, `%EmptyLabel`, `%PageLabel`, `%PrevButton`, + `%NextButton`, `%BrowseButton`, `%RefreshButton`, `%CloseButton`). All dynamic per-cell content + (thumbnail + name + prop material badge) is built in code at runtime because the entry set is + dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop** + palette-button selector grids. ### Body-part data model - 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`, diff --git a/README.md b/README.md index 411f7d3..b67e3cc 100644 --- a/README.md +++ b/README.md @@ -393,33 +393,45 @@ Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll ### 18. Sandbox Stage Builder -The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then flip between **Edit Mode** (build) and **Play Mode** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. +The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then switch between **Edit** (build), **Direct** (director/rule authoring), and **Play** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. -The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load). +The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load). Styling defaults (fonts/sizes/colors/grid) live in a hand-editable `res://sandbox_theme.json` (Phase 4b, §21). | File | Purpose | |---|---| | `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. | -| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (mode state machine, placement, camera, deletion, status bar, signals). | -| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`. | +| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (3-mode state machine, placement + drag-painting, camera, deletion, bottom status bar, mode badge/frame/cursors, signals); **Phase 3b** instantiates the `AssetSelector` grid popup + the two thumbnail renderers, owns the selector open/close flow and the lazy per-frame thumbnail drain, and wires the Stickman/Prop palette buttons to the selector (§22). | +| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`; exposes `is_terrain_id()` / `get_template_aabb()` / `spawn_id` tagging. `get_template_aabb()` returns the **sanitized** template AABB (mirrors `_spawn_terrain()`'s 16-px grid pass), so it doubles as the block-unit paint stride. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` entries removed); holds the session state `selected_stickman_path` / `selected_prop_id` and a per-path `_stickman_cache`; `prop` and `stickman` spawn the **selected** asset. | +| `res://scripts/stickman_library.gd` | **Phase 3b** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entry models (corrupt/missing-`body_parts` files skipped, empty `stickman_name` → filename basename), with `make_entry(path)` for arbitrary Browse-chosen paths (§22). | +| `res://scripts/prop_library.gd` | **Phase 3b** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard) with their `PropUtils.create_*()` payloads + material presets; `get_default_id()` = `"crate"` (§22). | +| `res://scripts/asset_selector.gd` | **Phase 3b** `class_name AssetSelector`, `extends PopupPanel` — grid UI controller (root of `scenes/asset_selector.tscn`): pagination 12/page (4×3), Prev/Next/page label, empty-state label, per-cell thumbnail + name + (prop) material badge, hover styling (no pre-highlight on open), window-resize re-centering, Browse/Refresh/Close wiring, Esc handling; signals `item_selected` / `cancelled` / `browse_requested` / `refresh_requested` (§22). | | `res://scripts/stage_selection.gd` | `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric AABB hit-testing. | | `res://scripts/stage_gizmos.gd` | `class_name StageGizmos`, `extends Node2D` — hover highlight, selection outline, rotate ring handle. | | `res://scripts/stage_grid.gd` | `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells). | +| `res://scripts/stage_placement_overlay.gd` | **Phase 4b** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting guide line + the director action trajectory / ghost marker. | +| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Phase 3b** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` into a 200×200 `Texture2D` by spawning the **real rig** (`StickmanFactory.spawn_from_data`) inside an offscreen `SubViewport`, framing it and capturing after a double `frame_post_draw` (§22). | +| `res://scripts/thumbnails/prop_thumbnail.gd` | **Phase 3b** `class_name PropThumbnail`, `extends Node` — renders a prop template into a 200×200 `Texture2D` via a lightweight `Polygon2D` + `Line2D` visual (no `RigidBody2D`, so no gravity), tinted by the material preset (§22). | +| `res://scripts/thumbnails/thumbnail_cache.gd` | **Phase 3b** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache under `user://thumbnails/`: stickman key = `basename_mtime`, prop key = `id_v`; `load_png`/`save_png`/`clean_stale_stickmen` (§22). | +| `res://scenes/asset_selector.tscn` | **Phase 3b** `PopupPanel` root + `asset_selector.gd` — minimal shell/layout skeleton (title bar, empty `GridContainer`, footer Prev/Next/Browse/Refresh/Close); all dynamic per-cell content is built in code at runtime (§22). | +| `res://sandbox_theme.json` | **Phase 4b** hand-editable styling defaults (font paths/sizes, grid snap default, mode accent colors). Loaded at `_ready()`; missing/malformed falls back to built-in constants. | -**Mode management:** +**Mode management** — a single **3-segment switcher** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` sits at the far left of the top bar (`enum StageMode { EDIT, DIRECT, PLAY }`). Each mode shows a **contextual toolbar** and a **mode badge pill** in the viewport's top-left corner (`✏️ EDIT` cyan, `🎬 DIRECTING` amber, `▶️ SIMULATING` green), sourced from `sandbox_theme.json` `mode_colors`: -- **Edit** (default) — `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active. -- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, selection is cleared, and the spawn palette plus the Grid/Snap/Size controls are hidden (restored on returning to Edit). -- The mode toggle button (leftmost) flips between the two; a `mode_changed(mode: int)` signal is emitted on every toggle, and the camera view persists across the switch. +- **Edit** (default) — spawner palette + Grid/Snap/Size controls visible; the construction **grid** is shown. `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active. Cursor is a crosshair. +- **Direct** — internally an **"edit-with-direct"** state: props frozen / stickmen standing / gizmos enabled (same as Edit) so a director can click stickmen and build action queues and rules, but the spawner/grid controls are hidden and the toolbar shows only a hint label (`Click a stickman to direct, or ⚡ When… for rules`). The **grid is hidden entirely**, and a thin **amber viewfinder frame** borders the viewport (a full-screen `MOUSE_FILTER_IGNORE` panel). Cursor is a crosshair. +- **Play** — layout tools and the grid hidden; props unfreeze and fall, and each stickman's action queue runs (`start_queue()`, `auto_recover = false`). Gizmos are hidden, selection is cleared, and the mode switcher is the only toolbar control. Cursor is the default arrow. +- The mode switcher no-ops when the mode is unchanged (`set_mode()` guard); a `mode_changed(mode: int)` signal carries the new value (`0`/`1`/`2`) on every real switch, and the camera view persists across switches. **Esc** exits Direct → Edit. +- **Per-mode cursor** — `_apply_cursor()` sets `CURSOR_CROSS` for Edit/Direct and `CURSOR_ARROW` for Play, and swaps to a runtime-generated amber **flag/reticle** custom cursor whenever a click-awaiting director step is active (§21); a generated `Image`/`ImageTexture` (no asset file needed). - **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide). -**Spawn palette** (six text buttons, built from the spawner registry): +**Spawn palette** (text buttons built from the spawner registry — Ground / Ramp / Step / Prop / Stickman / Area): -- **Ground / Ramp / Step** — `TerrainBlock` terrain, placed by centering the template on its local origin so rotation pivots on the block's center. -- **Crate** (`create_box()` + `WOOD`) / **Ball** (`create_ball()` + `RUBBER`) — `PropBlock` dynamic props. -- **Stickman** — a `StickmanRig` spawned from a cached `res://stickmen/test.stk` via `StickmanFactory.spawn_from_data()`, offset `(0, -385)` so the feet land on the cursor. +- **Ground / Ramp / Step** — `TerrainBlock` terrain. Single **terrain** palette items are **drag-painted** (§21): left-click-and-drag paints a staircase run of blocks; a terrain drag commits on release. Non-terrain (props/stickman/area) palette items keep the Phase 2 **single-click repeated placement**. +- **Prop** (Phase 3b) — opens a **selector grid** (§22) of the 4 prop templates; selecting one sets the `selected_prop_id` and enters placement mode spawning that `PropBlock` (`PropUtils.create_*()` + the matching material preset). Replaces the earlier separate **Crate** / **Ball** buttons. +- **Stickman** (Phase 3b) — opens a **selector grid** (§22) of every `.stk` in `res://stickmen/`; selecting one sets the `selected_stickman_path` and enters placement mode spawning a `StickmanRig` from that file (offset `(0, -385)` so the feet land on the cursor). Replaces the previous hard-coded single-`test.stk` placement. +- **Area** (Phase 4) — a `TriggerArea` sensor. -Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on). The stickman ghost is a static standing figure. The next left-click spawns the object there. Placement repeats until you press **Escape** or click a different button. Each placement emits `object_placed(node)`. +Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on); the stickman ghost is a static standing figure. **Left-click** keeps the Phase 2 repeated-placement behavior (each click/drag commits one placement and the tool stays active); **right-click** (or **Esc**) is the explicit **"put the tool down"** gesture — it cancels any in-progress drag, leaves already-placed cells if a drag had committed, and un-toggles the palette button. Each placement emits `object_placed(node)`. The **Stickman** and **Prop** buttons open a selector grid first (a modal `PopupPanel`, §22) and only enter placement mode once an asset is chosen. **Selection & gizmos (Edit only):** @@ -434,15 +446,15 @@ Clicking a palette button enters **placement mode**, which shows a translucent * **Grid & snap:** - A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1–100 px). -- The grid only shows in **Edit** mode and is hidden during **Play**. -- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`. +- The grid shows **only in Edit** mode — it is hidden entirely in **Direct** and **Play** (Phase 4b). +- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`. On first run the **initial default** grid size seeds from `sandbox_theme.json` `grid.snap_size` (15.0). **Deletion & camera:** -- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`. +- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`. Deleting also clears the hover highlight (`StageSelection.clear_hover()`), so a queued-for-deletion node no longer leaves a stale yellow hover box behind. - **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. **Touchpad**: pinch to zoom, two-finger drag to pan. The view persists across mode toggles. -**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: ` and updates live on spawn, selection, deletion, and mode changes. +**Status bar (bottom):** a bottom-anchored 28 px bar mirrors the stickman editor's `StatusBar` pattern. The **left** label carries the live status text (`Objects: N | Selected: `, plus any pending-walk / rule-builder / toast hints — the `Mode:` prefix moved to the badge pill). The **right** label shows live **mouse world coordinates** as `X: ### Y: ###`, polled each frame from `_camera.get_global_mouse_position()` (world-space, so the numbers pan/zoom with the view). > **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, and (Phase 3a) stickmen now walk up/down them via `NavigationAgent2D` — see §19. @@ -460,7 +472,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's **Direct tool workflow (Edit):** 1. Press the **Direct** toggle button (mutually exclusive with palette placement). A status hint prompts "Click a stickman". -2. **Left-click a stickman** → an action popup opens at the cursor with **Walk To / Speak / Wait / Ragdoll / Recover**. +2. **Left-click a stickman** → an action popup opens to the **right of the clicked stickman** (its world position converted to screen, offset 24 px) with **Walk To / Speak / Wait / Ragdoll / Recover**. 3. Choose an action: - **Walk To** → enters pending mode; the **next left-click on the stage** appends `{"type":"walk_to","target":click_pos}`. **Esc** cancels the pending target. - **Speak** → a text dialog (`AcceptDialog` + `LineEdit`); confirms append `{"type":"speak","text":...,"duration":2.0}`. @@ -488,7 +500,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's **Play execution:** - **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery). -- On **return to Edit**: each stickman `stop_queue()` then `snap_to_standing()`, and the waypoint overlay is re-enabled. +- On **return to Edit** (or Direct): each stickman `stop_queue()` then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled. - Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state). **Nav-mesh behavior:** @@ -511,6 +523,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's | `is_walking` | `func is_walking() -> bool` | Whether a walk is in progress. | | `speak` | `func speak(text: String, duration: float) -> void` | Show a `SpeechBubble` for `duration` s; auto-hides + emits `speech_finished`. | | `queue_action` / `clear_queue` / `get_queue` / `remove_action` / `insert_action` / `queue_size` | — | The mutable action queue; all mutations emit `queue_changed`. | +| `clear_reactive_actions` | `func clear_reactive_actions() -> void` | Drops rule-injected (`reactive`-tagged) actions, restoring the authored sequential queue; no-op while the runner is `EXECUTING` (callers invoke it on mode exit after `stop_queue()`). | | `start_queue` / `stop_queue` / `is_queue_running` | — | Runner control. `stop_queue()` aborts without emitting `queue_finished`. | | `is_ragdoll_at_rest` | `func is_ragdoll_at_rest() -> bool` | Whether the ragdoll has rested (independent of `auto_recover`); the runner waits on it for the `ragdoll` action. | | `arrived` | `signal arrived` | `walk_to` reached its destination. | @@ -558,12 +571,15 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir 5. Choose an **action** from a rule-action popup, then optionally its **target/position** (e.g. click a waypoint for `walk_to`). 6. A popup offers **"Add another action"** (repeat the action step) or **"Done"** to commit the rule. 7. **Esc** is the highest-priority cancel at any step. The rule-builder is a state machine (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) with status-bar hints and toast messages. +8. Choosing **"⬅ Back to actions"** in the trigger sub-menu does **not** cancel the rule flow — it resets the rule builder and re-opens the stickman's action popup (the context rig is preserved), so you can back out of building a rule and pick a sequential action instead. + +All rule-builder context menus are **session-anchored**: the first popup in a flow (the action popup right of the clicked stickman, or the rule-label edit entry at the click) records its screen position, and every child popup (trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another action") reopens at that same recorded position until the rule is confirmed or the flow is cancelled — so cycling menus never walks down the screen. **Rule visualization (Edit only):** `StageDirectorVisuals` draws each rule as a **dashed white connector line** from the trigger object to the target, a **green ⚡ trigger badge**, an **orange → action badge**, a dark label with `rule_summary()` text, and a **✕ delete icon**. Clicking the **rule label** reopens the rule for **consequence-only editing** (replaces the rule, keeping the same `id`); clicking the **✕** deletes the rule. Rules are auto-cleaned (`_cleanup_rules_for_nodes`) when any referenced object is deleted. **Trigger area placement:** the spawn palette gains an **"Area"** entry (from the `"area"` registry id). Placement works like any other palette item (translucent ghost, grid snap, click to place). Trigger areas are selectable/movable like other objects via the duck-typed `get_area_rect` AABB branch in `stage_selection.gd` / `stage_spawner.gd`. -**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. +**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue. **Prop collision detection (two mechanisms):** @@ -576,6 +592,140 @@ Edge-triggered dictionaries (which events already fired) are reset on Play/Edit **Deferred (Phase 5 and beyond):** `explode_prop` / `spawn_prop` action types, rule **conditions** and AND/OR combinators, rule **variables**, and **disk save** of rules are explicitly out of scope for Phase 4. +### 21. Phase 4b Polish — Mode Switcher, Theme, Terrain Painting, Director UX, Walk Jitter Fix + +**Phase 4b** is a **polish + bugfix** pass over the Sandbox Stage Builder. It does **not** add new gameplay systems; it restructures the top bar into a **3-segment mode switcher** with contextual toolbars, adds a **bottom status bar** with live mouse coordinates, a **mode badge/frame** and per-mode cursors, upgrades **terrain placement** from single-click into **drag-to-paint** with a grid spatial dictionary, makes the **director's "pick a target" flows** kid-friendly (cursor-attached tooltip, rubber-band trajectory, reticle cursor), introduces a hand-editable **`res://sandbox_theme.json`**, and fixes the **walk-waypoint arrival jitter**. Run via **F6** on `res://scenes/sandbox_stage.tscn`; not wired into the editor. See §18 for the mode-switcher / status-bar / badge / cursor surface; this section details the placement, styling, director-UX, and bugfix internals. + +#### 21.1 Terrain drag-painting (Edit mode) + +Terrain palette items (Ground/Ramp/Step) replace single-click placement with a **drag-to-paint "drawing" workflow**. Drag-painting quantizes to **block units**, not 16-px cells. The block-unit stride is the active terrain template's **sanitized AABB extent** (`StageSpawner.get_template_aabb(id).size`, computed over the same 16-px terrain-grid sanitize pass `_spawn_terrain()` actually places): + +| Template | Sanitized stride | +|---|---| +| Ground | **192 × 32** | +| Ramp | **192 × 128** | +| Step | **256 × 256** | + +Block centers are `block_cell * stride`, and Bresenham runs over block units. A **cursor-following translucent placement ghost** (block-unit snapped) shows the block the next paint would stamp — it is **freed while a drag is in progress** and **re-armed after each commit** (preserving LMB repeated placement); **RMB** / **Esc** puts the tool down. + +1. **Select** a terrain palette item. Pressing **LMB** on the stage sets a fixed **anchor block unit**; dragging updates a **target block unit** and computes the ordered run between them. +2. **Shift** locks the trajectory to a **cardinal axis** — if `|dx| >= |dy|` the y-delta is zeroed, else the x-delta (a pure 0°/90°/180°/270° run, no diagonals). Re-evaluated per motion event. +3. A high-contrast **dashed guide line** (color from `sandbox_theme.json` `mode_colors.guide_line`, default `#22c6ff`) draws from the anchor to the target block unit via the `StagePlacementOverlay` (a world-space `Node2D` sibling of the grid/gizmos). It appears **only during a real drag** (anchor ≠ target) and disappears **instantly** on release / **Esc** / **RMB** — a single-click placement never draws the guide line or guide circles. +4. The run uses **Bresenham's line algorithm** over block units. Along a horizontal/vertical run (including Shift-locked) blocks tile **edge-to-edge — no overlap, no gaps**; along a free diagonal they tile **corner-to-corner** (adjacent diagonal blocks share exactly a corner point — zero overlap, visually acceptable corner gaps). Each block unit is classified against the **grid spatial dictionary** into one of three states, and the per-block ghosts are tinted accordingly: + - **1 – empty** → **green** ghost → instantiated on release. + - **2 – occupied by the same block type** (same registry `spawn_id`, e.g. another `ground`) → neutral/transparent ghost → **skipped** on release (no double-create / no z-fight). Freshly painted in-drag blocks are marked so a drag crossing its own path skips re-stamping. + - **3 – occupied by a different/conflicting object** (prop/stickman/area/another terrain type) → **muted-red** ghost → **skipped** on release. +5. **Release** commits the batch **atomically**: only the "empty" block units spawn (all in one frame), the nav mesh is marked dirty **once** (`_nav_dirty = true`, not per node), the grid dictionary is rebuilt, and `object_placed(node)` fires per node. LMB keeps repeated placement active for the next drag (the placement ghost re-arms). +6. **RMB** (or **Esc**) ends draw/placement mode — it cancels an in-progress drag (nothing is placed until release), clears the guide line, and un-toggles the palette button and restores the cursor. + +**Grid spatial dictionary** (`_grid_cells`): a runtime `Dictionary` on `SandboxStage` keyed by 16-px grid-cell `Vector2i` (`StageSpawner.TERRAIN_GRID_SIZE`) → `Array[Node2D]`. It is **advisory only** — it drives the 3-state occupancy query (green/neutral/red tint + skip) but is **never authoritative for physics** or for the block-unit paint stride; the `World` tree is the source of truth. Occupancy marks **all 16-px cells covered by the node's world AABB** via `_rasterize_aabb_to_cells()` (for props/stickmen/areas it rasterizes `StageSelection.get_world_aabb()`), so one Ground block (192×32) spans ≈ 12×2 dictionary cells even though it paints as a single block unit. It is populated on place, updated on move/rotate/delete, and rebuilt on grid-size change. Terrain nodes carry a `spawn_id` string (`TerrainBlock.spawn_id`, set by `StageSpawner.spawn_id`) so same-template overlaps are detectable; `StageSpawner.is_terrain_id()` distinguishes terrain registry entries and `get_template_aabb(id)` exposes a terrain template's **sanitized** local AABB for ghost sizing / block-unit stride / cell rasterization. + +#### 21.2 Director targeting UX (Direct / rule-building) + +While a click-awaiting director step is active — a pending **Walk To** target, or the **"When…"** rule steps (`TRIGGER_TARGET` / `ACTION_TARGET` / `ACTION_POSITION`) — the stage shows a combined workflow: + +- **Reticle cursor** — `_apply_cursor()` swaps to a runtime-generated amber **flag/reticle** cursor (`Image.create` + `ImageTexture`, no asset file) whenever `_is_awaiting_click()` is true. +- **Cursor-attached floating tooltip pill** — a dark, rounded high-contrast label following the cursor (offset ~20 px, flipping near the screen edges) reading e.g. `🚩 Click to set walk target`, `🎯 Click the trigger area`, `💥 Click the prop`, etc. (`_action_hint_text()`). Esc or cancel clears it. +- **Rubber-band dashed trajectory** + **ghost marker** — drawn by `StagePlacementOverlay` from the action's origin (the stickman's feet for a walk/rule action, or the trigger/action anchor node for the "When…" flows) to the cursor; **green** when the target is valid and **red** when invalid (the point lies inside a `TerrainBlock` AABB, via the grid dictionary). A semi-transparent flag/ring + crosshair ghost marker sits at the target, grid-snapped when Snap is on. + +#### 21.3 `res://sandbox_theme.json` (styling defaults) + +A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema: + +```json +{ + "version": "1.0", + "fonts": { + "ui_font": "", + "emoji_font": "", + "action_popup_font_size": 24, + "action_popup_emoji_size": 22, + "assignment_badge_font_size": 20, + "assignment_badge_radius": 9, + "rule_label_font_size": 16, + "status_pill_font_size": 16, + "tooltip_font_size": 18 + }, + "grid": { + "snap_size": 15.0 + }, + "mode_colors": { + "edit_accent": "#22c6ff", + "direct_accent": "#ffb300", + "play_accent": "#33dd77", + "guide_line": "#22c6ff" + } +} +``` + +| Key | Default | Consumed by | +|---|---|---| +| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. | +| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. | +| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. | +| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. | +| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. | +| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). | +| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. | +| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). | + +`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present. + +**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`. + +#### 21.4 Walk-waypoint arrival jitter fix (`StickmanRig`) + +The Phase 3a walk could **jitter up/down at a waypoint** instead of stopping. Root cause: `_update_walking()` re-computed `_walk_mode` (`"nav"` vs `"direct"`) from `is_target_reachable()` **every physics frame**, and a waypoint near the nav-mesh boundary could flip that reachability, swapping `root_target` between the nav path point and the raw waypoint — two targets with a small vertical offset. Fix (all in `stickman_rig.gd`): + +1. **Mode latch once per walk.** After the map syncs, `_update_walking()` probes for up to `LATCH_PROBE_MAX_FRAMES` (**6**): it latches `"nav"` as soon as the agent reports the target reachable, or latches `"direct"` when the probe bound is reached (genuinely off-mesh). It is never re-evaluated mid-walk, so the rig cannot oscillate between two targets. +2. **Unified arrival radius against the FINAL target.** Both branches check `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE` (8 px root-space), then **snap** `global_position` onto the final target before `_finish_walk("arrive")` — removing any residual offset. The `"nav"` branch also terminates via **nav-finish**: when `is_navigation_finished()` reports the agent at the path's end **and** the rig is within `2 × ARRIVE_DISTANCE` of the final target, it snaps to the final target and finishes — so an on-mesh waypoint whose final path point sits just outside the 8 px radius still stops dead-on instead of drifting. (The Phase 3a *unconditional* `is_navigation_finished()`-at-12 px finish path is gone; nav-finish now fires only when already close.) +3. **Steer to the final target when close.** In the `"nav"` branch, when within `2 × ARRIVE_DISTANCE` of the final target the rig moves **straight at** it, ignoring a possibly behind-path `next_feet` point (prevents reversing). +4. **Atomic stop + marker re-assert.** `_finish_walk()` stops the player, restores the standing markers, then re-asserts them **one extra physics frame** (`_walk_settle_frames = 1` → `_settle_walk_markers()`), so a residual ±12.5 px walk body-bob keyframe does not pop on arrival. + +The result: `mode` stays constant for the whole walk, exactly one `arrived` fires, the rig's `global_position` is unchanged after arrival, and the walk/body-bob animation stops cleanly. `DEBUG_WALK` / `DEBUG_STAGE` (off by default) can be enabled to capture the `mode`/`dist`/`next`/`final` trace at arrival. + +#### 21.5 Head LookAt solver fix (`master_rig.tscn`) + +`master_rig.tscn`'s Head `SkeletonModification2DLookAt` now uses a **full-range, non-inverted band solved in global space** (`constraint_angle_min = -180`, `constraint_angle_max = 180`, `constraint_invert = false`, `constraint_in_localspace = false`). Under the previous ~55°-clamped, inverted, local-space band, the aim solver could oscillate frame-to-frame as the look direction crossed the band boundary, causing a per-frame **mirror** of the head rather than smooth convergence. With the full-range global band there is no boundary to cross, so dragging the Head IK handle converges cleanly onto the aim point (see §14 Phase 9 Round 7). + +### 22. Asset Library — Stickman & Prop Selector Grids (Phase 3b) + +**Phase 3b** replaces the Sandbox Stage Builder's hard-coded single-stickman and single-prop palette buttons (**Crate** / **Ball** / always-`test.stk` **Stickman**) with **visual selector grids**: clicking **Stickman** or **Prop** opens a modal grid popup (`PopupPanel`) of selectable assets; picking one sets the spawner's session selection and enters placement mode with that asset's ghost. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. **No `.stk` format change** (the `.stk` schema below is untouched this phase). Pixel rendering (thumbnails) is **manual/F6 verification only**; headless tests never assert on pixels. + +**Workflow:** press the **Stickman** or **Prop** palette button → the grid opens (modal `PopupPanel`, ESC-closeable) → click a cell → the selection is cached session-only, the grid closes, and placement mode begins (ghost of the selected asset appears). Placing repeats the **selected** asset; switching selection re-opens the grid. `Ground`/`Ramp`/`Step` and `Area` remain direct placement buttons, unchanged. + +**Two grids:** + +| Grid | Backing | Cells | Details | +|---|---|---|---| +| **Stickman** | `StickmanLibrary.scan()` of `res://stickmen/*.stk` | one per `.stk` (name = `stickman_name` else filename basename) | Rig-rendered thumbnail per cell; **Browse…** (`*.stk` `FileDialog`) selects an arbitrary path; **Refresh** rescans; **empty-state** label when no files; **single-item skip** — exactly one file bypasses the grid and places directly | +| **Prop** | `PropLibrary` (4 static templates) | Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard | Thumbnail + name + **material badge**; always a single page | + +**Selection is session-only:** the chosen `selected_stickman_path` / `selected_prop_id` live in memory on `StageSpawner`, persist across `EDIT ⇄ DIRECT ⇄ PLAY` toggles, and reset on scene reload. **No disk save** (`user://sandbox_settings.json` is not extended). If the currently selected stickman path is no longer in the scan, the first entry is selected instead on open/refresh. + +**Selector grid (`AssetSelector` / `scenes/asset_selector.tscn`):** a `PopupPanel` root (`exclusive = true`) built as a minimal shell — authored title bar, empty `GridContainer`, footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`) — with **all dynamic per-cell content** (texture + name + material badge) built in code at runtime. `open(kind, entries)` titles the popup ("Choose Your Stickman" / "Choose a Prop"), hides Browse/Refresh for props, and `popup_centered()`. Pagination slices **12/page (4×3)** with Prev/Next hidden when a single page; a static pure `page_bounds(total, page, page_size)` helper backs the slicing. Empty state shows a "no stickmen found" label instead of an empty grid. No cell is pre-highlighted on open (the previous selection-highlight border was removed). The popup re-centers on window resize (`size_changed` → `popup_centered()` while visible). Signals: `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`. + +**Thumbnails (rig-accurate, cached):** each 200×200 thumbnail is rendered **lazily, one per frame**, by a `Node` renderer owning a persistent offscreen `SubViewport` (`transparent_bg`, `UPDATE_ALWAYS`) with an enabled in-viewport `Camera2D`: + +- `StickmanThumbnail.render(stk_data)` spawns the **real rig** (`StickmanFactory.spawn_from_data`), frames it via `StageSpawner.get_world_aabb` (degenerate-figure bbox falls back to a fixed rect), awaits `RenderingServer.frame_post_draw` **twice**, captures, and frees the rig. This produces a figure identical to what actually gets placed — not a re-implementation of the editor preview. +- `PropThumbnail.render(payload, material_preset)` builds a lightweight `Polygon2D` + `Line2D` visual (mirroring `PropBlock` geometry but **not** a `RigidBody2D`, so nothing falls under gravity), tinted via `PropBlock.tint_for` for non-`NONE` presets. +- Both return `null` (treated as a **placeholder** by the selector) when the capture is blank/empty — the headless-renderer degrade. + +**Thumbnail cache (`ThumbnailCache`, `user://thumbnails/`):** rendered PNGs are written to disk and reused on later opens: + +| Kind | Directory | Key | Invalidation | +|---|---|---|---| +| Stickmen | `user://thumbnails/stickmen/` | `"_"` (`FileAccess.get_modified_time`) | A modified `.stk` yields a new key → PNG missing → regenerate; `clean_stale_stickmen` deletes superseded PNGs for the same basename | +| Props | `user://thumbnails/props/` | `"_v"` (`PROP_VERSION = 1`) | Regenerate when `PROP_VERSION` bumps or the PNG is missing | + +`StageSpawner` seeds the cache in `_init`; `SandboxStage._drain_thumbnail_queue()` (called from `_process`) pops one queued entry per frame, awaits its render, `save_png`s it, and hands the texture back via `_selector.set_thumbnail(entry, tex)` — so scanning 50+ files never stalls the UI (a placeholder texture shows until each cell's thumbnail arrives). + +**Palette integration & Esc priority:** `_on_palette_toggled` routes `stickman`/`prop` presses to `_open_selector(id)` (not straight to `set_placement_mode`); un-pressing while the matching selector is open closes it. While the grid is open a **dim backdrop** (`_selector_dim`, a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, mouse-ignoring, on the UI `CanvasLayer` behind the selector) is shown. `_open_selector` forces the palette button pressed, performs the single-item skip, and (for stickman) reselects the first entry when the selected path is absent. On `item_selected` the spawner's `selected_stickman_path`/`selected_prop_id` are set, the selector closes, and placement begins. `Browse…` builds a one-off entry via `StickmanLibrary.make_entry(path)` (toast on failure). Esc is handled in the `AssetSelector` (`_unhandled_input` → `cancelled`) and by the stage in its existing **Esc priority chain** — rule step → pending walk target → terrain drag → **selector** → DIRECT → placement → selection. An outside-click that closes the modal popup fires its `popup_hide` signal, which is routed to `_on_selector_cancelled()` (idempotency-guarded) so it likewise un-presses the palette button. While the selector is open, `_handle_world_click` / `_handle_mouse_motion` early-return (belt-and-suspenders on top of the modal `PopupPanel`). + +**New scripts:** `stickman_library.gd` / `prop_library.gd` / `thumbnails/thumbnail_cache.gd` / `thumbnails/stickman_thumbnail.gd` / `thumbnails/prop_thumbnail.gd` / `asset_selector.gd` (+ `scenes/asset_selector.tscn`). **Modified:** `stage_spawner.gd` (registry `crate`/`ball` → `prop`; `selected_stickman_path`/`selected_prop_id` session state; per-path `_stickman_cache`; new `get_selected_stickman_path()`/`get_selected_prop_id()` getters), `sandbox_stage.gd` (selector integration), and `tests/test_phase4b1_fixes.gd` (`spawn("crate")` → `spawn("prop")`). + +**Verification:** new headless suite `tests/test_phase3b_library.gd` (`extends SceneTree`, no pixel assertions) covering `StickmanLibrary.scan()`/corrupt-skip/`make_entry`, `PropLibrary.get_entries()`/`get_default_id()`, the `StageSpawner` registry ids (`ground/ramp/step/prop/stickman/area`), `_spawn_prop`/`_spawn_stickman` honoring `selected_prop_id`/`selected_stickman_path`, `ThumbnailCache` key/path formatting, `AssetSelector` pagination math (`PAGE_SIZE == 12`), and scene-load checks. Spec: `docs/phase_3b_asset_grid_spec.md`. + ## File format (`.stk`) Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files. @@ -714,22 +864,31 @@ Behavior: | `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | -| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. Null-guarded (`push_warning` + skip). **Not used by the editor.** | +| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** | | `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 0.8, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). | | `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). | | `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). | -| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. | +| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. Has a `spawn_id: String` property (set by `StageSpawner`) so same-template terrain overlaps are detectable during drag-painting. | | `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. | | `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. | | `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. | | `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). | -| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: EDIT/PLAY mode state machine (freezes props with `FREEZE_MODE_KINEMATIC`, ragdolls stickmen in PLAY), placement mode, camera pan/zoom, deletion, status bar, and signal fan-out (`mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). Standalone staging scene run via **F6**; not wired into the editor. | -| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin and caches `stickmen/test.stk` for the Stickman palette entry. | +| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). Standalone staging scene run via **F6**; not wired into the editor. | +| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). | +| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. | +| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. | +| `res://scripts/asset_selector.gd` | **Asset Library (Phase 3b).** `class_name AssetSelector`, `extends PopupPanel` — grid popup controller (root of `scenes/asset_selector.tscn`): pagination 12/page, empty state, per-cell thumbnail + name + prop material badge, Browse/Refresh/Close, Esc. | +| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` to a `Texture2D` via the real rig in an offscreen `SubViewport`. | +| `res://scripts/thumbnails/prop_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name PropThumbnail`, `extends Node` — renders a prop template to a `Texture2D` (lightweight non-physics visual). | +| `res://scripts/thumbnails/thumbnail_cache.gd` | **Asset Library (Phase 3b).** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache (`user://thumbnails/`) keyed by basename+mtime (stickmen) / `id_v` (props); load/save/stale cleanup. | +| `res://scenes/asset_selector.tscn` | **Asset Library (Phase 3b).** `PopupPanel` root + `asset_selector.gd` — minimal shell (title bar, empty grid, footer); dynamic cells built in code. | | `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. | | `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. | | `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. | -| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer and CanvasLayer top bar are built in code. | +| `res://scripts/stage_placement_overlay.gd` | **Sandbox Stage Builder (Phase 4b).** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting dashed guide line (`set_terrain_guide` / `clear_terrain_guide`) and the director action rubber-band trajectory + ghost marker (`set_action_trajectory` / `clear_action`); pure drawing, no hit-testing. | +| `res://sandbox_theme.json` | **Sandbox Stage Builder (Phase 4b).** Hand-editable styling defaults for the sandbox (font paths/sizes, grid snap default, mode accent + guide-line colors); loaded by `SandboxStage._load_theme()` with defaults on missing/malformed file. | +| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer, placement overlay, and CanvasLayer UI (mode switcher, toolbars, bottom status bar, badge, tooltip) are built in code. | | `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). | | `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. | | `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. | @@ -825,7 +984,7 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor > **Phase 9 Round 6 bugfix:** `StkRigAdapter._compute_mount_transform()` now selects the joint anchor as whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest the part's stored guide joint** (`center − guide_offset`) when a part carries `guide_offset`. This replaces the per-side family choice and the 180° flip heuristic for that case, fixing the **lower left leg** and **lower right arm**, which were mounted 180° off their bones (the far end attached at the joint) because the user's drawn-side conventions are inconsistent across parts — the stored guide placement is the ground truth for which drawn end is the joint. The nearest-end rule naturally preserves the 180° flip behavior (a flipped part's far end lands nearest the joint), the head chin, and every previously-correct case. Old files without the key keep the family rules + flip heuristic exactly as before. `theta`, `s`, the Round 5 offset `t`, and the head `HEAD_CHIN_DROP` fallback are unchanged. Per docs/phase9_round6_bugfix_spec.md; verified with a 32-assertion headless smoke test. -> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test. +> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (the solver now uses a full-range, non-inverted band solved in global space — `constraint_angle_min = -180 / max = 180`, `constraint_invert = false`, `constraint_in_localspace = false` — so the head converges to the aim point with no per-frame mirror oscillation; the earlier authored ~55° clamp is gone); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test. > **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified. diff --git a/docs/phase_3a_spec.md b/docs/phase_3a_spec.md index 1fa728f..32ad9ae 100644 --- a/docs/phase_3a_spec.md +++ b/docs/phase_3a_spec.md @@ -543,7 +543,8 @@ if _direct_mode: - If `_pending_walk_target` and `_context_rig` valid → append `{"type":"walk_to","target":world_pos}` to `_context_rig`; clear pending + context. - Else `var hit := _selection.hit_test(world_pos)`; if `hit is STICKMAN_RIG` → - `_context_rig = hit`, position `_action_popup` at the mouse and `popup()`. Non-stickman + `_context_rig = hit`, position `_action_popup` to the **right of the clicked stickman** + (`_world_to_screen(hit.global_position)` + 24 px) and `popup()`. Non-stickman clicks are ignored. **Popup handler `_on_action_popup_id_pressed(id)`** (guards `_context_rig` valid): @@ -657,7 +658,7 @@ Implementation notes: ## 7. UI flow (Milestone 3) 1. Press **"Direct"** (toggle). Palette spawn modes are cleared (mutually exclusive). -2. Click a stickman → `StageSelection.hit_test` → if stickman, open `_action_popup` at cursor. +2. Click a stickman → `StageSelection.hit_test` → if stickman, open `_action_popup` to the right of the clicked stickman (its world position converted to screen + 24 px). 3. Choose: - **Walk To** → enters pending mode; status hint ("Click stage for walk target — Esc to cancel"). - **Speak** → text dialog → append `speak` action. diff --git a/docs/phase_3b_asset_grid_spec.md b/docs/phase_3b_asset_grid_spec.md new file mode 100644 index 0000000..bea70ef --- /dev/null +++ b/docs/phase_3b_asset_grid_spec.md @@ -0,0 +1,744 @@ +# Phase 3b — Asset Library: Stickman + Prop Selector Grids (Spec) + +Status: IMPLEMENTED (revised by selector bugfix round, 2026-09-03) +Related plan: `plans/PHASE_3b_ASSET_GRID.md` +Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", ...)`; the test runner comment in `tests/test_phase4b_stage.gd:12` names `Godot_v4.7.1`). + +--- + +## 1. Overview & Scope + +Phase 3b replaces the Sandbox Stage Builder's hard-coded single-stickman and single-prop +palette buttons with **visual selector grids**: + +- Clicking **"Stickman"** opens a grid of every `.stk` file in `res://stickmen/`, each with a + rendered thumbnail. +- Clicking **"Prop"** opens a grid of 4 built-in prop templates (Crate/Ball/Plank/Triangle), + each with a thumbnail + material badge. +- Selecting a cell caches the asset (session-only) and enters placement mode (ghost appears). + +### In scope + +- `StickmanLibrary` — scan + index `.stk` files. +- `PropLibrary` — registry of the 4 prop templates. +- `AssetSelector` — `.tscn`-based grid popup (shell + root script). +- `StickmanThumbnail` / `PropThumbnail` — offscreen-SubViewport renderers. +- `ThumbnailCache` — `user://` PNG cache with invalidation. +- `StageSpawner` edits — remove `crate`/`ball` entries, add `prop`, re-route `stickman` to a + selected path with a per-path data cache. +- `SandboxStage` edits — selector open/close flow, palette-toggle branch, Esc priority, guards, + Browse `FileDialog`, Refresh. + +### Out of scope / untouched (must not regress) + +- **Terrain palette buttons** `Ground`/`Ramp`/`Step` and the **`Area`** button remain direct + placement buttons, unchanged (`scripts/stage_spawner.gd:178-220` registry, `scripts/sandbox_stage.gd:1151-1157`). +- **Terrain drag-painting** (`_begin_terrain_drag` / `_update_terrain_drag` / + `_commit_terrain_drag`, `scripts/sandbox_stage.gd:712-869`) — untouched; `prop`/`stickman` are + non-terrain ids so they keep single-placement via `_place_at`. +- **Ghost system** (`_spawn_ghost`, `_configure_ghost_collision`, `_update_ghost_position`, + `scripts/sandbox_stage.gd:614-668`) — untouched; the ghost automatically reflects the selected + asset once `selected_stickman_path`/`selected_prop_id` are honored by `spawn()`. +- **Director / rule-builder** flows (Phase 3a/4) — untouched. +- No `.stk` format changes; no editor changes. + +--- + +## 2. Recorded User Decisions + +1. **Persistence: SESSION-ONLY.** Selected stickman path + prop id live in memory on + `StageSpawner`, persist across `EDIT ⇄ DIRECT ⇄ PLAY` mode toggles, and reset on scene reload. + **No disk save** (do not extend `_save_settings`). +2. **Thumbnail fidelity: RENDER THE ACTUAL RIG.** Stickman thumbnails are produced by + `StickmanFactory.spawn_from_data(stk_data)` in an offscreen `SubViewport` — identical to what + gets placed on stage (not a re-implementation of `WholeStickmanPreview`). +3. **Prop set: ALL 4 TEMPLATES** — Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard. +4. **Selector build: SEPARATE `.tscn` SCENE** — `res://scenes/asset_selector.tscn` with root + script `scripts/asset_selector.gd`. The `.tscn` is a minimal **shell/layout skeleton only** + (title bar, empty grid placeholder, footer controls as authored nodes); all dynamic cell + content is still built in code at runtime. `sandbox_stage.gd` instantiates the scene and + applies its `_ui_font`/`_emoji_font`/theme overrides after instantiation. + +--- + +## 3. New Files + +| File | `class_name` / extends | Responsibility | +|---|---|---| +| `res://scripts/stickman_library.gd` | `StickmanLibrary` / `RefCounted` | Scan `res://stickmen/*.stk` → entry models; display-name fallback; corrupt-file skip; ad-hoc Browse entries | +| `res://scripts/prop_library.gd` | `PropLibrary` / `RefCounted` | Static registry of the 4 prop templates (id → payload + material preset + label) | +| `res://scripts/thumbnails/thumbnail_cache.gd` | `ThumbnailCache` / `RefCounted` | Disk PNG cache: key computation, load/save, invalidation, stale cleanup | +| `res://scripts/thumbnails/stickman_thumbnail.gd` | `StickmanThumbnail` / `Node` | Render a parsed `.stk` → `Texture2D` via real rig in offscreen `SubViewport` | +| `res://scripts/thumbnails/prop_thumbnail.gd` | `PropThumbnail` / `Node` | Render a prop template → `Texture2D` (lightweight visual, no physics) | +| `res://scripts/asset_selector.gd` | `AssetSelector` / `PopupPanel` | Grid UI controller (root of the `.tscn` shell) | +| `res://scenes/asset_selector.tscn` | `PopupPanel` root + `asset_selector.gd` | Shell: title bar, empty `GridContainer`, footer buttons | + +Directory `res://scripts/thumbnails/` does not exist yet — create it. + +--- + +## 4. Data Contracts + +### 4.1 Stickman entry (`StickmanLibrary.get_entries() -> Array[Dictionary]`) + +```gdscript +{ + "path": String, # "res://stickmen/bob.stk" (or a Browse-chosen arbitrary path) + "name": String, # display name: stickman_name if non-empty, else filename basename + "data": Dictionary, # parsed JSON root (parse failures skip the entry entirely — never {}) +} +``` + +Thumbnails are **not** embedded in the entry. They are resolved via `ThumbnailCache` and attached +at display time by `AssetSelector`, keeping the entry model pure/serializable and the scan cheap. + +### 4.2 Prop entry (`PropLibrary.get_entries() -> Array[Dictionary]`) + +```gdscript +{ + "id": String, # "crate" | "ball" | "plank" | "triangle" + "name": String, # "Crate" | "Ball" | "Plank" | "Triangle" + "material_preset": int, # PropBlock.MaterialPreset.WOOD / RUBBER / METAL / CARDBOARD + "material_label": String,# "Wood" / "Rubber" / "Metal" / "Cardboard" + "payload": Dictionary, # PropUtils.create_box() / create_ball() / create_plank() / create_triangle() +} +``` + +Default materials (per decision 3): + +| id | name | generator | `material_preset` | `material_label` | +|---|---|---|---|---| +| `crate` | Crate | `PropUtils.create_box()` | `PropBlock.MaterialPreset.WOOD` | Wood | +| `ball` | Ball | `PropUtils.create_ball()` | `PropBlock.MaterialPreset.RUBBER` | Rubber | +| `plank` | Plank | `PropUtils.create_plank()` | `PropBlock.MaterialPreset.METAL` | Metal | +| `triangle` | Triangle | `PropUtils.create_triangle()` | `PropBlock.MaterialPreset.CARDBOARD` | Cardboard | + +The generator color themes already match these presets (`scripts/prop_utils.gd:23-30`). + +--- + +## 5. Public API Signatures (GDScript) + +### 5.1 `StickmanLibrary` (`res://scripts/stickman_library.gd`) + +```gdscript +class_name StickmanLibrary +extends RefCounted + +const STICKMEN_DIR := "res://stickmen" + +var entries: Array[Dictionary] = [] # last scan result (empty until scan()) + +func scan() -> Array[Dictionary] + # DirAccess.open(STICKMEN_DIR) + list *.stk (sorted by display name, then path). + # Per file: StickmanFactory.load_stk(path); skip + push_warning on {} (corrupt/missing body_parts). + # name = String(data.get("stickman_name", "")).strip_edges() + # if name.is_empty(): name = path.get_file().get_basename() + # append { "path": path, "name": name, "data": data } + # Caches entries on self and returns them. + +func get_entries() -> Array[Dictionary] # returns entries (does NOT rescan) +func find_by_path(path: String) -> Dictionary # {} if absent +func make_entry(path: String) -> Dictionary # ad-hoc (Browse): load_stk + name fallback; {} on failure +``` + +### 5.2 `PropLibrary` (`res://scripts/prop_library.gd`) + +```gdscript +class_name PropLibrary +extends RefCounted + +static var _templates: Array[Dictionary] = [] # lazily built (cannot call PropUtils in a const) + +static func get_entries() -> Array[Dictionary] # builds once, returns the 4 templates (see 4.2) +static func get_ids() -> Array[String] +static func get_entry(id: String) -> Dictionary # {} if unknown +static func get_default_id() -> String # "crate" +``` + +Note: `PropUtils.create_*()` are `static func` — they **cannot** run in a `const` initializer, so +`_templates` is a `static var` built on first access. + +### 5.3 `ThumbnailCache` (`res://scripts/thumbnails/thumbnail_cache.gd`) + +```gdscript +class_name ThumbnailCache +extends RefCounted + +const STICKMEN_DIR := "user://thumbnails/stickmen" +const PROP_DIR := "user://thumbnails/props" +const PROP_VERSION := 1 # bump to invalidate all prop thumbnails + +func stickman_key(path: String) -> String + # "_" — mtime change => new key => regen +func stickman_png(key: String) -> String # STICKMEN_DIR + "/" + key + ".png" +func prop_png(id: String) -> String # PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png" + +func load_png(png_path: String) -> Texture2D # FileAccess.file_exists ? Image.load + ImageTexture : null +func save_png(tex: Texture2D, png_path: String) -> Error # tex.get_image().save_png(png_path) +func ensure_dir(dir: String) -> void # DirAccess.make_dir_recursive_absolute +func clean_stale_stickmen(valid_keys: Dictionary) -> void # optional: delete basename_* not in valid set +``` + +### 5.4 `StickmanThumbnail` (`res://scripts/thumbnails/stickman_thumbnail.gd`) + +```gdscript +class_name StickmanThumbnail +extends Node + +const SIZE := Vector2i(200, 200) + +func render(stk_data: Dictionary) -> Texture2D + # Spawns the real rig, frames it, awaits double frame_post_draw, captures, frees the rig. + # Returns a placeholder ImageTexture on blank/failed capture (headless degrade). +``` + +### 5.5 `PropThumbnail` (`res://scripts/thumbnails/prop_thumbnail.gd`) + +```gdscript +class_name PropThumbnail +extends Node + +const SIZE := Vector2i(200, 200) + +func render(payload: Dictionary, material_preset: int) -> Texture2D + # Builds a lightweight Polygon2D + Line2D from the payload (no RigidBody2D, so no gravity), + # tinted via PropBlock.tint_for(material_preset) when preset != NONE; frames + captures. +``` + +### 5.6 `AssetSelector` (`res://scripts/asset_selector.gd`, root of `.tscn`) + +```gdscript +class_name AssetSelector +extends PopupPanel + +signal item_selected(entry: Dictionary) +signal cancelled() +signal browse_requested() +signal refresh_requested() + +const COLUMNS := 4 +const ROWS := 3 +const PAGE_SIZE := COLUMNS * ROWS # 12 + +var kind: String = "" # "stickman" | "prop" + +func open(kind: String, entries: Array[Dictionary]) -> void +func set_entries(entries: Array[Dictionary]) -> void # used by Refresh +func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void # lazy thumbnail handoff (see 8.4) +func close() -> void +``` + +Internal (private) responsibilities: page state, `GridContainer` cell (re)build, Prev/Next/page +label, empty-state label, hover styling, Esc handling, Browse/Refresh/Close button wiring. + +> **Post-implementation revision (2026-09-03):** the two selection params (`selected_path`, +> `selected_id`) were **dropped** — the signature is now `open(kind, entries)`. The pre-highlight +> mechanism (`_selected_path`/`_selected_id` members, `_is_selected()` helper, and the selected +> stylebox cell border) was **removed**: no cell is highlighted on open. The selector also +> re-centers on window resize (the root's `size_changed` re-runs `popup_centered()` while visible). + +--- + +## 6. Thumbnail Generation Recipe (Godot 4.7 runtime) + +Both renderers are `Node`s (added to the stage tree so they may `await`). Each owns one persistent +offscreen `SubViewport` built in `_ready()`. + +### 6.1 Stickman (real rig) + +```gdscript +func _ready() -> void: + _viewport = SubViewport.new() + _viewport.size = SIZE + _viewport.transparent_bg = true + _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(_viewport) + _world = Node2D.new(); _viewport.add_child(_world) + _camera = Camera2D.new(); _camera.enabled = true + _world.add_child(_camera); _camera.make_current() + +func render(stk_data: Dictionary) -> Texture2D: + _clear_world() + var rig: StickmanRig = StickmanFactory.spawn_from_data(stk_data) # synchronous mount (adapter) + _world.add_child(rig) # _ready() runs now: profile/z-order/IK applied + rig.position = Vector2.ZERO + var bbox := StageSpawner.get_world_aabb(rig) # unions mounted Body/* geometry head-to-feet + if not bbox.has_area() or bbox.size.x < 1.0 or bbox.size.y < 1.0: + bbox = Rect2(Vector2(-60, -500), Vector2(120, 500)) # degenerate-figure fallback + _frame_camera(bbox) + await RenderingServer.frame_post_draw # await TWICE (standard offscreen capture recipe) + await RenderingServer.frame_post_draw + var img := _viewport.get_texture().get_image() + rig.queue_free() + return ImageTexture.create_from_image(img) + +func _frame_camera(bbox: Rect2) -> void: + var margin := 12.0 + var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y) + _camera.position = bbox.get_center() + _camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05)) +``` + +### 6.2 Prop (lightweight visual — no physics) + +Mirror `PropBlock._apply_polygon_geometry()` / `_apply_circle_geometry()` +(`scripts/prop_block.gd:169-194`) into a plain `Node2D` with a `Polygon2D` (fill) and a `Line2D` +(outline, closed loop, round joints/caps). **Do not instantiate `PropBlock`** — it is a +`RigidBody2D` and would fall under gravity inside the SubViewport. Apply +`PropBlock.tint_for(material_preset)` for non-`NONE` presets, then the same `_frame_camera` + +double-`frame_post_draw` capture as above. + +### 6.3 Correctness notes (bake into implementation) + +- SubViewport must be **in the tree** and `render_target_update_mode = UPDATE_ALWAYS` during capture. +- `await RenderingServer.frame_post_draw` **twice** before `get_texture().get_image()`. +- `Camera2D` must be `enabled = true` + `make_current()` **inside** the SubViewport. +- `_clear_world()` frees any prior rig/visual children before each capture. +- **Headless degrade:** in `--headless` the dummy renderer fires `frame_post_draw` but may return a + blank image. The renderer returns a **placeholder** `ImageTexture` (solid color + no-preview) when + the captured image is blank/empty. Pixel rendering is **manual/F6 verification only**; headless + tests must not assert on pixels (see §11). + +### 6.4 Cache keying & invalidation + +- **Stickmen:** key embeds `FileAccess.get_modified_time(path)` (Unix seconds). A modified `.stk` + yields a new key → missing PNG → regenerate. `clean_stale_stickmen` deletes superseded PNGs for + the same basename. +- **Props:** key = `id_v`; only 4 templates, cheap; regenerate when the version const + changes or the PNG is missing. +- Thumbnails are generated **lazily, one per frame** (see §8.4) so scanning 50+ files never stalls. + +--- + +## 7. `AssetSelector` — `.tscn`-Based Design + +### 7.1 Scene structure (`res://scenes/asset_selector.tscn`) + +``` +AssetSelector (PopupPanel, root, script = res://scripts/asset_selector.gd) +├── MarginContainer +│ └── VBoxContainer +│ ├── TitleBar (HBoxContainer) +│ │ ├── TitleLabel (Label) # text set at open(): "Choose Your Stickman" / "Choose a Prop" +│ │ └── CloseButton (Button, "×") # wired to cancelled.emit() +│ ├── GridContainer (columns = 4) # dynamic cells built in code, cleared per page +│ ├── EmptyLabel (Label, hidden by default) # tr("No stickmen found! Create one in the editor first.") +│ └── Footer (HBoxContainer) +│ ├── PrevButton (Button, "← Prev") +│ ├── PageLabel (Label, "Page 1/N") +│ ├── NextButton (Button, "Next →") +│ ├── BrowseButton (Button, "Browse…") # visible only when kind == "stickman" +│ └── RefreshButton (Button, "Refresh") +``` + +The `.tscn` is authored **minimal**: the container/control skeleton + unique-name markers +(`%TitleLabel`, `%GridContainer`, `%EmptyLabel`, `%PageLabel`, `%PrevButton`, `%NextButton`, +`%BrowseButton`, `%RefreshButton`). All per-cell content (TextureRect, name label, material badge) +is created in code at runtime because the entry set is dynamic. + +### 7.2 Root type & signals + +Root is `PopupPanel`. It is opened with `popup_centered()` (modal). Signals per §5.6: + +- `item_selected(entry)` — user clicked a cell. +- `cancelled()` — Close button or Esc. +- `browse_requested()` — Browse button (stickman only). +- `refresh_requested()` — Refresh button. + +Esc handling: `PopupPanel` sets `exclusive = true`; `AssetSelector` overrides +`_unhandled_input` for `KEY_ESCAPE` → `cancelled.emit()` (Window's built-in Esc close is not +relied on). + +> **Post-implementation revisions (2026-09-03):** +> - **Resize re-center:** the root's `size_changed` signal re-runs `popup_centered()` while the +> popup is visible, so the grid re-centers on window resize. +> - **Dim backdrop:** the stage shows a black `ColorRect` (`_selector_dim`, +> `SELECTOR_DIM_ALPHA = 0.5`, `mouse_filter = MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` +> behind the selector) while the selector is open, to dim the stage behind the modal grid. +> - **Outside-click → cancel:** the stage routes the selector's `popup_hide` signal to +> `_on_selector_cancelled()` (idempotency-guarded), so an outside-click that closes the modal +> popup also un-presses the palette button (mirroring an explicit cancel/Esc). + +### 7.3 How `sandbox_stage.gd` instantiates + theme-overrides it + +```gdscript +# preload at top: +const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn") + +# in _ready(), after _build_ui(): +_selector = ASSET_SELECTOR.instantiate() as AssetSelector +_ui.add_child(_selector) # _ui = the UI CanvasLayer (sandbox_stage.gd:1111) +_apply_ui_font(_selector) # existing helper (sandbox_stage.gd:1091) +# Apply _ui_font/_emoji_font to authored labels/buttons by walking _selector children +# (or add a small AssetSelector.apply_font(font, emoji_font) -> void called here). +_selector.item_selected.connect(_on_asset_selected) +_selector.cancelled.connect(_on_selector_cancelled) +_selector.browse_requested.connect(_on_browse_requested) +_selector.refresh_requested.connect(_on_refresh_requested) +``` + +If a dedicated `AssetSelector.apply_font(ui_font: Font, emoji_font: Font) -> void` helper is +preferred (walking the authored controls and adding font overrides), the stage calls it right +after `add_child` using `_ui_font`/`_emoji_font` (loaded in `_load_theme`, `scripts/sandbox_stage.gd:2164-2212`). + +--- + +## 8. `StageSpawner` Precise Edits (`scripts/stage_spawner.gd`) + +### 8.1 New state (replace the single `_stickman_data`) + +```gdscript +var selected_stickman_path: String = DEFAULT_STICKMAN_PATH # line 25 const +var selected_prop_id: String = "crate" # == PropLibrary.get_default_id() +var _stickman_cache: Dictionary = {} # path -> parsed data +``` + +### 8.2 `_init` (lines 45-50) + +Seed the cache instead of the single field: + +```gdscript +_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH) +if _stickman_cache[DEFAULT_STICKMAN_PATH].is_empty(): + push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH) +_build_registry() +``` + +### 8.3 `_build_registry` (lines 178-220) + +- **Remove** the `crate` entry (lines 202-206) and the `ball` entry (lines 207-211). +- **Keep** `stickman` (lines 212-215) as-is (spawn now reads `selected_stickman_path`). +- **Add** a `prop` entry: + +```gdscript +{ + "id": "prop", "label": "Prop", "kind": "prop", + "spawn_offset": Vector2.ZERO, +}, +``` + +Resulting spawn ids (and therefore palette buttons, via `get_spawnable_ids()` line 56): +`ground, ramp, step, prop, stickman, area`. + +### 8.4 `_spawn_stickman` (lines 268-278) + +```gdscript +func _spawn_stickman(world_position: Vector2) -> StickmanRig: + var data: Dictionary = _stickman_cache.get(selected_stickman_path, {}) + if data.is_empty(): + data = STICKMAN_FACTORY.load_stk(selected_stickman_path) + _stickman_cache[selected_stickman_path] = data + if data.is_empty(): + push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path) + return null + var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data) + if rig == null: + push_warning("StageSpawner: failed to spawn stickman.") + return null + rig.position = world_position + _world.add_child(rig) + return rig +``` + +### 8.5 `_spawn_prop` (lines 254-257) + +```gdscript +func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock: + var t: Dictionary = PropLibrary.get_entry(selected_prop_id) + if t.is_empty(): + push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id) + return null + return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO) +``` + +### 8.6 New getters (for status/tests) + +```gdscript +func get_selected_stickman_path() -> String +func get_selected_prop_id() -> String +``` + +--- + +## 9. `SandboxStage` Precise Edits (`scripts/sandbox_stage.gd`) + +### 9.1 New state + +```gdscript +var _stickman_library: StickmanLibrary +var _prop_library: PropLibrary +var _thumbnail_cache: ThumbnailCache +var _stickman_thumb: StickmanThumbnail +var _prop_thumb: PropThumbnail +var _selector: AssetSelector = null +var _selector_open: bool = false +var _selector_kind: String = "" # "stickman" | "prop" +var _browse_dialog: FileDialog = null +var _thumbnail_queue: Array[Dictionary] = [] # {entry, kind} pending lazy render (see 9.6) +``` + +### 9.2 `_ready()` (lines 244-270) + +After `_spawner = STAGE_SPAWNER.new(_world)` and around `_build_ui()`: + +```gdscript +_stickman_library = StickmanLibrary.new() +_prop_library = PropLibrary.new() # (get_entries() is static; instance kept only if needed) +_thumbnail_cache = ThumbnailCache.new() +_stickman_thumb = StickmanThumbnail.new() +_prop_thumb = PropThumbnail.new() +add_child(_stickman_thumb) # Node renderers must be in-tree to await +add_child(_prop_thumb) +``` + +Build the selector in `_build_ui()` (see §7.3) or immediately after it; build `_browse_dialog` +mirroring `scripts/test_harness.gd:286-292`: + +```gdscript +_browse_dialog = FileDialog.new() +_browse_dialog.title = "Open .stk" +_browse_dialog.access = FileDialog.ACCESS_FILESYSTEM +_browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE +_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"]) +_browse_dialog.file_selected.connect(_on_browse_file_selected) +_ui.add_child(_browse_dialog) +``` + +### 9.3 Palette toggle branch — `_on_palette_toggled` (lines 1404-1408) + +```gdscript +func _on_palette_toggled(pressed: bool, id: String) -> void: + if pressed: + if id == "stickman" or id == "prop": + _open_selector(id) + return + set_placement_mode(id) # terrain + area unchanged + else: + if _selector_open and _selector_kind == id: + _close_selector() + elif _placement_id == id: + set_placement_mode("") +``` + +### 9.4 Selector open/close flow + +```gdscript +func _open_selector(kind: String) -> void: + var entries: Array[Dictionary] + if kind == "stickman": + entries = _stickman_library.scan() + else: + entries = _prop_library.get_entries() + # Single-item skip (stickman only, per plan): + if kind == "stickman" and entries.size() == 1: + _on_asset_selected(entries[0]) # selects + enters placement, no grid + return + _selector_kind = kind + _selector_open = true + _selector.open(kind, entries) + # Keep the palette button visually pressed ("tool active"), but no ghost yet. + +func _on_asset_selected(entry: Dictionary) -> void: + if _selector_kind == "stickman": + _spawner.selected_stickman_path = String(entry["path"]) + else: + _spawner.selected_prop_id = String(entry["id"]) + _close_selector() + set_placement_mode(_selector_kind) # spawns ghost; palette button reflects placement + +func _close_selector() -> void: + if _selector != null: + _selector.hide() + _selector_open = false + _selector_kind = "" + +func _on_selector_cancelled() -> void: + _close_selector() + set_placement_mode("") # un-press the palette button +``` + +`set_placement_mode` (`scripts/sandbox_stage.gd:566-576`) already loops `_palette_buttons` to sync +pressed state, so it correctly presses the `stickman`/`prop` button on selection and un-presses on +cancel. + +> **Post-implementation revisions (2026-09-03):** `_open_selector` also shows a **dim backdrop** +> (`_selector_dim`, black `ColorRect` at `SELECTOR_DIM_ALPHA = 0.5`, `MOUSE_FILTER_IGNORE`, on the +> UI `CanvasLayer` behind the selector) while the selector is open; `_close_selector()` flips +> `_selector_open` off **before** hiding the popup. In addition to the explicit cancel/Esc path, the +> selector's `popup_hide` signal is connected to `_on_selector_cancelled()` (idempotency-guarded), +> so an outside-click that closes the modal popup likewise un-presses the palette button. + +### 9.5 Browse / Refresh handlers + +```gdscript +func _on_browse_requested() -> void: + _browse_dialog.popup_centered() + +func _on_browse_file_selected(path: String) -> void: + var entry := _stickman_library.make_entry(path) + if entry.is_empty(): + _show_toast("Could not load stickman: " + path) # existing toast helper (line 2090) + return + _on_asset_selected(entry) # selects ad-hoc entry + enters placement + +func _on_refresh_requested() -> void: + _selector.set_entries(_stickman_library.scan()) +``` + +### 9.6 Lazy thumbnail drain (in `_process`, lines 273-289) + +Thumbnail rendering is deferred out of the click path. When the selector opens, the stage enqueues +entries lacking a cached PNG; `_process` renders **one per frame** and hands the texture back via +`_selector.set_thumbnail(entry, tex)` (placeholder shown until then). This satisfies the +"non-blocking / loading state" acceptance criterion. (Alternatively, `AssetSelector` can own the +queue; the stage must own the renderers, so the stage-owned queue + `set_thumbnail` handoff is +recommended.) + +### 9.7 Esc priority (in `_unhandled_key_input`, lines 322-344) + +Insert before the `_placement_id != ""` branch: + +```gdscript +elif _selector_open: + _on_selector_cancelled() +``` + +Final Esc order: rule step → pending walk target → terrain drag → selector → DIRECT → placement → +selection. + +### 9.8 Guards + +While `_selector_open`, `_handle_world_click` (`scripts/sandbox_stage.gd:360-409`) and +`_handle_mouse_motion` (lines 412-426) early-return at the top (the modal `PopupPanel` already +blocks most input, but the stage uses `_input` for mouse — the explicit flag is belt-and-suspenders). + +--- + +## 10. Edge Cases + +| Edge case | Handling | +|---|---| +| No `.stk` files | Grid opens with empty-state label (do **not** silently skip — user needs the hint) | +| Exactly one `.stk` | Skip grid, select it, enter placement directly (stickman only) | +| Corrupt `.stk` (`load_stk` → `{}`, or missing `body_parts`) | Skip + `push_warning`; not counted as an entry | +| Empty `stickman_name` | Display filename basename (strip `.stk`) | +| 50+ files | Pagination 12/page; thumbnails lazy (1/frame); scan parses once + caches `data` | +| Modified `.stk` | New cache key (mtime-embedded) → regenerate thumbnail | +| Thumbnail render fails (headless / blank) | Placeholder texture; retried next open | +| Selected file deleted | On open, if `selected_stickman_path` not in scan → fall back to first entry (or empty state) | +| Browse-selected file (outside `stickmen/`) | Spawnable by path via `make_entry`; not rescanned; re-validated next open | +| Duplicate display names | Allowed; selection is by path/id, not name | +| Grid resize | `GridContainer` reflows; page bounds recomputed on `resized` | +| Window resize | Selector re-centers (`popup_centered()` re-run on the root's `size_changed` while visible); stage behind the grid is dimmed by `_selector_dim` while open | +| Outside-click closes the modal selector | `popup_hide` → `_on_selector_cancelled()` (idempotency-guarded) un-presses the palette button, same as Esc/cancel | +| `stickman`/`prop` button toggled off while selector open | `_on_palette_toggled` unpressed branch → `_close_selector()` | +| Prop pagination | Only 4 templates → always 1 page; Prev/Next hidden/disabled | + +--- + +## 11. Acceptance Criteria + +### 11.1 Stickman selector +- [ ] "Stickman" opens the grid (does not immediately spawn `test.stk`). +- [ ] Grid lists all `.stk` in `res://stickmen/` (test/basic/break). +- [ ] Each cell shows a rig-rendered thumbnail. +- [ ] Name = `stickman_name`, else filename (`test`/`break` fall back; `Basic` shows "Basic"). +- [ ] Click → select + close + enter placement (ghost appears). +- [ ] Selection persists for subsequent placements across EDIT/DIRECT/PLAY toggles. +- [ ] Pagination at 12/page. +- [ ] Browse opens `FileDialog` (`*.stk`) and selects an arbitrary file. +- [ ] Refresh rescans. +- [ ] Empty state label when no files. +- [ ] Single-item skip when exactly one file. +- [ ] Thumbnails cached to `user://thumbnails/stickmen/`. +- [ ] Modified `.stk` regenerates its thumbnail. + +### 11.2 Prop selector +- [ ] "Prop" opens the grid. +- [ ] Shows Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard. +- [ ] Each cell: thumbnail + name + material badge. +- [ ] Click → select + close + enter placement. +- [ ] Selection persists for subsequent placements. +- [ ] Single page. +- [ ] Thumbnails cached to `user://thumbnails/props/`. + +### 11.3 Palette integration +- [ ] `crate`/`ball` palette buttons removed. +- [ ] Single "Prop" button replaces them. +- [ ] "Stickman" opens the grid. +- [ ] `Ground`/`Ramp`/`Step`/`Area` unchanged and still place directly. +- [ ] Selecting an asset spawns the **selected** asset (verify ghost + placed object change when switching selection). + +### 11.4 Performance +- [ ] 50-file scan non-blocking. +- [ ] Thumbnail generation deferred (loading state / placeholder). +- [ ] Grid pagination smooth. + +--- + +## 12. Verification Plan + +### 12.1 Runner command (from `tests/test_phase4b_stage.gd:11-14`) + +``` +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_library.gd --path . +``` + +### 12.2 New headless test — `tests/test_phase3b_library.gd` (`extends SceneTree`) + +Cover (no pixel assertions — headless renders blank): +1. `StickmanLibrary.scan()` returns 3 entries (test/basic/break); names `["test","Basic","break"]` + (empty `stickman_name` → filename fallback). +2. Corrupt file skipped: write a temp `.stk` with invalid JSON into a **copied** scan dir override + (make `STICKMEN_DIR` overridable for tests, or test `make_entry()` on a bad path returns `{}`). +3. `make_entry()` on a valid path returns name+data; on missing/corrupt path returns `{}`. +4. `PropLibrary.get_entries()` → 4 templates with expected `id`/`material_preset`/ + `material_label`; `get_default_id() == "crate"`. +5. `StageSpawner` registry: `get_spawnable_ids()` == `["ground","ramp","step","prop","stickman","area"]` + (no `crate`/`ball`). +6. `_spawn_prop` honors `selected_prop_id`: default spawns a `PropBlock` (crate); set + `selected_prop_id = "ball"` → spawns a circle prop (`shape_type == ShapeType.CIRCLE`). +7. `_spawn_stickman` honors `selected_stickman_path`: set it to `res://stickmen/basic.stk` → spawns + a `StickmanRig` whose mounted geometry differs from `test.stk` (or at minimum spawns non-null). +8. `ThumbnailCache`: `stickman_key(path)` changes when `FileAccess.get_modified_time` changes + (mock by passing a path + expected mtime); `stickman_png`/`prop_png` path formatting; + `PROP_VERSION` bump changes `prop_png`. +9. Selector pagination math: expose a pure `static page_bounds(total, page, PAGE_SIZE) -> Dictionary` + (or test `PAGE_SIZE == 12` + a page-slicing helper) without instantiating UI. + +### 12.3 Existing tests to update + +- **`tests/test_phase4b1_fixes.gd:259`** — calls `stage._spawner.spawn("crate", ...)`. Update to + `stage._spawner.spawn("prop", ...)` (default `selected_prop_id` is `"crate"`, so behavior is + unchanged) **or** explicitly set `stage._spawner.selected_prop_id = "crate"` first. This is the + only existing test that references a removed registry id. +- **`tests/test_phase4b_stage.gd`** — references only `_palette_buttons["ground"]` (lines 78, 87, + 99, 108, 217, 238) and `spawn("ground")`/`spawn("stickman")`; **no `crate`/`ball` references, no + palette-count assertion** → no changes required, but re-run to confirm. +- `tests/test_phase4b_grid_dirty.gd`, `test_phase4b_terrain.gd`, `test_phase4b_logic.gd`, + `test_phase4b2_fixes.gd`, `test_phase4b_walk.gd` build props directly (`PropBlock.new()`) or use + `ground`/`stickman` only → **no changes required**. + +### 12.4 Manual (F6) + +`res://scenes/sandbox_stage.tscn`: visual grid, thumbnails, pagination, Browse, Refresh, +ghost/placement, mode-toggle persistence. + +--- + +## 13. Implementation Order (updated for `.tscn` decision) + +| Step | Task | Dependencies | +|---|---|---| +| 1 | `PropLibrary` (registry, static) | none | +| 2 | `ThumbnailCache` (keys, load/save, dirs) | none | +| 3 | `StickmanLibrary` (scan + entry model) | none | +| 4 | `StickmanThumbnail` (rig renderer) | 2, 3 | +| 5 | `PropThumbnail` (lightweight renderer) | 1, 2 | +| 6 | `AssetSelector` + `asset_selector.tscn` (shell + grid controller) | 1–5 | +| 7 | `StageSpawner` edits (registry, `selected_*`, per-path cache, getters) | 1 | +| 8 | `SandboxStage` edits (instantiate + theme, palette branch, open/close, Esc, guards, Browse/Refresh, lazy drain) | 6, 7 | +| 9 | Update `tests/test_phase4b1_fixes.gd:259` | 7 | +| 10 | Write `tests/test_phase3b_library.gd` | 1, 2, 3, 7, 8 | +| 11 | Manual F6 pass (thumbnails, pagination, persistence) | 8 | diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index badae4e..10e6f96 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -23,7 +23,11 @@ This document tracks known technical debt, optimization opportunities, and minor | 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) | | 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) | | 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once props/other stickmen are registered as obstacles. (2026‑08‑29) | -| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. Future: add a spatial hash / broadphase grid keyed by world cell to cull candidate pairs before the AABB/feet-point tests. (2026‑08‑30) | +| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) | +| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `plans/PHASE_4b_SPEC.md` §3. (2026-09-02) | +| 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) | +| 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) | +| 18 | **Asset selection is session-only, not saved** — `StageSpawner.selected_stickman_path` / `selected_prop_id` reset on scene reload (Phase 3b decision, per spec). | Low | Open | Deliberate for Phase 3b (spec §2 decision 1: no disk save, do not extend `_save_settings`). A future persistence phase could persist the last-chosen stickman/prop to `user://sandbox_settings.json` for convenience. (2026-09-03) | --- @@ -58,6 +62,11 @@ This document tracks known technical debt, optimization opportunities, and minor | 2026-08-29 | Phase 3a (Core Director Functionality) spec written (`docs/phase_3a_spec.md`). Logged #13 (slope-aware walking physics + dynamic obstacle avoidance deferred — the nav mesh itself is built in 3a). Notable non-debt decisions recorded in the spec: Play mode now runs the director script instead of auto-ragdolling stickmen; `walk_to(target)` treats `target` as a feet/ground destination via `FOOT_OFFSET`, with the `NavigationAgent2D` child placed at the feet `(0,+385)` so it paths on the ground-level nav mesh. | | 2026-08-29 | **`walk_to` stops-after-a-few-px bug fixed.** `_update_walking` (Phase 3a) now defers nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0` (map-sync guard) and forces the path query via `get_next_path_position()` before any empty-path/finished check. **Follow-up (same day):** the initial "warn + finish in place" unreachable-target policy was itself reported as "stickman stands still with a waypoint" and was replaced by **hybrid nav/direct steering** — an on-mesh target follows the nav path (`_walk_mode = "nav"`), an off-mesh/unreachable target walks **straight to the clicked waypoint** (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`), with **no** `push_warning`; `_walk_path_grace` removed (map-sync guard + forced path query replace it); the debug trace now carries `mode=nav|direct`. Off-by-default `DEBUG_WALK` (`stickman_rig.gd`) / `DEBUG_STAGE` (`sandbox_stage.gd`) traces added. #13 remains **Open** (slope physics + RVO avoidance are still deferred; the fix only changes unreachable-target handling). Logged in `BUGS.md`; verified with a 44-assertion headless regression suite. | | 2026-08-30 | Phase 4 (Triggers & Event System) implemented: `scripts/trigger_area.gd` (NEW placeable sensor), `sandbox_stage.gd` rule system (`_event_rules`, geometric event engine `_update_area_entry` / `_update_stickman_prop_collision`, rule-builder UI state machine, `_cleanup_rules_for_nodes`), `stickman_rig.gd` (`arrived` gains a `target` payload; new `enqueue_reactive`), `prop_block.gd` (`collided` physics signal), `stage_director_visuals.gd` rule visualization, `stage_spawner.gd` / `stage_selection.gd` `"area"` palette + duck-typed `get_area_rect`. Logged #14 (all-pairs event engine scales O(rigs×props + areas×movables); spatial hash suggested). | +| 2026-09-02 | Phase 4b (Polish) spec written (`plans/PHASE_4b_SPEC.md`). Logged #15 (walk-arrival jitter: `_walk_mode` re-evaluated per frame + arrival-radius mismatch) and #16 (no grid spatial dictionary; Phase 4b adds one for terrain drag-painting + event-engine broadphase). | +| 2026-09-02 | Phase 4b (Polish) implemented. Resolved **#15** (walk-mode latch once per walk, unified `ARRIVE_DISTANCE` arrival with snap-on-arrive, steer-to-final-when-close, one-frame marker re-assert) and **#16** (grid spatial dictionary `_grid_cells` @ `TERRAIN_GRID_SIZE` for the drag-painting 3-state query + director target-validity). #14 remains **Open** — the dictionary is not yet wired into the Phase 4 event engine (note updated). Added `res://sandbox_theme.json`, `scripts/stage_placement_overlay.gd`, the 3-segment mode switcher / status bar / badge / frame / cursors, and director tooltip/trajectory/reticle UX. | +| 2026-09-03 | Phase 3b (Asset Library) implemented (`docs/phase_3b_asset_grid_spec.md`): `scripts/stickman_library.gd` / `prop_library.gd`, `scripts/thumbnails/thumbnail_cache.gd` / `stickman_thumbnail.gd` / `prop_thumbnail.gd`, `scripts/asset_selector.gd` + `scenes/asset_selector.tscn`. The Stickman/Prop palette buttons now open visual selector grids (pagination 12/page, session-only selection, Browse/Refresh); `StageSpawner` registry becomes `ground/ramp/step/prop/stickman/area` (`crate`/`ball` removed) with `selected_*` session state + per-path stickman cache; `SandboxStage` owns the selector open/close flow, Esc priority, and the one-per-frame lazy thumbnail drain. Logged #17 (thumbnail cache growth / headless placeholder) and #18 (session-only selection). Verified with the new headless suite `tests/test_phase3b_library.gd` plus the updated `tests/test_phase4b1_fixes.gd`. | +| 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. | +| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. | --- diff --git a/master_rig.tscn b/master_rig.tscn index 1d7d309..c3f85e3 100644 --- a/master_rig.tscn +++ b/master_rig.tscn @@ -55,10 +55,10 @@ bone_index = 1 bone2d_node = NodePath("Torso/Head") target_nodepath = NodePath("../IK_Targets/Head") enable_constraint = true -constraint_angle_min = 54.99998 -constraint_angle_max = 304.9999 -constraint_angle_invert = true -constraint_in_localspace = true +constraint_angle_min = -180 +constraint_angle_max = 180 +constraint_angle_invert = false +constraint_in_localspace = false [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] modification_count = 5 diff --git a/plans/PHASE_3b_ASSET_GRID.md b/plans/PHASE_3b_ASSET_GRID.md new file mode 100644 index 0000000..ebad718 --- /dev/null +++ b/plans/PHASE_3b_ASSET_GRID.md @@ -0,0 +1,330 @@ +# Phase 3b: Asset Library — Stickman + Prop Selector Grids + +## 1. Overview + +Phase 3b adds a **visual asset library** to the Sandbox Stage Builder. Currently, placing a stickman always spawns the hard-coded `test.stk` figure, and props are limited to the palette buttons (Crate, Ball, Plank). This phase replaces those limitations with **visual selector grids** that let the user choose from all available assets. + +## 2. Core Concept + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ASSET LIBRARY WORKFLOW │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ EDIT MODE │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ User clicks "Stickman" palette button │ │ +│ │ ↓ │ │ +│ │ Stickman Selector Grid opens (popup overlay) │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Choose Your Stickman [×] Close │ │ │ +│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │ +│ │ │ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ │ │ +│ │ │ │ Bob │ │ Sally│ │ John │ │ Joe │ │ │ │ +│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ +│ │ │ ┌──────┐ ┌──────┐ │ │ │ +│ │ │ │ 📷 │ │ 📷 │ │ │ │ +│ │ │ │ Sue │ │ Tom │ │ │ │ +│ │ │ └──────┘ └──────┘ │ │ │ +│ │ │ [Prev] Page 1/2 [Next] [Browse...] [Refresh] │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ ↓ │ │ +│ │ User clicks a cell → grid closes │ │ +│ │ ↓ │ │ +│ │ Click the stage → spawns the selected stickman │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + +``` + +## 3. What's Being Replaced + +| Before | After | +| ------------------------------------------- | ------------------------------------------------- | +| "Stickman" palette button spawns `test.stk` | "Stickman" palette button opens the selector grid | +| Separate "Crate", "Ball", "Plank" buttons | Single "Prop" button opens the prop selector grid | +| No visual preview | Thumbnail preview in each grid cell | +| No choice | Choose from all available assets | +| Hard-coded file path | Dynamic library from scanned files | + +## 4. Feature Breakdown + +### 4.1. Stickman Selector Grid + +| Feature | Description | +| --------------- | ------------------------------------------------------------------------ | +| **Source** | Scans `res://stickmen/` for `.stk` files | +| **Thumbnails** | Renders each stickman to a texture (cached to `user://thumbnails/`) | +| **Display** | Grid cells: thumbnail + `stickman_name` (or filename if no name) | +| **Pagination** | 12 items per page (3 rows × 4 columns) with Prev/Next buttons | +| **Selection** | Click a cell → selected stickman is cached as the default | +| **Browse** | "Browse..." button opens a `FileDialog` to select a `.stk` from anywhere | +| **Refresh** | "Refresh" button rescans the `stickmen/` folder | +| **Empty state** | "No stickmen found! Create one in the editor first." | + +### 4.2. Prop Selector Grid + +| Feature | Description | +| -------------- | ------------------------------------------------------ | +| **Source** | Built-in prop templates (Crate, Ball, Plank, Triangle) | +| **Thumbnails** | Renders each prop with its material/color | +| **Display** | Grid cells: thumbnail + prop name + material badge | +| **Pagination** | 12 items per page with Prev/Next buttons | +| **Selection** | Click a cell → selected prop is cached as the default | +| **Future** | Custom props (`.prp` files) will appear here | + +### 4.3. Single Palette Buttons + +| Before | After | +| ----------------------- | ---------------------------- | +| "Stickman" (hard-coded) | "Stickman" (opens grid) | +| "Crate" (hard-coded) | Removed | +| "Ball" (hard-coded) | Removed | +| "Plank" (hard-coded) | Removed | +| (None) | **"Prop"** (opens prop grid) | + +## 5. Visual Design + +### 5.1. Grid Cell Layout + +``` +┌─────────────────────────────────────┐ +│ ┌─────────────────────────────┐ │ +│ │ │ │ +│ │ [THUMBNAIL] │ │ ← 150×150 px preview +│ │ │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ Bob │ │ ← Name (bold, centered) +│ │ 🪵 Wood │ │ ← Material badge (props only) +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ + +``` + +**Cell size:** ~160×220 px (thumbnail 150×150, name area 60px) + +### 5.2. Grid Popup Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Choose Your Stickman [×] Close │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ +│ │ Bob │ │Sally │ │ John │ │ Joe │ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ +│ │ Sue │ │ Tom │ │Alex │ │Jess │ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ +│ │Sam │ │Ella │ │Max │ │Mia │ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ │ +│ │ +│ [← Prev] Page 1/3 [Next →] [Browse...] [Refresh] │ +│ │ +│ Selected: Bob │ +└─────────────────────────────────────────────────────────────────────────┘ + +``` + +### 5.3. Visual States + +| State | Appearance | +| --------------------- | ------------------------------------- | +| **Default cell** | Light background, subtle border | +| **Hover** | Border highlight, slight scale (1.05) | +| **Selected** | Blue/cyan border, checkmark overlay | +| **Loading thumbnail** | Spinner or placeholder icon | +| **Empty cell** | "No preview available" placeholder | + +## 6. Data Flow + +### 6.1. Stickman Library Indexer + +```gdscript +# stickman_library.gd (NEW) +class_name StickmanLibrary +extends RefCounted + +# Scans res://stickmen/*.stk +# Returns Array[Dictionary] with: +# { +# "path": "res://stickmen/bob.stk", +# "name": "Bob", +# "thumbnail": Texture2D, +# "data": Dictionary # parsed .stk data (optional) +# } +``` + +### 6.2. Prop Library Registry + +```gdscript +# prop_library.gd (NEW) +class_name PropLibrary +extends RefCounted + +# Built-in prop templates: +# - Crate (Wood) +# - Ball (Rubber) +# - Plank (Metal) +# - Triangle (Cardboard) + +# Returns Array[Dictionary] with: +# { +# "id": "crate", +# "name": "Crate", +# "factory": Callable (returns shape payload), +# "material_preset": PropBlock.MaterialPreset.WOOD, +# "thumbnail": Texture2D +# } +``` + +### 6.3. Thumbnail Generation + +#### Stickman Thumbnails: + +- Parse the .stk file to get body_parts and part_order. +- Create a temporary SubViewport (200×200 px). +- Render the stickman using a simplified version of WholeStickmanPreview logic. +- Capture the viewport as a Texture2D. +- Cache to user://thumbnails/stickmen/.png. + +#### Prop Thumbnails: + +- Instantiate a temporary PropBlock in a SubViewport. +- Apply the material preset and geometry. +- Capture the viewport as a Texture2D. +- Cache to user://thumbnails/props/.png. + +#### Caching Strategy: + +- Thumbnails are generated once per file and cached to disk. +- On subsequent runs, load the cached thumbnail if it exists and the source file hasn't been modified. +- If the source file is modified, regenerate the thumbnail. + +## 7. UI Integration + +### 7.1. Palette Button Changes + +#### Button Behavior + +- "Stickman" Opens the Stickman Selector Grid +- "Prop" Opens the Prop Selector Grid + +### 7.2. Selected Asset Persistence + +- The last selected stickman is stored in StageSpawner.selected_stickman_path. +- The last selected prop is stored in StageSpawner.selected_prop_id. +- These persist across mode toggles and scene reloads (in memory only — no disk save in Phase 3b). + +### 7.3. Placement Flow + +``` +User clicks "Stickman" palette button + ↓ +Stickman Selector Grid opens + ↓ +User clicks a stickman cell + ↓ +Grid closes → selected stickman is cached + ↓ +Stage enters placement mode (ghost appears) + ↓ +User clicks the stage → spawns the selected stickman +``` + +## 8. File Structure + +| File | Purpose | +| ---------------------------------------------- | ------------------------------------------------------ | +| res://scripts/stickman_library.gd | Scans .stk files, manages thumbnails | +| res://scripts/prop_library.gd | Registry of prop templates | +| res://scripts/asset_selector.gd | Grid UI controller (shared between stickmen and props) | +| res://scenes/asset_selector.tscn | Grid popup scene | +| res://scripts/thumbnails/stickman_thumbnail.gd | Renders stickman to texture | +| res://scripts/thumbnails/prop_thumbnail.gd | Renders prop to texture | +| res://user://thumbnails/ | Cached thumbnails (generated at runtime) | + +## 9. Acceptance Criteria + +### 9.1. Stickman Selector Grid + +- [ ] Clicking "Stickman" palette button opens the grid. +- [ ] Grid displays all .stk files in res://stickmen/. +- [ ] Each cell shows a thumbnail preview of the stickman. +- [ ] Each cell shows the stickman's stickman_name (or filename if no name). +- [ ] Clicking a cell selects that stickman and closes the grid. +- [ ] The selected stickman persists for future placements. +- [ ] Pagination works when more than 12 stickmen exist. +- [ ] "Browse..." button opens a FileDialog to select any .stk. +- [ ] "Refresh" button rescans the stickmen/ folder. +- [ ] If no .stk files exist, shows "No stickmen found! Create one in the editor first." +- [ ] If only one .stk exists, the grid is skipped and the stickman is selected directly. +- [ ] Thumbnails are cached to user://thumbnails/ and reused. +- [ ] Modified .stk files regenerate their thumbnails. + +### 9.2. Prop Selector Grid + +- [ ] Clicking "Prop" palette button opens the grid. +- [ ] Grid displays all built-in prop templates. +- [ ] Each cell shows a thumbnail preview of the prop. +- [ ] Each cell shows the prop name and material badge. +- [ ] Clicking a cell selects that prop and closes the grid. +- [ ] The selected prop persists for future placements. +- [ ] Pagination works when more than 12 props exist. +- [ ] Thumbnails are cached to user://thumbnails/props/. + +### 9.3. Palette Integration + +- [ ] The old "Crate", "Ball", and "Plank" buttons are removed. +- [ ] A single "Prop" button replaces them. +- [ ] The "Stickman" button now opens the grid (instead of spawning test.stk). + +### 9.4. Performance + +- [ ] Scanning 50+ .stk files does not stall the UI. +- [ ] Thumbnail generation is non-blocking (or uses a loading state). +- [ ] Grid opens and paginates smoothly. + +## 10. Implementation Order + +| Step | Task | Dependencies | +| ---- | ---------------------------------------------------------- | ------------ | +| 1 | Create stickman_library.gd (file scanner + entry model) | None | +| 2 | Create stickman_thumbnail.gd (renders stickman to texture) | Step 1 | +| 3 | Create asset_selector.gd / asset_selector.tscn (grid UI) | Steps 1-2 | +| 4 | Integrate selector with "Stickman" palette button | Step 3 | +| 5 | Create prop_library.gd (built-in prop registry) | None | +| 6 | Create prop_thumbnail.gd (renders prop to texture) | Step 5 | +| 7 | Integrate selector with "Prop" palette button | Steps 5-6 | +| 8 | Remove old "Crate", "Ball", "Plank" buttons | Step 7 | +| 9 | Implement thumbnail caching | Steps 2, 6 | + +## 11. Edge Cases + +| Edge Case | Handling | +| -------------------------- | --------------------------------------------------------- | +| No .stk files exist | Show "No stickmen found! Create one in the editor first." | +| Only one .stk exists | Skip the grid, select it directly | +| Thumbnail generation fails | Show placeholder icon + regenerate on next run | +| File is corrupted/invalid | Skip the file, log a warning | +| Stickman has no name | Use the filename (without .stk) | +| Many files (50+) | Pagination keeps the UI responsive | +| Grid size changes | Rebuild the grid layout on resize | +| Selected file is deleted | Reselect the first available file (or show empty state) | + +## 12. Summary + +| Before | After | +| --------------------------------- | ------------------------------------- | +| Hard-coded test.stk | Visual grid of all .stk files | +| Separate Crate/Ball/Plank buttons | Single "Prop" button with visual grid | +| No preview | Thumbnail preview in every cell | +| No choice | Choose from all available assets | +| No caching | Thumbnails cached to user:// | + +This is the final piece connecting the Stickman Editor to the Sandbox Stage. Users can now create stickmen in the editor, save them as .stk, and pick them from the grid when placing actors on the stage. diff --git a/plans/PHASE_3c_EDITOR.md b/plans/PHASE_3c_EDITOR.md new file mode 100644 index 0000000..e6da920 --- /dev/null +++ b/plans/PHASE_3c_EDITOR.md @@ -0,0 +1,593 @@ +# Phase 3c: Editor Tools — Action & Rule Editing + +## 1. Overview + +Phase 3c adds **full editing capabilities** for both the Director Tool's action queues and the Event System's rules. Currently, directors can only **append** actions and **create/delete** rules. They cannot fix mistakes, change order, or modify existing items. This phase makes the entire script **fully editable**. + +The plan combines action editing and rule editing into a single, cohesive implementation with a logical progression from simpler to more complex features. + +--- + +## 2. Core Principles + +### 2.1. Extensibility First + +The system is built to accommodate future growth: + +| Future Addition | How It's Supported | +| ---------------------- | --------------------------------------------------------- | +| New action types | Registry pattern — add action template, UI auto-generates | +| New rule trigger types | Registry pattern — add trigger type, UI auto-generates | +| New action properties | Action data model is a Dictionary — add new keys freely | +| New rule properties | Rule data model is a Dictionary — add new keys freely | +| New visual styles | Theme JSON already supports font/color overrides | + +### 2.2. Consistency + +The UI patterns for action editing and rule editing are **identical**: + +| Pattern | Action Edition | Rule Edition | +| ------------ | ----------------------------------- | ---------------------------------- | +| Entry points | Popup menu + Right-click + Waypoint | Rule label + Stickman context menu | +| Panel UI | Action Queue Panel | Rule Panel | +| Editor Popup | Action Editor | Rule Editor | +| Controls | [✎] Edit, [✕] Delete, [≡] Reorder | Same | +| Drag handles | Reorder actions | Reorder rules | + +--- + +## 3. Implementation Order + +The plan is organized into **5 phases**, each building on the previous: + +| Phase | Focus | Deliverables | +| -------------- | ------------------------- | -------------------------------------------- | +| **Phase 3c.1** | **Foundation** | Shared UI components, extensible data models | +| **Phase 3c.2** | **Action Queue Panel** | View, edit, delete, reorder actions | +| **Phase 3c.3** | **Action Visual Editing** | Waypoint context menu, visual walk editing | +| **Phase 3c.4** | **Rule Panel** | View, edit, delete, reorder rules | +| **Phase 3c.5** | **Rule Visual Editing** | Rule label click, waypoint trigger editing | + +--- + +## 4. Phase 3c.1: Foundation + +### 4.1. Extensible Action Registry + +The action system should be registry-driven to support future action types: + +```gdscript + +# action_registry.gd (NEW) +const ACTION_TEMPLATES = { + "walk_to": { + "label": "Walk To", + "icon": "🚶", + "params": [ + { "key": "target", "type": "position", "required": true } + ] + }, + "speak": { + "label": "Speak", + "icon": "💬", + "params": [ + { "key": "text", "type": "text", "required": true }, + { "key": "duration", "type": "float", "default": 2.0 } + ] + }, + "wait": { + "label": "Wait", + "icon": "⏳", + "params": [ + { "key": "duration", "type": "float", "required": true } + ] + }, + "ragdoll": { + "label": "Ragdoll", + "icon": "💥", + "params": [] + }, + "recover": { + "label": "Recover", + "icon": "🔄", + "params": [] + } +} +``` + +**Extensibility:** Adding a new action type = appending to `ACTION_TEMPLATES`. No other code changes required. + +### 4.2. Extensible Trigger Registry + +Similarly, trigger types are registry-driven: + +```gdscript + +# trigger_registry.gd (NEW) +const TRIGGER_TEMPLATES = { + "arrived_at_waypoint": { + "label": "Arrives at waypoint", + "icon": "📍", + "target_type": "waypoint" + }, + "action_finished": { + "label": "Completes any action", + "icon": "✅", + "target_type": "action_type" + }, + "speech_finished": { + "label": "Finishes speaking", + "icon": "💬", + "target_type": "none" + }, + "entered_area": { + "label": "Enters trigger area", + "icon": "🎯", + "target_type": "area" + }, + "collided": { + "label": "Collides with something", + "icon": "💥", + "target_type": "prop" + } +} +``` + +### 4.3. Shared UI Components + +| Component | Purpose | Reused By | +| ----------------------- | ----------------------------- | ------------------------------ | +| **Panel Container** | Scrollable list of items | Action Queue Panel, Rule Panel | +| **Editor Popup** | Edit single item's properties | Action Editor, Rule Editor | +| **Drag Handle** | Reorder items | Both panels | +| **Delete Confirmation** | Confirm before deletion | Both panels | + +### 4.4. Extensible Action Properties + +Actions are stored as Dictionaries, so future properties can be added without breaking existing code: + +```gdscript + +# Current action +{ "type": "speak", "text": "Hello", "duration": 2.0 } +# Future action (with text color) +{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" } +``` + +**Extensibility:** New properties are just new keys in the Dictionary. The editor should display editable fields for all known keys and gracefully ignore unknown ones. + +--- + +## 5. Phase 3c.2: Action Queue Panel + +### 5.1. Feature Breakdown + +| Feature | Description | +| ------------------- | ------------------------------------------- | +| **Queue Panel** | Popup showing all actions for a stickman | +| **Edit Action** | Re-open action popup with pre-filled values | +| **Delete Action** | Remove action from queue (confirmation) | +| **Reorder Actions** | Drag handle to reorder | +| **Add Action** | Append new action from panel | +| **Clear All** | Remove all actions (confirmation) | + +### 5.2. Entry Points + +| Entry Point | When to Use | How it Works | +| --------------- | -------------------------------- | ------------------------------------------- | +| **Popup** | Edit queue for selected stickman | Click stickman → "Edit Queue" → panel opens | +| **Right-click** | Quick access | Right-click stickman → "Edit Queue" | + +### 5.3. UI Layout + +``` + +┌─────────────────────────────────────────────────────────────────────┐ +│ ACTION QUEUE PANEL │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Stickman: Bob [×] Close │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 1 🚶 Walk to Crate [✎] [✕] [≡] │ │ +│ │ 2 💬 Speak "Hello!" [✎] [✕] [≡] │ │ +│ │ 3 ⏳ Wait 2.0s [✎] [✕] [≡] │ │ +│ │ 4 💥 Ragdoll [✎] [✕] [≡] │ │ +│ │ 5 🔄 Recover [✎] [✕] [≡] │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Action] [Clear All] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 5.4. Edit Action Flow + +1. User clicks **[✎]** on an action. +2. The **same action popup** appears, with current values pre-filled. +3. User makes changes. +4. Click **OK** → action is updated in the queue. +5. Visuals update immediately. + +| Action Type | Pre-filled Values | +| ----------- | -------------------------------------------------- | +| **Walk To** | Current target position (waypoint dot highlighted) | +| **Speak** | Current text and duration | +| **Wait** | Current duration | +| **Ragdoll** | (No parameters) | +| **Recover** | (No parameters) | + +--- + +## 6. Phase 3c.3: Action Visual Editing + +### 6.1. Feature Breakdown + +| Feature | Description | +| ------------------------- | ----------------------------------------- | +| **Waypoint Context Menu** | Right-click waypoint → Edit/Delete/Insert | +| **Edit Walk (Visual)** | Click a new position → waypoint moves | +| **Insert Action** | Insert before/after a specific waypoint | + +### 6.2. Waypoint Context Menu + +In Edit mode, right-clicking a waypoint dot opens: + +``` + +Right-click Waypoint 3: +┌─────────────────────────────────────────────┐ +│ ✎ Edit this Walk │ +│ ✕ Delete this Walk │ +│ ⬆ Insert action before │ +│ ⬇ Insert action after │ +└─────────────────────────────────────────────┘ +``` + +| Action | Behavior | +| ------------------------ | -------------------------------------------------------------- | +| **Edit this Walk** | Enters target placement mode → click new spot → waypoint moves | +| **Delete this Walk** | Removes the walk action from the queue | +| **Insert action before** | Opens action popup → inserts new action before this one | +| **Insert action after** | Opens action popup → inserts new action after this one | + +### 6.3. Visual Update Flow + +``` +User right-clicks waypoint + ↓ +Context menu appears + ↓ +User clicks "Edit this Walk" + ↓ +Stage enters target placement mode + ↓ +Current waypoint is highlighted (blinking) + ↓ +User clicks new position on stage + ↓ +Old waypoint removed, new waypoint appears + ↓ +walk_to action's target is updated + ↓ +Dotted lines reconnect + ↓ +Order numbers remain the same +``` + +--- + +## 7. Phase 3c.4: Rule Panel + +### 7.1. Feature Breakdown + +| Feature | Description | +| -------------------------------- | --------------------------------------------- | +| **Rule Panel** | Popup showing all rules for a source stickman | +| **Edit Rule (Full)** | Edit trigger type, trigger target, action(s) | +| **Edit Rule (Consequence-Only)** | Quick edit of actions only | +| **Delete Rule** | Remove rule from registry (confirmation) | +| **Reorder Rules** | Drag handle to reorder evaluation order | +| **Add Action to Rule** | Add another action to an existing rule | +| **Remove Action from Rule** | Delete an action from a rule | +| **Clear All** | Remove all rules (confirmation) | + +### 7.2. Entry Points + +| Entry Point | When to Use | How it Works | +| -------------------- | ----------------------------- | ------------------------------------------------- | +| **Rule Label** | Edit a specific rule | Click the rule label (dashed line) → editor opens | +| **Stickman Context** | View all rules for a stickman | Right-click stickman → "Edit Rules" → panel opens | + +### 7.3. Rule Panel UI + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ RULE PANEL │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Source: Stickman A [×] Close │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 1 📍 When A arrives at Waypoint 3 │ │ +│ │ → B speaks "Hello there!" [✎] [✕] [≡] │ │ +│ │ │ │ +│ │ 2 📍 When A arrives at Waypoint 5 │ │ +│ │ → C walks to crate [✎] [✕] [≡] │ │ +│ │ │ │ +│ │ 3 🎯 When A enters Area │ │ +│ │ → All stickmen ragdoll [✎] [✕] [≡] │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Rule] [Clear All] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.4. Rule Editor (Full) + +Opens when user clicks **[✎]** on a rule in the panel: + +``` + +┌─────────────────────────────────────────────────────────────────────┐ +│ EDIT RULE │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Trigger: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ [📍 Arrives at waypoint ▼] [Click target →] │ │ +│ │ Target: Waypoint 3 (on stage) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ Actions: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │ +│ │ 2 🚶 C walks to crate [✎] [✕] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Action] [Cancel] [OK] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.5. Rule Editor (Consequence-Only) + +Opens when user clicks a rule label on the stage: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ EDIT RULE │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ When: 📍 A arrives at Waypoint 3 (read-only) │ +│ ────────────────────────────────────────────────────────────── │ +│ Then: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │ +│ │ 2 🚶 C walks to crate [✎] [✕] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Action] [Done] [Cancel] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Focus:** The trigger is displayed but **read-only**. For trigger editing, use the full rule editor. + +--- + +## 8. Phase 3c.5: Rule Visual Editing + +### 8.1. Feature Breakdown + +| Feature | Description | +| ---------------------------- | ------------------------------------------- | +| **Rule Label Click** | Click rule label → consequence-only editor | +| **Waypoint → Rules** | Right-click waypoint → "Edit Trigger Rules" | +| **Rule Reordering (Visual)** | Evaluation order shown on stage (optional) | + +### 8.2. Waypoint → Rules + +In Edit mode, right-clicking a waypoint dot that is used as a trigger target: + +``` +Right-click Waypoint 3: +┌─────────────────────────────────────────────┐ +│ ✎ Edit this Walk (action) │ +│ ───────────────────────────────────────── │ +│ ⚡ Edit Trigger Rules (2 rules) │ +└─────────────────────────────────────────────┘ +``` + +Clicking "Edit Trigger Rules" opens the Rule Panel filtered to rules using that waypoint. + +--- + +## 9. File Structure + +| File | Purpose | +| ----------------------------------- | ------------------------------------------------------ | +| `scripts/action_registry.gd` | Registry of action templates (extensible) | +| `scripts/trigger_registry.gd` | Registry of trigger templates (extensible) | +| `scripts/queue_panel.gd` | Action Queue Panel controller | +| `scripts/queue_panel.tscn` | Action Queue Panel scene | +| `scripts/rule_panel.gd` | Rule Panel controller | +| `scripts/rule_panel.tscn` | Rule Panel scene | +| `scripts/action_editor.gd` | Action Editor popup controller | +| `scripts/action_editor.tscn` | Action Editor popup scene | +| `scripts/rule_editor.gd` | Rule Editor popup controller (full + consequence-only) | +| `scripts/rule_editor.tscn` | Rule Editor popup scene | +| `scripts/waypoint_context.gd` | Waypoint right-click menu | +| `scripts/sandbox_stage.gd` | Add "Edit Queue", "Edit Rules", integrate editors | +| `scripts/stage_director_visuals.gd` | Waypoint hit-testing, rule label click → editor | + +--- + +## 10. Extensibility Guide + +### 10.1. Adding a New Action Type + +```gdscript + +# 1. Add to action_registry.gd +const ACTION_TEMPLATES = { + # ... existing actions ... + "jump": { + "label": "Jump", + "icon": "🦘", + "params": [ + { "key": "height", "type": "float", "default": 100.0 }, + { "key": "duration", "type": "float", "default": 0.5 } + ] + } +} +``` + +# 2. Implement the action in StickmanRig.\_process_queue() + +# 3. Add the action to the popup (automatically from registry) + +### 10.2. Adding a New Rule Trigger Type + +```gdscript + +# 1. Add to trigger_registry.gd +const TRIGGER_TEMPLATES = { + # ... existing triggers ... + "variable_changed": { + "label": "Variable changes", + "icon": "📊", + "target_type": "variable" + } +} +``` + +# 2. Emit the trigger signal from SandboxStage + +# 3. Add the trigger to the rule builder (automatically from registry) + +### 10.3. Adding a New Action Property + +```gdscript + +# 1. The action is stored as a Dictionary +{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" } +# 2. The Action Editor reads all keys from params +# 3. Unknown keys are displayed as read-only (or editable with generic control) +# 4. The action runner reads the new key when executing +``` + +--- + +## 11. Acceptance Criteria + +### 11.1. Action Queue Panel + +-"Edit Queue" opens panel from popup and right-click. + +-Panel shows all actions in order. + +-Each action shows type, parameters, and order number. + +-[✕] deletes action (confirmation dialog). + +-[✎] opens edit popup with pre-filled values. + +-[≡] reorders actions. + +-"Clear All" removes all actions (confirmation). + +-"Add Action" appends a new action. + +### 11.2. Edit Action + +- Walk To: Edit opens target placement mode; clicking new spot updates waypoint. +- Speak: Edit opens text dialog with current text pre-filled. +- Wait: Edit opens duration dialog with current value pre-filled. +- Ragdoll/Recover: Edit opens confirmation dialog. + +### 11.3. Waypoint Context Menu + +- Right-click waypoint opens context menu. +- "Edit this Walk" enters target placement mode. +- "Delete this Walk" removes the action. +- "Insert action before/after" inserts new action. + +### 11.4. Rule Panel + +- "Edit Rules" opens panel from stickman context menu. +- Panel shows all rules where stickman is trigger source. +- Each rule shows trigger type, target, and action(s). +- `[✕] deletes rule (confirmation). +- `[✎] opens rule editor with pre-filled values. +- `[≡] reorders rules. +- "Add Rule" opens rule builder. +- "Clear All" removes all rules (confirmation). + +### 11.5. Rule Editor + +- Full editor: trigger type dropdown works, trigger target can be clicked. +- Full editor: action type dropdown works, target can be clicked. +- Full editor: parameters (text, duration) can be edited. +- Consequence-only editor: trigger is read-only. +- Consequence-only editor: actions can be edited. +- Multi-action rules: add/remove actions. +- Saving updates rule in event registry. +- Visual connectors update immediately. + +### 11.6. Visual Updates + +- Waypoint dots move when Walk actions are edited. +- Speech badge text updates when Speak actions are edited. +- Wait duration updates. +- Dotted lines reconnect to reflect new order. +- Order numbers update after insert/delete/reorder. +- Rule labels update with new summaries. +- Dashed lines reconnect to new targets. + +### 11.7. Backward Compatibility + +- Existing queues load and display correctly. +- Editing preserves action types and parameters. +- Existing rules load and display correctly. +- Editing a rule preserves the rule ID. +- Deleting an action/rule cleans up all references. + +### 11.8. Extensibility + +- New action types can be added via registry (no code changes required). +- New trigger types can be added via registry. +- New action properties are supported (dictionary keys). +- Editor gracefully handles unknown keys. + +--- + +## 12. Implementation Order Summary + +| Phase | Focus | Key Deliverables | +| -------- | --------------------- | ------------------------------------------------------- | +| **3c.1** | Foundation | Action Registry, Trigger Registry, Shared UI Components | +| **3c.2** | Action Queue Panel | Queue panel, edit/delete/reorder actions | +| **3c.3** | Action Visual Editing | Waypoint context menu, visual walk editing | +| **3c.4** | Rule Panel | Rule panel, full/consequence-only edit, reorder | +| **3c.5** | Rule Visual Editing | Rule label click, waypoint trigger rules | + +--- + +## 13. Summary + +| Before | After | +| ------------------------------------ | --------------------------------------------- | +| Actions can only be appended | Actions can be edited, deleted, and reordered | +| Rules can only be created or deleted | Rules can be edited, deleted, and reordered | +| No way to fix mistakes | Edit any parameter | +| No way to change order | Drag to reorder | +| No visual editing | Waypoint context menu + visual walk editing | +| No chain reaction editing | Add/remove actions from rules | +| Fixed evaluation order | Drag to reorder rules | +| Tightly coupled code | Registry-driven, extensible architecture | + +**This completes the Director Tool's full editing capabilities.** Directors can now create, edit, delete, and reorder both actions and rules with full control and extensibility for future development. diff --git a/plans/PHASE_3c_SPEC.md b/plans/PHASE_3c_SPEC.md new file mode 100644 index 0000000..f0876ce --- /dev/null +++ b/plans/PHASE_3c_SPEC.md @@ -0,0 +1,565 @@ +# Phase 3c — Editor Tools: Action & Rule Editing (Spec) + +Status: SPEC (pending implementation) +Related plan: `plans/PHASE_3c_EDITOR.md` +Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", "Forward Plus")`). + +--- + +## 1. Overview & Scope + +Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules +**fully editable** (edit / delete / reorder / insert), replacing the current +append-only + create/delete-only behavior. It is built on two registry-driven tables +(action + trigger) so the UI is generated from data rather than hard-coded `match` +statements, matching the existing `StageSpawner._registry` / `PropLibrary` patterns. + +### In scope + +- Action + trigger **registries** (`action_registry.gd`, `trigger_registry.gd`). +- **Action Queue Panel** — view / edit / delete / reorder / add / clear a stickman's queue. +- **Action visual editing** — waypoint right-click context menu (edit walk / delete walk / + insert before / insert after). +- **Rule Panel** — view / edit / delete / reorder / add / clear rules (filtered by source stickman). +- **Rule Editor** — full edit (trigger type + target + actions) and consequence-only edit + (actions only, trigger read-only). +- **Rule visual editing** — rule-label click → consequence-only editor (already partially + present via `_begin_edit_rule`); waypoint → "Edit Trigger Rules". + +### Out of scope / untouched (must not regress) + +- **StickmanRig runner execution semantics** — the action runner (`_begin_action`, + `_update_runner`, the 5 action phases) is **not** changed. Editing mutates queue/rule + *data*; the runner already consumes any of those shapes. +- **Event engine matching** (`_rule_matches`, `_update_area_entry`, + `_update_stickman_prop_collision`) — unchanged. Rule *reordering* changes evaluation + order (array order) but not the per-rule matching logic. +- **Navigation / walk steering** (`_update_walking`, mode latch) — unchanged. +- **Terrain drag-painting, selection, gizmos, placement ghost, asset selector** — unchanged. +- **No `.stk` / `settings.json` format changes.** Queues and rules remain session-only + (persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the + Phase 3a/4 decision. +- **No new action types / trigger types** are implemented (only the *machinery* to add them + cleanly). The registry is the extension point; wiring a genuinely new action still requires + a runner case in `StickmanRig._begin_action` (see §6 and §13). + +--- + +## 2. Recorded Decisions + +1. **Registries are static tables, not singletons.** `action_registry.gd` / + `trigger_registry.gd` are `class_name`-less-optional, `RefCounted` scripts exposing + `static` const tables + `static func` accessors (mirroring `PropLibrary`). `sandbox_stage.gd` + preloads them like the other `preload` consts. No autoload, no instance state. +2. **All new UI is code-built; no `.tscn` files.** The plan lists `queue_panel.tscn`, + `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`. The codebase builds all stage + UI in code inside `_build_ui()` (top bar, popups, dialogs); only `AssetSelector` uses a + `.tscn` shell, and it has authored content. These panels/editors are **dynamic** (row lists + change every mutation), so they are `PopupPanel`-based controller scripts constructed in + code. **Dropped**: `action_editor.gd`/`action_editor.tscn` (action param editing reuses the + existing `_speak_dialog`/`_wait_dialog` + pending-target machinery) and + `waypoint_context.gd` (the waypoint menu is a code-built `PopupMenu`, exactly like + `_trigger_popup`). See §7 file list. +3. **Reorder UX = Move Up / Move Down buttons, not drag.** A `[≡]` drag handle in a `PopupPanel` + requires hand-rolled `_gui_input` drag/reorder hit-testing; up/down buttons are simpler, + keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable + headlessly. Each row shows `⬆`/`⬇` (disabled at the ends) plus `✎`/`✕`. Drag reorder is + logged as deferred (tech debt §13). +4. **Waypoint → action mapping returns `(rig, index, pos)`, not just a position.** + `StageDirectorVisuals.hit_test_waypoint()` currently returns only the nearest `Vector2` + (used by the rule builder for `arrived_at_waypoint`). A new `hit_test_waypoint_action()` + returns `{rig, index, pos}` so the context menu knows **which rig's queue and which + `walk_to` action index** to edit/delete/insert around. The existing position-only method is + kept for the rule builder (rules reference waypoint *positions*, not indices). +5. **Insert before/after maps directly to `StickmanRig.insert_action(index, action)`.** + "Before waypoint *i*" → `insert_action(i, action)`; "after" → `insert_action(i + 1, action)`. + `i` is the **queue index** of the `walk_to` action (equal to the waypoint ordinal − 1 because + `walk_to` is the only waypoint-producing action). `insert_action` already clamps to `[0, size]`. +6. **Edit-walk visual flow reuses the pending target-capture machinery.** A new edit state + (`_pending_walk_edit_index >= 0`) reuses `_pending_walk_target`'s cursor/hint/trajectory and + the `_handle_direct_click`/`_handle_world_click` routing; the next stage click **replaces** + the existing `walk_to.target` instead of appending. The edited waypoint is highlighted in + the director visuals. +7. **Rule reorder = array order; `id` is immutable and preserved.** `_event_rules` is iterated + in order by `_handle_event` (all matching rules execute — no short-circuit). Reordering swaps + array entries; editing preserves `id` (already the case in `_finalize_rule`). `_next_rule_id` + stays monotonic (no id reuse on delete). +8. **Rule Panel filter = `trigger.source`.** "Edit Rules" on a stickman shows rules whose + `trigger.source` equals that stickman's instance id. The waypoint "Edit Trigger Rules" filter + matches by `params.waypoint_pos` proximity (`WAYPOINT_MATCH_EPSILON`), because rules store + waypoint **positions**, not queue indices. +9. **Delete confirmation policy (deliberately asymmetric).** Panel-initiated `✕` deletes and + "Clear All" (queue and rules) confirm via `ConfirmationDialog`. The on-stage rule-label `✕` + and the waypoint "Delete this Walk" stay **immediate** (current low-friction behavior, + not regressed). Documented in §13. +10. **Ragdoll/Recover have no editable parameters.** Their `✎` is hidden in the panel and their + "edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens + confirmation dialog" is corrected — there is nothing to edit.) +11. **The full rule editor drives the existing `RuleStep` state machine.** Trigger-type change + re-enters `SELECT_TRIGGER`; trigger-target change re-enters `TRIGGER_TARGET`; "Add Action" + uses the existing `SELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-more` + flow. The editor panel is a *view*; `sandbox_stage.gd` remains the *controller* owning + `_rule_builder`/`_rule_step`/`_event_rules`. + +--- + +## 3. Discrepancies: Plan vs. Actual Code + +| # | Plan claim | Reality (verified) | Resolution | +|---|---|---|---| +| 1 | Panels/editors are new `.tscn` scenes (`queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`, `waypoint_context.gd`). | Stage UI is built entirely in code (`_build_ui`); only `AssetSelector` has a `.tscn` shell. | Code-built `PopupPanel` scripts (§2.2); drop `action_editor.gd` + `waypoint_context.gd` (§2.2). | +| 2 | Registry `params` with `key`/`type`/`default` drives the editor generically. | Action data lives in **two** shapes: queue action (`walk_to.target` top-level; `speak.text`/`.duration` top-level; `wait.duration` top-level) vs. rule action (`{type, target, params:{...}}`). No generic param model exists. | Registry describes *logical* params; `get_fields`/`make_action` normalize the two shapes (§5.3). | +| 3 | "New action type = append to registry; **no code changes**" (§10.1, §11.8). | `StickmanRig._begin_action` hard-codes the 5 types; a new type also needs a runner case + `_action_for_rig` + `_action_desc`. | Corrected: registry removes *UI* changes; runner changes still required (§6, §12.7). | +| 4 | `hit_test_waypoint()` returns an index. | It returns only `Vector2` (nearest waypoint position); no rig/index identity. | Add `hit_test_waypoint_action()` returning `{rig, index, pos}` (§2.4, §9.2). | +| 5 | "Rule label click → consequence-only edit already exists" (implied complete). | `_begin_edit_rule(id)` pre-populates and jumps straight to the add-action popup (`SELECT_ACTION`), preserving the trigger but offering **no actions list / remove / edit** and no trigger readout UI. | New `RuleEditor` (consequence-only mode) formalizes this; `_begin_edit_rule` routes to it (§11.4). | +| 6 | `action_finished` trigger has a `target_type: "action_type"` (registry). | The builder does **not** expose an action-type selector for `action_finished` (only "completes any action"); `_rule_matches` reads optional `params.action_type`. | Registry marks `action_type` as optional; the full editor exposes it **only if cheap** — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13). | +| 7 | Rule Panel "shows all rules for a source stickman" and reorders. | `_event_rules` is a flat stage-level array (no per-rig grouping); source is `trigger.source` (instance id). | Filter by `trigger.source` (§2.8); reorder operates on the flat array (§2.7). | +| 8 | §11 acceptance criteria formatting: stray backticks and `-` prefixes. | Cosmetic markdown errors in the plan. | Rewritten cleanly in §12. | +| 9 | Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." | Ragdoll/recover are param-less; editing is meaningless. | Corrected (§2.10). | +| 10 | Plan §11.8 "unknown keys displayed as read-only / editable." | No generic editor exists; the runner ignores unknown keys. | Corrected: unknown keys are **preserved** (round-tripped) on edit, never dropped; no generic widget (§12.7). | +| 11 | Waypoint context menu / stickman right-click are new. | Right-click is currently fully consumed by the EDIT placement-cancel branch (`_handle_world_click` returns on RMB). | Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1). | +| 12 | Godot "4.4" (task prompt). | `project.godot:19` and the test runner reference **4.7**. | Spec targets 4.7. | + +--- + +## 4. New Files + +| File | `class_name` / extends | Responsibility | +|---|---|---| +| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Static action template table + `static` helpers (labels/icons/params/describe/normalize). | +| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Static trigger template table + `static` helpers. | +| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals. | +| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals. | +| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals. | + +No `.tscn` files are added (decision 2). `action_editor.gd` and `waypoint_context.gd` from the +plan are **not** created — their responsibilities are absorbed into `sandbox_stage.gd` (existing +dialogs + a code-built `PopupMenu`). + +--- + +## 5. Data Contracts + +### 5.1 Queue action dict (consumed by `StickmanRig` runner — top-level keys) + +```gdscript +{ "type": "walk_to", "target": Vector2 } # target = feet/ground world position +{ "type": "speak", "text": String, "duration": float } +{ "type": "wait", "duration": float } +{ "type": "ragdoll" } +{ "type": "recover" } +# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected) +``` + +### 5.2 Rule dict (stored in `SandboxStage._event_rules`) + +```gdscript +{ + "id": int, # immutable, monotonic from _next_rule_id + "trigger": { + "type": String, # arrived_at_waypoint | action_finished | + # speech_finished | entered_area | collided + "source": int, # instance id of the triggering stickman + "target": int, # instance id (entered_area area | collided prop); + # -1 otherwise + "params": { + # arrived_at_waypoint -> { "waypoint_pos": Vector2 } + # action_finished -> { "action_type": String } (optional; "" == any) + # else -> {} + }, + }, + "actions": [ { "type": String, "target": int, "params": {...} } ], +} +``` + +Rule action `params`: +```gdscript +walk_to -> { "target": Vector2 } # destination (the action's `target` = walking stickman id) +speak -> { "text": String, "duration": float } +wait -> { "duration": float } +ragdoll / recover -> {} +``` + +> **Key asymmetry (documented):** in a **queue** action `walk_to`, `target` is the destination. +> In a **rule** action, `target` is the *stickman instance id* and the destination is +> `params.target`. The registry normalizes this via `get_fields`/`make_action` (§5.3). + +### 5.3 ActionRegistry API (`action_registry.gd`) + +```gdscript +static func get_types() -> Array[String] + # ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order) +static func get_label(type: String) -> String # "Walk To", "Speak", "Wait", "Ragdoll", "Recover" +static func get_icon(type: String) -> String # "🚶","💬","⏳","💥","🔄" +static func get_params(type: String) -> Array[Dictionary] + # [{ "key": "target", "kind": "position", "required": true }] + # [{ "key": "text", "kind": "text", "required": true }, + # { "key": "duration", "kind": "float", "default": 2.0 }] + # [{ "key": "duration", "kind": "float", "required": true }] + # [] for ragdoll/recover +static func has_params(type: String) -> bool +static func get_fields(action: Dictionary) -> Dictionary + # reads each param key from action top-level, falling back to action.params + # (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v}) +static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary + # for_rule=false -> { "type": type, ...top-level keys } + # for_rule=true -> { "type": type, "target": -1, "params": {...keys} } +static func describe(action: Dictionary) -> String + # "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers" + # (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home) +static func row_summary(action: Dictionary) -> String + # panel row text: "