Files
stickman/AGENTS.md
T
ryan d8b1b1164b Add Use Spot Overlay and Target Capture Bar functionality
- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots.
- Implemented UI elements including buttons for changing props and canceling actions.
- Added `PropUseSpotData` class for validation and serialization of prop use spots.
- Created tests for validating use spot data migration, serialization, and rule matching.
- Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
2026-10-13 11:23:44 -04:00

123 KiB
Raw Blame History

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)

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/.
  • 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 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. Keep the prompt and visible Cancel together, leave the mode pill unobstructed, and reuse or generalize StageUseTargetCaptureBar instead of copying its styling.

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 Buttons 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 Labels sourced from PropBlock.mass_for() / PropBlock.physics_material() (single source of truth — never themed), then per-shape fill/outline ColorPickerButtons + an outline-width SpinBox, a vertex-count label, and an Erase Shape button, then the Add Shape section (primitive OptionButton + per-primitive param SpinBoxes + 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) awaits _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 Bone2Ds/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 StickmanRigs 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 AcceptDialogs 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 Nodes (_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_pngs 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, awaits 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.