Files
stickman/docs/phase_3b_asset_grid_spec.md
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

33 KiB
Raw Permalink Blame History

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.
  • ThumbnailCacheuser:// 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 SCENEres://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 .stkTexture2D 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])

{
  "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])

{
  "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)

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)

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)

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)

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)

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)

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 Nodes (added to the stage tree so they may await). Each owns one persistent offscreen SubViewport built in _ready().

6.1 Stickman (real rig)

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_ESCAPEcancelled.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

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

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:

_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:
{
    "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)

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)

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)

func get_selected_stickman_path() -> String
func get_selected_prop_id() -> String

9. SandboxStage Precise Edits (scripts/sandbox_stage.gd)

9.1 New state

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():

_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:

_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)

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

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

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:

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