- 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.
745 lines
33 KiB
Markdown
745 lines
33 KiB
Markdown
# 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) | 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 |
|