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
+5 -2
View File
@@ -3,7 +3,7 @@ name: Developer
description: Implements core application features across Godot.
mode: subagent
model: "deepseek/deepseek-v4-pro"
maxSteps: 50
steps: 60
permission:
edit: allow
bash: allow
@@ -58,7 +58,10 @@ You are an expert Godot 4 game developer and code reviewer. Your purpose is to a
- Enforce static typing wherever possible: `var health: int = 100` or `func take_damage(amount: float) -> void:`.
- Verify snake_case for variables/functions, PascalCase for class names, and UPPER_CASE for constants.
- Check for proper use of `@export` annotations for inspector variables.
- Verify syntax using '..\Godot_v4.7.1-stable_win64_console.exe" . --check-only'
- Verify syntax using the project's Godot 4.7 console binary:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --quit`
(project-wide parse/import check). For a single script:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"`
## 5. Response Output Format
+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`,
+185 -26
View File
@@ -393,33 +393,45 @@ Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll
### 18. Sandbox Stage Builder
The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then flip between **Edit Mode** (build) and **Play Mode** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then switch between **Edit** (build), **Direct** (director/rule authoring), and **Play** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load).
The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load). Styling defaults (fonts/sizes/colors/grid) live in a hand-editable `res://sandbox_theme.json` (Phase 4b, §21).
| File | Purpose |
|---|---|
| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. |
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (mode state machine, placement, camera, deletion, status bar, signals). |
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`. |
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (3-mode state machine, placement + drag-painting, camera, deletion, bottom status bar, mode badge/frame/cursors, signals); **Phase 3b** instantiates the `AssetSelector` grid popup + the two thumbnail renderers, owns the selector open/close flow and the lazy per-frame thumbnail drain, and wires the Stickman/Prop palette buttons to the selector (§22). |
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`; exposes `is_terrain_id()` / `get_template_aabb()` / `spawn_id` tagging. `get_template_aabb()` returns the **sanitized** template AABB (mirrors `_spawn_terrain()`'s 16-px grid pass), so it doubles as the block-unit paint stride. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` entries removed); holds the session state `selected_stickman_path` / `selected_prop_id` and a per-path `_stickman_cache`; `prop` and `stickman` spawn the **selected** asset. |
| `res://scripts/stickman_library.gd` | **Phase 3b** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entry models (corrupt/missing-`body_parts` files skipped, empty `stickman_name` → filename basename), with `make_entry(path)` for arbitrary Browse-chosen paths (§22). |
| `res://scripts/prop_library.gd` | **Phase 3b** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard) with their `PropUtils.create_*()` payloads + material presets; `get_default_id()` = `"crate"` (§22). |
| `res://scripts/asset_selector.gd` | **Phase 3b** `class_name AssetSelector`, `extends PopupPanel` — grid UI controller (root of `scenes/asset_selector.tscn`): pagination 12/page (4×3), Prev/Next/page label, empty-state label, per-cell thumbnail + name + (prop) material badge, hover styling (no pre-highlight on open), window-resize re-centering, Browse/Refresh/Close wiring, Esc handling; signals `item_selected` / `cancelled` / `browse_requested` / `refresh_requested` (§22). |
| `res://scripts/stage_selection.gd` | `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric AABB hit-testing. |
| `res://scripts/stage_gizmos.gd` | `class_name StageGizmos`, `extends Node2D` — hover highlight, selection outline, rotate ring handle. |
| `res://scripts/stage_grid.gd` | `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells). |
| `res://scripts/stage_placement_overlay.gd` | **Phase 4b** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting guide line + the director action trajectory / ghost marker. |
| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Phase 3b** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` into a 200×200 `Texture2D` by spawning the **real rig** (`StickmanFactory.spawn_from_data`) inside an offscreen `SubViewport`, framing it and capturing after a double `frame_post_draw` (§22). |
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Phase 3b** `class_name PropThumbnail`, `extends Node` — renders a prop template into a 200×200 `Texture2D` via a lightweight `Polygon2D` + `Line2D` visual (no `RigidBody2D`, so no gravity), tinted by the material preset (§22). |
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Phase 3b** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache under `user://thumbnails/`: stickman key = `basename_mtime`, prop key = `id_v<PROP_VERSION>`; `load_png`/`save_png`/`clean_stale_stickmen` (§22). |
| `res://scenes/asset_selector.tscn` | **Phase 3b** `PopupPanel` root + `asset_selector.gd` — minimal shell/layout skeleton (title bar, empty `GridContainer`, footer Prev/Next/Browse/Refresh/Close); all dynamic per-cell content is built in code at runtime (§22). |
| `res://sandbox_theme.json` | **Phase 4b** hand-editable styling defaults (font paths/sizes, grid snap default, mode accent colors). Loaded at `_ready()`; missing/malformed falls back to built-in constants. |
**Mode management:**
**Mode management** — a single **3-segment switcher** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` sits at the far left of the top bar (`enum StageMode { EDIT, DIRECT, PLAY }`). Each mode shows a **contextual toolbar** and a **mode badge pill** in the viewport's top-left corner (`✏️ EDIT` cyan, `🎬 DIRECTING` amber, `▶️ SIMULATING` green), sourced from `sandbox_theme.json` `mode_colors`:
- **Edit** (default) — `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active.
- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, selection is cleared, and the spawn palette plus the Grid/Snap/Size controls are hidden (restored on returning to Edit).
- The mode toggle button (leftmost) flips between the two; a `mode_changed(mode: int)` signal is emitted on every toggle, and the camera view persists across the switch.
- **Edit** (default) — spawner palette + Grid/Snap/Size controls visible; the construction **grid** is shown. `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active. Cursor is a crosshair.
- **Direct** — internally an **"edit-with-direct"** state: props frozen / stickmen standing / gizmos enabled (same as Edit) so a director can click stickmen and build action queues and rules, but the spawner/grid controls are hidden and the toolbar shows only a hint label (`Click a stickman to direct, or ⚡ When… for rules`). The **grid is hidden entirely**, and a thin **amber viewfinder frame** borders the viewport (a full-screen `MOUSE_FILTER_IGNORE` panel). Cursor is a crosshair.
- **Play** — layout tools and the grid hidden; props unfreeze and fall, and each stickman's action queue runs (`start_queue()`, `auto_recover = false`). Gizmos are hidden, selection is cleared, and the mode switcher is the only toolbar control. Cursor is the default arrow.
- The mode switcher no-ops when the mode is unchanged (`set_mode()` guard); a `mode_changed(mode: int)` signal carries the new value (`0`/`1`/`2`) on every real switch, and the camera view persists across switches. **Esc** exits Direct → Edit.
- **Per-mode cursor** — `_apply_cursor()` sets `CURSOR_CROSS` for Edit/Direct and `CURSOR_ARROW` for Play, and swaps to a runtime-generated amber **flag/reticle** custom cursor whenever a click-awaiting director step is active (§21); a generated `Image`/`ImageTexture` (no asset file needed).
- **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide).
**Spawn palette** (six text buttons, built from the spawner registry):
**Spawn palette** (text buttons built from the spawner registry — Ground / Ramp / Step / Prop / Stickman / Area):
- **Ground / Ramp / Step** — `TerrainBlock` terrain, placed by centering the template on its local origin so rotation pivots on the block's center.
- **Crate** (`create_box()` + `WOOD`) / **Ball** (`create_ball()` + `RUBBER`) — `PropBlock` dynamic props.
- **Stickman** — a `StickmanRig` spawned from a cached `res://stickmen/test.stk` via `StickmanFactory.spawn_from_data()`, offset `(0, -385)` so the feet land on the cursor.
- **Ground / Ramp / Step** — `TerrainBlock` terrain. Single **terrain** palette items are **drag-painted** (§21): left-click-and-drag paints a staircase run of blocks; a terrain drag commits on release. Non-terrain (props/stickman/area) palette items keep the Phase 2 **single-click repeated placement**.
- **Prop** (Phase 3b) — opens a **selector grid** (§22) of the 4 prop templates; selecting one sets the `selected_prop_id` and enters placement mode spawning that `PropBlock` (`PropUtils.create_*()` + the matching material preset). Replaces the earlier separate **Crate** / **Ball** buttons.
- **Stickman** (Phase 3b) — opens a **selector grid** (§22) of every `.stk` in `res://stickmen/`; selecting one sets the `selected_stickman_path` and enters placement mode spawning a `StickmanRig` from that file (offset `(0, -385)` so the feet land on the cursor). Replaces the previous hard-coded single-`test.stk` placement.
- **Area** (Phase 4) — a `TriggerArea` sensor.
Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on). The stickman ghost is a static standing figure. The next left-click spawns the object there. Placement repeats until you press **Escape** or click a different button. Each placement emits `object_placed(node)`.
Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on); the stickman ghost is a static standing figure. **Left-click** keeps the Phase 2 repeated-placement behavior (each click/drag commits one placement and the tool stays active); **right-click** (or **Esc**) is the explicit **"put the tool down"** gesture — it cancels any in-progress drag, leaves already-placed cells if a drag had committed, and un-toggles the palette button. Each placement emits `object_placed(node)`. The **Stickman** and **Prop** buttons open a selector grid first (a modal `PopupPanel`, §22) and only enter placement mode once an asset is chosen.
**Selection & gizmos (Edit only):**
@@ -434,15 +446,15 @@ Clicking a palette button enters **placement mode**, which shows a translucent *
**Grid & snap:**
- A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1100 px).
- The grid only shows in **Edit** mode and is hidden during **Play**.
- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`.
- The grid shows **only in Edit** mode — it is hidden entirely in **Direct** and **Play** (Phase 4b).
- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`. On first run the **initial default** grid size seeds from `sandbox_theme.json` `grid.snap_size` (15.0).
**Deletion & camera:**
- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`.
- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`. Deleting also clears the hover highlight (`StageSelection.clear_hover()`), so a queued-for-deletion node no longer leaves a stale yellow hover box behind.
- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. **Touchpad**: pinch to zoom, two-finger drag to pan. The view persists across mode toggles.
**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <name or count>` and updates live on spawn, selection, deletion, and mode changes.
**Status bar (bottom):** a bottom-anchored 28 px bar mirrors the stickman editor's `StatusBar` pattern. The **left** label carries the live status text (`Objects: N | Selected: <name or count>`, plus any pending-walk / rule-builder / toast hints — the `Mode:` prefix moved to the badge pill). The **right** label shows live **mouse world coordinates** as `X: ### Y: ###`, polled each frame from `_camera.get_global_mouse_position()` (world-space, so the numbers pan/zoom with the view).
> **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, and (Phase 3a) stickmen now walk up/down them via `NavigationAgent2D` — see §19.
@@ -460,7 +472,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
**Direct tool workflow (Edit):**
1. Press the **Direct** toggle button (mutually exclusive with palette placement). A status hint prompts "Click a stickman".
2. **Left-click a stickman** → an action popup opens at the cursor with **Walk To / Speak / Wait / Ragdoll / Recover**.
2. **Left-click a stickman** → an action popup opens to the **right of the clicked stickman** (its world position converted to screen, offset 24 px) with **Walk To / Speak / Wait / Ragdoll / Recover**.
3. Choose an action:
- **Walk To** → enters pending mode; the **next left-click on the stage** appends `{"type":"walk_to","target":click_pos}`. **Esc** cancels the pending target.
- **Speak** → a text dialog (`AcceptDialog` + `LineEdit`); confirms append `{"type":"speak","text":...,"duration":2.0}`.
@@ -488,7 +500,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
**Play execution:**
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
- On **return to Edit**: each stickman `stop_queue()` then `snap_to_standing()`, and the waypoint overlay is re-enabled.
- On **return to Edit** (or Direct): each stickman `stop_queue()` then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled.
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
**Nav-mesh behavior:**
@@ -511,6 +523,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
| `is_walking` | `func is_walking() -> bool` | Whether a walk is in progress. |
| `speak` | `func speak(text: String, duration: float) -> void` | Show a `SpeechBubble` for `duration` s; auto-hides + emits `speech_finished`. |
| `queue_action` / `clear_queue` / `get_queue` / `remove_action` / `insert_action` / `queue_size` | — | The mutable action queue; all mutations emit `queue_changed`. |
| `clear_reactive_actions` | `func clear_reactive_actions() -> void` | Drops rule-injected (`reactive`-tagged) actions, restoring the authored sequential queue; no-op while the runner is `EXECUTING` (callers invoke it on mode exit after `stop_queue()`). |
| `start_queue` / `stop_queue` / `is_queue_running` | — | Runner control. `stop_queue()` aborts without emitting `queue_finished`. |
| `is_ragdoll_at_rest` | `func is_ragdoll_at_rest() -> bool` | Whether the ragdoll has rested (independent of `auto_recover`); the runner waits on it for the `ragdoll` action. |
| `arrived` | `signal arrived` | `walk_to` reached its destination. |
@@ -558,12 +571,15 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir
5. Choose an **action** from a rule-action popup, then optionally its **target/position** (e.g. click a waypoint for `walk_to`).
6. A popup offers **"Add another action"** (repeat the action step) or **"Done"** to commit the rule.
7. **Esc** is the highest-priority cancel at any step. The rule-builder is a state machine (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) with status-bar hints and toast messages.
8. Choosing **"⬅ Back to actions"** in the trigger sub-menu does **not** cancel the rule flow — it resets the rule builder and re-opens the stickman's action popup (the context rig is preserved), so you can back out of building a rule and pick a sequential action instead.
All rule-builder context menus are **session-anchored**: the first popup in a flow (the action popup right of the clicked stickman, or the rule-label edit entry at the click) records its screen position, and every child popup (trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another action") reopens at that same recorded position until the rule is confirmed or the flow is cancelled — so cycling menus never walks down the screen.
**Rule visualization (Edit only):** `StageDirectorVisuals` draws each rule as a **dashed white connector line** from the trigger object to the target, a **green ⚡ trigger badge**, an **orange → action badge**, a dark label with `rule_summary()` text, and a **✕ delete icon**. Clicking the **rule label** reopens the rule for **consequence-only editing** (replaces the rule, keeping the same `id`); clicking the **✕** deletes the rule. Rules are auto-cleaned (`_cleanup_rules_for_nodes`) when any referenced object is deleted.
**Trigger area placement:** the spawn palette gains an **"Area"** entry (from the `"area"` registry id). Placement works like any other palette item (translucent ghost, grid snap, click to place). Trigger areas are selectable/movable like other objects via the duck-typed `get_area_rect` AABB branch in `stage_selection.gd` / `stage_spawner.gd`.
**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition.
**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue.
**Prop collision detection (two mechanisms):**
@@ -576,6 +592,140 @@ Edge-triggered dictionaries (which events already fired) are reset on Play/Edit
**Deferred (Phase 5 and beyond):** `explode_prop` / `spawn_prop` action types, rule **conditions** and AND/OR combinators, rule **variables**, and **disk save** of rules are explicitly out of scope for Phase 4.
### 21. Phase 4b Polish — Mode Switcher, Theme, Terrain Painting, Director UX, Walk Jitter Fix
**Phase 4b** is a **polish + bugfix** pass over the Sandbox Stage Builder. It does **not** add new gameplay systems; it restructures the top bar into a **3-segment mode switcher** with contextual toolbars, adds a **bottom status bar** with live mouse coordinates, a **mode badge/frame** and per-mode cursors, upgrades **terrain placement** from single-click into **drag-to-paint** with a grid spatial dictionary, makes the **director's "pick a target" flows** kid-friendly (cursor-attached tooltip, rubber-band trajectory, reticle cursor), introduces a hand-editable **`res://sandbox_theme.json`**, and fixes the **walk-waypoint arrival jitter**. Run via **F6** on `res://scenes/sandbox_stage.tscn`; not wired into the editor. See §18 for the mode-switcher / status-bar / badge / cursor surface; this section details the placement, styling, director-UX, and bugfix internals.
#### 21.1 Terrain drag-painting (Edit mode)
Terrain palette items (Ground/Ramp/Step) replace single-click placement with a **drag-to-paint "drawing" workflow**. Drag-painting quantizes to **block units**, not 16-px cells. The block-unit stride is the active terrain template's **sanitized AABB extent** (`StageSpawner.get_template_aabb(id).size`, computed over the same 16-px terrain-grid sanitize pass `_spawn_terrain()` actually places):
| Template | Sanitized stride |
|---|---|
| Ground | **192 × 32** |
| Ramp | **192 × 128** |
| Step | **256 × 256** |
Block centers are `block_cell * stride`, and Bresenham runs over block units. A **cursor-following translucent placement ghost** (block-unit snapped) shows the block the next paint would stamp — it is **freed while a drag is in progress** and **re-armed after each commit** (preserving LMB repeated placement); **RMB** / **Esc** puts the tool down.
1. **Select** a terrain palette item. Pressing **LMB** on the stage sets a fixed **anchor block unit**; dragging updates a **target block unit** and computes the ordered run between them.
2. **Shift** locks the trajectory to a **cardinal axis** — if `|dx| >= |dy|` the y-delta is zeroed, else the x-delta (a pure 0°/90°/180°/270° run, no diagonals). Re-evaluated per motion event.
3. A high-contrast **dashed guide line** (color from `sandbox_theme.json` `mode_colors.guide_line`, default `#22c6ff`) draws from the anchor to the target block unit via the `StagePlacementOverlay` (a world-space `Node2D` sibling of the grid/gizmos). It appears **only during a real drag** (anchor ≠ target) and disappears **instantly** on release / **Esc** / **RMB** — a single-click placement never draws the guide line or guide circles.
4. The run uses **Bresenham's line algorithm** over block units. Along a horizontal/vertical run (including Shift-locked) blocks tile **edge-to-edge — no overlap, no gaps**; along a free diagonal they tile **corner-to-corner** (adjacent diagonal blocks share exactly a corner point — zero overlap, visually acceptable corner gaps). Each block unit is classified against the **grid spatial dictionary** into one of three states, and the per-block ghosts are tinted accordingly:
- **1 empty** → **green** ghost → instantiated on release.
- **2 occupied by the same block type** (same registry `spawn_id`, e.g. another `ground`) → neutral/transparent ghost → **skipped** on release (no double-create / no z-fight). Freshly painted in-drag blocks are marked so a drag crossing its own path skips re-stamping.
- **3 occupied by a different/conflicting object** (prop/stickman/area/another terrain type) → **muted-red** ghost → **skipped** on release.
5. **Release** commits the batch **atomically**: only the "empty" block units spawn (all in one frame), the nav mesh is marked dirty **once** (`_nav_dirty = true`, not per node), the grid dictionary is rebuilt, and `object_placed(node)` fires per node. LMB keeps repeated placement active for the next drag (the placement ghost re-arms).
6. **RMB** (or **Esc**) ends draw/placement mode — it cancels an in-progress drag (nothing is placed until release), clears the guide line, and un-toggles the palette button and restores the cursor.
**Grid spatial dictionary** (`_grid_cells`): a runtime `Dictionary` on `SandboxStage` keyed by 16-px grid-cell `Vector2i` (`StageSpawner.TERRAIN_GRID_SIZE`) → `Array[Node2D]`. It is **advisory only** — it drives the 3-state occupancy query (green/neutral/red tint + skip) but is **never authoritative for physics** or for the block-unit paint stride; the `World` tree is the source of truth. Occupancy marks **all 16-px cells covered by the node's world AABB** via `_rasterize_aabb_to_cells()` (for props/stickmen/areas it rasterizes `StageSelection.get_world_aabb()`), so one Ground block (192×32) spans ≈ 12×2 dictionary cells even though it paints as a single block unit. It is populated on place, updated on move/rotate/delete, and rebuilt on grid-size change. Terrain nodes carry a `spawn_id` string (`TerrainBlock.spawn_id`, set by `StageSpawner.spawn_id`) so same-template overlaps are detectable; `StageSpawner.is_terrain_id()` distinguishes terrain registry entries and `get_template_aabb(id)` exposes a terrain template's **sanitized** local AABB for ghost sizing / block-unit stride / cell rasterization.
#### 21.2 Director targeting UX (Direct / rule-building)
While a click-awaiting director step is active — a pending **Walk To** target, or the **"When…"** rule steps (`TRIGGER_TARGET` / `ACTION_TARGET` / `ACTION_POSITION`) — the stage shows a combined workflow:
- **Reticle cursor** — `_apply_cursor()` swaps to a runtime-generated amber **flag/reticle** cursor (`Image.create` + `ImageTexture`, no asset file) whenever `_is_awaiting_click()` is true.
- **Cursor-attached floating tooltip pill** — a dark, rounded high-contrast label following the cursor (offset ~20 px, flipping near the screen edges) reading e.g. `🚩 Click to set walk target`, `🎯 Click the trigger area`, `💥 Click the prop`, etc. (`_action_hint_text()`). Esc or cancel clears it.
- **Rubber-band dashed trajectory** + **ghost marker** — drawn by `StagePlacementOverlay` from the action's origin (the stickman's feet for a walk/rule action, or the trigger/action anchor node for the "When…" flows) to the cursor; **green** when the target is valid and **red** when invalid (the point lies inside a `TerrainBlock` AABB, via the grid dictionary). A semi-transparent flag/ring + crosshair ghost marker sits at the target, grid-snapped when Snap is on.
#### 21.3 `res://sandbox_theme.json` (styling defaults)
A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema:
```json
{
"version": "1.0",
"fonts": {
"ui_font": "",
"emoji_font": "",
"action_popup_font_size": 24,
"action_popup_emoji_size": 22,
"assignment_badge_font_size": 20,
"assignment_badge_radius": 9,
"rule_label_font_size": 16,
"status_pill_font_size": 16,
"tooltip_font_size": 18
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
}
```
| Key | Default | Consumed by |
|---|---|---|
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. |
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. |
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
#### 21.4 Walk-waypoint arrival jitter fix (`StickmanRig`)
The Phase 3a walk could **jitter up/down at a waypoint** instead of stopping. Root cause: `_update_walking()` re-computed `_walk_mode` (`"nav"` vs `"direct"`) from `is_target_reachable()` **every physics frame**, and a waypoint near the nav-mesh boundary could flip that reachability, swapping `root_target` between the nav path point and the raw waypoint — two targets with a small vertical offset. Fix (all in `stickman_rig.gd`):
1. **Mode latch once per walk.** After the map syncs, `_update_walking()` probes for up to `LATCH_PROBE_MAX_FRAMES` (**6**): it latches `"nav"` as soon as the agent reports the target reachable, or latches `"direct"` when the probe bound is reached (genuinely off-mesh). It is never re-evaluated mid-walk, so the rig cannot oscillate between two targets.
2. **Unified arrival radius against the FINAL target.** Both branches check `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE` (8 px root-space), then **snap** `global_position` onto the final target before `_finish_walk("arrive")` — removing any residual offset. The `"nav"` branch also terminates via **nav-finish**: when `is_navigation_finished()` reports the agent at the path's end **and** the rig is within `2 × ARRIVE_DISTANCE` of the final target, it snaps to the final target and finishes — so an on-mesh waypoint whose final path point sits just outside the 8 px radius still stops dead-on instead of drifting. (The Phase 3a *unconditional* `is_navigation_finished()`-at-12 px finish path is gone; nav-finish now fires only when already close.)
3. **Steer to the final target when close.** In the `"nav"` branch, when within `2 × ARRIVE_DISTANCE` of the final target the rig moves **straight at** it, ignoring a possibly behind-path `next_feet` point (prevents reversing).
4. **Atomic stop + marker re-assert.** `_finish_walk()` stops the player, restores the standing markers, then re-asserts them **one extra physics frame** (`_walk_settle_frames = 1``_settle_walk_markers()`), so a residual ±12.5 px walk body-bob keyframe does not pop on arrival.
The result: `mode` stays constant for the whole walk, exactly one `arrived` fires, the rig's `global_position` is unchanged after arrival, and the walk/body-bob animation stops cleanly. `DEBUG_WALK` / `DEBUG_STAGE` (off by default) can be enabled to capture the `mode`/`dist`/`next`/`final` trace at arrival.
#### 21.5 Head LookAt solver fix (`master_rig.tscn`)
`master_rig.tscn`'s Head `SkeletonModification2DLookAt` now uses a **full-range, non-inverted band solved in global space** (`constraint_angle_min = -180`, `constraint_angle_max = 180`, `constraint_invert = false`, `constraint_in_localspace = false`). Under the previous ~55°-clamped, inverted, local-space band, the aim solver could oscillate frame-to-frame as the look direction crossed the band boundary, causing a per-frame **mirror** of the head rather than smooth convergence. With the full-range global band there is no boundary to cross, so dragging the Head IK handle converges cleanly onto the aim point (see §14 Phase 9 Round 7).
### 22. Asset Library — Stickman & Prop Selector Grids (Phase 3b)
**Phase 3b** replaces the Sandbox Stage Builder's hard-coded single-stickman and single-prop palette buttons (**Crate** / **Ball** / always-`test.stk` **Stickman**) with **visual selector grids**: clicking **Stickman** or **Prop** opens a modal grid popup (`PopupPanel`) of selectable assets; picking one sets the spawner's session selection and enters placement mode with that asset's ghost. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. **No `.stk` format change** (the `.stk` schema below is untouched this phase). Pixel rendering (thumbnails) is **manual/F6 verification only**; headless tests never assert on pixels.
**Workflow:** press the **Stickman** or **Prop** palette button → the grid opens (modal `PopupPanel`, ESC-closeable) → click a cell → the selection is cached session-only, the grid closes, and placement mode begins (ghost of the selected asset appears). Placing repeats the **selected** asset; switching selection re-opens the grid. `Ground`/`Ramp`/`Step` and `Area` remain direct placement buttons, unchanged.
**Two grids:**
| Grid | Backing | Cells | Details |
|---|---|---|---|
| **Stickman** | `StickmanLibrary.scan()` of `res://stickmen/*.stk` | one per `.stk` (name = `stickman_name` else filename basename) | Rig-rendered thumbnail per cell; **Browse…** (`*.stk` `FileDialog`) selects an arbitrary path; **Refresh** rescans; **empty-state** label when no files; **single-item skip** — exactly one file bypasses the grid and places directly |
| **Prop** | `PropLibrary` (4 static templates) | Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard | Thumbnail + name + **material badge**; always a single page |
**Selection is session-only:** the chosen `selected_stickman_path` / `selected_prop_id` live in memory on `StageSpawner`, persist across `EDIT ⇄ DIRECT ⇄ PLAY` toggles, and reset on scene reload. **No disk save** (`user://sandbox_settings.json` is not extended). If the currently selected stickman path is no longer in the scan, the first entry is selected instead on open/refresh.
**Selector grid (`AssetSelector` / `scenes/asset_selector.tscn`):** a `PopupPanel` root (`exclusive = true`) built as a minimal shell — authored title bar, empty `GridContainer`, footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`) — with **all dynamic per-cell content** (texture + name + material badge) built in code at runtime. `open(kind, entries)` titles the popup ("Choose Your Stickman" / "Choose a Prop"), hides Browse/Refresh for props, and `popup_centered()`. Pagination slices **12/page (4×3)** with Prev/Next hidden when a single page; a static pure `page_bounds(total, page, page_size)` helper backs the slicing. Empty state shows a "no stickmen found" label instead of an empty grid. No cell is pre-highlighted on open (the previous selection-highlight border was removed). The popup re-centers on window resize (`size_changed``popup_centered()` while visible). Signals: `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`.
**Thumbnails (rig-accurate, cached):** each 200×200 thumbnail is rendered **lazily, one per frame**, by a `Node` renderer owning a persistent offscreen `SubViewport` (`transparent_bg`, `UPDATE_ALWAYS`) with an enabled in-viewport `Camera2D`:
- `StickmanThumbnail.render(stk_data)` spawns the **real rig** (`StickmanFactory.spawn_from_data`), frames it via `StageSpawner.get_world_aabb` (degenerate-figure bbox falls back to a fixed rect), awaits `RenderingServer.frame_post_draw` **twice**, captures, and frees the rig. This produces a figure identical to what actually gets placed — not a re-implementation of the editor preview.
- `PropThumbnail.render(payload, material_preset)` builds a lightweight `Polygon2D` + `Line2D` visual (mirroring `PropBlock` geometry but **not** a `RigidBody2D`, so nothing falls under gravity), tinted via `PropBlock.tint_for` for non-`NONE` presets.
- Both return `null` (treated as a **placeholder** by the selector) when the capture is blank/empty — the headless-renderer degrade.
**Thumbnail cache (`ThumbnailCache`, `user://thumbnails/`):** rendered PNGs are written to disk and reused on later opens:
| Kind | Directory | Key | Invalidation |
|---|---|---|---|
| Stickmen | `user://thumbnails/stickmen/` | `"<basename>_<mtime>"` (`FileAccess.get_modified_time`) | A modified `.stk` yields a new key → PNG missing → regenerate; `clean_stale_stickmen` deletes superseded PNGs for the same basename |
| Props | `user://thumbnails/props/` | `"<id>_v<PROP_VERSION>"` (`PROP_VERSION = 1`) | Regenerate when `PROP_VERSION` bumps or the PNG is missing |
`StageSpawner` seeds the cache in `_init`; `SandboxStage._drain_thumbnail_queue()` (called from `_process`) pops one queued entry per frame, awaits its render, `save_png`s it, and hands the texture back via `_selector.set_thumbnail(entry, tex)` — so scanning 50+ files never stalls the UI (a placeholder texture shows until each cell's thumbnail arrives).
**Palette integration & Esc priority:** `_on_palette_toggled` routes `stickman`/`prop` presses to `_open_selector(id)` (not straight to `set_placement_mode`); un-pressing while the matching selector is open closes it. While the grid is open a **dim backdrop** (`_selector_dim`, a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, mouse-ignoring, on the UI `CanvasLayer` behind the selector) is shown. `_open_selector` forces the palette button pressed, performs the single-item skip, and (for stickman) reselects the first entry when the selected path is absent. On `item_selected` the spawner's `selected_stickman_path`/`selected_prop_id` are set, the selector closes, and placement begins. `Browse…` builds a one-off entry via `StickmanLibrary.make_entry(path)` (toast on failure). Esc is handled in the `AssetSelector` (`_unhandled_input``cancelled`) and by the stage in its existing **Esc priority chain** — rule step → pending walk target → terrain drag → **selector** → DIRECT → placement → selection. An outside-click that closes the modal popup fires its `popup_hide` signal, which is routed to `_on_selector_cancelled()` (idempotency-guarded) so it likewise un-presses the palette button. While the selector is open, `_handle_world_click` / `_handle_mouse_motion` early-return (belt-and-suspenders on top of the modal `PopupPanel`).
**New scripts:** `stickman_library.gd` / `prop_library.gd` / `thumbnails/thumbnail_cache.gd` / `thumbnails/stickman_thumbnail.gd` / `thumbnails/prop_thumbnail.gd` / `asset_selector.gd` (+ `scenes/asset_selector.tscn`). **Modified:** `stage_spawner.gd` (registry `crate`/`ball``prop`; `selected_stickman_path`/`selected_prop_id` session state; per-path `_stickman_cache`; new `get_selected_stickman_path()`/`get_selected_prop_id()` getters), `sandbox_stage.gd` (selector integration), and `tests/test_phase4b1_fixes.gd` (`spawn("crate")``spawn("prop")`).
**Verification:** new headless suite `tests/test_phase3b_library.gd` (`extends SceneTree`, no pixel assertions) covering `StickmanLibrary.scan()`/corrupt-skip/`make_entry`, `PropLibrary.get_entries()`/`get_default_id()`, the `StageSpawner` registry ids (`ground/ramp/step/prop/stickman/area`), `_spawn_prop`/`_spawn_stickman` honoring `selected_prop_id`/`selected_stickman_path`, `ThumbnailCache` key/path formatting, `AssetSelector` pagination math (`PAGE_SIZE == 12`), and scene-load checks. Spec: `docs/phase_3b_asset_grid_spec.md`.
## File format (`.stk`)
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
@@ -714,22 +864,31 @@ Behavior:
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 46 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001``1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A C)).rotated(c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN``POSE_STANDING`, `STAND_UP_DURATION` 0.8, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. |
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. Has a `spawn_id: String` property (set by `StageSpawner`) so same-template terrain overlaps are detectable during drag-painting. |
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. |
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: EDIT/PLAY mode state machine (freezes props with `FREEZE_MODE_KINEMATIC`, ragdolls stickmen in PLAY), placement mode, camera pan/zoom, deletion, status bar, and signal fan-out (`mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). Standalone staging scene run via **F6**; not wired into the editor. |
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin and caches `stickmen/test.stk` for the Stickman palette entry. |
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). Standalone staging scene run via **F6**; not wired into the editor. |
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. |
| `res://scripts/asset_selector.gd` | **Asset Library (Phase 3b).** `class_name AssetSelector`, `extends PopupPanel` — grid popup controller (root of `scenes/asset_selector.tscn`): pagination 12/page, empty state, per-cell thumbnail + name + prop material badge, Browse/Refresh/Close, Esc. |
| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` to a `Texture2D` via the real rig in an offscreen `SubViewport`. |
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name PropThumbnail`, `extends Node` — renders a prop template to a `Texture2D` (lightweight non-physics visual). |
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Asset Library (Phase 3b).** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache (`user://thumbnails/`) keyed by basename+mtime (stickmen) / `id_v<PROP_VERSION>` (props); load/save/stale cleanup. |
| `res://scenes/asset_selector.tscn` | **Asset Library (Phase 3b).** `PopupPanel` root + `asset_selector.gd` — minimal shell (title bar, empty grid, footer); dynamic cells built in code. |
| `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. |
| `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. |
| `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. |
| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer and CanvasLayer top bar are built in code. |
| `res://scripts/stage_placement_overlay.gd` | **Sandbox Stage Builder (Phase 4b).** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting dashed guide line (`set_terrain_guide` / `clear_terrain_guide`) and the director action rubber-band trajectory + ghost marker (`set_action_trajectory` / `clear_action`); pure drawing, no hit-testing. |
| `res://sandbox_theme.json` | **Sandbox Stage Builder (Phase 4b).** Hand-editable styling defaults for the sandbox (font paths/sizes, grid snap default, mode accent + guide-line colors); loaded by `SandboxStage._load_theme()` with defaults on missing/malformed file. |
| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer, placement overlay, and CanvasLayer UI (mode switcher, toolbars, bottom status bar, badge, tooltip) are built in code. |
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
@@ -825,7 +984,7 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **Phase 9 Round 6 bugfix:** `StkRigAdapter._compute_mount_transform()` now selects the joint anchor as whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest the part's stored guide joint** (`center guide_offset`) when a part carries `guide_offset`. This replaces the per-side family choice and the 180° flip heuristic for that case, fixing the **lower left leg** and **lower right arm**, which were mounted 180° off their bones (the far end attached at the joint) because the user's drawn-side conventions are inconsistent across parts — the stored guide placement is the ground truth for which drawn end is the joint. The nearest-end rule naturally preserves the 180° flip behavior (a flipped part's far end lands nearest the joint), the head chin, and every previously-correct case. Old files without the key keep the family rules + flip heuristic exactly as before. `theta`, `s`, the Round 5 offset `t`, and the head `HEAD_CHIN_DROP` fallback are unchanged. Per docs/phase9_round6_bugfix_spec.md; verified with a 32-assertion headless smoke test.
> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test.
> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (the solver now uses a full-range, non-inverted band solved in global space — `constraint_angle_min = -180 / max = 180`, `constraint_invert = false`, `constraint_in_localspace = false` — so the head converges to the aim point with no per-frame mirror oscillation; the earlier authored ~55° clamp is gone); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test.
> **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
+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. |
---
+4 -4
View File
@@ -55,10 +55,10 @@ bone_index = 1
bone2d_node = NodePath("Torso/Head")
target_nodepath = NodePath("../IK_Targets/Head")
enable_constraint = true
constraint_angle_min = 54.99998
constraint_angle_max = 304.9999
constraint_angle_invert = true
constraint_in_localspace = true
constraint_angle_min = -180
constraint_angle_max = 180
constraint_angle_invert = false
constraint_in_localspace = false
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
modification_count = 5
+330
View File
@@ -0,0 +1,330 @@
# Phase 3b: Asset Library — Stickman + Prop Selector Grids
## 1. Overview
Phase 3b adds a **visual asset library** to the Sandbox Stage Builder. Currently, placing a stickman always spawns the hard-coded `test.stk` figure, and props are limited to the palette buttons (Crate, Ball, Plank). This phase replaces those limitations with **visual selector grids** that let the user choose from all available assets.
## 2. Core Concept
```
┌─────────────────────────────────────────────────────────────────────┐
│ ASSET LIBRARY WORKFLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ EDIT MODE │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ User clicks "Stickman" palette button │ │
│ │ ↓ │ │
│ │ Stickman Selector Grid opens (popup overlay) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Choose Your Stickman [×] Close │ │ │
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ │ │
│ │ │ │ Bob │ │ Sally│ │ John │ │ Joe │ │ │ │
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │
│ │ │ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │ 📷 │ │ 📷 │ │ │ │
│ │ │ │ Sue │ │ Tom │ │ │ │
│ │ │ └──────┘ └──────┘ │ │ │
│ │ │ [Prev] Page 1/2 [Next] [Browse...] [Refresh] │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ User clicks a cell → grid closes │ │
│ │ ↓ │ │
│ │ Click the stage → spawns the selected stickman │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## 3. What's Being Replaced
| Before | After |
| ------------------------------------------- | ------------------------------------------------- |
| "Stickman" palette button spawns `test.stk` | "Stickman" palette button opens the selector grid |
| Separate "Crate", "Ball", "Plank" buttons | Single "Prop" button opens the prop selector grid |
| No visual preview | Thumbnail preview in each grid cell |
| No choice | Choose from all available assets |
| Hard-coded file path | Dynamic library from scanned files |
## 4. Feature Breakdown
### 4.1. Stickman Selector Grid
| Feature | Description |
| --------------- | ------------------------------------------------------------------------ |
| **Source** | Scans `res://stickmen/` for `.stk` files |
| **Thumbnails** | Renders each stickman to a texture (cached to `user://thumbnails/`) |
| **Display** | Grid cells: thumbnail + `stickman_name` (or filename if no name) |
| **Pagination** | 12 items per page (3 rows × 4 columns) with Prev/Next buttons |
| **Selection** | Click a cell → selected stickman is cached as the default |
| **Browse** | "Browse..." button opens a `FileDialog` to select a `.stk` from anywhere |
| **Refresh** | "Refresh" button rescans the `stickmen/` folder |
| **Empty state** | "No stickmen found! Create one in the editor first." |
### 4.2. Prop Selector Grid
| Feature | Description |
| -------------- | ------------------------------------------------------ |
| **Source** | Built-in prop templates (Crate, Ball, Plank, Triangle) |
| **Thumbnails** | Renders each prop with its material/color |
| **Display** | Grid cells: thumbnail + prop name + material badge |
| **Pagination** | 12 items per page with Prev/Next buttons |
| **Selection** | Click a cell → selected prop is cached as the default |
| **Future** | Custom props (`.prp` files) will appear here |
### 4.3. Single Palette Buttons
| Before | After |
| ----------------------- | ---------------------------- |
| "Stickman" (hard-coded) | "Stickman" (opens grid) |
| "Crate" (hard-coded) | Removed |
| "Ball" (hard-coded) | Removed |
| "Plank" (hard-coded) | Removed |
| (None) | **"Prop"** (opens prop grid) |
## 5. Visual Design
### 5.1. Grid Cell Layout
```
┌─────────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ [THUMBNAIL] │ │ ← 150×150 px preview
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Bob │ │ ← Name (bold, centered)
│ │ 🪵 Wood │ │ ← Material badge (props only)
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
**Cell size:** ~160×220 px (thumbnail 150×150, name area 60px)
### 5.2. Grid Popup Layout
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Choose Your Stickman [×] Close │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │ Bob │ │Sally │ │ John │ │ Joe │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │ Sue │ │ Tom │ │Alex │ │Jess │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │Sam │ │Ella │ │Max │ │Mia │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │
│ [← Prev] Page 1/3 [Next →] [Browse...] [Refresh] │
│ │
│ Selected: Bob │
└─────────────────────────────────────────────────────────────────────────┘
```
### 5.3. Visual States
| State | Appearance |
| --------------------- | ------------------------------------- |
| **Default cell** | Light background, subtle border |
| **Hover** | Border highlight, slight scale (1.05) |
| **Selected** | Blue/cyan border, checkmark overlay |
| **Loading thumbnail** | Spinner or placeholder icon |
| **Empty cell** | "No preview available" placeholder |
## 6. Data Flow
### 6.1. Stickman Library Indexer
```gdscript
# stickman_library.gd (NEW)
class_name StickmanLibrary
extends RefCounted
# Scans res://stickmen/*.stk
# Returns Array[Dictionary] with:
# {
# "path": "res://stickmen/bob.stk",
# "name": "Bob",
# "thumbnail": Texture2D,
# "data": Dictionary # parsed .stk data (optional)
# }
```
### 6.2. Prop Library Registry
```gdscript
# prop_library.gd (NEW)
class_name PropLibrary
extends RefCounted
# Built-in prop templates:
# - Crate (Wood)
# - Ball (Rubber)
# - Plank (Metal)
# - Triangle (Cardboard)
# Returns Array[Dictionary] with:
# {
# "id": "crate",
# "name": "Crate",
# "factory": Callable (returns shape payload),
# "material_preset": PropBlock.MaterialPreset.WOOD,
# "thumbnail": Texture2D
# }
```
### 6.3. Thumbnail Generation
#### Stickman Thumbnails:
- Parse the .stk file to get body_parts and part_order.
- Create a temporary SubViewport (200×200 px).
- Render the stickman using a simplified version of WholeStickmanPreview logic.
- Capture the viewport as a Texture2D.
- Cache to user://thumbnails/stickmen/<hash>.png.
#### Prop Thumbnails:
- Instantiate a temporary PropBlock in a SubViewport.
- Apply the material preset and geometry.
- Capture the viewport as a Texture2D.
- Cache to user://thumbnails/props/<id>.png.
#### Caching Strategy:
- Thumbnails are generated once per file and cached to disk.
- On subsequent runs, load the cached thumbnail if it exists and the source file hasn't been modified.
- If the source file is modified, regenerate the thumbnail.
## 7. UI Integration
### 7.1. Palette Button Changes
#### Button Behavior
- "Stickman" Opens the Stickman Selector Grid
- "Prop" Opens the Prop Selector Grid
### 7.2. Selected Asset Persistence
- The last selected stickman is stored in StageSpawner.selected_stickman_path.
- The last selected prop is stored in StageSpawner.selected_prop_id.
- These persist across mode toggles and scene reloads (in memory only — no disk save in Phase 3b).
### 7.3. Placement Flow
```
User clicks "Stickman" palette button
Stickman Selector Grid opens
User clicks a stickman cell
Grid closes → selected stickman is cached
Stage enters placement mode (ghost appears)
User clicks the stage → spawns the selected stickman
```
## 8. File Structure
| File | Purpose |
| ---------------------------------------------- | ------------------------------------------------------ |
| res://scripts/stickman_library.gd | Scans .stk files, manages thumbnails |
| res://scripts/prop_library.gd | Registry of prop templates |
| res://scripts/asset_selector.gd | Grid UI controller (shared between stickmen and props) |
| res://scenes/asset_selector.tscn | Grid popup scene |
| res://scripts/thumbnails/stickman_thumbnail.gd | Renders stickman to texture |
| res://scripts/thumbnails/prop_thumbnail.gd | Renders prop to texture |
| res://user://thumbnails/ | Cached thumbnails (generated at runtime) |
## 9. Acceptance Criteria
### 9.1. Stickman Selector Grid
- [ ] Clicking "Stickman" palette button opens the grid.
- [ ] Grid displays all .stk files in res://stickmen/.
- [ ] Each cell shows a thumbnail preview of the stickman.
- [ ] Each cell shows the stickman's stickman_name (or filename if no name).
- [ ] Clicking a cell selects that stickman and closes the grid.
- [ ] The selected stickman persists for future placements.
- [ ] Pagination works when more than 12 stickmen exist.
- [ ] "Browse..." button opens a FileDialog to select any .stk.
- [ ] "Refresh" button rescans the stickmen/ folder.
- [ ] If no .stk files exist, shows "No stickmen found! Create one in the editor first."
- [ ] If only one .stk exists, the grid is skipped and the stickman is selected directly.
- [ ] Thumbnails are cached to user://thumbnails/ and reused.
- [ ] Modified .stk files regenerate their thumbnails.
### 9.2. Prop Selector Grid
- [ ] Clicking "Prop" palette button opens the grid.
- [ ] Grid displays all built-in prop templates.
- [ ] Each cell shows a thumbnail preview of the prop.
- [ ] Each cell shows the prop name and material badge.
- [ ] Clicking a cell selects that prop and closes the grid.
- [ ] The selected prop persists for future placements.
- [ ] Pagination works when more than 12 props exist.
- [ ] Thumbnails are cached to user://thumbnails/props/.
### 9.3. Palette Integration
- [ ] The old "Crate", "Ball", and "Plank" buttons are removed.
- [ ] A single "Prop" button replaces them.
- [ ] The "Stickman" button now opens the grid (instead of spawning test.stk).
### 9.4. Performance
- [ ] Scanning 50+ .stk files does not stall the UI.
- [ ] Thumbnail generation is non-blocking (or uses a loading state).
- [ ] Grid opens and paginates smoothly.
## 10. Implementation Order
| Step | Task | Dependencies |
| ---- | ---------------------------------------------------------- | ------------ |
| 1 | Create stickman_library.gd (file scanner + entry model) | None |
| 2 | Create stickman_thumbnail.gd (renders stickman to texture) | Step 1 |
| 3 | Create asset_selector.gd / asset_selector.tscn (grid UI) | Steps 1-2 |
| 4 | Integrate selector with "Stickman" palette button | Step 3 |
| 5 | Create prop_library.gd (built-in prop registry) | None |
| 6 | Create prop_thumbnail.gd (renders prop to texture) | Step 5 |
| 7 | Integrate selector with "Prop" palette button | Steps 5-6 |
| 8 | Remove old "Crate", "Ball", "Plank" buttons | Step 7 |
| 9 | Implement thumbnail caching | Steps 2, 6 |
## 11. Edge Cases
| Edge Case | Handling |
| -------------------------- | --------------------------------------------------------- |
| No .stk files exist | Show "No stickmen found! Create one in the editor first." |
| Only one .stk exists | Skip the grid, select it directly |
| Thumbnail generation fails | Show placeholder icon + regenerate on next run |
| File is corrupted/invalid | Skip the file, log a warning |
| Stickman has no name | Use the filename (without .stk) |
| Many files (50+) | Pagination keeps the UI responsive |
| Grid size changes | Rebuild the grid layout on resize |
| Selected file is deleted | Reselect the first available file (or show empty state) |
## 12. Summary
| Before | After |
| --------------------------------- | ------------------------------------- |
| Hard-coded test.stk | Visual grid of all .stk files |
| Separate Crate/Ball/Plank buttons | Single "Prop" button with visual grid |
| No preview | Thumbnail preview in every cell |
| No choice | Choose from all available assets |
| No caching | Thumbnails cached to user:// |
This is the final piece connecting the Stickman Editor to the Sandbox Stage. Users can now create stickmen in the editor, save them as .stk, and pick them from the grid when placing actors on the stage.
+593
View File
@@ -0,0 +1,593 @@
# Phase 3c: Editor Tools — Action & Rule Editing
## 1. Overview
Phase 3c adds **full editing capabilities** for both the Director Tool's action queues and the Event System's rules. Currently, directors can only **append** actions and **create/delete** rules. They cannot fix mistakes, change order, or modify existing items. This phase makes the entire script **fully editable**.
The plan combines action editing and rule editing into a single, cohesive implementation with a logical progression from simpler to more complex features.
---
## 2. Core Principles
### 2.1. Extensibility First
The system is built to accommodate future growth:
| Future Addition | How It's Supported |
| ---------------------- | --------------------------------------------------------- |
| New action types | Registry pattern — add action template, UI auto-generates |
| New rule trigger types | Registry pattern — add trigger type, UI auto-generates |
| New action properties | Action data model is a Dictionary — add new keys freely |
| New rule properties | Rule data model is a Dictionary — add new keys freely |
| New visual styles | Theme JSON already supports font/color overrides |
### 2.2. Consistency
The UI patterns for action editing and rule editing are **identical**:
| Pattern | Action Edition | Rule Edition |
| ------------ | ----------------------------------- | ---------------------------------- |
| Entry points | Popup menu + Right-click + Waypoint | Rule label + Stickman context menu |
| Panel UI | Action Queue Panel | Rule Panel |
| Editor Popup | Action Editor | Rule Editor |
| Controls | [✎] Edit, [✕] Delete, [≡] Reorder | Same |
| Drag handles | Reorder actions | Reorder rules |
---
## 3. Implementation Order
The plan is organized into **5 phases**, each building on the previous:
| Phase | Focus | Deliverables |
| -------------- | ------------------------- | -------------------------------------------- |
| **Phase 3c.1** | **Foundation** | Shared UI components, extensible data models |
| **Phase 3c.2** | **Action Queue Panel** | View, edit, delete, reorder actions |
| **Phase 3c.3** | **Action Visual Editing** | Waypoint context menu, visual walk editing |
| **Phase 3c.4** | **Rule Panel** | View, edit, delete, reorder rules |
| **Phase 3c.5** | **Rule Visual Editing** | Rule label click, waypoint trigger editing |
---
## 4. Phase 3c.1: Foundation
### 4.1. Extensible Action Registry
The action system should be registry-driven to support future action types:
```gdscript
# action_registry.gd (NEW)
const ACTION_TEMPLATES = {
"walk_to": {
"label": "Walk To",
"icon": "🚶",
"params": [
{ "key": "target", "type": "position", "required": true }
]
},
"speak": {
"label": "Speak",
"icon": "💬",
"params": [
{ "key": "text", "type": "text", "required": true },
{ "key": "duration", "type": "float", "default": 2.0 }
]
},
"wait": {
"label": "Wait",
"icon": "⏳",
"params": [
{ "key": "duration", "type": "float", "required": true }
]
},
"ragdoll": {
"label": "Ragdoll",
"icon": "💥",
"params": []
},
"recover": {
"label": "Recover",
"icon": "🔄",
"params": []
}
}
```
**Extensibility:** Adding a new action type = appending to `ACTION_TEMPLATES`. No other code changes required.
### 4.2. Extensible Trigger Registry
Similarly, trigger types are registry-driven:
```gdscript
# trigger_registry.gd (NEW)
const TRIGGER_TEMPLATES = {
"arrived_at_waypoint": {
"label": "Arrives at waypoint",
"icon": "📍",
"target_type": "waypoint"
},
"action_finished": {
"label": "Completes any action",
"icon": "✅",
"target_type": "action_type"
},
"speech_finished": {
"label": "Finishes speaking",
"icon": "💬",
"target_type": "none"
},
"entered_area": {
"label": "Enters trigger area",
"icon": "🎯",
"target_type": "area"
},
"collided": {
"label": "Collides with something",
"icon": "💥",
"target_type": "prop"
}
}
```
### 4.3. Shared UI Components
| Component | Purpose | Reused By |
| ----------------------- | ----------------------------- | ------------------------------ |
| **Panel Container** | Scrollable list of items | Action Queue Panel, Rule Panel |
| **Editor Popup** | Edit single item's properties | Action Editor, Rule Editor |
| **Drag Handle** | Reorder items | Both panels |
| **Delete Confirmation** | Confirm before deletion | Both panels |
### 4.4. Extensible Action Properties
Actions are stored as Dictionaries, so future properties can be added without breaking existing code:
```gdscript
# Current action
{ "type": "speak", "text": "Hello", "duration": 2.0 }
# Future action (with text color)
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
```
**Extensibility:** New properties are just new keys in the Dictionary. The editor should display editable fields for all known keys and gracefully ignore unknown ones.
---
## 5. Phase 3c.2: Action Queue Panel
### 5.1. Feature Breakdown
| Feature | Description |
| ------------------- | ------------------------------------------- |
| **Queue Panel** | Popup showing all actions for a stickman |
| **Edit Action** | Re-open action popup with pre-filled values |
| **Delete Action** | Remove action from queue (confirmation) |
| **Reorder Actions** | Drag handle to reorder |
| **Add Action** | Append new action from panel |
| **Clear All** | Remove all actions (confirmation) |
### 5.2. Entry Points
| Entry Point | When to Use | How it Works |
| --------------- | -------------------------------- | ------------------------------------------- |
| **Popup** | Edit queue for selected stickman | Click stickman → "Edit Queue" → panel opens |
| **Right-click** | Quick access | Right-click stickman → "Edit Queue" |
### 5.3. UI Layout
```
┌─────────────────────────────────────────────────────────────────────┐
│ ACTION QUEUE PANEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Stickman: Bob [×] Close │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ 1 🚶 Walk to Crate [✎] [✕] [≡] │ │
│ │ 2 💬 Speak "Hello!" [✎] [✕] [≡] │ │
│ │ 3 ⏳ Wait 2.0s [✎] [✕] [≡] │ │
│ │ 4 💥 Ragdoll [✎] [✕] [≡] │ │
│ │ 5 🔄 Recover [✎] [✕] [≡] │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Clear All] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 5.4. Edit Action Flow
1. User clicks **[✎]** on an action.
2. The **same action popup** appears, with current values pre-filled.
3. User makes changes.
4. Click **OK** → action is updated in the queue.
5. Visuals update immediately.
| Action Type | Pre-filled Values |
| ----------- | -------------------------------------------------- |
| **Walk To** | Current target position (waypoint dot highlighted) |
| **Speak** | Current text and duration |
| **Wait** | Current duration |
| **Ragdoll** | (No parameters) |
| **Recover** | (No parameters) |
---
## 6. Phase 3c.3: Action Visual Editing
### 6.1. Feature Breakdown
| Feature | Description |
| ------------------------- | ----------------------------------------- |
| **Waypoint Context Menu** | Right-click waypoint → Edit/Delete/Insert |
| **Edit Walk (Visual)** | Click a new position → waypoint moves |
| **Insert Action** | Insert before/after a specific waypoint |
### 6.2. Waypoint Context Menu
In Edit mode, right-clicking a waypoint dot opens:
```
Right-click Waypoint 3:
┌─────────────────────────────────────────────┐
│ ✎ Edit this Walk │
│ ✕ Delete this Walk │
│ ⬆ Insert action before │
│ ⬇ Insert action after │
└─────────────────────────────────────────────┘
```
| Action | Behavior |
| ------------------------ | -------------------------------------------------------------- |
| **Edit this Walk** | Enters target placement mode → click new spot → waypoint moves |
| **Delete this Walk** | Removes the walk action from the queue |
| **Insert action before** | Opens action popup → inserts new action before this one |
| **Insert action after** | Opens action popup → inserts new action after this one |
### 6.3. Visual Update Flow
```
User right-clicks waypoint
Context menu appears
User clicks "Edit this Walk"
Stage enters target placement mode
Current waypoint is highlighted (blinking)
User clicks new position on stage
Old waypoint removed, new waypoint appears
walk_to action's target is updated
Dotted lines reconnect
Order numbers remain the same
```
---
## 7. Phase 3c.4: Rule Panel
### 7.1. Feature Breakdown
| Feature | Description |
| -------------------------------- | --------------------------------------------- |
| **Rule Panel** | Popup showing all rules for a source stickman |
| **Edit Rule (Full)** | Edit trigger type, trigger target, action(s) |
| **Edit Rule (Consequence-Only)** | Quick edit of actions only |
| **Delete Rule** | Remove rule from registry (confirmation) |
| **Reorder Rules** | Drag handle to reorder evaluation order |
| **Add Action to Rule** | Add another action to an existing rule |
| **Remove Action from Rule** | Delete an action from a rule |
| **Clear All** | Remove all rules (confirmation) |
### 7.2. Entry Points
| Entry Point | When to Use | How it Works |
| -------------------- | ----------------------------- | ------------------------------------------------- |
| **Rule Label** | Edit a specific rule | Click the rule label (dashed line) → editor opens |
| **Stickman Context** | View all rules for a stickman | Right-click stickman → "Edit Rules" → panel opens |
### 7.3. Rule Panel UI
```
┌─────────────────────────────────────────────────────────────────────┐
│ RULE PANEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Source: Stickman A [×] Close │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ 1 📍 When A arrives at Waypoint 3 │ │
│ │ → B speaks "Hello there!" [✎] [✕] [≡] │ │
│ │ │ │
│ │ 2 📍 When A arrives at Waypoint 5 │ │
│ │ → C walks to crate [✎] [✕] [≡] │ │
│ │ │ │
│ │ 3 🎯 When A enters Area │ │
│ │ → All stickmen ragdoll [✎] [✕] [≡] │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Rule] [Clear All] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 7.4. Rule Editor (Full)
Opens when user clicks **[✎]** on a rule in the panel:
```
┌─────────────────────────────────────────────────────────────────────┐
│ EDIT RULE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Trigger: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [📍 Arrives at waypoint ▼] [Click target →] │ │
│ │ Target: Waypoint 3 (on stage) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Actions: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Cancel] [OK] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 7.5. Rule Editor (Consequence-Only)
Opens when user clicks a rule label on the stage:
```
┌─────────────────────────────────────────────────────────────────────┐
│ EDIT RULE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ When: 📍 A arrives at Waypoint 3 (read-only) │
│ ────────────────────────────────────────────────────────────── │
│ Then: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Done] [Cancel] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
**Focus:** The trigger is displayed but **read-only**. For trigger editing, use the full rule editor.
---
## 8. Phase 3c.5: Rule Visual Editing
### 8.1. Feature Breakdown
| Feature | Description |
| ---------------------------- | ------------------------------------------- |
| **Rule Label Click** | Click rule label → consequence-only editor |
| **Waypoint → Rules** | Right-click waypoint → "Edit Trigger Rules" |
| **Rule Reordering (Visual)** | Evaluation order shown on stage (optional) |
### 8.2. Waypoint → Rules
In Edit mode, right-clicking a waypoint dot that is used as a trigger target:
```
Right-click Waypoint 3:
┌─────────────────────────────────────────────┐
│ ✎ Edit this Walk (action) │
│ ───────────────────────────────────────── │
│ ⚡ Edit Trigger Rules (2 rules) │
└─────────────────────────────────────────────┘
```
Clicking "Edit Trigger Rules" opens the Rule Panel filtered to rules using that waypoint.
---
## 9. File Structure
| File | Purpose |
| ----------------------------------- | ------------------------------------------------------ |
| `scripts/action_registry.gd` | Registry of action templates (extensible) |
| `scripts/trigger_registry.gd` | Registry of trigger templates (extensible) |
| `scripts/queue_panel.gd` | Action Queue Panel controller |
| `scripts/queue_panel.tscn` | Action Queue Panel scene |
| `scripts/rule_panel.gd` | Rule Panel controller |
| `scripts/rule_panel.tscn` | Rule Panel scene |
| `scripts/action_editor.gd` | Action Editor popup controller |
| `scripts/action_editor.tscn` | Action Editor popup scene |
| `scripts/rule_editor.gd` | Rule Editor popup controller (full + consequence-only) |
| `scripts/rule_editor.tscn` | Rule Editor popup scene |
| `scripts/waypoint_context.gd` | Waypoint right-click menu |
| `scripts/sandbox_stage.gd` | Add "Edit Queue", "Edit Rules", integrate editors |
| `scripts/stage_director_visuals.gd` | Waypoint hit-testing, rule label click → editor |
---
## 10. Extensibility Guide
### 10.1. Adding a New Action Type
```gdscript
# 1. Add to action_registry.gd
const ACTION_TEMPLATES = {
# ... existing actions ...
"jump": {
"label": "Jump",
"icon": "🦘",
"params": [
{ "key": "height", "type": "float", "default": 100.0 },
{ "key": "duration", "type": "float", "default": 0.5 }
]
}
}
```
# 2. Implement the action in StickmanRig.\_process_queue()
# 3. Add the action to the popup (automatically from registry)
### 10.2. Adding a New Rule Trigger Type
```gdscript
# 1. Add to trigger_registry.gd
const TRIGGER_TEMPLATES = {
# ... existing triggers ...
"variable_changed": {
"label": "Variable changes",
"icon": "📊",
"target_type": "variable"
}
}
```
# 2. Emit the trigger signal from SandboxStage
# 3. Add the trigger to the rule builder (automatically from registry)
### 10.3. Adding a New Action Property
```gdscript
# 1. The action is stored as a Dictionary
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
# 2. The Action Editor reads all keys from params
# 3. Unknown keys are displayed as read-only (or editable with generic control)
# 4. The action runner reads the new key when executing
```
---
## 11. Acceptance Criteria
### 11.1. Action Queue Panel
-"Edit Queue" opens panel from popup and right-click.
-Panel shows all actions in order.
-Each action shows type, parameters, and order number.
-[✕] deletes action (confirmation dialog).
-[✎] opens edit popup with pre-filled values.
-[≡] reorders actions.
-"Clear All" removes all actions (confirmation).
-"Add Action" appends a new action.
### 11.2. Edit Action
- Walk To: Edit opens target placement mode; clicking new spot updates waypoint.
- Speak: Edit opens text dialog with current text pre-filled.
- Wait: Edit opens duration dialog with current value pre-filled.
- Ragdoll/Recover: Edit opens confirmation dialog.
### 11.3. Waypoint Context Menu
- Right-click waypoint opens context menu.
- "Edit this Walk" enters target placement mode.
- "Delete this Walk" removes the action.
- "Insert action before/after" inserts new action.
### 11.4. Rule Panel
- "Edit Rules" opens panel from stickman context menu.
- Panel shows all rules where stickman is trigger source.
- Each rule shows trigger type, target, and action(s).
- `[✕] deletes rule (confirmation).
- `[✎] opens rule editor with pre-filled values.
- `[≡] reorders rules.
- "Add Rule" opens rule builder.
- "Clear All" removes all rules (confirmation).
### 11.5. Rule Editor
- Full editor: trigger type dropdown works, trigger target can be clicked.
- Full editor: action type dropdown works, target can be clicked.
- Full editor: parameters (text, duration) can be edited.
- Consequence-only editor: trigger is read-only.
- Consequence-only editor: actions can be edited.
- Multi-action rules: add/remove actions.
- Saving updates rule in event registry.
- Visual connectors update immediately.
### 11.6. Visual Updates
- Waypoint dots move when Walk actions are edited.
- Speech badge text updates when Speak actions are edited.
- Wait duration updates.
- Dotted lines reconnect to reflect new order.
- Order numbers update after insert/delete/reorder.
- Rule labels update with new summaries.
- Dashed lines reconnect to new targets.
### 11.7. Backward Compatibility
- Existing queues load and display correctly.
- Editing preserves action types and parameters.
- Existing rules load and display correctly.
- Editing a rule preserves the rule ID.
- Deleting an action/rule cleans up all references.
### 11.8. Extensibility
- New action types can be added via registry (no code changes required).
- New trigger types can be added via registry.
- New action properties are supported (dictionary keys).
- Editor gracefully handles unknown keys.
---
## 12. Implementation Order Summary
| Phase | Focus | Key Deliverables |
| -------- | --------------------- | ------------------------------------------------------- |
| **3c.1** | Foundation | Action Registry, Trigger Registry, Shared UI Components |
| **3c.2** | Action Queue Panel | Queue panel, edit/delete/reorder actions |
| **3c.3** | Action Visual Editing | Waypoint context menu, visual walk editing |
| **3c.4** | Rule Panel | Rule panel, full/consequence-only edit, reorder |
| **3c.5** | Rule Visual Editing | Rule label click, waypoint trigger rules |
---
## 13. Summary
| Before | After |
| ------------------------------------ | --------------------------------------------- |
| Actions can only be appended | Actions can be edited, deleted, and reordered |
| Rules can only be created or deleted | Rules can be edited, deleted, and reordered |
| No way to fix mistakes | Edit any parameter |
| No way to change order | Drag to reorder |
| No visual editing | Waypoint context menu + visual walk editing |
| No chain reaction editing | Add/remove actions from rules |
| Fixed evaluation order | Drag to reorder rules |
| Tightly coupled code | Registry-driven, extensible architecture |
**This completes the Director Tool's full editing capabilities.** Directors can now create, edit, delete, and reorder both actions and rules with full control and extensibility for future development.
+565
View File
@@ -0,0 +1,565 @@
# Phase 3c — Editor Tools: Action & Rule Editing (Spec)
Status: SPEC (pending implementation)
Related plan: `plans/PHASE_3c_EDITOR.md`
Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", "Forward Plus")`).
---
## 1. Overview & Scope
Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules
**fully editable** (edit / delete / reorder / insert), replacing the current
append-only + create/delete-only behavior. It is built on two registry-driven tables
(action + trigger) so the UI is generated from data rather than hard-coded `match`
statements, matching the existing `StageSpawner._registry` / `PropLibrary` patterns.
### In scope
- Action + trigger **registries** (`action_registry.gd`, `trigger_registry.gd`).
- **Action Queue Panel** — view / edit / delete / reorder / add / clear a stickman's queue.
- **Action visual editing** — waypoint right-click context menu (edit walk / delete walk /
insert before / insert after).
- **Rule Panel** — view / edit / delete / reorder / add / clear rules (filtered by source stickman).
- **Rule Editor** — full edit (trigger type + target + actions) and consequence-only edit
(actions only, trigger read-only).
- **Rule visual editing** — rule-label click → consequence-only editor (already partially
present via `_begin_edit_rule`); waypoint → "Edit Trigger Rules".
### Out of scope / untouched (must not regress)
- **StickmanRig runner execution semantics** — the action runner (`_begin_action`,
`_update_runner`, the 5 action phases) is **not** changed. Editing mutates queue/rule
*data*; the runner already consumes any of those shapes.
- **Event engine matching** (`_rule_matches`, `_update_area_entry`,
`_update_stickman_prop_collision`) — unchanged. Rule *reordering* changes evaluation
order (array order) but not the per-rule matching logic.
- **Navigation / walk steering** (`_update_walking`, mode latch) — unchanged.
- **Terrain drag-painting, selection, gizmos, placement ghost, asset selector** — unchanged.
- **No `.stk` / `settings.json` format changes.** Queues and rules remain session-only
(persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the
Phase 3a/4 decision.
- **No new action types / trigger types** are implemented (only the *machinery* to add them
cleanly). The registry is the extension point; wiring a genuinely new action still requires
a runner case in `StickmanRig._begin_action` (see §6 and §13).
---
## 2. Recorded Decisions
1. **Registries are static tables, not singletons.** `action_registry.gd` /
`trigger_registry.gd` are `class_name`-less-optional, `RefCounted` scripts exposing
`static` const tables + `static func` accessors (mirroring `PropLibrary`). `sandbox_stage.gd`
preloads them like the other `preload` consts. No autoload, no instance state.
2. **All new UI is code-built; no `.tscn` files.** The plan lists `queue_panel.tscn`,
`rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`. The codebase builds all stage
UI in code inside `_build_ui()` (top bar, popups, dialogs); only `AssetSelector` uses a
`.tscn` shell, and it has authored content. These panels/editors are **dynamic** (row lists
change every mutation), so they are `PopupPanel`-based controller scripts constructed in
code. **Dropped**: `action_editor.gd`/`action_editor.tscn` (action param editing reuses the
existing `_speak_dialog`/`_wait_dialog` + pending-target machinery) and
`waypoint_context.gd` (the waypoint menu is a code-built `PopupMenu`, exactly like
`_trigger_popup`). See §7 file list.
3. **Reorder UX = Move Up / Move Down buttons, not drag.** A `[≡]` drag handle in a `PopupPanel`
requires hand-rolled `_gui_input` drag/reorder hit-testing; up/down buttons are simpler,
keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable
headlessly. Each row shows `⬆`/`⬇` (disabled at the ends) plus `✎`/`✕`. Drag reorder is
logged as deferred (tech debt §13).
4. **Waypoint → action mapping returns `(rig, index, pos)`, not just a position.**
`StageDirectorVisuals.hit_test_waypoint()` currently returns only the nearest `Vector2`
(used by the rule builder for `arrived_at_waypoint`). A new `hit_test_waypoint_action()`
returns `{rig, index, pos}` so the context menu knows **which rig's queue and which
`walk_to` action index** to edit/delete/insert around. The existing position-only method is
kept for the rule builder (rules reference waypoint *positions*, not indices).
5. **Insert before/after maps directly to `StickmanRig.insert_action(index, action)`.**
"Before waypoint *i*" → `insert_action(i, action)`; "after" → `insert_action(i + 1, action)`.
`i` is the **queue index** of the `walk_to` action (equal to the waypoint ordinal 1 because
`walk_to` is the only waypoint-producing action). `insert_action` already clamps to `[0, size]`.
6. **Edit-walk visual flow reuses the pending target-capture machinery.** A new edit state
(`_pending_walk_edit_index >= 0`) reuses `_pending_walk_target`'s cursor/hint/trajectory and
the `_handle_direct_click`/`_handle_world_click` routing; the next stage click **replaces**
the existing `walk_to.target` instead of appending. The edited waypoint is highlighted in
the director visuals.
7. **Rule reorder = array order; `id` is immutable and preserved.** `_event_rules` is iterated
in order by `_handle_event` (all matching rules execute — no short-circuit). Reordering swaps
array entries; editing preserves `id` (already the case in `_finalize_rule`). `_next_rule_id`
stays monotonic (no id reuse on delete).
8. **Rule Panel filter = `trigger.source`.** "Edit Rules" on a stickman shows rules whose
`trigger.source` equals that stickman's instance id. The waypoint "Edit Trigger Rules" filter
matches by `params.waypoint_pos` proximity (`WAYPOINT_MATCH_EPSILON`), because rules store
waypoint **positions**, not queue indices.
9. **Delete confirmation policy (deliberately asymmetric).** Panel-initiated `✕` deletes and
"Clear All" (queue and rules) confirm via `ConfirmationDialog`. The on-stage rule-label `✕`
and the waypoint "Delete this Walk" stay **immediate** (current low-friction behavior,
not regressed). Documented in §13.
10. **Ragdoll/Recover have no editable parameters.** Their `✎` is hidden in the panel and their
"edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens
confirmation dialog" is corrected — there is nothing to edit.)
11. **The full rule editor drives the existing `RuleStep` state machine.** Trigger-type change
re-enters `SELECT_TRIGGER`; trigger-target change re-enters `TRIGGER_TARGET`; "Add Action"
uses the existing `SELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-more`
flow. The editor panel is a *view*; `sandbox_stage.gd` remains the *controller* owning
`_rule_builder`/`_rule_step`/`_event_rules`.
---
## 3. Discrepancies: Plan vs. Actual Code
| # | Plan claim | Reality (verified) | Resolution |
|---|---|---|---|
| 1 | Panels/editors are new `.tscn` scenes (`queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`, `waypoint_context.gd`). | Stage UI is built entirely in code (`_build_ui`); only `AssetSelector` has a `.tscn` shell. | Code-built `PopupPanel` scripts (§2.2); drop `action_editor.gd` + `waypoint_context.gd` (§2.2). |
| 2 | Registry `params` with `key`/`type`/`default` drives the editor generically. | Action data lives in **two** shapes: queue action (`walk_to.target` top-level; `speak.text`/`.duration` top-level; `wait.duration` top-level) vs. rule action (`{type, target, params:{...}}`). No generic param model exists. | Registry describes *logical* params; `get_fields`/`make_action` normalize the two shapes (§5.3). |
| 3 | "New action type = append to registry; **no code changes**" (§10.1, §11.8). | `StickmanRig._begin_action` hard-codes the 5 types; a new type also needs a runner case + `_action_for_rig` + `_action_desc`. | Corrected: registry removes *UI* changes; runner changes still required (§6, §12.7). |
| 4 | `hit_test_waypoint()` returns an index. | It returns only `Vector2` (nearest waypoint position); no rig/index identity. | Add `hit_test_waypoint_action()` returning `{rig, index, pos}` (§2.4, §9.2). |
| 5 | "Rule label click → consequence-only edit already exists" (implied complete). | `_begin_edit_rule(id)` pre-populates and jumps straight to the add-action popup (`SELECT_ACTION`), preserving the trigger but offering **no actions list / remove / edit** and no trigger readout UI. | New `RuleEditor` (consequence-only mode) formalizes this; `_begin_edit_rule` routes to it (§11.4). |
| 6 | `action_finished` trigger has a `target_type: "action_type"` (registry). | The builder does **not** expose an action-type selector for `action_finished` (only "completes any action"); `_rule_matches` reads optional `params.action_type`. | Registry marks `action_type` as optional; the full editor exposes it **only if cheap** — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13). |
| 7 | Rule Panel "shows all rules for a source stickman" and reorders. | `_event_rules` is a flat stage-level array (no per-rig grouping); source is `trigger.source` (instance id). | Filter by `trigger.source` (§2.8); reorder operates on the flat array (§2.7). |
| 8 | §11 acceptance criteria formatting: stray backticks and `-` prefixes. | Cosmetic markdown errors in the plan. | Rewritten cleanly in §12. |
| 9 | Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." | Ragdoll/recover are param-less; editing is meaningless. | Corrected (§2.10). |
| 10 | Plan §11.8 "unknown keys displayed as read-only / editable." | No generic editor exists; the runner ignores unknown keys. | Corrected: unknown keys are **preserved** (round-tripped) on edit, never dropped; no generic widget (§12.7). |
| 11 | Waypoint context menu / stickman right-click are new. | Right-click is currently fully consumed by the EDIT placement-cancel branch (`_handle_world_click` returns on RMB). | Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1). |
| 12 | Godot "4.4" (task prompt). | `project.godot:19` and the test runner reference **4.7**. | Spec targets 4.7. |
---
## 4. New Files
| File | `class_name` / extends | Responsibility |
|---|---|---|
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Static action template table + `static` helpers (labels/icons/params/describe/normalize). |
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Static trigger template table + `static` helpers. |
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals. |
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals. |
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals. |
No `.tscn` files are added (decision 2). `action_editor.gd` and `waypoint_context.gd` from the
plan are **not** created — their responsibilities are absorbed into `sandbox_stage.gd` (existing
dialogs + a code-built `PopupMenu`).
---
## 5. Data Contracts
### 5.1 Queue action dict (consumed by `StickmanRig` runner — top-level keys)
```gdscript
{ "type": "walk_to", "target": Vector2 } # target = feet/ground world position
{ "type": "speak", "text": String, "duration": float }
{ "type": "wait", "duration": float }
{ "type": "ragdoll" }
{ "type": "recover" }
# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected)
```
### 5.2 Rule dict (stored in `SandboxStage._event_rules`)
```gdscript
{
"id": int, # immutable, monotonic from _next_rule_id
"trigger": {
"type": String, # arrived_at_waypoint | action_finished |
# speech_finished | entered_area | collided
"source": int, # instance id of the triggering stickman
"target": int, # instance id (entered_area area | collided prop);
# -1 otherwise
"params": {
# arrived_at_waypoint -> { "waypoint_pos": Vector2 }
# action_finished -> { "action_type": String } (optional; "" == any)
# else -> {}
},
},
"actions": [ { "type": String, "target": int, "params": {...} } ],
}
```
Rule action `params`:
```gdscript
walk_to -> { "target": Vector2 } # destination (the action's `target` = walking stickman id)
speak -> { "text": String, "duration": float }
wait -> { "duration": float }
ragdoll / recover -> {}
```
> **Key asymmetry (documented):** in a **queue** action `walk_to`, `target` is the destination.
> In a **rule** action, `target` is the *stickman instance id* and the destination is
> `params.target`. The registry normalizes this via `get_fields`/`make_action` (§5.3).
### 5.3 ActionRegistry API (`action_registry.gd`)
```gdscript
static func get_types() -> Array[String]
# ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order)
static func get_label(type: String) -> String # "Walk To", "Speak", "Wait", "Ragdoll", "Recover"
static func get_icon(type: String) -> String # "🚶","💬","⏳","💥","🔄"
static func get_params(type: String) -> Array[Dictionary]
# [{ "key": "target", "kind": "position", "required": true }]
# [{ "key": "text", "kind": "text", "required": true },
# { "key": "duration", "kind": "float", "default": 2.0 }]
# [{ "key": "duration", "kind": "float", "required": true }]
# [] for ragdoll/recover
static func has_params(type: String) -> bool
static func get_fields(action: Dictionary) -> Dictionary
# reads each param key from action top-level, falling back to action.params
# (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v})
static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary
# for_rule=false -> { "type": type, ...top-level keys }
# for_rule=true -> { "type": type, "target": -1, "params": {...keys} }
static func describe(action: Dictionary) -> String
# "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers"
# (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home)
static func row_summary(action: Dictionary) -> String
# panel row text: "<icon> <label> <param summary>"
```
### 5.4 TriggerRegistry API (`trigger_registry.gd`)
```gdscript
static func get_types() -> Array[String]
# ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"]
static func get_label(type: String) -> String
static func get_icon(type: String) -> String
static func get_target_kind(type: String) -> String
# "waypoint" | "action_type" | "none" | "area" | "prop"
static func needs_target(type: String) -> bool
# true for arrived_at_waypoint (waypoint), entered_area (area), collided (prop)
static func describe(trigger: Dictionary) -> String
# "arrives", "completes an action", "finishes speaking", "enters area", "collides"
```
> `speech_finished` (`none`) and `action_finished` (`action_type`) are "no world target" triggers.
> `action_finished`'s optional `params.action_type` filter is **not** surfaced in the Phase 3c
> editor (the builder's current "completes any action" behavior is preserved) — deferred §13.
---
## 6. Extensibility Contract (corrected from plan §10)
- **New action type** = (1) append a template to `ActionRegistry` (label/icon/params),
(2) add a `_begin_action` case + (if it has params) a `_action_for_rig` mapping in
`StickmanRig`, (3) add a params dialog if it has new param kinds. The popups/panels/rows
then auto-generate. **The runner case is not optional** (plan §11.8 "no code changes" is false).
- **New trigger type** = (1) append a template to `TriggerRegistry`, (2) emit a matching event in
`SandboxStage` and add a `_rule_matches` case, (3) add a target-capture branch if it has a
world target. UI auto-generates from the registry.
- **New action property** = add a param entry + a widget; the editor must be taught the widget.
Unknown keys on load/edit are **preserved** (round-tripped), never dropped.
---
## 7. File-by-File Change List
### 7.1 `scripts/stickman_rig.gd` (modified — additive only)
- `func move_action(from: int, to: int) -> void` — bounds-check both (`push_warning` + return on
out-of-range), remove-at + insert-at (reusing `remove_action`/`insert_action` semantics but
emitting `queue_changed` **once**). Used by reorder up/down.
- `func replace_action(index: int, action: Dictionary) -> void` — bounds-check, `action_queue[index]
= action`, emit `queue_changed`. Used by edit (speak/wait/walk-retarget/ragdoll/recover).
No runner-phase changes; `get_queue()`, `clear_queue()`, `insert_action`, `remove_action`,
`enqueue_reactive`, `clear_reactive_actions` are reused as-is.
### 7.2 `scripts/stage_director_visuals.gd` (modified — additive)
- `func _collect_waypoint_refs() -> Array[Dictionary]` — mirror `_collect_waypoints` exactly but
return `{ "rig": StickmanRig, "index": int, "pos": Vector2 }` (index = queue index of the
`walk_to` action). Same world-child iteration order.
- `func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary` — nearest ref within
`WAYPOINT_HIT_RADIUS_PX / _zoom()`; returns `{}` on miss. Keeps `hit_test_waypoint()` (still
used by the rule builder).
- `var edit_waypoint: Vector2 = Vector2.INF` + `func set_edit_waypoint(pos: Vector2) -> void`
(`mark_dirty()`). While finite, `_draw_waypoint` draws that dot with an accent outline/ring
(blinking is optional — a static accent + thicker ring is sufficient for the acceptance test;
a `_process`-driven blink is deferred). Cleared by the stage when edit-capture ends.
### 7.3 `scripts/sandbox_stage.gd` (modified — the controller)
New consts:
```gdscript
const ACT_EDIT_QUEUE := 7 # item ids added to _action_popup (after ACT_WHEN=5 / RULE_ACTION_DONE=6)
const ACT_EDIT_RULES := 8
const QUEUE_ADD_WALK := ACT_WALK # reuse 0..4 for the add-action popup
# ... (ACT_SPEAK/WAIT/RAGDOLL/RECOVER reused)
const WP_EDIT_WALK := 0
const WP_DELETE_WALK := 1
const WP_INSERT_BEFORE := 2
const WP_INSERT_AFTER := 3
const WP_EDIT_RULES := 4
const RIG_EDIT_QUEUE := 0
const RIG_EDIT_RULES := 1
```
New state:
```gdscript
var _pending_walk_edit_index: int = -1 # >=0 while retargeting an existing walk_to
var _editing_action_index: int = -1 # queue index being edited via speak/wait dialog
var _queue_add_context: StickmanRig = null # target rig for "Add Action" from the queue panel
var _waypoint_context: Dictionary = {} # {rig, index} for the open waypoint context menu
var _queue_panel: QueuePanel = null
var _rule_panel: RulePanel = null
var _rule_editor: RuleEditor = null
var _rig_context_popup: PopupMenu = null
var _waypoint_context_popup: PopupMenu = null
var _queue_add_popup: PopupMenu = null
var _confirm_dialog: ConfirmationDialog = null # shared, repurposed per action (with a pending closure)
var _confirm_action: Callable = Callable() # what to run on confirm
```
New signal wiring in `_build_ui()`:
- Add `_rig_context_popup` (`PopupMenu`: "📋 Edit Queue…", "📋 Edit Rules…"), `_waypoint_context_popup`
(`PopupMenu`: "✎ Edit this Walk", "✕ Delete this Walk", "⬆ Insert action before",
"⬇ Insert action after", "⚡ Edit Trigger Rules (N)"), `_queue_add_popup` (`PopupMenu`: the 5
action types), and a shared `_confirmation_dialog`. Apply `_apply_popup_theme` to the popups.
- Append `_action_popup.add_separator()` + `add_item("📋 Edit Queue…", ACT_EDIT_QUEUE)` +
`add_item("📋 Edit Rules…", ACT_EDIT_RULES)`.
- Instantiate `_queue_panel`, `_rule_panel`, `_rule_editor` (code-built; add to the `ui`
CanvasLayer) and connect their signals (below). Apply `_ui_font`/`_emoji_font` via their
`apply_font` methods.
Handlers (new/changed):
- `_handle_world_click` right-click branch (before placement-cancel): when `_rule_step == IDLE`
and mode is EDIT or DIRECT, hit-test waypoint → `_waypoint_context_popup`; else `_selection.hit_test`
a `StickmanRig``_rig_context_popup`. Both record `_popup_anchor` from the click's screen rect.
- `_on_action_popup_id_pressed`: new `ACT_EDIT_QUEUE`/`ACT_EDIT_RULES` cases → open panels for
`_context_rig`.
- Queue-panel signal handlers: `_queue_edit(index)`, `_queue_delete(index)` (confirm),
`_queue_move(index, dir)`, `_queue_add()`, `_queue_clear()` (confirm).
- Rule-panel signal handlers: `_rule_edit(id)`, `_rule_delete(id)` (confirm), `_rule_move(index,
dir)`, `_rule_add()`, `_rules_clear()` (confirm).
- Waypoint-context handlers: `_waypoint_edit_walk()`, `_waypoint_delete_walk()`,
`_waypoint_insert(before: bool)`, `_waypoint_edit_rules()`.
- Rig-context handlers: `_rig_edit_queue()`, `_rig_edit_rules()`.
- `_pending_walk_edit_index` consumed in `_handle_direct_click` (and the EDIT left-click path):
when `>= 0`, replace `_context_rig`'s queue action at that index with the new `walk_to` target
(via `replace_action`), clear the edit state + `edit_waypoint`, refresh cursor/status/visuals.
- `_move_rule(from: int, to: int)` helper (bounds-check, swap, `_director_visuals.set_rules`).
- `_open_rule_editor(id, consequence_only: bool)` — populate `RuleEditor` and show; `RuleEditor`
drives `_rule_builder`/`_rule_step` through signals back to the stage.
Esc priority (`_unhandled_key_input`): insert `_rule_editor`/`_queue_panel`/`_rule_panel` close
and `_pending_walk_edit_index` cancel into the existing chain (above placement/selection clear).
---
## 8. New File Detail
### 8.1 `scripts/queue_panel.gd``QueuePanel extends PopupPanel`
Signals (all emitted to the stage, which mutates data):
```gdscript
signal edit_requested(index: int)
signal delete_requested(index: int)
signal move_requested(index: int, dir: int) # dir: -1 up, +1 down
signal add_requested()
signal clear_requested()
signal closed()
```
API:
```gdscript
func open_for(rig_name: String, queue: Array[Dictionary]) -> void # store + _rebuild + popup_centered()
func refresh(queue: Array[Dictionary]) -> void # re-render after a mutation
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
Rows (one per action, in order): order number, `ActionRegistry.row_summary(action)`, then
`✎` (hidden for ragdoll/recover), `✕`, `⬆`, `⬇` (disabled at ends). Footer: `[Add Action]`
`[Clear All]` `[Close]`. `exclusive = true`; Esc → `closed.emit()`. Rebuilt on every `refresh`.
### 8.2 `scripts/rule_panel.gd``RulePanel extends PopupPanel`
Signals:
```gdscript
signal edit_requested(id: int)
signal delete_requested(id: int)
signal move_requested(index: int, dir: int)
signal add_requested()
signal clear_requested()
signal closed()
```
API:
```gdscript
func open_for(source_name: String, rules: Array[Dictionary]) -> void
func refresh(rules: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
Rows (one per rule): order number + `StageDirectorVisuals.rule_summary(rule)` (or
`TriggerRegistry.describe` + action summaries), then `✎`, `✕`, `⬆`, `⬇`. Footer:
`[Add Rule]` `[Clear All]` `[Close]`.
### 8.3 `scripts/rule_editor.gd``RuleEditor extends PopupPanel`
Signals:
```gdscript
signal trigger_type_changed(type: String)
signal trigger_target_requested()
signal action_add_requested()
signal action_edit_requested(index: int)
signal action_remove_requested(index: int)
signal done_requested()
signal cancelled()
```
API:
```gdscript
func open_full(trigger: Dictionary, actions: Array[Dictionary], trigger_types: Array[String]) -> void
func open_consequence(trigger_summary: String, actions: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
- **Full mode:** trigger `OptionButton` (registry types) + `[Set target…]` (hidden for
`speech_finished`/`action_finished`) + target readout; actions list (summary + `✎` `✕`);
`[Add Action]`; `[Cancel]` `[OK]`.
- **Consequence-only mode:** read-only trigger line; actions list; `[Add Action]`; `[Done]`
`[Cancel]`.
---
## 9. UI Flows
### 9.1 Action Queue Panel
Entry: Direct-click stickman → action popup → **"📋 Edit Queue…"**; or right-click stickman →
**"Edit Queue…"**. Panel lists the queue (order numbers, type, params). `✎` routes to the
existing edit machinery (§9.2); `✕`/Clear All confirm; `⬆`/`⬇` call `move_action`;
`[Add Action]` opens `_queue_add_popup` (appends via the same per-type flow, targeting the
queue's rig).
### 9.2 Action editing (reuses existing dialogs)
- **walk_to**`_pending_walk_edit_index = index`; `edit_waypoint` highlights the dot; next
stage click → `replace_action(index, {type:"walk_to", target: new_pos})`.
- **speak** — pre-fill `_speak_edit.text`/duration; confirm (with `_editing_action_index >= 0`)
`replace_action(index, {type:"speak", text, duration})`.
- **wait** — pre-fill `_wait_spin.value`; confirm → `replace_action`.
- **ragdoll/recover** — no `✎`; nothing to edit.
`_on_speak_confirmed`/`_on_wait_confirmed` gain an `_editing_action_index >= 0` branch
(prefixed above the existing `_rule_step == PARAMS` branch).
### 9.3 Waypoint context menu (EDIT/DIRECT, right-click on a dot)
Items: `✎ Edit this Walk`, `✕ Delete this Walk`, `⬆ Insert action before`, `⬇ Insert action
after`, `⚡ Edit Trigger Rules (N)` (N = count of rules referencing that waypoint position; hidden
when 0). Edit → §9.2 walk flow; delete → `remove_action(index)`; insert → `_queue_add_popup` then
`insert_action(index | index+1, action)`; Edit Trigger Rules → `RulePanel` filtered by
`params.waypoint_pos` proximity.
### 9.4 Rule Panel
Entry: right-click stickman → "Edit Rules…" (filter `trigger.source == rig id`); or action popup
→ "Edit Rules…". Rows show `rule_summary`; `✎` → full `RuleEditor`; `✕`/Clear All confirm;
`⬆`/`⬇``_move_rule`; `[Add Rule]` → fresh build (`ACT_WHEN` flow, source = panel rig).
### 9.5 Rule Editor
Full (`✎` in panel): trigger dropdown + target button + actions list (`✎`/`✕`) + `[Add Action]` +
`[OK]`. Consequence-only (rule-label click on stage): trigger read-only + actions list +
`[Add Action]` + `[Done]`. `OK`/`Done``_finalize_rule()` (preserves `id`); `Cancel`
`_cancel_rule_build()`.
---
## 10. Entry-Point Integration (existing code hooks)
- **Action popup items** appended in `_build_ui` after the `ACT_WHEN` separator
(`_action_popup` currently ends at `ACT_WHEN`).
- **Right-click routing** in `_handle_world_click` (RMB branch) gains waypoint → stickman hit
tests **before** the EDIT placement-cancel, gated on `_rule_step == IDLE` and mode != PLAY.
- **Rule-label click** (`_handle_world_click` LMB path already calls `_begin_edit_rule`) now
routes to `_open_rule_editor(id, true)` instead of the bare add-action popup.
- **`_clear_director_pending`** also resets `_pending_walk_edit_index`, `_editing_action_index`,
`edit_waypoint`, and hides the new panels/popups (mode exit must cancel edit flows).
---
## 11. Acceptance Criteria (corrected from plan §11)
### 11.1 Action Queue Panel
- "Edit Queue…" opens the panel from the action popup and from stickman right-click.
- Panel lists every action in queue order with an order number + type + parameter summary.
- `✕` deletes an action after a confirmation dialog.
- `✎` re-opens the action editor with values pre-filled (hidden for ragdoll/recover).
- `⬆`/`⬇` move an action (order numbers and waypoint connectors update; first/last disabled).
- "Add Action" appends via the add-action popup.
- "Clear All" removes all actions after a confirmation dialog.
### 11.2 Edit Action
- walk_to: edit enters target-capture mode; clicking a new spot moves the waypoint (dot +
dashed connector update; order numbers unchanged).
- speak: text + duration pre-filled; confirm updates the action + badge summary.
- wait: duration pre-filled; confirm updates.
- ragdoll/recover: no edit (param-less); `✎` hidden.
### 11.3 Waypoint Context Menu
- Right-click a waypoint dot opens the context menu (EDIT and DIRECT).
- "Edit this Walk" enters target placement (highlighted dot) and moves the waypoint.
- "Delete this Walk" removes the walk action (immediate).
- "Insert action before/after" inserts at `index` / `index + 1` (connectors reconnect, order
numbers renumber).
### 11.4 Rule Panel
- "Edit Rules…" opens the panel filtered to rules whose `trigger.source` is the stickman.
- Each rule shows a summary (trigger verb + action summaries) + order number.
- `✕` deletes after confirmation; `⬆`/`⬇` reorders; "Add Rule" starts the rule builder;
"Clear All" confirms and clears.
### 11.5 Rule Editor
- Full: trigger type dropdown works; trigger target can be re-clicked; action list supports
add/remove; parameters (text/duration/walk target) editable; `OK` updates the rule preserving
`id`.
- Consequence-only: trigger read-only; actions editable; `Done` updates the rule preserving `id`.
- Visual connectors update immediately on save.
### 11.6 Visual Updates
- Waypoint dots move on walk edit; speech badge text + wait duration reflect edits; dashed
connectors re-render after insert/delete/reorder; rule labels refresh; rule connectors
re-target on trigger/action target edits.
### 11.7 Backward Compatibility
- Existing queues/rules load and display unchanged.
- Editing preserves action types and unknown keys; editing a rule preserves `id`.
- Deleting a rule/action leaves no dangling references (`_cleanup_rules_for_nodes` on node delete
already covers object deletion; queue/rule edits only mutate their own data).
### 11.8 Extensibility (corrected)
- New action **UI** requires only a registry entry; new action **runtime** still needs a
`_begin_action` case (documented in §6).
- New trigger **UI** requires only a registry entry; new trigger **runtime** needs an event
emission + `_rule_matches` case.
- Unknown action keys are preserved on edit (not dropped); no generic unknown-key widget.
---
## 12. Implementation Order (matches the 5 sub-phases)
1. **3c.1 Foundation**`action_registry.gd` + `trigger_registry.gd`; `StickmanRig.move_action`
/ `replace_action`; `StageDirectorVisuals._collect_waypoint_refs` / `hit_test_waypoint_action` /
`edit_waypoint`. (No behavior change yet — existing popups may optionally read labels/icons
from the registry, but the plan allows keeping the hard-coded popup strings until 3c.2+.)
2. **3c.2 Action Queue Panel**`queue_panel.gd`; `_action_popup` items; `_rig_context_popup`;
queue edit/delete/move/add/clear handlers; `_confirmation_dialog`.
3. **3c.3 Action Visual Editing**`_waypoint_context_popup`; `_pending_walk_edit_index` +
`edit_waypoint` highlight; insert before/after; edit-walk target capture.
4. **3c.4 Rule Panel**`rule_panel.gd`; `rule_editor.gd` (full + consequence-only);
`_move_rule`; rewire `_begin_edit_rule` → consequence-only editor; rule add/delete/clear.
5. **3c.5 Rule Visual Editing** — waypoint "Edit Trigger Rules (N)" filter; waypoint→rule
proximity matching; final Esc-priority / `_clear_director_pending` cleanup.
Each sub-phase is independently testable via the headless `SceneTree` suite pattern
(`tests/test_phase4b_*.gd`: instantiate `sandbox_stage.tscn`, drive handlers directly, assert on
`_event_rules` / `rig.get_queue()` / popup visibility).
---
## 13. Tech-Debt / Deferred
- **Drag reorder** — up/down buttons chosen over `[≡]` drag (decision 3). Revisit if a
touch/pointer drag reorder is wanted.
- **Generic param-field auto-generation**`ActionRegistry.get_params` describes params, but
widgets (LineEdit/SpinBox/position click) remain hand-built per type. A registry→widget factory
is future work.
- **`action_finished` type filter** — the full editor does not expose `params.action_type`;
the trigger remains "completes any action" (§3.6).
- **`StageDirectorVisuals.rule_summary`/`_action_desc` duplication** — the registry's `describe`
now duplicates `rule_summary`'s summary logic; a follow-up can make `rule_summary` delegate to
the registry to remove the copy.
- **Asymmetric delete confirmation** — panel deletes confirm; on-stage rule `✕` and waypoint
"Delete this Walk" stay immediate (decision 9).
+459
View File
@@ -0,0 +1,459 @@
# Phase 4b Polish — Implementation Specification
> **Status:** Draft for implementation (tester/developer will refine unit tests).
> **Source plan:** `plans/PHASE_4b_POLISH.md`
> **Scope:** Sandbox Stage Builder (`scenes/sandbox_stage.tscn` + `scripts/sandbox_stage.gd` and its supporting scripts). **Not** wired into the main editor. Run standalone via **F6**.
---
## 1. Overview
Phase 4b is a **polish + bugfix** pass over the Sandbox Stage Builder (Phases 24). It does **not** add new gameplay systems; it (a) restructures the top-bar UI into a unified 3-segment mode switcher with contextual toolbars, a bottom status bar, a mode badge, per-mode viewport framing, and per-mode cursors; (b) upgrades terrain placement from single-click into a drag-to-paint "drawing" workflow with Bresenham staircase pathing, a 3-state occupancy query, ghost previews, and atomic batch commit backed by a new grid spatial dictionary; (c) makes the director tool's "pick a target" flows kid-friendly with cursor-attached tooltips, rubber-band trajectory lines, and a custom action cursor; (d) introduces a hand-editable theme JSON (fonts/sizes/colors/grid default); and (e) fixes the walk-waypoint arrival jitter.
The codebase state verified for this spec:
- The sandbox stage is `scripts/sandbox_stage.gd` (`class_name SandboxStage`), root of `scenes/sandbox_stage.tscn` (which is a minimal `Node2D` + `Camera2D` + empty `World`; **all UI is built in code** in `_build_ui()`).
- There are currently **two** modes (`enum StageMode { EDIT, PLAY }`, `sandbox_stage.gd:27`); "Direct" is a **separate bool** `_direct_mode` toggled by a `_direct_button` (`:153`, `:660-664`) that sits *between* the spawner buttons and the Grid/Snap controls.
- The status readout is a **right-aligned `Label` in the top bar** (`_status_label`, `:691-694`), not a bottom bar, and there is **no mouse-coordinate readout**.
- Terrain placement is **single-click**: `_place_at()` (`:467`) spawns one node per click; there is no drag trajectory, no Bresenham pathing, no occupancy query, and **no grid spatial dictionary** (`_rebake_navigation()` at `:798` iterates `World` children each bake).
- `StageDirectorVisuals` recomputes rule connector anchors **live** from `instance_from_id(...).global_position` on every `_draw()` (`stage_director_visuals.gd:257-287`), but it only redraws when `_dirty` is set (`:80-83`), and `_on_transform_committed()` (`sandbox_stage.gd:875-879`) does **not** call `mark_dirty()` — this is the "moving a TriggerArea does not update its rule connector" bug (§8).
- `walk_left`/`walk_right` bake a vertical body bob: `IK_Targets/Torso:position` is keyed `(0,10) → (0,-15) → (0,10) → (0,-15) → (0,10)` (`scripts/create_animations.gd:80`; baked into `master_rig.tscn`) — relevant to the jitter bug (§11).
- There is **no `res://assets/` directory, no `.ttf`, no `.theme`/`.tres`** in the repo; all drawing uses `ThemeDB.fallback_font`. The only existing sandbox config is the runtime `user://sandbox_settings.json` (`sandbox_stage.gd:1500-1527`, keys `version`/`grid_size`/`snap_to_grid`/`show_grid`).
- The established headless test pattern is `Godot_v4.4-stable_win64_console.exe --headless --script res://tests/<name>.gd --path .` (see `tests/test_text_baseline_fix.gd:13-16`).
---
## 2. Feature-by-feature breakdown
Each subsection: **Behavior**, **Affected files/functions**, **Data/format changes**, **Edge cases**.
---
### 2.1 Bottom status bar with mouse coordinates
**Behavior.** Add a bottom-anchored status bar (mirroring the stickman editor's `%StatusBar` pattern). Left side may carry the existing `Mode: … | Objects: N | Selected: …` text (or that moves to a toast); **right side** shows the live mouse world coordinates as `X: ### Y: ###`, exactly like the editor's `_process()` cursor readout (`scripts/stickman_editor.gd:147-162`, which polls `get_global_mouse_position()` each frame and writes `_status_cursor_coords.text`).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` (`:633`) — replace the top-bar status `Label` with a bottom bar. Build a `PanelContainer` + `HBoxContainer` anchored `Control.PRESET_BOTTOM_WIDE` (height ~28 px, mirroring the editor's 28 px `StatusBar`), containing a left `Label` (`_status_label`, expands) and a right `Label` (`_status_cursor_coords`). The top bar (`top_bar`, `offset_bottom = 40.0`, `:638-641`) must have its bottom reduced so the new bar does not overlap (`offset_bottom` stays 40 for the top bar; the bottom bar is a separate Control).
- Add `_process()` coordinate polling (extend the existing `_process` at `:219`): compute `_camera.get_global_mouse_position()`, write `X: %d Y: %d` into `_status_cursor_coords` (world space, integer-rounded, consistent with the editor). This is world-space (not screen-space) so it pans/zooms correctly.
- `_refresh_status()` (`:607`) keeps writing the left label.
**Edge cases.** When the mouse is over the top bar/bottom bar/popups (`_is_mouse_over_ui()` at `:1450`), the coordinate readout should still update (world position under a UI hover is still meaningful) — decide and document: the editor hides coords when not over a drawing surface, but the sandbox stage is a single full-screen viewport, so **always show** world coords. Handle `Camera2D` null (should not happen; it is `@onready`).
---
### 2.2 Unified mode switcher + contextual toolbars
**Behavior.** Replace the current `_mode_button` ("Edit"/"Play") + `_direct_button` ("Direct") with a single **3-segment control** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` at the far left of the top bar. The three modes have contextual toolbars:
- **Edit:** spawner buttons (Ground/Ramp/Step/Crate/Ball/Stickman/Area), Grid/Snap/Size controls. *(No separate "transform tool" buttons exist today — translation/rotation are direct-drag/ring gizmos, not toolbar tools.)*
- **Direct:** spawner buttons + grid/snap controls hidden; the toolbar shows director affordances (currently the director is entirely click/popup-driven, so this segment may initially show only a hint label; see Open Questions).
- **Play:** layout tools hidden; toolbar shows only the mode switcher (+ playback controls if added — see Open Questions).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `enum StageMode { EDIT, PLAY }` (`:27`) → extend to `enum StageMode { EDIT, DIRECT, PLAY }`. **Recommend values EDIT=0, DIRECT=1, PLAY=2** (segmented order). `mode_changed(mode: int)` (`:36`) now carries 3 values.
- Fold `_direct_mode: bool` (`:153`) into `current_mode`. Delete `_direct_button` (`:154`) and `_mode_button` (`:119`); introduce a small array of 3 `Button` (toggle_mode) built from a `const MODES = [{id, label}, …]` in `_build_ui()`.
- `set_mode(mode)` (`:354`) → route to `_enter_edit_mode()` / **new `_enter_direct_mode()`** / `_enter_play_mode()`. `_enter_direct_mode()` = enter EDIT-side state (freeze props, stand stickmen, gizmos enabled) **without** clearing selection-as-placement, set `_direct_mode` behavior, show director visuals, hide spawner/grid controls.
- `_enter_edit_mode()` (`:366`) must clear direct state (currently `_enter_play_mode()` clears `_direct_mode` at `:404`; edit must also reset it).
- `_on_direct_toggled` (`:885`), `_on_mode_toggled` (`:830`) — replaced by `_on_mode_segment_toggled(pressed, mode)`.
- `_set_build_controls_visible(visible)` (`:1469`) — split into `_set_edit_controls_visible(bool)` (spawners + grid/snap/size) and `_set_direct_controls_visible(bool)`; call from the three mode-enter functions.
- `_handle_world_click` (`:298`) — the `current_mode != StageMode.EDIT` early return (`:301`) must now accept `DIRECT` for the `_handle_direct_click` path; rule-builder and rule-label hit-testing still need to work in Direct mode.
- `_unhandled_key_input` Esc ordering (`:269-282`) — add a `DIRECT` branch (exit Direct → Edit) consistent with the current `_direct_mode` branch (`:276-278`).
- `_handle_mouse_motion` (`:335`) — the `current_mode != StageMode.EDIT` guard (`:339`) must allow Direct-mode hover only if needed (Direct currently does not hover; keep hover Edit-only, but permit Direct click routing).
**Data/format changes.** None.
**Edge cases.** `set_mode()` no-ops when `mode == current_mode` (`:355`) — keep. Entering PLAY from DIRECT must clear `_direct_mode` state and the pending-walk-target/rules popup (already handled in `_enter_play_mode` `:396-412`; verify after folding the bool). The old `mode_changed` consumers (only the stage itself) must be re-audited for the new enum values.
---
### 2.3 Viewport color frame / canvas background per mode
**Behavior.** Distinct canvas cues per mode:
- **Edit:** construction grid visible (current behavior).
- **Direct:** amber/gold viewport **border frame** (a thin full-screen border overlay, "camera viewfinder" feel) and/or gold-tinted gizmos. **Grid hidden entirely** (user decision — no dimmed grid).
- **Play:** grid faded out; green glow on the Play segment.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` — add a full-screen `Panel` (or `ColorRect`) Control in the `CanvasLayer` named e.g. `ModeFrame`, `mouse_filter = MOUSE_FILTER_IGNORE`, drawn **behind** the top bar and **over** the viewport, with a `StyleBoxFlat` that has transparent `bg_color` and a `border_color`/`border_width` from the theme JSON. Toggle `visible`/`border_color` in the three mode-enter functions.
- `_apply_grid_settings()` (`:1460-1467`) — the grid visibility line `_grid.visible = _show_grid and current_mode == StageMode.EDIT` (`:1464`) must change: `EDIT` → visible; `DIRECT`**hidden**; `PLAY` → hidden (fade).
- `_refresh_status()` (`:607`) — the Play segment glow is a button theme override applied on mode change.
- `scripts/stage_grid.gd` — no change required (Direct hides the grid; the optional `alpha` dim knob is **not** needed).
**Edge cases.** The frame overlay must never intercept input (`MOUSE_FILTER_IGNORE`). Border width is screen-constant (not zoom-dependent).
---
### 2.4 High-contrast status badge pill
**Behavior.** A prominent pill in a viewport corner showing the mode: `✏️ EDIT` (cyan/blue), `🎬 DIRECTING` (amber/gold), `▶️ SIMULATING` (green). Replaces the "Mode:" portion of the status text as the primary mode indicator.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` — build a `PanelContainer` (`StyleBoxFlat` with accent `bg_color`, rounded corners, padding) containing a `Label`, anchored top-left (or top-right) of the viewport, `mouse_filter = MOUSE_FILTER_IGNORE`.
- New `_refresh_mode_badge()` called from the three mode-enter functions and `_refresh_status()`; text + `StyleBoxFlat.bg_color` + label color sourced from the theme JSON mode colors (§2.9).
- `_refresh_status()` (`:607`) — drop `Mode: …` from the status text (now redundant) or keep it; the badge is authoritative.
**Edge cases.** Badge must float above the world but below the toolbar; ensure it doesn't overlap the top bar when the window is short.
---
### 2.5 Cursor feedback per mode
**Behavior.** `Input.set_default_cursor_shape()` per mode: Edit → `CURSOR_CROSS`, Direct → `CURSOR_CROSS` (or a custom reticle), Play → `CURSOR_ARROW`. The action-pick cursor (§2.10) overrides this with a custom flag/reticle.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- New `_apply_cursor()` called from the mode-enter functions and on `_pending_walk_target`/rule-builder step transitions. Use `Input.set_default_cursor_shape(Input.CURSOR_CROSS)` etc. Custom cursors (flag/reticle) require `Input.set_custom_mouse_cursor(texture, shape, hotspot)` — note this needs an image asset (none exists yet; a `res://assets/` addition or a runtime-generated `Image`/`ImageTexture` via `Image.create()` is acceptable).
**Edge cases.** Restore the default cursor when returning to Play/Edit and when the rule builder or pending-target is cancelled (Esc). A custom cursor must be cleared with `Input.set_custom_mouse_cursor(null)`.
---
### 2.6 Terrain placement improvements (Edit mode)
This is the largest workstream. Current state: `set_placement_mode()` spawns **one** ghost (`_spawn_ghost` `:495`) and `_place_at()` spawns **one** node per click (`:467`). There is **no** drag painting and **no** occupancy tracking.
**2.6.1 Anchor & drag trajectory.**
- Pressing LMB (in Edit, with a terrain placement id active) sets a fixed **anchor grid cell**; moving updates a **target grid cell** (`_snap_to_grid` `:1454`).
- **Shift** locks the trajectory to a cardinal axis: compute `dx`/`dy` from anchor→target; if `|dx| >= |dy|` zero the y-delta, else zero the x-delta (0°/90°/180°/270°). Re-evaluate per motion event.
**2.6.2 High-contrast dashed guide line.**
- Draw a dashed line from the anchor cell center to the locked target cell center. High-contrast accent (cyan/gold). Implemented either in a new overlay `Node2D` (sibling of `StageGrid`/`StageGizmos`, e.g. `PlacementOverlay`) or as a draw method in `StageGizmos`; recommend a **new lightweight overlay** to keep `StageGizmos` focused on selection.
**2.6.3 Bresenham staircase pathing + ghost pipeline.**
- Compute an ordered cell array from anchor to target using **Bresenham's line algorithm** (grid-cell space). Cells are `Vector2i`/`Vector2` at `cell * grid_size`.
- Replace the single `_ghost` with a **ghost array**: one translucent `TerrainBlock` per path cell (matching the active template). Update each frame in `_process()`/motion handling. Reuse `StageSpawner.spawn()` + reparent out of `World` into `_ghost_holder` (`:495-529`), `modulate.a ≈ 0.5`, collision disabled (already done for StaticBody2D ghost at `:513-516`). Free the whole ghost array on release/cancel.
**2.6.4 Three-state grid query (per cell).**
For every cell in the path, classify against a **grid spatial dictionary** (below):
1. **Empty** → green ghost → instantiate on release.
2. **Occupied by same block type** (same registry id, e.g. another `ground`) → neutral/transparent ghost → **skip** on release (no double-create, no z-fight).
3. **Occupied by a different/conflicting object** (crate/ball/stickman/area/different terrain) → muted-red ghost → **skip** on release.
Matching "same block type" requires knowing which registry id produced an existing `TerrainBlock`. Since `TerrainBlock` does not store its template id today, add a `set_meta("spawn_id", id)` in `_place_at()`/`_spawn_terrain()` (or a `spawn_id` property on `TerrainBlock`). Ground/Ramp/Step are distinct ids so only identical-template overlaps skip.
**2.6.5 Atomic batch commit + grid spatial dictionary.**
- **Grid spatial dictionary (new):** a `Dictionary` on `SandboxStage` keyed by grid-cell `Vector2i``Array[Node2D]` (nodes whose footprint overlaps that cell). Populated on spawn/load, updated on move/rotate/delete, cleared/rebuilt when grid size changes. Used by (a) the 3-state query, (b) optionally to accelerate `_rebake_navigation()`/event-engine broadphase (ties into tech-debt #14).
- Because `TerrainBlock` footprints can be larger than one cell (Ground is 200×32 at `TERRAIN_GRID_SIZE=16`, i.e. ~13×2 cells; Ramp/Step larger), occupancy must mark **all cells covered by the node's world AABB**, not just the anchor cell. For terrain the template is grid-aligned; for props/stickmen/areas use `StageSelection.get_world_aabb()` (`stage_selection.gd:129`) rasterized to cells. This is the one non-trivial piece — spec a helper `_rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]`.
- **Batch commit:** on release, collect all "empty" cells into one batch, spawn all nodes in one frame (loop `_spawner.spawn()`), then add them to the dictionary and mark `_nav_dirty = true` once (not per node). Emit `object_placed` per node (or add an `objects_placed(nodes)` signal; keep `object_placed` for compat). `_save_object_state()` per node (for authored restore).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`: `set_placement_mode` (`:456`), `_place_at` (`:467`), `_spawn_ghost`/`_free_ghost`/`_update_ghost_position` (`:495-544`) → replaced/extended by drag placement; `_handle_world_click` (`:298`) and `_handle_mouse_motion` (`:335`) gain drag-placement branches; `_on_transform_committed` (`:875`) and `delete_selected` (`:584`) must update the dictionary; `_rebake_navigation` (`:798`) optionally consumes it.
- `scripts/terrain_block.gd`: add a `spawn_id` `String` (or use `set_meta`) so same-type overlap is detectable.
- `scripts/stage_spawner.gd`: `_spawn_terrain` (`:202`) already centers the template; expose the template extent (e.g. a `get_template_aabb(id)`) for ghost sizing and cell rasterization. `TERRAIN_GRID_SIZE` (`:31`) is the cell size.
- **New file** `scripts/stage_placement_overlay.gd` (recommended): draws the dashed guide line + per-cell ghost tint state (green/neutral/red) — or fold into the ghost array directly.
**Data/format changes.** Grid spatial dictionary is runtime-only (not persisted). No `.stk`/settings format change.
**Edge cases.**
- **Case A (seamless extension):** blocks at (1,0),(2,0),(3,0); drag (4,0)→(7,0) → 4 new blocks.
- **Case B (overlap extension):** start on existing block (3,0), drag to (7,0) → cell 3 is "same-type skip", 47 spawn.
- **Case C:** drag across a crate → that cell red-skipped, neighbors still spawn.
- **Shift lock** suppresses diagonals entirely (Bresenham produces a pure horizontal/vertical run under cardinal lock).
- Grid-size change while a drag is active: recompute the dictionary or cancel the drag (simplest: cancel drag + free ghosts on `_on_grid_size_changed`).
- Terrain `spawn_id` on nodes placed before this phase (none, since the dictionary is new) — but a rebuild from scratch in `_ready()` must scan existing `World` children; since the stage starts empty, this is trivial.
- Batch spawn of e.g. 50 cells must not stall: reuse the existing per-node `_save_object_state` and single `_nav_dirty` coalescing (nav already coalesces via `_process` `:219-222`).
---
### 2.7 Build cancelling (RMB ends placement)
**Behavior.** When in Edit with a palette object selected (terrain/prop/stickman/area), **right-clicking** ends draw/placement mode — the palette button toggles off and the cursor returns to normal. **LMB keeps the existing Phase 2 repeated-placement behavior** (each LMB click/drag commits one placement and the tool stays active); RMB is the explicit "put the tool down" gesture.
> **Decision (user):** the plan's "LMB ends placement" was a typo — **RMB activates build cancelling**. This preserves Phase 2 repeated placement.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_unhandled_input` / `_gui_input` / world input path — add an RMB branch: when Edit mode + placement id active (or an active terrain drag), cancel the drag (free ghosts, keep already-placed cells if the drag committed), call `set_placement_mode("")` (frees the ghost and un-toggles the palette button via `set_placement_mode` `:456-464`), restore the cursor.
- Keep **Esc** cancel (`_unhandled_key_input` `:279-280`) for cancelling before committing (same code path).
- Verify RMB is not currently bound to another action in Edit mode (direct-mode context menus are DIRECT-only, so no conflict).
**Edge cases.** RMB during an in-progress terrain drag: cancel the drag — decide and document whether cells already painted in the current drag stay (commit-on-release semantics) or the whole drag is aborted; **recommend abort-the-drag** (nothing placed until release; RMB before release = clean cancel). RMB with no placement active: no-op (do not interfere with gizmo/context behavior). Blocked-cell-only drags (all red) simply place nothing on release; the tool stays active for another drag until RMB/Esc.
---
### 2.8 Moving a TriggerArea refreshes its rule connector
**Root cause (verified).** `StageDirectorVisuals` rule anchors are computed live each draw from `instance_from_id(...).global_position` (`stage_director_visuals.gd:257-287`), so a redraw **would** follow a moved area — but `_draw()` only runs when `_dirty` is set (`:80-83`), and `SandboxStage._on_transform_committed()` (`:875-879`) saves object state + marks nav dirty for terrain but **never calls `_director_visuals.mark_dirty()`**. Translating a `TriggerArea` via the gizmos (`StageGizmos.drag_to``end_drag``transform_committed`) therefore leaves the dashed connector at the stale position.
**Fix.** In `_on_transform_committed()` (`:875`), call `_director_visuals.mark_dirty()` whenever any moved node is a `TriggerArea` (or, simpler and cheap: unconditionally, since a moved stickman/prop also anchors rule lines/badges). Optionally also mark dirty **during** the drag for live-follow: add a lightweight `transform_dragged`/`transform_changed` signal or have `SandboxStage._handle_mouse_motion` (`:335`) call `mark_dirty()` while `_gizmos.is_dragging()`.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`: `_on_transform_committed` (`:875`).
- `scripts/stage_director_visuals.gd`: no change required (already recomputes live); optionally remove the `_dirty` gate and `queue_redraw()` every frame in `_process` for simplicity, but keep the gate (cheaper) and drive it via `mark_dirty`.
**Edge cases.** Rotating (not translating) an area also repositions its corners but `get_area_rect()` is axis-aligned around the node origin, so the connector anchor (`area.global_position`, `:265`) is unchanged by rotation — acceptable. Deleting a referenced area already triggers `_cleanup_rules_for_nodes``set_rules``mark_dirty` (`:1273-1278`).
---
### 2.9 Styling & theme JSON
**Behavior.** A single, hand-editable JSON config file drives: (1) the Direct-mode action/trigger popup font + emoji size (currently too small), (2) the assignment badge emoji size under stickmen/objects, (3) the grid snap size, and (4) configurable font names for the sandbox UI. All defaults live in the file; the stage loads it at `_ready()` and falls back to built-in constants if missing/malformed.
**Data/format changes (NEW file).** `res://sandbox_theme.json` (committed asset, editable in the editor or by hand). Concrete schema with defaults:
```json
{
"version": "1.0",
"fonts": {
"ui_font": "",
"emoji_font": "",
"action_popup_font_size": 24,
"action_popup_emoji_size": 22,
"assignment_badge_font_size": 20,
"assignment_badge_radius": 9,
"rule_label_font_size": 16,
"status_pill_font_size": 16,
"tooltip_font_size": 18
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
}
```
- `fonts.ui_font` / `fonts.emoji_font`: `res://` paths (e.g. `res://assets/fonts/...`). Empty string = `ThemeDB.fallback_font`. **No font assets exist yet**; loading an empty path or a missing file falls back to `ThemeDB.fallback_font` with a single `push_warning`.
- `action_popup_font_size` / `action_popup_emoji_size` → applied via `PopupMenu.add_theme_font_size_override("font_size", n)` on `_action_popup`, `_trigger_popup`, `_rule_action_popup`, `_rule_more_popup` (`sandbox_stage.gd:696-731`). Emoji size is the font size too (emoji render at the same size); if a dedicated `emoji_font` is set, apply `add_theme_font_override("font", emoji_font)`.
- `assignment_badge_font_size` / `assignment_badge_radius` → replaces `StageDirectorVisuals.ICON_SIZE_PX` (12.0) / `RULE_BADGE_RADIUS_PX` (7.0) and, for the order numbers, `NUMBER_FONT_SIZE_PX` (16.0) (`stage_director_visuals.gd:19-33`).
- `rule_label_font_size` → replaces `RULE_LABEL_FONT_SIZE_PX` (14.0) (`:30`).
- `status_pill_font_size` / `tooltip_font_size` → new badge/tooltip labels.
- `grid.snap_size` → the **default** grid size. Persistence model: keep the live user value in `user://sandbox_settings.json` (`grid_size`, `:1500-1527`) as the runtime source of truth; `sandbox_theme.json` supplies the **initial default** (and the clamp min/max stay `MIN_GRID_SIZE`/`MAX_GRID_SIZE`, `:52-53`). On first run (no `user://` file), seed `_grid_size` from the theme.
- `mode_colors` → drive the status pill (§2.4), the mode frame (§2.3), and the dashed guide line (§2.6.2). Parse via `Color(html_string)` / `Color.from_string` guarded with fallback constants.
**Affected files/functions.**
- **New file** `res://sandbox_theme.json` (the schema above).
- `scripts/sandbox_stage.gd`: new `const THEME_PATH := "res://sandbox_theme.json"`; `_load_theme()` called in `_ready()` (`:200`) before `_build_ui()`; store `_theme: Dictionary`; apply popup overrides in `_build_ui()`; push relevant values to `_director_visuals` and the new overlay.
- `scripts/stage_director_visuals.gd`: replace hardcoded font-size constants with instance vars set via a new `set_style(cfg: Dictionary)` (defaults = current constants), keeping the existing names as defaults so existing behavior is unchanged when no theme is present.
- `scripts/stage_grid.gd` (optional): `grid_alpha` for Direct-mode dimming.
**Edge cases.** Missing/malformed JSON → log once, use all defaults, do **not** crash. A referenced font file that doesn't exist → `push_warning` + fallback font. Unknown extra keys are ignored (forward-compatible). `Color.from_string` failures → fallback color.
---
### 2.10 Actions UX (cursor-attached tooltip, trajectory, ghost marker, action cursor)
**Behavior.** When a click-awaiting director step is active (`_pending_walk_target` for Walk To; the `RuleStep` steps `TRIGGER_TARGET`/`ACTION_TARGET`/`ACTION_POSITION` for "When… Trigger Area"/"When… Collision" and other rule steps), show the **combined workflow**:
1. **Custom action cursor** — swap to a flag/reticle cursor (see §2.5).
2. **Rubber-band dashed trajectory line** — from the stickman's feet (`rig.global_position - StickmanRig.FOOT_OFFSET`) to the cursor; green when the target is valid/reachable, red when invalid (off-reach / inside solid terrain). For "When…" flows the origin is the trigger/action anchor instead of the stickman.
3. **Ghost target marker** — a semi-transparent flag/reticle/footprint at the cursor, snapped to grid when Snap is on; optionally a pulsing floor ring.
4. **Cursor-attached floating tooltip pill** — a rounded high-contrast badge (orange/cyan) following the cursor, reading e.g. `🚩 Click to set walk target` + `[Esc to cancel]`, and for the rule steps `🎯 Click the trigger area` / `💥 Click the prop` etc. (reuse the existing `_rule_hint` strings at `sandbox_stage.gd:999-1003`, `:1019`, `:1141`).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_pending_walk_target` (`:157`), `_rule_step`/`_rule_hint` (`:176-180`), `_handle_direct_click` (`:895`), `_handle_rule_click`/`_handle_trigger_target_click`/`_handle_action_target_click`/`_handle_action_position_click` (`:1080-1169`).
- New overlay drawing + a code-built tooltip `PanelContainer`+`Label` in the `CanvasLayer`, positioned each frame in `_process()` at `get_viewport().get_mouse_position() + offset`, hidden unless a click-awaiting step is active. **Replace** the top-bar `"Click stage for walk target"` hint (`:621-622`) with the tooltip (or keep the bar text as a fallback).
- New helpers: `_is_awaiting_click() -> bool`, `_action_hint_text() -> String`, `_action_origin() -> Vector2`, `_is_target_valid(pos) -> bool`.
- `scripts/stage_director_visuals.gd` (or the new overlay): draw the trajectory line + ghost marker; needs the active pending target state, so **prefer a new overlay** owned by `SandboxStage` rather than overloading the director visuals.
**Edge cases.** Tooltip must not cover the cursor (offset ~1624 px right/up, flipping near screen edges). Esc must clear the tooltip, cursor, and line in one place (already centralized in `_unhandled_key_input` `:269-282`). The "valid/invalid" color requires a reachability check: reuse `StickmanRig.is_target_reachable` semantics but without a live agent — simplest is: green always during picking, red only when the point is inside a `TerrainBlock` AABB (testable via the grid dictionary / `StageSelection.get_world_aabb`); full nav reachability preview is deferred.
---
### 2.11 Walk-waypoint arrival jitter (bugfix)
Detailed analysis below (§3).
---
## 3. Walk-waypoint jitter — root-cause analysis & fix
### 3.1 Reproduce (from plan)
Place stickman → Direct → create a walk waypoint → Play → the stickman walks, then **jitters rapidly but slightly up/down at the waypoint** instead of stopping.
### 3.2 Verified code facts
- `StickmanRig.walk_to(target, speed)` (`stickman_rig.gd:1032-1060`) sets `_walk_target_feet`, plays `walk_left`/`walk_right`, `_walking = true`, `_walk_done = false`.
- `_update_walking(delta)` (`:1067-1118`) — per physics frame:
1. Map-sync guard: `NavigationServer2D.map_get_iteration_id(...) == 0 → return` (`:1076`).
2. `next_feet = _nav_agent.get_next_path_position()` (forces path update) (`:1084`).
3. `if _nav_agent.is_target_reachable():`**nav** branch: `if is_navigation_finished(): _finish_walk("finished"); return` else `root_target = next_feet + FOOT_OFFSET` (`:1086-1092`).
4. `else:`**direct** branch: `root_target = _walk_target_feet + FOOT_OFFSET` (`:1093-1099`).
5. `global_position = global_position.move_toward(root_target, _walk_speed_current * delta)` (`:1100`).
6. `if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE: _finish_walk("arrive"); return` (`:1101-1103`).
- Constants: `ARRIVE_DISTANCE = 8.0` (root-space) (`:160`), `NAV_PATH_DESIRED_DISTANCE = 8.0`, `NAV_TARGET_DESIRED_DISTANCE = 12.0` (feet-space) (`:161-162`), `FOOT_OFFSET = (0,-385)` (`:156`).
- `_finish_walk(reason)` (`:1121-1138`) stops the animation, `_restore_standing_markers()`, `_walking = false`, `_walk_done = true`, emits `arrived(_walk_target_feet)`.
- `_walk_mode` (`"nav"|"direct"`) is **recomputed every frame** from `is_target_reachable()` (`:337`, `:1086-1099`); nothing latches it.
- `walk_left`/`walk_right` key **`IK_Targets/Torso:position`** between `(0,10)` and `(0,-15)` — a ±12.5 px **vertical body bob** (`scripts/create_animations.gd:80`; baked in `master_rig.tscn` tracks `IK_Targets/Torso:position`, and the `.:facing_profile` track).
### 3.3 Root-cause hypothesis (ranked; confirm at runtime with `DEBUG_WALK`)
1. **Mode-flip oscillation (primary).** Because `_walk_mode` is re-evaluated every frame and a clicked waypoint frequently sits **at/near the nav-mesh boundary** (the nav mesh is only the placed terrain polygons — `_rebake_navigation` `:798` — so a waypoint clicked in open space or just off an edge is borderline), `is_target_reachable()` can flip between `true` and `false` across consecutive frames while the agent moves. Each flip swaps `root_target` between:
- nav: `next_feet + FOOT_OFFSET` (clamped to the terrain-surface Y), and
- direct: `_walk_target_feet + FOOT_OFFSET` (the raw clicked Y).
Two targets with a small **vertical** offset → the rig visibly jitters up/down until the `ARRIVE_DISTANCE` guard finally trips.
2. **Arrival-radius mismatch + intermediate-point re-targeting (secondary).** `is_navigation_finished()` triggers at `NAV_TARGET_DESIRED_DISTANCE` (12 px, feet-space) while the hard arrival guard is `ARRIVE_DISTANCE` (8 px, root-space). Near the destination `get_next_path_position()` can return a point at/behind the agent, so `move_toward` steps can reverse direction (micro-oscillation). The two different radii mean the walk can terminate early ("finished" at 12 px) *or* chase a now-behind `next_feet` point.
3. **Body-bob frame interaction (visual, tertiary).** The walk animation bobs `IK_Targets/Torso` ±12.5 px each cycle. If the arrival frame's `_anim_player.stop()` (keep_state=false resets to the walk animation's first keyframe) races `_restore_standing_markers()` (writes exact `STAND_POSE`), there can be a brief vertical pop — small-amplitude and "up/down", matching the report.
### 3.4 Concrete fix approach
All three are addressed with small, contained changes to `stickman_rig.gd`:
1. **Latch the mode once per walk.** In `walk_to()` (or on the first post-sync frame), compute `_walk_mode` **once** (`is_target_reachable()`), store it, and stop re-evaluating per frame in `_update_walking`. Optionally allow a one-way upgrade nav→direct only (never direct→nav) to keep steering robust if the path later empties.
2. **Unified arrival radius against the FINAL target.** In both branches, once `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE`, call `_finish_walk("arrive")` **and** snap `global_position = _walk_target_feet + FOOT_OFFSET` before restoring markers (removes any residual offset). Drop the premature `_finish_walk("finished")` early-return, or keep it only when the rig is *also* within `ARRIVE_DISTANCE`.
3. **Steer to the final target when close.** When within e.g. `2 * ARRIVE_DISTANCE` of the final target, ignore `next_feet` and move directly toward `_walk_target_feet + FOOT_OFFSET` (prevents chasing a behind-path point).
4. **Make stop/restore atomic.** In `_finish_walk`, call `_anim_player.stop()` then `_restore_standing_markers()` (already ordered correctly), and add a one-frame re-assert (`_restore_standing_markers()` again next physics frame if `_walk_done`) if the trace shows a residual bob. Keep `DEBUG_WALK` (`:167`) prints to capture `mode`, `dist`, `next`, `final` at arrival.
**Verification:** enable `DEBUG_WALK` and `DEBUG_STAGE`, reproduce with (a) a waypoint on flat ground, (b) a waypoint in open space off the terrain, (c) a waypoint exactly on a terrain edge. The fix is verified when `mode` stays constant for the whole walk and exactly one `arrive` fires with no post-arrival position change.
---
## 4. Implementation order / checklist (grouped by workstream)
> Order is dependency-aware; each workstream is independently testable.
**WS0 — Theme JSON foundation (do first; everything else reads it).**
- [ ] Add `res://sandbox_theme.json` (§2.9 schema).
- [ ] `SandboxStage._load_theme()` + `_theme` var; call in `_ready()` before `_build_ui()`.
- [ ] `StageDirectorVisuals.set_style(cfg)` (defaults = current constants).
- [ ] Headless test: missing file / malformed JSON → defaults, no crash.
**WS1 — UI chrome.**
- [ ] Bottom status bar + `_status_cursor_coords` + `_process` polling (§2.1).
- [ ] `StageMode { EDIT, DIRECT, PLAY }` refactor + 3-segment control + `_enter_direct_mode()` (§2.2).
- [ ] `_set_edit_controls_visible` / `_set_direct_controls_visible` split (§2.2).
- [ ] Viewport mode frame + grid visibility per mode (§2.3).
- [ ] Mode badge pill + `_refresh_mode_badge()` (§2.4).
- [ ] Per-mode cursor `_apply_cursor()` (§2.5).
**WS2 — Terrain placement.**
- [ ] Grid spatial dictionary (`_grid_cells`, `_rasterize_aabb_to_cells`, populate/update/delete paths) (§2.6.5).
- [ ] `TerrainBlock` spawn-id tagging + `StageSpawner.get_template_aabb(id)` (§2.6.4).
- [ ] Drag anchor/target + Shift cardinal lock (§2.6.1).
- [ ] Bresenham path + per-cell ghost array + 3-state tint (§2.6.22.6.4).
- [ ] Batch commit + single `_nav_dirty` (§2.6.5).
- [ ] RMB ends placement; LMB keeps repeated placement (§2.7).
- [ ] Guide-line overlay (new `scripts/stage_placement_overlay.gd`).
**WS3 — Director UX.**
- [ ] `_on_transform_committed``_director_visuals.mark_dirty()` (+ live-drag dirty) (§2.8).
- [ ] Cursor-attached tooltip pill + `_is_awaiting_click`/`_action_hint_text` (§2.10).
- [ ] Rubber-band trajectory + ghost marker + green/red validity (§2.10).
- [ ] Flag/reticle action cursor (§2.10, shares §2.5 infra).
- [ ] Apply popup font/emoji overrides from theme (§2.9).
**WS4 — Bugfix.**
- [ ] `StickmanRig._update_walking` mode latch + unified arrival + snap-on-arrive + atomic stop (§3.4).
- [ ] `DEBUG_WALK` capture for the tester.
**WS5 — Docs.**
- [ ] Update `README.md` §1820 (mode switcher, status bar, theme JSON, terrain painting, jitter fix).
- [ ] Append entries to `docs/tech_debt_and_optimizations.md` (grid dictionary addresses #14; walk-mode latch note).
---
## 5. Testing plan
There is **no CLI build/test/lint**; the project is run in the Godot editor (F5/F6). The established **headless assertion pattern** (verified in `tests/test_text_baseline_fix.gd`) is:
```
& "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/<name>.gd --path .
```
The script `extends SceneTree`, prints `PASS/FAIL` per assertion, and `quit(0/1)`. The tester agent will author such scripts under `tests/`. Manual F6 verification on `res://scenes/sandbox_stage.tscn` is also required for visual/interaction features.
**Behaviors that must be verified:**
1. **Theme JSON** — missing file → defaults; malformed → defaults + single warning; overridden `action_popup_font_size` actually changes `PopupMenu` font size; `grid.snap_size` seeds `_grid_size` on first run.
2. **Status bar**`_status_cursor_coords` updates each frame with world coords; pans/zooms reflect in the numbers.
3. **Mode switcher** — 3 segments; Edit shows spawners+grid, Direct hides them, Play hides them; `mode_changed` emits 0/1/2; entering Direct from Play and vice-versa clears pending state; Esc from Direct → Edit.
4. **Mode frame/badge/cursor** — correct accent per mode; frame never intercepts input; cursor shape correct per mode and restored after cancel.
5. **Terrain painting** — Case A/B/C from §2.6.4 produce the expected block counts; Shift lock yields axis-aligned runs with no diagonals; batch commit spawns all blocks in one frame (nav baked once); same-type overlap skips; conflicting-object cells are red and skipped; LMB ends placement and toggles the button off.
6. **Grid dictionary** — moving/deleting a block updates occupancy; querying a cell returns correct classification; no stale entries after delete.
7. **Trigger-area move** — translating an area whose rule exists moves the dashed connector (and `⚡` badge) to the new position on drag end (and, if implemented, live during drag).
8. **Director tooltip/trajectory** — pending walk target shows tooltip + dashed line + ghost marker; Esc clears all three; rule-step hints use the tooltip; green/red validity for in-terrain target.
9. **Walk jitter** — with `DEBUG_WALK` on, `mode` stays constant for the whole walk; exactly one `arrived` fires; rig `global_position` is unchanged after arrival for 60+ physics frames; body-bob stops (Torso marker at `(0,10)`).
10. **Regression** — sequential queues, reactive rules (`entered_area`, `collided`), ragdoll/recover, and prop unfreeze still work (Phase 3a/4 acceptance).
---
## 6. Risks & open questions
### Risks
1. **Mode refactor scope.** Folding `_direct_mode` into a 3-value `StageMode` touches input routing (`_handle_world_click`, `_handle_mouse_motion`, Esc ordering), status, and the visuals enable/disable paths. Low logic risk but broad; mitigate by keeping Direct internally an "Edit-with-direct" state (a thin `_enter_direct_mode()` that reuses `_enter_edit_mode()` side effects).
2. **Grid spatial dictionary correctness.** Rasterizing arbitrary AABBs (rotated/oversized props/areas) to cells is the main new algorithm; a wrong rasterization causes wrong 3-state tints. Keep the dictionary **advisory** (visual tint + skip) and never authoritative for physics; always re-derive from `World` when in doubt.
3. **Custom cursor asset.** No image assets exist. Generating a reticle/flag via `Image.create()`/`ImageTexture` at runtime avoids asset dependency; otherwise a `res://assets/` addition is required.
4. **Bresenham in world vs cell space.** Terrain cells are `TERRAIN_GRID_SIZE=16` but the stage grid is `_grid_size` (default 15). Decide which grid drives terrain painting (see Open Questions) — mixing them produces misaligned ghosts.
### Open questions (resolved by user 2026-09-02)
1. **Terrain painting grid.****Terrain grid size 16** (`StageSpawner.TERRAIN_GRID_SIZE`). Terrain cells quantize to 16, matching terrain template dimensions so blocks align edge-to-edge; the user stage grid (`_grid_size`) remains a separate visual/snap aid.
2. **"Build cancelling" vs repeated placement.** → **RMB activates build cancelling** (the plan's "LMB" was a typo). LMB keeps Phase 2 repeated placement; RMB/Esc put the tool down. §2.7 updated accordingly.
3. **Play toolbar "Pause/Restart".****Out of scope for Phase 4b.** Play keeps only the mode switcher; note as future work.
4. **Direct toolbar contents.****Hint label only.** The director stays click/popup-driven; the Direct toolbar segment shows a brief instruction hint.
5. **Grid visibility in Direct mode.****Hide the grid entirely** in Direct (amber frame only). §2.3 updated accordingly.
6. **Theme JSON location/persistence split.****Accepted:** `res://sandbox_theme.json` = hand-editable styling defaults; `user://sandbox_settings.json` = live persisted values (grid size, snap, etc.).
7. **Jitter fix confirmation.****Yes** — developer may enable `DEBUG_WALK`/`DEBUG_STAGE` during the fix and must revert both to OFF before merge.
---
## 7. Phase 4b.1 bugfix decisions (2026-09-02, post-implementation triage)
> Recorded per the fix-pipeline triage of 5 user-reported bugs. Bug 1 was a **Spec/Design
> Defect** — the resolved open-question #1 encoded a false premise. The other four are
> Implementation Defects and need no spec change beyond this record.
### Decision D1 — Terrain paint stride = template extent (Bug 1)
The earlier decision ("terrain cells quantize to `TERRAIN_GRID_SIZE` 16, matching terrain
template dimensions") is **factually wrong**: templates are Ground 200×32, Ramp 192×128,
Step 256×256 px, so stamping one full block per 16-px Bresenham cell causes massive
overlap.
**New rule (supersedes open-question #1):** terrain drag-painting quantizes to **block
units** whose stride is the active template's AABB extent per axis
(`StageSpawner.get_template_aabb(id).size`, e.g. Ground → `(200, 32)`). Cell centers are
`block_cell * stride`; Bresenham runs over block units.
- **Horizontal/vertical runs (incl. Shift-locked):** blocks tile edge-to-edge, no overlap,
no gaps.
- **Free diagonals:** blocks tile corner-to-corner (adjacent diagonal blocks share exactly
a corner point — zero overlap, visually acceptable corner gaps).
- The 16-px `_grid_cells` dictionary remains **advisory only** (§2.6.5) for the 3-state
occupancy query and already rasterizes real AABBs.
- In-drag same-type self-overlap: mark freshly painted block cells in the dictionary (or a
transient in-drag set) so a drag crossing its own path skips re-stamping (§2.6.4 Case B).
### Bug 2/4/5 decisions (implementation only, no design change)
- Bug 2 (guide line persists after release) and Bug 4 (single-placement guide circles) are
overlay redraw/state defects in `stage_placement_overlay.gd` + `_update_terrain_drag`
fix: `queue_redraw()` on clear; suppress the guide when `target == anchor` (single click).
- Bug 5 (no cursor-following terrain ghost) is a Phase 2 regression — `_spawn_ghost()`
must allow terrain again; drag start frees the single ghost, drag end re-spawns it
(LMB-repeated placement preserved).
- Bug 3 (residual waypoint jitter) extends §3.4 without changing its intent: add a
nav-termination condition (`is_navigation_finished()` gated on
`dist_to_final <= 2*ARRIVE_DISTANCE`) and remove vertical drift on final approach.
+23
View File
@@ -0,0 +1,23 @@
{
"version": "1.0",
"fonts": {
"ui_font": "",
"emoji_font": "",
"action_popup_font_size": 24,
"action_popup_emoji_size": 22,
"assignment_badge_font_size": 20,
"assignment_badge_radius": 9,
"rule_label_font_size": 16,
"status_pill_font_size": 16,
"tooltip_font_size": 18
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
}
+81
View File
@@ -0,0 +1,81 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/asset_selector.gd" id="1_selector"]
[node name="AssetSelector" type="PopupPanel"]
title = "Asset Selector"
size = Vector2i(660, 560)
script = ExtResource("1_selector")
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TitleBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TitleBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 18
text = "Choose"
[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/TitleBar"]
unique_name_in_owner = true
layout_mode = 2
text = "×"
[node name="GridContainer" type="GridContainer" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 8
columns = 4
[node name="EmptyLabel" type="Label" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
visible = false
size_flags_vertical = 3
text = "No stickmen found! Create one in the editor first."
horizontal_alignment = 1
vertical_alignment = 1
[node name="Footer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
alignment = 1
[node name="PrevButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "← Prev"
[node name="PageLabel" type="Label" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "Page 1/1"
[node name="NextButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "Next →"
[node name="BrowseButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "Browse…"
[node name="RefreshButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "Refresh"
+217
View File
@@ -0,0 +1,217 @@
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
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
const THUMB_SIZE := Vector2(120.0, 120.0)
var kind: String = ""
var _entries: Array[Dictionary] = []
var _page: int = 0
var _thumbnails: Dictionary = {}
var _cell_texrects: Dictionary = {}
var _placeholder_tex: ImageTexture = null
var _ui_font: Font = null
var _emoji_font: Font = null
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@onready var _empty_label: Label = %EmptyLabel
@onready var _page_label: Label = %PageLabel
@onready var _prev_button: Button = %PrevButton
@onready var _next_button: Button = %NextButton
@onready var _browse_button: Button = %BrowseButton
@onready var _refresh_button: Button = %RefreshButton
@onready var _close_button: Button = %CloseButton
func _ready() -> void:
exclusive = true
_prev_button.pressed.connect(_on_prev)
_next_button.pressed.connect(_on_next)
_browse_button.pressed.connect(func() -> void: browse_requested.emit())
_refresh_button.pressed.connect(func() -> void: refresh_requested.emit())
_close_button.pressed.connect(func() -> void: cancelled.emit())
get_tree().root.size_changed.connect(_on_root_size_changed)
func _on_root_size_changed() -> void:
if visible:
popup_centered()
func open(p_kind: String, entries: Array[Dictionary]) -> void:
kind = p_kind
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
_browse_button.visible = kind == "stickman"
_refresh_button.visible = kind == "stickman"
set_entries(entries)
popup_centered()
func set_entries(entries: Array[Dictionary]) -> void:
_entries = entries
_thumbnails.clear()
_cell_texrects.clear()
_page = 0
_rebuild()
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
var key := _entry_key(entry)
_thumbnails[key] = tex
if _cell_texrects.has(key):
(_cell_texrects[key] as TextureRect).texture = tex
func close() -> void:
hide()
func apply_font(ui_font: Font, emoji_font: Font) -> void:
_ui_font = ui_font
_emoji_font = emoji_font
var controls: Array = [
_title_label, _empty_label, _page_label,
_prev_button, _next_button, _browse_button, _refresh_button, _close_button,
]
for c: Control in controls:
_apply_font_to(c)
func _unhandled_input(event: InputEvent) -> void:
if visible and event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
get_viewport().set_input_as_handled()
cancelled.emit()
static func page_bounds(total: int, page: int, page_size: int = PAGE_SIZE) -> Dictionary:
var page_count := maxi(1, int(ceil(float(total) / float(page_size))))
var start := page * page_size
if start >= total:
start = 0
var end := mini(start + page_size, total)
return { "start": start, "end": end, "total": total, "page_count": page_count }
func _entry_key(entry: Dictionary) -> String:
if kind == "stickman":
return String(entry.get("path", ""))
return String(entry.get("id", ""))
func _on_prev() -> void:
if _page > 0:
_page -= 1
_rebuild()
func _on_next() -> void:
if (_page + 1) * PAGE_SIZE < _entries.size():
_page += 1
_rebuild()
func _page_count() -> int:
if _entries.is_empty():
return 1
return int(ceil(float(_entries.size()) / float(PAGE_SIZE)))
func _rebuild() -> void:
for c: Node in _grid.get_children():
c.queue_free()
_cell_texrects.clear()
_empty_label.visible = _entries.is_empty()
_grid.visible = not _entries.is_empty()
var total := _page_count()
var single := total <= 1
_prev_button.visible = not single
_next_button.visible = not single
_page_label.visible = not single
_prev_button.disabled = _page <= 0
_next_button.disabled = (_page + 1) * PAGE_SIZE >= _entries.size()
_page_label.text = "Page %d/%d" % [_page + 1, total]
if _entries.is_empty():
return
var bounds := page_bounds(_entries.size(), _page)
for i: int in range(int(bounds["start"]), int(bounds["end"])):
_grid.add_child(_build_cell(_entries[i]))
func _build_cell(entry: Dictionary) -> Control:
var key := _entry_key(entry)
var cell := Button.new()
cell.custom_minimum_size = CELL_MIN_SIZE
cell.toggle_mode = false
cell.focus_mode = Control.FOCUS_NONE
cell.pressed.connect(_on_cell_pressed.bind(entry))
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_theme_constant_override("separation", 2)
cell.add_child(vbox)
var tex_rect := TextureRect.new()
tex_rect.custom_minimum_size = THUMB_SIZE
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
var tex: Texture2D = _thumbnails.get(key, null)
tex_rect.texture = tex if tex != null else _get_placeholder()
vbox.add_child(tex_rect)
_cell_texrects[key] = tex_rect
var name_label := Label.new()
name_label.text = String(entry.get("name", ""))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_label.add_theme_font_size_override("font_size", 14)
_apply_font_to(name_label)
vbox.add_child(name_label)
if kind == "prop":
var badge := Label.new()
badge.text = String(entry.get("material_label", ""))
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge.add_theme_font_size_override("font_size", 12)
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
_apply_font_to(badge)
vbox.add_child(badge)
return cell
func _on_cell_pressed(entry: Dictionary) -> void:
item_selected.emit(entry)
func _apply_font_to(c: Control) -> void:
if c == null:
return
if _ui_font != null:
c.add_theme_font_override("font", _ui_font)
elif _emoji_font != null:
c.add_theme_font_override("font", _emoji_font)
func _get_placeholder() -> ImageTexture:
if _placeholder_tex == null:
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8)
img.fill(Color(0.16, 0.16, 0.16, 1.0))
_placeholder_tex = ImageTexture.create_from_image(img)
return _placeholder_tex
+1
View File
@@ -0,0 +1 @@
uid://b1ikg6vt3tiu3
+50
View File
@@ -0,0 +1,50 @@
class_name PropLibrary
extends RefCounted
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
static var _templates: Array[Dictionary] = []
static func get_entries() -> Array[Dictionary]:
if _templates.is_empty():
_build_templates()
return _templates
static func get_ids() -> Array[String]:
var ids: Array[String] = []
for t: Dictionary in get_entries():
ids.append(String(t["id"]))
return ids
static func get_entry(id: String) -> Dictionary:
for t: Dictionary in get_entries():
if String(t["id"]) == id:
return t
return {}
static func get_default_id() -> String:
return "crate"
static func _build_templates() -> void:
_templates = [
_make("crate", "Crate", PROP_BLOCK.MaterialPreset.WOOD, "Wood", PROP_UTILS.create_box()),
_make("ball", "Ball", PROP_BLOCK.MaterialPreset.RUBBER, "Rubber", PROP_UTILS.create_ball()),
_make("plank", "Plank", PROP_BLOCK.MaterialPreset.METAL, "Metal", PROP_UTILS.create_plank()),
_make("triangle", "Triangle", PROP_BLOCK.MaterialPreset.CARDBOARD, "Cardboard", PROP_UTILS.create_triangle()),
]
static func _make(id: String, name: String, preset: int, label: String, payload: Dictionary) -> Dictionary:
return {
"id": id,
"name": name,
"material_preset": preset,
"material_label": label,
"payload": payload,
}
+1
View File
@@ -0,0 +1 @@
uid://b0s2ncqoqflus
+1032 -85
View File
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -37,6 +37,14 @@ var world: Node2D = null
var enabled: bool = true
var _dirty: bool = true
## Phase 4b style overrides (set via set_style from sandbox_theme.json). Defaults
## equal the constants above so behavior is unchanged when no theme is present.
var badge_icon_size: float = ICON_SIZE_PX
var badge_number_size: float = NUMBER_FONT_SIZE_PX
var badge_radius: float = RULE_BADGE_RADIUS_PX
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
var emoji_font: Font = null
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
var rules: Array[Dictionary] = []
@@ -50,6 +58,19 @@ func set_enabled(value: bool) -> void:
func mark_dirty() -> void:
_dirty = true
## Applies a theme dictionary (sandbox_theme.json §fonts) onto the badge/label
## sizes. Missing keys fall back to the current constants so behavior is
## unchanged when no theme is present.
func set_style(cfg: Dictionary) -> void:
var fonts: Dictionary = cfg.get("fonts", {})
badge_icon_size = float(fonts.get("assignment_badge_font_size", ICON_SIZE_PX))
badge_number_size = float(fonts.get("assignment_badge_font_size", NUMBER_FONT_SIZE_PX))
badge_radius = float(fonts.get("assignment_badge_radius", RULE_BADGE_RADIUS_PX))
rule_label_font_size = float(fonts.get("rule_label_font_size", RULE_LABEL_FONT_SIZE_PX))
mark_dirty()
func set_rules(r: Array[Dictionary]) -> void:
rules = r
mark_dirty()
@@ -144,10 +165,10 @@ func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
draw_string(ThemeDB.fallback_font, pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(NUMBER_FONT_SIZE_PX / zoom), NUMBER_COLOR)
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
var s := ICON_SIZE_PX / zoom
var s := badge_icon_size / zoom
match type:
"speak":
var bw := s * 1.6
@@ -221,7 +242,7 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
var summary := rule_summary(rule)
var mid := (trigger_anchor + action_anchor) * 0.5
var font := ThemeDB.fallback_font
var font_size := int(RULE_LABEL_FONT_SIZE_PX / zoom)
var font_size := int(rule_label_font_size / zoom)
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var padding := Vector2(6.0, 4.0) / zoom
var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0)
@@ -242,15 +263,20 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color) -> void:
var radius := RULE_BADGE_RADIUS_PX / zoom
var radius := badge_radius / zoom
draw_circle(anchor, radius, color)
draw_arc(anchor, radius, 0.0, TAU, 32, Color.WHITE, 2.0 / zoom, true)
var font := ThemeDB.fallback_font
var font_size := int(ICON_SIZE_PX / zoom)
var font := _badge_font()
var font_size := int(badge_icon_size / zoom)
var glyph_size := font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
draw_string(font, anchor + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.5), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
## Badge glyph font: the configured emoji font when set, else the fallback font.
func _badge_font() -> Font:
return emoji_font if emoji_font != null else ThemeDB.fallback_font
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
## entered_area (or source pos fallback), source position otherwise. Vector2.INF
## on unresolved source.
+1 -1
View File
@@ -166,7 +166,7 @@ func _draw() -> void:
if not _enabled:
return
var zoom := _zoom()
if _hovered != null and is_instance_valid(_hovered) and not _targets.has(_hovered):
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
# A selection outline for every selected object.
for node: Node2D in _targets:
+106
View File
@@ -0,0 +1,106 @@
class_name StagePlacementOverlay
extends Node2D
## StagePlacementOverlay - Phase 4b world-space overlay for the Sandbox Stage.
##
## Draws (1) the terrain drag-painting guide line between the anchor and target
## cells and (2) the director action rubber-band trajectory + ghost target
## marker while a click-awaiting step is active. Pure drawing; no hit-testing.
## Sits above the World and ghost holder, below the CanvasLayer UI.
var camera: Camera2D = null
## Terrain guide-line state.
var terrain_guide_visible: bool = false
var terrain_anchor: Vector2 = Vector2.ZERO
var terrain_target: Vector2 = Vector2.ZERO
var guide_line_color: Color = Color("#22c6ff")
## Director action-trajectory state.
var action_visible: bool = false
var action_origin: Vector2 = Vector2.ZERO
var action_target: Vector2 = Vector2.ZERO
var action_valid: bool = true
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
const DASH_LENGTH_PX := 8.0
const DASH_GAP_PX := 5.0
const LINE_WIDTH_PX := 2.0
const MARKER_RADIUS_PX := 10.0
func _process(_delta: float) -> void:
if terrain_guide_visible or action_visible:
queue_redraw()
func set_terrain_guide(anchor: Vector2, target: Vector2) -> void:
terrain_guide_visible = true
terrain_anchor = anchor
terrain_target = target
func clear_terrain_guide() -> void:
terrain_guide_visible = false
queue_redraw()
func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> void:
action_visible = true
action_origin = origin
action_target = target
action_valid = valid
func clear_action() -> void:
action_visible = false
queue_redraw()
func _draw() -> void:
if terrain_guide_visible:
_draw_dashed(terrain_anchor, terrain_target, guide_line_color)
_draw_cell_marker(terrain_anchor, guide_line_color)
_draw_cell_marker(terrain_target, guide_line_color)
if action_visible:
var color := valid_color if action_valid else invalid_color
_draw_dashed(action_origin, action_target, color)
_draw_ghost_marker(action_target, color)
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
var zoom := _zoom()
var dash := DASH_LENGTH_PX / zoom
var gap := DASH_GAP_PX / zoom
var dir := from.direction_to(to)
var total := from.distance_to(to)
var dist := 0.0
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, color, LINE_WIDTH_PX / zoom, true)
dist += dash + gap
func _draw_cell_marker(pos: Vector2, color: Color) -> void:
var zoom := _zoom()
var r := MARKER_RADIUS_PX / zoom
draw_arc(pos, r, 0.0, TAU, 24, color, LINE_WIDTH_PX / zoom, true)
## Semi-transparent flag/ring at the target point: a filled soft circle plus a
## crosshair so the drop location reads clearly under the cursor.
func _draw_ghost_marker(pos: Vector2, color: Color) -> void:
var zoom := _zoom()
var r := MARKER_RADIUS_PX / zoom
var fill := Color(color.r, color.g, color.b, 0.25)
draw_circle(pos, r, fill)
draw_arc(pos, r, 0.0, TAU, 32, color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(-r, 0.0), pos + Vector2(r, 0.0), color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(0.0, -r), pos + Vector2(0.0, r), color, LINE_WIDTH_PX / zoom, true)
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
return 1.0
+1
View File
@@ -0,0 +1 @@
uid://ehmg2htvh4xt
+9
View File
@@ -59,6 +59,15 @@ func clear_selection() -> void:
selection_changed.emit(_selected.duplicate())
## Clears the hovered node (e.g. when it is about to be deleted), emitting
## hover_changed(null) so the gizmo layer drops its stale highlight.
func clear_hover() -> void:
if _hovered == null:
return
_hovered = null
hover_changed.emit(null)
func select_only(node: Node2D) -> void:
_selected = [node]
_primary = node
+61 -16
View File
@@ -14,6 +14,7 @@ extends RefCounted
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
@@ -36,7 +37,9 @@ const TERRAIN_GRID_SIZE: float = 16.0
var _world: Node2D
var _registry: Array[Dictionary] = []
var _stickman_data: Dictionary = {}
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH
var selected_prop_id: String = "crate"
var _stickman_cache: Dictionary = {}
# ---------------------------------------------------------------------------
# Lifecycle
@@ -44,8 +47,8 @@ var _stickman_data: Dictionary = {}
func _init(world: Node2D) -> void:
_world = world
_stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if _stickman_data.is_empty():
_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if (_stickman_cache[DEFAULT_STICKMAN_PATH] as Dictionary).is_empty():
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
_build_registry()
@@ -72,6 +75,47 @@ func get_spawn_offset(id: String) -> Vector2:
return entry.get("spawn_offset", Vector2.ZERO)
func get_selected_stickman_path() -> String:
return selected_stickman_path
func get_selected_prop_id() -> String:
return selected_prop_id
## True when `id` names a terrain template (ground/ramp/step).
func is_terrain_id(id: String) -> bool:
var entry := _find_entry(id)
return String(entry.get("kind", "")) == "terrain"
## Local-space AABB of a terrain template's centered polygon (before placement),
## used to size ghosts and rasterize the terrain-painting occupancy cells.
## The AABB is computed over the SAME sanitized geometry that `_spawn_terrain`
## actually places (TerrainUtils.sanitize_points at TERRAIN_GRID_SIZE), so the
## D1 paint stride matches the real block footprint (e.g. the 200px-wide Ground
## template sanitizes to a 192px footprint and therefore a 192px stride, which
## makes horizontal runs tile edge-to-edge with no gaps).
func get_template_aabb(id: String) -> Rect2:
var entry := _find_entry(id)
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
return Rect2()
var template: PackedVector2Array = entry["points"]
if template.is_empty():
return Rect2()
var center := _points_center(template)
var centered := PackedVector2Array()
for p: Vector2 in template:
centered.append(p - center)
var cleaned := TERRAIN_UTILS.sanitize_points(centered, TERRAIN_GRID_SIZE)
if cleaned.is_empty():
return Rect2()
var rect := Rect2(cleaned[0], Vector2.ZERO)
for p: Vector2 in cleaned:
rect = rect.expand(p)
return rect
## Spawn the registry type at `world_position`; returns null + push_warning on
## an unknown id.
func spawn(id: String, world_position: Vector2) -> Node2D:
@@ -167,13 +211,7 @@ func _build_registry() -> void:
"spawn_offset": Vector2.ZERO,
},
{
"id": "crate", "label": "Crate", "kind": "prop",
"payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD,
"spawn_offset": Vector2.ZERO,
},
{
"id": "ball", "label": "Ball", "kind": "prop",
"payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER,
"id": "prop", "label": "Prop", "kind": "prop",
"spawn_offset": Vector2.ZERO,
},
{
@@ -214,13 +252,16 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
float(entry.get("width", 2.0))
)
block.position = world_position
block.spawn_id = String(entry.get("id", ""))
return block
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD))
return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO)
var t: Dictionary = PROP_LIBRARY.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)
func _spawn_area(world_position: Vector2) -> Node2D:
@@ -232,10 +273,14 @@ func _spawn_area(world_position: Vector2) -> Node2D:
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
if _stickman_data.is_empty():
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
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(_stickman_data)
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
+67
View File
@@ -0,0 +1,67 @@
class_name StickmanLibrary
extends RefCounted
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
const STICKMEN_DIR := "res://stickmen"
var entries: Array[Dictionary] = []
func scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]:
entries = _scan_dir(dir_path)
return entries
func get_entries() -> Array[Dictionary]:
return entries
func find_by_path(path: String) -> Dictionary:
for e: Dictionary in entries:
if String(e.get("path", "")) == path:
return e
return {}
func make_entry(path: String) -> Dictionary:
var data: Dictionary = STICKMAN_FACTORY.load_stk(path)
if data.is_empty() or not data.has("body_parts"):
if not data.is_empty():
push_warning("StickmanLibrary: skipped '%s' (missing body_parts)." % path)
return {}
var name := String(data.get("stickman_name", "")).strip_edges()
if name.is_empty():
name = path.get_file().get_basename()
return { "path": path, "name": name, "data": data }
func _scan_dir(dir_path: String) -> Array[Dictionary]:
if not DirAccess.dir_exists_absolute(dir_path):
push_warning("StickmanLibrary: stickmen dir '%s' not found." % dir_path)
return []
var dir := DirAccess.open(dir_path)
if dir == null:
push_warning("StickmanLibrary: failed to open '%s'." % dir_path)
return []
var files: Array[String] = []
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not dir.current_is_dir() and fname.get_extension().to_lower() == "stk":
files.append(dir_path + "/" + fname)
fname = dir.get_next()
dir.list_dir_end()
var built: Array[Dictionary] = []
for path: String in files:
var entry := make_entry(path)
if not entry.is_empty():
built.append(entry)
built.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var na := String(a["name"])
var nb := String(b["name"])
if na != nb:
return na < nb
return String(a["path"]) < String(b["path"])
)
return built
+1
View File
@@ -0,0 +1 @@
uid://d1s0xetl4uics
+88 -13
View File
@@ -160,6 +160,9 @@ const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
const NAV_TARGET_DESIRED_DISTANCE := 12.0
## How many post-sync physics frames to wait for the nav agent's reachability
## flag before deciding the walk is genuinely off-mesh (latch "direct").
const LATCH_PROBE_MAX_FRAMES := 6
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
@@ -335,6 +338,9 @@ var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_done: bool = false
var _ragdoll_at_rest: bool = false
@@ -412,6 +418,7 @@ func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
@@ -1058,6 +1065,12 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
_anim_player.play(anim_name)
_walking = true
_walk_done = false
# The nav/direct steering mode is latched once per walk (on the first
# post-sync frame) so it cannot flip between frames and oscillate the rig.
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
func is_walking() -> bool:
@@ -1076,29 +1089,61 @@ func _update_walking(delta: float) -> void:
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Latch the steering mode once the map is synced, so a waypoint that sits
# near the mesh boundary can't flip nav<->direct between frames (that flip
# swaps between two vertically-offset targets and reads as up/down jitter).
# Reachability only becomes meaningful a frame or two AFTER the map syncs and
# a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES:
# latch "nav" as soon as the agent reports the target reachable; if it never
# does within the bound (genuinely off-mesh), latch "direct".
if not _walk_mode_latched:
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
_walk_latch_probe_frames += 1
_nav_agent.get_next_path_position()
if _nav_agent.is_target_reachable():
_walk_mode_latched = true
_walk_mode = "nav"
_walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames)
else:
_walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames)
return
else:
_walk_mode_latched = true
_walk_mode = "direct"
_walk_dbg("latch mode=direct (probe bound reached)")
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever the
# stored path is empty (set_target_position resets it via _request_repath).
# The read-only get_current_navigation_path() accessor alone never triggers a
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var final_root := _walk_target_feet + FOOT_OFFSET
var dist_to_final := global_position.distance_to(final_root)
var root_target: Vector2
if _nav_agent.is_target_reachable():
# NAV branch: the target lies on the mesh — follow the path to it.
_walk_mode = "nav"
if _nav_agent.is_navigation_finished():
_finish_walk("finished")
if _walk_mode == "nav":
# Terminate once the nav agent reports its path complete AND the rig is
# close enough: the agent finishes at target_desired_distance (12 px,
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
# last stale next-waypoint would oscillate the rig around the 16-px band.
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
global_position = final_root
_finish_walk("arrive")
return
root_target = next_feet + FOOT_OFFSET
# Near the destination, ignore the (possibly behind-path) next waypoint
# and steer straight at the final target so the rig cannot reverse.
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
root_target = final_root
else:
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
# steer straight at the clicked point, ignoring the nav mesh.
if _walk_mode != "direct":
_walk_dbg("off-mesh waypoint: switching to direct steering")
_walk_mode = "direct"
root_target = _walk_target_feet + FOOT_OFFSET
# DIRECT branch: an off-mesh waypoint is a supported case — steer
# straight at the clicked point, ignoring the nav mesh.
root_target = final_root
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
# Unified arrival radius against the FINAL target, in both branches. Snap
# the residual offset away so the rig lands exactly on the waypoint.
if dist_to_final <= ARRIVE_DISTANCE:
global_position = final_root
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
@@ -1118,6 +1163,15 @@ func _update_walking(delta: float) -> void:
])
## Re-asserts the standing pose one extra physics frame after a walk ends, so
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left
## on the markers when the animation stop and marker restore race.
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_restore_standing_markers()
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
@@ -1132,6 +1186,7 @@ func _finish_walk(reason: String = "") -> void:
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_settle_frames = 1
_walk_done = true
_walking = false
arrived.emit(_walk_target_feet)
@@ -1145,6 +1200,9 @@ func _cancel_walking() -> void:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
# ---------------------------------------------------------------------------
@@ -1221,6 +1279,8 @@ func queue_size() -> int:
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
for action: Dictionary in actions:
action["reactive"] = true
var start := action_queue.size()
action_queue.append_array(actions)
queue_changed.emit()
@@ -1233,6 +1293,21 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
_stop_requested = false
## Drops rule-injected ("reactive") actions from the queue, restoring the
## authored sequential queue after a Play session. No-op while the runner is
## executing (callers invoke it on mode exit, after stop_queue()).
func clear_reactive_actions() -> void:
if _runner_state == RunnerState.EXECUTING:
return
var kept: Array[Dictionary] = []
for action: Dictionary in action_queue:
if not bool(action.get("reactive", false)):
kept.append(action)
if kept.size() != action_queue.size():
action_queue = kept
queue_changed.emit()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
+5
View File
@@ -50,6 +50,11 @@ const COLLISION_NODE_NAME := "CollisionPolygon2D"
if _outline != null:
_outline.width = value
## Registry id of the spawn template that produced this block (Phase 4b). Used
## by the terrain drag-painting three-state overlap query to detect same-type
## overlaps. Plain var (not inspector-editable).
var spawn_id: String = ""
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
+112
View File
@@ -0,0 +1,112 @@
class_name PropThumbnail
extends Node
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const SIZE := Vector2i(200, 200)
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
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
_viewport.add_child(_camera)
_camera.make_current()
func render(payload: Dictionary, material_preset: int) -> Texture2D:
_clear_world()
var points := _compute_points(payload)
var fill := Color(payload.get("fill_color", Color.WHITE))
var outline := Color(payload.get("outline_color", Color.BLACK))
if material_preset != PROP_BLOCK.MaterialPreset.NONE:
var tint := PROP_BLOCK.tint_for(material_preset)
fill = tint
outline = tint.darkened(0.55)
var outline_width := float(payload.get("outline_width", 2.0))
var visual := Node2D.new()
var poly := Polygon2D.new()
poly.polygon = points
poly.color = fill
var line := Line2D.new()
var loop := points.duplicate()
if not loop.is_empty():
loop.append(points[0])
line.points = loop
line.default_color = outline
line.width = outline_width
line.joint_mode = Line2D.LINE_JOINT_ROUND
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
visual.add_child(poly)
visual.add_child(line)
_world.add_child(visual)
_frame_camera(_points_bbox(points))
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
visual.queue_free()
if tex == null:
return null
var img := tex.get_image()
if _is_blank(img):
return null
return ImageTexture.create_from_image(img)
func _compute_points(payload: Dictionary) -> PackedVector2Array:
var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON))
if shape_type == PROP_BLOCK.ShapeType.CIRCLE:
var radius := float(payload.get("radius", 24.0))
var loop := PackedVector2Array()
for i: int in PROP_BLOCK.CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(PROP_BLOCK.CIRCLE_SEGMENTS)
loop.append(Vector2(cos(angle), sin(angle)) * radius)
return loop
return payload.get("points", PackedVector2Array())
func _points_bbox(points: PackedVector2Array) -> Rect2:
if points.is_empty():
return Rect2(Vector2(-24, -24), Vector2(48, 48))
var rect := Rect2(points[0], Vector2.ZERO)
for p: Vector2 in points:
rect = rect.expand(p)
if rect.size.x < 1.0 or rect.size.y < 1.0:
return Rect2(Vector2(-24, -24), Vector2(48, 48))
return rect
func _clear_world() -> void:
for child: Node in _world.get_children():
child.queue_free()
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))
func _is_blank(img: Image) -> bool:
if img == null or img.is_empty():
return true
var used := img.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
return true
for y: int in range(used.position.y, used.end.y):
for x: int in range(used.position.x, used.end.x):
if img.get_pixel(x, y).a > 0.0:
return false
return true
+1
View File
@@ -0,0 +1 @@
uid://baau4hydvu46y
+73
View File
@@ -0,0 +1,73 @@
class_name StickmanThumbnail
extends Node
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const SIZE := Vector2i(200, 200)
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
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
_viewport.add_child(_camera)
_camera.make_current()
func render(stk_data: Dictionary) -> Texture2D:
_clear_world()
var rig: Node2D = STICKMAN_FACTORY.spawn_from_data(stk_data)
if rig == null:
return null
_world.add_child(rig)
rig.position = Vector2.ZERO
var bbox := STAGE_SPAWNER.get_world_aabb(rig)
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))
_frame_camera(bbox)
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
rig.queue_free()
if tex == null:
return null
var img := tex.get_image()
if _is_blank(img):
return null
return ImageTexture.create_from_image(img)
func _clear_world() -> void:
for child: Node in _world.get_children():
child.queue_free()
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))
func _is_blank(img: Image) -> bool:
if img == null or img.is_empty():
return true
var used := img.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
return true
for y: int in range(used.position.y, used.end.y):
for x: int in range(used.position.x, used.end.x):
if img.get_pixel(x, y).a > 0.0:
return false
return true
@@ -0,0 +1 @@
uid://5b0eubudwgua
+58
View File
@@ -0,0 +1,58 @@
class_name ThumbnailCache
extends RefCounted
const STICKMEN_DIR := "user://thumbnails/stickmen"
const PROP_DIR := "user://thumbnails/props"
const PROP_VERSION := 1
func stickman_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
func stickman_png(key: String) -> String:
return STICKMEN_DIR + "/" + key + ".png"
func prop_png(id: String) -> String:
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
func load_png(png_path: String) -> Texture2D:
if not FileAccess.file_exists(png_path):
return null
var img := Image.load_from_file(png_path)
if img == null:
return null
return ImageTexture.create_from_image(img)
func save_png(tex: Texture2D, png_path: String) -> Error:
if tex == null:
return ERR_INVALID_PARAMETER
var img := tex.get_image()
if img == null:
return ERR_CANT_CREATE
ensure_dir(png_path.get_base_dir())
return img.save_png(png_path)
func ensure_dir(dir: String) -> void:
DirAccess.make_dir_recursive_absolute(dir)
func clean_stale_stickmen(valid_keys: Dictionary) -> void:
if not DirAccess.dir_exists_absolute(STICKMEN_DIR):
return
var dir := DirAccess.open(STICKMEN_DIR)
if dir == null:
return
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not dir.current_is_dir() and fname.ends_with(".png"):
var key := fname.trim_suffix(".png")
if not valid_keys.has(key):
DirAccess.remove_absolute(STICKMEN_DIR + "/" + fname)
fname = dir.get_next()
dir.list_dir_end()
@@ -0,0 +1 @@
uid://ceffhet5bsvy3
+289
View File
@@ -0,0 +1,289 @@
# test_phase3b_library.gd
# Headless tests for Phase 3b Asset Library (no pixel rendering):
# 1. StickmanLibrary.scan() returns 3 entries (test/basic/break) sorted by
# display name; empty stickman_name falls back to the filename basename.
# 2. Corrupt / body-parts-less .stk files are skipped (scan dir override).
# 3. make_entry() valid path -> name+data; missing/corrupt path -> {}.
# 4. PropLibrary.get_entries() -> 4 templates; get_default_id() == "crate".
# 5. StageSpawner registry ids == [ground, ramp, step, prop, stickman, area].
# 6. _spawn_prop honors selected_prop_id (crate polygon / ball circle).
# 7. _spawn_stickman honors selected_stickman_path (basic.stk spawns non-null).
# 8. ThumbnailCache key/png formatting.
# 9. AssetSelector pagination math (PAGE_SIZE == 12; page_bounds slicing).
# 10. Scene load checks (sandbox_stage.tscn + asset_selector.tscn parse/load).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_library.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const ASSET_SELECTOR := preload("res://scripts/asset_selector.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const ASSET_SELECTOR_SCENE := preload("res://scenes/asset_selector.tscn")
const TEST_DIR := "user://phase3b_test"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 3b ASSET LIBRARY TEST (headless)")
print("========================================================")
_test_scan()
_test_corrupt_skip()
_test_make_entry()
_test_prop_library()
_test_spawner_registry()
_test_spawn_prop()
_test_spawn_stickman()
_test_thumbnail_cache()
_test_pagination()
_test_scene_loads()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_scan() -> void:
print("")
print("--- StickmanLibrary.scan() ---")
var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan()
_check(entries.size() == 3, "scan returns 3 entries (got %d)" % entries.size())
var names: Array[String] = []
for e: Dictionary in entries:
names.append(String(e["name"]))
_check(names == ["Basic", "break", "test"],
"entries sorted by display name (got %s)" % str(names))
_check(String(_by_path(entries, "basic.stk")["name"]) == "Basic",
"basic.stk display name is 'Basic' (stickman_name)")
_check(String(_by_path(entries, "test.stk")["name"]) == "test",
"test.stk falls back to filename basename")
_check(String(_by_path(entries, "break.stk")["name"]) == "break",
"break.stk falls back to filename basename")
_check(not _by_path(entries, "basic.stk").is_empty(), "basic.stk entry has data")
var found: Dictionary = lib.find_by_path("res://stickmen/basic.stk")
_check(not found.is_empty() and String(found["name"]) == "Basic",
"find_by_path resolves basic.stk")
func _test_corrupt_skip() -> void:
print("")
print("--- Corrupt / body-parts-less .stk skipped ---")
_ensure_test_dir()
_write_file(TEST_DIR + "/good.stk", "{\"stickman_name\":\"Good\",\"body_parts\":{}}")
_write_file(TEST_DIR + "/corrupt.stk", "{ not valid json !!!")
_write_file(TEST_DIR + "/nobody.stk", "{\"version\":\"1.5\"}")
var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan(TEST_DIR)
_check(entries.size() == 1,
"scan skips corrupt + body-parts-less files (got %d)" % entries.size())
if entries.size() == 1:
_check(String(entries[0]["name"]) == "Good",
"only the valid file survives (name='%s')" % String(entries[0]["name"]))
_clean_test_dir()
func _test_make_entry() -> void:
print("")
print("--- make_entry() ---")
var lib = STICKMAN_LIBRARY.new()
var entry := lib.make_entry("res://stickmen/basic.stk")
_check(not entry.is_empty(), "make_entry on valid path returns an entry")
_check(String(entry["name"]) == "Basic", "make_entry name == 'Basic'")
_check(not (entry["data"] as Dictionary).is_empty(), "make_entry carries parsed data")
_check(lib.make_entry("res://stickmen/does_not_exist.stk").is_empty(),
"make_entry on missing path returns {}")
_ensure_test_dir()
_write_file(TEST_DIR + "/bad.stk", "nope nope")
_check(lib.make_entry(TEST_DIR + "/bad.stk").is_empty(),
"make_entry on corrupt file returns {}")
_clean_test_dir()
func _test_prop_library() -> void:
print("")
print("--- PropLibrary ---")
var entries: Array[Dictionary] = PROP_LIBRARY.get_entries()
_check(entries.size() == 4, "4 prop templates (got %d)" % entries.size())
_check(PROP_LIBRARY.get_default_id() == "crate", "default prop id == 'crate'")
var ids: Array[String] = PROP_LIBRARY.get_ids()
_check(ids == ["crate", "ball", "plank", "triangle"],
"prop ids in order (got %s)" % str(ids))
var crate: Dictionary = PROP_LIBRARY.get_entry("crate")
_check(int(crate["material_preset"]) == PROP_BLOCK.MaterialPreset.WOOD,
"crate material preset == WOOD")
_check(String(crate["material_label"]) == "Wood", "crate material label == 'Wood'")
var ball: Dictionary = PROP_LIBRARY.get_entry("ball")
_check(int(ball["material_preset"]) == PROP_BLOCK.MaterialPreset.RUBBER,
"ball material preset == RUBBER")
_check(PROP_LIBRARY.get_entry("nope").is_empty(), "unknown prop id returns {}")
func _test_spawner_registry() -> void:
print("")
print("--- StageSpawner registry ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
var ids: Array[String] = spawner.get_spawnable_ids()
_check(ids == ["ground", "ramp", "step", "prop", "stickman", "area"],
"registry ids == [ground, ramp, step, prop, stickman, area] (got %s)" % str(ids))
_check(not ids.has("crate") and not ids.has("ball"),
"crate/ball registry entries removed")
_check(spawner.get_selected_prop_id() == "crate", "default selected_prop_id == 'crate'")
_check(spawner.get_selected_stickman_path() == "res://stickmen/test.stk",
"default selected_stickman_path == test.stk")
world.queue_free()
func _test_spawn_prop() -> void:
print("")
print("--- _spawn_prop honors selected_prop_id ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
var crate: Node2D = spawner.spawn("prop", Vector2.ZERO)
_check(crate != null, "prop spawns (default crate)")
_check(crate is PROP_BLOCK, "default prop is a PropBlock")
_check((crate as PROP_BLOCK).shape_type == PROP_BLOCK.ShapeType.POLYGON,
"default crate is a POLYGON prop")
spawner.selected_prop_id = "ball"
var ball: Node2D = spawner.spawn("prop", Vector2(100, 0))
_check(ball != null, "ball prop spawns after selected_prop_id = 'ball'")
_check((ball as PROP_BLOCK).shape_type == PROP_BLOCK.ShapeType.CIRCLE,
"ball prop is a CIRCLE prop")
world.queue_free()
func _test_spawn_stickman() -> void:
print("")
print("--- _spawn_stickman honors selected_stickman_path ---")
var world := Node2D.new()
root.add_child(world)
var spawner = STAGE_SPAWNER.new(world)
spawner.selected_stickman_path = "res://stickmen/basic.stk"
var rig: Node2D = spawner.spawn("stickman", Vector2.ZERO)
_check(rig != null, "stickman spawns from basic.stk")
_check(rig is RIG, "basic.stk spawns a StickmanRig")
world.queue_free()
func _test_thumbnail_cache() -> void:
print("")
print("--- ThumbnailCache key/png formatting ---")
var cache = THUMBNAIL_CACHE.new()
var mtime: int = FileAccess.get_modified_time("res://stickmen/test.stk")
var key := cache.stickman_key("res://stickmen/test.stk")
_check(key == "test_%d" % mtime, "stickman_key embeds basename + mtime (got '%s')" % key)
_check(cache.stickman_png(key) == "user://thumbnails/stickmen/" + key + ".png",
"stickman_png path formatting (got '%s')" % cache.stickman_png(key))
_check(cache.prop_png("crate") == "user://thumbnails/props/crate_v" + str(THUMBNAIL_CACHE.PROP_VERSION) + ".png",
"prop_png embeds id + version (got '%s')" % cache.prop_png("crate"))
_check(THUMBNAIL_CACHE.PROP_VERSION == 1, "PROP_VERSION == 1")
_check(cache.load_png("user://does_not_exist_phase3b.png") == null,
"load_png on missing file returns null")
func _test_pagination() -> void:
print("")
print("--- AssetSelector pagination ---")
_check(ASSET_SELECTOR.PAGE_SIZE == 12, "PAGE_SIZE == 12 (got %d)" % ASSET_SELECTOR.PAGE_SIZE)
_check(ASSET_SELECTOR.COLUMNS == 4 and ASSET_SELECTOR.ROWS == 3,
"COLUMNS=4, ROWS=3")
var b0: Dictionary = ASSET_SELECTOR.page_bounds(20, 0)
_check(int(b0["start"]) == 0 and int(b0["end"]) == 12 and int(b0["page_count"]) == 2,
"page 0 slices [0,12) of 20, page_count=2")
var b1: Dictionary = ASSET_SELECTOR.page_bounds(20, 1)
_check(int(b1["start"]) == 12 and int(b1["end"]) == 20,
"page 1 slices [12,20) of 20")
var b4: Dictionary = ASSET_SELECTOR.page_bounds(4, 0)
_check(int(b4["start"]) == 0 and int(b4["end"]) == 4 and int(b4["page_count"]) == 1,
"4 entries -> single page")
func _test_scene_loads() -> void:
print("")
print("--- Scene load checks ---")
_check(load("res://scenes/sandbox_stage.tscn") != null, "sandbox_stage.tscn loads")
_check(load("res://scenes/asset_selector.tscn") != null, "asset_selector.tscn loads")
var selector: AssetSelector = ASSET_SELECTOR_SCENE.instantiate() as AssetSelector
_check(selector != null, "asset_selector.tscn instantiates as AssetSelector")
root.add_child(selector)
_check(selector._title_label != null and selector._grid != null,
"asset_selector unique-name markers resolve")
_check(selector._browse_button != null and selector._refresh_button != null,
"asset_selector footer buttons resolve")
selector.queue_free()
await process_frame
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_check(stage._selector != null, "stage builds the AssetSelector")
_check(stage._stickman_library != null, "stage builds the StickmanLibrary")
_check(stage._stickman_thumb != null and stage._prop_thumb != null,
"stage builds the thumbnail renderers")
_check(stage._spawner.get_spawnable_ids() == ["ground", "ramp", "step", "prop", "stickman", "area"],
"stage spawner registry excludes crate/ball")
stage.queue_free()
await process_frame
func _by_path(entries: Array[Dictionary], suffix: String) -> Dictionary:
for e: Dictionary in entries:
if String(e["path"]).ends_with(suffix):
return e
return {}
func _ensure_test_dir() -> void:
DirAccess.make_dir_recursive_absolute(TEST_DIR)
func _write_file(path: String, text: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
if f != null:
f.store_string(text)
f.close()
func _clean_test_dir() -> void:
var dir := DirAccess.open(TEST_DIR)
if dir != null:
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not dir.current_is_dir():
DirAccess.remove_absolute(TEST_DIR + "/" + fname)
fname = dir.get_next()
dir.list_dir_end()
DirAccess.remove_absolute(TEST_DIR)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dpqa4ekcjcnp5
+380
View File
@@ -0,0 +1,380 @@
# test_phase3b_ui_fixes.gd
# Headless regression suite for the Phase 3b Selector UI fix pass (5 bugs):
#
# Bug 1 (popup lost on window resize): AssetSelector connects
# get_tree().root.size_changed -> _on_root_size_changed, which re-centers via
# popup_centered() when visible (and no-ops while hidden).
# * The size_changed connection exists after _ready.
# * Calling _on_root_size_changed() while hidden leaves it hidden (no-op).
# * Calling _on_root_size_changed() while visible re-centers without error
# and keeps the popup visible. (A real OS window resize cannot be fired
# headless; exercising the handler is the intended coverage.)
#
# Bug 2 (selector backdrop): SandboxStage builds `_selector_dim` (a full-rect
# ColorRect at SELECTOR_DIM_ALPHA = 0.5 with MOUSE_FILTER_IGNORE) on the UI
# CanvasLayer BELOW the AssetSelector; _open_selector() shows it,
# _close_selector() hides it.
# * _selector_dim is created, starts hidden, is a sibling of the selector
# under the same UI layer at a lower index, has the dim color + ignore
# mouse filter, becomes visible after _open_selector(...) and hidden again
# after _close_selector().
#
# Bug 3 (direct-popup position): SandboxStage._world_to_screen(world_pos) is
# the pure inverse of Camera2D.get_global_mouse_position():
# screen = (world - camera.position) * camera.zoom + viewport_size * 0.5
# * Hand-computed known-value checks for two camera setups.
# * Engine round-trip: _world_to_screen(camera.get_global_mouse_position())
# returns the viewport mouse position.
#
# Bug 4 (selector pre-highlight removed): AssetSelector.open(kind, entries) no
# longer takes selected_path/selected_id and never highlights a cell.
# * The removed API is gone (_is_selected method, _selected_path/_selected_id
# members, selected-stylebox tokens in the source).
# * open() has arity 2.
# * Opening 2+ entries builds one cell per entry and NO cell carries a
# stylebox override (the old highlight applied one to the "selected" cell).
# * On the full SandboxStage, _open_selector("stickman") runs the real scan
# (3 stickmen, no single-item skip), populates the grid, and still shows no
# pre-highlight. When the selected file is missing the fall-back-to-first
# entry logic still runs before open() and no highlight appears.
#
# Bug 5 (cancel / popup_hide state): _on_selector_cancelled() is idempotent
# (guard `if not _selector_open: return`) and _close_selector() flips
# _selector_open=false BEFORE hiding the selector.
# * After open -> cancel: _selector_open false, _placement_id "", palette
# buttons unpressed, selector + dim hidden.
# * Calling _on_selector_cancelled() a second time does not error and leaves
# the state clean (this mirrors the popup_hide re-entry the fix guards
# against; popup_hide itself cannot fire headless).
# * _close_selector() is itself idempotent.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_ui_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const ASSET_SELECTOR_SCENE := preload("res://scenes/asset_selector.tscn")
## Button stylebox states the old pre-highlight could have overridden on a cell.
const BUTTON_STYLEBOX_STATES: Array[String] = [
"normal", "hover", "pressed", "focus", "disabled", "selected",
]
const FAKE_ENTRIES: Array[Dictionary] = [
{ "path": "res://fake_one.stk", "name": "One" },
{ "path": "res://fake_two.stk", "name": "Two" },
{ "path": "res://fake_three.stk", "name": "Three" },
]
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
# instead of hanging the headless process forever.
var watchdog := create_timer(120.0)
watchdog.timeout.connect(func() -> void:
print("FAIL: watchdog timeout - test run aborted before quit()")
quit(2))
print("")
print("========================================================")
print(" PHASE 3b SELECTOR UI FIX REGRESSION TEST (headless)")
print("========================================================")
await _test_bug1_root_resize_handler()
await _test_bug2_dim_state()
await _test_bug3_world_to_screen()
await _test_bug4_selector_prehighlight_removed()
await _test_bug4_stage_grid_no_prehighlight()
await _test_bug5_cancel_state_idempotency()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: root-window resize re-centers the selector popup
# ---------------------------------------------------------------------------
func _test_bug1_root_resize_handler() -> void:
print("")
print("--- Bug 1: selector re-centers on root window resize ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
_check(sel.has_method("_on_root_size_changed"),
"AssetSelector exposes _on_root_size_changed")
_check(get_root().size_changed.is_connected(Callable(sel, "_on_root_size_changed")),
"root.size_changed is connected to _on_root_size_changed")
# Hidden: handler must be a no-op (no popup, stays hidden).
sel._on_root_size_changed()
_check(not sel.visible,
"resize handler is a no-op while the selector is hidden")
# Visible: handler re-centers via popup_centered() without error and keeps
# the popup visible (real OS resizes cannot fire headless).
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "selector is visible after open()")
sel._on_root_size_changed()
_check(sel.visible,
"resize handler runs without error while visible and keeps the popup visible")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 2: selector dim backdrop state
# ---------------------------------------------------------------------------
func _test_bug2_dim_state() -> void:
print("")
print("--- Bug 2: _selector_dim backdrop state ---")
var stage := _new_stage()
_check(stage._selector_dim != null, "stage builds _selector_dim")
if stage._selector_dim == null:
await _free_stage(stage)
return
_check(not stage._selector_dim.visible, "dim starts hidden")
_check(stage._selector_dim.get_parent() == stage._selector.get_parent(),
"dim and selector share the same UI layer parent")
_check(stage._selector_dim.get_index() < stage._selector.get_index(),
"dim is added below the selector (renders behind the popup)")
_check(stage._selector_dim.mouse_filter == Control.MOUSE_FILTER_IGNORE,
"dim never intercepts mouse events")
_check(is_equal_approx(stage._selector_dim.color.a, stage.SELECTOR_DIM_ALPHA),
"dim alpha == SELECTOR_DIM_ALPHA (%.2f)" % stage.SELECTOR_DIM_ALPHA)
# 'prop' always has 4 templates so _open_selector never single-item-skips.
stage._open_selector("prop")
_check(stage._selector_dim.visible, "dim becomes visible after _open_selector()")
stage._close_selector()
_check(not stage._selector_dim.visible, "dim hidden again after _close_selector()")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: _world_to_screen pure math
# ---------------------------------------------------------------------------
func _test_bug3_world_to_screen() -> void:
print("")
print("--- Bug 3: _world_to_screen math ---")
var stage := _new_stage()
var cam: Camera2D = stage._camera
cam.make_current()
var vp_size: Vector2 = get_root().get_visible_rect().size
# Known-value checks across two camera setups and several world points.
_assert_world_to_screen(stage, cam, vp_size,
Vector2(120.0, -300.0), Vector2(1.5, 1.5),
[Vector2(40.0, -60.0), Vector2(-800.0, 200.0), Vector2(0.0, 0.0)])
_assert_world_to_screen(stage, cam, vp_size,
Vector2(-25.0, 40.0), Vector2(0.5, 0.5),
[Vector2(333.0, -777.0), Vector2(-10.5, 12.25)])
# Engine round-trip: inverse of Camera2D.get_global_mouse_position().
cam.position = Vector2(120.0, -300.0)
cam.zoom = Vector2(1.5, 1.5)
var mouse: Vector2 = get_root().get_mouse_position()
var gmp: Vector2 = cam.get_global_mouse_position()
var screen_of_gmp: Vector2 = stage._world_to_screen(gmp)
_check(screen_of_gmp.distance_to(mouse) < 0.1,
"round-trip _world_to_screen(camera.get_global_mouse_position()) ~= viewport mouse (dist %.5f)"
% screen_of_gmp.distance_to(mouse))
await _free_stage(stage)
func _assert_world_to_screen(stage: Node2D, cam: Camera2D, vp_size: Vector2,
cam_pos: Vector2, cam_zoom: Vector2, points: Array) -> void:
cam.position = cam_pos
cam.zoom = cam_zoom
for p in points:
var w: Vector2 = p as Vector2
var expected: Vector2 = (w - cam_pos) * cam_zoom + vp_size * 0.5
var actual: Vector2 = stage._world_to_screen(w)
_check(expected.distance_to(actual) < 0.01,
"cam %s zoom %s: _world_to_screen(%s) == %s (got %s)"
% [cam_pos, cam_zoom, w, expected, actual])
# ---------------------------------------------------------------------------
# Bug 4: AssetSelector pre-highlight removed (direct instance)
# ---------------------------------------------------------------------------
func _test_bug4_selector_prehighlight_removed() -> void:
print("")
print("--- Bug 4: AssetSelector pre-highlight removed ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
# Removed API is gone.
_check(not sel.has_method("_is_selected"), "pre-highlight helper _is_selected() removed")
_check(sel.get("_selected_path") == null, "member _selected_path removed")
_check(sel.get("_selected_id") == null, "member _selected_id removed")
# Source-presence: none of the deleted tokens may lurk in the script.
var file := FileAccess.open("res://scripts/asset_selector.gd", FileAccess.READ)
_check(file != null, "asset_selector.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(not text.contains("_is_selected"), "source contains no _is_selected token")
_check(not text.contains("_selected_path"), "source contains no _selected_path token")
_check(not text.contains("_selected_id"), "source contains no _selected_id token")
# open() signature is (kind, entries) - no selected_path/selected_id args.
_check(_method_arg_count(sel, "open") == 2,
"open() takes exactly 2 args (got %d)" % _method_arg_count(sel, "open"))
# Populating 2+ entries builds a cell per entry with NO pre-highlight stylebox.
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "open() pops the selector")
_check(sel._grid.get_child_count() == FAKE_ENTRIES.size(),
"grid holds one cell per entry (got %d)" % sel._grid.get_child_count())
_check(not _any_cell_has_stylebox_override(sel),
"no cell carries a pre-highlight stylebox override")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 4 on the full stage: real scan populates the grid with no pre-highlight
# ---------------------------------------------------------------------------
func _test_bug4_stage_grid_no_prehighlight() -> void:
print("")
print("--- Bug 4: full-stage selector grid has no pre-highlight ---")
var stage := _new_stage()
# Selected file missing -> fall-back-to-first-entry still runs before open().
stage._spawner.selected_stickman_path = "res://stickmen/does_not_exist.stk"
stage._open_selector("stickman")
_check(stage._selector_open, "stage selector opens from a palette toggle")
_check(stage._spawner.get_selected_stickman_path().ends_with("basic.stk"),
"missing selected file falls back to the first scanned entry (got '%s')"
% stage._spawner.get_selected_stickman_path())
# Real scan has 3 stickmen -> no single-item auto-select skip.
var grid: GridContainer = stage._selector._grid
_check(grid.get_child_count() == 3,
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count())
_check(not stage._selector.has_method("_is_selected"),
"stage selector exposes no pre-highlight API")
_check(not _any_cell_has_stylebox_override(stage._selector),
"no stickman cell carries a pre-highlight stylebox override")
stage._on_selector_cancelled()
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 5: cancel / popup_hide state + idempotency
# ---------------------------------------------------------------------------
func _test_bug5_cancel_state_idempotency() -> void:
print("")
print("--- Bug 5: _on_selector_cancelled state + idempotency ---")
var stage := _new_stage()
var stickman_btn: Button = stage._palette_buttons["stickman"]
stage._open_selector("stickman")
_check(stage._selector_open, "selector is open after _open_selector()")
_check(stage._selector.visible, "selector popup visible while open")
_check(stage._selector_dim.visible, "dim visible while open")
_check(stickman_btn.button_pressed, "stickman palette button pressed while open")
stage._on_selector_cancelled()
_check(not stage._selector_open, "cancel clears _selector_open")
_check(stage._placement_id == "", "cancel clears _placement_id")
_check(stage._selector_kind == "", "cancel clears _selector_kind")
_check(not stickman_btn.button_pressed, "cancel unpresses the palette button")
_check(not stage._selector.visible, "cancel hides the selector popup")
_check(not stage._selector_dim.visible, "cancel hides the dim backdrop")
# popup_hide cannot fire headless; a second direct invocation exercises the
# idempotency guard that the popup_hide -> _on_selector_cancelled path relies
# on (_close_selector flips _selector_open=false before hide()).
stage._on_selector_cancelled()
_check(not stage._selector_open, "second cancel does not reopen state")
_check(stage._placement_id == "", "second cancel leaves placement clean")
_check(not stickman_btn.button_pressed, "second cancel leaves palette clean")
_check(not stage._selector.visible and not stage._selector_dim.visible,
"second cancel leaves selector + dim hidden")
# _close_selector itself is idempotent (same ordering guarantees).
stage._close_selector()
_check(not stage._selector_open and not stage._selector.visible,
"_close_selector() twice is a clean no-op")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
## True when any cell Button in the selector's grid has a stylebox override for a
## standard button state (the old "currently selected asset gets a highlight
## stylebox border" behavior would have left exactly such an override).
func _any_cell_has_stylebox_override(sel) -> bool:
var grid: GridContainer = sel._grid
for child: Node in grid.get_children():
var cell := child as Button
if cell == null:
continue
for state: String in BUTTON_STYLEBOX_STATES:
if cell.has_theme_stylebox_override(state):
return true
return false
## Number of declared parameters for `method_name` on an Object (via reflection).
func _method_arg_count(obj: Object, method_name: String) -> int:
for m: Dictionary in obj.get_method_list():
if String(m["name"]) == method_name:
return int((m["args"] as Array).size())
return -1
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://c9p4m6q2xk8wv
+547
View File
@@ -0,0 +1,547 @@
# test_phase4b1_fixes.gd
# Headless regression suite for the Phase 4b.1 fix pass (spec §7 D1 + Bug 2-5):
#
# Bug 1 (D1 - block-unit paint stride): terrain drag-painting quantizes to the
# active template's ACTUAL footprint (the sanitized AABB, e.g. Ground = 192x32,
# NOT the raw 200x32 template and NOT the 16-px grid):
# * horizontal ground run tiles at the 192px stride (end-to-end, no
# interior overlap, no gaps);
# * vertical ground run tiles at the 32px stride;
# * free diagonal tiles corner-to-corner (no interior overlap between
# consecutive blocks);
# * a second drag crossing an existing same-type block classifies skip
# (Case B), including the transient in-drag `_drag_painted` set;
# * a cell occupied by a conflicting prop classifies blocked and is skipped
# while its empty neighbours still spawn.
#
# Bug 2 (persistent guide line): StagePlacementOverlay.clear_terrain_guide /
# clear_action clear the visible flags AND request a redraw so the erased
# line is actually removed (source-presence check: is_queued_for_redraw()
# does not exist in Godot 4.4, so the redraw request is verified statically).
#
# Bug 4 (single-placement guide circles): a single click (anchor == target)
# never shows the guide line on the placement overlay.
#
# Bug 3 (waypoint jitter): a waypoint on a nav-mesh target terminates through
# the nav branch with exactly one `arrived`, position snapped to the final
# root target and stable for 60+ physics frames; an off-mesh waypoint still
# reaches arrival through direct mode.
#
# Bug 5 (cursor ghost regression): _spawn_ghost creates a cursor-following
# ghost for a terrain placement id (block-unit snapped), drag begin frees
# it, drag end re-arms it while the tool is still active, and
# set_placement_mode("") frees it.
#
# Run with (either console):
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path .
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const TERRAIN_BLOCK := preload("res://scripts/terrain_block.gd")
const OVERLAY_SCRIPT := preload("res://scripts/stage_placement_overlay.gd")
const RIG := preload("res://scripts/stickman_rig.gd")
# The Ground registry template is authored 200x32 but StageSpawner sanitizes
# every terrain polygon onto the 16px TERRAIN_GRID_SIZE before placement, so the
# ACTUAL spawned footprint (and therefore the D1 paint stride) is 192x32.
const GROUND_STRIDE_X := 192.0
const GROUND_STRIDE_Y := 32.0
const GROUND_HALF_X := 96.0
const GROUND_HALF_Y := 16.0
const SETTLE_FRAMES := 60
const MAX_WALK_FRAMES := 360
var _checks := 0
var _failures := 0
var _arrived_count := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
print("========================================================")
_test_bug1_horizontal_run()
_test_bug1_vertical_run()
_test_bug1_diagonal_run()
_test_bug1_same_type_skip()
_test_bug1_conflicting_prop()
_test_bug2_overlay_clear()
_test_bug4_single_click_guide()
await _test_bug3_walk_termination()
await _test_bug5_ghost_lifecycle()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1 (D1): block-stride quantization
# ---------------------------------------------------------------------------
func _test_bug1_horizontal_run() -> void:
print("")
print("--- Bug 1: horizontal ground run tiles at the 192px stride ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 0), Vector2i(2, 0)]
_check(stage._drag_cells == expected_cells,
"drag (0,0)->(2,0) path is 3 block cells (got %s)" % str(stage._drag_cells))
_check(stage._classify_cell(Vector2i(0, 0)) == 1 and stage._classify_cell(Vector2i(2, 0)) == 1,
"empty horizontal cells classify empty (1)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
var after := _terrain_count(world)
_check(after - before == 3, "horizontal run committed 3 blocks (got %d)" % (after - before))
var blocks := _terrain_blocks(world)
blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.x < (b as TerrainBlock).position.x)
_check(blocks.size() == 3, "3 ground blocks present (got %d)" % blocks.size())
var xs: Array[float] = []
for b: TerrainBlock in blocks:
xs.append(b.position.x)
var expected_xs: Array[float] = [0.0, GROUND_STRIDE_X, 2.0 * GROUND_STRIDE_X]
_check(xs == expected_xs,
"block centers at x = 0/192/384 (got %s)" % str(xs))
# End-to-end adjacency: each consecutive pair has zero gap and NO strict
# interior overlap (the edge/corner contact that used to double-stamp).
var adj_ok := true
var gap_ok := true
for i: int in range(blocks.size() - 1):
var a := _block_world_aabb(blocks[i])
var b := _block_world_aabb(blocks[i + 1])
if _strict_overlap(a, b):
adj_ok = false
if not is_equal_approx(b.position.x - a.end.x, 0.0):
gap_ok = false
_check(adj_ok, "horizontal consecutive blocks have no interior overlap")
_check(gap_ok, "horizontal consecutive blocks abut edge-to-edge (zero gap)")
var total_span := _block_world_aabb(blocks[2]).end.x - _block_world_aabb(blocks[0]).position.x
_check(is_equal_approx(total_span, 3.0 * GROUND_STRIDE_X),
"3-block run spans exactly %dpx (got %.1f)" % [3 * GROUND_STRIDE_X, total_span])
_free_stage(stage)
func _test_bug1_vertical_run() -> void:
print("")
print("--- Bug 1: vertical ground run tiles at the 32px stride ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(0, 3)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(0, 1), Vector2i(0, 2), Vector2i(0, 3)]
_check(stage._drag_cells == expected_cells,
"drag (0,0)->(0,3) path is 4 block cells (got %s)" % str(stage._drag_cells))
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 4, "vertical run committed 4 blocks (got %d)" % delta)
var blocks := _terrain_blocks(world)
blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.y < (b as TerrainBlock).position.y)
var ys: Array[float] = []
for b: TerrainBlock in blocks:
ys.append(b.position.y)
var expected_ys: Array[float] = [0.0, 32.0, 64.0, 96.0]
_check(ys == expected_ys,
"block centers at y = 0/32/64/96 (got %s)" % str(ys))
var adj_ok := true
var gap_ok := true
for i: int in range(blocks.size() - 1):
var a := _block_world_aabb(blocks[i])
var b := _block_world_aabb(blocks[i + 1])
if _strict_overlap(a, b):
adj_ok = false
if not is_equal_approx(b.position.y - a.end.y, 0.0):
gap_ok = false
_check(adj_ok, "vertical consecutive blocks have no interior overlap")
_check(gap_ok, "vertical consecutive blocks abut edge-to-edge (zero gap)")
_free_stage(stage)
func _test_bug1_diagonal_run() -> void:
print("")
print("--- Bug 1: free diagonal tiles corner-to-corner (no interior overlap) ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 2)))
var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 1), Vector2i(2, 2)]
_check(stage._drag_cells == expected_cells,
"diagonal drag path is 3 staircase cells (got %s)" % str(stage._drag_cells))
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 3, "diagonal run committed 3 blocks (got %d)" % delta)
var blocks := _terrain_blocks(world)
var no_overlap := true
for i: int in range(blocks.size()):
for j: int in range(i + 1, blocks.size()):
if _strict_overlap(_block_world_aabb(blocks[i]), _block_world_aabb(blocks[j])):
no_overlap = false
_check(no_overlap, "diagonal blocks share at most a corner - no interior overlap anywhere")
_free_stage(stage)
func _test_bug1_same_type_skip() -> void:
print("")
print("--- Bug 1: second drag over an existing same-type block skips (Case B) ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
# First drag commits cells (0,0),(1,0),(2,0).
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._commit_terrain_drag()
_check(_terrain_count(world) == 3, "first drag committed 3 blocks")
# Second drag starts ON the existing block at cell (2,0) and extends to (4,0).
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0)))
_check(stage._classify_cell(Vector2i(2, 0)) == 2,
"occupied same-type cell (2,0) classifies skip (2) (got %d)" % stage._classify_cell(Vector2i(2, 0)))
_check(stage._classify_cell(Vector2i(3, 0)) == 1 and stage._classify_cell(Vector2i(4, 0)) == 1,
"extension cells (3,0),(4,0) classify empty (1)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 2, "Case B second drag committed only the 2 empty cells (got %d)" % delta)
# Transient in-drag self-overlap set participates in classification.
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(40, 0)))
stage._drag_painted[Vector2i(40, 0)] = true
_check(stage._classify_cell(Vector2i(40, 0)) == 2,
"in-drag _drag_painted cell classifies same-type skip (2) (got %d)" % stage._classify_cell(Vector2i(40, 0)))
stage._cancel_terrain_drag()
_free_stage(stage)
func _test_bug1_conflicting_prop() -> void:
print("")
print("--- Bug 1: conflicting prop cell classifies blocked and is skipped ---")
var stage := _new_stage()
var world: Node2D = stage._world
stage.set_placement_mode("ground")
# Crate sits at cell (3,0) center (600,0) (48x48 prop inside the 200x32 cell).
var crate: Node2D = stage._spawner.spawn("prop", stage._terrain_cell_center(Vector2i(3, 0)))
_check(crate != null, "crate prop spawns at cell (3,0)")
stage._rebuild_grid_cells()
_check(stage._classify_cell(Vector2i(3, 0)) == 3,
"conflicting prop cell (3,0) classifies blocked (3) (got %d)" % stage._classify_cell(Vector2i(3, 0)))
_check(stage._classify_cell(Vector2i(2, 0)) == 1,
"empty neighbour cell (2,0) classifies empty (1)")
var before := _terrain_count(world)
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0)))
_check(stage._drag_cells.size() == 3, "conflict drag path has 3 cells (got %d)" % stage._drag_cells.size())
stage._commit_terrain_drag()
var delta := _terrain_count(world) - before
_check(delta == 2, "conflict drag committed only the 2 empty edge cells (got %d)" % delta)
# The middle cell must not contain any freshly committed ground block.
var blocked_cell_clean := true
for b: TerrainBlock in _terrain_blocks(world):
if _strict_overlap(_block_world_aabb(b), _crate_aabb(crate)):
blocked_cell_clean = false
_check(blocked_cell_clean, "no committed ground block overlaps the crate cell")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 2 (overlay clear redraw) + Bug 4 (single-click guide suppression)
# ---------------------------------------------------------------------------
func _test_bug2_overlay_clear() -> void:
print("")
print("--- Bug 2: overlay clear requests a redraw + resets visible flags ---")
# Source-presence check: clear_terrain_guide() and clear_action() must both
# call queue_redraw() so the erased line is removed (Godot 4.4 exposes no
# is_queued_for_redraw(), so this is the headless-observable regression).
var file := FileAccess.open("res://scripts/stage_placement_overlay.gd", FileAccess.READ)
_check(file != null, "stage_placement_overlay.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(_func_body_contains(text, "clear_terrain_guide", "queue_redraw()"),
"clear_terrain_guide() body contains queue_redraw()")
_check(_func_body_contains(text, "clear_action", "queue_redraw()"),
"clear_action() body contains queue_redraw()")
# Behavioral flags: clearing hides the guide/trajectory state.
var overlay: Node2D = OVERLAY_SCRIPT.new()
root.add_child(overlay)
overlay.set_process(false)
overlay.set_terrain_guide(Vector2(0.0, 0.0), Vector2(400.0, 0.0))
_check(overlay.terrain_guide_visible, "set_terrain_guide marks the guide visible")
overlay.clear_terrain_guide()
_check(not overlay.terrain_guide_visible, "clear_terrain_guide hides the guide")
overlay.set_action_trajectory(Vector2(0.0, 0.0), Vector2(300.0, 0.0), true)
_check(overlay.action_visible, "set_action_trajectory marks the action visible")
overlay.clear_action()
_check(not overlay.action_visible, "clear_action hides the action trajectory")
overlay.free()
func _test_bug4_single_click_guide() -> void:
print("")
print("--- Bug 4: single-click drag never shows the guide line ---")
var stage := _new_stage()
var world: Node2D = stage._world
var overlay = stage._placement_overlay
_check(overlay != null, "placement overlay is built")
stage.set_placement_mode("ground")
_check(not overlay.terrain_guide_visible, "guide hidden before any drag")
# Single click (anchor == target): _begin_terrain_drag -> _update_terrain_drag
# with the same cell must NOT call set_terrain_guide.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(20, 0)))
_check(overlay.terrain_guide_visible == false,
"single-click begin keeps guide hidden (anchor == target)")
var before := _terrain_count(world)
stage._commit_terrain_drag()
_check(_terrain_count(world) - before == 1, "single click committed exactly 1 block")
_check(not overlay.terrain_guide_visible, "guide still hidden after single-click commit")
# Multi-cell drag DOES show the guide while dragging...
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0)))
_check(overlay.terrain_guide_visible, "multi-cell drag shows the guide")
# ...and retracting to the anchor again hides it (target == anchor).
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0)))
_check(not overlay.terrain_guide_visible, "retracting to the anchor hides the guide")
# ...and commit clears it.
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0)))
stage._commit_terrain_drag()
_check(not overlay.terrain_guide_visible, "commit clears the guide")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: nav-mode termination (waypoint jitter fix)
# ---------------------------------------------------------------------------
func _test_bug3_walk_termination() -> void:
print("")
print("--- Bug 3: nav-mode termination + off-mesh direct still works ---")
# On-mesh waypoint near the slab's right edge (the former oscillation zone):
# the rig must terminate exactly once through the nav branch and stay put.
await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav")
# Off-mesh waypoint in open space: direct mode must still reach arrival.
await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct")
func _on_arrived(_target: Vector2) -> void:
_arrived_count += 1
func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void:
var stage := _new_stage()
var ground = stage._spawner.spawn("ground", Vector2(0, 0))
_check(ground != null, "%s: ground spawns" % label)
stage._rebake_navigation()
var rig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "%s: stickman spawns" % label)
if rig == null:
_free_stage(stage)
return
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_done, "%s: walk not done immediately" % label)
var frames := 0
var mode_flipped := false
while not rig._walk_done and frames < MAX_WALK_FRAMES:
if rig._walk_mode_latched and rig._walk_mode != expected_mode:
mode_flipped = true
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: steering mode latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
_check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label)
# Position snapped to the exact final root target (feet + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
# Post-arrival stability: no position change for SETTLE_FRAMES physics frames.
var p0: Vector2 = rig.global_position
var stable := true
for i: int in SETTLE_FRAMES:
await physics_frame
if rig.global_position != p0:
stable = false
break
_check(stable and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
_free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 5: terrain cursor ghost lifecycle
# ---------------------------------------------------------------------------
func _test_bug5_ghost_lifecycle() -> void:
print("")
print("--- Bug 5: terrain cursor ghost spawn / free / re-arm ---")
var stage := _new_stage()
var world: Node2D = stage._world
var holder: Node2D = stage._ghost_holder
_check(stage._ghost == null, "no ghost before placement armed")
stage.set_placement_mode("ground")
_check(stage._ghost != null, "_spawn_ghost creates a ghost for terrain placement")
var ghost: Node2D = stage._ghost
_check(ghost is TERRAIN_BLOCK, "terrain ghost is a TerrainBlock")
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
# Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor).
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
+ stage._spawner.get_spawn_offset("ground")
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos)
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
stage._update_ghost_position()
_check(ghost.position.distance_to(expected_pos) <= 0.01,
"ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos])
_check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0),
"ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x])
_check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0),
"ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y)
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
_check(stage._ghost == null, "drag begin frees the single ghost")
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(32, 0)))
_check(stage._ghost_array.size() == 3,
"drag path shows 3 per-cell ghosts (got %d)" % stage._ghost_array.size())
# Commit re-arms the single ghost (LMB repeated placement preserved).
var before := _terrain_count(world)
stage._commit_terrain_drag()
_check(_terrain_count(world) - before == 3, "ghost lifecycle drag committed 3 blocks")
_check(stage._ghost != null, "drag end re-arms the cursor ghost while tool is active")
_check(stage._ghost_array.is_empty(), "per-cell ghosts freed after commit")
_check(stage._ghost.get_parent() == holder, "re-armed ghost lives in the ghost holder")
# set_placement_mode("") frees the ghost (queue_free) and empties the holder
# at the end of the current frame.
var held_ghost: Node2D = stage._ghost
stage.set_placement_mode("")
_check(stage._ghost == null, "set_placement_mode('') frees the ghost")
_check(held_ghost != null and held_ghost.is_queued_for_deletion(),
"freed ghost is queued for deletion")
await process_frame
_check(holder.get_child_count() == 0, "ghost holder is empty after clearing placement")
_free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _terrain_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
n += 1
return n
func _terrain_blocks(world: Node2D) -> Array:
var out: Array = []
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
out.append(child)
return out
func _block_world_aabb(block: Node2D) -> Rect2:
return STAGE_SELECTION.get_world_aabb(block)
func _crate_aabb(crate: Node2D) -> Rect2:
return STAGE_SELECTION.get_world_aabb(crate)
## Strict interior overlap - a shared edge or a shared corner is NOT an overlap
## (mirrors the fixed `_aabb_overlaps` in sandbox_stage.gd).
func _strict_overlap(a: Rect2, b: Rect2) -> bool:
return a.position.x < b.end.x and b.position.x < a.end.x \
and a.position.y < b.end.y and b.position.y < a.end.y
func _func_body_contains(text: String, func_name: String, needle: String) -> bool:
var marker := "func " + func_name
var start := text.find(marker)
if start == -1:
return false
var end := text.find("\nfunc ", start + marker.length())
if end == -1:
end = text.length()
return text.substr(start, end - start).contains(needle)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dn748uqvslgmk
+446
View File
@@ -0,0 +1,446 @@
# test_phase4b2_fixes.gd
# Headless regression suite for the Phase 4b.2 fix pass (four Sandbox Stage
# Builder bugs):
#
# Bug 1 (stale yellow hover box after Delete): delete_selected() now calls
# StageSelection.clear_hover() (new) after clear_selection() so the gizmo
# layer drops the stale highlight, and StageGizmos._draw() skips any hovered
# node that is queued for deletion.
# * delete_selected() leaves the selection's hovered node null and emits
# hover_changed(null), which the stage forwards to StageGizmos.set_hover
# (gizmo _hovered becomes null).
# * clear_hover() with no hover is a silent no-op (no signal emission).
# * StageGizmos._draw() source contains the is_queued_for_deletion() guard
# (draw output is not observable headless, so the guard is asserted as a
# source-presence check, matching the repo's established pattern).
#
# Bug 2 ('Back to actions' dead-end): _on_trigger_popup_id_pressed(TRIG_BACK)
# now _reset_rule_builder()s and re-opens the action popup (at the recorded
# session anchor) when _context_rig is valid, instead of cancel-hiding
# everything.
# * After Back the rule-builder state is IDLE/empty AND _action_popup is
# visible again (PopupMenu.visible IS observable headless).
#
# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a
# full-range (-180..180), non-inverted, non-local-space constraint.
# * Constraint flags/band are asserted directly on the instantiated rig.
# * The Head marker is settled at the walk-left pose (-100,-614), then
# snapped to STAND_POSE (100,-614) (the snap_to_standing() scenario on
# PLAY/EDIT exit). With the FIXED constraint the head bone + Body/Head
# visual rotation show zero frame-to-frame motion; the OLD (invert +
# localspace + 55..305) constraint exhibits a ~0.8 rad one-frame flip
# (this suite's regression discriminator).
#
# Bug 4 (reactive badge accumulation): StickmanRig.enqueue_reactive() tags
# injected actions reactive=true, and the new clear_reactive_actions()
# (no-op while EXECUTING) drops them back to the authored queue.
# * One enqueue+clear cycle restores the authored queue exactly.
# * Repeated cycles never accumulate (stable counts, no 1,2,3 build-up).
# * clear_reactive_actions() is a no-op while the runner is EXECUTING.
# * queue_changed is emitted on every actual queue mutation.
#
# Run with (either console):
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_fixes.gd --path .
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b2_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
# Physics frames / thresholds for the Bug 3 head-jitter measurement.
const WARMUP_FRAMES := 30
const MEASURE_FRAMES := 60
## A single-frame rotation step above this (radians) is treated as a mirror
## flip / oscillation event. The fixed constraint produces 0.0; the old
## constraint produced ~0.8 rad on the STAND_POSE snap.
const FLIP_THRESHOLD_RAD := 0.5
## Generous ceiling used by the "standing still" assertion; fixed = 0.0.
const STILL_MAX_STEP_RAD := 0.25
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b.2 FIX REGRESSION TEST (headless)")
print("========================================================")
await _test_bug1_delete_clears_hover()
_test_bug1_gizmo_draw_guard_source()
_test_bug2_back_to_actions()
await _test_bug3_head_lookat()
await _test_bug4_reactive_actions()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: stale yellow hover box after Delete
# ---------------------------------------------------------------------------
func _test_bug1_delete_clears_hover() -> void:
print("")
print("--- Bug 1: delete_selected clears the stale hover highlight ---")
var stage := _new_stage()
var world: Node2D = stage._world
var obj: Node2D = stage._spawner.spawn("ground", Vector2(0, 0))
_check(obj != null, "ground block spawns for hover/delete test")
_check(world.get_children().has(obj), "spawned block is a direct World child")
# Put the selection AND the hover on the same block, exactly like hovering a
# selected object and pressing Delete.
stage._selection.select_only(obj)
stage._selection.update_hover(STAGE_SELECTION.get_world_aabb(obj).get_center())
_check(stage._selection._hovered == obj,
"update_hover targets the spawned block (hover set)")
_check(stage._gizmos._hovered == obj,
"gizmo layer mirrors the hover via hover_changed -> set_hover")
# Watch the hover signal during the delete.
var hover_payloads: Array = []
stage._selection.hover_changed.connect(func(n): hover_payloads.append(n))
stage.delete_selected()
_check(stage._selection._hovered == null,
"selection hover is null after delete_selected (no stale hover)")
_check(stage._gizmos._hovered == null,
"gizmo hover target cleared after delete_selected (yellow box disappears)")
_check(stage._selection.get_selected().is_empty(),
"selection is empty after delete_selected")
_check(not hover_payloads.is_empty() and hover_payloads[-1] == null,
"clear_hover emitted hover_changed(null) during delete")
# clear_hover() with nothing hovered must be a silent no-op.
var noop_payloads: Array = []
stage._selection.hover_changed.connect(func(n): noop_payloads.append(n))
stage._selection.clear_hover()
_check(noop_payloads.is_empty(),
"clear_hover with no hover emits nothing (idempotent)")
await _free_stage(stage)
func _test_bug1_gizmo_draw_guard_source() -> void:
print("")
print("--- Bug 1: StageGizmos._draw() skips queued-for-deletion hovers ---")
# Drawing output is not observable headless, so assert the guard that makes
# a deleted-but-still-referenced hover node disappear from the draw pass.
# This mirrors the source-presence checks used elsewhere in the repo.
var file := FileAccess.open("res://scripts/stage_gizmos.gd", FileAccess.READ)
_check(file != null, "stage_gizmos.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(_func_body_contains(text, "_draw", "is_queued_for_deletion()"),
"StageGizmos._draw() body contains the is_queued_for_deletion() hover guard")
# ---------------------------------------------------------------------------
# Bug 2: 'Back to actions' must return to the action popup
# ---------------------------------------------------------------------------
func _test_bug2_back_to_actions() -> void:
print("")
print("--- Bug 2: trigger-popup Back resets builder and re-opens actions ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman spawns for the popup flow")
if rig == null:
await _free_stage(stage)
return
stage._context_rig = rig
# "⚡ When..." from the action popup opens the trigger sub-menu and arms the
# rule builder (RuleStep.SELECT_TRIGGER = 1).
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(int(stage._rule_step) == 1,
"After When... the builder sits at SELECT_TRIGGER (got %d)" % int(stage._rule_step))
_check(not stage._rule_builder.is_empty() and stage._rule_builder.has("trigger"),
"After When... a trigger shell exists in the rule builder")
_check(stage._rule_context_rig == rig,
"rule context rig mirrors _context_rig")
_check(stage._trigger_popup.visible,
"trigger popup is open after When...")
# Press '⬅ Back to actions' (TRIG_BACK = 5).
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(int(stage._rule_step) == 0,
"Back resets the rule builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"Back empties the rule builder dict")
_check(stage._rule_context_rig == null,
"Back clears the rule context rig")
_check(stage._rule_hint.is_empty(),
"Back clears the rule hint text")
_check(stage._action_popup.visible,
"Back re-opens the ACTION popup (no dead-end)")
_check(stage._context_rig == rig,
"Back keeps _context_rig so further actions target the same stickman")
# When the context rig is gone, Back must not crash and must not re-open the
# action popup over nothing.
stage._context_rig = null
stage._action_popup.hide()
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._action_popup.visible == false,
"Back without a valid context rig leaves the action popup closed")
_check(int(stage._rule_step) == 0,
"Back without a valid context rig still resets the builder")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: head LookAt mirror jitter
# ---------------------------------------------------------------------------
func _test_bug3_head_lookat() -> void:
print("")
print("--- Bug 3: Head LookAt full-range constraint + no mirror jitter ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the head test")
if rig == null:
await _free_stage(stage)
return
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
var stack: SkeletonModificationStack2D = skeleton.modification_stack
_check(stack != null, "rig skeleton carries a modification stack")
_check(stack != null and stack.enabled,
"rig modification stack is enabled after _ready")
var look_at: SkeletonModification2DLookAt = null
if stack != null:
for i: int in stack.modification_count:
var mod = stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
look_at = mod as SkeletonModification2DLookAt
break
_check(look_at != null, "Head SkeletonModification2DLookAt exists in the stack")
if look_at == null:
await _free_stage(stage)
return
# 1) The scene fix: full-range, non-inverted, non-local-space constraint.
_check(is_equal_approx(look_at.constraint_angle_min, -180.0),
"LookAt constraint_angle_min is -180 (got %.3f)" % look_at.constraint_angle_min)
_check(is_equal_approx(look_at.constraint_angle_max, 180.0),
"LookAt constraint_angle_max is 180 (got %.3f)" % look_at.constraint_angle_max)
_check(not look_at.constraint_angle_invert,
"LookAt constraint_angle_invert is false")
_check(not look_at.constraint_in_localspace,
"LookAt constraint_in_localspace is false")
_check(look_at.constraint_angle_max - look_at.constraint_angle_min >= 359.0,
"LookAt constraint spans a full 360-degree band (got %.1f deg)"
% (look_at.constraint_angle_max - look_at.constraint_angle_min))
var head_bone := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
var head_body := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var head_target := rig.get_node_or_null(NodePath("IK_Targets/Head")) as Marker2D
_check(head_bone != null and head_body != null and head_target != null,
"head bone / Body/Head visual / Head aim marker all resolve")
# 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all.
var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"]
head_target.position = stand_pos
for i: int in WARMUP_FRAMES:
await physics_frame
var still := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
_check(still["flips"] == 0,
"STAND_POSE: no oscillation events over %d frames" % MEASURE_FRAMES)
_check(still["max_step"] <= STILL_MAX_STEP_RAD,
"STAND_POSE: per-frame head motion <= %.2f rad (max %.4f)"
% [STILL_MAX_STEP_RAD, still["max_step"]])
# 3) PLAY/EDIT exit discriminator: settle on the walk-LEFT pose, then snap
# the aim marker back to STAND_POSE (what snap_to_standing() does). The
# fixed constraint stays still; the old 55..305/invert/localspace constraint
# jumped ~0.8 rad in a single frame (the mirror-jitter regression).
head_target.position = Vector2(-100.0, -614.0) # walk-left aim pose
for i: int in WARMUP_FRAMES:
await physics_frame
head_target.position = stand_pos
var snap := await _measure_head(head_bone, head_body, MEASURE_FRAMES)
_check(snap["flips"] == 0,
"STAND_POSE snap: no mirror-flip frame (got %d > threshold)" % snap["flips"])
_check(snap["max_step"] <= FLIP_THRESHOLD_RAD,
"STAND_POSE snap: max one-frame rotation %.3f rad stays under the %.2f rad flip threshold"
% [snap["max_step"], FLIP_THRESHOLD_RAD])
await _free_stage(stage)
## Awaits `frames` physics frames, tracking the head bone + Body/Head visual
## rotation. Returns {flips, max_step}: `flips` counts frames whose wrapped
## step exceeds FLIP_THRESHOLD_RAD; `max_step` is the largest wrapped step.
func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Dictionary:
var flips := 0
var max_step := 0.0
var prev_bone := head_bone.global_rotation
var prev_body := head_body.global_rotation
for i: int in frames:
await physics_frame
var step := absf(wrapf(head_bone.global_rotation - prev_bone, -PI, PI))
var body_step := absf(wrapf(head_body.global_rotation - prev_body, -PI, PI))
max_step = maxf(max_step, maxf(step, body_step))
if step > FLIP_THRESHOLD_RAD or body_step > FLIP_THRESHOLD_RAD:
flips += 1
prev_bone = head_bone.global_rotation
prev_body = head_body.global_rotation
return { "flips": flips, "max_step": max_step }
# ---------------------------------------------------------------------------
# Bug 4: reactive badge accumulation across Play sessions
# ---------------------------------------------------------------------------
func _test_bug4_reactive_actions() -> void:
print("")
print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the queue test")
if rig == null:
await _free_stage(stage)
return
# Authored sequential queue (what the director builds in EDIT).
rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 })
rig.queue_action({ "type": "wait", "duration": 1.0 })
_check(rig.queue_size() == 2, "authored queue holds 2 actions")
# Cycle 1: a reactive action is injected (rule firing during PLAY), which
# auto-starts the runner; leaving PLAY stops the queue first, then
# clear_reactive_actions() (the exact _enter_edit_shared() ordering).
var qc1: Array = []
rig.queue_changed.connect(func(): qc1.append(true))
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
rig.enqueue_reactive(r1)
_check(rig.queue_size() == 3,
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 1,
"injected action is tagged reactive=true")
_check(rig.is_queue_running(),
"enqueue_reactive auto-resumes the runner from IDLE")
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed")
rig.stop_queue()
rig.clear_reactive_actions()
_check(rig.queue_size() == 2,
"stop + clear restores the authored queue (2, got %d)" % rig.queue_size())
_check(_action_types(rig) == ["speak", "wait"],
"queue types after clear are exactly the authored actions (got %s)"
% str(_action_types(rig)))
_check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change")
# Cycle 2: a bigger reactive burst then stop + clear again - counts must
# stay stable (no 1,2,3 accumulation across repeated Play sessions).
var r2: Array[Dictionary] = [
{ "type": "wait", "duration": 0.5 },
{ "type": "ragdoll" },
]
rig.enqueue_reactive(r2)
_check(rig.queue_size() == 4,
"second injection grows to 4 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 2,
"second burst tags both actions reactive")
rig.stop_queue()
rig.clear_reactive_actions()
_check(rig.queue_size() == 2,
"second stop + clear restores 2 authored actions - no accumulation (got %d)" % rig.queue_size())
_check(_action_types(rig) == ["speak", "wait"],
"queue types stay stable across two reactive cycles (got %s)"
% str(_action_types(rig)))
# clear_reactive_actions() must be a no-op while the runner is executing.
rig.start_queue()
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
var r3: Array[Dictionary] = [{ "type": "recover" }]
rig.enqueue_reactive(r3)
_check(rig.queue_size() == 3,
"reactive action can still append while the runner is executing")
var qc3: Array = []
rig.queue_changed.connect(func(): qc3.append(true))
rig.clear_reactive_actions()
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1,
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)")
_check(qc3.is_empty(),
"no queue_changed emitted by the EXECUTING no-op")
rig.stop_queue()
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
rig.clear_reactive_actions()
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0,
"after stop, clear drops the injected reactive action (queue 2)")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _count_reactive(rig: StickmanRig) -> int:
var n := 0
for a: Dictionary in rig.get_queue():
if bool(a.get("reactive", false)):
n += 1
return n
func _action_types(rig: StickmanRig) -> Array[String]:
var out: Array[String] = []
for a: Dictionary in rig.get_queue():
out.append(str(a.get("type", "")))
return out
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _func_body_contains(text: String, func_name: String, needle: String) -> bool:
var marker := "func " + func_name
var start := text.find(marker)
if start == -1:
return false
var end := text.find("\nfunc ", start + marker.length())
if end == -1:
end = text.length()
return text.substr(start, end - start).contains(needle)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dt1qgbwe4ti46
+101
View File
@@ -0,0 +1,101 @@
# test_phase4b_grid_dirty.gd
# Headless checks for the Phase 4b grid spatial dictionary lifecycle and the
# WS3 TriggerArea-move redraw bugfix:
# 1. After placing a block, its cells are in the dictionary.
# 2. Moving a block via transform_committed moves its occupancy (old cells gone).
# 3. Deleting a block removes its cells (no stale entries).
# 4. _on_transform_committed marks StageDirectorVisuals dirty (TriggerArea move
# refreshes the dashed rule connector).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_grid_dirty.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b GRID DICT / DIRTY-FLAG TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var world: Node2D = stage._world
# --- Place a ground block at cell (0,0) through the spawner path ---
var block = stage._spawner.spawn("ground", stage._terrain_cell_center(Vector2i(0, 0)))
_check(block != null, "ground block spawns")
_check((block as TerrainBlock).spawn_id == "ground", "terrain block carries spawn_id 'ground'")
stage._rebuild_grid_cells()
var cell00 := Vector2i(0, 0)
_check(not stage._grid_cells.get(cell00, []).is_empty(),
"cell (0,0) occupied after place")
# --- Move it +400 x via the committed-transform path ---
block.position += Vector2(400.0, 0.0)
var moved_nodes: Array[Node2D] = [block as Node2D]
stage._on_transform_committed(moved_nodes)
_check(stage._grid_cells.get(cell00, []).is_empty(),
"old cell (0,0) no longer occupied after move")
var new_cell := Vector2i(25, 0)
_check(not stage._grid_cells.get(new_cell, []).is_empty(),
"new cell (%s) occupied after move" % new_cell)
# --- Delete it via the stage delete path ---
stage._selection.select_only(block)
stage.delete_selected()
_check(stage._grid_cells.get(new_cell, []).is_empty(),
"cell (%s) cleared after delete (no stale entries)" % new_cell)
await process_frame
# --- TriggerArea move -> director visuals mark_dirty ---
var visuals = stage._director_visuals
visuals._dirty = false
var area: Node2D = TRIGGER_AREA.new()
area.position = Vector2(0.0, 0.0)
world.add_child(area)
var rules_arr: Array[Dictionary] = [{
"id": 1,
"trigger": { "type": "entered_area", "source": 0, "target": area.get_instance_id(), "params": {} },
"actions": [],
}]
visuals.set_rules(rules_arr)
visuals._dirty = false
area.position += Vector2(120.0, -40.0)
var moved_area: Array[Node2D] = [area as Node2D]
stage._on_transform_committed(moved_area)
_check(visuals._dirty == true,
"_on_transform_committed marks director visuals dirty (TriggerArea move)")
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://c0r76v20va6yt
+159
View File
@@ -0,0 +1,159 @@
# test_phase4b_logic.gd
# Headless sanity checks for Phase 4b pure logic (no rendering):
# 1. sandbox_theme.json parses and drives the stage's accent/popup/grid values.
# 2. Bresenham cell pathing matches hand-computed staircase runs.
# 3. AABB -> grid-cell rasterization covers the expected cells.
# 4. The three-state occupancy query (empty / same-type skip / conflicting).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_logic.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b LOGIC TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_test_theme(stage)
_test_bresenham(stage)
_test_rasterize(stage)
_test_classify(stage)
stage.queue_free()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_theme(stage: Node2D) -> void:
print("")
print("--- Theme loading ---")
_check(stage._theme_grid_default == 15.0,
"theme grid.snap_size seeds default grid (%.1f)" % stage._theme_grid_default)
_check(stage._action_popup_font_size == 24,
"theme action_popup_font_size == 24 (got %d)" % stage._action_popup_font_size)
_check(stage._accent_edit.to_html(false) == "22c6ff",
"theme edit_accent parses to 22c6ff (got %s)" % stage._accent_edit.to_html(false))
_check(stage._accent_direct.to_html(false) == "ffb300",
"theme direct_accent parses to ffb300 (got %s)" % stage._accent_direct.to_html(false))
func _test_bresenham(stage: Node2D) -> void:
print("")
print("--- Bresenham pathing ---")
# Horizontal run: (0,0)->(3,0) yields 4 cells on the row.
var cells: Array[Vector2i] = stage._bresenham_cells(Vector2i(0, 0), Vector2i(3, 0))
_check(cells.size() == 4, "horizontal (0,0)->(3,0) has 4 cells (got %d)" % cells.size())
_check(_cells_on_row(cells, 0), "horizontal run stays on row y=0")
# Diagonal: (0,0)->(3,3) yields exactly 4 staircase cells.
var diag: Array[Vector2i] = stage._bresenham_cells(Vector2i(0, 0), Vector2i(3, 3))
_check(diag.size() == 4, "diagonal (0,0)->(3,3) has 4 cells (got %d)" % diag.size())
_check(_is_staircase(diag), "diagonal run is a monotonic staircase")
# Single cell: anchor == target yields one cell.
var single: Array[Vector2i] = stage._bresenham_cells(Vector2i(2, 2), Vector2i(2, 2))
_check(single.size() == 1, "anchor==target yields 1 cell (got %d)" % single.size())
func _test_rasterize(stage: Node2D) -> void:
print("")
print("--- AABB rasterization ---")
# A single 16x16 cell at the origin maps to exactly one cell.
var one: Array[Vector2i] = stage._rasterize_aabb_to_cells(Rect2(0.0, 0.0, 16.0, 16.0))
_check(one.size() == 1 and one[0] == Vector2i(0, 0),
"16x16 AABB at origin rasterizes to cell (0,0)")
# A ground block spans 200x32 centered at origin; with grid 16 that touches
# 14 columns (x -7..6) x 2 rows (y -1..0) = 28 cells.
var aabb := Rect2(Vector2(-100.0, -16.0), Vector2(200.0, 32.0))
var cells: Array[Vector2i] = stage._rasterize_aabb_to_cells(aabb)
_check(cells.size() == 28, "ground AABB rasterizes to 28 cells (got %d)" % cells.size())
_check(cells.has(Vector2i(-7, -1)) and cells.has(Vector2i(6, 0)),
"ground rasterization covers its corner cells")
# Empty / degenerate AABB yields no cells.
_check(stage._rasterize_aabb_to_cells(Rect2()).is_empty(), "empty AABB yields no cells")
func _test_classify(stage: Node2D) -> void:
print("")
print("--- Three-state occupancy query ---")
# Spawn two ground blocks at distinct cells into the World; classify.
var world: Node2D = stage._world
var ground_a := TERRAIN_UTILS.spawn_block(world, PackedVector2Array([
Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16),
]), STAGE_SPAWNER.TERRAIN_GRID_SIZE)
ground_a.spawn_id = "ground"
ground_a.position = stage._terrain_cell_center(Vector2i(0, 0))
stage._rebuild_grid_cells()
stage.set_placement_mode("ground")
var center_cell := Vector2i(0, 0)
var far_cell := Vector2i(20, 20)
_check(stage._classify_cell(center_cell) == 2,
"same-type overlap classified as skip (2)")
_check(stage._classify_cell(far_cell) == 1,
"empty cell classified as empty (1)")
stage.set_placement_mode("")
# A conflicting object (a crate with real polygon geometry) overlapping a
# cell returns 3.
var crate := PROP_BLOCK.new()
crate.name = "Crate"
crate.polygon_points = PackedVector2Array([
Vector2(-24, -24), Vector2(24, -24), Vector2(24, 24), Vector2(-24, 24),
])
crate.position = stage._terrain_cell_center(Vector2i(0, 0))
world.add_child(crate)
stage._rebuild_grid_cells()
stage.set_placement_mode("ground")
_check(stage._classify_cell(center_cell) == 3,
"conflicting object classified as blocked (3)")
stage.set_placement_mode("")
crate.queue_free()
ground_a.queue_free()
func _cells_on_row(cells: Array[Vector2i], y: int) -> bool:
for c: Vector2i in cells:
if c.y != y:
return false
return true
func _is_staircase(cells: Array[Vector2i]) -> bool:
for i: int in range(1, cells.size()):
var d := cells[i] - cells[i - 1]
if absi(d.x) != 1 or absi(d.y) != 1:
return false
return true
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://jwc6ei4kgbw4
+466
View File
@@ -0,0 +1,466 @@
# test_phase4b_popup_anchor.gd
# Headless regression suite for the Phase 4b rule-builder popup-anchor fix
# (SandboxStage session popup anchor):
#
# Bug (popup jumps / follows the live mouse across the When->Back cycle):
# The rule-builder menus ("⚡ When..." -> trigger sub-menu -> "⬅ Back to
# actions" -> ...) previously recomputed their popup screen rect from the
# live mouse position on every hop, so the menus could land far away from
# the stickman whose action popup opened the session.
#
# Fix: scripts/sandbox_stage.gd now keeps session anchor state
# `_popup_anchor: Rect2i` / `_popup_anchor_set: bool` plus helpers
# `_set_popup_anchor(rect)`, `_clear_popup_anchor()`, and
# `_popup_anchor_rect()` (self-records from `_mouse_popup_rect()` when
# unset). The first context menu of a session records the anchor:
# `_handle_direct_click` records its rect and `_begin_edit_rule` records
# the mouse rect. Every child popup in the session reuses it
# (`ACT_WHEN`, `_open_rule_action_popup`, `_open_rule_more_popup`,
# `TRIG_BACK`). It is cleared on `_finalize_rule()`,
# `_cancel_rule_build()`, and `_clear_director_pending()`, but NOT by
# `_reset_rule_builder()` (TRIG_BACK re-opens the action popup at the same
# anchor) and NOT by `RULE_MORE_ADD`.
#
# Coverage:
# * Anchor primitives + `_popup_anchor_rect()` self-recording when unset.
# * Direct first menu records the anchor from the stickman's screen rect.
# * When -> Back -> When -> Back keeps the stored anchor IDENTICAL on every
# hop (the OLD code recomputed from the live mouse each hop; the anchor
# here is chosen far from the headless mouse rect so a recompute would
# visibly change it).
# * `_cancel_rule_build()` clears the anchor.
# * `_finalize_rule()` clears the anchor.
# * `_clear_director_pending()` (mode exit) clears the anchor.
# * `_begin_edit_rule(id)` records a fresh anchor.
# * `_reset_rule_builder()` does NOT clear the anchor (TRIG_BACK invariant).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_popup_anchor.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
# instead of hanging the headless process forever.
var watchdog := create_timer(120.0)
watchdog.timeout.connect(func() -> void:
print("FAIL: watchdog timeout - test run aborted before quit()")
quit(2))
print("")
print("========================================================")
print(" PHASE 4b POPUP-ANCHOR REGRESSION TEST (headless)")
print("========================================================")
_test_anchor_primitives_and_self_record()
_test_direct_first_menu_records_anchor()
await _test_when_back_cycle_keeps_anchor()
_test_cancel_clears_anchor()
_test_confirm_clears_anchor()
_test_mode_exit_clears_anchor()
await _test_edit_rule_records_fresh_anchor()
_test_reset_preserves_anchor()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Anchor state primitives + lazy self-record
# ---------------------------------------------------------------------------
func _test_anchor_primitives_and_self_record() -> void:
print("")
print("--- Anchor primitives + self-recording on first use ---")
var stage := _new_stage()
_check(not stage._popup_anchor_set,
"session starts with _popup_anchor_set == false")
_check(stage._popup_anchor == Rect2i(),
"session starts with an empty _popup_anchor rect")
stage._set_popup_anchor(Rect2i(123, 456, 0, 0))
_check(stage._popup_anchor_set,
"_set_popup_anchor() marks the anchor as set")
_check(stage._popup_anchor == Rect2i(123, 456, 0, 0),
"_set_popup_anchor() stores the given rect exactly")
stage._clear_popup_anchor()
_check(not stage._popup_anchor_set,
"_clear_popup_anchor() clears the set flag")
_check(stage._popup_anchor == Rect2i(),
"_clear_popup_anchor() zeroes the stored rect")
# Lazy self-record: first use when unset records the mouse rect.
var mouse_rect: Rect2i = stage._mouse_popup_rect()
var recorded: Rect2i = stage._popup_anchor_rect()
_check(recorded == mouse_rect,
"_popup_anchor_rect() when unset returns the current mouse rect")
_check(stage._popup_anchor_set,
"_popup_anchor_rect() when unset records the anchor as set")
_check(stage._popup_anchor == mouse_rect,
"self-recorded anchor matches the mouse rect exactly")
# A second call reuses the stored value instead of re-reading the mouse.
var recorded2: Rect2i = stage._popup_anchor_rect()
_check(recorded2 == stage._popup_anchor,
"subsequent _popup_anchor_rect() calls reuse the stored anchor")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Direct first menu records the anchor from the stickman's screen rect
# ---------------------------------------------------------------------------
func _test_direct_first_menu_records_anchor() -> void:
print("")
print("--- Direct first menu records the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the direct-click test")
if rig == null:
await _free_stage(stage)
return
# Deterministic camera so the expected screen rect is known exactly.
stage._camera.position = Vector2(3000.0, -400.0)
stage._camera.zoom = Vector2(0.5, 0.5)
# The click routes through the real _selection.hit_test path: click the
# center of the rig's world AABB.
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
var click_pos: Vector2 = aabb.get_center()
stage._handle_direct_click(click_pos)
var expected := Rect2i(
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
Vector2i.ZERO)
_check(stage._context_rig == rig,
"direct click selects the rig under the cursor (context rig set)")
_check(stage._popup_anchor_set,
"direct first menu records the session anchor (set flag on)")
_check(stage._popup_anchor == expected,
"direct first menu records the stickman screen rect + 24px offset (got %s)"
% str(stage._popup_anchor))
# The anchor must be DISTINCT from the headless mouse rect, otherwise the
# When/Back hop discriminator could not tell reuse from recompute.
_check(stage._popup_anchor != stage._mouse_popup_rect(),
"recorded anchor differs from the live mouse rect (discriminator valid)")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# When -> Back -> When -> Back keeps the stored anchor identical on every hop
# ---------------------------------------------------------------------------
func _test_when_back_cycle_keeps_anchor() -> void:
print("")
print("--- When/Back cycle reuses the stored anchor on every hop ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the When/Back cycle")
if rig == null:
await _free_stage(stage)
return
stage._camera.position = Vector2(3000.0, -400.0)
stage._camera.zoom = Vector2(0.5, 0.5)
# Session start mirrors the real UI flow: a Direct click on the stickman
# opens the ACTION popup and records the session anchor at the stickman's
# screen rect (far from the live mouse). Then the user presses "⚡ When...".
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
stage._handle_direct_click(aabb.get_center())
var expected: Rect2i = Rect2i(
Vector2i(stage._world_to_screen(rig.global_position)) + Vector2i(24, 0),
Vector2i.ZERO)
_check(stage._context_rig == rig,
"direct click arms the rig before the When/Back cycle")
_check(stage._popup_anchor_set and stage._popup_anchor == expected,
"session anchor recorded by the direct first menu before the hops")
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"When... keeps the recorded session anchor (set flag on)")
var r1: Rect2i = stage._popup_anchor
_check(stage._popup_anchor == expected,
"When... does not re-record over the direct-click anchor")
_check(stage._trigger_popup.visible,
"trigger popup is open after When...")
_check(int(stage._rule_step) == 1,
"After When... the builder sits at SELECT_TRIGGER (got %d)"
% int(stage._rule_step))
# Back to the action popup.
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._popup_anchor_set,
"TRIG_BACK keeps the session anchor set (no clear on reset)")
_check(stage._popup_anchor == r1,
"TRIG_BACK leaves the stored anchor unchanged (hop 1)")
_check(int(stage._rule_step) == 0,
"TRIG_BACK resets the builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"TRIG_BACK empties the rule builder dict")
_check(stage._action_popup.visible,
"TRIG_BACK re-opens the ACTION popup (no dead-end)")
# When... again from the action popup.
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"second When... still has the anchor set")
_check(stage._popup_anchor == r1,
"second When... reuses the stored anchor (hop 2, not the live mouse)")
_check(int(stage._rule_step) == 1,
"second When... returns to SELECT_TRIGGER (got %d)" % int(stage._rule_step))
_check(stage._trigger_popup.visible,
"trigger popup re-opens after the second When...")
# Back again.
stage._on_trigger_popup_id_pressed(stage.TRIG_BACK)
_check(stage._popup_anchor == r1,
"second TRIG_BACK keeps the identical stored anchor (hop 3)")
_check(stage._popup_anchor_set,
"second TRIG_BACK leaves the anchor set (session still active)")
_check(int(stage._rule_step) == 0,
"second TRIG_BACK resets the builder again (got %d)"
% int(stage._rule_step))
# Discriminator sanity: the recorded anchor is NOT the live mouse rect, so
# any code path that recomputed the rect per hop would have changed r1.
_check(stage._popup_anchor != stage._mouse_popup_rect(),
"anchor stayed distinct from the live mouse rect the whole cycle")
_check(stage._popup_anchor == r1,
"anchor is byte-for-byte identical across the whole When/Back cycle")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Cancel clears the anchor
# ---------------------------------------------------------------------------
func _test_cancel_clears_anchor() -> void:
print("")
print("--- _cancel_rule_build() clears the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the cancel test")
if rig == null:
await _free_stage(stage)
return
stage._context_rig = rig
# Enter a rule build (SELECT_TRIGGER) through the real handler.
stage._set_popup_anchor(Rect2i(900, 700, 0, 0))
stage._on_action_popup_id_pressed(stage.ACT_WHEN)
_check(stage._popup_anchor_set,
"When... keeps the pre-recorded anchor (set flag on)")
_check(int(stage._rule_step) == 1,
"rule build sits at SELECT_TRIGGER before cancel (got %d)"
% int(stage._rule_step))
stage._cancel_rule_build()
_check(not stage._popup_anchor_set,
"cancel clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"cancel zeroes the stored anchor")
_check(int(stage._rule_step) == 0,
"cancel resets the builder to IDLE (got %d)" % int(stage._rule_step))
_check(stage._rule_builder.is_empty(),
"cancel empties the rule builder dict")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Confirm clears the anchor
# ---------------------------------------------------------------------------
func _test_confirm_clears_anchor() -> void:
print("")
print("--- _finalize_rule() clears the session anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the confirm test")
if rig == null:
await _free_stage(stage)
return
# A mid-flight rule build whose trigger references the live rig (real rule
# builds always carry a valid source; this also keeps the director visuals'
# rule-draw path resolvable after _finalize_rule -> set_rules).
stage._rule_builder = {
"trigger": { "type": "action_finished", "source": rig.get_instance_id(), "target": -1, "params": {} },
"actions": [
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
],
}
stage._rule_step = 1 # a builder is mid-flight (SELECT_TRIGGER)
stage._set_popup_anchor(Rect2i(400, 300, 0, 0))
_check(stage._popup_anchor_set, "anchor is set before confirm")
var before: int = stage._event_rules.size()
stage._finalize_rule()
_check(not stage._popup_anchor_set,
"confirm clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"confirm zeroes the stored anchor")
_check(stage._event_rules.size() == before + 1,
"confirm finalized one new rule (got %d -> %d)"
% [before, stage._event_rules.size()])
_check(not stage._event_rules.is_empty()
and String(stage._event_rules[-1].get("trigger", {}).get("type", "")) == "action_finished",
"finalized rule carries the in-progress trigger")
_check(int(stage._rule_step) == 0,
"confirm resets the builder to IDLE (got %d)" % int(stage._rule_step))
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Mode exit clears the anchor
# ---------------------------------------------------------------------------
func _test_mode_exit_clears_anchor() -> void:
print("")
print("--- _clear_director_pending() clears the session anchor ---")
var stage := _new_stage()
stage._pending_walk_target = true
stage._set_popup_anchor(Rect2i(777, 888, 0, 0))
_check(stage._popup_anchor_set, "anchor is set before mode exit")
stage._clear_director_pending()
_check(not stage._popup_anchor_set,
"mode exit clears _popup_anchor_set")
_check(stage._popup_anchor == Rect2i(),
"mode exit zeroes the stored anchor")
_check(not stage._pending_walk_target,
"mode exit still cancels a pending walk target")
_check(stage._context_rig == null,
"mode exit still clears the context rig")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Edit-rule entry records a fresh anchor
# ---------------------------------------------------------------------------
func _test_edit_rule_records_fresh_anchor() -> void:
print("")
print("--- _begin_edit_rule() records a fresh anchor ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the edit-rule test")
if rig == null:
await _free_stage(stage)
return
# An existing authored rule referencing the rig as its source (the rule
# label click path populates _event_rules the same way).
var stored_rule := {
"id": 0,
"trigger": {
"type": "action_finished",
"source": rig.get_instance_id(),
"target": -1,
"params": {},
},
"actions": [
{ "type": "speak", "target": rig.get_instance_id(), "params": { "text": "hi", "duration": 1.0 } },
],
}
var rules_arr: Array[Dictionary] = [stored_rule]
stage._event_rules = rules_arr
# A stale anchor from a previous session must be replaced, not reused.
stage._set_popup_anchor(Rect2i(1, 2, 0, 0))
stage._begin_edit_rule(0)
var mouse_rect: Rect2i = stage._mouse_popup_rect()
_check(stage._popup_anchor_set,
"edit-rule entry records the anchor (set flag on)")
_check(stage._popup_anchor == mouse_rect,
"edit-rule entry records a FRESH mouse rect (got %s, mouse %s)"
% [str(stage._popup_anchor), str(mouse_rect)])
_check(stage._rule_editing_id == 0,
"edit-rule entry arms _rule_editing_id (got %d)" % stage._rule_editing_id)
_check(int(stage._rule_step) == 3,
"edit-rule entry sits at SELECT_ACTION (got %d)" % int(stage._rule_step))
_check(stage._rule_context_rig == rig,
"edit-rule entry resolves the rule source rig")
_check(stage._rule_action_popup.visible,
"edit-rule entry opens the rule-action popup")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# _reset_rule_builder preserves the anchor (TRIG_BACK invariant)
# ---------------------------------------------------------------------------
func _test_reset_preserves_anchor() -> void:
print("")
print("--- _reset_rule_builder() preserves the session anchor ---")
var stage := _new_stage()
# TRIG_BACK calls _reset_rule_builder() and THEN re-opens the action popup at
# _popup_anchor_rect(), so a reset that cleared the anchor would re-record
# from the live mouse and lose the session position.
stage._set_popup_anchor(Rect2i(555, 444, 0, 0))
stage._reset_rule_builder()
_check(stage._popup_anchor_set,
"reset keeps _popup_anchor_set true (TRIG_BACK must preserve the anchor)")
_check(stage._popup_anchor == Rect2i(555, 444, 0, 0),
"reset leaves the stored anchor untouched")
_check(int(stage._rule_step) == 0,
"reset still returns the builder to IDLE (got %d)" % int(stage._rule_step))
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://bcedsl4sq2xxs
+260
View File
@@ -0,0 +1,260 @@
# test_phase4b_stage.gd
# Headless logic checks for the Phase 4b mode-switcher / toolbar / stage state:
# 1. StageMode enum values (EDIT=0, DIRECT=1, PLAY=2).
# 2. mode_changed emits the right int per transition; toolbar/control visibility
# and grid visibility switch per mode; badge text updates.
# 3. Esc from DIRECT returns to EDIT.
# 4. Entering PLAY/DIRECT/EDIT clears pending director/rule-builder state.
# 5. Theme fallback for a missing / malformed theme file (via a copy path).
# 6. RMB ends placement; LMB keeps repeated placement active (script-level).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_stage.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b STAGE/UI LOGIC TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
_test_mode_enum(stage)
_test_mode_transitions(stage)
_test_status_bar(stage)
_test_esc_from_direct(stage)
_test_mode_clears_pending(stage)
_test_theme_fallback(stage)
_test_placement_buttons(stage)
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_mode_enum(stage: Node2D) -> void:
print("")
print("--- StageMode enum values ---")
_check(int(stage.StageMode.EDIT) == 0, "StageMode.EDIT == 0")
_check(int(stage.StageMode.DIRECT) == 1, "StageMode.DIRECT == 1")
_check(int(stage.StageMode.PLAY) == 2, "StageMode.PLAY == 2")
_check(stage._mode_buttons.size() == 3, "3-segment mode switcher built (got %d)" % stage._mode_buttons.size())
_check(not (stage._mode_buttons[0] as Button).visible or true,
"mode segments exist in top bar")
func _test_mode_transitions(stage: Node2D) -> void:
print("")
print("--- Mode transitions (visibility / badge / grid / signals) ---")
var emitted: Array[int] = []
stage.mode_changed.connect(func(m): emitted.append(m))
# Startup defaults: EDIT active (current_mode EDIT).
_check(int(stage.current_mode) == stage.StageMode.EDIT, "starts in EDIT")
_check((stage._mode_buttons[0] as Button).button_pressed,
"EDIT segment is visually pressed at startup")
_check((stage._palette_buttons["ground"] as Button).visible,
"EDIT shows palette buttons")
_check(stage._grid.visible, "EDIT shows grid")
_check(stage._mode_badge_label.text == "✏️ EDIT", "badge says EDIT at startup")
_check(stage._direct_hint_label.visible == false, "direct hint hidden at startup")
stage.set_mode(stage.StageMode.DIRECT)
_check(int(stage.current_mode) == stage.StageMode.DIRECT, "DIRECT entered")
_check(emitted == [1], "mode_changed emitted 1 for DIRECT")
_check((stage._palette_buttons["ground"] as Button).visible == false,
"DIRECT hides palette buttons")
_check(stage._grid.visible == false, "DIRECT hides grid")
_check(stage._direct_hint_label.visible, "DIRECT shows director hint")
_check(stage._mode_badge_label.text == "🎬 DIRECTING", "badge says DIRECTING")
_check(stage._mode_frame.visible, "amber DIRECT frame visible")
_check((stage._mode_buttons[1] as Button).button_pressed,
"DIRECT segment is visually pressed")
stage.set_mode(stage.StageMode.PLAY)
_check(int(stage.current_mode) == stage.StageMode.PLAY, "PLAY entered")
_check(emitted == [1, 2], "mode_changed emitted 2 for PLAY")
_check((stage._palette_buttons["ground"] as Button).visible == false,
"PLAY hides palette buttons")
_check(stage._direct_hint_label.visible == false, "PLAY hides direct hint")
_check(stage._mode_badge_label.text == "▶️ SIMULATING", "badge says SIMULATING")
_check((stage._mode_buttons[2] as Button).button_pressed,
"PLAY segment is visually pressed")
stage.set_mode(stage.StageMode.EDIT)
_check(emitted == [1, 2, 0], "mode_changed emitted 0 for EDIT")
_check((stage._palette_buttons["ground"] as Button).visible,
"back in EDIT shows palette again")
_check(stage._grid.visible, "back in EDIT shows grid again")
_check((stage._mode_buttons[0] as Button).button_pressed,
"EDIT segment pressed again")
func _test_status_bar(stage: Node2D) -> void:
print("")
print("--- Bottom status bar cursor coords (spec §2.1) ---")
_check(stage._status_cursor_coords != null, "status cursor-coords label exists")
var coords: Label = stage._status_cursor_coords
# Deterministic format check: the reader writes "X: <int> Y: <int>" per frame.
stage._update_cursor_coords()
var text: String = coords.text
_check(text.begins_with("X: ") and text.contains(" Y: "),
"cursor coords text formatted as 'X: n Y: n' (got '%s')" % text)
var parts := text.split(" ")
_check(parts.size() >= 2 and parts[1].begins_with("Y: "),
"cursor coords carries a Y component (got '%s')" % text)
func _test_esc_from_direct(stage: Node2D) -> void:
print("")
print("--- Esc from DIRECT -> EDIT ---")
stage.set_mode(stage.StageMode.DIRECT)
var esc := InputEventKey.new()
esc.keycode = KEY_ESCAPE
esc.pressed = true
stage._unhandled_key_input(esc)
_check(int(stage.current_mode) == stage.StageMode.EDIT,
"Esc while DIRECT returns to EDIT (mode=%d)" % int(stage.current_mode))
func _test_mode_clears_pending(stage: Node2D) -> void:
print("")
print("--- Mode transitions clear pending director/rule state ---")
# Enter PLAY mid-walk-pick from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._pending_walk_target = true
stage.set_mode(stage.StageMode.PLAY)
_check(stage._pending_walk_target == false,
"pending walk target cleared entering PLAY from DIRECT")
# Enter EDIT mid-rule-build from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._rule_step = 3 # RuleStep.TRIGGER_TARGET
stage._rule_builder = { "trigger": { "type": "entered_area" } }
stage._rule_hint = "Click the trigger area"
stage.set_mode(stage.StageMode.EDIT)
_check(stage._rule_step == 0,
"rule builder reset entering EDIT from DIRECT (step=%d)" % stage._rule_step)
_check(stage._rule_builder.is_empty(), "rule builder dict cleared entering EDIT")
# Enter PLAY mid-rule-build from DIRECT.
stage.set_mode(stage.StageMode.DIRECT)
stage._rule_step = 5 # RuleStep.ACTION_POSITION
stage._rule_builder = { "trigger": { "type": "arrived_at_waypoint" } }
stage.set_mode(stage.StageMode.PLAY)
_check(stage._rule_step == 0,
"rule builder reset entering PLAY from DIRECT (step=%d)" % stage._rule_step)
# Enter DIRECT from PLAY clears stale placement.
stage.set_mode(stage.StageMode.PLAY)
stage.set_placement_mode("ground")
stage.set_mode(stage.StageMode.DIRECT)
_check(stage._placement_id == "",
"entering DIRECT clears an armed placement (got '%s')" % stage._placement_id)
func _test_theme_fallback(stage: Node2D) -> void:
print("")
print("--- Theme fallback (missing / malformed JSON file) ---")
# Live theme applied to popups: the real sandbox_theme.json drives the action
# popup font size (spec §5.1: an override actually reaches the PopupMenu).
_check(stage._action_popup.get_theme_font_size("font_size") == stage._action_popup_font_size,
"action popup font-size override wired to theme (%d)" % stage._action_popup_font_size)
# These instances are NOT added to the tree, so _ready()/_load_theme() do not
# run and the scalar vars still hold their declaration defaults. Exercising
# the missing/malformed branches on them proves the fallback leaves those
# defaults untouched (no crash, no sentinel values).
var stage2: Node2D = STAGE_SCENE.instantiate()
stage2._load_theme("user://does_not_exist_phase4b.json")
_check(stage2._theme_grid_default == stage2.DEFAULT_GRID_SIZE,
"missing theme -> grid default %.1f" % stage2._theme_grid_default)
_check(stage2._action_popup_font_size == 24,
"missing theme -> default popup font size %d" % stage2._action_popup_font_size)
_check(stage2._theme.is_empty(), "missing theme leaves _theme empty")
stage2.free()
# Malformed-file fallback.
var mal := FileAccess.open("user://malformed_theme_phase4b.json", FileAccess.WRITE)
if mal != null:
mal.store_string("{ not valid json !!!")
mal.close()
var stage3: Node2D = STAGE_SCENE.instantiate()
stage3._load_theme("user://malformed_theme_phase4b.json")
_check(stage3._theme_grid_default == stage3.DEFAULT_GRID_SIZE,
"malformed theme -> grid default %.1f" % stage3._theme_grid_default)
_check(stage3._action_popup_font_size == 24,
"malformed theme -> default popup font size %d" % stage3._action_popup_font_size)
_check(stage3._theme.is_empty(), "malformed theme leaves _theme empty")
stage3.free()
DirAccess.remove_absolute("user://malformed_theme_phase4b.json")
func _test_placement_buttons(stage: Node2D) -> void:
print("")
print("--- Placement button semantics (RMB ends, LMB keeps tool) ---")
# LMB repeated placement: set_placement_mode arms a palette button and stays armed.
stage.set_mode(stage.StageMode.EDIT)
stage.set_placement_mode("ground")
_check(stage._placement_id == "ground", "ground placement armed")
_check((stage._palette_buttons["ground"] as Button).button_pressed,
"ground palette button pressed when armed")
# A successful terrain drag commit keeps the tool armed (LMB repeated placement).
var world: Node2D = stage._world
var before := _terrain_child_count(world)
var start_cell := Vector2i(60, 60)
var end_cell := Vector2i(62, 60)
stage._begin_terrain_drag(stage._terrain_cell_center(start_cell))
stage._update_terrain_drag(stage._terrain_cell_center(end_cell))
stage._commit_terrain_drag()
var after := _terrain_child_count(world)
_check(stage._placement_id == "ground",
"LMB drag commit keeps placement armed (got '%s')" % stage._placement_id)
_check(after - before >= 1, "drag commit placed %d new terrain block(s)" % (after - before))
_check(stage._terrain_dragging == false, "drag ends after commit")
# RMB ends placement + unpresses the palette button.
var rmb := InputEventMouseButton.new()
rmb.button_index = MOUSE_BUTTON_RIGHT
rmb.pressed = true
stage._handle_world_click(rmb)
_check(stage._placement_id == "", "RMB clears placement (got '%s')" % stage._placement_id)
_check((stage._palette_buttons["ground"] as Button).button_pressed == false,
"ground palette button unpressed after RMB")
# RMB with nothing armed is a no-op (still EDIT).
stage._handle_world_click(rmb)
_check(int(stage.current_mode) == stage.StageMode.EDIT,
"RMB with no placement leaves mode untouched")
func _terrain_child_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TerrainBlock:
n += 1
return n
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://cinfvn7tmkn1q
+120
View File
@@ -0,0 +1,120 @@
# test_phase4b_terrain.gd
# Headless checks for Phase 4b terrain drag-painting batch commit semantics
# (spec §2.6.4/2.6.5 + §5.5/5.6):
# 1. Dragging across an EMPTY open region commits one block per path cell.
# 2. Dragging over an existing same-type block commits ZERO new blocks
# (same-type overlap skip; advisory dictionary, no double-create).
# 3. A cell occupied by a conflicting prop is skipped while its empty
# neighbours still spawn (Case C).
# 4. A drag never leaves ghost terrain behind in the World.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_terrain.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const TERRAIN_BLOCK := preload("res://scripts/terrain_block.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b TERRAIN DRAG COMMIT TEST (headless)")
print("========================================================")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var world: Node2D = stage._world
# --- Empty-region drag: 3 path cells -> 3 blocks ---
var before := _terrain_count(world)
stage.set_mode(stage.StageMode.EDIT)
stage.set_placement_mode("ground")
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
_check(stage._drag_cells.size() == 3, "path has 3 cells (got %d)" % stage._drag_cells.size())
stage._commit_terrain_drag()
var after_empty := _terrain_count(world)
_check(after_empty - before == 3, "empty drag committed 3 blocks (got %d)" % (after_empty - before))
# --- Same-type overlap drag: over a region now occupied by ground -> 0 ---
var b2 := _terrain_count(world)
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0)))
stage._commit_terrain_drag()
var a2 := _terrain_count(world)
_check(a2 - b2 == 0, "same-type overlap drag committed 0 blocks (got %d)" % (a2 - b2))
# --- Conflicting-object skip: put a crate in a far empty region, drag over it ---
# With block stride (Ground = 200x32), the crate's 48x48 AABB covers exactly
# one block cell (70,0), not a 16-px span.
var crate := _spawn_crate(world, stage._terrain_cell_center(Vector2i(70, 0)))
stage._rebuild_grid_cells()
var b3 := _terrain_count(world)
# Drag cells (65,0)..(75,0): the middle cell (70,0) conflict-red, edges empty.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(65, 0)))
stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(75, 0)))
_check(stage._drag_cells.size() == 11, "conflict drag path has 11 cells (got %d)" % stage._drag_cells.size())
var cls_edge: int = stage._classify_cell(Vector2i(65, 0))
var cls_mid: int = stage._classify_cell(Vector2i(70, 0))
_check(cls_mid == 3, "crate cell classified conflict (3) (got %d)" % cls_mid)
_check(cls_edge == 1, "edge cell (65,0) empty (1) (got %d)" % cls_edge)
stage._commit_terrain_drag()
var a3 := _terrain_count(world)
# Path length 11 minus the single crate cell that gets skipped = 10 commits.
_check(a3 - b3 == 10, "conflict drag committed only empty cells (expected 10, got %d)" % (a3 - b3))
# --- No ghost terrain leaked into World ---
var ghost_blocks := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
ghost_blocks += 1
_check(ghost_blocks == a3, "World contains only committed blocks (got %d, expected %d)" % [ghost_blocks, a3])
crate.queue_free()
stage.queue_free()
await process_frame
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _spawn_crate(world: Node2D, pos: Vector2) -> Node2D:
var crate = preload("res://scripts/prop_block.gd").new()
crate.name = "Crate"
crate.polygon_points = PackedVector2Array([
Vector2(-24, -24), Vector2(24, -24), Vector2(24, 24), Vector2(-24, 24),
])
crate.position = pos
world.add_child(crate)
return crate
func _terrain_count(world: Node2D) -> int:
var n := 0
for child: Node in world.get_children():
if child is TERRAIN_BLOCK:
n += 1
return n
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dvc538j0obier
+117
View File
@@ -0,0 +1,117 @@
# test_phase4b_walk.gd
# Headless regression checks for the Phase 4b walk-waypoint arrival-jitter fix
# (spec §3.4 / §5.9):
# 1. On-mesh waypoint: steering mode latches "nav" once, exactly one `arrived`
# fires, and the rig position is unchanged for 60+ physics frames after.
# 2. Off-mesh waypoint: mode latches "direct" once, one arrive, stable.
# 3. No vertical jitter: the root's final resting Y equals the snapped target.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_walk.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const SETTLE_FRAMES := 60
const MAX_FRAMES := 300
var _checks := 0
var _failures := 0
var _arrived_count := 0
func _initialize() -> void:
call_deferred("_run")
func _on_arrived(_target: Vector2) -> void:
_arrived_count += 1
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 4b WALK JITTER / LATCH TEST (headless)")
print("========================================================")
# On-mesh waypoint (x=50 inside the 200-wide ground slab).
await _walk_case("on-mesh", Vector2(50.0, 0.0), "nav")
# Off-mesh waypoint (x=200 outside the slab edge at x=100).
await _walk_case("off-mesh", Vector2(200.0, 0.0), "direct")
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void:
print("")
print("--- %s waypoint (%s) ---" % [label, target_feet])
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
var ground = stage._spawner.spawn("ground", Vector2(0, 0))
stage._rebake_navigation()
var rig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "%s: stickman spawns" % label)
if rig == null:
stage.queue_free()
await process_frame
return
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_mode_latched, "%s: mode not latched before map sync" % label)
var frames := 0
while not rig._walk_done and frames < MAX_FRAMES:
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: mode is latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
# Final position equals the snapped root target (feet target + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
# Body-bob settle: Torso marker restored to STAND_POSE (0,10).
var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso"))
if torso != null:
var stand_torso: Dictionary = RIG.STAND_POSE["Torso"]
var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10))
_check((torso as Node2D).position.distance_to(expected_pos) <= 1.0,
"%s: Torso marker restored to standing pose (dist=%.3f)" % [label, (torso as Node2D).position.distance_to(expected_pos)])
else:
_check(false, "%s: IK_Targets/Torso missing" % label)
# Post-arrival stability for 60 physics frames.
var p0: Vector2 = rig.global_position
var settled := true
for i in SETTLE_FRAMES:
await physics_frame
if rig.global_position != p0:
settled = false
break
_check(settled and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
stage.queue_free()
await process_frame
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://die6ipduc15sl