Files
stickman/docs/phase_3b_asset_grid_spec.md
T
ryan 1f91f3d2e5 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.
2026-09-04 15:08:08 -04:00

745 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |