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:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user