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
+145 -6
View File
@@ -616,7 +616,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`_enter_play_mode()` (the mode toggle and status label stay visible).
- **Phase 3a director tool:** adds a **"Direct"** palette toggle button (mutually exclusive with
placement) and a `PopupMenu` (`_action_popup`, items `Walk To`/`Speak`/`Wait`/`Ragdoll`/`Recover`,
ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); speak/wait
ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); the popup is
positioned at the clicked stickman's world position converted to screen (`_world_to_screen(world_pos)`)
offset 24 px right, not at the mouse cursor (this first menu also records the session popup anchor
for the Phase 4 rule-builder child menus); speak/wait
`AcceptDialog`s append `speak`/`wait` actions; `Walk To` enters a pending target-capture mode whose
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
priority: pending target → exit direct mode → existing placement clears). Builds a code-built
@@ -645,6 +648,47 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
→ "Add another action / Done" popup. **Esc** has highest priority; status-bar hints + toast
messages guide the flow. Rule **label click** → consequence-only edit (replaces the rule, same
`id`); **✕** deletes; `_cleanup_rules_for_nodes` auto-removes rules referencing deleted objects.
All rule-builder context popups are **session-anchored**: the first popup in a session records
its screen position (`_popup_anchor` / `_popup_anchor_set`; the Direct first menu = right of the
clicked stickman, the rule-label edit entry = the click position), and every child popup in the
session — "⚡ When…" trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another
action" — reuses that recorded position via `_set_popup_anchor(rect)` / `_popup_anchor_rect()`,
so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on
confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit
(`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path
intentionally reuses it).
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
`_build_ui()` with theme/font overrides applied via `AssetSelector.apply_font`), backed by a
dim backdrop `_selector_dim` (a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, `mouse_filter =
MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector
is open). State
`_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds
`_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s
(`_stickman_thumb`/`_prop_thumb`, added as children so they can `await`), plus a Browse
`_browse_dialog` `FileDialog` (`*.stk`). `_on_palette_toggled` routes `stickman`/`prop` presses
to `_open_selector(id)` (which forces the palette button pressed, rescans entries, performs the
**single-item skip** for a lone stickman file, and — when the selected path is absent from the
scan — reselects the first entry); its un-press branch closes a matching open selector.
`_on_asset_selected(entry)` writes the selection to `StageSpawner` (`selected_stickman_path` /
`selected_prop_id`), closes the selector, and calls `set_placement_mode(kind)`;
`_close_selector()` also clears `_thumbnail_queue` (it flips `_selector_open` off before hiding
the popup). `_on_selector_cancelled()` closes + exits
placement (un-presses the palette button); the selector's `popup_hide` signal is also routed to
`_on_selector_cancelled()` (idempotency-guarded), so an outside-click close likewise un-presses
the palette button. `_on_browse_file_selected` builds an ad-hoc entry via
`StickmanLibrary.make_entry(path)` (toast on failure); `_on_refresh_requested` rescans +
re-enqueues. **Lazy thumbnail drain:** `_load_or_enqueue_thumbnails` seeds the selector from
any cached PNG and enqueues the rest; `_process` → `_drain_thumbnail_queue()` renders **one per
frame** (awaits the renderer, `save_png`s it, hands the texture back via
`_selector.set_thumbnail` while the selector is open). The selector re-centers on window resize
(the root `AssetSelector` re-runs `popup_centered()` on `size_changed` while visible). **Esc
priority** inserts the selector
before DIRECT/placement in `_unhandled_key_input`; `_handle_world_click` / `_handle_mouse_motion`
early-return while `_selector_open` (belt-and-suspenders over the modal popup). Selection is
**session-only** — persists across EDIT/DIRECT/PLAY toggles, resets on scene reload, **no disk
save** (`_save_settings` is untouched).
- `scripts/stickman_speech_bubble.gd` — `class_name SpeechBubble`, `extends Node2D`; a **world-space
speech bubble** drawn in `_draw()` (Phase 3a, **not used by the editor**). Child of a `StickmanRig`
at `SPEECH_BUBBLE_OFFSET` (above the head), so it follows the figure and scales with the camera.
@@ -677,13 +721,98 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`PropUtils.spawn_prop`, `StickmanFactory.spawn_from_data` via `preload` consts. Terrain entries
store origin-relative point templates (ground/ramp/step); `_spawn_terrain` centers the template
bbox on its local origin then sets `block.position` to the cursor, so `global_rotation` =
"rotate about center". The Stickman entry caches `res://stickmen/test.stk` once (`load_stk` in
`_init`) and applies `STICKMAN_FOOT_OFFSET (0, -385)` so feet land at the cursor. Also exposes
a `static get_world_aabb(node)` helper (for a stickman it unions the mounted `Body/*` shape
geometry via a recursive `_collect_visual_points()` so the box is centered head-to-feet).
"rotate about center". Also exposes a `static get_world_aabb(node)` helper (for a stickman it
unions the mounted `Body/*` shape geometry via a recursive `_collect_visual_points()` so the box
is centered head-to-feet).
- **Phase 4 area palette entry:** a new `"area"` registry entry (label "Area") → `_spawn_area()`
instantiates a `TriggerArea`; `get_world_aabb()` gains a **duck-typed** `get_area_rect` AABB
branch (if the node responds to `get_area_rect()`, use its `Rect2` as the world bounds).
- **Phase 3b asset library (selected-asset spawner):** the registry entries are now
`ground/ramp/step` (terrain) + `prop` + `stickman` + `area` — the separate `crate`/`ball`
entries were **removed** and a single `"prop"` entry (label "Prop", kind `"prop"`) added, so
`get_spawnable_ids()` == `["ground","ramp","step","prop","stickman","area"]`. Session state:
`selected_stickman_path` (default `DEFAULT_STICKMAN_PATH` = `res://stickmen/test.stk`) and
`selected_prop_id` (default `"crate"`), read/written by the sandbox selector (Phase 3b) — they
are **session-only** (no disk save). `_stickman_cache: Dictionary` (path → parsed data) seeds
the default path in `_init` and lazily loads+parses any newly selected path on first spawn.
`_spawn_stickman` spawns from the **selected** path (applies `STICKMAN_FOOT_OFFSET (0, -385)`
so feet land at the cursor); `_spawn_prop` looks the **selected** prop id up via
`PropLibrary.get_entry(id)` and spawns its payload (`PropUtils.spawn_prop`) + material preset.
New getters `get_selected_stickman_path()` / `get_selected_prop_id()` (status + tests).
- `scripts/stickman_library.gd` — `class_name StickmanLibrary`, `extends RefCounted` (Phase 3b,
**not used by the editor**). Scans `res://stickmen/*.stk` into entry models for the asset
selector. `const STICKMEN_DIR := "res://stickmen"`; `var entries: Array[Dictionary]`. Public API:
`scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]` (via `DirAccess` + per-file
`StickmanFactory.load_stk`; a corrupt or missing-`body_parts` file is **skipped** with a
`push_warning` — never `{}`-as-entry; the display `name` = `stickman_name` (stripped) if
non-empty else the filename basename; results sorted by name, then path, cached on `self` and
returned), `get_entries() -> Array[Dictionary]` (returns the last scan, does **not** rescan),
`find_by_path(path) -> Dictionary` (`{}` if absent), and `make_entry(path) -> Dictionary`
(ad-hoc single-file entry for **Browse**: `load_stk` + name fallback; `{}` on load failure or
missing `body_parts`). Entry shape `{ "path", "name", "data" }`; thumbnails are **not** embedded
in entries — resolved via `ThumbnailCache` at display time by the selector.
- `scripts/prop_library.gd` — `class_name PropLibrary`, `extends RefCounted` (Phase 3b, **not used
by the editor**). A **static** registry of the 4 built-in prop templates for the asset selector.
`static var _templates: Array[Dictionary]` (lazily built on first access — `PropUtils.create_*()`
are `static func` and cannot run in a `const`). Public API: `static get_entries()` (builds once,
returns the 4 templates), `static get_ids()`, `static get_entry(id) -> Dictionary` (`{}` if
unknown), `static get_default_id() -> String` (`"crate"`). Template dicts carry `{id, name,
material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber
(`create_ball()` + `RUBBER`), Plank/Metal (`create_plank()` + `METAL`), Triangle/Cardboard
(`create_triangle()` + `CARDBOARD`) — generator color themes already match the presets.
- `scripts/thumbnails/thumbnail_cache.gd` — `class_name ThumbnailCache`, `extends RefCounted`
(Phase 3b, **not used by the editor**). Disk PNG cache under `user://thumbnails/`. Consts
`STICKMEN_DIR := "user://thumbnails/stickmen"`, `PROP_DIR := "user://thumbnails/props"`,
`PROP_VERSION := 1`. Public API: `stickman_key(path)` → `"<basename>_<FileAccess.get_modified_time(path)>"`
(an mtime change yields a new key → missing PNG → regenerate); `stickman_png(key)` /
`prop_png(id)` (`"<id>_v<PROP_VERSION>.png"` — bump `PROP_VERSION` to invalidate all prop
thumbnails); `load_png(png_path) -> Texture2D` (`Image.load_from_file` + `ImageTexture`, `null`
if missing/unloadable); `save_png(tex, png_path) -> Error` (creates the parent dir); `ensure_dir`;
`clean_stale_stickmen(valid_keys)` (deletes `basename_*` PNGs not in the valid key set).
- `scripts/thumbnails/stickman_thumbnail.gd` — `class_name StickmanThumbnail`, `extends Node`
(Phase 3b, **not used by the editor**). Renders a parsed `.stk` into a `Texture2D` for the asset
selector by spawning the **real rig**. `const SIZE := Vector2i(200, 200)`. `_ready()` builds one
persistent offscreen `SubViewport` (`transparent_bg`, `render_target_update_mode = UPDATE_ALWAYS`)
with an enabled in-viewport `Camera2D` (`make_current()`). `render(stk_data) -> Texture2D`:
frees prior children, `StickmanFactory.spawn_from_data(stk_data)`s the rig into the viewport at
`Vector2.ZERO`, frames it via `StageSpawner.get_world_aabb(rig)` (a degenerate/no-area bbox falls
back to a fixed `(120×500)` rect), `_frame_camera` fits it with a 12 px margin, `await`s
`RenderingServer.frame_post_draw` **twice** (standard offscreen capture recipe), grabs the viewport
texture, frees the rig, and returns an `ImageTexture` — or `null` (a placeholder) when the capture
is blank/empty (the **headless degrade**; pixel rendering is manual/F6 verification only). Output is
identical to the rig actually placed on stage, not a re-implementation of the editor preview.
- `scripts/thumbnails/prop_thumbnail.gd` — `class_name PropThumbnail`, `extends Node` (Phase 3b,
**not used by the editor**). Renders a prop template into a `Texture2D`. `const SIZE :=
Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as
`StickmanThumbnail`. `render(payload, material_preset) -> Texture2D` mirrors `PropBlock`'s
geometry into a plain `Node2D` with a `Polygon2D` (fill) + `Line2D` (closed loop, round
joints/caps) — **not** a `RigidBody2D`, so nothing falls under gravity inside the viewport;
circle payloads are re-expanded via `PropBlock.CIRCLE_SEGMENTS`; non-`NONE` presets tint fill via
`PropBlock.tint_for` and darken the outline; then frames + double-`frame_post_draw` captures and
returns an `ImageTexture` or `null` (blank → placeholder).
- `scripts/asset_selector.gd` — `class_name AssetSelector`, `extends PopupPanel` (Phase 3b, **not
used by the editor**); root script of `scenes/asset_selector.tscn`. A **grid popup controller**.
Signals `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`.
`const COLUMNS := 4`, `ROWS := 3`, `PAGE_SIZE := 12`, `CELL_MIN_SIZE`, `THUMB_SIZE`. `var kind`
(`"stickman"` | `"prop"`). Public API: `open(kind, entries)` (sets
the title "Choose Your Stickman"/"Choose a Prop", shows **Browse…**/**Refresh** only for
stickmen, then `popup_centered()`), `set_entries(entries)` (used by Refresh — resets page +
thumbnails), `set_thumbnail(entry, tex)` (lazy handoff from the stage's drain), `close()`, and
`apply_font(ui_font, emoji_font)` (walks the authored controls). `_ready()` sets `exclusive =
true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()`
(the Window's built-in Esc close is not relied on). Re-centers on window resize: the root's
`size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build:
`_rebuild()` frees the `%GridContainer` children, toggles the empty-state `%EmptyLabel`,
shows/hides Prev/Next/page label by page count, and slices the current page via the
**static pure** `page_bounds(total, page, page_size) -> Dictionary {start, end, total,
page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect`
(cached/placeholder texture) + a name `Label` + (prop only) a material-badge `Label`; cells emit
`item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling
was removed). The `.tscn` is a **minimal shell**
(title bar, empty grid, footer as authored unique-name nodes `%TitleLabel`/`%GridContainer`/
`%EmptyLabel`/`%PageLabel`/`%PrevButton`/`%NextButton`/`%BrowseButton`/`%RefreshButton`/
`%CloseButton`); all dynamic per-cell content is built in code at runtime because the entry set is
dynamic.
- `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/
box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb`
unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively
@@ -745,7 +874,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
root `Node2D` + script, `Camera2D` at position `(0, -400)` zoom `(0.5, 0.5)`, and an empty
`World` (Node2D) container; the `GridLayer` (`StageGrid`), `GizmoLayer` (`StageGizmos`),
`PlacementGhost` holder, and the CanvasLayer top-bar UI (mode toggle + six palette buttons +
Grid/Snap/Size controls + status label) are built in code at `_ready()`.
Grid/Snap/Size controls + status label) are built in code at `_ready()`. Phase 3b instantiates
the `AssetSelector` grid popup (see below) into the UI `CanvasLayer`.
- `scenes/asset_selector.tscn` — **standalone asset-selector grid popup** (Phase 3b, not wired
into the editor). Rooted at `AssetSelector` (`PopupPanel`, `scripts/asset_selector.gd`); a
**minimal shell/layout skeleton** — authored title bar, empty `GridContainer`, empty-state
`Label`, and a footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`), all as
unique-name nodes (`%TitleLabel`, `%GridContainer`, `%EmptyLabel`, `%PageLabel`, `%PrevButton`,
`%NextButton`, `%BrowseButton`, `%RefreshButton`, `%CloseButton`). All dynamic per-cell content
(thumbnail + name + prop material badge) is built in code at runtime because the entry set is
dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop**
palette-button selector grids.
### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,