Add headless regression tests for Phase 4b features

- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+3 -2
View File
@@ -543,7 +543,8 @@ if _direct_mode:
- If `_pending_walk_target` and `_context_rig` valid → append
`{"type":"walk_to","target":world_pos}` to `_context_rig`; clear pending + context.
- Else `var hit := _selection.hit_test(world_pos)`; if `hit is STICKMAN_RIG` →
`_context_rig = hit`, position `_action_popup` at the mouse and `popup()`. Non-stickman
`_context_rig = hit`, position `_action_popup` to the **right of the clicked stickman**
(`_world_to_screen(hit.global_position)` + 24 px) and `popup()`. Non-stickman
clicks are ignored.
**Popup handler `_on_action_popup_id_pressed(id)`** (guards `_context_rig` valid):
@@ -657,7 +658,7 @@ Implementation notes:
## 7. UI flow (Milestone 3)
1. Press **"Direct"** (toggle). Palette spawn modes are cleared (mutually exclusive).
2. Click a stickman → `StageSelection.hit_test` → if stickman, open `_action_popup` at cursor.
2. Click a stickman → `StageSelection.hit_test` → if stickman, open `_action_popup` to the right of the clicked stickman (its world position converted to screen + 24 px).
3. Choose:
- **Walk To** → enters pending mode; status hint ("Click stage for walk target — Esc to cancel").
- **Speak** → text dialog → append `speak` action.
+744
View File
@@ -0,0 +1,744 @@
# Phase 3b — Asset Library: Stickman + Prop Selector Grids (Spec)
Status: IMPLEMENTED (revised by selector bugfix round, 2026-09-03)
Related plan: `plans/PHASE_3b_ASSET_GRID.md`
Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", ...)`; the test runner comment in `tests/test_phase4b_stage.gd:12` names `Godot_v4.7.1`).
---
## 1. Overview & Scope
Phase 3b replaces the Sandbox Stage Builder's hard-coded single-stickman and single-prop
palette buttons with **visual selector grids**:
- Clicking **"Stickman"** opens a grid of every `.stk` file in `res://stickmen/`, each with a
rendered thumbnail.
- Clicking **"Prop"** opens a grid of 4 built-in prop templates (Crate/Ball/Plank/Triangle),
each with a thumbnail + material badge.
- Selecting a cell caches the asset (session-only) and enters placement mode (ghost appears).
### In scope
- `StickmanLibrary` — scan + index `.stk` files.
- `PropLibrary` — registry of the 4 prop templates.
- `AssetSelector``.tscn`-based grid popup (shell + root script).
- `StickmanThumbnail` / `PropThumbnail` — offscreen-SubViewport renderers.
- `ThumbnailCache``user://` PNG cache with invalidation.
- `StageSpawner` edits — remove `crate`/`ball` entries, add `prop`, re-route `stickman` to a
selected path with a per-path data cache.
- `SandboxStage` edits — selector open/close flow, palette-toggle branch, Esc priority, guards,
Browse `FileDialog`, Refresh.
### Out of scope / untouched (must not regress)
- **Terrain palette buttons** `Ground`/`Ramp`/`Step` and the **`Area`** button remain direct
placement buttons, unchanged (`scripts/stage_spawner.gd:178-220` registry, `scripts/sandbox_stage.gd:1151-1157`).
- **Terrain drag-painting** (`_begin_terrain_drag` / `_update_terrain_drag` /
`_commit_terrain_drag`, `scripts/sandbox_stage.gd:712-869`) — untouched; `prop`/`stickman` are
non-terrain ids so they keep single-placement via `_place_at`.
- **Ghost system** (`_spawn_ghost`, `_configure_ghost_collision`, `_update_ghost_position`,
`scripts/sandbox_stage.gd:614-668`) — untouched; the ghost automatically reflects the selected
asset once `selected_stickman_path`/`selected_prop_id` are honored by `spawn()`.
- **Director / rule-builder** flows (Phase 3a/4) — untouched.
- No `.stk` format changes; no editor changes.
---
## 2. Recorded User Decisions
1. **Persistence: SESSION-ONLY.** Selected stickman path + prop id live in memory on
`StageSpawner`, persist across `EDIT ⇄ DIRECT ⇄ PLAY` mode toggles, and reset on scene reload.
**No disk save** (do not extend `_save_settings`).
2. **Thumbnail fidelity: RENDER THE ACTUAL RIG.** Stickman thumbnails are produced by
`StickmanFactory.spawn_from_data(stk_data)` in an offscreen `SubViewport` — identical to what
gets placed on stage (not a re-implementation of `WholeStickmanPreview`).
3. **Prop set: ALL 4 TEMPLATES** — Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard.
4. **Selector build: SEPARATE `.tscn` SCENE**`res://scenes/asset_selector.tscn` with root
script `scripts/asset_selector.gd`. The `.tscn` is a minimal **shell/layout skeleton only**
(title bar, empty grid placeholder, footer controls as authored nodes); all dynamic cell
content is still built in code at runtime. `sandbox_stage.gd` instantiates the scene and
applies its `_ui_font`/`_emoji_font`/theme overrides after instantiation.
---
## 3. New Files
| File | `class_name` / extends | Responsibility |
|---|---|---|
| `res://scripts/stickman_library.gd` | `StickmanLibrary` / `RefCounted` | Scan `res://stickmen/*.stk` → entry models; display-name fallback; corrupt-file skip; ad-hoc Browse entries |
| `res://scripts/prop_library.gd` | `PropLibrary` / `RefCounted` | Static registry of the 4 prop templates (id → payload + material preset + label) |
| `res://scripts/thumbnails/thumbnail_cache.gd` | `ThumbnailCache` / `RefCounted` | Disk PNG cache: key computation, load/save, invalidation, stale cleanup |
| `res://scripts/thumbnails/stickman_thumbnail.gd` | `StickmanThumbnail` / `Node` | Render a parsed `.stk``Texture2D` via real rig in offscreen `SubViewport` |
| `res://scripts/thumbnails/prop_thumbnail.gd` | `PropThumbnail` / `Node` | Render a prop template → `Texture2D` (lightweight visual, no physics) |
| `res://scripts/asset_selector.gd` | `AssetSelector` / `PopupPanel` | Grid UI controller (root of the `.tscn` shell) |
| `res://scenes/asset_selector.tscn` | `PopupPanel` root + `asset_selector.gd` | Shell: title bar, empty `GridContainer`, footer buttons |
Directory `res://scripts/thumbnails/` does not exist yet — create it.
---
## 4. Data Contracts
### 4.1 Stickman entry (`StickmanLibrary.get_entries() -> Array[Dictionary]`)
```gdscript
{
"path": String, # "res://stickmen/bob.stk" (or a Browse-chosen arbitrary path)
"name": String, # display name: stickman_name if non-empty, else filename basename
"data": Dictionary, # parsed JSON root (parse failures skip the entry entirely — never {})
}
```
Thumbnails are **not** embedded in the entry. They are resolved via `ThumbnailCache` and attached
at display time by `AssetSelector`, keeping the entry model pure/serializable and the scan cheap.
### 4.2 Prop entry (`PropLibrary.get_entries() -> Array[Dictionary]`)
```gdscript
{
"id": String, # "crate" | "ball" | "plank" | "triangle"
"name": String, # "Crate" | "Ball" | "Plank" | "Triangle"
"material_preset": int, # PropBlock.MaterialPreset.WOOD / RUBBER / METAL / CARDBOARD
"material_label": String,# "Wood" / "Rubber" / "Metal" / "Cardboard"
"payload": Dictionary, # PropUtils.create_box() / create_ball() / create_plank() / create_triangle()
}
```
Default materials (per decision 3):
| id | name | generator | `material_preset` | `material_label` |
|---|---|---|---|---|
| `crate` | Crate | `PropUtils.create_box()` | `PropBlock.MaterialPreset.WOOD` | Wood |
| `ball` | Ball | `PropUtils.create_ball()` | `PropBlock.MaterialPreset.RUBBER` | Rubber |
| `plank` | Plank | `PropUtils.create_plank()` | `PropBlock.MaterialPreset.METAL` | Metal |
| `triangle` | Triangle | `PropUtils.create_triangle()` | `PropBlock.MaterialPreset.CARDBOARD` | Cardboard |
The generator color themes already match these presets (`scripts/prop_utils.gd:23-30`).
---
## 5. Public API Signatures (GDScript)
### 5.1 `StickmanLibrary` (`res://scripts/stickman_library.gd`)
```gdscript
class_name StickmanLibrary
extends RefCounted
const STICKMEN_DIR := "res://stickmen"
var entries: Array[Dictionary] = [] # last scan result (empty until scan())
func scan() -> Array[Dictionary]
# DirAccess.open(STICKMEN_DIR) + list *.stk (sorted by display name, then path).
# Per file: StickmanFactory.load_stk(path); skip + push_warning on {} (corrupt/missing body_parts).
# name = String(data.get("stickman_name", "")).strip_edges()
# if name.is_empty(): name = path.get_file().get_basename()
# append { "path": path, "name": name, "data": data }
# Caches entries on self and returns them.
func get_entries() -> Array[Dictionary] # returns entries (does NOT rescan)
func find_by_path(path: String) -> Dictionary # {} if absent
func make_entry(path: String) -> Dictionary # ad-hoc (Browse): load_stk + name fallback; {} on failure
```
### 5.2 `PropLibrary` (`res://scripts/prop_library.gd`)
```gdscript
class_name PropLibrary
extends RefCounted
static var _templates: Array[Dictionary] = [] # lazily built (cannot call PropUtils in a const)
static func get_entries() -> Array[Dictionary] # builds once, returns the 4 templates (see 4.2)
static func get_ids() -> Array[String]
static func get_entry(id: String) -> Dictionary # {} if unknown
static func get_default_id() -> String # "crate"
```
Note: `PropUtils.create_*()` are `static func` — they **cannot** run in a `const` initializer, so
`_templates` is a `static var` built on first access.
### 5.3 `ThumbnailCache` (`res://scripts/thumbnails/thumbnail_cache.gd`)
```gdscript
class_name ThumbnailCache
extends RefCounted
const STICKMEN_DIR := "user://thumbnails/stickmen"
const PROP_DIR := "user://thumbnails/props"
const PROP_VERSION := 1 # bump to invalidate all prop thumbnails
func stickman_key(path: String) -> String
# "<basename>_<FileAccess.get_modified_time(path)>" — mtime change => new key => regen
func stickman_png(key: String) -> String # STICKMEN_DIR + "/" + key + ".png"
func prop_png(id: String) -> String # PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
func load_png(png_path: String) -> Texture2D # FileAccess.file_exists ? Image.load + ImageTexture : null
func save_png(tex: Texture2D, png_path: String) -> Error # tex.get_image().save_png(png_path)
func ensure_dir(dir: String) -> void # DirAccess.make_dir_recursive_absolute
func clean_stale_stickmen(valid_keys: Dictionary) -> void # optional: delete basename_* not in valid set
```
### 5.4 `StickmanThumbnail` (`res://scripts/thumbnails/stickman_thumbnail.gd`)
```gdscript
class_name StickmanThumbnail
extends Node
const SIZE := Vector2i(200, 200)
func render(stk_data: Dictionary) -> Texture2D
# Spawns the real rig, frames it, awaits double frame_post_draw, captures, frees the rig.
# Returns a placeholder ImageTexture on blank/failed capture (headless degrade).
```
### 5.5 `PropThumbnail` (`res://scripts/thumbnails/prop_thumbnail.gd`)
```gdscript
class_name PropThumbnail
extends Node
const SIZE := Vector2i(200, 200)
func render(payload: Dictionary, material_preset: int) -> Texture2D
# Builds a lightweight Polygon2D + Line2D from the payload (no RigidBody2D, so no gravity),
# tinted via PropBlock.tint_for(material_preset) when preset != NONE; frames + captures.
```
### 5.6 `AssetSelector` (`res://scripts/asset_selector.gd`, root of `.tscn`)
```gdscript
class_name AssetSelector
extends PopupPanel
signal item_selected(entry: Dictionary)
signal cancelled()
signal browse_requested()
signal refresh_requested()
const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS # 12
var kind: String = "" # "stickman" | "prop"
func open(kind: String, entries: Array[Dictionary]) -> void
func set_entries(entries: Array[Dictionary]) -> void # used by Refresh
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void # lazy thumbnail handoff (see 8.4)
func close() -> void
```
Internal (private) responsibilities: page state, `GridContainer` cell (re)build, Prev/Next/page
label, empty-state label, hover styling, Esc handling, Browse/Refresh/Close button wiring.
> **Post-implementation revision (2026-09-03):** the two selection params (`selected_path`,
> `selected_id`) were **dropped** — the signature is now `open(kind, entries)`. The pre-highlight
> mechanism (`_selected_path`/`_selected_id` members, `_is_selected()` helper, and the selected
> stylebox cell border) was **removed**: no cell is highlighted on open. The selector also
> re-centers on window resize (the root's `size_changed` re-runs `popup_centered()` while visible).
---
## 6. Thumbnail Generation Recipe (Godot 4.7 runtime)
Both renderers are `Node`s (added to the stage tree so they may `await`). Each owns one persistent
offscreen `SubViewport` built in `_ready()`.
### 6.1 Stickman (real rig)
```gdscript
func _ready() -> void:
_viewport = SubViewport.new()
_viewport.size = SIZE
_viewport.transparent_bg = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
add_child(_viewport)
_world = Node2D.new(); _viewport.add_child(_world)
_camera = Camera2D.new(); _camera.enabled = true
_world.add_child(_camera); _camera.make_current()
func render(stk_data: Dictionary) -> Texture2D:
_clear_world()
var rig: StickmanRig = StickmanFactory.spawn_from_data(stk_data) # synchronous mount (adapter)
_world.add_child(rig) # _ready() runs now: profile/z-order/IK applied
rig.position = Vector2.ZERO
var bbox := StageSpawner.get_world_aabb(rig) # unions mounted Body/* geometry head-to-feet
if not bbox.has_area() or bbox.size.x < 1.0 or bbox.size.y < 1.0:
bbox = Rect2(Vector2(-60, -500), Vector2(120, 500)) # degenerate-figure fallback
_frame_camera(bbox)
await RenderingServer.frame_post_draw # await TWICE (standard offscreen capture recipe)
await RenderingServer.frame_post_draw
var img := _viewport.get_texture().get_image()
rig.queue_free()
return ImageTexture.create_from_image(img)
func _frame_camera(bbox: Rect2) -> void:
var margin := 12.0
var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y)
_camera.position = bbox.get_center()
_camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05))
```
### 6.2 Prop (lightweight visual — no physics)
Mirror `PropBlock._apply_polygon_geometry()` / `_apply_circle_geometry()`
(`scripts/prop_block.gd:169-194`) into a plain `Node2D` with a `Polygon2D` (fill) and a `Line2D`
(outline, closed loop, round joints/caps). **Do not instantiate `PropBlock`** — it is a
`RigidBody2D` and would fall under gravity inside the SubViewport. Apply
`PropBlock.tint_for(material_preset)` for non-`NONE` presets, then the same `_frame_camera` +
double-`frame_post_draw` capture as above.
### 6.3 Correctness notes (bake into implementation)
- SubViewport must be **in the tree** and `render_target_update_mode = UPDATE_ALWAYS` during capture.
- `await RenderingServer.frame_post_draw` **twice** before `get_texture().get_image()`.
- `Camera2D` must be `enabled = true` + `make_current()` **inside** the SubViewport.
- `_clear_world()` frees any prior rig/visual children before each capture.
- **Headless degrade:** in `--headless` the dummy renderer fires `frame_post_draw` but may return a
blank image. The renderer returns a **placeholder** `ImageTexture` (solid color + no-preview) when
the captured image is blank/empty. Pixel rendering is **manual/F6 verification only**; headless
tests must not assert on pixels (see §11).
### 6.4 Cache keying & invalidation
- **Stickmen:** key embeds `FileAccess.get_modified_time(path)` (Unix seconds). A modified `.stk`
yields a new key → missing PNG → regenerate. `clean_stale_stickmen` deletes superseded PNGs for
the same basename.
- **Props:** key = `id_v<PROP_VERSION>`; only 4 templates, cheap; regenerate when the version const
changes or the PNG is missing.
- Thumbnails are generated **lazily, one per frame** (see §8.4) so scanning 50+ files never stalls.
---
## 7. `AssetSelector` — `.tscn`-Based Design
### 7.1 Scene structure (`res://scenes/asset_selector.tscn`)
```
AssetSelector (PopupPanel, root, script = res://scripts/asset_selector.gd)
├── MarginContainer
│ └── VBoxContainer
│ ├── TitleBar (HBoxContainer)
│ │ ├── TitleLabel (Label) # text set at open(): "Choose Your Stickman" / "Choose a Prop"
│ │ └── CloseButton (Button, "×") # wired to cancelled.emit()
│ ├── GridContainer (columns = 4) # dynamic cells built in code, cleared per page
│ ├── EmptyLabel (Label, hidden by default) # tr("No stickmen found! Create one in the editor first.")
│ └── Footer (HBoxContainer)
│ ├── PrevButton (Button, "← Prev")
│ ├── PageLabel (Label, "Page 1/N")
│ ├── NextButton (Button, "Next →")
│ ├── BrowseButton (Button, "Browse…") # visible only when kind == "stickman"
│ └── RefreshButton (Button, "Refresh")
```
The `.tscn` is authored **minimal**: the container/control skeleton + unique-name markers
(`%TitleLabel`, `%GridContainer`, `%EmptyLabel`, `%PageLabel`, `%PrevButton`, `%NextButton`,
`%BrowseButton`, `%RefreshButton`). All per-cell content (TextureRect, name label, material badge)
is created in code at runtime because the entry set is dynamic.
### 7.2 Root type & signals
Root is `PopupPanel`. It is opened with `popup_centered()` (modal). Signals per §5.6:
- `item_selected(entry)` — user clicked a cell.
- `cancelled()` — Close button or Esc.
- `browse_requested()` — Browse button (stickman only).
- `refresh_requested()` — Refresh button.
Esc handling: `PopupPanel` sets `exclusive = true`; `AssetSelector` overrides
`_unhandled_input` for `KEY_ESCAPE``cancelled.emit()` (Window's built-in Esc close is not
relied on).
> **Post-implementation revisions (2026-09-03):**
> - **Resize re-center:** the root's `size_changed` signal re-runs `popup_centered()` while the
> popup is visible, so the grid re-centers on window resize.
> - **Dim backdrop:** the stage shows a black `ColorRect` (`_selector_dim`,
> `SELECTOR_DIM_ALPHA = 0.5`, `mouse_filter = MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer`
> behind the selector) while the selector is open, to dim the stage behind the modal grid.
> - **Outside-click → cancel:** the stage routes the selector's `popup_hide` signal to
> `_on_selector_cancelled()` (idempotency-guarded), so an outside-click that closes the modal
> popup also un-presses the palette button (mirroring an explicit cancel/Esc).
### 7.3 How `sandbox_stage.gd` instantiates + theme-overrides it
```gdscript
# preload at top:
const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
# in _ready(), after _build_ui():
_selector = ASSET_SELECTOR.instantiate() as AssetSelector
_ui.add_child(_selector) # _ui = the UI CanvasLayer (sandbox_stage.gd:1111)
_apply_ui_font(_selector) # existing helper (sandbox_stage.gd:1091)
# Apply _ui_font/_emoji_font to authored labels/buttons by walking _selector children
# (or add a small AssetSelector.apply_font(font, emoji_font) -> void called here).
_selector.item_selected.connect(_on_asset_selected)
_selector.cancelled.connect(_on_selector_cancelled)
_selector.browse_requested.connect(_on_browse_requested)
_selector.refresh_requested.connect(_on_refresh_requested)
```
If a dedicated `AssetSelector.apply_font(ui_font: Font, emoji_font: Font) -> void` helper is
preferred (walking the authored controls and adding font overrides), the stage calls it right
after `add_child` using `_ui_font`/`_emoji_font` (loaded in `_load_theme`, `scripts/sandbox_stage.gd:2164-2212`).
---
## 8. `StageSpawner` Precise Edits (`scripts/stage_spawner.gd`)
### 8.1 New state (replace the single `_stickman_data`)
```gdscript
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH # line 25 const
var selected_prop_id: String = "crate" # == PropLibrary.get_default_id()
var _stickman_cache: Dictionary = {} # path -> parsed data
```
### 8.2 `_init` (lines 45-50)
Seed the cache instead of the single field:
```gdscript
_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if _stickman_cache[DEFAULT_STICKMAN_PATH].is_empty():
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
_build_registry()
```
### 8.3 `_build_registry` (lines 178-220)
- **Remove** the `crate` entry (lines 202-206) and the `ball` entry (lines 207-211).
- **Keep** `stickman` (lines 212-215) as-is (spawn now reads `selected_stickman_path`).
- **Add** a `prop` entry:
```gdscript
{
"id": "prop", "label": "Prop", "kind": "prop",
"spawn_offset": Vector2.ZERO,
},
```
Resulting spawn ids (and therefore palette buttons, via `get_spawnable_ids()` line 56):
`ground, ramp, step, prop, stickman, area`.
### 8.4 `_spawn_stickman` (lines 268-278)
```gdscript
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {})
if data.is_empty():
data = STICKMAN_FACTORY.load_stk(selected_stickman_path)
_stickman_cache[selected_stickman_path] = data
if data.is_empty():
push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
rig.position = world_position
_world.add_child(rig)
return rig
```
### 8.5 `_spawn_prop` (lines 254-257)
```gdscript
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var t: Dictionary = PropLibrary.get_entry(selected_prop_id)
if t.is_empty():
push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id)
return null
return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO)
```
### 8.6 New getters (for status/tests)
```gdscript
func get_selected_stickman_path() -> String
func get_selected_prop_id() -> String
```
---
## 9. `SandboxStage` Precise Edits (`scripts/sandbox_stage.gd`)
### 9.1 New state
```gdscript
var _stickman_library: StickmanLibrary
var _prop_library: PropLibrary
var _thumbnail_cache: ThumbnailCache
var _stickman_thumb: StickmanThumbnail
var _prop_thumb: PropThumbnail
var _selector: AssetSelector = null
var _selector_open: bool = false
var _selector_kind: String = "" # "stickman" | "prop"
var _browse_dialog: FileDialog = null
var _thumbnail_queue: Array[Dictionary] = [] # {entry, kind} pending lazy render (see 9.6)
```
### 9.2 `_ready()` (lines 244-270)
After `_spawner = STAGE_SPAWNER.new(_world)` and around `_build_ui()`:
```gdscript
_stickman_library = StickmanLibrary.new()
_prop_library = PropLibrary.new() # (get_entries() is static; instance kept only if needed)
_thumbnail_cache = ThumbnailCache.new()
_stickman_thumb = StickmanThumbnail.new()
_prop_thumb = PropThumbnail.new()
add_child(_stickman_thumb) # Node renderers must be in-tree to await
add_child(_prop_thumb)
```
Build the selector in `_build_ui()` (see §7.3) or immediately after it; build `_browse_dialog`
mirroring `scripts/test_harness.gd:286-292`:
```gdscript
_browse_dialog = FileDialog.new()
_browse_dialog.title = "Open .stk"
_browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
_browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.file_selected.connect(_on_browse_file_selected)
_ui.add_child(_browse_dialog)
```
### 9.3 Palette toggle branch — `_on_palette_toggled` (lines 1404-1408)
```gdscript
func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed:
if id == "stickman" or id == "prop":
_open_selector(id)
return
set_placement_mode(id) # terrain + area unchanged
else:
if _selector_open and _selector_kind == id:
_close_selector()
elif _placement_id == id:
set_placement_mode("")
```
### 9.4 Selector open/close flow
```gdscript
func _open_selector(kind: String) -> void:
var entries: Array[Dictionary]
if kind == "stickman":
entries = _stickman_library.scan()
else:
entries = _prop_library.get_entries()
# Single-item skip (stickman only, per plan):
if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0]) # selects + enters placement, no grid
return
_selector_kind = kind
_selector_open = true
_selector.open(kind, entries)
# Keep the palette button visually pressed ("tool active"), but no ghost yet.
func _on_asset_selected(entry: Dictionary) -> void:
if _selector_kind == "stickman":
_spawner.selected_stickman_path = String(entry["path"])
else:
_spawner.selected_prop_id = String(entry["id"])
_close_selector()
set_placement_mode(_selector_kind) # spawns ghost; palette button reflects placement
func _close_selector() -> void:
if _selector != null:
_selector.hide()
_selector_open = false
_selector_kind = ""
func _on_selector_cancelled() -> void:
_close_selector()
set_placement_mode("") # un-press the palette button
```
`set_placement_mode` (`scripts/sandbox_stage.gd:566-576`) already loops `_palette_buttons` to sync
pressed state, so it correctly presses the `stickman`/`prop` button on selection and un-presses on
cancel.
> **Post-implementation revisions (2026-09-03):** `_open_selector` also shows a **dim backdrop**
> (`_selector_dim`, black `ColorRect` at `SELECTOR_DIM_ALPHA = 0.5`, `MOUSE_FILTER_IGNORE`, on the
> UI `CanvasLayer` behind the selector) while the selector is open; `_close_selector()` flips
> `_selector_open` off **before** hiding the popup. In addition to the explicit cancel/Esc path, the
> selector's `popup_hide` signal is connected to `_on_selector_cancelled()` (idempotency-guarded),
> so an outside-click that closes the modal popup likewise un-presses the palette button.
### 9.5 Browse / Refresh handlers
```gdscript
func _on_browse_requested() -> void:
_browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void:
var entry := _stickman_library.make_entry(path)
if entry.is_empty():
_show_toast("Could not load stickman: " + path) # existing toast helper (line 2090)
return
_on_asset_selected(entry) # selects ad-hoc entry + enters placement
func _on_refresh_requested() -> void:
_selector.set_entries(_stickman_library.scan())
```
### 9.6 Lazy thumbnail drain (in `_process`, lines 273-289)
Thumbnail rendering is deferred out of the click path. When the selector opens, the stage enqueues
entries lacking a cached PNG; `_process` renders **one per frame** and hands the texture back via
`_selector.set_thumbnail(entry, tex)` (placeholder shown until then). This satisfies the
"non-blocking / loading state" acceptance criterion. (Alternatively, `AssetSelector` can own the
queue; the stage must own the renderers, so the stage-owned queue + `set_thumbnail` handoff is
recommended.)
### 9.7 Esc priority (in `_unhandled_key_input`, lines 322-344)
Insert before the `_placement_id != ""` branch:
```gdscript
elif _selector_open:
_on_selector_cancelled()
```
Final Esc order: rule step → pending walk target → terrain drag → selector → DIRECT → placement →
selection.
### 9.8 Guards
While `_selector_open`, `_handle_world_click` (`scripts/sandbox_stage.gd:360-409`) and
`_handle_mouse_motion` (lines 412-426) early-return at the top (the modal `PopupPanel` already
blocks most input, but the stage uses `_input` for mouse — the explicit flag is belt-and-suspenders).
---
## 10. Edge Cases
| Edge case | Handling |
|---|---|
| No `.stk` files | Grid opens with empty-state label (do **not** silently skip — user needs the hint) |
| Exactly one `.stk` | Skip grid, select it, enter placement directly (stickman only) |
| Corrupt `.stk` (`load_stk``{}`, or missing `body_parts`) | Skip + `push_warning`; not counted as an entry |
| Empty `stickman_name` | Display filename basename (strip `.stk`) |
| 50+ files | Pagination 12/page; thumbnails lazy (1/frame); scan parses once + caches `data` |
| Modified `.stk` | New cache key (mtime-embedded) → regenerate thumbnail |
| Thumbnail render fails (headless / blank) | Placeholder texture; retried next open |
| Selected file deleted | On open, if `selected_stickman_path` not in scan → fall back to first entry (or empty state) |
| Browse-selected file (outside `stickmen/`) | Spawnable by path via `make_entry`; not rescanned; re-validated next open |
| Duplicate display names | Allowed; selection is by path/id, not name |
| Grid resize | `GridContainer` reflows; page bounds recomputed on `resized` |
| Window resize | Selector re-centers (`popup_centered()` re-run on the root's `size_changed` while visible); stage behind the grid is dimmed by `_selector_dim` while open |
| Outside-click closes the modal selector | `popup_hide``_on_selector_cancelled()` (idempotency-guarded) un-presses the palette button, same as Esc/cancel |
| `stickman`/`prop` button toggled off while selector open | `_on_palette_toggled` unpressed branch → `_close_selector()` |
| Prop pagination | Only 4 templates → always 1 page; Prev/Next hidden/disabled |
---
## 11. Acceptance Criteria
### 11.1 Stickman selector
- [ ] "Stickman" opens the grid (does not immediately spawn `test.stk`).
- [ ] Grid lists all `.stk` in `res://stickmen/` (test/basic/break).
- [ ] Each cell shows a rig-rendered thumbnail.
- [ ] Name = `stickman_name`, else filename (`test`/`break` fall back; `Basic` shows "Basic").
- [ ] Click → select + close + enter placement (ghost appears).
- [ ] Selection persists for subsequent placements across EDIT/DIRECT/PLAY toggles.
- [ ] Pagination at 12/page.
- [ ] Browse opens `FileDialog` (`*.stk`) and selects an arbitrary file.
- [ ] Refresh rescans.
- [ ] Empty state label when no files.
- [ ] Single-item skip when exactly one file.
- [ ] Thumbnails cached to `user://thumbnails/stickmen/`.
- [ ] Modified `.stk` regenerates its thumbnail.
### 11.2 Prop selector
- [ ] "Prop" opens the grid.
- [ ] Shows Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard.
- [ ] Each cell: thumbnail + name + material badge.
- [ ] Click → select + close + enter placement.
- [ ] Selection persists for subsequent placements.
- [ ] Single page.
- [ ] Thumbnails cached to `user://thumbnails/props/`.
### 11.3 Palette integration
- [ ] `crate`/`ball` palette buttons removed.
- [ ] Single "Prop" button replaces them.
- [ ] "Stickman" opens the grid.
- [ ] `Ground`/`Ramp`/`Step`/`Area` unchanged and still place directly.
- [ ] Selecting an asset spawns the **selected** asset (verify ghost + placed object change when switching selection).
### 11.4 Performance
- [ ] 50-file scan non-blocking.
- [ ] Thumbnail generation deferred (loading state / placeholder).
- [ ] Grid pagination smooth.
---
## 12. Verification Plan
### 12.1 Runner command (from `tests/test_phase4b_stage.gd:11-14`)
```
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_library.gd --path .
```
### 12.2 New headless test — `tests/test_phase3b_library.gd` (`extends SceneTree`)
Cover (no pixel assertions — headless renders blank):
1. `StickmanLibrary.scan()` returns 3 entries (test/basic/break); names `["test","Basic","break"]`
(empty `stickman_name` → filename fallback).
2. Corrupt file skipped: write a temp `.stk` with invalid JSON into a **copied** scan dir override
(make `STICKMEN_DIR` overridable for tests, or test `make_entry()` on a bad path returns `{}`).
3. `make_entry()` on a valid path returns name+data; on missing/corrupt path returns `{}`.
4. `PropLibrary.get_entries()` → 4 templates with expected `id`/`material_preset`/
`material_label`; `get_default_id() == "crate"`.
5. `StageSpawner` registry: `get_spawnable_ids()` == `["ground","ramp","step","prop","stickman","area"]`
(no `crate`/`ball`).
6. `_spawn_prop` honors `selected_prop_id`: default spawns a `PropBlock` (crate); set
`selected_prop_id = "ball"` → spawns a circle prop (`shape_type == ShapeType.CIRCLE`).
7. `_spawn_stickman` honors `selected_stickman_path`: set it to `res://stickmen/basic.stk` → spawns
a `StickmanRig` whose mounted geometry differs from `test.stk` (or at minimum spawns non-null).
8. `ThumbnailCache`: `stickman_key(path)` changes when `FileAccess.get_modified_time` changes
(mock by passing a path + expected mtime); `stickman_png`/`prop_png` path formatting;
`PROP_VERSION` bump changes `prop_png`.
9. Selector pagination math: expose a pure `static page_bounds(total, page, PAGE_SIZE) -> Dictionary`
(or test `PAGE_SIZE == 12` + a page-slicing helper) without instantiating UI.
### 12.3 Existing tests to update
- **`tests/test_phase4b1_fixes.gd:259`** — calls `stage._spawner.spawn("crate", ...)`. Update to
`stage._spawner.spawn("prop", ...)` (default `selected_prop_id` is `"crate"`, so behavior is
unchanged) **or** explicitly set `stage._spawner.selected_prop_id = "crate"` first. This is the
only existing test that references a removed registry id.
- **`tests/test_phase4b_stage.gd`** — references only `_palette_buttons["ground"]` (lines 78, 87,
99, 108, 217, 238) and `spawn("ground")`/`spawn("stickman")`; **no `crate`/`ball` references, no
palette-count assertion** → no changes required, but re-run to confirm.
- `tests/test_phase4b_grid_dirty.gd`, `test_phase4b_terrain.gd`, `test_phase4b_logic.gd`,
`test_phase4b2_fixes.gd`, `test_phase4b_walk.gd` build props directly (`PropBlock.new()`) or use
`ground`/`stickman` only → **no changes required**.
### 12.4 Manual (F6)
`res://scenes/sandbox_stage.tscn`: visual grid, thumbnails, pagination, Browse, Refresh,
ghost/placement, mode-toggle persistence.
---
## 13. Implementation Order (updated for `.tscn` decision)
| Step | Task | Dependencies |
|---|---|---|
| 1 | `PropLibrary` (registry, static) | none |
| 2 | `ThumbnailCache` (keys, load/save, dirs) | none |
| 3 | `StickmanLibrary` (scan + entry model) | none |
| 4 | `StickmanThumbnail` (rig renderer) | 2, 3 |
| 5 | `PropThumbnail` (lightweight renderer) | 1, 2 |
| 6 | `AssetSelector` + `asset_selector.tscn` (shell + grid controller) | 15 |
| 7 | `StageSpawner` edits (registry, `selected_*`, per-path cache, getters) | 1 |
| 8 | `SandboxStage` edits (instantiate + theme, palette branch, open/close, Esc, guards, Browse/Refresh, lazy drain) | 6, 7 |
| 9 | Update `tests/test_phase4b1_fixes.gd:259` | 7 |
| 10 | Write `tests/test_phase3b_library.gd` | 1, 2, 3, 7, 8 |
| 11 | Manual F6 pass (thumbnails, pagination, persistence) | 8 |
+10 -1
View File
@@ -23,7 +23,11 @@ This document tracks known technical debt, optimization opportunities, and minor
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDITmode freezing is typespecific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`based sensors) will need another case in `scripts/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a ducktyped `set_simulating(bool)` interface once more physical object kinds appear. (20260827) |
| 12 | **Stage AABB Selection Precision**`StageSelection.get_world_aabb` uses conservative worldspace AABBs (polygon point union / fixed rig rect), not pointinpolygon. | Low | Open | Clicks in the boundingbox corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can misselect. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (20260827) |
| 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once props/other stickmen are registered as obstacles. (20260829) |
| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame**`sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. Future: add a spatial hash / broadphase grid keyed by world cell to cull candidate pairs before the AABB/feet-point tests. (20260830) |
| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame**`sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it**#14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (20260830 / note updated 20260902) |
| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)**`StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `plans/PHASE_4b_SPEC.md` §3. (2026-09-02) |
| 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine**#14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) |
| 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v<PROP_VERSION>`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) |
| 18 | **Asset selection is session-only, not saved**`StageSpawner.selected_stickman_path` / `selected_prop_id` reset on scene reload (Phase 3b decision, per spec). | Low | Open | Deliberate for Phase 3b (spec §2 decision 1: no disk save, do not extend `_save_settings`). A future persistence phase could persist the last-chosen stickman/prop to `user://sandbox_settings.json` for convenience. (2026-09-03) |
---
@@ -58,6 +62,11 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-08-29 | Phase 3a (Core Director Functionality) spec written (`docs/phase_3a_spec.md`). Logged #13 (slope-aware walking physics + dynamic obstacle avoidance deferred — the nav mesh itself is built in 3a). Notable non-debt decisions recorded in the spec: Play mode now runs the director script instead of auto-ragdolling stickmen; `walk_to(target)` treats `target` as a feet/ground destination via `FOOT_OFFSET`, with the `NavigationAgent2D` child placed at the feet `(0,+385)` so it paths on the ground-level nav mesh. |
| 2026-08-29 | **`walk_to` stops-after-a-few-px bug fixed.** `_update_walking` (Phase 3a) now defers nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0` (map-sync guard) and forces the path query via `get_next_path_position()` before any empty-path/finished check. **Follow-up (same day):** the initial "warn + finish in place" unreachable-target policy was itself reported as "stickman stands still with a waypoint" and was replaced by **hybrid nav/direct steering** — an on-mesh target follows the nav path (`_walk_mode = "nav"`), an off-mesh/unreachable target walks **straight to the clicked waypoint** (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`), with **no** `push_warning`; `_walk_path_grace` removed (map-sync guard + forced path query replace it); the debug trace now carries `mode=nav|direct`. Off-by-default `DEBUG_WALK` (`stickman_rig.gd`) / `DEBUG_STAGE` (`sandbox_stage.gd`) traces added. #13 remains **Open** (slope physics + RVO avoidance are still deferred; the fix only changes unreachable-target handling). Logged in `BUGS.md`; verified with a 44-assertion headless regression suite. |
| 2026-08-30 | Phase 4 (Triggers & Event System) implemented: `scripts/trigger_area.gd` (NEW placeable sensor), `sandbox_stage.gd` rule system (`_event_rules`, geometric event engine `_update_area_entry` / `_update_stickman_prop_collision`, rule-builder UI state machine, `_cleanup_rules_for_nodes`), `stickman_rig.gd` (`arrived` gains a `target` payload; new `enqueue_reactive`), `prop_block.gd` (`collided` physics signal), `stage_director_visuals.gd` rule visualization, `stage_spawner.gd` / `stage_selection.gd` `"area"` palette + duck-typed `get_area_rect`. Logged #14 (all-pairs event engine scales O(rigs×props + areas×movables); spatial hash suggested). |
| 2026-09-02 | Phase 4b (Polish) spec written (`plans/PHASE_4b_SPEC.md`). Logged #15 (walk-arrival jitter: `_walk_mode` re-evaluated per frame + arrival-radius mismatch) and #16 (no grid spatial dictionary; Phase 4b adds one for terrain drag-painting + event-engine broadphase). |
| 2026-09-02 | Phase 4b (Polish) implemented. Resolved **#15** (walk-mode latch once per walk, unified `ARRIVE_DISTANCE` arrival with snap-on-arrive, steer-to-final-when-close, one-frame marker re-assert) and **#16** (grid spatial dictionary `_grid_cells` @ `TERRAIN_GRID_SIZE` for the drag-painting 3-state query + director target-validity). #14 remains **Open** — the dictionary is not yet wired into the Phase 4 event engine (note updated). Added `res://sandbox_theme.json`, `scripts/stage_placement_overlay.gd`, the 3-segment mode switcher / status bar / badge / frame / cursors, and director tooltip/trajectory/reticle UX. |
| 2026-09-03 | Phase 3b (Asset Library) implemented (`docs/phase_3b_asset_grid_spec.md`): `scripts/stickman_library.gd` / `prop_library.gd`, `scripts/thumbnails/thumbnail_cache.gd` / `stickman_thumbnail.gd` / `prop_thumbnail.gd`, `scripts/asset_selector.gd` + `scenes/asset_selector.tscn`. The Stickman/Prop palette buttons now open visual selector grids (pagination 12/page, session-only selection, Browse/Refresh); `StageSpawner` registry becomes `ground/ramp/step/prop/stickman/area` (`crate`/`ball` removed) with `selected_*` session state + per-path stickman cache; `SandboxStage` owns the selector open/close flow, Esc priority, and the one-per-frame lazy thumbnail drain. Logged #17 (thumbnail cache growth / headless placeholder) and #18 (session-only selection). Verified with the new headless suite `tests/test_phase3b_library.gd` plus the updated `tests/test_phase4b1_fixes.gd`. |
| 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed``popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. |
| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. |
---