- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode. - Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features. - Implemented document transactions for scaling, including normalization to a 96 px maximum dimension. - Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props. - Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions. - Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
1405 lines
120 KiB
Markdown
1405 lines
120 KiB
Markdown
# 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/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
|
||
|
||
## 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`
|
||
- 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/sandbox_stage.tscn -- --play --scene <path>.scene`
|
||
- Args after `--` are parsed by `scripts/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/prop_editor.tscn` and is **not**
|
||
the configured `run/main_scene`.
|
||
|
||
## 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 `stick.tscn` rig.
|
||
|
||
## Architecture
|
||
|
||
- `scripts/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/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/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 `master_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/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/prop_editor.tscn`). Owns the menu bar, save/load/new flow, `.prop`
|
||
(de)serialization, the material-preset + per-shape inspector, the mode toolbar, the status
|
||
bar, the window title, the exit dialog, and the autosave/recovery pipeline. State:
|
||
`_theme: PropTheme`, `_material_preset: int` (`PropBlock.MaterialPreset`), `_current_path`,
|
||
`_dirty`, `_clipboard`, `_thumb: PropThumbnail`, `_thumb_cache: ThumbnailCache`, `_ui_theme:
|
||
Theme` (local only — never mutates the global GUI theme), plus `_recent_props`.
|
||
- **Menu bar:** **📁 File** = 🆕 New / 💾 Save / 📄 Save As... / 📂 Load... / separator /
|
||
🕑 **Recent** submenu; **👁️ View** = 🔍 Reset View. There is **no Edit menu** (Round 4
|
||
removed it — grid/snap moved to the top-right controls). Menu labels carry kid-friendly
|
||
emoji.
|
||
- **Recent props (Round 5/6):** `SETTINGS_PATH := "user://prop_editor_settings.json"` (schema
|
||
`{ "version": 1, "recent_props": [...] }`), `RECENT_MAX := 5`; `_load_settings()` /
|
||
`_save_settings()` read/write the list. `static func update_recent_list(list, path,
|
||
max_count = RECENT_MAX) -> Array` is a pure helper (dedupe + front-insert + trim). The
|
||
**🕑 Recent** submenu (`FileMenu/RecentProps` child `PopupMenu`) is rebuilt on
|
||
`about_to_popup` (`_update_recent_menu`): missing files are pruned (and settings re-saved)
|
||
and an empty list shows a disabled "(No recent props)" placeholder. `_push_recent(path)` is
|
||
called on every successful **load AND save**; selecting a recents entry calls `_load_from()`,
|
||
and a missing file shows the error dialog + removes the entry. `RECENT_MAX` is 5,
|
||
most-recent-first.
|
||
- **Top-right controls (Round 4):** built in `_build_top_right_controls()` into `%TopRightControls`
|
||
(the right side of `%TopRow`, beside `%MenuBar`): a `Grid:` `Label` + `_grid_spin` `SpinBox`
|
||
(1–512, initialized from `_theme.grid_size()`) + a `Snap` `CheckBox` (`_snap_check`). This
|
||
replaces the old `Edit → Configure Grid / Snap...` dialog/menu entirely.
|
||
- **Mode toolbar (Round 2/3):** a centered `%ModeBar` `HBoxContainer` above the canvas
|
||
(`_build_mode_toolbar()`) holding two `toggle_mode` `Button`s sharing a `ButtonGroup`:
|
||
🖐️ **Arrange** (`EditMode.TRANSFORM`) and ✏️ **Reshape** (`EditMode.POINTS`).
|
||
`static func mode_button_text(mode: int) -> String` is the pure, headless-testable
|
||
label builder (`MODE_EMOJI`/`MODE_LABELS`, clamps to the last entry). Each button applies
|
||
`_theme.font_mode()` + `_theme.mode_button_min_height()` and sets
|
||
`focus_mode = FOCUS_NONE` (Round 4 fix — the `focus` stylebox draws after `pressed`, which
|
||
otherwise masked the selected/accent state until the cursor moved). `_sync_mode_buttons()`
|
||
re-presses the right button on `mode_changed` (via `set_pressed_no_signal`) and colors the
|
||
label with `accent_color()`.
|
||
- **Inspector (`_build_inspector()`, built into `%InspectorList`):** a material-preset
|
||
`OptionButton` (`PRESET_LABELS` None/Wood/Rubber/Cardboard/Metal) + read-only mass/friction/
|
||
bounce `Label`s sourced from `PropBlock.mass_for()` / `PropBlock.physics_material()`
|
||
(single source of truth — never themed), then per-shape fill/outline `ColorPickerButton`s +
|
||
an outline-width `SpinBox`, a vertex-count label, and an **Erase Shape** button, then the
|
||
**Add Shape** section (primitive `OptionButton` + per-primitive param `SpinBox`es +
|
||
**Add a Shape** button). `PRIMITIVE_IDS`/`PRIMITIVE_LABELS` = Box/Plank/Triangle/Line/Circle
|
||
(**Ball removed, Round 4** — circle covers it; `"ball"` stays available programmatically).
|
||
`_refresh_inspector_from_selection()` syncs the controls from the selected shape and gates
|
||
the **circle Radius row**: when the shape's `reshaped` flag is set the `_radius_row` is
|
||
hidden and a muted "✏️ This shape was reshaped" note (`_reshaped_note`) is shown instead.
|
||
`_sync_mode_controls(mode)` hides the Add-Shape section (`_add_section`) and the Erase
|
||
button in POINTS (Reshape) mode, called from `_on_canvas_mode_changed` and `_ready()`.
|
||
- **Window title (Round 3):** `static func build_window_title(prop_name, file_path, dirty) ->
|
||
String` is a pure, headless-testable builder producing `[name][ — filename.prop ][ *]`
|
||
(empty name → `UNTITLED_NAME` = "Untitled"; `file_path.get_file()` keeps the `.prop`
|
||
extension; ` *` when dirty). `_update_window_title()` is the single re-derivation site,
|
||
called from `_update_save_status()` (which is already invoked on every relevant transition).
|
||
`_on_save_file_selected()` normalizes the path to `.prop` so `get_file()` always carries the
|
||
extension.
|
||
- **Exit dialog (Round 3):** `get_tree().auto_accept_quit = false` in `_ready()` +
|
||
`_notification(NOTIFICATION_WM_CLOSE_REQUEST)` → `_request_close()`. Clean tree quits
|
||
immediately; dirty shows the themed `_exit_dialog` ("⚠️ Unsaved Changes") with **💾 Save** /
|
||
**🗑️ Discard** (`add_button`, action `&"discard"`) / **❌ Cancel**. Save with no
|
||
`_current_path` opens the Save dialog (the pending flag stays set); otherwise
|
||
`_save_and_finish()`. `_save_to(path, with_thumbnail)` returns success and **skips the
|
||
thumbnail render on exit-save** (the PNG is a cache nicety — a fast, deterministic quit). A
|
||
successful save-then-quit clears the autosave via `_save_to`. The **same dialog** is reused
|
||
by **File → New** (`_request_new()` sets `_pending_new`, different prompt text; Save →
|
||
`_do_new()`, Discard → `_do_new()`, Cancel → stay).
|
||
- **Autosave / recovery (Round 3):** `AUTOSAVE_PATH := "user://prop_editor_autosave.prop"`,
|
||
`AUTOSAVE_DEBOUNCE := 1.0`. Dirty setpoints route through `_mark_dirty()` (sets `_dirty`,
|
||
refreshes the status bar/title, arms `_autosave_remaining`); `_process(delta)` counts down
|
||
and calls `_write_autosave()`, which stamps `metadata.autosave = true` /
|
||
`metadata.source_path` / `metadata.modified_unix` and writes atomically via
|
||
`PropFile.save_safe()` (no re-trigger: it does not touch `_dirty` or emit `shape_changed`).
|
||
`_clear_autosave()` removes the file (also resetting the countdown). `_check_autosave_recovery()`
|
||
(called from `_ready()` after dialogs are built) prompts 🔄 **Restore** / 🗑️ **Discard** on a
|
||
stale-checked autosave (if the saved source exists and is newer-or-equal, the autosave is
|
||
dropped silently); Restore calls `_apply_json_data(raw, true)` (the `is_recovery` flag keeps
|
||
the prop **dirty**) and restores `_current_path`. Clear semantics: clear on successful
|
||
save/load/new/discard-quit; **kept after Restore**; stale autosaves are dropped at startup.
|
||
- **Save flow:** `_save_to(path, with_thumbnail = true) -> bool` writes `PropFile.save(path,
|
||
_build_json_data())`, updates `_current_path`/`_dirty`, clears the autosave, refreshes
|
||
status/title, and (when `with_thumbnail`) `await`s `_save_prop_thumbnail()`. The thumbnail
|
||
path reuses `PropThumbnailScript.render(PropFile.flatten_to_payload(shapes),
|
||
_material_preset)` then `ThumbnailCacheScript.save_png(tex, _thumb_cache.prop_file_png(path))`
|
||
(200×200 `user://thumbnails/props/<basename>_<mtime>.png`); a blank/headless capture
|
||
(`null`) is skipped silently. The editor preloads `prop_file.gd`, `prop_theme.gd`,
|
||
`prop_block.gd`, `prop_canvas.gd`, `thumbnails/prop_thumbnail.gd`, and
|
||
`thumbnails/thumbnail_cache.gd` via `preload` consts. `_apply_json_data(json, is_recovery =
|
||
false) -> String` returns `""` on success or an error string (dialog on failure).
|
||
- **Phase 10a final controls:** the Arrange top bar orders **Transform Whole Prop**,
|
||
**Normalize Size**, **Center Prop**, and **Show Stickman Guide**. The numeric scale textbox
|
||
and Apply Scale button are not exposed. Transform Whole Prop is Arrange-only and previews
|
||
whole-document move, origin-based rotation, and uniform resize; Snap rounds move deltas,
|
||
Ctrl snaps rotation to 15°, editable anchors follow, and Esc cancels the preview. Reshape
|
||
and Attach hide Transform Whole Prop and Normalize Size; Center Prop and Show Stickman Guide
|
||
remain available.
|
||
- `scripts/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/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://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/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/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/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 `master_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.
|
||
`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 `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
|
||
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
|
||
- `scripts/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
|
||
facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9
|
||
Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node
|
||
resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a
|
||
live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu
|
||
ids), `BendDirection { NORMAL, INVERTED }`, and `RigState { ANIMATED, RAGDOLL, RECOVERING }`.
|
||
Constants (moved from the harness): `SKELETON_PATH`,
|
||
`BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`),
|
||
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
|
||
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
|
||
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
|
||
`FORWARD`, a preset whose setter writes the four per-joint vars, reorders `Body/*`, and
|
||
applies a whole-rig Y-axis mirror for LEFT — `Master.scale.x = -1`; RIGHT/FORWARD `(1,1)`) and an
|
||
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
|
||
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
|
||
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
|
||
`auto_recover` (true), plus recovery constants
|
||
`STAND_POSE`/`IK_TARGET_PATHS`/`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD`/
|
||
`STAND_UP_DURATION`/`STABILIZATION_DELAY`/`RAGDOLL_TARGET_SOFTNESS`. The
|
||
**Joint bend configuration (EDIT ME)** block holds every per-joint bend decision of the
|
||
ragdoll cycle, keyed by facing profile, so joints can be tuned without touching pipeline
|
||
code: `RAGDOLL_JOINT_BAND_LIMITS[profile][joint] = {"lower": deg, "upper": deg}` (the
|
||
fall's knee/elbow `PinJoint2D` band — 0 = straight, small side ~5 resists hyperextension,
|
||
large side ~150 is the fold; missing entries fall back to the `RAGDOLL_JOINTS` `limit`
|
||
string), `RECOVERY_LEG_SIDE_FORCE[profile][leg]` (optional leg IK `flip_bend_direction`
|
||
override; missing = match the captured fall pose's knee side), and
|
||
`RECOVERY_JOINT_PATH[profile][joint]` (optional `"shortest"` elbow/knee tween-path
|
||
override; missing = pass through straight when the shortest path would cross ±180°).
|
||
Signals `facing_profile_changed(profile)` /
|
||
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`,
|
||
`set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and
|
||
`get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/
|
||
`Vector2.ZERO`). `_ready()` resolves `Skeleton2D`/`Body`/4 lower `Bone2D`s/4 TwoBoneIK
|
||
modifications (matched by `joint_two_bone2d_node` NodePath, **never stack index**), enables the
|
||
modification stack (`stack.enabled = true`), applies the current profile once, then sets
|
||
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
||
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
||
`"StickmanRig: "` throughout; never crashes.
|
||
- **Whole-rig Y-axis mirror (replaces `_apply_head_flip()`, 2026 design change):** Facing LEFT
|
||
is applied as a **whole-rig Y-axis mirror** — `Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) —
|
||
so the head AND body mirror together and face the correct direction. The old `_apply_head_flip()`
|
||
and the `Body/Head.scale.x` mirror are **removed** (the Head Pivot node's driver transform is
|
||
untouched). Per-joint `flip_bend_direction` flags (`PROFILE_FLAGS`) and `Z_ORDER_BY_PROFILE` are
|
||
**kept provisionally** (unchanged): the mirror reflects the whole skeleton + `IK_Targets` +
|
||
mounted `Body/*` geometry, while z-order (depth) is unaffected by an X-mirror. `walk_to()`
|
||
always plays the canonical `walk_right` clip (X-mirrored by the root for LEFT; `walk_left` is
|
||
no longer used at runtime) and sets the facing profile explicitly. Two **head-related fixes**
|
||
under the mirror: the master_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 — `master_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/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 `master_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://master_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/create_animations.gd` — `@tool extends EditorScript`; a **standalone editor utility**
|
||
(run manually with `master_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 `master_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/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
|
||
run via **F6** on `res://scenes/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 `stickmen/break.stk`, `stickmen/basic.stk`, `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_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_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/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/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/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/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/physics_test_harness.tscn`). Builds flat ground, angled ramps, and stepped
|
||
`TerrainBlock` instances via `TerrainUtils`, instantiates `res://master_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/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/sandbox_stage.tscn`). Owns the EDIT/DIRECT/PLAY mode state machine, placement mode,
|
||
camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` /
|
||
`StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, DIRECT, PLAY }`
|
||
(default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` +
|
||
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
|
||
KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first);
|
||
PLAY unfreezes props, enables PLAY-only terrain following/free-fall, and starts stickman queues
|
||
with `auto_recover = false`; ragdoll/recover remain explicit actions. Signals
|
||
`mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`,
|
||
`object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` /
|
||
`max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport).
|
||
`_world_children_selectable()` returns direct `Node2D` children of `World` excluding
|
||
`RagdollBodyContainer`. **Authored-state / restart-the-sim:** `_authored` (instance id →
|
||
`{node, position, rotation}`) is updated at **edit time** on every place (`_place_at` →
|
||
`_save_object_state`) and move/rotate (`transform_committed` → `_on_transform_committed`),
|
||
and cleared on delete (`_clear_object_state`). `_enter_edit_mode()` stands stickmen, then
|
||
freezes every prop (`freeze_mode = FREEZE_MODE_KINEMATIC` **before** `freeze = true`, so the
|
||
body freezes directly as kinematic — never via the static layer, whose transform sync drops a
|
||
subsequent position set), then `_restore_authored_state()` teleports via
|
||
`global_position`/`global_rotation` + zeroed velocity. Because the freeze+teleport can take a
|
||
physics frame or two to settle, `_restore_authored_state()` is also re-asserted over the next
|
||
few `_physics_process` frames (`_restore_frames_left`) — so every Play session starts from and
|
||
returns to the same authored state. **Phase 10a scale lifecycle:** prop corner-scale gestures
|
||
use the existing transform signal path, synchronize the frozen body during EDIT, save the
|
||
authored scale on commit, and retain that scale across PLAY → EDIT → PLAY. **Placement ghost:**
|
||
while a palette
|
||
item is active, `set_placement_mode(id)` spawns a translucent (`modulate.a = 0.5`),
|
||
non-colliding (`collision_layer/mask = 0`, frozen) copy of the object reparented out of
|
||
`World` into `_ghost_holder`; `_process()` tracks it to the cursor (+ snap) via
|
||
`_update_ghost_position()`, and `_place_at()` re-spawns it after each placement. A ghost
|
||
**stickman** has its `Skeleton2D` modification stack disabled and its `AnimationPlayer`
|
||
stopped so it renders as a static standing figure (its limbs don't flex/follow the cursor).
|
||
**Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap`
|
||
`CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json`
|
||
(`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate
|
||
drags via `_snap_to_grid()` / `StageGizmos.snap_size`. Gizmo input uses the exact mouse-event
|
||
coordinates converted by `_viewport_to_world()` for press, motion, and release. Frozen props
|
||
are synchronized to the edited `global_transform` through `PhysicsServer2D` during drag and
|
||
for three post-release settle frames. The grid is visible only in EDIT mode
|
||
(`_apply_grid_settings()` sets `_grid.visible = _show_grid and current_mode == EDIT`, re-applied
|
||
on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor)
|
||
and `InputEventPanGesture` (two-finger pan, `delta × 3 / zoom`) alongside the mouse wheel/middle
|
||
pan. UI built in code (CanvasLayer +
|
||
PanelContainer top bar): mode toggle, six palette buttons (built from the spawner registry),
|
||
Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are
|
||
hidden in PLAY via `_set_build_controls_visible()`, called from `_enter_edit_mode()` /
|
||
`_enter_play_mode()` (the mode toggle and status label stay visible).
|
||
- **Phase 3a director tool:** adds a **"Direct"** palette toggle button (mutually exclusive with
|
||
placement) and a `PopupMenu` (`_action_popup`, items `Walk To`/`Speak`/`Wait`/`Ragdoll`/`Recover`,
|
||
ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); the popup is
|
||
positioned at the clicked stickman's world position converted to screen (`_world_to_screen(world_pos)`)
|
||
offset 24 px right, not at the mouse cursor (this first menu also records the session popup anchor
|
||
for the Phase 4 rule-builder child menus); speak/wait
|
||
`AcceptDialog`s append `speak`/`wait` actions; `Walk To` enters a pending target-capture mode whose
|
||
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
|
||
priority: pending target → exit direct mode → existing placement clears). Builds a code-built
|
||
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
|
||
hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
|
||
short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
|
||
flag re-bakes once per frame (`_process`) on terrain
|
||
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
|
||
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
|
||
auto-ragdolls stickmen — it sets each rig `auto_recover = false`, calls `begin_terrain_fall()`,
|
||
then calls `start_queue()`;
|
||
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
|
||
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
|
||
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
|
||
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
|
||
- **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules:
|
||
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
|
||
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
|
||
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
|
||
toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY
|
||
(`_update_area_entry` tests each movable's visible world AABB against the current transformed
|
||
`TriggerArea` world AABB); Phase 4.5 routes stickman-prop `collided` events from `PropBlock`
|
||
physics contacts instead of polling stickman feet points,
|
||
with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI
|
||
state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`,
|
||
`ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct
|
||
action popup: trigger sub-menu popup → trigger-target click → rule-action popup → target/position
|
||
→ "Add another action / Done" popup. **Esc** has highest priority; status-bar hints + toast
|
||
messages guide the flow. Rule **label click** → consequence-only edit (replaces the rule, same
|
||
`id`); **✕** deletes; `_cleanup_rules_for_nodes` auto-removes rules referencing deleted objects.
|
||
All rule-builder context popups are **session-anchored**: the first popup in a session records
|
||
its screen position (`_popup_anchor` / `_popup_anchor_set`; the Direct first menu = right of the
|
||
clicked stickman, the rule-label edit entry = the click position), and every child popup in the
|
||
session — "⚡ When…" trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another
|
||
action" — reuses that recorded position via `_set_popup_anchor(rect)` / `_popup_anchor_rect()`,
|
||
so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on
|
||
confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit
|
||
(`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path
|
||
intentionally reuses it). **Phase 4 final-bugs runtime:** rule actions are dispatched into
|
||
`StickmanRig`'s private runtime lane: Overlay speech runs concurrently in one replaceable
|
||
slot, Sequential actions interrupt the current primary action while retaining authored future
|
||
work, and Takeover actions cancel both before taking control. Collision callbacks are PLAY-only,
|
||
so delayed contacts after STOP cannot recreate a flinch. Walk To summaries read the waypoint
|
||
from `params.target`, separate from the top-level actor instance id.
|
||
- **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable
|
||
instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area
|
||
entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can
|
||
trigger when a visible leg/body part enters even if its feet anchor is still outside. The
|
||
`_area_overlap` edge state is independent per area/movable instance-ID pair: it fires once on
|
||
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
|
||
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
|
||
all passed) and manual editor verification of A entering an area causing B's speech bubble.
|
||
- **Phase 6 runtime terrain/events:** `_enter_play_mode()` calls `begin_terrain_fall()` before
|
||
`start_queue()`. EDIT/DIRECT disable terrain following and restore authored transforms. The
|
||
navigation bake uses shallow exposed upward-surface ribbons and connects neighboring surface
|
||
endpoints only within 64 px horizontal / 96 px vertical step limits; the `NavigationAgent2D`
|
||
remains at `(0, 385)` and follows the selected walk speed while terrain owns vertical root motion.
|
||
`_update_area_entry()` unions each visible stickman's current and previous world AABB so fast
|
||
free-fall still emits `entered_area`. In a reactive batch, the first takeover cancels existing
|
||
work once while later takeover actions remain ordered; `[ragdoll, recover]` therefore ragdolls,
|
||
waits for rest, then recovers. Terrain diagnostics are gated by `StickmanRig.DEBUG_TERRAIN` and
|
||
are off by default.
|
||
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
|
||
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
|
||
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
|
||
`_build_ui()` with theme/font overrides applied via `AssetSelector.apply_font`), backed by a
|
||
dim backdrop `_selector_dim` (a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, `mouse_filter =
|
||
MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector
|
||
is open). State
|
||
`_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds
|
||
`_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s
|
||
(`_stickman_thumb`/`_prop_thumb`, added as children so they can `await`), plus a Browse
|
||
`_browse_dialog` `FileDialog` (`*.stk`). `_on_palette_toggled` routes `stickman`/`prop` presses
|
||
to `_open_selector(id)` (which forces the palette button pressed, rescans entries, performs the
|
||
**single-item skip** for a lone stickman file, and — when the selected path is absent from the
|
||
scan — reselects the first entry); its un-press branch closes a matching open selector.
|
||
`_on_asset_selected(entry)` writes the selection to `StageSpawner` (`selected_stickman_path` /
|
||
`selected_prop_id`), closes the selector, and calls `set_placement_mode(kind)`;
|
||
`_close_selector()` also clears `_thumbnail_queue` (it flips `_selector_open` off before hiding
|
||
the popup). `_on_selector_cancelled()` closes + exits
|
||
placement (un-presses the palette button); the selector's `popup_hide` signal is also routed to
|
||
`_on_selector_cancelled()` (idempotency-guarded), so an outside-click close likewise un-presses
|
||
the palette button. `_on_browse_file_selected` builds an ad-hoc entry via
|
||
`StickmanLibrary.make_entry(path)` (toast on failure); `_on_refresh_requested` rescans +
|
||
re-enqueues. **Lazy thumbnail drain:** `_load_or_enqueue_thumbnails` seeds the selector from
|
||
any cached PNG and enqueues the rest; `_process` → `_drain_thumbnail_queue()` renders **one per
|
||
frame** (awaits the renderer, `save_png`s it, hands the texture back via
|
||
`_selector.set_thumbnail` while the selector is open). The selector re-centers on window resize
|
||
(the root `AssetSelector` re-runs `popup_centered()` on `size_changed` while visible). **Esc
|
||
priority** inserts the selector
|
||
before DIRECT/placement in `_unhandled_key_input`; `_handle_world_click` / `_handle_mouse_motion`
|
||
early-return while `_selector_open` (belt-and-suspenders over the modal popup). Selection is
|
||
**session-only** — persists across EDIT/DIRECT/PLAY toggles, resets on scene reload, **no disk
|
||
save** (`_save_settings` is untouched).
|
||
- `scripts/stickman_speech_bubble.gd` — `class_name SpeechBubble`, `extends Node2D`; a **world-space
|
||
speech bubble** drawn in `_draw()` (Phase 3a, **not used by the editor**). Child of a `StickmanRig`
|
||
at `SPEECH_BUBBLE_OFFSET` (above the head), so it follows the figure and scales with the camera.
|
||
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_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_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://stickmen/test.stk`) and
|
||
`selected_prop_id` (default `"crate"`), read/written by the sandbox selector (Phase 3b) — they
|
||
are **session-only** (no disk save). `_stickman_cache: Dictionary` (path → parsed data) seeds
|
||
the default path in `_init` and lazily loads+parses any newly selected path on first spawn.
|
||
`_spawn_stickman` spawns from the **selected** path (applies `STICKMAN_FOOT_OFFSET (0, -385)`
|
||
so feet land at the cursor); `_spawn_prop` looks the **selected** prop id up via
|
||
`PropLibrary.get_entry(id)` and spawns its payload (`PropUtils.spawn_prop`) + material preset.
|
||
New getters `get_selected_stickman_path()` / `get_selected_prop_id()` (status + tests).
|
||
- `scripts/stickman_library.gd` — `class_name StickmanLibrary`, `extends RefCounted` (Phase 3b,
|
||
**not used by the editor**). Scans `res://stickmen/*.stk` into entry models for the asset
|
||
selector. `const STICKMEN_DIR := "res://stickmen"`; `var entries: Array[Dictionary]`. Public API:
|
||
`scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]` (via `DirAccess` + per-file
|
||
`StickmanFactory.load_stk`; a corrupt or missing-`body_parts` file is **skipped** with a
|
||
`push_warning` — never `{}`-as-entry; the display `name` = `stickman_name` (stripped) if
|
||
non-empty else the filename basename; results sorted by name, then path, cached on `self` and
|
||
returned), `get_entries() -> Array[Dictionary]` (returns the last scan, does **not** rescan),
|
||
`find_by_path(path) -> Dictionary` (`{}` if absent), and `make_entry(path) -> Dictionary`
|
||
(ad-hoc single-file entry for **Browse**: `load_stk` + name fallback; `{}` on load failure or
|
||
missing `body_parts`). Entry shape `{ "path", "name", "data" }`; thumbnails are **not** embedded
|
||
in entries — resolved via `ThumbnailCache` at display time by the selector.
|
||
- `scripts/prop_library.gd` — `class_name PropLibrary`, `extends RefCounted` (Phase 3b, **not used
|
||
by the editor**). A **static** registry of the 4 built-in prop templates for the asset selector.
|
||
`static var _templates: Array[Dictionary]` (lazily built on first access — `PropUtils.create_*()`
|
||
are `static func` and cannot run in a `const`). Public API: `static get_entries()` (builds once,
|
||
returns the 4 templates), `static get_ids()`, `static get_entry(id) -> Dictionary` (`{}` if
|
||
unknown), `static get_default_id() -> String` (`"crate"`). Template dicts carry `{id, name,
|
||
material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber
|
||
(`create_ball()` + `RUBBER`), Plank/Metal (`create_plank()` + `METAL`), Triangle/Cardboard
|
||
(`create_triangle()` + `CARDBOARD`) — generator color themes already match the presets.
|
||
- `scripts/thumbnails/thumbnail_cache.gd` — `class_name ThumbnailCache`, `extends RefCounted`
|
||
(Phase 3b, **not used by the editor**). Disk PNG cache under `user://thumbnails/`. Consts
|
||
`STICKMEN_DIR := "user://thumbnails/stickmen"`, `PROP_DIR := "user://thumbnails/props"`,
|
||
`PROP_VERSION := 1`. Public API: `stickman_key(path)` → `"<basename>_<FileAccess.get_modified_time(path)>"`
|
||
(an mtime change yields a new key → missing PNG → regenerate); `stickman_png(key)` /
|
||
`prop_png(id)` (`"<id>_v<PROP_VERSION>.png"` — bump `PROP_VERSION` to invalidate all prop
|
||
thumbnails); `load_png(png_path) -> Texture2D` (`Image.load_from_file` + `ImageTexture`, `null`
|
||
if missing/unloadable); `save_png(tex, png_path) -> Error` (creates the parent dir); `ensure_dir`;
|
||
`clean_stale_stickmen(valid_keys)` (deletes `basename_*` PNGs not in the valid key set).
|
||
- `scripts/thumbnails/stickman_thumbnail.gd` — `class_name StickmanThumbnail`, `extends Node`
|
||
(Phase 3b, **not used by the editor**). Renders a parsed `.stk` into a `Texture2D` for the asset
|
||
selector by spawning the **real rig**. `const SIZE := Vector2i(200, 200)`. `_ready()` builds one
|
||
persistent offscreen `SubViewport` (`transparent_bg`, `render_target_update_mode = UPDATE_ALWAYS`)
|
||
with an enabled in-viewport `Camera2D` (`make_current()`). `render(stk_data) -> Texture2D`:
|
||
frees prior children, `StickmanFactory.spawn_from_data(stk_data)`s the rig into the viewport at
|
||
`Vector2.ZERO`, frames it via `StageSpawner.get_world_aabb(rig)` (a degenerate/no-area bbox falls
|
||
back to a fixed `(120×500)` rect), `_frame_camera` fits it with a 12 px margin, `await`s
|
||
`RenderingServer.frame_post_draw` **twice** (standard offscreen capture recipe), grabs the viewport
|
||
texture, frees the rig, and returns an `ImageTexture` — or `null` (a placeholder) when the capture
|
||
is blank/empty (the **headless degrade**; pixel rendering is manual/F6 verification only). Output is
|
||
identical to the rig actually placed on stage, not a re-implementation of the editor preview.
|
||
- `scripts/thumbnails/prop_thumbnail.gd` — `class_name PropThumbnail`, `extends Node` (Phase 3b,
|
||
**not used by the editor**). Renders a prop template into a `Texture2D`. `const SIZE :=
|
||
Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as
|
||
`StickmanThumbnail`. `render(payload, material_preset) -> Texture2D` mirrors `PropBlock`'s
|
||
geometry into a plain `Node2D` with a `Polygon2D` (fill) + `Line2D` (closed loop, round
|
||
joints/caps) — **not** a `RigidBody2D`, so nothing falls under gravity inside the viewport;
|
||
circle payloads are re-expanded via `PropBlock.CIRCLE_SEGMENTS`; non-`NONE` presets tint fill via
|
||
`PropBlock.tint_for` and darken the outline; then frames + double-`frame_post_draw` captures and
|
||
returns an `ImageTexture` or `null` (blank → placeholder).
|
||
- `scripts/asset_selector.gd` — `class_name AssetSelector`, `extends PopupPanel` (Phase 3b, **not
|
||
used by the editor**); root script of `scenes/asset_selector.tscn`. A **grid popup controller**.
|
||
Signals `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`.
|
||
`const COLUMNS := 4`, `ROWS := 3`, `PAGE_SIZE := 12`, `CELL_MIN_SIZE`, `THUMB_SIZE`. `var kind`
|
||
(`"stickman"` | `"prop"`). Public API: `open(kind, entries)` (sets
|
||
the title "Choose Your Stickman"/"Choose a Prop", shows **Browse…**/**Refresh** only for
|
||
stickmen, then `popup_centered()`), `set_entries(entries)` (used by Refresh — resets page +
|
||
thumbnails), `set_thumbnail(entry, tex)` (lazy handoff from the stage's drain), `close()`, and
|
||
`apply_font(ui_font, emoji_font)` (walks the authored controls). `_ready()` sets `exclusive =
|
||
true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()`
|
||
(the Window's built-in Esc close is not relied on). Re-centers on window resize: the root's
|
||
`size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build:
|
||
`_rebuild()` frees the `%GridContainer` children, toggles the empty-state `%EmptyLabel`,
|
||
shows/hides Prev/Next/page label by page count, and slices the current page via the
|
||
**static pure** `page_bounds(total, page, page_size) -> Dictionary {start, end, total,
|
||
page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect`
|
||
(cached/placeholder texture) + a name `Label` + (prop only) a material-badge `Label`; cells emit
|
||
`item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling
|
||
was removed). The `.tscn` is a **minimal shell**
|
||
(title bar, empty grid, footer as authored unique-name nodes `%TitleLabel`/`%GridContainer`/
|
||
`%EmptyLabel`/`%PageLabel`/`%PrevButton`/`%NextButton`/`%BrowseButton`/`%RefreshButton`/
|
||
`%CloseButton`); all dynamic per-cell content is built in code at runtime because the entry set is
|
||
dynamic.
|
||
- `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/
|
||
box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb`
|
||
unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively
|
||
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_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_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/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/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/test_harness.tscn` — **standalone staging scene** (Phase 9, not wired into the
|
||
editor; run via **F6**). Backed by `scripts/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/physics_test_harness.tscn` — **standalone staging scene** (Vector Terrain System /
|
||
Dynamic Vector Props, not wired into the editor; run via **F6**). Backed by
|
||
`scripts/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/sandbox_stage.tscn` — **standalone staging scene** (Sandbox Stage Builder, Phase 2,
|
||
not wired into the editor; run via **F6**). Backed by `scripts/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/asset_selector.tscn` — **standalone asset-selector grid popup** (Phase 3b, not wired
|
||
into the editor). Rooted at `AssetSelector` (`PopupPanel`, `scripts/asset_selector.gd`); a
|
||
**minimal shell/layout skeleton** — authored title bar, empty `GridContainer`, empty-state
|
||
`Label`, and a footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`), all as
|
||
unique-name nodes (`%TitleLabel`, `%GridContainer`, `%EmptyLabel`, `%PageLabel`, `%PrevButton`,
|
||
`%NextButton`, `%BrowseButton`, `%RefreshButton`, `%CloseButton`). All dynamic per-cell content
|
||
(thumbnail + name + prop material badge) is built in code at runtime because the entry set is
|
||
dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop**
|
||
palette-button selector grids.
|
||
- `scenes/prop_editor.tscn` — **standalone Prop Editor scene** (Phase 9, not wired into the
|
||
editor; launched via **F6**). Backed by `scripts/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://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)
|
||
|
||
- `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.
|