Add headless regression tests for Phase 4b features

- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+744
View File
@@ -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
# "<basename>_<FileAccess.get_modified_time(path)>" — 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<PROP_VERSION>`; 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) | 15 |
| 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 |