- 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.
33 KiB
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
.stkfile inres://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.stkfiles.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.StageSpawneredits — removecrate/ballentries, addprop, re-routestickmanto a selected path with a per-path data cache.SandboxStageedits — selector open/close flow, palette-toggle branch, Esc priority, guards, BrowseFileDialog, Refresh.
Out of scope / untouched (must not regress)
- Terrain palette buttons
Ground/Ramp/Stepand theAreabutton remain direct placement buttons, unchanged (scripts/stage_spawner.gd:178-220registry,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/stickmanare 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 onceselected_stickman_path/selected_prop_idare honored byspawn(). - Director / rule-builder flows (Phase 3a/4) — untouched.
- No
.stkformat changes; no editor changes.
2. Recorded User Decisions
- Persistence: SESSION-ONLY. Selected stickman path + prop id live in memory on
StageSpawner, persist acrossEDIT ⇄ DIRECT ⇄ PLAYmode toggles, and reset on scene reload. No disk save (do not extend_save_settings). - Thumbnail fidelity: RENDER THE ACTUAL RIG. Stickman thumbnails are produced by
StickmanFactory.spawn_from_data(stk_data)in an offscreenSubViewport— identical to what gets placed on stage (not a re-implementation ofWholeStickmanPreview). - Prop set: ALL 4 TEMPLATES — Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard.
- Selector build: SEPARATE
.tscnSCENE —res://scenes/asset_selector.tscnwith root scriptscripts/asset_selector.gd. The.tscnis 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.gdinstantiates 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])
{
"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 nowopen(kind, entries). The pre-highlight mechanism (_selected_path/_selected_idmembers,_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'ssize_changedre-runspopup_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_ALWAYSduring capture. await RenderingServer.frame_post_drawtwice beforeget_texture().get_image().Camera2Dmust beenabled = true+make_current()inside the SubViewport._clear_world()frees any prior rig/visual children before each capture.- Headless degrade: in
--headlessthe dummy renderer firesframe_post_drawbut may return a blank image. The renderer returns a placeholderImageTexture(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.stkyields a new key → missing PNG → regenerate.clean_stale_stickmendeletes 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_changedsignal re-runspopup_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 UICanvasLayerbehind 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_hidesignal 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
crateentry (lines 202-206) and theballentry (lines 207-211). - Keep
stickman(lines 212-215) as-is (spawn now readsselected_stickman_path). - Add a
propentry:
{
"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_selectoralso shows a dim backdrop (_selector_dim, blackColorRectatSELECTOR_DIM_ALPHA = 0.5,MOUSE_FILTER_IGNORE, on the UICanvasLayerbehind the selector) while the selector is open;_close_selector()flips_selector_openoff before hiding the popup. In addition to the explicit cancel/Esc path, the selector'spopup_hidesignal 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
.stkinres://stickmen/(test/basic/break). - Each cell shows a rig-rendered thumbnail.
- Name =
stickman_name, else filename (test/breakfall back;Basicshows "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
.stkregenerates 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/ballpalette buttons removed.- Single "Prop" button replaces them.
- "Stickman" opens the grid.
Ground/Ramp/Step/Areaunchanged 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):
StickmanLibrary.scan()returns 3 entries (test/basic/break); names["test","Basic","break"](emptystickman_name→ filename fallback).- Corrupt file skipped: write a temp
.stkwith invalid JSON into a copied scan dir override (makeSTICKMEN_DIRoverridable for tests, or testmake_entry()on a bad path returns{}). make_entry()on a valid path returns name+data; on missing/corrupt path returns{}.PropLibrary.get_entries()→ 4 templates with expectedid/material_preset/material_label;get_default_id() == "crate".StageSpawnerregistry:get_spawnable_ids()==["ground","ramp","step","prop","stickman","area"](nocrate/ball)._spawn_prophonorsselected_prop_id: default spawns aPropBlock(crate); setselected_prop_id = "ball"→ spawns a circle prop (shape_type == ShapeType.CIRCLE)._spawn_stickmanhonorsselected_stickman_path: set it tores://stickmen/basic.stk→ spawns aStickmanRigwhose mounted geometry differs fromtest.stk(or at minimum spawns non-null).ThumbnailCache:stickman_key(path)changes whenFileAccess.get_modified_timechanges (mock by passing a path + expected mtime);stickman_png/prop_pngpath formatting;PROP_VERSIONbump changesprop_png.- Selector pagination math: expose a pure
static page_bounds(total, page, PAGE_SIZE) -> Dictionary(or testPAGE_SIZE == 12+ a page-slicing helper) without instantiating UI.
12.3 Existing tests to update
tests/test_phase4b1_fixes.gd:259— callsstage._spawner.spawn("crate", ...). Update tostage._spawner.spawn("prop", ...)(defaultselected_prop_idis"crate", so behavior is unchanged) or explicitly setstage._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) andspawn("ground")/spawn("stickman"); nocrate/ballreferences, 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.gdbuild props directly (PropBlock.new()) or useground/stickmanonly → no changes required.
12.4 Manual (F6)
res://scenes/sandbox_stage.tscn: visual grid, thumbnails, pagination, Browse, Refresh,
ghost/placement, mode-toggle persistence.
13. Implementation Order (updated for .tscn decision)
| Step | Task | Dependencies |
|---|---|---|
| 1 | PropLibrary (registry, static) |
none |
| 2 | ThumbnailCache (keys, load/save, dirs) |
none |
| 3 | StickmanLibrary (scan + entry model) |
none |
| 4 | StickmanThumbnail (rig renderer) |
2, 3 |
| 5 | PropThumbnail (lightweight renderer) |
1, 2 |
| 6 | AssetSelector + asset_selector.tscn (shell + grid controller) |
1–5 |
| 7 | StageSpawner edits (registry, selected_*, per-path cache, getters) |
1 |
| 8 | SandboxStage edits (instantiate + theme, palette branch, open/close, Esc, guards, Browse/Refresh, lazy drain) |
6, 7 |
| 9 | Update tests/test_phase4b1_fixes.gd:259 |
7 |
| 10 | Write tests/test_phase3b_library.gd |
1, 2, 3, 7, 8 |
| 11 | Manual F6 pass (thumbnails, pagination, persistence) | 8 |