# 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 |