Files
stickman/AGENTS.md
T

1467 lines
127 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — stickman (Godot 4.7.1)
## Project type
- **Godot 4.7.1** 2D/GUI project (Forward Plus renderer)
- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
- **Main scene:** `res://scenes/main.tscn` (set as `run/main_scene` in `project.godot`; hosts the Stickman Editor)
## Feature branches and worktrees
- Start each new feature or phase that will create project files in a dedicated Git branch and worktree. Use the same branch from its design brief and specs through implementation and verification, so parallel features stay isolated.
- Read-only discussion and proposals do not need a worktree. Before the first persistent feature change, inspect `git status --short`, `git branch --show-current`, and `git worktree list`.
- If the current checkout is already the matching feature worktree, continue there. Otherwise, create a new worktree at `.worktrees/<feature-slug>` with branch `feature/<feature-slug>`, based on the integration branch (usually `main`). Include the phase number in the slug when known, such as `phase-20-stage-search`.
- Example from the repository root: `git worktree add .worktrees/phase-20-stage-search -b feature/phase-20-stage-search main`. Confirm the resulting path and branch with `git worktree list` and `git branch --show-current`.
- Reuse an existing worktree for the same feature. Do not switch branches in the user's current checkout, overwrite an existing path/branch, stash, reset, clean, or move uncommitted work. If relevant uncommitted changes need to be carried into the feature worktree, ask how to handle them before proceeding.
- Pass the absolute worktree path and branch name to every subagent. All project reads, writes, Godot commands, and visual checks for the feature must target that worktree. If no worktree path was supplied, do not write feature files in the shared checkout; tell the orchestrator what is missing.
- Do not merge feature branches automatically. Keep each feature branch available for review and merge only when the user asks or the active workflow explicitly authorizes it.
- `.worktrees/` is a local worktree container and must remain gitignored.
## Repository layout
- `scripts/` and `scenes/` stay as separate roots with matching feature groups. Editor controllers live in `scripts/editors/{stickman,prop,stage}`; stage, stickman runtime, props, terrain, and speech systems live in `scripts/{stage,characters/stickman,props,terrain,speech}`.
- Reusable UI, thumbnails, and `ContentPaths` live in `scripts/shared/`. Harnesses and rig-authoring scripts live in `scripts/tools/`.
- The startup wrapper is `scenes/main.tscn`. Feature scenes are grouped under `scenes/editors/`, runtime stage scenes under `scenes/stage/`, stickman rigs under `scenes/characters/stickman/`, tools under `scenes/tools/`, and preserved old rigs under `scenes/legacy/`.
- Project libraries live in `content/stickmen/`, `content/props/`, and `content/stages/`; fonts stay in `resources/fonts/`.
- Unapproved Artist output lives under ignored `content/review/artist/<request-slug>/`. It must include a preview and `review.md`, does not enter the immediate-child prop/stickman library scans, and is promoted to the appropriate production folder only after explicit user approval.
- Maintained docs live in `docs/{guides,architecture,specs,plans}/`; older plans and project references live in `docs/archive/`. Keep `README.md` and `AGENTS.md` at the root.
- `ContentPaths.canonicalize(path)` maps only old project paths under `stickmen/`, `props/`, and `game_scenes/` into `content/`. It preserves unknown and external paths. Keep stage serialization and persisted paths routed through it when adding path-bearing data.
## Command-line testing & debug playback
- **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand:
- macOS: `/Applications/Godot.app/Contents/MacOS/Godot`
- Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"`
- `<project>` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows).
- **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`):
`<godot> --headless --path <project> --script res://tests/test_cli_scene.gd`
- Path migration compatibility: `tests/test_content_paths.gd`.
- Prop Editor suites (same pattern): `tests/test_prop_editor_file.gd`, `tests/test_prop_editor_theme.gd`,
`tests/test_prop_editor_canvas.gd`, `tests/test_prop_editor_thumb.gd`, `tests/test_prop_editor_round3.gd`.
- **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal):
`<godot> --path <project> res://scenes/stage/sandbox_stage.tscn -- --play --scene <path>.scene`
- Args after `--` are parsed by `scripts/tools/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`.
- `--scene <path>` (also `--scene=<path>` or a single bare positional) loads the scene; `--play` enters PLAY after a successful load.
- `--play` requires a scene — alone it shows the error dialog and stays blank/EDIT.
- A load failure shows a "Could Not Load Scene" dialog and falls back to a blank scene.
- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines.
## Project overview
Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a
stick-figure editor. Its broader product direction is an approachable moviemaker where
children can create and customize stickmen, build sets with floors/terrain and props,
arrange characters and objects, direct behavior with queued actions and event-triggered
rules, and play the resulting scenes.
The codebase currently has two primary workflows. The main `Control`-based stickman editor
authors body-part vector shapes and assembles them in the "Whole Stickman" preview. The
Sandbox Stage provides the wider scene workflow through EDIT/DIRECT/PLAY modes, terrain,
props, live stickman rigs, action queues, and events. The editor remains the configured
`run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools
that form the foundation of the broader moviemaking experience.
The **Prop Editor** (Phase 9) is a standalone, kid-friendly vector-prop authoring tool — the
authoring counterpart to the stickman editor — for creating, editing, and saving `.prop`
vector prop assets. It is launched via **F6** on `res://scenes/editors/prop/prop_editor.tscn` and is **not**
the configured `run/main_scene`.
## Shared Stage inspector UI
- For new **in-stage selected-object inspectors**, use the approved
[Stage panel UI system spec](docs/specs/stage_panel_ui_system.md) as the design and
implementation reference. The shared controls live in `scripts/shared/stage_ui/`:
`StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePanelToggleRow`, and
`StagePanelSwitchVisual`; shared visual tokens live under `stage_panels` in
`SandboxTheme` (`scripts/stage/sandbox_theme.gd` and `sandbox_theme.json`). Extend these
components and tokens before creating parallel panel styling or behavior.
- This kit currently targets in-stage selection/inspector cards. For modal dialogs, editor
inspectors, or other distinct UI workflows, review the spec for reusable visual rules but
decide whether the components' layout and interaction model fit before adopting them.
- For temporary in-stage target or placement capture in **EDIT** or **DIRECT**, follow the
transient capture-bar pattern in the [Stage panel UI system spec](docs/specs/stage_panel_ui_system.md).
Keep the prompt and visible Cancel together, leave the mode pill unobstructed, and reuse or
generalize `StageUseTargetCaptureBar` instead of copying its styling.
## Child-facing dialogs and popups
- When a user prefixes a UI/UX question or request with `uiux:` or `uiux,`, route it to the
custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit
delegation request. The UI/UX agent is read-only; carry its design guidance into implementation
separately when implementation is requested.
- Before making, mocking, reviewing, or suggesting Prop or Stickman UI/UX changes, have `@uiux`
read `docs/guides/editor_visual_style_guide.md` and consult the relevant implemented patterns
and feature specs. Treat the guide as the current visual baseline; call out intentional
departures and distinguish current behavior from recommendations.
- For new or revised child-facing dialogs, popups, and short setup flows, follow the
[Dialog Design Principles](docs/specs/dialog_design_principles.md). Use rounded containers
and controls, clear borders, blue primary actions with bold white labels, comfortable button
spacing, and large readable type. Prefer a visible, labeled action such as Cancel, Done, or
Back; do not make a small corner × the default or only way to close a dialog.
- When a dialog resizes after a tab, filter, or other in-place choice, preserve its current
anchor so the clicked control does not jump away from the pointer. Keep the dialog within the
safe viewport by limiting its height and scrolling content; nudge it only as much as needed
to keep essential controls visible. Avoid recentering after every content change.
- Keep reusable values in `SandboxTheme` or `PropTheme`. The Choose Scenery dialog in
`scripts/shared/ui/asset_selector.gd` and the `popups.asset_selector` tokens are the current
implementation reference. Adapt its layout to each workflow.
## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
- Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy `scenes/legacy/stick.tscn` rig.
## Architecture
- `scripts/editors/stickman/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar,
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel
selection so only one shape is selected at a time (`shape_selected` → deselect others).
Collects per-part `{shapes[], position, rotation, scale, pivot, length, guide_offset}` for save/load.
- **Phase 8 save export:** writes top-level `proportions` (hardcoded master-rig rest-pose
constants 168/200/200/200/391.5 via the `PROPORTIONS` const) and per-part `pivot`/`length`
computed from the panel's local shape bounding box (`_compute_part_pivot_length()`): `pivot` =
bbox center, `length` = bbox width for the 4 arm parts (`X_AXIS_PARTS`) and bbox height
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
recomputed on every save.
- **Phase 9 Round 5 guide-offset export:** each part's save dict also gains `"guide_offset":
{x, y}` = (part bbox center in preview space) − (guide joint in preview space), computed in
`_collect_all_shape_data()` as `(pos + pivot) - _whole_preview.get_guide_joint_preview(...)` —
a **pure master-space delta** (both points are preview-world coordinates, so panel-size terms
cancel). The part→joint map is `const GUIDE_JOINT_FOR_PART` (head→"Neck" — the head bone's
rig attachment origin, NOT the circle center — torso→"Hips", upper arms→Shoulders, lower
arms→Elbows, upper legs→"Hips", lower legs→Knees). Write-only metadata like `pivot`/`length`;
the load path (`_apply_json_data`) ignores it, so v1.0–v1.4 files load unchanged and gain the
key on their next save.
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
most-recent-first), loads from `settings.json` (`recent_colors` key) in
`_load_settings()`, saves on each color selection via `_save_settings()`, and
broadcasts to all 10 panels via `_broadcast_recent_colors()`.
- `_on_color_selected(color, part_name)`: deduplicates, inserts the hex string at the
front, trims to 8 entries, saves settings, then broadcasts to all panels.
- `_broadcast_recent_colors()`: pushes `_recent_colors` to every panel via
`BodyPartPanel.set_recent_colors(_recent_colors)`.
- **Phase 6 status bar (cursor coords):** `_process()` polls
`get_global_mouse_position()` each frame, checks each panel via
`is_cursor_over_drawing(global_pos)` and the preview via
`is_cursor_over_preview(global_pos)`, converts to world space via
`global_to_world(global_pos)`, and writes `"X: ### Y: ###"` to `_status_cursor_coords`.
- **Phase 6 snap status:** `_status_snap_status` displays `"SNAP: ON"` / `"SNAP: OFF"`,
set in `_ready()` and re-synced when snap is toggled (`_on_edit_menu_id_pressed`).
- **Phase 7 pose guide toggle:** stores `_show_guide: bool` (default `true`), persisted to
`settings.json` under the `show_pose_guide` key (default `true`) via `_load_settings()` /
`_save_settings()`. The View menu item (id 1) is a **dynamic, text-only** label (no
checkmark): "Hide Pose Guide" while the guide is visible, "Show Pose Guide" while hidden,
set via `_guide_menu_label()`. `_update_guide_menu_item()` refreshes only the item text;
it is synced on the `about_to_popup` signal (`_on_view_menu_about_to_popup`) and again at
the end of `_load_settings()` (so a persisted `show_pose_guide: false` shows "Show Pose
Guide" immediately at startup). `_on_view_menu_id_pressed` (id 1) toggles the state, saves,
and broadcasts. `_broadcast_settings()` pushes the value to the preview via
`WholeStickmanPreview.set_show_guide(_show_guide)` (called in `_ready()` after
`_load_settings()`).
- `scripts/editors/stickman/body_part_panel.gd` — `class_name BodyPartPanel`, `extends PanelContainer`.
Reusable per-part editor. Public API:
- `set_shape_data(data: Variant)` — import shape data (Array or single Dictionary; used on Load/Clear)
- `get_shape_data() -> Array[Dictionary]` — export array of `{shape_type, points, color, closed, vertex_flags}`
- `clear_shape()` — reset panel, clears all shapes (does **not** emit `shape_changed`)
- `select()` — mark the panel's topmost shape as selected (white outline highlight)
- `deselect()` — clear selection and cancel any in-progress vertex drag
- `signal shape_changed(shapes: Array)` — emitted when any shape is created, modified, deleted, or reordered
- `signal shape_selected()` — emitted on left-click; the editor deselects all other panels
- `signal color_selected(color: Color)` — emitted when the color is confirmed (OK button)
- `set_recent_colors(colors_hex: Array)` — clears existing ColorPicker presets and populates with the given hex colors
- `is_cursor_over_drawing(global_pos: Vector2) -> bool` — true if `global_pos` is over the drawing surface
- `global_to_world(global_pos: Vector2) -> Vector2` — maps a global position to drawing ("world") space
- **Phase 2 vertex editing:** left-click on a shape selects it; drag a vertex handle to
reshape in real time; with a shape selected, right-click near an outline edge offers
"Create Point" (inserts a vertex flagged `1` at the edge midpoint). Original vertices
are filled circles; user-created vertices are hollow rectangles.
- **Phase 4 multi-shape:** panels store a `shapes[]` array. Right-click context menu
includes "Send Back" (id 7) and "Bring Forward" (id 8) for Z-ordering. "Delete" (id 5)
removes the specific shape under the mouse. `_selected_shape_idx` tracks which shape
is active for vertex editing.
- **Per-panel zoom:** mouse wheel multiplies `_zoom` by 1.10, clamped to `[0.3, 3.0]`;
drawing and input hit-testing both run in world space via `draw_set_transform`.
- **Phase 6 touchpad:** `_gui_input` handles `InputEventMagnifyGesture` (pinch zoom)
and `InputEventPanGesture` (2-finger drag panning), both checked before
`InputEventMouseButton`. Pan gesture delta is multiplied by 3.0 for speed
parity with mouse panning.
- **Phase 6 cursor-centered zoom:** both mouse wheel and pinch zoom adjust
`_pan_offset` so the world point under the cursor stays fixed during zoom.
- **Selection gizmos always on top:** bounding box, rotation circle, and scale
crosses for the selected part are drawn in a second pass after all parts,
via `_selected_gizmo_bounds`, so they always render in front.
- `scripts/editors/stickman/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, `extends Control`;
the assembly preview. Owns per-part position/rotation/scale, Z-order (`_part_order`),
selection + gizmos, grid drawing, and pan/zoom. Public API includes `set_body_parts()`,
`set_show_guide(enabled: bool)`, `reset_view()`, `is_cursor_over_preview(global_pos)`, and
(Phase 9 Round 5) `get_guide_joint_preview(joint_name) -> Vector2` — returns the preview-space
position of a `GUIDE_JOINTS` entry via `_guide_to_preview()`, guarded against unknown names
(`push_warning` + `Vector2.ZERO`).
- **Phase 7 pose silhouette guide:** `set_show_guide()` stores `_show_guide: bool`
(default `true`) and redraws; `_draw_silhouette_guide()` is called in `_on_preview_draw()`
after the part loop and before the drag highlight/selection gizmos, so it renders above
the grid **and in front of user parts** (ghosting over them), below the selection
gizmos and drag highlight. The guide is **centered** at the default view and after
`Reset Views`, computed at draw time from the live preview size via
`_guide_to_preview()` = `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE +
preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
world-space fixture that moves with pan/zoom. Joint positions are **hardcoded
constants** derived from the `stickman_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors
Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles;
`GUIDE_SCALE = 1.0`, `GUIDE_FIGURE_CENTER = (0, -93.75)`, `GUIDE_HEAD_RADIUS = 100.0`).
Color-coded: left limbs cyan-blue `Color(0.35, 0.70, 1.00)`, right limbs orange-red
`Color(1.00, 0.50, 0.20)`, central spine/head white, with lines at alpha `0.45` and joint
dots at alpha `0.65`. Joint dots use `GUIDE_JOINT_RADIUS / _zoom` (6 px constant screen
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
part dragging/selection.
- `scripts/editors/prop/prop_editor.gd` — `class_name PropEditor`, `extends Control`; the **Prop Editor**
controller (Phase 9, **not wired into the editor**; launched via **F6** on
`res://scenes/editors/prop/prop_editor.tscn`). Owns the menu bar, save/load/new flow, `.prop`
(de)serialization, the material-preset + per-shape inspector, the mode toolbar, the status
bar, the window title, the exit dialog, and the autosave/recovery pipeline. State:
`_theme: PropTheme`, `_material_preset: int` (`PropBlock.MaterialPreset`), `_current_path`,
`_dirty`, `_clipboard`, `_thumb: PropThumbnail`, `_thumb_cache: ThumbnailCache`, `_ui_theme:
Theme` (local only — never mutates the global GUI theme), plus `_recent_props`.
- **Menu bar:** **📁 File** = 🆕 New / 💾 Save / 📄 Save As... / 📂 Load... / separator /
🕑 **Recent** submenu; **👁️ View** = 🔍 Reset View. There is **no Edit menu** (Round 4
removed it — grid/snap moved to the top-right controls). Menu labels carry kid-friendly
emoji.
- **Recent props (Round 5/6):** `SETTINGS_PATH := "user://prop_editor_settings.json"` (schema
`{ "version": 1, "recent_props": [...] }`), `RECENT_MAX := 5`; `_load_settings()` /
`_save_settings()` read/write the list. `static func update_recent_list(list, path,
max_count = RECENT_MAX) -> Array` is a pure helper (dedupe + front-insert + trim). The
**🕑 Recent** submenu (`FileMenu/RecentProps` child `PopupMenu`) is rebuilt on
`about_to_popup` (`_update_recent_menu`): missing files are pruned (and settings re-saved)
and an empty list shows a disabled "(No recent props)" placeholder. `_push_recent(path)` is
called on every successful **load AND save**; selecting a recents entry calls `_load_from()`,
and a missing file shows the error dialog + removes the entry. `RECENT_MAX` is 5,
most-recent-first.
- **Top-right controls (Round 4):** built in `_build_top_right_controls()` into `%TopRightControls`
(the right side of `%TopRow`, beside `%MenuBar`): a `Grid:` `Label` + `_grid_spin` `SpinBox`
(1–512, initialized from `_theme.grid_size()`) + a `Snap` `CheckBox` (`_snap_check`). This
replaces the old `Edit → Configure Grid / Snap...` dialog/menu entirely.
- **Mode toolbar (Round 2/3):** a centered `%ModeBar` `HBoxContainer` above the canvas
(`_build_mode_toolbar()`) holding two `toggle_mode` `Button`s sharing a `ButtonGroup`:
🖐️ **Arrange** (`EditMode.TRANSFORM`) and ✏️ **Reshape** (`EditMode.POINTS`).
`static func mode_button_text(mode: int) -> String` is the pure, headless-testable
label builder (`MODE_EMOJI`/`MODE_LABELS`, clamps to the last entry). Each button applies
`_theme.font_mode()` + `_theme.mode_button_min_height()` and sets
`focus_mode = FOCUS_NONE` (Round 4 fix — the `focus` stylebox draws after `pressed`, which
otherwise masked the selected/accent state until the cursor moved). `_sync_mode_buttons()`
re-presses the right button on `mode_changed` (via `set_pressed_no_signal`) and colors the
label with `accent_color()`.
- **Inspector (`_build_inspector()`, built into `%InspectorList`):** a material-preset
`OptionButton` (`PRESET_LABELS` None/Wood/Rubber/Cardboard/Metal) + read-only mass/friction/
bounce `Label`s sourced from `PropBlock.mass_for()` / `PropBlock.physics_material()`
(single source of truth — never themed), then per-shape fill/outline `ColorPickerButton`s +
an outline-width `SpinBox`, a vertex-count label, and an **Erase Shape** button, then the
**Add Shape** section (primitive `OptionButton` + per-primitive param `SpinBox`es +
**Add a Shape** button). `PRIMITIVE_IDS`/`PRIMITIVE_LABELS` = Box/Plank/Triangle/Line/Circle
(**Ball removed, Round 4** — circle covers it; `"ball"` stays available programmatically).
`_refresh_inspector_from_selection()` syncs the controls from the selected shape and gates
the **circle Radius row**: when the shape's `reshaped` flag is set the `_radius_row` is
hidden and a muted "✏️ This shape was reshaped" note (`_reshaped_note`) is shown instead.
`_sync_mode_controls(mode)` hides the Add-Shape section (`_add_section`) and the Erase
button in POINTS (Reshape) mode, called from `_on_canvas_mode_changed` and `_ready()`.
- **Window title (Round 3):** `static func build_window_title(prop_name, file_path, dirty) ->
String` is a pure, headless-testable builder producing `[name][ — filename.prop ][ *]`
(empty name → `UNTITLED_NAME` = "Untitled"; `file_path.get_file()` keeps the `.prop`
extension; ` *` when dirty). `_update_window_title()` is the single re-derivation site,
called from `_update_save_status()` (which is already invoked on every relevant transition).
`_on_save_file_selected()` normalizes the path to `.prop` so `get_file()` always carries the
extension.
- **Exit dialog (Round 3):** `get_tree().auto_accept_quit = false` in `_ready()` +
`_notification(NOTIFICATION_WM_CLOSE_REQUEST)` → `_request_close()`. Clean tree quits
immediately; dirty shows the themed `_exit_dialog` ("⚠️ Unsaved Changes") with **💾 Save** /
**🗑️ Discard** (`add_button`, action `&"discard"`) / **❌ Cancel**. Save with no
`_current_path` opens the Save dialog (the pending flag stays set); otherwise
`_save_and_finish()`. `_save_to(path, with_thumbnail)` returns success and **skips the
thumbnail render on exit-save** (the PNG is a cache nicety — a fast, deterministic quit). A
successful save-then-quit clears the autosave via `_save_to`. The **same dialog** is reused
by **File → New** (`_request_new()` sets `_pending_new`, different prompt text; Save →
`_do_new()`, Discard → `_do_new()`, Cancel → stay).
- **Autosave / recovery (Round 3):** `AUTOSAVE_PATH := "user://prop_editor_autosave.prop"`,
`AUTOSAVE_DEBOUNCE := 1.0`. Dirty setpoints route through `_mark_dirty()` (sets `_dirty`,
refreshes the status bar/title, arms `_autosave_remaining`); `_process(delta)` counts down
and calls `_write_autosave()`, which stamps `metadata.autosave = true` /
`metadata.source_path` / `metadata.modified_unix` and writes atomically via
`PropFile.save_safe()` (no re-trigger: it does not touch `_dirty` or emit `shape_changed`).
`_clear_autosave()` removes the file (also resetting the countdown). `_check_autosave_recovery()`
(called from `_ready()` after dialogs are built) prompts 🔄 **Restore** / 🗑️ **Discard** on a
stale-checked autosave (if the saved source exists and is newer-or-equal, the autosave is
dropped silently); Restore calls `_apply_json_data(raw, true)` (the `is_recovery` flag keeps
the prop **dirty**) and restores `_current_path`. Clear semantics: clear on successful
save/load/new/discard-quit; **kept after Restore**; stale autosaves are dropped at startup.
- **Save flow:** `_save_to(path, with_thumbnail = true) -> bool` writes `PropFile.save(path,
_build_json_data())`, updates `_current_path`/`_dirty`, clears the autosave, refreshes
status/title, and (when `with_thumbnail`) `await`s `_save_prop_thumbnail()`. The thumbnail
path reuses `PropThumbnailScript.render(PropFile.flatten_to_payload(shapes),
_material_preset)` then `ThumbnailCacheScript.save_png(tex, _thumb_cache.prop_file_png(path))`
(200×200 `user://thumbnails/props/<basename>_<mtime>.png`); a blank/headless capture
(`null`) is skipped silently. The editor preloads `prop_file.gd`, `prop_theme.gd`,
`prop_block.gd`, `prop_canvas.gd`, `thumbnails/prop_thumbnail.gd`, and
`thumbnails/thumbnail_cache.gd` via `preload` consts. `_apply_json_data(json, is_recovery =
false) -> String` returns `""` on success or an error string (dialog on failure).
- **Phase 10a final controls:** the Arrange top bar orders **Transform Whole Prop**,
**Normalize Size**, **Center Prop**, and **Show Stickman Guide**. The numeric scale textbox
and Apply Scale button are not exposed. Transform Whole Prop is Arrange-only and previews
whole-document move, origin-based rotation, and uniform resize; Snap rounds move deltas,
Ctrl snaps rotation to 15°, editable anchors follow, and Esc cancels the preview. Reshape
and Attach hide Transform Whole Prop and Normalize Size; Center Prop and Show Stickman Guide
remain available.
- `scripts/editors/prop/prop_canvas.gd` — `class_name PropCanvas`, `extends Control`; the Prop Editor's
**multi-shape vector canvas** (Phase 9, **not used by the editor**), mirroring
`BodyPartPanel`'s proven input model (cursor-centered zoom, pinch/pan gestures, middle-mouse
pan, vertex drag, right-click context menu, z-order) while adding prop-specific needs
(per-shape fill/outline/width, polygon/circle/line primitives, transform gizmos, an in-memory
clipboard, grid-snap sanitize-on-commit). Enums `ShapeType { POLYGON, CIRCLE, LINE }`,
`VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }`, `DragMode { NONE, VERTEX, TRANSLATE, ROTATE,
SCALE }`, and `EditMode { TRANSFORM, POINTS }`. Signals `shape_changed(shapes)`,
`shape_selected(idx)`, `shape_copy_requested(shape)`, `paste_from_context_requested(world_pos)`,
`vertex_created(shape_idx, vertex_idx)`, `mode_changed(mode)`. State `shapes:
Array[Dictionary]`, `_selected_shape_idx`, `_zoom`/`_pan_offset`, `_grid_size`/`_snap_enabled`,
`_edit_mode` (default TRANSFORM), `_has_clipboard`, and the drag/rotate/scale state. Public
API mirrors the Phase 9 spec: `set_shape_data`/`get_shape_data`, `clear`, `select`/`deselect`/
`get_selected_index`, `set_grid_size`/`set_snap_enabled`/`reset_view`, `add_primitive`,
`insert_vertex`/`remove_vertex`, `bring_forward`/`send_back`, `mirror_x`/`mirror_y`,
`copy_shape`/`paste_shape`/`set_has_clipboard`, `translate_selected`/`rotate_selected`/
`scale_selected`, `is_cursor_over_drawing`/`global_to_world`, `set_shape_radius`,
`set_context_menu_font_size`, `create_point_on_edge`, `hit_test_segment`, and
`set_edit_mode`/`get_edit_mode`.
- **Zoom / pan:** wheel zoom is cursor-centered (mouse wheel and pinch), clamped to
`[MIN_ZOOM 0.3, MAX_ZOOM 3.0]` with `ZOOM_STEP 1.10`; middle-mouse drag and
`InputEventPanGesture` pan (delta × 3 for parity), matching `BodyPartPanel`/preview.
- **Edit modes (Round 2/3):** `EditMode { TRANSFORM, POINTS }` with kid-facing labels
("Arrange" / "Reshape") owned by the controller. `set_edit_mode(mode)` clamps unknown values
to TRANSFORM, cancels any in-progress drag, redraws, and emits `mode_changed` **only on a
real change**; `get_edit_mode()` reads it. The mode gates **input and drawing only** — every
math API (`insert_vertex`, `translate_selected`, `sanitize_shape`, clipboard, mirror, z-order,
`select`/`deselect`, `set_shape_data`/`clear`) stays **mode-agnostic**, and the mode
**persists across clear/load** (a tool preference).
- **Arrange (TRANSFORM) gizmos:** selection draws a bounding box, a **rotation DOT below the
bbox bottom-center** (`ROTATION_CIRCLE_RADIUS 5.0` / `ROTATION_CIRCLE_HIT_RADIUS 12.0` /
`ROTATION_CIRCLE_OFFSET 26.0`, mirroring `whole_stickman_preview`), and four corner **scale
CROSSES** (`SCALE_CROSS_SIZE 5.0` / `SCALE_CROSS_HIT_RADIUS 12.0`) — the Round 1
ring/scale-circles were replaced. Rotation uses **incremental angle accumulation**
(`_rotate_total_delta` + `_rotate_prev_angle`, wrapping-safely) so dragging a full circle
never jumps at ±180°; **Ctrl** snaps to **15°** (`ROTATION_SNAP_DEG`). Scale is free-form,
**Ctrl** locks the aspect ratio (uniform). Dragging the shape body translates it.
- **Reshape (POINTS) vertex editing (Round 2):** color-matched **3 px** vertex handles
(`HANDLE_RADIUS 3.0`, yellow while dragging; user-created vertices are hollow rects) with a
dark halo backing; vertex drag reshapes live. **Create Point** inserts at the **cursor's
projection** onto the nearest segment (clamped, closed-aware) via `create_point_on_edge()`,
which auto-selects, flags the vertex USER_CREATED, emits `vertex_created`, and **flashes the
new vertex with a 0.6 s yellow ring** (`FLASH_DURATION`/`FLASH_RING_RADIUS`). **Remove Point**
deletes a vertex; **< 2 vertices drops the shape**. In POINTS mode the shape body is
**select-only** (no translate).
- **POINTS-mode right-click matrix (Round 3):** on a vertex (selected first, then front→back)
→ **Remove Point** only; near a **segment** (within `EDGE_HIT_THRESHOLD / _zoom`, via the
public `hit_test_segment(world_pos) -> int` segment-proximity helper) → **Create Point** only
(shape is selected first); the interior body of a closed shape → **select only, no menu**;
empty canvas → **no menu** (the Add-Shape menu is disabled in POINTS mode).
- **TRANSFORM-mode right-click menu:** on a shape → **Copy / Paste / Erase / Flip Left-Right /
Flip Up-Down / Bring Forward / Send Back**; on empty canvas → **Add Box / Plank / Triangle /
Line / Circle** (**Ball removed**, Round 4) (+ Paste when the clipboard is populated). Copy
emits `shape_copy_requested` (the editor owns the clipboard slot); Paste emits
`paste_from_context_requested`; `copy_shape(idx)` returns a deep duplicate
(`duplicate(true)`) and `paste_shape(source, target_pos)` inserts it offset by
`CLIPBOARD_PASTE_OFFSET` (16, 16). `set_context_menu_font_size(size)` applies the theme's
`font_context_menu` to the RMB menu.
- **`reshaped` flag (Round 6):** every shape dict carries `reshaped: bool` (default `false`;
internal + export + `.prop` schema). It is set `true` by vertex insert/remove/drag,
`create_point_on_edge` (via insert), and **non-uniform scaling of a circle** (both the gizmo
drag and `scale_selected`); it is cleared by `set_shape_radius()` (which regenerates the
points from the radius). Translate/rotate/mirror/uniform-scale do **not** set it (they
preserve primitive-ness). The editor's inspector gates primitive-specific rows on it (today:
the circle Radius row, replaced by the muted "reshaped" note).
- **Sanitize on commit:** vertex-drag, rotate, and scale commits run the points through
`TerrainUtils.sanitize_points` (grid-snapped, clockwise, collinear-stripped), with the
`vertex_flags` winding reversed to match. `sanitize_shape` is also exposed statically on
`PropFile`.
- **Two-tone halo selection (Round 7):** every selection element — the selection polyline,
the bounding box, the rotation dot, the four scale crosses, and the POINTS-mode vertex
handles — is drawn as a **dark backing pass** (`SELECTION_HALO_COLOR`, black at 0.85 alpha,
~2 px wider) + a **bright core pass** (`SELECTION_CORE_COLOR`, white, original width). The
pair is visible on **any** background (the Round 6 fill-adaptive `contrast_color_for` was
removed because it could not know what sits *behind* a selected shape).
- **Phase 10a whole-prop transform:** the canvas exposes typed move/rotate/resize request
signals and renders a union gizmo while Transform Whole Prop is armed. Body, rotation-dot,
and corner gestures use snapshot-only previews and commit one atomic document edit on
release; `set_whole_prop_transform_enabled()` and related transform names are the preferred
API. Resize-named compatibility aliases remain internally for existing controller paths.
- `scripts/editors/prop/prop_file.gd` — `class_name PropFile`, `extends RefCounted`; the static `.prop`
schema + converters (Phase 9, **not used by the editor**). Consts `FILE_VERSION := "1.0"`,
`FILE_EXT := ".prop"`, `DEFAULT_DIR := "res://content/props"`, `SUPPORTED_VERSIONS: ["1.0"]`,
`PRESET_STRINGS` (index == `PropBlock.MaterialPreset`: none/wood/rubber/cardboard/metal),
`SHAPE_STRINGS` (polygon/circle/line), `DEFAULT_RADIUS`, `DEFAULT_CIRCLE_SEGMENTS`.
- **Schema (v1.0):** top-level `version`, `prop_name`, `material_preset`, `shapes[]`, and
write-only `metadata` (`created_at`/`modified_at`). Each shape carries `shape_type`
(`polygon`/`circle`/`line`), `points` (`[{x,y}]`, polygon/line), `radius` (circle),
`fill_color`/`outline_color` (hex), `outline_width`, `closed`, `vertex_flags` (0/1), and
**`reshaped`** (Round 6, default `false`; additive/backward-compatible). `metadata` is
ignored on load.
- `preset_to_string` / `preset_from_string` (unknown → `MaterialPreset.NONE`),
`shape_type_to_payload_type` (circle → CIRCLE, else POLYGON), `sanitize_shape(shape,
grid_size)` (circles pass through; polygon/line points run through
`TerrainUtils.sanitize_points`, keeping `vertex_flags` aligned).
- `to_file_dict(prop_name, material_preset, shapes)` / `from_file_dict(data)` (accepts a dict
or JSON string; returns `{}` + `push_warning` on any schema/version error; missing keys fall
back to defaults; a non-`"1.0"` `version` is rejected).
- `flatten_to_payload(shapes) -> Dictionary` — the deterministic single-shape payload
`PropUtils.spawn_prop()` consumes: **frontmost closed polygon** (scan last→first) → else
**circle** (last→first) → else the **first shape's** points as a polygon; colors come from
the chosen shape (material tint fallback); empty `shapes` → `{}` (caller skips). Lines are
editor-only and never become collision geometry.
- `save(path, data)` (FileAccess WRITE + `JSON.stringify`) and `load(path)` (`{}` +
`push_warning` on read/parse failure), plus **`save_safe(path, data)`** — an atomic
tmp/`.bak` swap used by the autosave (and later the editor's normal save path may use it).
- **Round 5 serialization lesson (critical):** `_serialize_shape()` must route `points`
through the **`_coerce_points()` helper**. Assigning an export-format `Array` of `{x, y}`
dictionaries directly to a typed `PackedVector2Array` **silently zeroes every coordinate**
(Godot cannot construct `Vector2` from a dictionary; the count is preserved but every
element becomes `Vector2.ZERO`). Round 1–4 files saved this way are unrecoverable. Any typed
`Packed*Array` assignment from dictionary/JSON data must go through a Variant-coercion
helper, and regression tests must assert point **values**, not just counts.
- `scripts/editors/prop/prop_theme.gd` — `class_name PropTheme`, `extends RefCounted`; the **validated theme
loader** for `res://prop_theme.json` (Phase 9, **not used by the editor**), modeled on
`SandboxTheme`. Consts `SCHEMA_VERSION := "1.0"`, `DEFAULT_PATH := "res://prop_theme.json"`,
and `DEFAULT_CONFIG` (mirrors the committed file). `load_from(path)` deep-merges the file over
`DEFAULT_CONFIG` (missing/malformed → defaults; warn-once via `_warn_once`), so an older file
without newer sections still loads. Accessors: `value` (dotted path), `color` (`#RRGGBB[AA]`
validation + warn-once), `number`/`integer` (clamped, finite-guarded).
`material_tint(preset, channel)` implements the fallback hierarchy **theme file →
`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)`** (outline's final fallback is the tint
darkened 0.55). `outline_width_default/min/max`, `primitive_default(primitive, key)`,
`grid_size`, `canvas_background_color`, `canvas_grid_color`.
- **Physics is never themeable** — mass/friction/bounce come only from `PropBlock`'s static
preset tables.
- **`ui` tokens (Rounds 2–4):** fonts `body` **18**, `header` **24**, `menu` **22**,
`menu_bar` **22**, `mode` **26**, `status` **18**, `prop_name` **20**, `context_menu` **22**;
hit targets `button_min_height` **36**, `row_min_height` **36**, `mode_button_min_height`
**56**; colors `text` `#F2F2F2`, `text_muted` `#9A9A9A`, `border` `#FFFFFF33`, `accent`
`#4FA3FF`. Accessors `font_body/header/menu/status/mode/menu_bar/prop_name/context_menu`,
`button_min_height`, `row_min_height`, `mode_button_min_height`, `text_color`,
`text_muted_color`, `border_color`, `accent_color`. All are clamped with `DEFAULT_CONFIG`
fallbacks and applied through a **local** `Theme` (no global GUI mutation).
- `scripts/shared/thumbnails/thumbnail_cache.gd` — see its existing section; the Phase 9 extension
adds `prop_file_key(path)` → `"<basename>_<mtime>"` and `prop_file_png(path)` →
`PROP_DIR + "/" + key + ".png"` for **custom `.prop`** files (mtime-keyed so a re-edited prop
invalidates its cached PNG). Built-in template thumbnails keep `prop_png(id)` →
`"<id>_v<PROP_VERSION>.png"`; `stickman_key`/`stickman_png` are unchanged.
- `scripts/characters/stickman/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
`static func apply(stk_data, rig)` fits an instantiated `stickman_rig.tscn` to a loaded `.stk`
dictionary, calling three private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
lengths + lower-bone origins, and zeroes the Head driver's local position so the chin sits on
the neck joint), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and
`Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual
nodes — one node per shape: closed → single `Polygon2D`, open → single `Line2D` width 2). The
`RemoteTransform2D` drivers keep their defaults (`update_rotation = true`), so mounted shapes
follow their bones in every pose.
- **Phase 2 (Sandbox) single-shape support:** `_mount_shapes()` reads a part's `shapes` array
when present (v1.2+), and otherwise — when the part dict itself carries `points` — wraps the
whole dict as a single shape (`shapes = [pd]`), so v1.0/v1.1 single-shape `.stk` files (e.g.
`content/stickmen/test.stk`) mount as visible geometry instead of being cleared to nothing.
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
-proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
other part — it clears the `Body/Head` node's inline `@tool` circle script via
`set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers
`_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed.
- **Phase 9 Round 2 bugfix (hanging-convention mount):** driver rotation neutralization was
**removed** — the `RemoteTransform2D` drivers keep `update_rotation = true`, so each mounted
part rotates to follow its bone in every pose (IK flexing included). `_mount_shapes()` no
longer reads the file's `pivot`/`length` fields — it recomputes a per-part bounding box at
mount time via `_compute_part_bbox()` (empty bbox → the part is skipped) and derives the
mount transform via `_compute_mount_transform()`, which emits `{anchor, scale, theta}`:
geometry is mounted in the rig's **hanging convention** (joint anchor at the local origin,
far end along local `+Y`). Anchors: head and torso → bottom-center `(cx, max_y)` (chin / hip
end at the origin); left limbs drawn horizontally → `(max_x, cy)`; right limbs drawn
horizontally → `(min_x, cy)`; vertically drawn limbs → top-center `(cx, min_y)`. Alignment
rotation θ maps the far end onto local `+Y`: head `0`, torso `π` (driver π cancels it at
rest), left horizontal limbs `−π/2`, right horizontal limbs `+π/2`, vertical limbs `0`.
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`); the
cross axis stays 1:1 (so horizontally-drawn legs become ~200×28, not 101-px bars).
`_bone_length_for()` maps each part to its bone length (upper/lower arm/leg, torso).
`_map_point()` applies `(P − J) ⋅ S` then `q.rotated(θ)`. The **head driver's** local
position (`Skeleton2D/Torso/Head/RemoteTransform2D`) is zeroed in `_fit_bones()` so the
mounted head's chin lands on the neck joint. `DEFAULT_LINE_WIDTH := 2.0` (was 16.0) matches
the editor's 2 px outline. `_reset_node_transform()` still resets each `Body/*` container's
scale to `(1, 1)` and rotation to `0` before mounting (position untouched, owned by the
driver).
- **Phase 9 Round 3 bugfix (part preview transform + one node per shape):** the mount
pipeline now **composes the part's preview transform** `E(P) = C + R(rot)·S·(P − C)`
(scale-then-rotate about the raw bbox center — the editor's exact Whole-Stickman-preview
transform) **before** the hanging-convention mount. `_mount_shapes()` reads the per-part
`rotation` (degrees, default `0.0`) and `scale` (`{x,y}`, default `(1,1)`) from the part
dict and applies `E` to the raw joint end `J_raw` and far point `F_pt_raw`
(`J' = E(J_raw)`, `F' = E(F_pt_raw) − J'`). The anchor, alignment θ, and bone-fit scale `s`
are then computed on the **transformed geometry**: rotations near ±180°
(`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end (`A = F_pt'`, `V = −F'`)
so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint and its
hip end at the neck); other rotations keep limbs attached along their bones (a 90° forearm
hangs from the elbow with its content turned, exactly as assembled). The bone-fit scale
`s = bone_length / |V|` is measured on the transformed extent so user-scaled parts are not
double-fitted. The **head** mounts upright with `θ = 0`, `s = 1` (a bone-fit scale would
double-scale the face), but still applies the part scale through `E` (face ≈160 px) with
the chin at the neck joint and the flip anchor rule still applying. `_mount_shape()` mounts
**one node per shape**: closed → single `Polygon2D` (fill only, no paired `Line2D` outline);
open → single `Line2D` (width 2).
- **Phase 9 Round 4 bugfix (head chin drop):** adds `const HEAD_CHIN_DROP := 28.0`, derived
from the editor's pose guide — the head circle (radius 100) is centered at the Head joint
`(0, −463.5)`, so its bottom is `−363.5`; the neck (Head bone origin) is at `−391.5`, so the
chin drops 28 px below the neck. The mounted head points get a `Vector2(0.0, 28.0)`
rig-space translation (`offset` in `_compute_mount_transform()` / `_map_point()`, applied
**after** the part transform and the `(θ = 0, s = 1)` transform; flip-agnostic — only the
head branch sets a non-zero `offset`). Result: the head's chin lands at world ≈ `(0, −363.5)`,
overlapping the torso's top (which ends at `−391.5`) by 28 px — matching the silhouette
guide in the editor.
- **Phase 9 Round 5 guide-offset application:** `_mount_shapes()` reads each part's
`guide_offset` (`{x, y}`, default absent) and, **only when the key is present** (old files
keep the previous offset-0 behavior and the head falls back to the Round 4
`HEAD_CHIN_DROP`), applies a node-frame translation
`t = (guide_offset + (A − C)).rotated(−c_node)` where A = the mount anchor already computed
(the transformed joint end `J'`, or the transformed far end `F_pt'` when flipped — Round 3),
C = the raw bbox center, and `c_node` = the part's driver `RemoteTransform2D.global_rotation`
at apply time (read via the reintroduced `DRIVER_PATHS` const; null-guarded, fallback 0.0).
This converts the editor's master-space guide offset into a bone-relative placement so the
harness reproduces the guide placement 1:1 (and, for the head — mapped to the guide **Neck**
joint — subsumes the `HEAD_CHIN_DROP` fallback). `t` is applied in `_map_point()` as the
final rig-space translation, after `E`/θ/scale/flip and independent of the flip logic.
Re-saving a `.stk` from the editor populates the offsets.
- **Phase 9 Round 6 bugfix (guide-driven anchor selection):** when `guide_offset` is present,
the joint anchor in `_compute_mount_transform()` is now whichever transformed end
(`j_prime = E(J_raw)` or `f_pt_prime = E(F_pt_raw)`) is **nearest the part's stored guide
joint** (`center − guide_offset`): if `d_far < d_joint` (strict) the far end attaches
(`anchor = f_pt_prime`, `v = −f_prime`), else the family end (`anchor = j_prime`, `v =
f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the
`guide_offset` case, fixing the **lower left leg** (knee now at the joint, was the ankle)
and **lower right arm** (elbow now at the joint, was the wrist), both 180° off their bones
because the user's drawn-side conventions are inconsistent per part. The nearest-end rule
preserves every previously-correct case and naturally reproduces the 180° flip (a flipped
part's far end lands nearest the joint — e.g. the flipped right upper arm shoulder and the
flipped torso neck end), plus the head chin (nearest the guide Neck). Old files **without**
the key keep the previous family rules + flip heuristic exactly as before. `theta`, `s`, the
Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume
`anchor`/`v` generically.
Targets `stickman_rig.tscn` node paths; every node lookup is null-guarded (missing node →
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
- `scripts/characters/stickman/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
facing direction, per-joint bone bend, and `Body/*` z-order** for `stickman_rig.tscn` (Phase 9
Task 4). Attached to the `Master` root node of `stickman_rig.tscn`. **Non-`@tool`** — node
resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a
live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu
ids), `BendDirection { NORMAL, INVERTED }`, and `RigState { ANIMATED, RAGDOLL, RECOVERING }`.
Constants (moved from the harness): `SKELETON_PATH`,
`BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`),
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
`FORWARD`, a preset whose setter writes the four per-joint vars, reorders `Body/*`, and
applies a whole-rig Y-axis mirror for LEFT — `Master.scale.x = -1`; RIGHT/FORWARD `(1,1)`) and an
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
`auto_recover` (true), plus recovery constants
`STAND_POSE`/`IK_TARGET_PATHS`/`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD`/
`STAND_UP_DURATION`/`STABILIZATION_DELAY`/`RAGDOLL_TARGET_SOFTNESS`. The
**Joint bend configuration (EDIT ME)** block holds every per-joint bend decision of the
ragdoll cycle, keyed by facing profile, so joints can be tuned without touching pipeline
code: `RAGDOLL_JOINT_BAND_LIMITS[profile][joint] = {"lower": deg, "upper": deg}` (the
fall's knee/elbow `PinJoint2D` band — 0 = straight, small side ~5 resists hyperextension,
large side ~150 is the fold; missing entries fall back to the `RAGDOLL_JOINTS` `limit`
string), `RECOVERY_LEG_SIDE_FORCE[profile][leg]` (optional leg IK `flip_bend_direction`
override; missing = match the captured fall pose's knee side), and
`RECOVERY_JOINT_PATH[profile][joint]` (optional `"shortest"` elbow/knee tween-path
override; missing = pass through straight when the shortest path would cross ±180°).
Signals `facing_profile_changed(profile)` /
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`,
`set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and
`get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/
`Vector2.ZERO`). `_ready()` resolves `Skeleton2D`/`Body`/4 lower `Bone2D`s/4 TwoBoneIK
modifications (matched by `joint_two_bone2d_node` NodePath, **never stack index**), enables the
modification stack (`stack.enabled = true`), applies the current profile once, then sets
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
`"StickmanRig: "` throughout; never crashes.
- **Whole-rig Y-axis mirror (replaces `_apply_head_flip()`, 2026 design change):** Facing LEFT
is applied as a **whole-rig Y-axis mirror** — `Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) —
so the head AND body mirror together and face the correct direction. The old `_apply_head_flip()`
and the `Body/Head.scale.x` mirror are **removed** (the Head Pivot node's driver transform is
untouched). Per-joint `flip_bend_direction` flags (`PROFILE_FLAGS`) and `Z_ORDER_BY_PROFILE` are
**kept provisionally** (unchanged): the mirror reflects the whole skeleton + `IK_Targets` +
mounted `Body/*` geometry, while z-order (depth) is unaffected by an X-mirror. `walk_to()`
always plays the canonical `walk_right` clip (X-mirrored by the root for LEFT; `walk_left` is
no longer used at runtime) and sets the facing profile explicitly. Two **head-related fixes**
under the mirror: the stickman_rig.tscn head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`)
no longer sets `update_scale = false` — it pushes the **full transform** like every other `Body`
driver, so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel
push re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). And
`_apply_head_lookat_mirror_mode()` (called from `_apply_profile()`) **disables** the head
`SkeletonModification2DLookAt` when facing LEFT and pins the head bone rotation to the FORWARD
canonical aim (π); `_pin_mirrored_head_rotation()` re-asserts that pin each `_physics_process`
frame while ANIMATED/RECOVERING so recovery's stack re-arm can't let LookAt flip the bone.
RIGHT/FORWARD re-enable the LookAt. Consequence: interactive head-aiming while facing LEFT is
intentionally static.
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the
ragdoll or cancels the recovery tween, sets the 6 IK targets directly to `STAND_POSE`, re-shows
`Body/*`, re-enables IK, and returns to `ANIMATED` with no stand-up glide). `_physics_process()`
→ `_track_momentum(delta)` caches the rig
root's linear/angular velocity from per-frame `global_position`/`global_rotation` deltas, then
drives `_update_rest_detection()` (auto-recovery trigger). `_enter_ragdoll()` `stop(true)`s the
`AnimationPlayer` (`ANIMATION_PLAYER_PATH` const, keep_state — no pose reset), builds the
ragdoll from the CURRENT solved bone positions while the IK stack is still enabled (disabling
it first would revert the bones to the authored rest pose, popping the figure), then hides
`Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the
ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state =
RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
`get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10**
`RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass
2.0, limb capsules radius 8 masses 1.0–2.0; `collision_layer`/`collision_mask` = 1, bodies
spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per
non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at
build). Each body clones the rig's own mounted visual geometry into the ragdoll body frame
via `_clone_part_visuals()` (reads the entry's `visual_path` `Body/*` node, copies each
`Line2D`/`Polygon2D` child with points baked into the body's local space), falling back to a
gray capsule / white circle when the part node is missing or has no mounted shapes. Angular
limits via `_apply_ragdoll_joint_limits()`: elbow/knee bands come from the editable
`RAGDOLL_JOINT_BAND_LIMITS` table (per profile, per joint) — FORWARD arms `{-150°, +5°}`,
legs `{-5°, +150°}`; LEFT/RIGHT override both sides (e.g. locked `{60°, 100°}`); the
`elbow_knee`/`elbow_knee_ccw` `limit` strings are only the fallback for joints missing from
the table. Shoulder/hip ±160°, neck free. Cached
momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the
same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the
harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `stickman_rig.tscn` is
**not** modified.
- **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state ==
RAGDOLL`)
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates
`_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover`
on it calls `_start_recovery()`. - **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone
rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head
LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry
offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs
**no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation`
decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a
mid-walk pose; the animation is not resumed). - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local
`{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and
probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
(a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`,
`GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe
context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle
clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()`
(translates the rig root so the standing figure's **feet** sit on the ground at the
landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet +
FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local
shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives
the LEFT X-mirror), sets `state = RECOVERING` + emits. - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
`to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`;
the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and
`rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via
`chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into
`(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would
otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the
terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also
computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows
`Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the
stack reports `!get_is_setup()`, re-asserts `enabled = true` and
`Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
`RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head
LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee
side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). - **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`)
of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the
`_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use
`_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower −
bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space
and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven**
toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay
disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed
first keyframe can never match an arbitrary ragdoll rest pose). - **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods,
re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the
pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk
poses). - `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
`set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll;
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). Runtime diagnosis is
gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces the
stack + bone-following state — the temporary per-step debug prints have been stripped).
- **Phase 4.5 collision bodies and reactions:** while ANIMATED, `_build_collision_bodies()` creates
ten metadata-tagged `AnimatableBody2D` proxies (head, torso, and eight limbs), stored in
`_collision_bodies`. Proxies are top-level relative to their `Body/*` visuals,
`sync_to_physics = false`, layer/mask 1, and `_sync_collision_bodies()` copies each visual's
global transform every physics frame so RemoteTransform2D/IK poses and whole-rig LEFT
mirroring cannot leave stale shapes. Torso/limb capsules use fitted lengths in the hanging
frame; the head circle is centered from mounted geometry and expands beyond its 90 px minimum
when needed. `set_collision_bodies_enabled()` disables both layer and mask for ghosts/RAGDOLL
and restores them for RECOVERING/ANIMATED. `apply_collision_reaction()` uses contact position
plus local force quadrant to choose mirrored side/upward/downward flinch direction, applies a
capped `REACTION_MAX_ANGLE` offset from a captured base rotation, and decays without cumulative
full rotations. `snap_to_standing()` kills reaction/recovery tweens and restores captured base
rotations synchronously; the stage gates collision dispatch to PLAY so delayed contacts cannot
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
`StickmanRig:` throughout.
- **Phase 6 continuous terrain locomotion:** terrain following is PLAY-only. `begin_terrain_fall()`
arms a kinematic gravity fall for unsupported rigs; `snap_to_terrain()` is retained as a
compatibility alias, and `set_terrain_following_enabled(false)` clears contacts and restores
live leg targets from `AnimationTargets/*_Base`. The center support uses a three-ray fan
(`[-24, 0, +24]`); each lower-leg endpoint uses an independent three-ray fan (`[-4, 0, +4]`).
Rays accept only `TerrainBlock` colliders with upward-facing normals, exclude the rig's own
collision-proxy RIDs, and choose the highest valid surface. Runtime root elevation accumulates
across consecutive ramps/steps; unsupported rigs free-fall with bounded gravity, and walking
animation pauses until landing. The terrain controller writes final `IK_Targets/Left_Leg` /
`Right_Leg` positions from layered animation base markers, complementary continuous stance/swing
weights, world-space stance locks, and a predictive 24 px foot-penetration guard. It also filters
lower floors beneath overlapping ramps and applies centerline pelvis compensation. `DEBUG_TERRAIN`
is `false` in the shipped build; temporary `[terrain]` traces are opt-in.
- **Phase 6a airborne/landing:** extends the PLAY-only terrain controller with internal
`TerrainMotion.AIRBORNE` and `LANDING_IMPACT` phases while keeping `RigState.ANIMATED`. Ledge
departures inherit signed grounded X velocity and apply exponential drag (`AIR_DRAG_X = 0.75`);
airborne horizontal travel uses a swept query against each of the ten synchronized collision
proxies and stops at the first safe fraction. The captured live hand/foot pose receives additive
flail, with hands reaching canonical local `+X` before lifting; the LEFT root mirror supplies the
opposite screen-facing direction. Falls below `HARD_LANDING_MIN_FALL_HEIGHT = 80` px land softly;
fall distance through 900 px scales torso compression `32..160` px and downward hand drop
`270..520` px across the 0.08 s compress + 0.22 s recover timeline. Both feet lock to
independent terrain samples, the upright root remains unrotated, and `LANDING_IMPACT` blocks
primary queue progression while overlay speech continues. Direct ragdoll and terrain disable
cancel the procedural pose cleanly. `tests/test_phase6a_airborne_landing.gd` passes 24/24
assertions; the implementation is user-verified (2026-09-17).
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
(feet → root; matches `StageSpawner.STICKMAN_FOOT_OFFSET`), `NAV_AGENT_LOCAL_POS := (0, 385)`
(`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE`
/ `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`,
`action_finished(action, index)`, `queue_finished` (on completion, not stop), `queue_changed`
(any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to(
target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`,
`speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides
- emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` /
`remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the
runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a
`NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh;
`avoidance_enabled = false`, `max_speed = walk_speed`, shared default nav map layer 1). A
`_physics_process` ordering of `_track_momentum` → `_update_rest_detection` → `_update_walking`
→ `_update_speech` → `_update_runner` drives the runner state machine per `ActionPhase`
(`walk_to` → wait for `_walk_done`; `speak` → wait for `!_speech_active`; `wait` → `_phase_timer`
countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` →
`request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public
query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking(false)` (no stale
walk/path state while preserving the current solved pose for instant ragdoll handoff) and resets
`_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):**
`_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0`
(map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path /
finished check (the read-only `get_current_navigation_path()` alone never triggers a
repath), then branches on `is_target_reachable()`: an **on-mesh target** follows the nav path
(`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct
straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target =
waypoint + `FOOT_OFFSET`) — a supported case with **no** `push_warning` (the old "warn + finish
in place" policy was replaced; the rig never stands still at a waypoint). There is **no**
`_walk_path_grace` variable (the map-sync guard + forced path query replace it) and **no
unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default
diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage).
Walking is kinematic
(`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb
animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set
explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and
the animation `.:facing_profile` tracks are neutralized/removed). `_finish_walk()` captures the
eight current limb-bone rotations, calls `AnimationPlayer.stop(true)`, and leaves all six IK
marker properties at their current values; no marker reset occurs at queue completion. LEFT idle
disables mirrored TwoBoneIK while preserving that stopped marker/bone frame; it does not restore
the factory pose. `snap_to_standing()` is the explicit stage-reset path that restores the
factory-captured spawn pose.
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
`enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a
private runtime lane without mutating `action_queue`: Overlay speech runs concurrently in one
replaceable slot, Sequential actions cancel the current primary action while retaining authored
future work, and Takeover actions cancel both primary and overlay work before taking control.
- `scripts/characters/stickman/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `stickman_rig.tscn` instance:
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
- `static func spawn_from_data(stk_data: Dictionary) -> StickmanRig` — instantiates
`res://scenes/characters/stickman/stickman_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root
(typed as `StickmanRig` since the rig now carries the `StickmanRig` root script).
- `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_data()`;
returns `null` on empty data.
- `scripts/tools/create_animations.gd` — `@tool extends EditorScript`; a **standalone editor utility**
(run manually with `stickman_rig.tscn` open, **not** auto-loaded or referenced at runtime).
Supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right`
(same keyframes as the old script, via `_generate_walk_animation()`) and a one-shot `stand_up`
(via `_generate_pose_animation()`, `STAND_UP_DURATION` = 2.0, `loop_mode = LOOP_NONE`) into the
open scene's default `AnimationLibrary`. `stand_up` keys the 6 `IK_Targets/*:position` tracks
plus a `IK_Targets/Torso:rotation` track from `POSE_DOWN` (a generic "lying on back" pose) to
`POSE_STANDING` (matching `stickman_rig.tscn` defaults), with `POSE_PATHS`/`POSE_MARKERS` consts.
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).
- `scripts/tools/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
run via **F6** on `res://scenes/tools/test_harness.tscn`) for debugging bone scales, vector drawing
offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button → `FileDialog` (`*.stk`);
quick-select buttons for `content/stickmen/break.stk`, `content/stickmen/basic.stk`, `content/stickmen/test.stk`; "Show
Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:
`SubViewportContainer` → `SubViewport` → world `Node2D` + enabled `Camera2D`; middle-mouse pan,
mouse-wheel zoom, camera recenters on each spawn. Each load frees the previous rig and spawns a
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): true
bone **segments** (a joint dot at each `Bone2D` origin + a parent→child line to each `Bone2D`
child, color-coded left cyan / right orange / central white) with leaf bones drawn out to their
IK targets (`LeftLowerArm→Left_Hand`, `RightLowerArm→Right_Hand`, `LeftLowerLeg→Left_Leg`,
`RightLowerLeg→Right_Leg`) so wrist/ankle joints are visible (Phase 9 Round 2; previously only
origin→parent-origin lines were drawn). The **Head** leaf is the exception (Phase 9 Round 3):
its IK target is a `SkeletonModification2DLookAt` aim point, not a joint, so it is **not** in
`LEAF_BONE_IK_PATHS` and the no-target fallback draws a ~90 px segment along the bone's own
direction (`Vector2(length, 0)` rotated by `bone_angle` then `global_rotation`) instead of a
line to the aim point; colored markers at
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
flexes limbs live (the rig self-enables its modification stack in `_ready()`).
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
Dragging the **Torso** handle translates **bones only** (no target following) — the marker's
`RemoteTransform2D` moves the hip bone and the whole skeleton + `Body/*` visuals follow
rigidly, while the limb/head targets stay put (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 also draws a **null-guarded semi-transparent yellow aim line** from the Head bone
origin to the head marker (`_draw_ik_handles`, width `1.5/zoom`, alpha `0.5`) — a visual aid
for the LookAt test.
- **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control
in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip
Bend Direction" flags. The facing profile, the per-joint bend flags, and the `Body/*` z-order
tables now **live in the `StickmanRig` script** (Phase 9 Task 4) — the harness drives the rig
via `_rig_script: StickmanRig` (typed root from `StickmanFactory.spawn()`; signals connected
**before** `add_child`) and keeps `_facing_profile` (default `FORWARD`) only as a **UI mirror**
for the `[√] ` menu prefix + respawn re-application; the rig's exported `facing_profile` is the
authority. Menu item ids are `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD` (values used
directly as menu item ids). State `_context_joint`, `_facing_button`/`_facing_menu`,
`_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the
current profile prefixed `[√] `, refreshed on `about_to_popup` and after selection (mirroring
the editor's snap-menu pattern); selection calls `_rig_script.set_facing_profile(id)`. Hit-test
for the right-click toggle iterates `_rig_script.get_bend_joints()` with positions from
`_rig_script.get_bend_joint_global_position(joint)`. Per-joint toggle: right-click inside the
viewport on an elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0`
screen px converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context
menu labeled **"Normal Bend"** (when the rig's `get_joint_bend_flipped(joint)` is true) or
**"Invert Bend"** (when false); selecting calls
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are
not. Lifecycle: `_free_current_rig()` clears `_rig_script` (and `_context_joint`);
`_load_and_spawn()` captures the remembered profile before `add_child` (so the rig's `_ready()`
`facing_profile_changed(FORWARD)` doesn't clobber the mirror) then re-applies
`_rig_script.set_facing_profile(remembered_profile)` after `_resolve_rig_nodes()`. The rig
enables its own modification stack in `_ready()`. No persistence to disk.
- **Phase 9 Task 2 body-part z-order:** the rig's `_apply_profile()` reorders the rig's `Body/*`
visual part nodes (tree order = draw order) from the Facing profile; `Z_ORDER_BY_PROFILE` (now
owned by `StickmanRig`) maps each `FacingProfile` to the `Body/*` part node names in
**back-to-front draw order** (Godot 4 `Node2D` draws siblings in tree order; all parts keep
`z_index = 0`). FORWARD: torso → left/right upper legs → left/right lower legs → left/right
upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm
pair then left leg pair **behind** the torso, right leg pair then right arm pair in front;
RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side
(behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the
**head is always frontmost**. The rig's `_apply_body_z_order()` walks the profile array
back-to-front and `move_child(part, count - 1)`s each existing child (missing parts skipped),
which yields the profile order; unknown extra children stay at the back. Safe with the adapter:
shapes are children of the part nodes, so moving a part moves its whole shape group. No
persistence to disk.
- **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar
`HBox` (after "Show IK Handles", before the status label), default ON, toggled via
`_on_show_coords_toggled(pressed)` (sets `_show_coords: bool = true`, flips
`_coords_panel.visible`). A code-built right-side readout (`_build_coords_panel()`, called
from `_build_ui()` after the viewport container so it renders in front): `_coords_panel`
(`PanelContainer`) + monospace selectable `_coords_label` (`RichTextLabel`,
`selection_enabled` + `context_menu_enabled` + `fit_content`, autowrap off, scroll off,
`FOCUS_CLICK`; `SystemFont`: Consolas/Menlo/DejaVu Sans Mono/Courier New via the
`normal_font`/`normal_font_size` (18) theme overrides), anchored `PRESET_TOP_RIGHT`,
`offset_top = 40.0`, `offset_right = -8.0`, `offset_left = -COORDS_PANEL_WIDTH` (320.0),
`grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN`
(auto-height/width, grows left so text never runs off-screen), `mouse_filter =
MOUSE_FILTER_IGNORE` on the panel (the label itself stays interactive for selection);
StyleBoxFlat
bg `Color(0,0,0,0.55)`, border `Color(1,1,1,0.12)` w1, corner radius 4, content margin 8.
Consts `COORDS_PANEL_WIDTH := 320.0` and `COORD_BONE_PATHS` (10 Skeleton2D-relative bone
paths: Torso, Torso/Head, both upper/lower arms, both upper/lower legs). State
`_coord_bones: Dictionary` (bone display name → `Bone2D`, keyed by `path.get_file()`),
`_show_coords`. `_resolve_rig_nodes()` calls `_resolve_coord_bones()` (via
`_skeleton.get_node_or_null`, `push_warning` on missing); `_free_current_rig()` clears
`_coord_bones` (toggle persists across respawns). `_process(delta)` early-outs when hidden
or label null, else `_update_coords_display()` — sections "Skeleton2D" pos + rot deg,
"Bones" pos + rot deg, "IK Targets" pos only (reusing `IK_HANDLE_PATHS`/`_ik_handles`);
"No rig loaded" fallback; every read guarded with `is_instance_valid`; the label text is
only reassigned when the built string changes, so an active text selection survives idle
frames. Values are
world-space (`global_position`/`global_rotation`), rotation in degrees via
`_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk.
- **Phase 9 Task 5 rig animation:** adds top-bar controls immediately after the "Facing" menu
— an `_anim_dropdown` `OptionButton` populated per spawn from
`AnimationPlayer.get_animation_list()` (preferring `walk_right` via `DEFAULT_ANIMATION`), a
`_play_button` whose label swaps "Play"/"Pause"/"Resume" by `_playback_state`, a `_stop_button`
(Stop), and a `_loop_check` `CheckBox` default ON (harness-level, persists across respawns like
`_show_coords`). The harness resolves the rig's `AnimationPlayer` directly by node path via the
`ANIMATION_PLAYER_PATH` const (`_resolve_anim_player()`, called at the end of
`_resolve_rig_nodes()`) and drives it directly; the `AnimationTree` node remains an untouched
unconfigured placeholder (out of scope, D1). Loop is implemented by writing
`Animation.loop_mode` (`LOOP_LINEAR`/`LOOP_NONE`) on the selected animation before each play
(`_apply_loop_mode()`); playback state is tracked by the enum
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
and state. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now
set explicitly via `set_facing_profile()` / `walk_to()` (which sets LEFT/RIGHT from the walk
direction and root-mirrors for LEFT), so playing `walk_right` no longer flips the rig's profile
through an animation track. No
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
- `scripts/terrain/terrain_block.gd` — `class_name TerrainBlock`, `extends StaticBody2D`; a **reusable
vector terrain component** (Vector Terrain System, **not used by the editor**). Builds its three
children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp vector border,
`outline_color`/`outline_width`, auto-closed loop by appending the first vertex to the end,
`LINE_JOINT_ROUND` + round caps), and `CollisionPolygon2D` (`BUILD_SOLIDS` solid decomposition —
supports concave blocks). Exported properties: `polygon_points: PackedVector2Array`,
`fill_color`, `outline_color`, `outline_width`; a unified setter pushes vertex changes to all
three children live (no manual rebuilds).
- `scripts/terrain/terrain_utils.gd` — `class_name TerrainUtils`, `extends RefCounted`; static utility
(**not used by the editor**):
- `sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array` —
sanitization pipeline in order: grid snap → redundancy removal via a **local
`_simplify_polyline()`** (Godot 4.7 has **no `Geometry2D.simplify_polyline()`**; the local
version drops consecutive duplicates, a closing duplicate when `last == first`, and collinear
vertices) → clockwise enforcement via `Geometry2D.is_polygon_clockwise()` (reverses if false,
guaranteeing clockwise output).
- `spawn_block(...)` — factory that sanitizes raw input vectors (`sanitize_points`), creates a
`TerrainBlock`, applies the cleaned points, and adds it to the target container.
- `scripts/props/prop_block.gd` — `class_name PropBlock`, `extends RigidBody2D`; a **reusable
dynamic vector prop component** (Dynamic Vector Props, **not used by the editor**), `@tool`.
Builds its children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp
vector outline, `outline_color`/`outline_width`, auto-closed loop by appending the first
vertex, `LINE_JOINT_ROUND` + `LINE_CAP_ROUND`), and a collision node — `CollisionPolygon2D`
(`BUILD_SOLIDS`) in `POLYGON` mode, or `CollisionShape2D` + `CircleShape2D` (48-segment
radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported
properties: `shape_type` (`@export_enum("Polygon","Circle")`), `polygon_points:
PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and
`material_preset` (`@export_enum("None","Wood","Rubber","Cardboard","Metal")`). Presets set
`mass` + `physics_material_override` via static `mass_for()`/`physics_material()`/`tint_for()`
(Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85;
Cardboard: mass 0.4, friction 0.3, bounce 0.05; Metal: mass 8.0, friction 0.9, bounce 0.0;
None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
editor safety); `_apply_shape()` enables exactly one collision node.
- **Phase 4 / Phase 4.5 collision signals:** `collided(other, contact_normal, contact_position)`
and `separated(other)`; `_ready()` sets `contact_monitor = true`,
`max_contacts_reported = 32`, and shape-cast CCD. The guarded `body_entered` path reads a
matching direct-state contact row as world-space normal/position, with a center-direction
fallback when timing leaves no row. The physics signal covers prop-vs-prop and Phase 4.5
stickman-prop contacts; the old geometric feet-point stickman test is removed.
- **Phase 10a instance scaling:** Sandbox EDIT exposes four uniform corner crosses only for
a single `PropBlock`. During PLAY, the authored root scale is moved into child visuals and
collision geometry, including the custom one-hull path and nonuniform circle fallback;
returning to EDIT restores the authored root scale. Preset mass and material physics stay
unchanged.
- `scripts/stage/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
dashed border). **No physics and no signals** — it is a pure geometric region evaluated by
`sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules.
- `scripts/props/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
(**not used by the editor**):
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
`create_plank(length := 160.0, thickness := 16.0)` / `create_triangle(base := 56.0, height
:= 48.0)` — primitive generators returning shape-payload dictionaries with default
dimensions + color themes (wood/rubber/metal/cardboard).
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity
:= Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload
(`shape_type` + geometry + colors via `_apply_shape_payload()`, sanitizing polygon points
through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only
when non-zero), and returns the spawned prop.
- `scripts/tools/physics_test_harness.gd` — `class_name PhysicsTestHarness`, `extends Node2D`; standalone
staging scene root (Vector Terrain System / Dynamic Vector Props, **not wired into the editor**;
run via **F6** on `res://scenes/tools/physics_test_harness.tscn`). Builds flat ground, angled ramps, and stepped
`TerrainBlock` instances via `TerrainUtils`, instantiates `res://scenes/characters/stickman/stickman_rig.tscn` standing on the
flat ground, and handles camera input. A top-bar UI (`_build_ui()`, a `CanvasLayer` +
`PanelContainer` matching `test_harness`'s style) replaces the old key bindings: **Spawn
Crate** / **Spawn Ball** / **Spawn Plank** buttons spawn dynamic props above the angled ramp
via `PropUtils.spawn_prop()` (`PROP_SPAWN_POSITION = (300, -300)`: Wood Crate
(`create_box()`, `WOOD`, velocity `(60,0)`), Bouncy Ball (`create_ball()`, `RUBBER`,
`(-80,0)`), Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
`StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the
rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)`
( `RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.
- **Phase 10 ragdoll trigger:** the harness stores the spawned rig in `_rig: StickmanRig`
and shows a toggle-mode `Button` (`_ragdoll_toggle`) whose text flips
**"Stickman"** ↔ **"Ragdoll"** (`_update_ragdoll_toggle()`, `set_pressed_no_signal`
keeps the label in sync without retriggering). Toggling calls `_rig.set_ragdoll(pressed)`,
then on entry calls `_remove_rig_collision_proxy()` (the ragdoll collides directly with the
terrain) and on exit `_add_rig_collision_proxy()`. `_add_rig_collision_proxy()` is idempotent
— it first `_find_rig_collision_proxy()` (a direct child named `"RigCollisionProxy"`) and
returns early if one exists, so rapid toggling leaves no duplicate proxies.
- **Phase 10 external forces:** a **"Knock Up"** button (`_knock_up()`) tests impulses beyond
gravity: when the rig is in RAGDOLL mode it calls `StickmanRig.apply_ragdoll_velocity_boost(
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body,
preserving internal structure), and applies the same upward velocity delta to every dynamic
prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together.
- **Phase 11 recovery UI:** a `Label("Rest")` + `SpinBox` (`_rest_timeout_spinbox`, min 0.1 /
max 10.0 / step 0.1, initialized to `_rig.rest_timeout` after `_build_ui`) and a **"Recover
Now"** button (`_recover_now()` → `_rig.request_recovery()`) are added after the "Knock Up"
button. `_on_rest_timeout_changed(v)` writes `_rig.rest_timeout = v` (runtime-only, no
settings.json). `_spawn_rig()` connects `_rig.state_changed` → `_on_rig_state_changed()`,
which removes the collision proxy on `RAGDOLL`, re-adds it on `ANIMATED`/`RECOVERING`, and
always `_update_ragdoll_toggle()` (proxy helpers are idempotent, so the toggle handler's own
add/remove is harmless). The toggle label reads "Stickman" during `RECOVERING` (since
` is_in_ragdoll()` is false).
- `scripts/editors/stage/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage
Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on
`res://scenes/stage/sandbox_stage.tscn`). Owns the EDIT/DIRECT/PLAY mode state machine, placement mode,
camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` /
`StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, DIRECT, PLAY }`
(default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` +
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first);
PLAY unfreezes props, enables PLAY-only terrain following/free-fall, and starts stickman queues
with `auto_recover = false`; ragdoll/recover remain explicit actions. Signals
`mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`,
`object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` /
`max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport).
`_world_children_selectable()` returns direct `Node2D` children of `World` excluding
`RagdollBodyContainer`. **Authored-state / restart-the-sim:** `_authored` (instance id →
`{node, position, rotation}`) is updated at **edit time** on every place (`_place_at` →
`_save_object_state`) and move/rotate (`transform_committed` → `_on_transform_committed`),
and cleared on delete (`_clear_object_state`). `_enter_edit_mode()` stands stickmen, then
freezes every prop (`freeze_mode = FREEZE_MODE_KINEMATIC` **before** `freeze = true`, so the
body freezes directly as kinematic — never via the static layer, whose transform sync drops a
subsequent position set), then `_restore_authored_state()` teleports via
`global_position`/`global_rotation` + zeroed velocity. Because the freeze+teleport can take a
physics frame or two to settle, `_restore_authored_state()` is also re-asserted over the next
few `_physics_process` frames (`_restore_frames_left`) — so every Play session starts from and
returns to the same authored state. **Phase 10a scale lifecycle:** prop corner-scale gestures
use the existing transform signal path, synchronize the frozen body during EDIT, save the
authored scale on commit, and retain that scale across PLAY → EDIT → PLAY. **Placement ghost:**
while a palette
item is active, `set_placement_mode(id)` spawns a translucent (`modulate.a = 0.5`),
non-colliding (`collision_layer/mask = 0`, frozen) copy of the object reparented out of
`World` into `_ghost_holder`; `_process()` tracks it to the cursor (+ snap) via
`_update_ghost_position()`, and `_place_at()` re-spawns it after each placement. A ghost
**stickman** has its `Skeleton2D` modification stack disabled and its `AnimationPlayer`
stopped so it renders as a static standing figure (its limbs don't flex/follow the cursor).
**Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap`
`CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json`
(`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate
drags via `_snap_to_grid()` / `StageGizmos.snap_size`. Gizmo input uses the exact mouse-event
coordinates converted by `_viewport_to_world()` for press, motion, and release. Frozen props
are synchronized to the edited `global_transform` through `PhysicsServer2D` during drag and
for three post-release settle frames. The grid is visible only in EDIT mode
(`_apply_grid_settings()` sets `_grid.visible = _show_grid and current_mode == EDIT`, re-applied
on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor)
and `InputEventPanGesture` (two-finger pan, `delta × 3 / zoom`) alongside the mouse wheel/middle
pan. UI built in code (CanvasLayer +
PanelContainer top bar): mode toggle, six palette buttons (built from the spawner registry),
Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are
hidden in PLAY via `_set_build_controls_visible()`, called from `_enter_edit_mode()` /
`_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`); 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
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
flag re-bakes once per frame (`_process`) on terrain
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
auto-ragdolls stickmen — it sets each rig `auto_recover = false`, calls `begin_terrain_fall()`,
then calls `start_queue()`;
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
- **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules:
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY
(`_update_area_entry` tests each movable's visible world AABB against the current transformed
`TriggerArea` world AABB); Phase 4.5 routes stickman-prop `collided` events from `PropBlock`
physics contacts instead of polling stickman feet points,
with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI
state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`,
`ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct
action popup: trigger sub-menu popup → trigger-target click → rule-action popup → target/position
→ "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 4 final-bugs runtime:** rule actions are dispatched into
`StickmanRig`'s private runtime lane: Overlay speech runs concurrently in one replaceable
slot, Sequential actions interrupt the current primary action while retaining authored future
work, and Takeover actions cancel both before taking control. Collision callbacks are PLAY-only,
so delayed contacts after STOP cannot recreate a flinch. Walk To summaries read the waypoint
from `params.target`, separate from the top-level actor instance id.
- **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable
instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area
entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can
trigger when a visible leg/body part enters even if its feet anchor is still outside. The
`_area_overlap` edge state is independent per area/movable instance-ID pair: it fires once on
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
all passed) and manual editor verification of A entering an area causing B's speech bubble.
- **Phase 6 runtime terrain/events:** `_enter_play_mode()` calls `begin_terrain_fall()` before
`start_queue()`. EDIT/DIRECT disable terrain following and restore authored transforms. The
navigation bake uses shallow exposed upward-surface ribbons and connects neighboring surface
endpoints only within 64 px horizontal / 96 px vertical step limits; the `NavigationAgent2D`
remains at `(0, 385)` and follows the selected walk speed while terrain owns vertical root motion.
`_update_area_entry()` unions each visible stickman's current and previous world AABB so fast
free-fall still emits `entered_area`. In a reactive batch, the first takeover cancels existing
work once while later takeover actions remain ordered; `[ragdoll, recover]` therefore ragdolls,
waits for rest, then recovers. Terrain diagnostics are gated by `StickmanRig.DEBUG_TERRAIN` and
are off by default.
- **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/stage/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/speech/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.
Consts `FONT_SIZE`/`PADDING`/`TAIL_HEIGHT`/`MAX_WIDTH`/`BG_COLOR`/`BORDER_COLOR`/`TEXT_COLOR`.
Measures text via `ThemeDB.fallback_font.get_string_size(...)`, draws a rounded-rect background +
a downward tail triangle centered on the rig-local origin, then `draw_string(...)`; `visible = false`
by default, no hit-testing. Public API: `show_text(text)` (store, `visible = true`, `queue_redraw()`),
`hide_bubble()`. Driven by `StickmanRig.speak()`; the rig owns hiding + the `speech_finished` signal.
- `scripts/stage/stage_director_visuals.gd` — `class_name StageDirectorVisuals`, `extends Node2D`; the
**Edit-mode director overlay** (Phase 3a, **not used by the editor**), mirroring `StageGrid`/
`StageGizmos`. State `camera`/`world`/`enabled`/`_dirty`; public `set_enabled(value)` /
`mark_dirty()`. `_process()` redraws on a dirty flag; `_draw()` reads each rig's queue via
`_collect_rigs()` (direct `World` children filtered `is StickmanRig`). Per action in order:
`walk_to` → a blue waypoint dot (`WAYPOINT_RADIUS_PX / _zoom()`) with white outline + order number at
`action["target"]`; dashed connectors (`draw_dashed_line`, `DASH_*` / `_zoom()`) between consecutive
dots (and from the rig's current feet position to the first dot); non-walk actions (`speak`/`wait`/
`ragdoll`/`recover`) → a badge (speech bubble / clock / X / up-arrow glyph + order number) anchored
at the **stickman's position at that point in the sequence** — derived by simulating the queue
(start at rig feet + `FOOT_OFFSET`; each `walk_to` advances the anchor; non-walk anchors at the
current position), consecutive badges stacking `(0, -28)/zoom`. All sizes divided by `_zoom()` so
markers stay screen-constant; pure `_draw()`, no hit-testing; hidden in PLAY via `set_enabled(false)`.
- **Phase 4 rule visualization:** `_draw()` also renders `_event_rules` (set via `set_rules()`) —
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** (the label + icon are hit-testable via `hit_test_rule()`, `Vector2.INF` sentinel from
`hit_test_waypoint()`); click-label → consequence-only edit, click-✕ → delete.
- `scripts/stage/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
`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". 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://content/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/characters/stickman/stickman_library.gd` — `class_name StickmanLibrary`, `extends RefCounted` (Phase 3b,
**not used by the editor**). Scans `res://content/stickmen/*.stk` into entry models for the asset
selector. `const STICKMEN_DIR := "res://content/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/props/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/shared/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/shared/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/shared/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/shared/ui/asset_selector.gd` — `class_name AssetSelector`, `extends PopupPanel` (Phase 3b, **not
used by the editor**); root script of `scenes/stage/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/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
unions its mounted `Body/*` `Line2D`/`Polygon2D` geometry (`_collect_visual_points()`), so the
bounding box is centered on the actual figure head-to-feet (not a fixed rect).
`_frontmost_at` = highest `World` child index wins,
smallest area breaks ties. `_is_selectable` excludes the `RagdollBodyContainer` subtree. Signals
`hover_changed(node)` / `selection_changed(nodes)`; public API `get_selected`/`get_primary`/
`clear_selection`/`select_only`/`add_to_selection`/`toggle_selection`/`is_selected`/`hit_test`/
`update_hover`/`box_select`. (Phase 4) `get_world_aabb` also handles `TriggerArea` objects via
the same **duck-typed** `get_area_rect` AABB branch, so trigger areas are selectable/hit-testable.
- `scripts/stage/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight +
selection outlines + a rotate ring (Phase 2). **No translate handle** — objects are dragged
directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection,
and `begin_translate_drag(node, world_pos)` starts a TRANSLATE drag that moves **all** selected
nodes together (`drag_to` drives `global_position` with optional grid snap via `snap_size`,
snapping the drag anchor and applying the same delta to the rest). Translation accumulates from
the drag origin, so sub-grid mouse events remain smooth; snapping uses only `snap_size`, never
object dimensions. `transform_started(nodes)` fires at drag start and
`transform_updated(nodes)` fires on every drag update so the stage can synchronize frozen
physics bodies. The **rotate ring** is drawn
and hit-tested only when exactly one node is selected (`hit_test` → `Handle.ROTATE`).
The rotate drag is **absolute-from-start** — `drag_to()` sets `global_rotation` to
`_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to
15° increments via `snappedf(new_rotation, deg_to_rad(15.0))`.
`_draw()` draws a selection outline for **every** selected node. Pure `_draw()` +
distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE, ROTATE, SCALE }`.
**Phase 10a** adds four constant-screen-size corner crosses for a single `PropBlock`; the
`Handle.SCALE` drag applies one positive uniform factor around the root origin through the
existing transform signals. Other selections keep the existing gizmos.
`transform_committed(nodes: Array[Node2D])` emitted on drag end. `set_enabled(false)` hides +
disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`.
- `scripts/stage/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space
grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()`
- viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled`
are set by `SandboxStage`. Added as the first child of the stage root so it renders behind
`World`; no hit-testing.
- Scenes:
- `scenes/editors/stickman/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
`%CenterColumn`, `%WholeStickmanPreview`, `%SaveDialog`, `%LoadDialog`,
`%ClearConfirmDialog`, `%ErrorDialog`, `%StatusBar`, `%CursorCoords`, `%SnapStatus`.
- **Phase 6 status bar:** `StatusBar` is an `HBoxContainer` bottom-anchored at 28 px
height containing `CursorCoords` (left) and `SnapStatus` (right); `MainLayout`
`offset_bottom` is `-28.0` to leave room for it.
- `scenes/editors/stickman/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel
sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column
height in their parent VBoxContainer.
- `scenes/tools/test_harness.tscn` — **standalone staging scene** (Phase 9, not wired into the
editor; run via **F6**). Backed by `scripts/tools/test_harness.gd`. Top UI bar with file open /
quick-select, "Show Bones" / "Show IK Handles" / "Show Coords" toggles, and a
loaded-filename status label;
`SubViewport` world with an enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on
spawn); interactive limb IK via `SkeletonModificationStack2D` TwoBoneIK.
- `scenes/tools/physics_test_harness.tscn` — **standalone staging scene** (Vector Terrain System /
Dynamic Vector Props, not wired into the editor; run via **F6**). Backed by
`scripts/tools/physics_test_harness.gd`. Root `Node2D` + script, `Camera2D` at position `(0, -400)`
zoom `0.5`, empty Environment container. Wheel-zoom scales between `0.25x` and `3.0x`
(resolution independence / vector outline thickness); middle-drag pans. Top-bar buttons
**Spawn Crate / Spawn Ball / Spawn Plank** spawn dynamic props (`PropUtils`) above the
angled ramp; a toggle button flips the rig **Stickman** ↔ **Ragdoll** (removing/restoring
the best-effort `StaticBody2D` rig collision proxy); a **Knock Up** button impulses the
ragdoll and all props upward.
- `scenes/stage/sandbox_stage.tscn` — **standalone staging scene** (Sandbox Stage Builder, Phase 2,
not wired into the editor; run via **F6**). Backed by `scripts/editors/stage/sandbox_stage.gd`. Minimal
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()`. Phase 3b instantiates
the `AssetSelector` grid popup (see below) into the UI `CanvasLayer`.
- `scenes/stage/asset_selector.tscn` — **standalone asset-selector grid popup** (Phase 3b, not wired
into the editor). Rooted at `AssetSelector` (`PopupPanel`, `scripts/shared/ui/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.
- `scenes/editors/prop/prop_editor.tscn` — **standalone Prop Editor scene** (Phase 9, not wired into the
editor; launched via **F6**). Backed by `scripts/editors/prop/prop_editor.gd`, minimal shell with
`%MenuBar` inside `%TopRow` (beside `%TopRightControls` — the grid spinbox + snap checkbox),
`%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`,
`%InspectorColumn`/`%InspectorList`, a bottom status bar with `%SaveStatus` left +
`%CursorCoords` right, and `%SaveDialog`/`%LoadDialog`/`%ErrorDialog`. The menu, inspector,
mode toolbar, and dialogs are built in code; the `.tscn` is only the layout skeleton.
### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
`right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`,
`right_upper_leg`, `right_lower_leg`.
- Shape dictionary: `{ "shape_type": String, "points": Array[{x,y}], "color": "#hex",
"closed": bool, "vertex_flags": Array[int] }`.
- `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty).
Descriptive tag in Phase 2 — rendering uses `closed`.
- `closed`: `true` = filled + closed outline, `false` = open outline-only.
- `vertex_flags`: same length as `points`; `0` = original vertex (filled circle),
`1` = user-created via "Create Point" (hollow rectangle).
- **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array
position (first = back, last = front). Per-part data includes `{shapes[], position,
rotation, scale}`.
- **Phase 8:** per-part data adds `pivot` `{x, y}` (local bounding-box center = rotation
origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the
`.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three
are write-only metadata recomputed on every save — never read back on load.
- **Phase 9 Round 5:** per-part data adds `guide_offset` `{x, y}` (bbox center − guide joint,
preview space; pure master-space delta) for format v1.5. Write-only metadata like
`pivot`/`length`; the load path ignores it, so v1.0–v1.4 files load unchanged and gain the
key on their next save.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
`"1.0"`–`"1.4"` files auto-migrate on load).
### `.prop` file format (Phase 9)
- The Prop Editor's `.prop` vector-prop assets are versioned JSON (`version "1.0"`),
defined in `README.md`, saved under `res://content/props/*.prop`, and converted to a runtime
payload by `PropFile.flatten_to_payload()`.
- Shape dictionary (per entry in `shapes[]`): `{ "shape_type": "polygon"|"circle"|"line",
"points": Array[{x,y}] (polygon/line), "radius": float (circle), "fill_color": "#hex",
"outline_color": "#hex", "outline_width": float, "closed": bool,
"vertex_flags": Array[int], "reshaped": bool }`.
- `vertex_flags`: same length as `points`; `0` = original vertex, `1` = user-created via
"Create Point" (circles: empty). `reshaped` defaults to `false`; set by vertex
insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius()`.
- **Backward compatibility rule:** unknown/missing keys fall back to defaults (material
preset → `"none"`, `shape_type` → `"polygon"`, missing colors → material tint, missing
`reshaped` → `false`); only **additive** keys may be introduced — never change the
meaning of an existing key. A non-`"1.0"` `version` is rejected on load.
- Prop Editor settings files:
- `user://prop_editor_settings.json` — schema `{ "version": 1, "recent_props": [...] }`,
the last up-to-5 opened/saved prop paths, most-recent-first.
- `user://prop_editor_autosave.prop` — the debounced (1.0 s) autosave, written atomically
via `PropFile.save_safe()` with `metadata.autosave`/`source_path`/`modified_unix`;
cleared on save/load/new/discard, kept after Restore, dropped when stale at startup.
### settings.json (Phase 6)
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
loaded in `_load_settings()`.
- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`.
- `recent_colors: Array[String]` — the last up-to-8 selected hex colors, most-recent-first.
Populated on load and flushed on every color selection.
- `show_pose_guide: bool` — whether the pose silhouette guide is visible in the Whole
Stickman preview. Default `true`. Loaded in `_load_settings()` and flushed on every toggle
via `_broadcast_settings()`.
### Legacy scene (do not delete)
- `scenes/legacy/stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
`RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head
circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for
future animation/rigging phases.
## Editor conventions
- `.godot/` is gitignored; never edit it manually.
- Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes.
- UID references (Godot 4 native) exist in scenes; do not change them by hand.
- Use standard Godot `Control` nodes where applicable.
- Use `class_name` for globally referenced scripts (`BodyPartPanel`, `WholeStickmanPreview`).
- Prefer `%UniqueName` access over exported node paths in `stickman_editor.gd` / `body_part_panel.gd`.
- Keep the `.stk` JSON format backward compatible — never change the meaning of an existing key.