Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043f114083 | ||
|
|
0a7f97cec7 | ||
|
|
97f3730413 | ||
|
|
00abacc7b2 | ||
|
|
e3689c86c8 | ||
|
|
ed9a2d0d18 | ||
|
|
b66ded5f60 | ||
|
|
fbce638bc5 | ||
|
|
db4ff8d939 | ||
|
|
9a22322dba | ||
|
|
1a2a43c805 | ||
|
|
baa19a9341 | ||
|
|
2071ee03b7 |
@@ -1,24 +1,52 @@
|
||||
name = "architect"
|
||||
description = "Defines system requirements and technical specifications for Godot features."
|
||||
description = "Evaluates architectural choices, proposes Godot 4 best practices, asks interactive clarification questions, and writes implementation specs."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
developer_instructions = """
|
||||
You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory standards and coding rules.
|
||||
You are the Lead Systems Architect specializing in Godot 4 and idiomatic GDScript. Refer to `AGENTS.md` for project directory standards and coding rules.
|
||||
|
||||
## Responsibilities
|
||||
1. **Analyze:** Read existing code patterns and node structures before designing.
|
||||
2. **Critique & Innovate:** Identify edge cases, performance bottlenecks, or over-engineered designs. Offer superior GDScript patterns (e.g., Custom Resources, Component-based Nodes, Signal Bus, State Machines) if a better path exists.
|
||||
3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows.
|
||||
4. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||
|
||||
1. **Analyze:** Read existing code patterns before designing.
|
||||
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
|
||||
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||
---
|
||||
|
||||
## Workflow
|
||||
## TWO-PHASE WORKFLOW
|
||||
|
||||
1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous.
|
||||
2. **Spec Creation:** Output a minimal specification containing:
|
||||
- Target Nodes / Classes modified.
|
||||
- Signal interfaces & static type contracts.
|
||||
### PHASE 1: Scope, Critique & Pitch (DEFAULT FIRST TURN)
|
||||
Unless explicitly instructed by the prompt to "Skip clarification and build spec immediately," DO NOT write to `specs/[feature-name].md` during your first response.
|
||||
|
||||
Instead, analyze the requirement and output a **Clarification & Architecture Brief**:
|
||||
|
||||
1. **Clarification Questions (Up to 3):**
|
||||
- Identify structural ambiguities (e.g., node hierarchy, state handling, physics, static vs. dynamic loading).
|
||||
- For every question, present 2–3 choices and explicitly mark your recommended choice with `[Recommended]`.
|
||||
|
||||
2. **Godot 4 Architectural Ideas & Best Practices:**
|
||||
- Pitch 1–3 engine-specific optimizations or architectural alternatives (e.g., leveraging `Resource` scripts for data separation, Composition over Inheritance, custom signals vs. direct calls).
|
||||
- Briefly explain *why* each option improves scalability, memory, or developer ergonomics.
|
||||
|
||||
3. **Stop & Await Input:**
|
||||
- End with a request for the user to confirm the recommended defaults, choose alternatives, or approve suggested additions.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 2: Spec Creation & Handoff (AFTER USER FEEDBACK)
|
||||
Once the user responds to Phase 1 (or approves the pitch), perform the following:
|
||||
|
||||
1. **Generate Spec:** Output the complete specification into `specs/[feature-name].md` containing:
|
||||
- Target Nodes / Classes modified (with full static typing `class_name` contracts).
|
||||
- Signal interfaces & static payload shapes.
|
||||
- Layout/Theme updates required.
|
||||
3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval.
|
||||
- **Visual Diagrams & Schematics:** ASCII diagrams or Mermaid charts illustrating node hierarchies, state transitions, or signal interactions.
|
||||
- **Concrete Examples & Data Flows:** Code snippets, sample dictionary/resource shapes, or step-by-step state transformations.
|
||||
- **Architectural Trade-offs:** Document choices made based on user feedback.
|
||||
|
||||
2. **Log Debt:** Append key architectural trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||
|
||||
3. **Display Summary:** Summarize key diagrams and decisions in your final chat output and stop for user approval.
|
||||
"""
|
||||
@@ -1,23 +1,28 @@
|
||||
---
|
||||
name: spec-pipeline
|
||||
description: "Lean implementation loop: Architect -> Developer -> User Verification."
|
||||
description: "Lean implementation loop: Architect (Clarify -> Spec) -> Developer -> User Verification."
|
||||
---
|
||||
|
||||
## Execution Logic
|
||||
|
||||
1. **Phase 1: Architecture (Optional for small edits)**
|
||||
- `@architect` defines signatures, node structures, or spec updates in `specs/[feature-name].md`.
|
||||
- **STOP:** Wait for user approval on design choices before invoking developer.
|
||||
1. **Phase 1a: Architecture Proposal & Clarification**
|
||||
- `@architect` analyzes existing patterns, pitches 1–3 Godot best-practice ideas, and asks up to 3 clarification questions with `[Recommended]` options.
|
||||
- `@architect` stops without writing the spec file yet.
|
||||
|
||||
2. **Phase 2: Implementation & Engine Validation**
|
||||
- `@developer` updates code and scenes using targeted diffs/snippets.
|
||||
2. **Phase 1b: User Feedback & Spec Generation**
|
||||
- User approves, modifies, or selects choices from `@architect`'s proposal.
|
||||
- `@architect` writes the finalized specification to `specs/[feature-name].md` and logs decisions in `docs/tech_debt_and_optimizations.md`.
|
||||
- **STOP:** Wait for final user sign-off on the spec before invoking developer.
|
||||
|
||||
3. **Phase 2: Implementation & Engine Validation**
|
||||
- `@developer` updates code and scenes using targeted diffs/snippets based on `specs/[feature-name].md`.
|
||||
- `@developer` runs headless syntax verification:
|
||||
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"`
|
||||
- If static check fails, `@developer` fixes the code immediately.
|
||||
|
||||
3. **Phase 3: User Hand-off (Stop)**
|
||||
4. **Phase 3: User Hand-off (Stop)**
|
||||
- Present the changes to the user to test inside the Godot Editor.
|
||||
- Do NOT update READMEs or documentation yet.
|
||||
|
||||
4. **Phase 4: Documentation (Triggered manually by user)**
|
||||
5. **Phase 4: Documentation (Triggered manually by user)**
|
||||
- Only call `@writer` after the user confirms: _"Feature verified and working."_
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
# AGENTS.md — stickman (Godot 4.4)
|
||||
# AGENTS.md — stickman (Godot 4.7.1)
|
||||
|
||||
## Project type
|
||||
- **Godot 4.4** 2D/GUI project (Forward Plus renderer)
|
||||
|
||||
- **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()`.
|
||||
@@ -21,17 +25,33 @@
|
||||
- **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 an editor tool for drawing and assembling stick figures. It is a
|
||||
`Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes
|
||||
are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and
|
||||
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale.
|
||||
|
||||
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
|
||||
@@ -44,7 +64,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
{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
|
||||
@@ -124,7 +144,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
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;
|
||||
@@ -134,6 +154,240 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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`
|
||||
@@ -149,7 +403,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
-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.
|
||||
@@ -220,7 +474,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(`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
|
||||
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
|
||||
@@ -230,8 +484,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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.
|
||||
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
|
||||
@@ -291,9 +545,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
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
|
||||
@@ -304,89 +558,83 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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`
|
||||
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.
|
||||
`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`)
|
||||
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).
|
||||
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,
|
||||
@@ -416,57 +664,70 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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)`
|
||||
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()`,
|
||||
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.
|
||||
- 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.
|
||||
`_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:
|
||||
@@ -571,7 +832,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`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);
|
||||
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
|
||||
@@ -600,7 +861,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(`_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`,
|
||||
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
|
||||
@@ -634,7 +895,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(`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
|
||||
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;
|
||||
@@ -648,19 +909,24 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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 +
|
||||
-> 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
|
||||
:= 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
|
||||
:= 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.
|
||||
@@ -676,7 +942,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`(-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
|
||||
( `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
|
||||
@@ -688,7 +954,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, 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,
|
||||
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 /
|
||||
@@ -724,7 +990,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`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. **Placement ghost:** while a palette
|
||||
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
|
||||
@@ -768,7 +1037,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`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`,
|
||||
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
|
||||
@@ -819,7 +1088,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
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
|
||||
@@ -914,7 +1183,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
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`
|
||||
@@ -940,7 +1209,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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
|
||||
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;
|
||||
@@ -956,13 +1225,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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()`
|
||||
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`
|
||||
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**
|
||||
@@ -996,14 +1265,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`_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 }`.
|
||||
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.
|
||||
- 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`,
|
||||
@@ -1046,13 +1318,21 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(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] }`.
|
||||
"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.
|
||||
@@ -1060,7 +1340,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`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}`.
|
||||
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
|
||||
@@ -1072,7 +1352,31 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
- 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`.
|
||||
@@ -1083,12 +1387,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
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.
|
||||
|
||||
@@ -667,65 +667,18 @@ While a click-awaiting director step is active — a pending **Walk To** target,
|
||||
|
||||
#### 21.3 `res://sandbox_theme.json` (styling defaults)
|
||||
|
||||
A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema:
|
||||
The Sandbox theme is schema `2.0` and now covers the code-built chrome,
|
||||
palette, selectors, dialogs, overlays, reusable widget data, fonts, colors,
|
||||
and grid defaults. See the complete current key reference, examples, legacy
|
||||
compatibility notes, and troubleshooting guide in
|
||||
[docs/SANDBOX_THEME.md](docs/SANDBOX_THEME.md).
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"ui_font_bold": "",
|
||||
"ui_font_italic": "",
|
||||
"action_popup_font_size": 24,
|
||||
"action_popup_emoji_size": 22,
|
||||
"assignment_badge_font_size": 20,
|
||||
"assignment_badge_radius": 9,
|
||||
"rule_label_font_size": 16,
|
||||
"status_pill_font_size": 16,
|
||||
"tooltip_font_size": 18,
|
||||
"queue_panel_font_size": 18,
|
||||
"rule_panel_font_size": 18,
|
||||
"action_editor_font_size": 18,
|
||||
"rule_editor_font_size": 18,
|
||||
"panel_row_font_size": 16,
|
||||
"panel_title_font_size": 18,
|
||||
"panel_title_bold": true,
|
||||
"rule_label_bold": false,
|
||||
"badge_bold": true,
|
||||
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||||
},
|
||||
"grid": {
|
||||
"snap_size": 15.0
|
||||
},
|
||||
"mode_colors": {
|
||||
"edit_accent": "#22c6ff",
|
||||
"direct_accent": "#ffb300",
|
||||
"play_accent": "#33dd77",
|
||||
"guide_line": "#22c6ff"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | Consumed by |
|
||||
|---|---|---|
|
||||
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
|
||||
| `fonts.ui_font_bold` / `fonts.ui_font_italic` | `""` (fallback to `ui_font`) | **Phase 3c** style-variant font paths for bold/italic; empty → `ui_font`. Bold/italic are realised via a dedicated `FontVariation` (e.g. `variation_embolden`, OpenType slant) when no separate file is configured. |
|
||||
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. **Phase 3c:** `action_popup_emoji_size` is now **consumed** — applied as the popup menu's emoji-glyph font size when a popup font is configured (`_apply_popup_theme()`). |
|
||||
| `fonts.action_popup` (optional object) | `{ "size": 24, "bold": false, "italic": false }` | **Phase 3c** per-widget object form for the popups; when present, overrides the flat `action_popup_font_size` / bold keys for that widget. |
|
||||
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
|
||||
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. **Phase 3c** `fonts.rule_label_bold` (default `false`) adds the bold flag; `fonts.badge_bold` (default `true`) bolds the action/trigger badges. |
|
||||
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
|
||||
| `fonts.queue_panel_font_size` / `rule_panel_font_size` | 18 / 18 | **Phase 3c** font sizes for the Queue / Rule panel bodies; fall back to `action_popup_font_size`. |
|
||||
| `fonts.action_editor_font_size` / `rule_editor_font_size` | 18 / 18 | **Phase 3c** font sizes for the Action / Rule editors; fall back to `action_popup_font_size`. |
|
||||
| `fonts.panel_row_font_size` / `panel_title_font_size` | 16 / 18 | **Phase 3c** per-row summary/number label and panel title label sizes; `fonts.panel_title_bold` (default `true`) bolds panel titles. |
|
||||
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
|
||||
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
|
||||
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
|
||||
|
||||
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
|
||||
|
||||
**Phase 3c theming (font styles/sizes):** the Phase 3c widgets now accept an `apply_font(ui_font, emoji_font, sizes)` call — `QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` (see §23) each expose it (mirroring `AssetSelector.apply_font`), and `SandboxStage._build_ui()` invokes it **after** `add_child(...)`. Per-widget sizes come from the `*_panel_font_size` / `*_editor_font_size` / `panel_row_font_size` / `panel_title_font_size` keys; bold/italic style flags (`panel_title_bold`, `rule_label_bold`, `badge_bold`) and a widget's optional `{size, bold, italic}` object form are applied via the `ui_font_bold` / `ui_font_italic` `FontVariation`-derived font. The `PopupMenu`s (rig/waypoint context) keep `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the `action_popup` object form. Authoritative schema: `docs/phase_3c_editor_spec.md` §14.
|
||||
The short version: edit `res://sandbox_theme.json`, restart the Sandbox scene
|
||||
to reload it, and remember that the live grid size/snap/grid-visibility remain
|
||||
in `user://sandbox_settings.json`. Missing or malformed theme data falls back
|
||||
to built-in defaults; missing fonts fall back to `ThemeDB.fallback_font` with a
|
||||
warning. Speech font/size/emoji lists are reusable-widget data reserved for a
|
||||
future speech-customization UI and do not yet alter saved speech actions.
|
||||
|
||||
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
||||
|
||||
@@ -930,6 +883,86 @@ Each entry in the `shapes` array:
|
||||
|
||||
> On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions.
|
||||
|
||||
## File format (`.prop`)
|
||||
|
||||
The **Prop Editor** (Phase 9, extended by Phase 10a) authors vector prop assets saved as `.prop` files, stored under `res://props/*.prop`. Files are UTF-8 JSON, pretty-printed with tab indentation, versioned, and designed to remain **backward compatible** — the format stores the editor's full multi-shape stack, while a deterministic converter (`PropFile.flatten_to_payload()`) collapses it into the single-shape payload `PropUtils.spawn_prop()` consumes at runtime (frontmost closed polygon → else circle → else the first shape; lines are editor-only and never become collision geometry).
|
||||
|
||||
In the Prop Editor, Arrange exposes **Transform Whole Prop**, **Normalize Size**,
|
||||
**Center Prop**, and **Show Stickman Guide** in that order. Whole-prop move,
|
||||
origin-based rotation, and uniform resize preview from a snapshot and commit once;
|
||||
Snap rounds movement, Ctrl snaps rotation to 15°, and Esc cancels. The Sandbox
|
||||
Stage has a separate single-prop corner scale gesture whose visual and collision
|
||||
bounds remain synchronized during PLAY.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "My Crate",
|
||||
"material_preset": "wood",
|
||||
"shapes": [
|
||||
{
|
||||
"shape_type": "polygon",
|
||||
"points": [
|
||||
{ "x": -24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": 24.0 },
|
||||
{ "x": -24.0, "y": 24.0 }
|
||||
],
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [0, 0, 0, 0],
|
||||
"reshaped": false
|
||||
},
|
||||
{
|
||||
"shape_type": "circle",
|
||||
"radius": 24.0,
|
||||
"fill_color": "#E63333",
|
||||
"outline_color": "#590D0D",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [],
|
||||
"reshaped": false
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": "2026-09-18T10:00:00",
|
||||
"modified_at": "2026-09-18T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Root object
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | `string` | Format version. Currently `"1.0"`. A non-`"1.0"` version is rejected on load. |
|
||||
| `prop_name` | `string` | Optional display name; an empty name falls back to the filename basename. |
|
||||
| `material_preset` | `string` | `"none"` \| `"wood"` \| `"rubber"` \| `"cardboard"` \| `"metal"`. Missing/invalid → `"none"`. Sets `mass` + `physics_material_override` (from `PropBlock`'s static preset tables). |
|
||||
| `shapes` | `array` | Array of shape objects (see below). Draw/Z-order = array order (first = back, last = front). |
|
||||
| `metadata.created_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
|
||||
| `metadata.modified_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
|
||||
|
||||
### Shape object
|
||||
|
||||
Each entry in the `shapes` array:
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `shape_type` | `string` | `"polygon"`, `"circle"`, or `"line"`. Missing → `"polygon"`. |
|
||||
| `points` | `array` | Array of `{ "x": float, "y": float }` vertices — polygon/line only. Missing → `[]`. |
|
||||
| `radius` | `float` | Circle only; default `24.0`. |
|
||||
| `fill_color` / `outline_color` | `string` | Hex colour (`#RRGGBB` / `#RRGGBBAA`). Missing → the material tint (from `PropBlock.tint_for`). |
|
||||
| `outline_width` | `float` | Vector outline thickness. Default `2.0` (`PropBlock.DEFAULT_OUTLINE_WIDTH`). |
|
||||
| `closed` | `bool` | Polygon/circle `true` (filled); line `false` (outline-only). |
|
||||
| `vertex_flags` | `array[int]` | Same length as `points`. `0` = original vertex, `1` = user-created via "Create Point" (circles: empty). |
|
||||
| `reshaped` | `bool` | **Round 6.** `false` by default. Set `true` when the shape's vertices were edited (insert/remove/drag) or a circle was scaled non-uniformly; cleared by `set_shape_radius()`. The inspector hides primitive-specific fields (today: the circle Radius row) when set. Additive/backward-compatible — old files load as `false`. |
|
||||
|
||||
> **Backward compatibility rule:** unknown/missing keys fall back to defaults, and only **additive** keys may be introduced — never change the meaning of an existing key. The `reshaped` flag is the Round 6 addition; a `material_preset` of `"none"` and a missing `shape_type` both fall back gracefully.
|
||||
|
||||
> **Save-serialization note (Round 5):** point coordinates must be written through a Variant-coercion helper (`_coerce_points`). Assigning an `Array` of `{x,y}` dictionaries directly to a typed `PackedVector2Array` silently zeroes every coordinate in Godot — files saved by the Round 1–4 build were unrecoverable.
|
||||
|
||||
## Settings file (`user://settings.json`)
|
||||
|
||||
Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility:
|
||||
@@ -958,6 +991,8 @@ Behavior:
|
||||
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
|
||||
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
|
||||
|
||||
> **Prop Editor settings (Phase 9, separate files):** the standalone Prop Editor uses its own user-data files — `user://prop_editor_settings.json` (schema `{ "version": 1, "recent_props": [...] }`, the last up-to-5 opened/saved `.prop` paths, most-recent-first) and `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, and dropped when stale at startup). The Prop Editor does **not** read or write `user://settings.json`.
|
||||
|
||||
## Project structure
|
||||
|
||||
| Path | Purpose |
|
||||
@@ -976,8 +1011,13 @@ Behavior:
|
||||
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
|
||||
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
|
||||
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. |
|
||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. Also the single source of truth for the Prop Editor's inspector physics readouts. |
|
||||
| `res://scripts/prop_editor.gd` | **Prop Editor (Phase 9).** `class_name PropEditor`, `extends Control` — standalone vector-prop authoring controller (run via **F6** on `res://scenes/prop_editor.tscn`; **not** the main scene). Owns the 📁 File (🆕 New / 💾 Save / 📄 Save As… / 📂 Load… / 🕑 Recent) and 👁️ View (🔍 Reset View) menus, the top-right Grid spinbox + Snap checkbox, the centered Arrange/Reshape mode toolbar, the material-preset + per-shape inspector (physics readouts from `PropBlock`; hides the circle Radius row + shows a "reshaped" note when a shape's `reshaped` flag is set), the status bar, the window title builder, the exit dialog (💾 Save / 🗑️ Discard / ❌ Cancel), recent-props persistence (`user://prop_editor_settings.json`, max 5), and the debounced autosave/recovery pipeline (`user://prop_editor_autosave.prop` via `PropFile.save_safe`). Saves export a cached 200×200 thumbnail. |
|
||||
| `res://scripts/prop_canvas.gd` | **Prop Editor (Phase 9).** `class_name PropCanvas`, `extends Control` — multi-shape vector canvas: cursor-centered zoom/pan (wheel, pinch, middle-mouse), `EditMode { TRANSFORM, POINTS }` ("Arrange"/"Reshape") gating input + drawing only, rotation dot + corner scale crosses (Ctrl = 15° snap / aspect lock), color-matched vertex handles with Create/Remove Point (0.6 s flash), per-mode right-click menus, a local clipboard, mirror + z-order, the per-shape `reshaped` flag, sanitize-on-commit via `TerrainUtils`, and two-tone halo selection. |
|
||||
| `res://scripts/prop_file.gd` | **Prop Editor (Phase 9).** `class_name PropFile`, `extends RefCounted` — static `.prop` v1.0 schema + converters: `to_file_dict`/`from_file_dict` (round-trip, version rejection, missing-key defaults), `preset_to_string`/`from_string`, `sanitize_shape`, `flatten_to_payload` (frontmost closed polygon → circle → first shape), `save`/`load`, and the atomic `save_safe` (tmp/`.bak` swap). Points must be coerced via `_coerce_points` when serializing (Round 5). |
|
||||
| `res://scripts/prop_theme.gd` | **Prop Editor (Phase 9).** `class_name PropTheme`, `extends RefCounted` — validated loader for `res://prop_theme.json` (deep-merge, warn-once, clamped accessors); hierarchy theme file → `DEFAULT_CONFIG` → `PropBlock` constants. Physics is never themeable. Holds the Round 2–4 `ui` tokens (fonts, hit targets, colors). Applied through a **local** Theme only. |
|
||||
| `res://scenes/prop_editor.tscn` | **Prop Editor (Phase 9).** Standalone scene backing `scripts/prop_editor.gd` (run via **F6**; not the main scene): minimal shell (`%MenuBar` in `%TopRow` beside `%TopRightControls`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`, `%InspectorColumn`/`%InspectorList`, status bar, dialogs); UI built in code. |
|
||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). `PropFile.flatten_to_payload()` produces the payload it consumes. |
|
||||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder / Phase 4.5.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). **Phase 4.5** routes `PropBlock` physics contacts through proxy metadata, tracks per-part body IDs for pair debouncing, preserves rig→prop rule ordering, purges stale pairs on separation/deletion/ragdoll, and restores authored rig state when returning PLAY→DIRECT. Standalone staging scene run via **F6**; not wired into the editor. |
|
||||
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
||||
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
||||
@@ -1104,3 +1144,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
||||
|
||||
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery first calls `_capture_pre_ragdoll_state()` (6 IK markers, 8 limb + head bone rotations, facing profile, 4 bend flags, head LookAt state, per-leg `leg_sides`, and entry chain offsets `torso_chain_offset`/`head_chain_offset`) as the END target, then `_capture_ragdoll_pose()` records the 10 bodies' rig-local pose **and** probes the terrain via `_probe_ground_y()` (a 7-ray fan with a tolerance band, excluding the ragdoll RIDs), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`, with a mirror-aware local shift `local_shift = to_local(shift) − to_local(Vector2.ZERO)`), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves via `_snap_skeleton_to_pose()` — the 6 IK targets plus direct arm/head bone rotations (`rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`, head via `head_chain_offset`), all normalized into `(−π, π]`, with the ankles clamped onto the terrain and the two leg IK mods enabled with the captured knee side — and finally `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the markers → their pre-ragdoll values and the arm/head bones to the snap-computed `_recovery_bones` targets (elbows via `_natural_joint_target`), legs IK-driven. `_on_stand_up_finished()` restores the facing profile + bend flags + LookAt state, re-enables the arm mods, and returns to `ANIMATED`. The per-joint bend decisions live in three editable tables — `RAGDOLL_JOINT_BAND_LIMITS`, `RECOVERY_LEG_SIDE_FORCE`, `RECOVERY_JOINT_PATH` (Joint bend configuration (EDIT ME)); diagnostics are gated behind `DEBUG_RECOVERY := false`.
|
||||
Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat). `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|
||||
|
||||
> **Phase 9 Prop Editor (Rounds 1–7):** a standalone, kid-friendly vector-prop authoring tool — the authoring counterpart to the stickman editor — run via **F6** on `res://scenes/prop_editor.tscn` (not the main scene). It adds a new `.prop` v1.0 JSON format (`res://props/*.prop`, multi-shape stack + deterministic `PropFile.flatten_to_payload()` conversion), a dedicated `PropCanvas` multi-shape canvas, a validated `PropTheme` (`res://prop_theme.json`) with a Round 2–4 `ui` token section, and a save-side 200×200 thumbnail pipeline reusing `PropThumbnail` + a mtime-keyed `ThumbnailCache.prop_file_png`. Round highlights: **R2** — two explicit canvas modes ("Arrange"/"Reshape"), the stickman-style bottom rotation dot + corner scale crosses, color-matched 3 px vertex handles, and cursor-projection "Create Point" with a 0.6 s flash; **R3** — POINTS-mode right-click is point-editing only (segment proximity via `hit_test_segment`), centered mode toolbar with emoji, window title `Untitled — file.prop *`, exit dialog (💾/🗑️/❌), and a debounced autosave/recovery prompt; **R4** — selected-mode is clearly outlined (`FOCUS_NONE` + accent-border `pressed` stylebox), bigger theme-driven defaults, the Grid spinbox + Snap checkbox moved to the menu row (Edit menu removed), File → New with a save prompt, and **Ball removed** from the add menus; **R5** — File → 🕑 Recent (last 5, persisted) and a critical serialization fix (typed `PackedVector2Array` assignments from `{x,y}` dicts silently zeroed every coordinate — `_serialize_shape` now routes through `_coerce_points`); **R6** — Recent updates on save, a per-shape **`reshaped`** flag gates the circle Radius row, and fill-adaptive selection contrast; **R7** — two-tone halo selection (dark backing + bright core) so outlines/boxes/dots/crosses/handles read on any background. Several Prop Editor rows are logged in `docs/tech_debt_and_optimizations.md` (#29–#35).
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Sandbox theme reference
|
||||
|
||||
`res://sandbox_theme.json` is the hand-editable presentation configuration for
|
||||
the Sandbox Stage (`res://scenes/sandbox_stage.tscn`). It is loaded by
|
||||
`SandboxStage` during `_ready()`, before the code-built UI is created. The
|
||||
loader is `res://scripts/sandbox_theme.gd` (`class_name SandboxTheme`).
|
||||
|
||||
The committed file uses schema `"2.0"`. The theme controls presentation
|
||||
defaults only; it does not save a scene, placement, or action data.
|
||||
|
||||
## Load, merge, and validation rules
|
||||
|
||||
The load path is:
|
||||
|
||||
```text
|
||||
res://sandbox_theme.json
|
||||
│
|
||||
▼
|
||||
SandboxTheme.DEFAULT_CONFIG
|
||||
│ deep-merge file values
|
||||
▼
|
||||
SandboxStage._build_ui() and visual overlays
|
||||
```
|
||||
|
||||
- Missing, unreadable, malformed, or non-object JSON uses the built-in default
|
||||
configuration and emits one warning. The stage continues to run.
|
||||
- Missing nested keys inherit the built-in value. Unknown keys are retained in
|
||||
the raw dictionary but have no effect until a consumer is added.
|
||||
- `stylebox(component)` resolves styles in this order:
|
||||
`global` → `<section>.style_defaults` → the component object. For example,
|
||||
`palette` is resolved from `global`, then `palette.style_defaults`, then
|
||||
`palette`.
|
||||
- Colors must be strings in `#RRGGBB` or `#RRGGBBAA` form. Invalid colors use
|
||||
the requested fallback and emit a warning.
|
||||
- Numeric accessors accept finite integers/floats and clamp them to the range
|
||||
requested by the consumer. Invalid or non-finite numbers use that consumer's
|
||||
fallback. Size arrays are accepted when they contain two numeric values;
|
||||
individual controls apply their own minimum sizes.
|
||||
- Font paths are `res://` resource paths. A missing resource, or a resource
|
||||
that is not a `Font`, uses `ThemeDB.fallback_font` and emits a warning.
|
||||
- The theme is loaded once per Sandbox Stage instance. Restart the scene (F6)
|
||||
to see edits; there is no live file watcher or reload button.
|
||||
|
||||
The live grid size, snap toggle, and grid visibility are stored in
|
||||
`user://sandbox_settings.json`. `grid.snap_size` supplies the initial grid-size
|
||||
default; it does not overwrite a value already saved in user settings.
|
||||
|
||||
## Schema overview
|
||||
|
||||
Every current top-level block is listed below. The values shown are the
|
||||
defaults in the committed `sandbox_theme.json` (not necessarily the fallback
|
||||
used by an older caller).
|
||||
|
||||
### Current consumer status
|
||||
|
||||
The schema intentionally includes forward-looking values for the reusable
|
||||
widget and speech work. The current Sandbox runtime applies these groups:
|
||||
|
||||
- `global` style values used by resolved styleboxes and the selector control
|
||||
theme (background/border/radius/padding); `global.text_color` and
|
||||
`global.muted_text_color` are available presentation values but are not
|
||||
applied to every existing label automatically.
|
||||
- `fonts.ui_font`, `emoji_font`, bold/italic variants, the nested `sizes`
|
||||
entries for action popups, tooltips, status pills, queue/rule/action editors,
|
||||
panel rows/titles, `file_menu`, `file_menu_popup`, and `mode_switcher`, plus
|
||||
the related `flags`.
|
||||
- `mode_colors.edit`, `direct`, `play`, and `guide`; `grid.snap_size` as the
|
||||
first-run grid default; dialog sizes/fonts; `chrome.mode_switcher` width;
|
||||
palette width/card size/drag threshold/style; selector cell and thumbnail
|
||||
sizes; and all `overlays` values.
|
||||
|
||||
These declared values currently remain reserved or require a legacy adapter:
|
||||
|
||||
- `chrome.top_bar.height`, `status_bar.height`, `mode_frame`, `mode_badge`,
|
||||
and `tooltip` describe the intended chrome contract, but the corresponding
|
||||
layout/style constants are still authored in `SandboxStage`.
|
||||
- `popups.action_menu.font_size` and `popups.asset_selector.backdrop_alpha`
|
||||
are reserved component overrides. The active action-menu size comes from
|
||||
`fonts.sizes.action_popup`; selector `cell_size` and `thumbnail_size` are
|
||||
active.
|
||||
- `palette.icon_size` and `palette.selected_border_width` are reserved for
|
||||
the palette card renderer; the current card has its own glyph/border values.
|
||||
- `fonts.sizes.assignment_badge`, `rule_label`, `asset_label`, and
|
||||
`asset_badge` are part of the resolved schema, but the current direct call
|
||||
into `StageDirectorVisuals` still reads the legacy flat badge/label names.
|
||||
Use the legacy names documented below when changing those director visuals.
|
||||
- `fonts.presets` and every `widgets` list are exposed through reusable
|
||||
controls, but the current Sandbox Speak action does not instantiate those
|
||||
controls yet. They are future speech-customization data.
|
||||
|
||||
### `version`
|
||||
|
||||
| Key | Format | Current value | Notes |
|
||||
|---|---|---:|---|
|
||||
| `version` | string | `"2.0"` | Use `"2.0"` when using the nested schema below. |
|
||||
|
||||
### `global`
|
||||
|
||||
Global style values are inherited by `SandboxTheme.stylebox()` and the default
|
||||
control theme.
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `font_preset` | string | `"ui"` | Named base-font selection for future/general UI use. |
|
||||
| `font_size` | integer | `18` | Default `Control` font size. |
|
||||
| `text_color`, `muted_text_color` | color string | `#F5F7FA`, `#AAB2C0` | Primary and secondary text colors. |
|
||||
| `bg_color`, `surface_color` | color string | `#171B24F2`, `#252B38FA` | Global and raised-surface backgrounds. |
|
||||
| `border_color` | color string | `#FFFFFF33` | Default border color. |
|
||||
| `border_width` | integer | `1` | Default border width. |
|
||||
| `corner_radius` | integer | `10` | Default corner radius. |
|
||||
| `padding`, `spacing` | integer | `8`, `8` | Default style padding and layout spacing. |
|
||||
|
||||
### `fonts`
|
||||
|
||||
Font paths may be empty (`""`) to use the engine fallback. `presets` is an
|
||||
object of arbitrary preset IDs; each entry has a display `label` and a `path`.
|
||||
The committed file provides `ui`, `comic`, `pixel`, and `scary` entries.
|
||||
|
||||
| Key | Format | Default | Consumer |
|
||||
|---|---|---:|---|
|
||||
| `ui_font`, `emoji_font` | `res://` path or empty | `""` | Base UI and emoji-capable stage/director controls. |
|
||||
| `ui_font_bold`, `ui_font_italic` | `res://` path or empty | `""` | Optional style variants; bold can be generated from the base font when no dedicated path is supplied. |
|
||||
| `presets` | object `{id:{label,path}}` | see JSON | `font_presets()` and future font selectors. |
|
||||
| `sizes.action_popup` | integer | `24` | Director/action popup text. |
|
||||
| `sizes.action_popup_emoji` | integer | `22` | Effective popup size when a popup font is active (Godot popup menus have one font size). |
|
||||
| `sizes.assignment_badge` | integer | `20` | Reserved resolved-theme value; current director visuals use legacy `fonts.assignment_badge_font_size`. |
|
||||
| `sizes.rule_label` | integer | `18` | Reserved resolved-theme value; current director visuals use legacy `fonts.rule_label_font_size`. |
|
||||
| `sizes.status_pill` | integer | `18` | Mode badge/pill. |
|
||||
| `sizes.tooltip` | integer | `18` | Cursor-attached targeting tooltip. |
|
||||
| `sizes.file_menu` | integer | `22` | `File` button in the top bar. |
|
||||
| `sizes.file_menu_popup` | integer | `26` | File menu and Open Recent popup rows. |
|
||||
| `sizes.mode_switcher` | integer | `24` | Edit/Direct/Play buttons. |
|
||||
| `sizes.queue_panel`, `sizes.rule_panel` | integer | `22`, `22` | Queue and rule editor panels. |
|
||||
| `sizes.action_editor`, `sizes.rule_editor` | integer | `22`, `22` | Action and rule editor controls. |
|
||||
| `sizes.panel_row`, `sizes.panel_title` | integer | `20`, `24` | Editor row text and panel titles. |
|
||||
| `sizes.asset_label`, `sizes.asset_badge` | integer | `18`, `14` | Reserved asset-card text sizing. |
|
||||
| `flags.panel_title_bold` | boolean | `true` | Bold queue/rule panel titles. |
|
||||
| `flags.rule_label_bold` | boolean | `false` | Bold director rule labels. |
|
||||
| `flags.badge_bold` | boolean | `true` | Bold director badges. |
|
||||
| `flags.action_popup_bold`, `flags.action_popup_italic` | boolean | `false`, `false` | Popup style flags. |
|
||||
|
||||
### `mode_colors`
|
||||
|
||||
Each value is a color string. These accents are used by the mode badge/frame,
|
||||
active mode controls, targeting feedback, and related stage chrome.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `edit` | `#22C6FF` | Edit-mode accent. |
|
||||
| `direct` | `#FFB300` | Direct-mode accent. |
|
||||
| `play` | `#33DD77` | Play-mode accent. |
|
||||
| `guide` | `#22C6FF` | Terrain painting guide line. |
|
||||
|
||||
### `grid`
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `snap_size` | finite number | `15.0` | Initial grid size, clamped to the stage's supported range. |
|
||||
|
||||
### `chrome`
|
||||
|
||||
`style_defaults` is the optional common style object for chrome components. Its
|
||||
style keys follow the `global` style formats (`bg_color`, `border_color`,
|
||||
`border_width`, `corner_radius`, `padding`).
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `style_defaults` | style object | `{ "bg_color":"#171B24F2", "corner_radius":10 }` | Shared chrome style base. |
|
||||
| `top_bar.height` | number | `64` | Top navigation bar height. |
|
||||
| `status_bar.height` | number | `38` | Bottom status bar height. |
|
||||
| `mode_switcher.segment_min_width` | number | `124` | Minimum width of each Edit/Direct/Play segment. |
|
||||
| `mode_frame.border_width` | integer | `3` | Colored stage frame width. |
|
||||
| `mode_badge.corner_radius` | integer | `12` | Mode badge corner radius. |
|
||||
| `mode_badge.padding` | number | `10` | Mode badge padding. |
|
||||
| `tooltip.bg_color` | color | `#101010E6` | Targeting tooltip background. |
|
||||
| `tooltip.border_width` | integer | `1` | Targeting tooltip border. |
|
||||
| `dialogs.queue_size` | `[width,height]` | `[680,520]` | Queue editor window minimum size. |
|
||||
| `dialogs.rule_size` | `[width,height]` | `[720,560]` | Rule editor window minimum size. |
|
||||
| `dialogs.action_size` | `[width,height]` | `[560,390]` | Action editor window minimum size. |
|
||||
| `dialogs.confirm_size` | `[width,height]` | `[620,300]` | Rename, unsaved, missing-assets, and other confirmation/error windows. |
|
||||
| `dialogs.font_size` | integer | `22` | Dialog controls/buttons. |
|
||||
| `dialogs.message_font_size` | integer | `26` | Centered AcceptDialog message text. |
|
||||
|
||||
### `popups`
|
||||
|
||||
`style_defaults` accepts the normal style keys and is inherited by popup
|
||||
components.
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `style_defaults` | style object | bg `#202633FA`, border `#FFFFFF33`, radius `12`, padding `12` | Common popup surface. |
|
||||
| `action_menu.font_size` | integer | `24` | Reserved action-menu component override; use `fonts.sizes.action_popup` for the active menu. |
|
||||
| `dialog` | object | `{}` | Reserved per-dialog popup overrides. |
|
||||
| `asset_selector.backdrop_alpha` | finite number | `0.55` | Reserved selector backdrop opacity. |
|
||||
| `asset_selector.cell_size` | `[width,height]` | `[190,220]` | Prop/stickman selector cell size. |
|
||||
| `asset_selector.thumbnail_size` | `[width,height]` | `[156,156]` | Prop/stickman selector thumbnail size. |
|
||||
|
||||
### `palette`
|
||||
|
||||
`style_defaults` uses normal style keys and styles the stage placement palette.
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `style_defaults` | style object | bg `#252B38FA`, radius `10`, padding `6` | Palette surface/card base. |
|
||||
| `dock_width` | number | `116` | Palette width in Edit mode and stage-frame inset. |
|
||||
| `card_size` | `[width,height]` | `[96,94]` | Placement-card minimum size; the card keeps room for its label. |
|
||||
| `icon_size` | number | `30` | Reserved palette icon/glyph size. |
|
||||
| `drag_threshold` | number | `8` | Pointer distance before a card becomes a drag. |
|
||||
| `selected_border_width` | integer | `3` | Reserved selected palette card border. |
|
||||
|
||||
### `overlays`
|
||||
|
||||
`style_defaults` controls dashed overlay geometry in world pixels; colors are
|
||||
RGBA strings.
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---:|---|
|
||||
| `style_defaults.line_width` | number | `2` | Dashed trajectory/guide width. |
|
||||
| `style_defaults.dash_length` | number | `8` | Dash length. |
|
||||
| `style_defaults.dash_gap` | number | `5` | Gap length. |
|
||||
| `terrain_guide.color` | color | `#22C6FF` | Terrain paint guide. |
|
||||
| `valid.color` | color | `#4DE66680` | Valid director target/placement feedback. |
|
||||
| `same_type.color` | color | `#FFFFFF26` | Same-type placement feedback. |
|
||||
| `invalid.color` | color | `#FF4D4D80` | Invalid target/placement feedback. |
|
||||
|
||||
### `widgets`
|
||||
|
||||
These lists feed reusable controls (`EmojiQuickGrid`, `FontPresetPicker`, and
|
||||
`PresetSizeToggle`). They are data sources, not speech-action persistence.
|
||||
|
||||
| Key | Format | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `swatches` | array of color strings | white, black, cyan, amber, green, red | Color selector entries. |
|
||||
| `size_presets` | array of `{id,label,value}` objects | Small/Medium/HUGE (`20/28/40`) | Size selector entries; `value` is an integer-like size. |
|
||||
| `emoji` | array of strings | eight emoji entries | Emoji quick-grid entries. |
|
||||
|
||||
## Practical edits
|
||||
|
||||
Make the top navigation easier to read:
|
||||
|
||||
```json
|
||||
{
|
||||
"fonts": {
|
||||
"sizes": {
|
||||
"file_menu": 26,
|
||||
"file_menu_popup": 30,
|
||||
"mode_switcher": 28
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Change the mode frame and make the stage colors more distinct:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode_colors": {
|
||||
"edit": "#4DD9FF",
|
||||
"direct": "#FF8A3D",
|
||||
"play": "#65E68A",
|
||||
"guide": "#4DD9FF"
|
||||
},
|
||||
"chrome": { "mode_frame": { "border_width": 4 } }
|
||||
}
|
||||
```
|
||||
|
||||
Give selectors larger cards and thumbnails:
|
||||
|
||||
```json
|
||||
{
|
||||
"popups": {
|
||||
"asset_selector": {
|
||||
"cell_size": [220, 260],
|
||||
"thumbnail_size": [190, 190]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make kid-facing editors and confirmations larger:
|
||||
|
||||
```json
|
||||
{
|
||||
"chrome": {
|
||||
"dialogs": {
|
||||
"queue_size": [760, 600],
|
||||
"rule_size": [800, 640],
|
||||
"font_size": 26,
|
||||
"message_font_size": 30
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"sizes": {
|
||||
"queue_panel": 24,
|
||||
"rule_panel": 24,
|
||||
"action_editor": 24,
|
||||
"rule_editor": 24,
|
||||
"panel_row": 22,
|
||||
"panel_title": 28
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Legacy v1 files
|
||||
|
||||
Older Phase 4b/3c theme files used flat keys such as
|
||||
`fonts.action_popup_font_size`, `fonts.queue_panel_font_size`,
|
||||
`fonts.panel_title_font_size`, and mode keys such as
|
||||
`mode_colors.edit_accent` and `mode_colors.guide_line`. They remain accepted.
|
||||
The loader aliases the legacy mode names and selected flat font-size names into
|
||||
the nested structure, while `SandboxStage` retains the old precedence for a
|
||||
file that does not declare version `"2.0"`.
|
||||
|
||||
The complete legacy names still read by the current runtime are:
|
||||
|
||||
```text
|
||||
fonts.action_popup_font_size fonts.action_popup_emoji_size
|
||||
fonts.assignment_badge_font_size fonts.assignment_badge_radius
|
||||
fonts.rule_label_font_size fonts.status_pill_font_size
|
||||
fonts.tooltip_font_size fonts.queue_panel_font_size
|
||||
fonts.rule_panel_font_size fonts.action_editor_font_size
|
||||
fonts.rule_editor_font_size fonts.panel_row_font_size
|
||||
fonts.panel_title_font_size fonts.panel_title_bold
|
||||
fonts.rule_label_bold fonts.badge_bold
|
||||
fonts.action_popup = { size, bold, italic }
|
||||
|
||||
mode_colors.edit_accent mode_colors.direct_accent
|
||||
mode_colors.play_accent mode_colors.guide_line
|
||||
```
|
||||
|
||||
For a migration, copy the values into the nested keys in this document and set
|
||||
`"version": "2.0"`. Keep only one spelling of each setting so the result is
|
||||
easy to maintain.
|
||||
|
||||
## Fonts, emoji, and troubleshooting
|
||||
|
||||
- Use project-relative paths such as `res://fonts/kids_font.ttf`; do not use a
|
||||
machine-specific absolute path.
|
||||
- If text falls back to the Godot default, check the path, import status, and
|
||||
that the resource is actually a `Font`/font file. Restart the Sandbox after
|
||||
correcting it.
|
||||
- Emoji availability depends on the selected font and platform glyph support.
|
||||
A missing `emoji_font` is non-fatal; the normal UI fallback remains usable.
|
||||
- If a color or number is ignored, check JSON commas/quotes, use a supported
|
||||
color form, and keep numbers finite. The warning identifies the affected key.
|
||||
- If a selector or dialog looks cramped, increase its size array and its
|
||||
related font size together; restart F6 to reload the file.
|
||||
|
||||
## Speech customization status
|
||||
|
||||
`fonts.presets` (`ui`, `comic`, `pixel`, `scary`), `widgets.size_presets`,
|
||||
`widgets.swatches`, and `widgets.emoji` are the reusable data contract for a
|
||||
future speech-customization UI. They are intentionally available now so a
|
||||
future Speak dialog can consume the same theme.
|
||||
|
||||
The current Sandbox speech action still stores and renders text through the
|
||||
existing `StickmanSpeechBubble`; these theme lists do not yet change saved
|
||||
speech actions, bubble shape, bubble colors, or bubble font at runtime. Do not
|
||||
assume that changing a preset changes an existing speech action. Treat
|
||||
speech-specific fields under `popups.dialog` or future bubble sections as
|
||||
reserved until that feature is implemented.
|
||||
@@ -0,0 +1,639 @@
|
||||
# Prop Editor User Guide
|
||||
|
||||
The Prop Editor is a standalone tool for making vector props and saving them as
|
||||
`.prop` files. It is separate from the main Stickman Editor and Sandbox Stage.
|
||||
This guide describes the current controls and provides a complete manual test
|
||||
checklist.
|
||||
|
||||
## Start the editor
|
||||
|
||||
1. Open `res://scenes/prop_editor.tscn` in the Godot editor.
|
||||
2. Press **F6** to run the current scene.
|
||||
|
||||
The Prop Editor is not the project's configured main scene. If F6 runs a
|
||||
different scene, open `prop_editor.tscn` first.
|
||||
|
||||
## Layout
|
||||
|
||||
The window is divided into these areas:
|
||||
|
||||
| Area | Purpose |
|
||||
| --- | --- |
|
||||
| `📁 File` and `👁️ View` | File operations and canvas view reset |
|
||||
| `Grid` and `Snap` | Shared grid spacing and snapping settings |
|
||||
| `Prop Name` | The name stored in the `.prop` file |
|
||||
| Mode bar | Switch between `🖐️ Arrange`, `✏️ Reshape`, and `📌 Attach` |
|
||||
| Arrange controls | Whole-prop transform, normalization, centering, and the stickman guide |
|
||||
| Authoring canvas | Draw, select, transform, and reshape the prop |
|
||||
| Inspector | Material, shape, primitive, and anchor controls |
|
||||
| Holding Preview | A private stickman-and-prop preview shown in Attach mode |
|
||||
| Status bar | Current prop/preset/dirty state and cursor coordinates |
|
||||
|
||||
There are two adjustable splitters:
|
||||
|
||||
- Drag the main splitter between the canvas and Inspector to make the Inspector
|
||||
wider or narrower.
|
||||
- In Attach mode, drag the splitter between the authoring canvas and Holding
|
||||
Preview in either direction. The preview keeps a minimum width so its
|
||||
controls remain usable.
|
||||
|
||||
The Inspector starts at its minimum usable width so the canvas has room to
|
||||
work. Drag the canvas/Inspector splitter to widen it when labels or controls
|
||||
need more space. Its background is the theme color `#262C39`, intentionally
|
||||
darker than the canvas; the Inspector also scrolls vertically on smaller
|
||||
windows.
|
||||
|
||||
The Inspector scrolls vertically. On a small window, widen the Inspector or
|
||||
scroll it rather than expecting every section to fit at once.
|
||||
|
||||
## File and View menus
|
||||
|
||||
### `📁 File`
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `🆕 New` | Clears the current prop and starts a new document. If the current document is dirty, asks whether to save first. |
|
||||
| `💾 Save` | Saves to the current path. If the prop has never been saved, opens Save As. |
|
||||
| `📄 Save As...` | Opens the save dialog and changes the current path to the new file. |
|
||||
| `📂 Load...` | Opens a `.prop` file. A successful load replaces the current document. |
|
||||
| `🕑 Recent` | Shows up to five most-recently saved or loaded prop paths. Missing files are removed from this list. |
|
||||
|
||||
Save dialogs use the `.prop` filter. Save As normalizes a filename so it ends
|
||||
in `.prop` even if that extension was omitted. Keeping authored props under
|
||||
`res://props/` is recommended.
|
||||
|
||||
### `👁️ View`
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `🔍 Reset View` | Resets the authoring canvas zoom and pan to 1x, with the local origin centered in view. It does not reset anchor values or the Holding Preview camera. |
|
||||
|
||||
The Holding Preview has its own `Reset View` button; see [Holding Preview](#holding-preview).
|
||||
|
||||
### Top-right canvas controls
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Grid` | Sets the visible grid spacing and the snap increment, from 1 to 512. |
|
||||
| `Snap` | Enables or disables snapping for shape transforms and anchor movement. |
|
||||
|
||||
## Prop name and modes
|
||||
|
||||
### Prop Name
|
||||
|
||||
Type a name in the `Prop Name` field. The name is saved in the file and marks
|
||||
the document dirty as soon as it changes. An empty name is displayed as
|
||||
`Unnamed Prop` in the window title/status, and as `Untitled` in the generated
|
||||
window title when no name is present.
|
||||
|
||||
### `🖐️ Arrange`
|
||||
|
||||
Arrange is the mode for selecting, moving, rotating, scaling, mirroring, and
|
||||
ordering complete shapes. The Add Shape inspector and the shape Erase button
|
||||
are available in this mode.
|
||||
|
||||
#### Arrange controls
|
||||
|
||||
The Arrange mode top bar keeps the whole-prop actions in this order:
|
||||
|
||||
1. `✥ Transform Whole Prop`
|
||||
2. `📏 Normalize Size`
|
||||
3. `🎯 Center Prop`
|
||||
4. `Show Stickman Guide`
|
||||
|
||||
The scale textbox and Apply Scale button are not part of the editor. Use
|
||||
Normalize Size for the standard document size, or use Transform Whole Prop for
|
||||
direct canvas manipulation. Normalize Size fits the shape-point bounds to a
|
||||
96 px maximum dimension while preserving the fixed origin and proportional
|
||||
anchor offsets. Center Prop separately moves the shape bounds and editable
|
||||
anchors around the canonical origin. It is a deliberate one-time command: the
|
||||
editor does not recenter automatically after drawing, reshaping, or moving a
|
||||
shape, and empty or already centered props are unchanged.
|
||||
|
||||
With Transform Whole Prop enabled, drag visible shape geometry to move the
|
||||
whole prop, drag the dot below the union bounds to rotate it around the
|
||||
canonical origin, or drag a corner cross to resize it uniformly. Snap rounds
|
||||
the move delta to the current grid. Hold **Ctrl** while rotating to snap to
|
||||
15-degree increments. Grip and other editable anchor markers follow each
|
||||
operation. The tool previews from the starting document and commits once when
|
||||
the gesture is released; **Esc** cancels the active preview. The canonical
|
||||
origin itself does not move.
|
||||
|
||||
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach.
|
||||
Center Prop and Show Stickman Guide remain available in those modes. Turning
|
||||
Transform Whole Prop off restores the selected shape and normal per-shape
|
||||
Arrange gizmos.
|
||||
|
||||
When a shape is selected, the canvas draws:
|
||||
|
||||
- a two-tone selection outline and bounding box;
|
||||
- a small rotation dot below the bounding-box center;
|
||||
- four corner scale crosses.
|
||||
|
||||
Drag the shape body to translate it. Drag the rotation dot to rotate it. Hold
|
||||
**Ctrl** while rotating to snap to 15-degree increments. Drag a corner cross
|
||||
to scale. Hold **Ctrl** while scaling to keep the aspect ratio.
|
||||
|
||||
Clicking a shape selects the frontmost shape under the cursor. With the canvas
|
||||
selected (rather than a text field), **Delete** or **Backspace** also removes
|
||||
the selected shape.
|
||||
|
||||
### `✏️ Reshape`
|
||||
|
||||
Reshape is for editing individual vertices. It does not move the whole shape
|
||||
and does not show the Add Shape section, Erase Shape button, Normalize Size,
|
||||
or Transform Whole Prop. Center Prop and Show Stickman Guide remain available.
|
||||
When the canvas
|
||||
is empty, it does not show the Arrange-only “Right-click to add a shape” hint.
|
||||
|
||||
- Original vertices are filled circles.
|
||||
- User-created vertices are hollow rectangles.
|
||||
- Drag a vertex to reshape the outline.
|
||||
- A vertex drag, insertion, or removal marks the shape as reshaped.
|
||||
- Releasing a vertex drag sanitizes polygon/line points (including grid snap
|
||||
when enabled, collinear cleanup, and winding cleanup).
|
||||
- A reshaped circle hides its Radius control and shows `✏️ This shape was
|
||||
reshaped`. Choosing a new radius regenerates a clean circle and clears that
|
||||
reshaped state.
|
||||
|
||||
### `📌 Attach`
|
||||
|
||||
Attach is for authoring named anchors and checking a selected grip on the
|
||||
private stickman Holding Preview. It does not add gameplay attachment or
|
||||
physics behavior to Sandbox Stage.
|
||||
|
||||
Attach mode shows the Attach toolbar and the Anchors inspector. Shape editing
|
||||
controls are hidden, as are Normalize Size and Transform Whole Prop. Center Prop
|
||||
and Show Stickman Guide remain available, and the prop remains visible on the
|
||||
authoring canvas.
|
||||
|
||||
## Grid, Snap, and canvas navigation
|
||||
|
||||
The top-right `Grid:` SpinBox accepts values from 1 to 512. It controls the
|
||||
drawn grid spacing and the snap increment. The `Snap` checkbox enables or
|
||||
disables snapping for canvas transforms and anchor movement.
|
||||
|
||||
In Attach mode, the top `Snap` checkbox also controls anchor movement. There is
|
||||
no separate anchor snapping control. The grid is still available while
|
||||
snapping is off; it is only a visual guide in that state.
|
||||
|
||||
The authoring canvas supports these gestures in every mode:
|
||||
|
||||
| Gesture | Result |
|
||||
| --- | --- |
|
||||
| Mouse wheel up/down | Cursor-centered zoom in/out, clamped to 0.3x–3.0x |
|
||||
| Pinch/magnify gesture | Cursor-centered zoom |
|
||||
| Middle-mouse drag | Pan the canvas |
|
||||
| Two-finger pan gesture | Pan the canvas |
|
||||
| Left-click | Mode-dependent selection or editing |
|
||||
| Right-click | Mode-dependent context menu |
|
||||
|
||||
The bottom-right status text shows `X` and `Y` world coordinates while the
|
||||
cursor is over the authoring canvas. It shows dashes elsewhere.
|
||||
|
||||
## Authoring canvas context menus
|
||||
|
||||
### Arrange right-click menu
|
||||
|
||||
Right-clicking a shape selects the frontmost shape under the cursor and shows:
|
||||
|
||||
| Action | Result |
|
||||
| --- | --- |
|
||||
| `Copy` | Copies the selected shape into the editor clipboard. |
|
||||
| `Paste` | Appears when the clipboard contains a shape; inserts a copy offset from the cursor. |
|
||||
| `Erase` | Removes the selected shape. |
|
||||
| `Flip Left-Right` | Mirrors the shape around its own bounding-box center horizontally. |
|
||||
| `Flip Up-Down` | Mirrors the shape around its own bounding-box center vertically. |
|
||||
| `Bring Forward` | Moves the shape one step toward the front of the drawing stack. |
|
||||
| `Send Back` | Moves the shape one step toward the back of the drawing stack. |
|
||||
|
||||
Right-clicking empty canvas shows:
|
||||
|
||||
`Add Box`, `Add Plank`, `Add Triangle`, `Add Line`, and `Add Circle`.
|
||||
|
||||
`Paste` is also available there when the clipboard is populated. New shapes
|
||||
are placed at the clicked world position and become selected.
|
||||
|
||||
### Reshape right-click menu
|
||||
|
||||
Reshape intentionally has a smaller menu:
|
||||
|
||||
| Where you right-click | Result |
|
||||
| --- | --- |
|
||||
| A vertex | `Remove Point` |
|
||||
| Near an outline segment | `Create Point`; the new point is inserted at the cursor's projection onto that segment and flashes briefly in yellow |
|
||||
| Inside a closed shape | Selects the shape; no menu is shown |
|
||||
| Empty canvas | Nothing; shapes cannot be added from this mode |
|
||||
|
||||
Removing a point from a shape with fewer than two remaining vertices removes
|
||||
the shape. A two-point polygon becomes an open line.
|
||||
|
||||
### Attach right-click behavior
|
||||
|
||||
Right-click does not open a shape menu in Attach mode. Select and edit anchors
|
||||
with left-click, the Attach inspector, or the anchor presets.
|
||||
|
||||
## Material and shape Inspector
|
||||
|
||||
The Inspector is scrollable and changes with the active mode and selection.
|
||||
|
||||
### Material
|
||||
|
||||
The Material section is hidden in Attach mode to keep the anchor controls and
|
||||
Holding Preview readable. Return to Arrange or Reshape to edit the material.
|
||||
|
||||
The material preset dropdown contains `None`, `Wood`, `Rubber`, `Cardboard`,
|
||||
and `Metal`. The preset chooses the default colors used when a new primitive
|
||||
is added and displays read-only physics information:
|
||||
|
||||
- `Mass`
|
||||
- `Friction`
|
||||
- `Bounce`
|
||||
|
||||
These readouts come from the prop physics tables. They are informational in
|
||||
the Prop Editor; changing the dropdown does not turn the authoring canvas into
|
||||
a physics simulation. Save after changing the preset so the new material is
|
||||
stored in the `.prop` file. The current implementation updates the preset and
|
||||
readouts immediately; a preset-only change may not show `• Modified` until
|
||||
another dirty-tracked edit is made.
|
||||
|
||||
### Selected Shape
|
||||
|
||||
This section appears when a shape is selected in Arrange or Reshape mode.
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Fill` color picker | Changes the selected shape's fill color live. |
|
||||
| `Outline` color picker | Changes the selected shape's outline color live. |
|
||||
| `Width` SpinBox | Changes outline width. |
|
||||
| `Radius` SpinBox | Changes a clean circle's radius and regenerates its points. |
|
||||
| `Vertices` | Read-only count of the selected shape's points. |
|
||||
| `Erase Shape` | Deletes the selected shape. It is hidden in Reshape mode. |
|
||||
|
||||
The Radius row is only shown for a circle that has not been reshaped. Editing
|
||||
circle vertices or scaling it non-uniformly changes it into a freeform shape;
|
||||
the muted reshaped note explains why Radius is unavailable.
|
||||
|
||||
### Add Shape
|
||||
|
||||
The Add Shape section is available in Arrange mode. Choose a primitive from
|
||||
the dropdown, adjust its parameters, then press `Add a Shape`.
|
||||
|
||||
| Primitive | Parameters |
|
||||
| --- | --- |
|
||||
| `Box` | `Size X`, `Size Y` |
|
||||
| `Plank` | `Length`, `Thickness` |
|
||||
| `Triangle` | `Base`, `Height` |
|
||||
| `Line` | `Length` |
|
||||
| `Circle` | `Radius` |
|
||||
|
||||
The new primitive uses the current material's default fill/outline tint and
|
||||
outline width. `Ball` is not shown in the current menu; Circle is the supported
|
||||
round primitive in the user interface.
|
||||
|
||||
## Anchors and Attach mode
|
||||
|
||||
### Anchor concepts
|
||||
|
||||
An anchor is a named point in prop-local coordinates. Anchors are saved with
|
||||
the `.prop` file alongside the shape stack.
|
||||
|
||||
Every prop always has one fixed anchor named `origin`:
|
||||
|
||||
- role: `pivot`;
|
||||
- position: `(0, 0)`;
|
||||
- rotation: `0` degrees;
|
||||
- Z offset: `0`;
|
||||
- allowed hands: `both`.
|
||||
|
||||
Origin cannot be moved, rotated, renamed, or deleted, and it is not listed as
|
||||
an editable anchor. A subtle, non-interactive center marker is drawn at the
|
||||
fixed local origin in Arrange, Reshape, and Attach. It is a canvas reference,
|
||||
not an anchor gizmo that can be selected.
|
||||
|
||||
If an older `.prop` file has no `anchors` section, the loader synthesizes this
|
||||
origin automatically. Saving that file writes the anchor section back out.
|
||||
|
||||
### Attach toolbar
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Show Stickman Preview` | Shows or hides the private Holding Preview pane. |
|
||||
| `Show Other Markers` | Shows or hides the passive origin marker and unselected anchor markers. The selected anchor remains visible. |
|
||||
|
||||
The Holding Preview is enabled by default when Attach mode is entered.
|
||||
|
||||
### Anchors list and buttons
|
||||
|
||||
The list shows every editable custom anchor. `origin` is intentionally hidden
|
||||
from this list because it is fixed and cannot be edited.
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Add Anchor` | Creates a normalized ID such as `anchor`, places it at the current shape bounds center, and gives it role `custom` with both hands allowed. |
|
||||
| `Rename` | Opens a text-entry dialog. Type a new name, then click `Rename` or press **Enter** to confirm; click Cancel or close the dialog to leave the name unchanged. |
|
||||
| `Delete` | Deletes the selected editable anchor. |
|
||||
|
||||
Anchor IDs are lower-case, normalized to identifier-safe characters, limited
|
||||
to 32 characters, and made unique with a suffix such as `_2` when necessary.
|
||||
The name is committed only after the rename dialog is confirmed.
|
||||
|
||||
### Anchor fields
|
||||
|
||||
| Field | Values and behavior |
|
||||
| --- | --- |
|
||||
| `Role` | `grip`, `emitter`, `impact`, `mount`, or `custom`. `pivot` is reserved for the hidden fixed origin and is not offered for editable anchors. |
|
||||
| `Allowed Hands` | `both`, `left_only`, or `right_only`. This affects Holding Preview compatibility. |
|
||||
| `X`, `Y` | Anchor position in prop-local coordinates. |
|
||||
| `Rotation` | Anchor rotation in degrees, wrapped to the range -180 through 179. |
|
||||
| `Z Offset` | Draw-order offset from -5 through 5. |
|
||||
|
||||
The X/Y fields can also be edited by dragging the selected marker. For grip
|
||||
and emitter anchors, the selected marker has a small rotation dot underneath
|
||||
it. Drag that dot to rotate the anchor; hold **Ctrl** to snap rotation to
|
||||
15-degree increments. The origin has no rotation dot.
|
||||
|
||||
Anchor marker colors identify roles: cyan for grip, red for emitter, pink
|
||||
diamond for impact, purple for mount, and light gray for custom. The selected
|
||||
marker has an additional yellow selection ring. The fixed origin uses its own
|
||||
subtle center marker rather than an editable anchor marker.
|
||||
|
||||
### Anchor presets
|
||||
|
||||
Presets replace the current non-origin anchor layout and select the first
|
||||
editable grip:
|
||||
|
||||
| Preset | Anchors created |
|
||||
| --- | --- |
|
||||
| `One-Handed` | One `primary_grip` |
|
||||
| `Two-Handed` | `primary_grip` and `secondary_grip` |
|
||||
| `Tool / Nozzle` | `primary_grip` and `emitter` |
|
||||
| `Melee Edge` | `primary_grip` and `impact` |
|
||||
|
||||
Preset positions are derived from the current shape bounds. Applying a preset
|
||||
is an edit and must be saved like any other change.
|
||||
|
||||
## Holding Preview
|
||||
|
||||
Holding Preview appears in Attach mode when `Show Stickman Preview` is on. It
|
||||
uses a private visual-only rig and displays the complete authored multi-shape
|
||||
prop when the selected anchor is a grip compatible with the selected hand. The
|
||||
preview pane is borderless and visually separated from the Inspector by the
|
||||
layout splitters.
|
||||
|
||||
If the selected anchor is not a compatible grip, the prop is hidden and the
|
||||
hint explains that a compatible grip must be selected. Select a grip in the
|
||||
Anchors list to see the prop held.
|
||||
|
||||
### Preview header controls
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Left Hand` / `Right Hand` | Chooses which hand holds the selected grip. A left-only or right-only grip disables the incompatible choice and forces the compatible hand. |
|
||||
| `Front Pose` / `Walk Left` / `Walk Right` | Chooses the still front-facing pose or an in-place walking direction. Walking mirrors the preview as needed without changing saved anchor data. |
|
||||
|
||||
### Preview footer controls
|
||||
|
||||
| Control | What it does |
|
||||
| --- | --- |
|
||||
| `Pause Walk` / `Resume Walk` | Pauses or resumes the selected walking animation. The button is disabled for Front Pose. |
|
||||
| `Reset Arm` | Pauses walking and resets the selected arm's manual IK override. In Front Pose, it returns both preview hand targets to their rest positions. |
|
||||
| `Reset View` | Reframes the camera around the complete stickman and visible prop. |
|
||||
|
||||
### Pose and camera gestures
|
||||
|
||||
- Drag the cyan diamond target in the preview viewport to pose the selected
|
||||
arm. Dragging automatically pauses walking; use Resume Walk to return to the
|
||||
walk animation.
|
||||
- The target is clamped to the arm's reachable range, so the IK solver does
|
||||
not request an impossible wrist position.
|
||||
- The prop follows the solved wrist and selected grip anchor, including the
|
||||
anchor's rotation and Z offset.
|
||||
- Mouse wheel zooms the preview camera around the cursor.
|
||||
- Middle-mouse drag pans the preview camera.
|
||||
|
||||
The preview is for visual authoring only. It does not create a gameplay
|
||||
`PropBlock`, collision bodies, physics, Sandbox actions, or runtime attachment
|
||||
state. It currently previews one selected grip and one hand; secondary grips
|
||||
are saved, but they do not drive a second-hand IK solver.
|
||||
|
||||
## Saving, dirty state, and recovery
|
||||
|
||||
### Saving an off-origin prop
|
||||
|
||||
The editor does not silently recenter a prop. On an explicit Save or Save As,
|
||||
it warns when the combined shape-bounds center is farther from the origin than
|
||||
`max(1024 px, 2 × bounds diagonal)`. Choose one of:
|
||||
|
||||
| Choice | Result |
|
||||
| --- | --- |
|
||||
| `🎯 Center & Save` | Translates all shapes and non-origin anchors together, keeps the fixed origin at `(0, 0)`, focuses the canvas on it, then saves. |
|
||||
| `💾 Save Anyway` | Saves the current positions unchanged. |
|
||||
| `❌ Cancel` | Leaves the document and save flow unchanged. |
|
||||
|
||||
Autosaves never show this warning and never recenter the prop.
|
||||
|
||||
The editor marks the document dirty after changes to the name, shapes, or
|
||||
anchors. Save after changing the material preset as well; a preset-only change
|
||||
updates the current model but may not show `• Modified` until another
|
||||
dirty-tracked edit is made. The status bar shows:
|
||||
|
||||
`Prop Name — Material • Modified`
|
||||
|
||||
The window title also adds `*` while dirty and includes the current filename
|
||||
when one exists.
|
||||
|
||||
After about one second without further edits, a dirty document is written to
|
||||
`user://prop_editor_autosave.prop`. This is an internal recovery file, not the
|
||||
normal saved prop path.
|
||||
|
||||
- A successful Save, Load, New, or discard clears the autosave.
|
||||
- If the editor starts with a usable unsaved autosave, it asks whether to
|
||||
`🔄 Restore` or `🗑️ Discard` it.
|
||||
- Restoring keeps the document dirty so it still requires an intentional Save.
|
||||
- An autosave is silently discarded when its saved source file is already
|
||||
newer or equal.
|
||||
- The recent list is stored in
|
||||
`user://prop_editor_settings.json` and contains at most five paths,
|
||||
newest first.
|
||||
|
||||
Closing the window or choosing New while dirty opens a prompt with `💾 Save`,
|
||||
`🗑️ Discard`, and `❌ Cancel`. Cancel leaves the document open. Save As and
|
||||
Load dialogs report malformed or unsupported files through the Error dialog.
|
||||
|
||||
## Current limitations and future scope
|
||||
|
||||
- Attach mode and Holding Preview are Prop Editor authoring tools only.
|
||||
Sandbox Stage custom-prop loading, equipping, releasing, physics, collision,
|
||||
and gameplay effects are future work.
|
||||
- Preview uses one selected grip and one-hand IK. Two-hand IK, emitter effects,
|
||||
impact behavior, mount behavior, throwing, and runtime lifecycle are not
|
||||
implemented here.
|
||||
- The preview can show the full multi-shape authored prop. Existing runtime
|
||||
prop spawning still uses a deterministic single-shape conversion: the
|
||||
frontmost closed polygon, otherwise a circle, otherwise the first shape.
|
||||
Lines are editor-only and do not become collision geometry in that path.
|
||||
- There is no document-wide Undo/Redo history yet. Autosave and recovery are
|
||||
safety features, not an edit history.
|
||||
- The shape clipboard is an in-memory editor clipboard; it is cleared when a
|
||||
new document is started and is not saved into `.prop` files.
|
||||
|
||||
## Complete manual test checklist
|
||||
|
||||
### Launch and layout
|
||||
|
||||
- [ ] Open `res://scenes/prop_editor.tscn` and press F6.
|
||||
- [ ] Confirm the File/View menus, prop-name field, mode buttons, canvas,
|
||||
Inspector, and status bar are visible.
|
||||
- [ ] Drag the main canvas/Inspector splitter left and right.
|
||||
- [ ] Make the window narrow; confirm Inspector content can be scrolled and
|
||||
controls do not run off the window.
|
||||
- [ ] Confirm the Inspector starts at its minimum usable width and has a
|
||||
darker contrasting background than the canvas.
|
||||
- [ ] Confirm the top `Grid` field and `Snap` button have no clipped top or
|
||||
bottom edges.
|
||||
- [ ] Enter Attach mode and drag the canvas/preview splitter both directions.
|
||||
- [ ] Open `eye.prop` in Arrange mode, switch to Attach, and confirm the
|
||||
preview does not push into the Prop Name row or bottom status bar.
|
||||
- [ ] Toggle `Show Stickman Preview` and confirm the preview pane appears and
|
||||
disappears without affecting the prop document.
|
||||
|
||||
### New, naming, material, and status
|
||||
|
||||
- [ ] Choose File → New and confirm the canvas is empty except for the subtle
|
||||
fixed-origin marker visible in all three modes.
|
||||
- [ ] Type a prop name; confirm the status shows `• Modified` and the window
|
||||
title gets `*`.
|
||||
- [ ] Select each material preset and confirm the Mass, Friction, and Bounce
|
||||
readouts update.
|
||||
- [ ] Enter Attach mode and confirm the Material section is hidden.
|
||||
- [ ] Confirm the physics readouts are informational and do not launch a
|
||||
simulation.
|
||||
|
||||
### Add and style shapes
|
||||
|
||||
- [ ] In Arrange mode, add a Box, Plank, Triangle, Line, and Circle from the
|
||||
Add Shape section.
|
||||
- [ ] Change the primitive parameters before adding each shape.
|
||||
- [ ] Right-click empty canvas and add each of the same five primitives.
|
||||
- [ ] Select a shape and change Fill, Outline, and Width; confirm the canvas
|
||||
updates immediately.
|
||||
- [ ] Confirm Vertices reports the selected shape's point count.
|
||||
- [ ] Click Erase Shape and confirm only the selected shape is removed.
|
||||
|
||||
### Arrange transforms and context menu
|
||||
|
||||
- [ ] Confirm the Arrange top bar order is Transform Whole Prop, Normalize Size,
|
||||
Center Prop, then Show Stickman Guide.
|
||||
- [ ] Drag a selected shape body to translate it.
|
||||
- [ ] Drag the rotation dot and confirm the shape rotates continuously.
|
||||
- [ ] Hold Ctrl while rotating and confirm 15-degree stepping.
|
||||
- [ ] Drag each scale cross; hold Ctrl and confirm uniform scaling.
|
||||
- [ ] Enable Transform Whole Prop; drag visible geometry, the rotation dot, and
|
||||
each corner cross, confirming that all shapes and editable anchors follow.
|
||||
- [ ] With Snap enabled, move the whole prop and confirm the move delta follows
|
||||
the current grid; hold Ctrl while rotating and confirm 15-degree snapping.
|
||||
- [ ] Press Esc during a whole-prop gesture and confirm the preview cancels
|
||||
without changing the document.
|
||||
- [ ] Switch to Reshape or Attach and confirm Transform Whole Prop and
|
||||
Normalize Size are hidden while Center Prop and Show Stickman Guide remain.
|
||||
- [ ] Use Copy, right-click empty canvas, and Paste; confirm the pasted shape
|
||||
is offset and selected.
|
||||
- [ ] Test Flip Left-Right and Flip Up-Down.
|
||||
- [ ] Stack two or more shapes and test Bring Forward and Send Back.
|
||||
- [ ] Confirm the two-tone selection outline, bounding box, dot, and crosses
|
||||
remain visible over light and dark shapes.
|
||||
|
||||
### Reshape and navigation
|
||||
|
||||
- [ ] Switch to Reshape and confirm Add Shape and Erase Shape are hidden.
|
||||
- [ ] Drag an original vertex and confirm the outline changes.
|
||||
- [ ] Right-click a segment and choose Create Point; confirm a hollow vertex
|
||||
appears at the cursor's projected segment position and flashes yellow.
|
||||
- [ ] Right-click a vertex and choose Remove Point.
|
||||
- [ ] Right-click the interior of a closed shape and confirm it selects without
|
||||
opening a menu.
|
||||
- [ ] Right-click empty canvas and confirm no menu appears.
|
||||
- [ ] Reshape a circle and confirm Radius is replaced by the reshaped note.
|
||||
- [ ] Change Radius after reshaping and confirm a clean circle and Radius row
|
||||
return.
|
||||
- [ ] Test wheel zoom, pinch/pan if available, and middle-mouse pan. Use View
|
||||
→ Reset View and confirm only canvas pan/zoom resets.
|
||||
|
||||
### Anchors
|
||||
|
||||
- [ ] Switch to Attach and confirm the Attach toolbar and Anchors inspector
|
||||
appear.
|
||||
- [ ] Confirm the fixed origin marker is visible in Arrange, Reshape, and
|
||||
Attach, has no text label, and cannot intercept clicks.
|
||||
- [ ] Confirm `origin` is not in the editable Anchors list.
|
||||
- [ ] Add an anchor and confirm it appears in the list at the shape-bounds
|
||||
center with role `custom`.
|
||||
- [ ] Rename it with a normalized name; confirm both clicking Rename and
|
||||
pressing **Enter** commit the change, while Cancel leaves the old name.
|
||||
- [ ] Add another anchor and confirm IDs remain normalized and unique.
|
||||
- [ ] Delete a non-origin anchor and confirm it is removed.
|
||||
- [ ] Test every Role and Allowed Hands option.
|
||||
- [ ] Confirm Role contains grip, emitter, impact, mount, and custom, but not
|
||||
pivot.
|
||||
- [ ] Edit X, Y, Rotation, and Z Offset in the Inspector and confirm the marker
|
||||
and preview update.
|
||||
- [ ] Drag a selected anchor marker. Toggle Snap and confirm movement follows
|
||||
the Grid increment.
|
||||
- [ ] For a grip or emitter, drag the rotation dot and test Ctrl 15-degree
|
||||
snapping. Confirm the origin marker has no rotation dot.
|
||||
- [ ] Toggle Show Other Markers and confirm the origin marker and unselected
|
||||
markers hide/show while the selected anchor remains visible.
|
||||
- [ ] Apply each preset and confirm its documented anchors appear.
|
||||
- [ ] Draw or move a prop far from the origin, click Center Prop, and confirm
|
||||
shapes and anchors translate together while the prop's relative layout stays
|
||||
unchanged and the canvas focuses on the center marker.
|
||||
|
||||
### Holding Preview and IK
|
||||
|
||||
- [ ] Select a grip anchor and confirm the complete authored prop appears in
|
||||
Holding Preview, not only the cyan target marker.
|
||||
- [ ] Select a non-grip role and confirm the prop hides with the compatibility
|
||||
hint.
|
||||
- [ ] Confirm the hand choices are labeled Left Hand and Right Hand. Set a grip
|
||||
to left-only and right-only and
|
||||
confirm the incompatible hand is disabled/forced away.
|
||||
- [ ] Test Front Pose, Walk Left, and Walk Right. Confirm walking previews move
|
||||
in place and do not change saved anchor X/rotation values.
|
||||
- [ ] Drag the cyan target and confirm the arm bends with IK and the prop
|
||||
follows the solved wrist.
|
||||
- [ ] Confirm dragging the target pauses walking, then click Resume Walk and
|
||||
confirm the selected walk resumes.
|
||||
- [ ] Click Pause Walk and Resume Walk and confirm the button text and motion
|
||||
state change. Confirm Pause Walk is disabled for Front Pose.
|
||||
- [ ] Click Reset Arm and confirm walking pauses and the selected arm's manual
|
||||
IK override resets. In Front Pose, confirm both hand targets return to rest.
|
||||
- [ ] Wheel-zoom and middle-drag the preview camera.
|
||||
- [ ] Click Reset View and confirm the complete stickman and visible prop are
|
||||
framed.
|
||||
- [ ] Confirm the preview has no gameplay physics or collision response.
|
||||
|
||||
### Save, load, recent, autosave, and compatibility
|
||||
|
||||
- [ ] Save As with and without typing `.prop`; confirm the final file has one
|
||||
`.prop` extension.
|
||||
- [ ] Reload the saved file and confirm name, material, all shapes, colors,
|
||||
transforms, and anchors survive.
|
||||
- [ ] Confirm a successful save clears the dirty indicator and updates Recent.
|
||||
- [ ] Load through File → Recent and confirm the same document returns.
|
||||
- [ ] Save/load enough files to verify Recent keeps only five, newest first.
|
||||
- [ ] Move a prop far from the origin and use Save; verify the off-origin
|
||||
warning offers Center & Save, Save Anyway, and Cancel, and that each choice
|
||||
has the documented result.
|
||||
- [ ] Remove or rename a recent file outside the editor; open Recent and
|
||||
confirm the missing entry is pruned.
|
||||
- [ ] Make an edit, wait at least one second, close/restart the editor, and
|
||||
test Restore and Discard in the recovery dialog.
|
||||
- [ ] Confirm Restore keeps the prop dirty and Discard clears the recovery
|
||||
file.
|
||||
- [ ] Test File → New and window close while dirty; verify Save, Discard, and
|
||||
Cancel each do the expected thing.
|
||||
- [ ] Load a `.prop` file that has no `anchors` key. Confirm Attach mode
|
||||
synthesizes the fixed origin, then save and verify anchors are written.
|
||||
- [ ] Load malformed JSON or an unsupported `version` and confirm the Error
|
||||
dialog appears without replacing the current valid document.
|
||||
@@ -37,6 +37,24 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) |
|
||||
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
|
||||
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
|
||||
| 28 | **Airborne collision sweep cost scales per proxy** — Phase 6a tests the horizontal movement against each of the rig's ten synchronized collision proxies every airborne physics frame. | Low | Open | The per-proxy `PhysicsShapeQueryParameters2D.cast_motion()` checks reuse the existing proxy shapes and avoid a second body-size approximation; this is acceptable for the current sandbox scale. With many simultaneously falling rigs, the cost grows with `rigs × 10` casts per physics frame. Consider a combined query shape or broadphase candidate filter if airborne crowds become a target. (2026-09-17) |
|
||||
| 29 | **Prop Editor multi-shape → single-shape `PropBlock` flattening** — Phase 9 (`.prop` v1.0) stores a full multi-shape `shapes[]` stack, but `PropFile.flatten_to_payload()` collapses it into the one payload `PropUtils.spawn_prop()` consumes (frontmost closed polygon → circle → first shape). Also: a circle whose vertices were hand-edited still flattens by an average-distance radius, and a non-NONE preset thumbnail ignores payload colors because `PropThumbnail` re-tints from the preset. | Low | Open | Deliberate contract until a multi-shape `PropBlock` composition (multiple `Polygon2D`/`Line2D` children + merged collision) exists; recorded per `specs/phase_9_prop_editor.md` §6. Custom `.prop` thumbnails are mtime-keyed (`ThumbnailCache.prop_file_key`/`prop_file_png`, `<basename>_<mtime>.png`), so re-edits invalidate the cached PNG; built-in template PNGs stay `id_v<PROP_VERSION>`-keyed. (2026-09-18) |
|
||||
| 30 | **Prop Editor Round 2 interaction model** — vertex editing and shape transform are separated into two explicit canvas modes (`EditMode { TRANSFORM, POINTS }`, kid-facing "Move & Resize" / "Edit Points") driven by an always-visible toolbar toggle; "Create Point" inserts at the cursor's segment projection and flashes the new vertex (0.6 s) instead of inserting an invisible collinear midpoint. | Low | Open | Deliberate, user-approved design (`specs/phase_9_prop_editor_round2.md` §5): modes gate *input* only — all public math APIs stay mode-agnostic. "Create Point" stays outline-only per user decision; auto-begin-drag was rejected as modal/surprising. Revisit only if kids find the two modes confusing in practice. (2026-09-18) |
|
||||
| 31 | **Prop Editor Round 3 chrome/autosave decisions** — exit-save skips the thumbnail render (fast, deterministic quit; the PNG is a cache nicety), autosave is debounced 1.0 s to `user://prop_editor_autosave.prop` via `PropFile.save_safe` (atomic tmp/bak swap), and all human-facing strings (mode labels/emoji, menu copy) live in the `prop_editor.gd` controller while the canvas stays string-free. | Low | Open | User-approved (`specs/phase_9_prop_editor_round3.md` §6). `save_safe` leaves a `.bak` window during the rename — acceptable on desktop, revisit if autosaves ever run on a filesystem without atomic rename. (2026-09-18) |
|
||||
| 32 | **Prop Editor mode buttons drop keyboard focus** — `_make_mode_button` sets `focus_mode = FOCUS_NONE` because the Button `focus` stylebox draws after `pressed` and (with the themed `focus` = normal panel) masked the selected/accent state until the cursor moved away. | Low | Open | Round 4 fix (`specs/phase_9_prop_editor_round4.md`). Trade-off: the Arrange/Reshape toggle is mouse-only. Revisit if keyboard accessibility for the mode toggle becomes a requirement — a focus stylebox matching the pressed state (or a focus-visible-only border) would restore it. (2026-09-19) |
|
||||
| 33 | **Prop File save-serialization zeroed every point (fixed, Round 5)** — `PropFile._serialize_shape` assigned an export-format `Array` of `{x,y}` dicts directly to a typed `PackedVector2Array`; Godot's implicit conversion cannot build `Vector2` from a dictionary and silently yielded `Vector2.ZERO` per element, so every `.prop` saved by Round 1-4 wrote all points as `(0,0)` (loading as invisible/degenerate geometry). | High | ✅ Resolved | Round 5: `_serialize_shape` now routes points through `_coerce_points`; regression assertions check point *values* through `to_file_dict` and the full round-trip, plus an end-to-end canvas→save→load test. The previously saved `props/eye.prop` and `props/box.prop` are unrecoverable (coordinates were zeroed at save time) and were re-authored. Lesson: any typed `Packed*Array` assignment from dictionary/JSON data needs a Variant-coercion helper. (2026-09-19) |
|
||||
| 34 | **Prop Editor per-shape `reshaped` flag** — every shape carries `reshaped` (false by default; set by vertex insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius`), which gates primitive-specific inspector fields (today: the circle Radius row, replaced by a muted "reshaped" note). | Low | Open | Round 6 (`specs/phase_9_prop_editor_round6.md`). Only the circle consumes the flag today; future primitive params (box W/H, plank length/thickness) should reuse it instead of adding per-type detection. An "un-reshape" affordance (restoring a clean primitive from its params) is a possible future feature. (2026-09-19) |
|
||||
| 35 | **Prop Editor selection uses a two-tone halo** — after Round 6's fill-based contrast failed for the reported "shape in front of a white shape" case (the selection extends beyond the shape onto the background), every selection element (outline polyline, bbox, rotation dot, scale crosses, vertex handles) is drawn as a dark backing pass + bright core pass. | Low | Open | Round 7 (`specs/phase_9_prop_editor_round7.md`). The pair is visible on any background but doubles the selection draw calls; negligible at sandbox scale. If a custom selection color is ever wanted (e.g. per-user theme), it should replace the core pass while keeping the halo. (2026-09-19) |
|
||||
| 36 | **Phase 9a anchor roles have no gameplay consumers yet** — `.prop` assets can author/query `grip`, `emitter`, `impact`, `mount`, and `custom`, but Phase 9a is Prop Editor-only. Secondary grips do not drive two-hand IK, and no role dispatches runtime effects, damage, mounting, or equip behavior. | Medium | Open | User-approved staging in `specs/phase_9a_prop_attach.md`: land a stable authoring/schema seam plus an isolated one-hand IK preview first. A separately approved future runtime phase must design custom-prop loading, equip/release ownership, two-hand IK, emitters, melee impacts, accessory mounting, throwing, and selector/stage persistence without assuming the editor preview is gameplay-authoritative. (2026-09-20) |
|
||||
| 37 | **Prop Editor still has no document-wide undo/redo** — Phase 9a anchor edits use the existing dirty/autosave/recovery pipeline, but there is no `UndoRedo` history for either shapes or anchors. | Medium | Open | An anchor-only stack was explicitly rejected because Arrange/Reshape edits would remain non-undoable and the UI would be inconsistent. Future work should introduce one document-level command/history boundary covering shapes, anchors, material/name changes, and preset replacement together. (2026-09-20) |
|
||||
| 38 | **Phase 9a preview does not establish runtime attachment parity** — the Prop Editor uses a private, non-physics, full-multi-shape rig preview with editor-only facing and z-order conventions. It intentionally proves anchor authoring and arm-IK evaluation, not Sandbox loading, collision, physics, mirroring, per-limb interleaving, or attachment lifecycle. | Medium | Open | A future runtime spec must independently validate the anchor transform against gameplay animation/physics and decide its visual representation, sockets or solved-wrist API, mirror strategy, collision/freeze behavior, per-limb draw order, release/ragdoll handling, and multi-shape fidelity. Do not treat preview acceptance tests as runtime tests. (2026-09-20) |
|
||||
| 39 | **Phase 9b prop centering is geometric AABB centering, not physical center of mass** — `Center Prop` uses the union AABB of internal polygon/line/circle points and deliberately excludes outline expansion, anchors, material mass, and collision geometry. | Low | Open | This is deterministic and appropriate for the fixed authoring origin, but a future physics-pivot workflow may need a separately designed center-of-mass or collision-bounds command. Phase 9b keeps canonical `origin` fixed/serialized, hides it from editing, never auto-centers after edits, and uses an explicit-save warning heuristic of `max(1024 px, 2 × bounds diagonal)`. Document-wide undo remains tracked separately by #37. (2026-09-20) |
|
||||
| 40 | **Phase 9c walking preview uses exclusive gait-or-manual ownership of the selected hand target** — the private AnimationPlayer owns the target while gait plays; beginning a cyan-target drag pauses the gait before pointer input takes ownership. | Low | Open | This deterministic policy prevents AnimationPlayer and manual IK from overwriting the same Marker2D in one frame. It means an author cannot add a live hand offset while the walk continues. If that becomes valuable, design a preview-only additive offset/blend layer rather than relying on process order. The gait remains an in-place, non-physics authoring preview and is not navigation/runtime parity (#38). (2026-09-21) |
|
||||
| 41 | **Phase 10 custom prop collision uses one convex hull** — the planned Sandbox prop renders every authored layer, but collision encloses all valid closed-shape points in one solid hull. | Low | Planned | User-approved in `specs/phase_10_prop_integration.md`: one fixture keeps dynamic rigid bodies stable and avoids overlapping per-shape contacts. Hollows and concavities collide as filled space; revisit compound collision only if playtesting shows material misses. `PropFile.flatten_to_payload()` remains for legacy single-shape consumers; #29 stays open until implementation lands. (2026-09-21) |
|
||||
| 42 | **Phase 10 Browse keeps external `.prop` paths as scene asset IDs** — arbitrary filesystem files are used in place rather than copied into `res://props/`. | Medium | Planned | This matches stickman Browse and avoids filename/import conflicts, but scenes depend on the external path remaining valid. A missing custom prop becomes an inert placeholder that preserves its original scene entry and rule references through later saves; restoring the file and reloading resolves it. Existing missing-stickman rule skipping is unchanged. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
|
||||
| 43 | **Phase 10 thumbnail invalidation relies on path plus mtime** — custom cache keys will hash the full canonical path and include file modification time. | Low | Planned | This prevents equal basenames in different folders from sharing a PNG, while retaining cheap refresh checks. A filesystem with coarse timestamp resolution can miss two same-tick edits; content hashing is deferred unless observed. Cleanup must not remove built-in or unrelated external thumbnails. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
|
||||
| 44 | **Phase 10a Sandbox prop scaling depends on rigid-body collision scale behavior** — the approved corner gesture changes `PropBlock.scale`, so visual and physics bounds must agree for built-in circles/polygons and custom one-hull props. | Medium | Resolved | `PropBlock.begin_play_instance_scale()` transfers authored scale into child visuals and collision geometry during PLAY, including custom one-hull props and the nonuniform circle fallback, then `end_play_instance_scale()` restores the authored root scale in EDIT. `tests/test_phase10a_prop_scaling.gd` verifies scaled polygon and circle extents, rotated polygon contacts, dynamic PLAY contacts, and legacy nonuniform circle ellipse contacts. (2026-09-22) |
|
||||
| 45 | **Phase 10a geometric normalization excludes stroke and physical mass distribution** — Normalize Size fits the union of shape points to 96 px on its longest axis, scaling about the fixed prop origin while leaving outline width unchanged. | Low | Planned | This preserves anchor offsets and current Center Prop AABB semantics; an off-origin prop remains proportionally off-origin. A future visual-stroke fit or center-of-mass sizing tool would require a separate contract. Existing #39 tracks AABB versus physical center; #37 tracks missing document-wide undo. See `specs/phase_10a_prop_scaling.md`. (2026-09-21) |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +97,17 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
|
||||
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
|
||||
| 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. |
|
||||
| 2026-09-17 | Phase 6a (Airborne Momentum, Procedural Air Pose, and Landing Absorption) implemented and user-verified. `StickmanRig` now carries signed ledge momentum with tuned exponential drag (`AIR_DRAG_X = 0.75`), swept horizontal proxy collision, additive free-fall pose with forward-before-lift arms, and height-driven landing absorption: falls below 80 px are soft; 80..900 px maps to 32..160 px torso compression and 270..520 px hand drop over 0.30 s. Independent foot contacts preserve ramps/stairs, while `LANDING_IMPACT` blocks primary queue progression and direct ragdoll remains an immediate preemption. `tests/test_phase6a_airborne_landing.gd` passed 24 assertions; `tests/test_phase4b_terrain.gd` passed 8 assertions. Logged #28 (per-proxy airborne sweep scaling). |
|
||||
| 2026-09-20 | Phase 9a Prop Attach specification written and refined (`specs/phase_9a_prop_attach.md`). Approved architecture: append Attach to the existing Arrange/Reshape system; additive role-based `.prop` anchors with a fixed synthesized `origin`; and an isolated Prop Editor stickman preview that uses real arm IK while showing the full authored prop. All Sandbox/custom-prop loading, attachment, sockets, physics/collision, lifecycle, and runtime tests are explicitly deferred. Logged #36 (all gameplay consumers deferred), #37 (document-wide undo remains absent), and #38 (editor preview is not a runtime-parity guarantee). Existing #29 remains open independently because the current runtime still flattens multi-shape props. |
|
||||
| 2026-09-20 | Phase 9b Prop Centering specification written (`specs/phase_9b_prop_centering.md`). Approved architecture: hide canonical origin from editable anchors while retaining it in `.prop` v1.0; draw a passive constant-screen-size `(0,0)` marker in all three modes; add an always-visible Center Prop document transaction; center the authoring camera on origin for startup/New/load/reset and preserve zoom after centering; and warn explicit saves with Center & Save / Save Anyway / Cancel when displacement exceeds `max(1024 px, 2 × bounds diagonal)`. Autosave stays silent and automatic recenter-on-edit is explicitly rejected. Logged #39 (AABB authoring center is not a future physics center of mass); #37 continues to track document-wide undo. |
|
||||
| 2026-09-21 | Phase 9c Prop Editor UX specification written (`specs/phase_9c_prop_editor_ux.md`). Approved architecture: a single Front Pose / Walk Left / Walk Right preview-motion selector with dynamic Pause/Resume; manual arm IK pauses gait before taking target ownership; modal typed anchor rename; Show Other Markers controls origin plus unselected anchors; one global Snap source; theme-driven `#262C39` inspector; container-driven unclipped chrome; simplified full-figure preview with no blue border. The preview directly plays the proven in-place gait/mirror policy and remains isolated from navigation, physics, actions, and Sandbox state. Logged #40 (exclusive gait/manual hand-target ownership). |
|
||||
| 2026-09-21 | Phase 10 custom prop integration specification written (`specs/phase_10_prop_integration.md`) after user approval of full layered visuals with one convex collision hull, direct external-path Browse, and missing-prop placeholders that preserve scene entries and rule references. The design reuses Sandbox prop freeze/restore and save flows, extends PropLibrary with a merged scan while retaining its four-template API, and gives custom thumbnails path-unique mtime keys. Logged #41–#43; #29 remains open pending implementation. |
|
||||
| 2026-09-21 | Phase 10a Prop Scaling specification written (`specs/phase_10a_prop_scaling.md`) after approval of origin-based Normalize Size and single-`PropBlock` uniform corner scaling around its local origin; an initial numeric document-scale control was removed during final UI refinement. The editor scales points, circle radius, and non-origin anchors as one transaction; a world-space 1:1 stickman guide defaults OFF and persists only in editor settings. Sandbox reuses its existing scene scale and PLAY→EDIT authored-state lifecycle. Logged #44 for physics collision verification and #45 for geometry-only fit; #37 and #39 remain open. |
|
||||
| 2026-09-22 | Phase 10a whole-prop drag amendment approved (`specs/phase_10a_prop_scaling.md` §3.4). In Arrange, a visible **Resize Whole Prop** toggle displays one union-bounds outline with large handles; dragging previews all shape points, circle radii, and non-origin anchors uniformly about canonical `(0,0)` from a captured snapshot. Release calls the existing document-scale transaction once; Esc, mode switch, toggle OFF, New, Load, and Clear discard a live preview. The preview stays outside authoritative save/autosave state, and the toggle adds no `.prop` field or fourth primary mode. Existing per-shape Arrange behavior remains when the toggle is OFF. Document-wide undo stays deferred under #37; this drag should become one future undo command. |
|
||||
| 2026-09-22 | Phase 10a whole-prop transform extension approved and implemented (`specs/phase_10a_prop_scaling.md` §3.4). The prior Resize Whole Prop toggle becomes one **Transform Whole Prop** tool that mirrors Arrange: drag visible geometry to move the whole document, drag the union rotation dot to rotate it about canonical `(0,0)`, and drag a corner for the existing uniform origin-based resize. Move translates every point and non-origin anchor (snapping the baseline delta when Snap is ON); rotate transforms every point/anchor position and adds the angle to editable anchor orientations; circle radius changes only on resize. All gestures preview from snapshots outside authoritative save state and commit once on release; Esc cancellation, selection restoration, mode lifecycle, `.prop` v1.0, and deferred document undo remain unchanged. |
|
||||
| 2026-09-22 | Phase 10a implementation and final UI were user verified. The Arrange top bar is ordered **Transform Whole Prop**, **Normalize Size**, **Center Prop**, **Show Stickman Guide**; the numeric scale textbox and Apply Scale button are removed. Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while Center Prop and Show Stickman Guide remain available. Whole-prop move, 15-degree Ctrl rotation, uniform resize, anchor following, Snap movement, and Esc cancellation are documented in `docs/prop_editor_user_guide.md`; focused tests and the Prop Editor startup smoke test passed. |
|
||||
|
||||
| 2026-09-23 | Phase 11 interactive-prop specification written (specs/phase_11_prop_interaction.md), awaiting user sign-off. Approved direction: gated 11A pickup/drop plus triggers, 11B deterministic exact-target throws, and 11C ragdoll disarm; add a backward-compatible pickup capability defaulting true so authored props such as trees can opt out; preserve grip anchors into PropBlock and reuse PropAnchorData; serialize queue prop targets by scene object ID; keep held props under World; dispatch prop events through SandboxStage; cap throws by a tunable 900 px/s launch speed and 0.35–1.5 s flight time. No implementation or debt-status changes are recorded in this entry. |
|
||||
---
|
||||
|
||||
_End of Log_
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"scene_name": "Untitled",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 328.366180419922,
|
||||
"y": -711.875732421875
|
||||
},
|
||||
"zoom": 0.375657439231873
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -336.0,
|
||||
"y": -972.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://stickmen/break.stk",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_queue": [
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": 696.0,
|
||||
"y": -612.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ragdoll"
|
||||
},
|
||||
{
|
||||
"type": "recover"
|
||||
},
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": -167.4599609375,
|
||||
"y": -596.639892578125
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ragdoll"
|
||||
},
|
||||
{
|
||||
"type": "recover"
|
||||
},
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": -792.1015625,
|
||||
"y": -615.075927734375
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1152.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1344.0,
|
||||
"y": -576.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1789757195,
|
||||
"modified_unix": 1789779264.018,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"scene_name": "Untitled",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -400.0
|
||||
},
|
||||
"zoom": 0.5
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1344.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1152.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_12",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_13",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": 128.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -216.0,
|
||||
"y": -348.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://stickmen/break.stk",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_queue": [
|
||||
{
|
||||
"type": "wait",
|
||||
"duration": 1.0
|
||||
},
|
||||
{
|
||||
"type": "pick_up_prop",
|
||||
"target_prop_id": "prop_1",
|
||||
"hand": "auto"
|
||||
},
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": 300.0,
|
||||
"y": 84.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "throw_prop",
|
||||
"target_prop_id": "prop_1",
|
||||
"target_position": {
|
||||
"x": 924.0,
|
||||
"y": -540.0
|
||||
},
|
||||
"flight_time": 1.45
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "prop_1",
|
||||
"type": "prop",
|
||||
"position": {
|
||||
"x": 204.0,
|
||||
"y": -264.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "res://props/briefcase.prop",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"material_preset": 1
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1790175307,
|
||||
"modified_unix": 1790175629.897,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
+540
-8
@@ -3,10 +3,10 @@
|
||||
"scene_name": "Untitled",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 1070.22937011719,
|
||||
"y": -54.6092796325684
|
||||
"x": 1074.43005371094,
|
||||
"y": 2419.70385742188
|
||||
},
|
||||
"zoom": 0.375657439231873
|
||||
"zoom": 0.17715610563755
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
@@ -205,8 +205,8 @@
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": 3112.55639648438,
|
||||
"y": 493.14306640625
|
||||
"x": -1861.99194335938,
|
||||
"y": 434.2099609375
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -439,8 +439,8 @@
|
||||
"file_id": "area_1",
|
||||
"type": "area",
|
||||
"position": {
|
||||
"x": 1650.52270507813,
|
||||
"y": -186.824584960938
|
||||
"x": 3245.060546875,
|
||||
"y": -1320.83642578125
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"scale": {
|
||||
@@ -451,6 +451,538 @@
|
||||
"x": 96.0,
|
||||
"y": 96.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_30",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -488.163452148438,
|
||||
"y": -1807.93115234375
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_31",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 204.134155273438,
|
||||
"y": -1419.78088378906
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_32",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 340.276489257813,
|
||||
"y": -1889.03723144531
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_33",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1620.59313964844,
|
||||
"y": -130.774658203125
|
||||
},
|
||||
"rotation": 74.9999986719706,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_34",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1753.83874511719,
|
||||
"y": -12.01220703125
|
||||
},
|
||||
"rotation": 74.9999986719706,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_35",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1910.25769042969,
|
||||
"y": 118.336669921875
|
||||
},
|
||||
"rotation": 74.9999986719706,
|
||||
"asset_id": "ramp",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_36",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5376.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_37",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -5184.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_38",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4992.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_39",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4800.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_40",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4608.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_41",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4416.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_42",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4224.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_43",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4032.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_44",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3840.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_45",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3648.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_46",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3456.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_47",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3264.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_48",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3072.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_49",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2880.0,
|
||||
"y": 5600.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_50",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -4054.02685546875,
|
||||
"y": 1123.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_51",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3862.02685546875,
|
||||
"y": 1123.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_52",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3670.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_53",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3478.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_54",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3286.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_55",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -3094.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_56",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2902.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_57",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2710.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_58",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2518.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_59",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2326.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_60",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2134.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_61",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1942.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_62",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1750.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_63",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1558.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_64",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1366.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_65",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1174.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_66",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -982.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_67",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -790.02685546875,
|
||||
"y": 1091.7216796875
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"event_rules": [
|
||||
@@ -485,7 +1017,7 @@
|
||||
],
|
||||
"metadata": {
|
||||
"created_unix": 1789479607,
|
||||
"modified_unix": 1789529735.774,
|
||||
"modified_unix": 1789616216.822,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"scene_name": "Untitled",
|
||||
"camera": {
|
||||
"position": {
|
||||
"x": 976.030700683594,
|
||||
"y": 725.191955566406
|
||||
},
|
||||
"zoom": 0.282236993312836
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file_id": "terrain_1",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_2",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_3",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_4",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -384.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_5",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -192.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_6",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_7",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 192.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_8",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 384.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_9",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 576.0,
|
||||
"y": -288.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "stickman_1",
|
||||
"type": "stickman",
|
||||
"position": {
|
||||
"x": -222.0,
|
||||
"y": -959.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"stk_path": "res://stickmen/break.stk",
|
||||
"facing_profile": "FORWARD",
|
||||
"action_queue": [
|
||||
{
|
||||
"type": "walk_to",
|
||||
"target": {
|
||||
"x": -1302.19653320313,
|
||||
"y": -305.856567382813
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ragdoll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_10",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 768.0,
|
||||
"y": 2048.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_11",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 960.0,
|
||||
"y": 2048.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_12",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1152.0,
|
||||
"y": 2048.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_13",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1344.0,
|
||||
"y": 2048.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_14",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1536.0,
|
||||
"y": 2080.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_15",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1728.0,
|
||||
"y": 2080.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_16",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 1920.0,
|
||||
"y": 2080.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_17",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": 2112.0,
|
||||
"y": 2080.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_18",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -2112.0,
|
||||
"y": 2016.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_19",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1920.0,
|
||||
"y": 2016.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_20",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1728.0,
|
||||
"y": 2016.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_21",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1536.0,
|
||||
"y": 2016.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_22",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1344.0,
|
||||
"y": 1984.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_23",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -1152.0,
|
||||
"y": 1984.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_24",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -960.0,
|
||||
"y": 1984.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_25",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -768.0,
|
||||
"y": 1984.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"file_id": "terrain_26",
|
||||
"type": "terrain",
|
||||
"position": {
|
||||
"x": -576.0,
|
||||
"y": 1984.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"asset_id": "ground",
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"event_rules": [],
|
||||
"metadata": {
|
||||
"created_unix": 1789666501,
|
||||
"modified_unix": 1789666824.739,
|
||||
"autosave": false,
|
||||
"source_path": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# Architectural Plan: Dynamic `.prop` Loading & Sandbox Integration
|
||||
|
||||
This document outlines the architectural plan to transition `PropLibrary` from a static registry into a dynamic filesystem-backed library (matching `StickmanLibrary`) and integrate user-created `.prop` files into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture Overview
|
||||
|
||||
The system bridges custom prop files produced by the Prop Editor into the sandbox physics simulation through four primary layers:
|
||||
|
||||
1. **Library & File Scanning (`PropLibrary`):** Scans `res://props/` for `*.prop` files, deserializes them via `PropFile`, flattens multi-shape hierarchies into collision payloads, and merges them with built-in templates.
|
||||
|
||||
2. **Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`):** Computes cache keys using file modification timestamps (`mtime`), renders visual previews offscreen, and streams textures to the selector grid.
|
||||
|
||||
3. **UI Integration (`AssetSelector` & `SandboxStage`):** Presents custom props alongside built-in presets in a unified grid, offering folder scanning, ad-hoc file browsing, and palette live-refresh.
|
||||
|
||||
4. **World Spawning & Physics Lifecycle (`StageSpawner` & `PropBlock`):** Instantiates `RigidBody2D` nodes configured with custom collision geometry and material physical properties (mass, friction, bounce), tracking authored positions across sandbox mode transitions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Specifications
|
||||
|
||||
### A. Library Layer (`res://scripts/prop_library.gd`)
|
||||
|
||||
- **Dual-Source Registry:** Maintain built-in templates (Crate, Ball, Plank, Triangle) as default presets while scanning `res://props/*.prop` on demand.
|
||||
|
||||
- **Entry Model Schema:** Standardize entry dictionaries returned to the UI:
|
||||
- **Identifier (`id`):** Resource path (`res://props/chair.prop`) for custom files, or string key (`crate`) for templates.
|
||||
|
||||
- **Display Name (`name`):** Extracted from `.prop` metadata, with fallback to capitalized file basename.
|
||||
|
||||
- **Material Preset & Label:** Physical properties index (Wood, Metal, Rubber, Cardboard) and human-readable label.
|
||||
|
||||
- **Payload (`payload`):** Flattened shape and collision dictionary generated via `PropFile.flatten_to_payload()`.
|
||||
|
||||
- **Provenance Flags:** Boolean flag indicating whether the entry is custom and string path reference for scene serialization.
|
||||
|
||||
- **Error Handling:** Skip invalid or corrupted `.prop` files gracefully while outputting descriptive warnings.
|
||||
|
||||
### B. Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`)
|
||||
|
||||
- **Dynamic Keying Strategy:**
|
||||
- Built-in prop templates compute keys using internal version constants.
|
||||
|
||||
- Custom `.prop` files compute keys combining file basename and filesystem modification timestamp (`FileAccess.get_modified_time`).
|
||||
|
||||
- **Renderer Extension (`PropThumbnail`):** Extend offscreen rendering to support composite shapes and custom vector styling (fill color, outline width, polygons, circles) rather than single primitive shapes.
|
||||
|
||||
- **Stale Cache Cleanup:** Automatically purge superseded PNG thumbnails when custom `.prop` files are edited or deleted.
|
||||
|
||||
### C. World Spawning Pipeline (`StageSpawner`)
|
||||
|
||||
- **Payload Forwarding:** Pass the entry payload directly to `PropUtils.spawn_prop()`, which generates the corresponding `PropBlock` node with `CollisionPolygon2D` or `CollisionShape2D` nodes.
|
||||
|
||||
- **Provenance Tagging:** Store `spawn_id` and source path metadata on spawned instances to support scene serialization and reloading.
|
||||
|
||||
- **Authored Pose Capture:** Save placement positions to instance metadata (`_authored_position`, `_authored_rotation`) during **Edit** mode.
|
||||
|
||||
### D. UI & Sandbox Integration (`AssetSelector` & `SandboxStage`)
|
||||
|
||||
- **Grid Population:** Trigger `PropLibrary.scan()` when opening the prop selector, populating grid cells with thumbnails and display names.
|
||||
|
||||
- **Browse & Refresh Integration:** Connect the "Browse…" button to a file dialog for arbitrary `*.prop` selection, and the "Refresh" button to re-scan `res://props/`.
|
||||
|
||||
- **Lazy Thumbnail Processing:** Process offscreen thumbnail generation non-blockingly during stage frame processing to keep UI interactions responsive.
|
||||
|
||||
---
|
||||
|
||||
## 3. Physics & Sandbox Lifecycle Integration
|
||||
|
||||
Custom `.prop` instances must follow the existing 3-mode sandbox state machine:
|
||||
|
||||
| Stage Mode | Prop Physics State | Interaction Capabilities | Reset Behavior |
|
||||
| ---------- | -------------------------------------------------- | ------------------------ | -------------- |
|
||||
| **Edit** | RigidBody frozen (`freeze = true`, kinematic mode) |
|
||||
|
||||
| Move, rotate, scale, or delete via selection gizmos
|
||||
|
||||
| N/A (Authored state recorded on movement)
|
||||
|
||||
|
|
||||
| **Direct** | RigidBody frozen (`freeze = true`, kinematic mode)
|
||||
|
||||
| Gizmos active for rule/trigger assignment; spawner palette hidden
|
||||
|
||||
| N/A
|
||||
|
||||
|
|
||||
| **Play** | RigidBody unfrozen (`freeze = false`)
|
||||
|
||||
| Full physical simulation, collisions with terrain/stickmen/props
|
||||
|
||||
| Mode exit restores initial authored position/rotation and zeroes linear/angular velocity
|
||||
|
||||
|
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
```
|
||||
[ Phase 1: Library & Data Layer ]
|
||||
└─ Extend PropLibrary (res://props/*.prop scanning + PropFile parsing)
|
||||
└─ Define unified Prop Entry Dictionary schema
|
||||
|
||||
[ Phase 2: Thumbnail Pipeline ]
|
||||
└─ Update PropThumbnail to render multi-shape custom payloads
|
||||
└─ Implement modification timestamp cache invalidation in ThumbnailCache
|
||||
|
||||
[ Phase 3: Spawning & UI Integration ]
|
||||
└─ Update StageSpawner to tag custom prop provenance
|
||||
└─ Wire AssetSelector grid refresh and file dialog browsing
|
||||
|
||||
[ Phase 4: Stage Lifecycle & Verification ]
|
||||
└─ Verify Edit -> Play -> Edit position resets for custom props
|
||||
└─ Validate scene saving/loading persistence
|
||||
|
||||
```
|
||||
|
||||
### Milestone Checklist for Architect Agent
|
||||
|
||||
1. **Directory Scanning:** `PropLibrary` reads `res://props/` and extracts payloads via `PropFile`.
|
||||
|
||||
2. **Visual Previews:** `PropThumbnail` renders multi-polygon custom vector props to textures.
|
||||
|
||||
3. **Palette Display:** `AssetSelector` displays merged built-in templates and `.prop` files.
|
||||
|
||||
4. **Physics Interaction:** Custom props unfreeze during **Play** mode, participating in physics simulation and collision events.
|
||||
|
||||
5. **State Reset:** Props return to their original authored transforms when switching from **Play** back to **Edit** mode.
|
||||
@@ -0,0 +1,118 @@
|
||||
Here is the revised architectural specification incorporating your exact adjustments for your architect agent:
|
||||
|
||||
---
|
||||
|
||||
# Architectural Spec: Dual-Layer Prop Scaling System (Revised)
|
||||
|
||||
Status: **implemented and user verified** (2026-09-22).
|
||||
|
||||
This specification outlines the architecture for supporting prop scaling across two layers: permanent template scaling in the **Prop Editor** and contextual instance scaling in the **Sandbox Stage**.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture & Division of Responsibilities
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PROP EDITOR (Phase 9) │
|
||||
│ • Permanent template geometry mutation │
|
||||
│ • Vertex & radius coordinate transformations │
|
||||
│ • Anchor offset auto-scaling │
|
||||
│ • Stickman guide silhouette (Default: OFF) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│ Saves .prop file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SANDBOX STAGE (Phase 10) │
|
||||
│ • Contextual instance scaling via corner gizmos only │
|
||||
│ • Locked aspect ratio (uniform scaling) │
|
||||
│ • Physics mass decoupled from visual scale │
|
||||
└────────────────────────────┴────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
| Layer | Target Entity | Operation Type | Scaling Behavior | Data Persistence |
|
||||
| ----------------- | ----------------------------- | --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| **Prop Editor** | Template Asset (`.prop`) | Permanent geometry mutation | Bakes scale into raw vector vertices, radii, and anchor offsets. | Persists into `.prop` file. |
|
||||
| **Sandbox Stage** | Object Instance (`PropBlock`) | Node transform scaling | Pure gizmo scaling with **locked aspect ratio**. | Persists into `.scene` file (`scale: { x, y }`). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Prop Editor Implementation (Template Sizing)
|
||||
|
||||
### A. Document Geometry Scale Tool
|
||||
|
||||
- **Scope:** Modifies raw geometry arrays (`points` for polygons/lines, `radius` for circles) in the shape stack.
|
||||
|
||||
- **Anchor Recalculation:** All registered anchor offset positions (`x`, `y`) automatically multiply by the scale factor so grip, pivot, and emitter points maintain exact relative alignment.
|
||||
|
||||
- **Normalize Size Action:** Adds a canvas action (📏 **Normalize Size**) alongside 🎯 **Center Prop** that computes the bounding box union of all shapes and uniformly scales the document to fit within a standard $96 \times 96\text{ px}$ canvas box.
|
||||
|
||||
### B. Stickman Visual Reference Silhouette
|
||||
|
||||
- **Overlay Guide:** Renders a semi-transparent 1:1 Stickman figure silhouette on the canvas background for scale context against full character rigs ($\approx 336 \times 940\text{ px}$).
|
||||
|
||||
- **Default State:** Toggleable via the UI / canvas controls and **defaults to OFF**.
|
||||
|
||||
- **View Aid Only:** Non-selectable, non-interactive visual guide that is excluded from `.prop` file serialization.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sandbox Stage Implementation (Instance Sizing)
|
||||
|
||||
### A. Gizmo-Driven Scaling (No UI SpinBoxes / No Presets)
|
||||
|
||||
- **Corner Scale Handles:** Extends `StageGizmos` in `EDIT` mode to render corner cross handles on selected prop bounding boxes.
|
||||
|
||||
- **Aspect Ratio Locking:** Dragging any corner handle scales the object while strictly **locking the aspect ratio** (uniform scaling).
|
||||
|
||||
- **Streamlined UI:** No numeric scale SpinBoxes or preset buttons (`S`/`M`/`L`) are added to the stage UI; all scaling is performed intuitively directly on the canvas via gizmo manipulation.
|
||||
|
||||
### B. Mode Lifecycle & Physics Rules
|
||||
|
||||
- **Decoupled Mass Contract:** Scaling a prop adjusts node visual geometry (`Polygon2D`, `Line2D`) and collision bounds (`CollisionPolygon2D`, `CollisionShape2D`). RigidBody `mass` remains independent, governed strictly by the assigned Material Preset (Wood, Metal, Rubber, Cardboard).
|
||||
|
||||
- **Lifecycle State Handling (`PLAY` $\rightarrow$ `EDIT`):** Transitioning from `PLAY` back to `EDIT` mode restores the authored `position` and `rotation` and zeroes linear/angular velocity, but **does not reset or alter the node scale**. The authored scale set during editing is retained as-is.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
1. **Phase A (Prop Editor Tooling):**
|
||||
|
||||
- Implement document-wide geometry and anchor scaling logic in `PropEditor`.
|
||||
|
||||
- Add the toggleable Stickman Reference Silhouette (**default OFF**) and "Normalize Size" canvas button.
|
||||
|
||||
2. **Phase B (Sandbox Gizmo Scaling):**
|
||||
|
||||
- Extend `StageGizmos` with corner scale handles that lock aspect ratio during drag operations.
|
||||
|
||||
- Ensure `StageSelection` AABB bounding boxes recalculate accurately to account for scaled instances.
|
||||
|
||||
3. **Phase C (Persistence & Verification):**
|
||||
|
||||
- Verify scene save/load serialization of scaled `PropBlock` instances.
|
||||
|
||||
- Confirm that `EDIT` $\rightarrow$ `PLAY` $\rightarrow$ `EDIT` mode switches restore authored position/rotation while keeping object scale intact.
|
||||
|
||||
## 5. Verified final Prop Editor controls
|
||||
|
||||
The Arrange mode top bar presents these controls in order:
|
||||
|
||||
1. **Transform Whole Prop**
|
||||
2. **Normalize Size**
|
||||
3. **Center Prop**
|
||||
4. **Show Stickman Guide**
|
||||
|
||||
The numeric scale textbox and Apply Scale button were removed. Transform Whole
|
||||
Prop previews and commits whole-document move, rotation, and uniform resize
|
||||
gestures around the canonical origin. Grid Snap rounds move deltas, Ctrl snaps
|
||||
rotation to 15-degree increments, editable anchors follow the geometry, and Esc
|
||||
cancels an active preview. Normalize Size fits the shape-point bounds to a 96 px
|
||||
maximum dimension. Transform Whole Prop and Normalize Size are hidden in Reshape
|
||||
and Attach; Center Prop and Show Stickman Guide remain available in all modes.
|
||||
|
||||
Sandbox instance scaling remains a separate prop-only uniform corner-gizmo
|
||||
operation. It retains the authored scene scale, preset-governed mass, and
|
||||
PLAY-to-EDIT position/rotation restoration behavior.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Architecture Specification: Interactive Prop System & Event Engine
|
||||
|
||||
This specification details the technical architecture for bringing interactive prop capabilities—including auto-approach, IK socket binding, parabolic throwing, ragdoll disarming, and "When..." rule triggers—into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview & Layer Interactions
|
||||
|
||||
Prop interactions bridge five core subsystems across the runtime stack:
|
||||
|
||||
```
|
||||
[ Director UI / Target Selector ]
|
||||
│
|
||||
▼
|
||||
[ StickmanActionRunner ] ──(Auto-Approach)──► [ StickmanLocomotion ]
|
||||
│
|
||||
▼
|
||||
[ StickmanRig IK ] ──(Socket Binding)──► [ PropBlock Physics ]
|
||||
│
|
||||
▼
|
||||
[ StageEventRegistry ] ──("When..." Rules)──► [ Reactive Actions ]
|
||||
|
||||
```
|
||||
|
||||
1. **Director UI & Target Selection:** Captures spatial throw targets and enqueues prop interaction commands.
|
||||
|
||||
2. **Action Runner & Locomotion:** Evaluates spatial reach; automatically inserts `walk_to` sub-goals when props are out of arm's reach.
|
||||
|
||||
3. **IK & Bone Socketing:** Extends arm IK targets toward prop `grip` anchors and manages `RemoteTransform2D` binding.
|
||||
|
||||
4. **Physics Lifecycle:** Unfreezes/freezes `PropBlock` bodies and inherits velocity during release, throw, or ragdoll disarm transitions.
|
||||
|
||||
5. **Event Registry:** Emits and evaluates edge-triggered prop rules within the stage's "When → Then" engine.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Action Queue & Auto-Approach (`walk_to`)
|
||||
|
||||
To ensure smooth director interaction, the action queue automatically resolves spatial gaps between the stickman and target props.
|
||||
|
||||
### Action Payload Schemas
|
||||
|
||||
- **`pick_up_prop`**: `{ "type": "pick_up_prop", "target_prop_id": String, "hand": "auto" | "left" | "right" }`
|
||||
|
||||
- **`drop_prop`**: `{ "type": "drop_prop" }`
|
||||
|
||||
- **`throw_prop`**: `{ "type": "throw_prop", "target_position": Vector2, "force": float }`
|
||||
|
||||
### Smart Approach Execution
|
||||
|
||||
When `StickmanActionRunner` receives a `pick_up_prop` payload:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ pick_up_prop Action Start │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ Distance = |rig.position - prop.position| │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
Is Distance > Reach Radius (e.g. 40px)?
|
||||
┌───────────────────────┴───────────────────────┐
|
||||
│ │
|
||||
[ YES ] [ NO ]
|
||||
│ │
|
||||
┌─────────────────▼─────────────────┐ ┌─────────────────▼─────────────────┐
|
||||
│ 1. Pause pick_up_prop sequence │ │ 1. Skip locomotion step │
|
||||
│ 2. Prepend walk_to(prop_offset) │ │ 2. Trigger arm IK reach animation │
|
||||
│ 3. Resume pickup on arrival │ └───────────────────────────────────┘
|
||||
└───────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Distance Check:** Evaluates global horizontal/spatial distance between the stickman root and prop `origin` pivot.
|
||||
|
||||
2. **Sub-Goal Injection:** If outside reach radius, `StickmanActionRunner` pauses the pickup sequence and internally issues a `walk_to` command to a stand point adjacent to the prop.
|
||||
|
||||
3. **Sequence Resume:** Upon receiving `StickmanLocomotion.arrived_at_waypoint`, the runner resumes the pickup sequence and triggers arm IK extension.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pickup, Drop, and Throw Pipelines
|
||||
|
||||
### A. Prop Pickup Sequence & IK Socketing
|
||||
|
||||
1. **IK Reach Phase (150 ms):** `TwoBoneIK` targets for the selected arm interpolate toward the world position of the prop's `grip` anchor.
|
||||
|
||||
2. **Socket Attachment:**
|
||||
|
||||
- Set `PropBlock.freeze = true` (kinematic mode) to suspend environmental physics forces.
|
||||
|
||||
- Instantiate or activate a `RemoteTransform2D` node attached to the lower arm / wrist joint, targeting the `PropBlock`.
|
||||
|
||||
- Apply anchor local offsets (`x, y, rotation_deg, z_index_offset`) to align the prop grip directly within the hand geometry.
|
||||
|
||||
3. **Holding Stance Latch:** Lock upper limb IK targets into a carried stance during subsequent walk cycles.
|
||||
|
||||
### B. Prop Drop Sequence
|
||||
|
||||
1. **Unbinding:** Clear `RemoteTransform2D` target and reparent `PropBlock` back to the stage world root.
|
||||
|
||||
2. **Physics Activation:** Set `PropBlock.freeze = false` to re-enable rigid body simulation, gravity, and collision shapes.
|
||||
|
||||
3. **Inertia Inheritance:** Set `PropBlock.linear_velocity` equal to the stickman's current movement velocity (`_last_grounded_walk_velocity_x`) so dropped items retain forward momentum.
|
||||
|
||||
4. **Arm Recovery:** Blend wrist IK targets smoothly back to standard locomotion/idle rest positions.
|
||||
|
||||
### C. Prop Throw Sequence & Trajectory Selection
|
||||
|
||||
#### Director Target UI Flow
|
||||
|
||||
- **Targeting Mode:** Selecting "Throw Prop" in Director/Play mode activates an aim reticle cursor and dynamic arc preview.
|
||||
|
||||
- **Trajectory Math:** Given hand release point $P_0$, target landing point $P_t$, base speed $v_0$, and gravity $g$:
|
||||
|
||||
$$\theta = \arctan\left(\frac{v_0^2 \pm \sqrt{v_0^4 - g(g \cdot x^2 + 2 y \cdot v_0^2)}}{g \cdot x}\right)$$
|
||||
|
||||
Calculates exact initial launch velocity components $V_{\text{launch}} = (v_0 \cos\theta, v_0 \sin\theta)$.
|
||||
|
||||
#### Keyframe Throw Animation Arc
|
||||
|
||||
```
|
||||
[ Windup: 100ms ] ──► [ Release Frame: 150ms ] ──► [ Follow-Through: 50ms ]
|
||||
- Torso tilts -15° - Arm sweeps to angle - Arm extends along arc
|
||||
- Arm pulls backward - Prop unfreezes + impulse - Blends back to idle
|
||||
|
||||
```
|
||||
|
||||
1. **Windup (100 ms):** Torso rotates backward opposite the launch vector while the arm pulls back.
|
||||
2. **Release Frame (150 ms):**
|
||||
|
||||
- Arm sweeps rapidly along the calculated launch angle $\theta$.
|
||||
- Unbind `RemoteTransform2D` and reparent prop to world root.
|
||||
|
||||
- Set `PropBlock.freeze = false` and execute `apply_central_impulse(V_launch)`.
|
||||
|
||||
- Apply rotational torque proportional to the distance between hand grip and prop center-of-mass.
|
||||
|
||||
3. **Follow-Through (50 ms):** Arm extends toward launch vector, then smoothly dampens back to default idle/walking pose.
|
||||
|
||||
---
|
||||
|
||||
## 4. Physics & State Machine Lifecycle (Ragdoll Disarm Handoff)
|
||||
|
||||
When `StickmanRig.set_ragdoll(true)` is executed (via impact, command, or keypress), any held prop must be immediately disarmed before kinematic nodes are hidden and ragdoll bodies are built.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ StickmanRig.set_ragdoll(true) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 1. Pre-Ragdoll Intercept │
|
||||
│ Query if held_prop != null │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 2. Execute Disarm Handoff │
|
||||
│ - Clear RemoteTransform2D binding │
|
||||
│ - Reparent PropBlock to world root │
|
||||
│ - Set prop.freeze = false │
|
||||
│ - Apply cached momentum (_track_momentum) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 3. Build Physics Ragdoll │
|
||||
│ Hide animated body, build RigidBody2D │
|
||||
│ network at current solved bone positions │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Pre-Ragdoll Intercept:** Prior to clearing collision proxies or hiding body meshes, check for an active `held_prop` binding.
|
||||
|
||||
2. **Physics Unbind:**
|
||||
|
||||
- Detach the `RemoteTransform2D` socket.
|
||||
|
||||
- Reparent prop to the world root and set `PropBlock.freeze = false`.
|
||||
|
||||
- Transfer cached rig momentum (`_track_momentum()`) directly to `PropBlock.linear_velocity` and `angular_velocity` so the prop tumbles naturally alongside the falling figure.
|
||||
|
||||
3. **Ragdoll Construction:** Proceed with standard `_build_ragdoll()` processing at current solved bone transforms.
|
||||
|
||||
---
|
||||
|
||||
## 5. Event System Integration ("When..." Triggers)
|
||||
|
||||
Three new edge-triggered prop event types are added to `StageEventRegistry` (`sandbox_stage.gd`) to support the director's "When → Then" mental model.
|
||||
|
||||
### Prop Trigger Specifications
|
||||
|
||||
| Trigger Type | Source Object | Target Object | Firing Condition |
|
||||
| ---------------- | --------------- | --------------- | ------------------------------------------------------------------------------- |
|
||||
| `prop_picked_up` | Stickman / Prop | Prop / Stickman | Emitted as soon as IK arm reach completes and `RemoteTransform2D` socket locks. |
|
||||
|
||||
|
|
||||
| `prop_dropped` | Stickman / Prop | Prop / Stickman | Emitted when a stickman drops a prop manually or via ragdoll disarm.
|
||||
|
||||
|
|
||||
| `prop_thrown` | Stickman / Prop | Prop / Stickman | Emitted at the exact release frame of a throw animation arc.
|
||||
|
||||
|
|
||||
|
||||
### Signal Routing & Evaluation
|
||||
|
||||
1. `StickmanActionRunner` emits `prop_action_executed(action_type, stickman, prop)` upon completing or releasing an anchor attachment.
|
||||
|
||||
2. `StageEventRegistry` matches active stage rules where `rule.trigger.type` equals `prop_picked_up`, `prop_dropped`, or `prop_thrown`.
|
||||
|
||||
3. Matching rules trigger reactive actions on target stickmen or props (e.g., _When Stickman A picks up Sword X $\rightarrow$ Stickman B ragdolls_).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Roadmap for Architect Agent
|
||||
|
||||
```
|
||||
[ Phase 1: Action Queue & Locomotion ]
|
||||
├─ Add pick_up_prop, drop_prop, and throw_prop to StickmanActionRunner
|
||||
└─ Implement distance evaluation & auto-enqueueing of walk_to sub-goals
|
||||
|
||||
[ Phase 2: Socket Attachment & IK Handshake ]
|
||||
├─ Implement RemoteTransform2D socketing to wrist joint with grip offsets
|
||||
└─ Implement holding stance arm pose blending
|
||||
|
||||
[ Phase 3: Throw Mechanics & Trajectory UI ]
|
||||
├─ Add trajectory solver & reticle target selector to Director UI
|
||||
└─ Build throw animation timeline (windup -> release impulse -> follow-through)
|
||||
|
||||
[ Phase 4: State Machine Integration & Disarm ]
|
||||
├─ Intercept held props during set_ragdoll(true) transitions
|
||||
└─ Transfer cached linear/angular momentum to disarmed prop physics body
|
||||
|
||||
[ Phase 5: Event System Expansion ]
|
||||
├─ Register prop_picked_up, prop_dropped, prop_thrown triggers in StageEventRegistry
|
||||
└─ Wire action signals to trigger "When -> Then" rule evaluations
|
||||
|
||||
```
|
||||
|
||||
### Architectural Acceptance Criteria Checklist
|
||||
|
||||
- [ ] **Auto-Approach:** Requesting pickup of a distant prop automatically causes the stickman to walk to the prop before reaching out.
|
||||
|
||||
- [ ] **Grip Alignment:** Hand attaches precisely to the prop's authored `grip` anchor, respecting local position, rotation, and Z-index offsets.
|
||||
|
||||
- [ ] **Trajectory Accuracy:** Thrown props follow the calculated parabolic arc toward the director's targeted ground point.
|
||||
|
||||
- [ ] **Ragdoll Disarm:** Entering ragdoll mode immediately detaches held props and applies rig momentum so items tumble realistically.
|
||||
|
||||
- [ ] **Event Rule Engine:** Rules configured with `When prop_picked_up`, `When prop_dropped`, or `When prop_thrown` execute downstream actions reliably.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase 6a Architecture Proposal: Airborne Momentum, Dynamic Air-Pose & Landing Impact Absorption
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Phase 6a extends the shipped Phase 6 continuous terrain locomotion system by replacing rigid vertical free-falls and instant landings with dynamic, momentum-driven airborne physics. It introduces horizontal air momentum decay, procedural wind-drag pose flailing during fall, and velocity-proportional landing impact compression that temporarily holds action queue execution.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Mechanics & State Integration
|
||||
|
||||
### 2.1 Airborne Horizontal Momentum Decay
|
||||
|
||||
- **Momentum Snapshot**: Upon detecting a loss of ground support during `ANIMATED` mode, `StickmanRig` caches the last horizontal velocity ($v_x$) from navigation or manual walking.
|
||||
|
||||
- **Parabolic Arc**: During free-fall frames, $v_x$ continues to shift root $X$ while applying exponential air drag ($\text{AIR\_DRAG\_X} = 4.0$). Vertical gravity (`TERRAIN_GRAVITY`) accelerates $Y$ down to `TERRAIN_MAX_FALL_SPEED`.
|
||||
|
||||
### 2.2 Procedural Air Pose & Wind-Drag Flail
|
||||
|
||||
- **Limb Displacement**: As downward velocity ($v_y$) increases, upper limb `IK_Targets` blend away from `AnimationTargets/*_Base` markers, pulling hands above the head to simulate wind resistance.
|
||||
|
||||
- **High-Frequency Flail**: Layer anti-phased sine-wave offsets ($\sim 12\text{ Hz}$) onto hand and foot IK targets, scaling amplitude with $v_y$ to create a realistic airborne fluttering effect.
|
||||
|
||||
### 2.3 Landing Impact Compression & Queue Hold
|
||||
|
||||
- **Velocity Capture**: On `_probe_ground_y()` contact detection, record the final impact fall speed ($v_{\text{impact}}$).
|
||||
|
||||
- **Impact Absorption**: If $v_{\text{impact}} \ge \text{MIN\_IMPACT\_VELOCITY}$, trigger a 2-phase pelvis squash tween:
|
||||
|
||||
1. **Compress**: Rapidly ease pelvis Y downward ($\sim 0.08\text{s}$) into a deep crouch, forcing leg `TwoBoneIK` solvers to flex while feet stay locked to ground contacts.
|
||||
|
||||
2. **Recover**: Spring pelvis Y back up to standing terrain height ($\sim 0.22\text{s}$) using a sine-out easing curve.
|
||||
|
||||
- **Queue Synchronization**: Set an internal `_is_landing_impact` flag during absorption to pause `action_queue` processing, resuming automatically once standing recovery completes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component & API Specification
|
||||
|
||||
| Component | Target System | Implementation Details |
|
||||
| --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Momentum Tracker** | `StickmanRig._physics_process()` | Snapshot $v_x$ at fall transition; apply $v_x \cdot \Delta t$ with exponential decay to root $X$ during free-fall. |
|
||||
| **Air Pose Solver** | `StickmanRig` IK Stack | Offset live arm/leg `IK_Targets` dynamically relative to base markers based on $v_y$. |
|
||||
| **Impact Absorption** | `StickmanRig` Terrain Controller | Run pelvis squash/spring tween on ground contact; hold `action_queue` until complete. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification & Acceptance Criteria
|
||||
|
||||
- **Ledge Ejection**: Walking off an elevated ledge projects the rig outward in a parabolic curve rather than dropping straight down.
|
||||
|
||||
- **Air Flail**: Arms flex upward and hands/feet flutter rhythmically during high-speed free-fall.
|
||||
|
||||
- **Impact Cushion**: Drops exceeding minimum velocity threshold exhibit a visible knee bend and pelvis compression before standing up.
|
||||
|
||||
- **Queue Order**: Action queue execution halts during landing impact recovery and resumes cleanly without dropping or skipping pending actions.
|
||||
|
||||
## 5. Open Questions and Edge Cases
|
||||
|
||||
To ensure the implementation runs smoothly, here are the key edge cases and technical questions your architect should clarify before finalizing Phase 6a:
|
||||
|
||||
- **Ragdoll Escalation Threshold**: If the fall velocity ($v_{\text{impact}}$) exceeds a extreme safety limit, should the stickman escalate into a full dynamic ragdoll collapse instead of executing a soft crouch-and-recover animation?
|
||||
- **Mid-Air Wall Collisions**: How should horizontal air momentum ($v_x$) react if the stickman drifts laterally into a vertical wall or prop before touching the floor? Should horizontal velocity immediately zero out on wall contact to prevent body clipping?
|
||||
- **Sloped Landing Compression**: How does pelvic squash behave when landing on an angled ramp or stair tread? We need to confirm whether left and right foot IK targets remain independently anchored to unequal terrain elevations while the pelvis squashes downward.
|
||||
|
||||
- **Action Queue Preemption**: If a dynamic trigger fires or a user issues a new direct action while the stickman is airborne or mid-landing crouch, should the incoming action cancel the landing recovery sequence or wait at the front of the queue?
|
||||
- **Custom Pose Overrides**: How should wind-drag flailing blend if the character goes airborne while performing a non-locomotion action (like holding an item or attacking)? Should flailing override all hand keyframes, or only apply during default walk/idle states?
|
||||
@@ -0,0 +1,134 @@
|
||||
# High-Level Architectural Plan: Graphical UI & Palette Polish Phase
|
||||
|
||||
## Objective
|
||||
|
||||
To overhaul the `SandboxStage` user interface into a polished, kid-friendly graphical experience, while establishing a flexible, schema-driven theme system that seamlessly supports present UI elements and future feature additions (such as dynamic speech bubble styling, sound customization, and extended rule editors).
|
||||
|
||||
---
|
||||
|
||||
## 1. Centralized Theme & Resource Infrastructure
|
||||
|
||||
### 1.1 JSON Theme Schema Expansion (`sandbox_theme.json`)
|
||||
|
||||
- **Unified Theme Specification**: Expand the `sandbox_theme.json` contract to serve as the single source of truth for typography, spatial grids, mode accent colors, and component-specific visual presets.
|
||||
|
||||
- **Subsystem Category Blocks**: Structure the schema into modular sub-dictionaries (e.g., `fonts`, `mode_colors`, `grid`, `popups`, `speech_bubbles`).
|
||||
|
||||
- **Graceful Fallback Pipeline**:
|
||||
|
||||
1. Specific component override value.
|
||||
|
||||
2. Subsystem block default (e.g., `speech_bubbles.style_defaults`).
|
||||
|
||||
3. Global theme default (`fonts.ui_font`).
|
||||
|
||||
4. System fallback (`ThemeDB.fallback_font`).
|
||||
|
||||
- **Resource Preloading & Validation**: Ensure the theme loader validates paths, gracefully handles missing assets with non-blocking warnings, and caches loaded `Font` and `StyleBox` objects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Graphical Stage Chrome & Mode Navigation
|
||||
|
||||
### 2.1 Mode Switcher & Viewport Frame
|
||||
|
||||
- **3-Segment Graphical Switcher**: Replace standard text toggles with a stylized 3-segment mode control (`[✏️ Edit | 🎬 Direct | ▶️ Play]`).
|
||||
|
||||
- **Viewport Frame Accent**: Render a screen-constant border frame (`_mode_frame`) around the viewport that dynamically tints according to the active mode accent color.
|
||||
|
||||
- **Mode Badge Pill**: Maintain a rounded, high-contrast status badge in the viewport corner displaying the active state with mode-specific icons and accent backgrounds.
|
||||
|
||||
### 2.2 Status Bar & Dynamic Cursor System
|
||||
|
||||
- **Status & Coordinate Bar**: Polish the bottom bar for scannability, hosting contextual operational hints and live spatial coordinates.
|
||||
|
||||
- **Contextual Cursors & Floating Tooltips**:
|
||||
- Swap between arrow/crosshair, placement cursors, and custom action reticles depending on mode and pending operations.
|
||||
|
||||
- Attach floating, high-contrast tooltip pills offset from the cursor during target selection workflows (e.g., direct mode commands or trigger mapping).
|
||||
|
||||
---
|
||||
|
||||
## 3. Icon-Based Palette & Thumbnail Engine
|
||||
|
||||
### 3.1 Draggable / Clickable Card Palette
|
||||
|
||||
- **Visual Asset Cards**: Transition spawner controls from text buttons to visual card tiles displaying item icons, labels, and category badges.
|
||||
|
||||
- **Placement Integration**: Support both click-to-select active placement and direct drag-and-drop actions onto the stage.
|
||||
|
||||
### 3.2 Modal Asset Selector & Thumbnail Pipeline
|
||||
|
||||
- **Asset Selector Modal**: Refine the modal popup and its dimming background backdrop for browsing extended stickmen and prop libraries.
|
||||
|
||||
- **Asynchronous Thumbnail Cache**:
|
||||
- Streamline off-screen thumbnail generation for custom props and stickmen.
|
||||
|
||||
- Cache generated preview textures to prevent UI stuttering during library scans.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visual Overlay & Placement Feedback Engine
|
||||
|
||||
### 4.1 Terrain Drag-Painting & Ghosting Feedback
|
||||
|
||||
- **Block-Unit Stride Alignment**: Align placement ghosts and Bresenham line painting to the active terrain template's AABB extent.
|
||||
|
||||
- **3-State Ghost Tinting**:
|
||||
- **Green**: Valid, empty space ready for placement.
|
||||
|
||||
- **Faint / Neutral**: Same-type block (skipped to avoid double-stamping).
|
||||
|
||||
- **Red**: Conflicting entity or invalid geometry.
|
||||
|
||||
### 4.2 Guidance & Target Trajectories
|
||||
|
||||
- **Axis-Aligned Guides**: Render high-contrast dashed lines along cardinal axes during Shift-locked terrain drags.
|
||||
|
||||
- **Rubber-Band Trajectories**: Draw contextual connection lines and target reticles from character origins to mouse targets during Direct mode actions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reusable Control Widgets & Forward-Compatible Systems
|
||||
|
||||
### 5.1 Standardized Kid-Friendly UI Components
|
||||
|
||||
Develop a suite of reusable, theme-aware control widgets that can be embedded across dialogs, action popups, and future parameter editors:
|
||||
|
||||
- **Color Swatch Selector**: Clickable circle palettes populated from theme swatch arrays or editor history.
|
||||
|
||||
- **Preset Size Toggles**: Segmented buttons for coarse adjustments (e.g., **Small**, **Medium**, **HUGE**) mapping to backend integer/scale metrics.
|
||||
|
||||
- **Preset Style / Font Pickers**: Visual icon or text buttons allowing selection between preset fonts (e.g., Standard, Comic, Pixel, Scary).
|
||||
|
||||
- **Emoji Quick Grid**: A dedicated grid widget for inserting expressive graphics directly into text fields.
|
||||
|
||||
### 5.2 Context Menus & Action Popups
|
||||
|
||||
- **Uniform Popup Styling**: Apply standardized theme rules, font sizes, and emoji scaling across action context menus, trigger submenus, and dialogs.
|
||||
|
||||
- **Clean Action Dialogs**: Align text input (Speak), duration (Wait), and rename popups with dark-mode sandbox styling.
|
||||
|
||||
### 5.3 Extension Blueprint: Speech Bubble Customization System
|
||||
|
||||
Architect the theme and component pipeline so that future speech bubble upgrades plug directly into the visual foundation without refactoring:
|
||||
|
||||
- **Data Contract**: Ensure the `speak` action payload accepts visual parameters (`font_preset`, `size_preset`, `color`, `bubble_style`) alongside text.
|
||||
|
||||
- **Style Engine Integration**: Speech bubble nodes consume geometry styles (Standard, Yell, Thought, Whisper) and theme font presets directly from `sandbox_theme.json`.
|
||||
|
||||
- **UI Reusability**: The standard Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets drop directly into the Direct mode `Speak` parameter dialog.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Sequence
|
||||
|
||||
1. **Phase 1: Theme Engine & Core Schema**: Extend `sandbox_theme.json` schema, build centralized theme loading/fallback pipeline, and integrate theme application hooks across stage UI.
|
||||
|
||||
2. **Phase 2: Chrome & Mode Navigation Polish**: Implement the 3-segment mode control, viewport frame accents, status badges, and cursor/tooltip overlay controllers.
|
||||
|
||||
3. **Phase 3: Reusable UI Component Library**: Build the Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets.
|
||||
|
||||
4. **Phase 4: Graphical Palette & Modal Overhaul**: Implement icon-based asset cards, refine the `AssetSelector` modal, and optimize asynchronous thumbnail caching.
|
||||
|
||||
5. **Phase 5: Context Menus & Overlay Polish**: Update action popups with uniform theme sizing and polish drag-painting ghost/guide overlays.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Architecture Plan: Rig Relocation & Component Modularization
|
||||
|
||||
### **Objective**
|
||||
|
||||
Relocate `master_rig.tscn` to `res://scenes/stickman_rig.tscn` and decouple `stickman_rig.gd` into dedicated, single-responsibility sub-components to improve maintainability, testability, and concern separation.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 1: File Operations & Scene Relocation**
|
||||
|
||||
1. Move `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn` within Godot to maintain internal UID mappings and resource links.
|
||||
|
||||
2. Verify sub-resource integrity after relocation.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 2: Script Modularization (`stickman_rig.gd`)**
|
||||
|
||||
Extract key responsibilities into sub-nodes or dedicated helper scripts attached to `stickman_rig.tscn`:
|
||||
|
||||
- **Ragdoll & Recovery Component (`StickmanRagdoll`)**
|
||||
- Move ragdoll body/joint tables (`RAGDOLL_BODIES`, `RAGDOLL_JOINTS`) and joint limit bands.
|
||||
|
||||
- Move procedural physics node instantiation (`RagdollBodyContainer`), pin joint setups, and impulse application (`apply_ragdoll_velocity_boost`).
|
||||
|
||||
- Extract rest detection polling (`REST_LINEAR_THRESHOLD`, `REST_ANGULAR_THRESHOLD`), rest timers, and stand-up recovery tweens (`request_recovery`, `snap_to_standing`).
|
||||
|
||||
- **Locomotion & Terrain Subsystem (`StickmanLocomotion`)**
|
||||
- Extract surface raycast probing (`_probe_ground_y`, `_probe_terrain_fan`) and root elevation adjustments (`_reanchor_root_to_landing`).
|
||||
|
||||
- Move airborne physics, horizontal drag calculations (`AIR_DRAG_X`), downward gravity acceleration, and procedural wind-drag pose flailing.
|
||||
|
||||
- Extract landing impact velocity capture and knee-compression crouch/spring recovery tweens.
|
||||
|
||||
- **Director & Action Runner (`StickmanActionRunner`)**
|
||||
- Extract the action queue data structure (`action_queue`) and execution state machines (`RunnerState`, `ActionPhase`).
|
||||
|
||||
- Move pathfinding coordination (`NavigationAgent2D`), kinematic `walk_to` execution, and `FOOT_OFFSET` alignment.
|
||||
|
||||
- Handle speech bubble triggers (`speak`), wait timers, and rule-injected reactive action clearing (`clear_reactive_actions`).
|
||||
|
||||
- **Core Rig Coordinator (`StickmanRig`)**
|
||||
- Retain core rig state management (`ANIMATED`, `RAGDOLL`, `RECOVERING`).
|
||||
|
||||
- Focus strictly on facing profiles (`FacingProfile`), TwoBoneIK bend direction toggles (`BendDirection`), and draw-order sorting (`Z_ORDER_BY_PROFILE`).
|
||||
|
||||
- Serve as the root coordinator relaying signals and state transitions to sub-components.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 3: Reference & Script Updates**
|
||||
|
||||
Update hardcoded string references from `"res://master_rig.tscn"` to `"res://scenes/stickman_rig.tscn"`:
|
||||
|
||||
- **`scripts/stickman_factory.gd`**: Update `spawn_from_data()` instantiation target.
|
||||
|
||||
- **`scripts/test_harness.gd`**, **`scripts/stk_rig_adapter.gd`**, **`scripts/create_animations.gd`**: Update script comments, tool headers, and path constants.
|
||||
|
||||
- **`tests/` Suite**: Search and replace hardcoded scene path string literals across all unit tests.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 4: Documentation Updates**
|
||||
|
||||
Update path references across project markdown files:
|
||||
|
||||
- `README.md`, `AGENTS.md`, `RIGGING.md`, and relevant files in `specs/` and `plans/`.
|
||||
|
||||
---
|
||||
|
||||
### **Phase 5: Verification & Acceptance Criteria**
|
||||
|
||||
1. **Separation & Testability**: Confirm that locomotion raycasting and action runner execution can be tested independently without instantiating ragdoll physics.
|
||||
|
||||
2. **Factory Instantiation**: Verify `StickmanFactory.spawn()` loads `res://scenes/stickman_rig.tscn` cleanly without path errors.
|
||||
|
||||
3. **Test Harness Verification**: Run `res://scenes/test_harness.tscn` via **F6** to ensure walk, speak, wait, and ragdoll actions execute cleanly.
|
||||
|
||||
4. **Automated Suite**: Run the full project unit test suite in `tests/` to confirm all assertions pass.
|
||||
|
||||
---
|
||||
|
||||
### **Completion Record (verified implementation)**
|
||||
|
||||
Phase 8 is implemented. The relocated scene is `res://scenes/stickman_rig.tscn`; its root `Master` node is a `StickmanRig` facade over three authored component children. The facade remains the compatibility boundary for callers while each component owns one subsystem:
|
||||
|
||||
```text
|
||||
Master (StickmanRig)
|
||||
├── RigComponents
|
||||
│ ├── Locomotion (StickmanLocomotion)
|
||||
│ │ ├── terrain probing / ground following
|
||||
│ │ ├── walking and airborne motion
|
||||
│ │ └── collision proxies and landing reactions
|
||||
│ ├── Ragdoll (StickmanRagdoll)
|
||||
│ │ ├── procedural RigidBody2D + PinJoint2D graph
|
||||
│ │ ├── rest detection and momentum handoff
|
||||
│ │ └── capture, re-anchor, and stand-up recovery
|
||||
│ └── ActionRunner (StickmanActionRunner)
|
||||
│ ├── queued walk / speak / wait actions
|
||||
│ └── ragdoll and recovery requests
|
||||
├── Body/* mounted visuals, driven by RemoteTransform2D nodes
|
||||
├── Skeleton2D/Torso/* Bone2D chain and IK modifications
|
||||
├── IK_Targets/* six animation/recovery markers
|
||||
└── AnimationPlayer canonical walk clips and authored animation data
|
||||
```
|
||||
|
||||
`StickmanRig` owns cross-cutting state (`ANIMATED`, `RAGDOLL`, `RECOVERING`), facing profiles, bend flags, body z-order, and signal/API forwarding. `StickmanRagdoll` owns physics construction and recovery; `StickmanLocomotion` owns terrain/motion; `StickmanActionRunner` owns director queue execution. Component references are resolved and configured in `StickmanRig._ready()` and are null-guarded with warnings.
|
||||
|
||||
#### Ragdoll handoff and recovery
|
||||
|
||||
The runtime handoff preserves the current solved IK pose before physics takes ownership:
|
||||
|
||||
```text
|
||||
ANIMATED
|
||||
│ set_ragdoll(true)
|
||||
▼
|
||||
capture pre-ragdoll markers, bone rotations, facing, bend flags, knee sides
|
||||
│ build bodies from current bone endpoints; copy mounted Body/* visuals
|
||||
▼
|
||||
RAGDOLL ── rest + timeout ──▶ capture body pose + probe terrain
|
||||
│ destroy physics graph
|
||||
│ re-anchor root at captured landing feet
|
||||
▼
|
||||
RECOVERING ── tween markers/bones toward captured entry pose ──▶ ANIMATED
|
||||
```
|
||||
|
||||
The important dictionaries have these shapes (vector values are runtime `Vector2`s):
|
||||
|
||||
```gdscript
|
||||
_spawn_pose = {
|
||||
"Torso": {"pos": captured_torso_pos, "rot": captured_torso_rotation},
|
||||
"Head": {"pos": captured_head_tip, "rot": 0.0},
|
||||
"Left_Hand": {"pos": captured_left_wrist, "rot": 0.0},
|
||||
"Right_Hand": {"pos": captured_right_wrist, "rot": 0.0},
|
||||
"Left_Leg": {"pos": captured_left_ankle, "rot": 0.0},
|
||||
"Right_Leg": {"pos": captured_right_ankle, "rot": 0.0},
|
||||
}
|
||||
|
||||
_captured_pose["left_lower_leg"] = {
|
||||
"pos": rig_local_body_position,
|
||||
"rot": body_global_rotation - rig_global_rotation,
|
||||
"half": capsule_half_length,
|
||||
}
|
||||
```
|
||||
|
||||
`_spawn_pose` is initialized from the adapted skeleton by `StickmanFactory.spawn_from_data()` and is the placement ghost's authored pose; the values therefore depend on the loaded `.stk`, rather than on `STAND_POSE`. `_pre_ragdoll_state` is the recovery endpoint: it stores the six markers, limb/head rotations, facing profile, bend flags, head LookAt state, knee-side information, and chain-space offsets. `_captured_pose` is the fallen physics pose used to reconstruct the skeleton and landing position.
|
||||
|
||||
#### Stage mode flow and pose preservation
|
||||
|
||||
EDIT and DIRECT are two views of the same frozen, non-simulating world. The mode transition receives `previous_mode` so it can avoid an unnecessary reset:
|
||||
|
||||
```text
|
||||
EDIT ◄──────────────► DIRECT
|
||||
│ │
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
PLAY
|
||||
│
|
||||
set_mode(EDIT or DIRECT)
|
||||
│
|
||||
▼
|
||||
cleanup + restore spawn pose
|
||||
```
|
||||
|
||||
| Transition | Stage behavior | Rig pose behavior |
|
||||
|---|---|---|
|
||||
| EDIT → DIRECT | Hide edit controls; keep world frozen | Preserve exact live pose and IK markers |
|
||||
| DIRECT → EDIT | Restore edit controls; keep world frozen | Preserve exact live pose and IK markers |
|
||||
| EDIT/DIRECT → PLAY | Enable simulation, terrain following, and action queues | Begin from the current authored/editor pose |
|
||||
| PLAY → EDIT/DIRECT | Stop queues, disable terrain following, freeze physics, restore object transforms | `snap_to_standing()` restores the factory-captured `_spawn_pose` |
|
||||
|
||||
The root cause of the verified regression was that `snap_to_standing()` restored the legacy hardcoded `STAND_POSE`. That generic pose lowered the arms and rotated the head whenever PLAY cleanup ran. The central fix is to restore `_spawn_pose` when it exists, with `STAND_POSE` retained only as a compatibility fallback for rigs that have no captured spawn pose. Ragdoll recovery is separate: its stand-up tween still targets `_pre_ragdoll_state`, so recovery returns to the pose captured at ragdoll entry rather than the factory pose.
|
||||
|
||||
#### Facing, walk-phase bend stabilization, and head tracking
|
||||
|
||||
`FacingProfile.LEFT` is a whole-rig X mirror (`Master.scale.x = -1`); RIGHT and FORWARD use identity scale. Runtime walking always plays the canonical `walk_right` clip and mirrors it for LEFT. The profile still applies provisional per-joint bend flags and z-order tables. For side profiles, a walk keyframe can place a leg exactly straight, leaving PinJoint2D with no geometric knee side. The ragdoll component therefore:
|
||||
|
||||
- permits the straight hinge boundary (`0°`) and the intended fold range (`±150°`) for LEFT/RIGHT profiles;
|
||||
- adds only a small 3° profile-relative bias at the near-straight ragdoll handoff; and
|
||||
- checks each rendered RAGDOLL frame, correcting only an actual wrong-side knee crossing while preserving normal physical motion.
|
||||
|
||||
Head tracking is owned by the `SkeletonModification2DLookAt` targeting `IK_Targets/Head`. RIGHT/FORWARD enable it. LEFT disables LookAt and pins the canonical head rotation because the whole-rig mirror must remain mirror-invariant; this is intentional rather than a missing target update. The scene's authored head constraint is disabled so the active LookAt can follow moved markers without the old constraint interpretation pinning it.
|
||||
|
||||
#### Regression coverage
|
||||
|
||||
The completed implementation was checked with focused headless tests and the existing suites:
|
||||
|
||||
| Test | Result | Coverage |
|
||||
|---|---:|---|
|
||||
| `tests/test_phase8_rig_modularization.gd` | 23/23 | relocated scene, component ownership, head tracking, facing flags, ragdoll facade |
|
||||
| `tests/test_phase8_edit_direct_pose.gd` | 45/45 | EDIT↔DIRECT round trip and EDIT→PLAY→DIRECT pose/marker restoration |
|
||||
| `tests/test_phase8_ragdoll_walk_handoff.gd` | 80/80 | LEFT/RIGHT profiles across multiple walk animation phases and physics frames |
|
||||
| `tests/test_phase45_collision_bodies.gd` | 223/223 | collision proxies, reactions, and PLAY cleanup to authored pose |
|
||||
| `tests/test_phase3c_walk_recovery.gd` | 185/185 | walk, ragdoll, recovery, and action integration |
|
||||
| `tests/test_phase6a_airborne_landing.gd` | 24/24 | airborne pose, landing, and ragdoll cleanup |
|
||||
| `tests/test_phase4b_stage.gd` | 53/53 | stage mode UI/state transitions and pending-action cleanup |
|
||||
|
||||
The plan's original acceptance criteria remain the historical intent; this record captures the as-built ownership and the follow-up fixes verified after user testing.
|
||||
@@ -0,0 +1,151 @@
|
||||
**Phase 1: Prop Theme & Material Token Integration**
|
||||
|
||||
- **Task 1.1: Prop Design Token Schema**
|
||||
- Define a standalone JSON theme configuration (`prop_theme.json`) structuring design tokens specifically for prop editing (e.g., `outline_widths`, `material_tints`, `primitive_defaults`).
|
||||
|
||||
- Implement fallback hierarchy for default colors, line widths, and physics values.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- `prop_theme.json` parses successfully and loads default tokens on editor startup.
|
||||
|
||||
- Missing keys in custom prop files fall back gracefully without raising runtime errors.
|
||||
|
||||
- **Task 1.2: Material Preset Visual Utility**
|
||||
- Create helper logic mapping physics material presets (Wood, Rubber, Cardboard, Metal) directly to default mass, friction, bounce, and fill/outline color tints.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Selecting a material preset populates default mass, friction, restitution, and visual tint values.
|
||||
|
||||
- **Task 1.3: Prop Control Inspector Theme**
|
||||
- Apply standardized `.theme` styling to Prop Editor control widgets (`OptionButton`, `SpinBox`, `ColorPicker`, `Button`).
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Prop Editor controls match visual styling without mutating global GUI settings or sandbox scenes.
|
||||
|
||||
---
|
||||
|
||||
**Phase 2: Vector Geometry, Vertex Editing & Shape Manipulation**
|
||||
|
||||
- **Task 2.1: Multi-Shape Creation & Primitive Geometry Controls**
|
||||
- Implement UI controls to instantiate primitive shapes (Polygons, Circles, Lines) onto the active drawing surface.
|
||||
|
||||
- Support an internal shape stack array for multiple vector shapes per prop asset.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Adding a shape appends a new vector item to the shape stack array.
|
||||
|
||||
- Switching primitive modes exposes mode-specific geometry parameters (e.g., radius for circles, vertex lists for polygons).
|
||||
|
||||
- **Task 2.2: Direct Vertex Dragging & Reshaping**
|
||||
- Render interactive vertex handles on selected shapes.
|
||||
|
||||
- Implement left-click drag on vertex handles to reshape polygon geometry in real time.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Dragging a vertex handle updates the shape mesh visually during input.
|
||||
|
||||
- Releasing the mouse commits the updated vertex coordinates.
|
||||
|
||||
- **Task 2.3: Contextual Vertex Insertion & Deletion**
|
||||
- Right-clicking an outline segment displays a **"Create Point"** menu option to insert a vertex at the edge midpoint.
|
||||
|
||||
- Right-clicking an existing vertex handle displays a **"Remove Point"** option to delete the vertex.
|
||||
|
||||
- Handle degenerate edges gracefully.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Inserting a vertex adds a new point to the shape's point list at the targeted midpoint.
|
||||
|
||||
- Deleting a vertex removes the handle and closes the surrounding line segment.
|
||||
|
||||
- Shapes reduced below 2 vertices are removed from the canvas.
|
||||
|
||||
- **Task 2.4: Shape Selection, Context Menu & Z-Ordering**
|
||||
- Build a canvas hit-testing system to select shapes via left-click.
|
||||
|
||||
- Implement a right-click context menu offering Copy, Paste, Delete, Mirror X, Mirror Y, Bring Forward, and Send Back.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Left-clicking a shape highlights its bounding box and handles.
|
||||
|
||||
- Pressing `Delete`/`Backspace` or selecting "Delete" removes the targeted shape.
|
||||
|
||||
- "Bring Forward" and "Send Back" alter the target shape's draw order in the rendering stack.
|
||||
|
||||
- **Task 2.5: Translation, Rotation & Scaling Gizmos**
|
||||
- Implement direct click-and-drag translation handles on selected shape bounding boxes.
|
||||
|
||||
- Provide a rotation gizmo that snaps to **15-degree increments** when holding **Ctrl**.
|
||||
|
||||
- Provide corner scaling handles that lock **aspect ratio** uniform scaling while holding **Ctrl**.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Dragging the translation handle translates all vertices in unison.
|
||||
|
||||
- Holding **Ctrl** during rotation snaps rotation strictly to 15-degree steps.
|
||||
|
||||
- Holding **Ctrl** during scaling locks aspect ratio; releasing **Ctrl** allows non-uniform scaling.
|
||||
|
||||
- **Task 2.6: Shape Mirroring Engine**
|
||||
- Implement **Mirror X** and **Mirror Y** shape transformations relative to the shape's bounding-box center.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Triggering "Mirror X" inverts X-coordinates across the shape center; "Mirror Y" inverts Y-coordinates.
|
||||
|
||||
- **Task 2.7: In-Memory Shape Clipboard System**
|
||||
- Implement a dedicated shape clipboard module storing deep duplicates of shape property dictionaries.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Copying a shape stores its full parameter dictionary in memory.
|
||||
|
||||
- Pasting creates a duplicate shape offset from the original.
|
||||
|
||||
- The "Paste" context menu option is disabled when the clipboard is empty.
|
||||
|
||||
- **Task 2.8: Live Vector Canvas Synchronizer**
|
||||
- Wire UI input signals directly to `PropBlock` setters for real-time visual updates.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Modifying `fill_color`, `outline_color`, or `outline_width` reflects instantly on the active canvas.
|
||||
|
||||
- **Task 2.9: Point Sanitization Pipeline**
|
||||
- Pass edited polygon coordinates through `TerrainUtils.sanitize_points()`.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Committed geometry maintains clockwise vertex ordering and strips redundant collinear points.
|
||||
|
||||
---
|
||||
|
||||
**Phase 3: Physical Property Inspector & Styling Tools**
|
||||
|
||||
- **Task 3.1: Material Preset Picker**
|
||||
- Implement UI selectors for assigning `material_preset` values and displaying physics properties.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Changing material presets updates displayed physics values (mass, friction, restitution).
|
||||
|
||||
- **Task 3.2: Custom Color Swatch & Palette Pickers**
|
||||
- Build reusable visual color swatches for custom interior fill and outline stroke colors.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Selecting custom colors overrides preset colors and updates visual stroke/fill values.
|
||||
|
||||
---
|
||||
|
||||
**Phase 4: Prop Preview Rendering & Disk Caching**
|
||||
|
||||
- **Task 4.1: Prop SubViewport Thumbnail Generator**
|
||||
- Build an isolated `SubViewport` preview pipeline (`prop_thumbnail.gd`) to capture 2D textures.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- `prop_thumbnail.gd` renders off-screen prop geometry into a transparent 2D texture without interfering with main editor performance.
|
||||
|
||||
- **Task 4.2: Disk Caching Pipeline**
|
||||
- Implement local thumbnail export writing textures to `user://thumbnails/props/<id>.png`.
|
||||
|
||||
- Validate file timestamps to reload cached textures when up-to-date.
|
||||
|
||||
- _Acceptance Criteria:_
|
||||
- Saving a prop exports a 200x200 PNG thumbnail to `user://thumbnails/props/<id>.png`.
|
||||
|
||||
- The editor loads cached thumbnails from disk if source files are unchanged.
|
||||
@@ -0,0 +1,167 @@
|
||||
### Objective Summary
|
||||
|
||||
This revised plan details the addition of an **Attach** mode to the Prop Editor (`[ Arrange | Reshape | Attach ]`), introducing a flexible, **Role-Based Dynamic Anchor Architecture** for vector prop files (`.prop`). Instead of hardcoding weapon-centric anchors (like a fixed muzzle), anchors are defined as named dictionary entries assigned specific functional roles—such as `pivot`, `grip`, `emitter`, `impact`, or `mount`. Coupled with interactive canvas gizmos, a live semi-transparent Stickman Hand Preview silhouette, and `StickmanRig` IK attachment logic, this system allows any prop (from flashlights and magic staves to broadswords and rifles) to be held, flipped, and interacted with accurately across all facing profiles.
|
||||
|
||||
---
|
||||
|
||||
### 1. Dynamic Role-Based Anchor Data Architecture (`.prop` Schema)
|
||||
|
||||
The `.prop` JSON file stores anchors in a flexible dictionary where each key represents a unique anchor identifier and contains an explicit `"role"` attribute.
|
||||
|
||||
#### JSON Data Layout
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "Arcane Staff",
|
||||
"anchors": {
|
||||
"origin": {
|
||||
"role": "pivot",
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"primary_grip": {
|
||||
"role": "grip",
|
||||
"x": 0.0,
|
||||
"y": 18.0,
|
||||
"rotation_deg": 0.0,
|
||||
"z_index_offset": 1,
|
||||
"allowed_hands": "both"
|
||||
},
|
||||
"secondary_grip": {
|
||||
"role": "grip",
|
||||
"x": 0.0,
|
||||
"y": -12.0,
|
||||
"rotation_deg": 0.0,
|
||||
"z_index_offset": 1,
|
||||
"allowed_hands": "left_only"
|
||||
},
|
||||
"crystal_head": {
|
||||
"role": "emitter",
|
||||
"x": 0.0,
|
||||
"y": -48.0,
|
||||
"rotation_deg": -90.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Parameter Schema
|
||||
|
||||
- **`role`**: Enforced category enum (`pivot`, `grip`, `emitter`, `impact`, `mount`, `custom`) defining visual gizmo styling and runtime behavior.
|
||||
- **`x`, `y**`: Pixel offset relative to the prop’s root origin point (0,0).
|
||||
- **`rotation_deg`**: Angular rotation applied to the prop geometry when attached, aligning prop handles seamlessly with a stickman’s hand/wrist joint.
|
||||
- **`z_index_offset`**: Integer sorting shift (e.g., -1 or +1) controlling whether prop elements render behind or in front of the holding hand.
|
||||
- **`allowed_hands`**: Hand access permission (`both`, `left_only`, `right_only`) used during character interaction checks.
|
||||
|
||||
#### Standard Anchor Role Registry
|
||||
|
||||
- **`pivot`**: The mandatory root origin (0,0) used for spawning, ground collision, terrain placement, and linear/angular center-of-mass physics when thrown.
|
||||
- **`grip`**: Hand attachment targets for primary or secondary holding locations.
|
||||
- **`emitter`**: Action output coordinates for spell particles, flashlights/light cones, projectile spawns, spray nozzles, or laser beams.
|
||||
- **`impact`**: Point-of-contact coordinates for melee striking edges, spear points, or hammer heads.
|
||||
- **`mount`**: Auxiliary attachment locations for modular accessories, scopes, or vehicle mounts.
|
||||
|
||||
---
|
||||
|
||||
### 2. UI Integration & 'Attach' Mode Setup
|
||||
|
||||
#### Segmented Mode Switcher
|
||||
|
||||
- Expand the canvas toolbar mode control to three tabs: **`[ Arrange | Reshape | Attach ]`**.
|
||||
- Selecting **Attach** hides shape vertex handles and activates the anchor editing overlay.
|
||||
|
||||
#### Attach Mode Toolbar
|
||||
|
||||
- **Show Hand Overlay**: Toggle button to display/hide the semi-transparent stickman lower-arm and hand preview.
|
||||
- **Snap to Grid**: Toggle button for snapping anchor translation coordinates.
|
||||
- **Show Inactive Gizmos**: Toggle visibility for non-selected background anchors.
|
||||
|
||||
#### Anchor Management Panel (Sidebar Inspector)
|
||||
|
||||
- **Anchor List Control**:
|
||||
- Scrollable list showing all defined anchors with **Add Anchor**, **Delete**, and **Rename** buttons.
|
||||
- Ability to add dynamic custom anchors and assign them any standard role.
|
||||
|
||||
- **Property Fields**:
|
||||
- **Role Selector**: Dropdown menu (`Pivot`, `Grip`, `Emitter`, `Impact`, `Mount`, `Custom`).
|
||||
- **Position Inputs**: Numeric fields for X and Y coordinates with arrow incrementors.
|
||||
- **Rotation Slider**: Angle adjustment (-180° to +180°) with numeric spinbox.
|
||||
- **Z-Index Offset**: Integer field (-5 to +5) for layer sorting relative to hands.
|
||||
- **Allowed Hands**: Dropdown (`Both`, `Left Only`, `Right Only`).
|
||||
|
||||
- **Quick Preset Generator**: Buttons to instantly apply standard layouts (e.g., _One-Handed Grip_, _Two-Handed Weapon_, _Tool / Extinguisher Nozzle_, _Melee Edge_).
|
||||
|
||||
---
|
||||
|
||||
### 3. Canvas Visual Tools & Interactive Editing
|
||||
|
||||
#### Role-Based Gizmo Markers
|
||||
|
||||
Each anchor role renders with a distinct visual icon and color code on the canvas:
|
||||
|
||||
- **`pivot`**: Yellow Crosshair (locked to or editable near origin).
|
||||
- **`grip`**: Cyan / Orange Hand Icon (primary vs. secondary grips).
|
||||
- **`emitter`**: Red Target Reticle (shows direction vector arrow for particle/beam orientation).
|
||||
- **`impact`**: Bright Pink Diamond / Slash Marker (indicates tip or edge).
|
||||
- **`mount`**: Purple Ring Anchor.
|
||||
|
||||
#### Canvas Interaction Mechanics
|
||||
|
||||
- **Translation Dragging**: Click and drag any anchor gizmo across the viewport with full grid-snap support.
|
||||
- **Rotation Arc Ring**: Selecting a `grip` or `emitter` anchor displays a surrounding rotation ring handle. Dragging the ring dynamically dials in `rotation_deg` while updating the live inspector readout.
|
||||
|
||||
#### Stickman Hand Preview Silhouette Overlay
|
||||
|
||||
- **Visual Representation**: When enabled, draws a semi-transparent stickman lower-arm and hand silhouette attached directly to the currently selected `grip` anchor.
|
||||
- **Real-Time Alignment**:
|
||||
- The arm and hand rotate dynamically in response to `rotation_deg` adjustments.
|
||||
- The preview respects `z_index_offset`, rendering the hand over or under prop geometry to give immediate visual feedback.
|
||||
- Guarantees sword hilts, briefcase handles, and tool grips fit stickman hands before running gameplay tests.
|
||||
|
||||
---
|
||||
|
||||
### 4. Rig Compatibility & Runtime Behavior (`StickmanRig`)
|
||||
|
||||
- **IK Hand Attachment**:
|
||||
- `StickmanRig` queries the prop's `grip` anchors during pick-up or equip actions, snapping the wrist joint of `LeftLowerArm` or `RightLowerArm` to the exact anchor location.
|
||||
|
||||
- **Facing Profile Mirroring**:
|
||||
- When a character flips (e.g., facing left with `scale.x = -1`), the prop mirrors along with the rig.
|
||||
- Symmetrical anchor positioning ensures tools and weapons flip naturally without offset errors.
|
||||
|
||||
- **Action & Effect Dispatching**:
|
||||
- When firing, casting, or emitting light, runtime scripts search for anchors with the `emitter` role to instantiate particles, light nodes, or hitscan traces.
|
||||
- Melee collision scripts query `impact` anchors to detect high-velocity hit points.
|
||||
|
||||
- **Throwing Physics**:
|
||||
- Dropping or throwing a prop applies linear/angular velocity calculations relative to the `pivot` anchor position.
|
||||
|
||||
---
|
||||
|
||||
### 5. Implementation Roadmap
|
||||
|
||||
1. **Anchor Data Model & JSON Updates**:
|
||||
|
||||
- Refactor internal dictionary parsers to support role-based dynamic key schemas.
|
||||
- Update `.prop` saving and loading procedures to serialize custom anchor entries and their `role` tags.
|
||||
|
||||
2. **UI Switcher & Sidebar Panel**:
|
||||
|
||||
- Add the `Attach` tab to the primary mode control `[ Arrange | Reshape | Attach ]`.
|
||||
- Build the Anchor Management sidebar panel with role selectors, dynamic list controls, and property spinboxes.
|
||||
|
||||
3. **Canvas Rendering & Gizmo Input**:
|
||||
|
||||
- Implement canvas drawing routines for role-specific gizmos (`pivot`, `grip`, `emitter`, `impact`, `mount`).
|
||||
- Add gizmo click-picking, drag translation, and rotation ring interaction handles.
|
||||
|
||||
4. **Hand Preview Silhouette System**:
|
||||
|
||||
- Create the semi-transparent lower-arm and hand preview overlay attached to `grip` anchors.
|
||||
- Connect real-time rotation and Z-order sorting updates to the overlay renderer.
|
||||
|
||||
5. **Autosave, Thumbnail & Runtime Verification**:
|
||||
|
||||
- Hook anchor adjustments into editor dirty-state tracking, undo/redo stacks, and autosave routines.
|
||||
- Verify exported `.prop` files in the asset selector preview and validate runtime IK snapping on the test stage.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"outline_widths": { "default": 2.0, "min": 0.5, "max": 32.0 },
|
||||
"material_tints": {
|
||||
"wood": { "fill": "#8C6133", "outline": "#40290F" },
|
||||
"rubber": { "fill": "#E63333", "outline": "#590D0D" },
|
||||
"cardboard": { "fill": "#D9B873", "outline": "#66521D" },
|
||||
"metal": { "fill": "#808C99", "outline": "#262E38" },
|
||||
"none": { "fill": "#8C6133", "outline": "#26201E" }
|
||||
},
|
||||
"primitive_defaults": {
|
||||
"box": { "size_x": 48.0, "size_y": 48.0 },
|
||||
"ball": { "radius": 24.0 },
|
||||
"plank": { "length": 160.0, "thickness": 16.0 },
|
||||
"triangle": { "base": 56.0, "height": 48.0 },
|
||||
"line": { "length": 120.0 },
|
||||
"circle": { "radius": 40.0, "segments": 12 }
|
||||
},
|
||||
"canvas": {
|
||||
"grid_size": 15,
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26"
|
||||
},
|
||||
"ui": {
|
||||
"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",
|
||||
"inspector_background": "#262C39"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"anchors": {
|
||||
"origin": {
|
||||
"allowed_hands": "both",
|
||||
"role": "pivot",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z_index_offset": 0
|
||||
},
|
||||
"primary_grip": {
|
||||
"allowed_hands": "both",
|
||||
"role": "grip",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z_index_offset": 1
|
||||
}
|
||||
},
|
||||
"material_preset": "rubber",
|
||||
"metadata": {
|
||||
"created_at": "2026-09-22T00:26:21",
|
||||
"modified_at": "2026-09-22T00:26:21"
|
||||
},
|
||||
"prop_name": "Baseball",
|
||||
"shapes": [
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#F5F5F0FF",
|
||||
"outline_color": "#A0A0A0FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 25.1999988555908,
|
||||
"y": 0.0
|
||||
},
|
||||
{
|
||||
"x": 21.8231983184814,
|
||||
"y": 12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": 12.5999994277954,
|
||||
"y": 21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": 25.1999988555908
|
||||
},
|
||||
{
|
||||
"x": -12.5999994277954,
|
||||
"y": 21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": -21.8231983184814,
|
||||
"y": 12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": -25.1999988555908,
|
||||
"y": 0.0
|
||||
},
|
||||
{
|
||||
"x": -21.8231983184814,
|
||||
"y": -12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": -12.5999994277954,
|
||||
"y": -21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": -25.1999988555908
|
||||
},
|
||||
{
|
||||
"x": 12.5999994277954,
|
||||
"y": -21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 21.8231983184814,
|
||||
"y": -12.5999994277954
|
||||
}
|
||||
],
|
||||
"radius": 25.1996288299561,
|
||||
"reshaped": false,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": false,
|
||||
"fill_color": "#00000000",
|
||||
"outline_color": "#D62828FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": -8.64000034332275,
|
||||
"y": -20.1599998474121
|
||||
},
|
||||
{
|
||||
"x": -15.8400001525879,
|
||||
"y": -10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": -15.8400001525879,
|
||||
"y": 10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": -8.64000034332275,
|
||||
"y": 20.1599998474121
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "line",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": false,
|
||||
"fill_color": "#00000000",
|
||||
"outline_color": "#D62828FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 8.64000034332275,
|
||||
"y": -20.1599998474121
|
||||
},
|
||||
{
|
||||
"x": 15.8400001525879,
|
||||
"y": -10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": 15.8400001525879,
|
||||
"y": 10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": 8.64000034332275,
|
||||
"y": 20.1599998474121
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "line",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"material_preset": "none",
|
||||
"metadata": {
|
||||
"created_at": "2026-09-19T16:03:21",
|
||||
"modified_at": "2026-09-19T16:03:21"
|
||||
},
|
||||
"prop_name": "",
|
||||
"shapes": [
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "ffffffff",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 1258.0,
|
||||
"y": 499.732421875
|
||||
},
|
||||
{
|
||||
"x": 1230.26721191406,
|
||||
"y": 603.232421875
|
||||
},
|
||||
{
|
||||
"x": 1154.5,
|
||||
"y": 678.999694824219
|
||||
},
|
||||
{
|
||||
"x": 1051.0,
|
||||
"y": 706.732421875
|
||||
},
|
||||
{
|
||||
"x": 947.5,
|
||||
"y": 678.999694824219
|
||||
},
|
||||
{
|
||||
"x": 871.732727050781,
|
||||
"y": 603.232421875
|
||||
},
|
||||
{
|
||||
"x": 844.0,
|
||||
"y": 499.732421875
|
||||
},
|
||||
{
|
||||
"x": 871.732727050781,
|
||||
"y": 396.232421875
|
||||
},
|
||||
{
|
||||
"x": 947.5,
|
||||
"y": 371.465148925781
|
||||
},
|
||||
{
|
||||
"x": 1044.0,
|
||||
"y": 507.732421875
|
||||
},
|
||||
{
|
||||
"x": 1152.5,
|
||||
"y": 367.465148925781
|
||||
},
|
||||
{
|
||||
"x": 1230.26721191406,
|
||||
"y": 396.232421875
|
||||
}
|
||||
],
|
||||
"radius": 182.826955159505,
|
||||
"reshaped": true,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 1146.23413085938,
|
||||
"y": 564.821594238281
|
||||
},
|
||||
{
|
||||
"x": 1194.23413085938,
|
||||
"y": 564.821594238281
|
||||
},
|
||||
{
|
||||
"x": 1194.23413085938,
|
||||
"y": 612.821594238281
|
||||
},
|
||||
{
|
||||
"x": 1146.23413085938,
|
||||
"y": 612.821594238281
|
||||
}
|
||||
],
|
||||
"reshaped": false,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"anchors": {
|
||||
"origin": {
|
||||
"allowed_hands": "both",
|
||||
"role": "pivot",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z_index_offset": 0
|
||||
},
|
||||
"primary_grip": {
|
||||
"allowed_hands": "both",
|
||||
"role": "grip",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": -82.066291809082,
|
||||
"z_index_offset": 1
|
||||
}
|
||||
},
|
||||
"material_preset": "wood",
|
||||
"metadata": {
|
||||
"created_at": "2026-09-22T00:22:32",
|
||||
"modified_at": "2026-09-22T00:22:32"
|
||||
},
|
||||
"prop_name": "Brown Briefcase",
|
||||
"shapes": [
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#3D2314FF",
|
||||
"outline_color": "#26150CFF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": -48.2742919921875,
|
||||
"y": -57.9291458129883
|
||||
},
|
||||
{
|
||||
"x": -48.2742919921875,
|
||||
"y": -106.20344543457
|
||||
},
|
||||
{
|
||||
"x": 48.2742919921875,
|
||||
"y": -106.20344543457
|
||||
},
|
||||
{
|
||||
"x": 48.2742919921875,
|
||||
"y": -57.9291458129883
|
||||
},
|
||||
{
|
||||
"x": 34.7574882507324,
|
||||
"y": -57.9291458129883
|
||||
},
|
||||
{
|
||||
"x": 34.7574882507324,
|
||||
"y": -90.7556686401367
|
||||
},
|
||||
{
|
||||
"x": -34.7574882507324,
|
||||
"y": -90.7556686401367
|
||||
},
|
||||
{
|
||||
"x": -34.7574882507324,
|
||||
"y": -57.9291458129883
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#6D3C19FF",
|
||||
"outline_color": "#3B200AFF",
|
||||
"outline_width": 3.0,
|
||||
"points": [
|
||||
{
|
||||
"x": -135.168014526367,
|
||||
"y": -57.9291458129883
|
||||
},
|
||||
{
|
||||
"x": 135.168014526367,
|
||||
"y": -57.9291458129883
|
||||
},
|
||||
{
|
||||
"x": 135.168014526367,
|
||||
"y": 77.2388610839844
|
||||
},
|
||||
{
|
||||
"x": -135.168014526367,
|
||||
"y": 77.2388610839844
|
||||
}
|
||||
],
|
||||
"reshaped": false,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#D4AF37FF",
|
||||
"outline_color": "#8B7500FF",
|
||||
"outline_width": 1.5,
|
||||
"points": [
|
||||
{
|
||||
"x": -86.8937225341797,
|
||||
"y": -61.791088104248
|
||||
},
|
||||
{
|
||||
"x": -57.9291458129883,
|
||||
"y": -61.791088104248
|
||||
},
|
||||
{
|
||||
"x": -57.9291458129883,
|
||||
"y": -42.481372833252
|
||||
},
|
||||
{
|
||||
"x": -86.8937225341797,
|
||||
"y": -42.481372833252
|
||||
}
|
||||
],
|
||||
"reshaped": false,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#D4AF37FF",
|
||||
"outline_color": "#8B7500FF",
|
||||
"outline_width": 1.5,
|
||||
"points": [
|
||||
{
|
||||
"x": 57.9291458129883,
|
||||
"y": -61.791088104248
|
||||
},
|
||||
{
|
||||
"x": 86.8937225341797,
|
||||
"y": -61.791088104248
|
||||
},
|
||||
{
|
||||
"x": 86.8937225341797,
|
||||
"y": -42.481372833252
|
||||
},
|
||||
{
|
||||
"x": 57.9291458129883,
|
||||
"y": -42.481372833252
|
||||
}
|
||||
],
|
||||
"reshaped": false,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"material_preset": "none",
|
||||
"metadata": {
|
||||
"created_at": "2026-09-19T21:11:33",
|
||||
"modified_at": "2026-09-19T21:11:33"
|
||||
},
|
||||
"prop_name": "",
|
||||
"shapes": [
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "ffffffff",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 4.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 1294.0,
|
||||
"y": 556.848999023438
|
||||
},
|
||||
{
|
||||
"x": 1274.30578613281,
|
||||
"y": 630.348999023438
|
||||
},
|
||||
{
|
||||
"x": 1220.5,
|
||||
"y": 684.154724121094
|
||||
},
|
||||
{
|
||||
"x": 1147.0,
|
||||
"y": 703.848999023438
|
||||
},
|
||||
{
|
||||
"x": 1073.5,
|
||||
"y": 684.154724121094
|
||||
},
|
||||
{
|
||||
"x": 1019.69427490234,
|
||||
"y": 630.348999023438
|
||||
},
|
||||
{
|
||||
"x": 1000.0,
|
||||
"y": 556.848999023438
|
||||
},
|
||||
{
|
||||
"x": 1019.69427490234,
|
||||
"y": 483.348999023438
|
||||
},
|
||||
{
|
||||
"x": 1079.5,
|
||||
"y": 453.543273925781
|
||||
},
|
||||
{
|
||||
"x": 1152.0,
|
||||
"y": 455.848999023438
|
||||
},
|
||||
{
|
||||
"x": 1223.5,
|
||||
"y": 454.543273925781
|
||||
},
|
||||
{
|
||||
"x": 1274.30578613281,
|
||||
"y": 483.348999023438
|
||||
}
|
||||
],
|
||||
"radius": 139.647333145142,
|
||||
"reshaped": false,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "3b52bbff",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 1207.81555175781,
|
||||
"y": 569.486694335938
|
||||
},
|
||||
{
|
||||
"x": 1199.5625,
|
||||
"y": 600.287231445313
|
||||
},
|
||||
{
|
||||
"x": 1177.01501464844,
|
||||
"y": 622.834716796875
|
||||
},
|
||||
{
|
||||
"x": 1146.21447753906,
|
||||
"y": 631.087768554688
|
||||
},
|
||||
{
|
||||
"x": 1115.41394042969,
|
||||
"y": 622.834716796875
|
||||
},
|
||||
{
|
||||
"x": 1092.86645507813,
|
||||
"y": 600.287231445313
|
||||
},
|
||||
{
|
||||
"x": 1084.61340332031,
|
||||
"y": 569.486694335938
|
||||
},
|
||||
{
|
||||
"x": 1092.86645507813,
|
||||
"y": 538.686157226563
|
||||
},
|
||||
{
|
||||
"x": 1115.41394042969,
|
||||
"y": 516.138671875
|
||||
},
|
||||
{
|
||||
"x": 1146.21447753906,
|
||||
"y": 507.885620117188
|
||||
},
|
||||
{
|
||||
"x": 1177.01501464844,
|
||||
"y": 516.138671875
|
||||
},
|
||||
{
|
||||
"x": 1199.5625,
|
||||
"y": 538.686157226563
|
||||
}
|
||||
],
|
||||
"radius": 61.6010309855143,
|
||||
"reshaped": false,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "000000ff",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 1147.79870605469,
|
||||
"y": 526.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1160.27172851563,
|
||||
"y": 532.014404296875
|
||||
},
|
||||
{
|
||||
"x": 1169.40283203125,
|
||||
"y": 546.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1172.74487304688,
|
||||
"y": 566.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1169.40283203125,
|
||||
"y": 586.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1160.27185058594,
|
||||
"y": 601.29638671875
|
||||
},
|
||||
{
|
||||
"x": 1147.79870605469,
|
||||
"y": 606.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1135.32568359375,
|
||||
"y": 601.29638671875
|
||||
},
|
||||
{
|
||||
"x": 1126.19470214844,
|
||||
"y": 586.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1122.8525390625,
|
||||
"y": 566.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1126.19470214844,
|
||||
"y": 546.655395507813
|
||||
},
|
||||
{
|
||||
"x": 1135.32556152344,
|
||||
"y": 532.014404296875
|
||||
}
|
||||
],
|
||||
"radius": 32.9105350176493,
|
||||
"reshaped": false,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 966.386901855469,
|
||||
"y": 478.505645751953
|
||||
},
|
||||
{
|
||||
"x": 1051.41577148438,
|
||||
"y": 447.389190673828
|
||||
},
|
||||
{
|
||||
"x": 1146.44836425781,
|
||||
"y": 434.252014160156
|
||||
},
|
||||
{
|
||||
"x": 1260.01403808594,
|
||||
"y": 452.928924560547
|
||||
},
|
||||
{
|
||||
"x": 1342.70739746094,
|
||||
"y": 480.989318847656
|
||||
},
|
||||
{
|
||||
"x": 1286.82446289063,
|
||||
"y": 375.060638427734
|
||||
},
|
||||
{
|
||||
"x": 989.98193359375,
|
||||
"y": 375.060638427734
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "polygon",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
+9
-33
@@ -1,35 +1,11 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"action_popup_font_size": 24,
|
||||
"action_popup_emoji_size": 22,
|
||||
"assignment_badge_font_size": 20,
|
||||
"assignment_badge_radius": 9,
|
||||
"rule_label_font_size": 16,
|
||||
"status_pill_font_size": 16,
|
||||
"tooltip_font_size": 18,
|
||||
"ui_font_bold": "",
|
||||
"ui_font_italic": "",
|
||||
"queue_panel_font_size": 18,
|
||||
"rule_panel_font_size": 18,
|
||||
"action_editor_font_size": 18,
|
||||
"rule_editor_font_size": 18,
|
||||
"panel_row_font_size": 16,
|
||||
"panel_title_font_size": 18,
|
||||
"panel_title_bold": true,
|
||||
"rule_label_bold": false,
|
||||
"badge_bold": true,
|
||||
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||||
},
|
||||
"grid": {
|
||||
"snap_size": 15.0
|
||||
},
|
||||
"mode_colors": {
|
||||
"edit_accent": "#22c6ff",
|
||||
"direct_accent": "#ffb300",
|
||||
"play_accent": "#33dd77",
|
||||
"guide_line": "#22c6ff"
|
||||
}
|
||||
"version": "2.0",
|
||||
"global": { "font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8 },
|
||||
"fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
|
||||
"mode_colors": { "edit":"#22C6FF", "direct":"#FFB300", "play":"#33DD77", "guide":"#22C6FF" }, "grid": { "snap_size":15.0 },
|
||||
"chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
|
||||
"popups": { "style_defaults":{"bg_color":"#202633FA","border_color":"#FFFFFF33","corner_radius":12,"padding":12}, "action_menu":{"font_size":24}, "dialog":{}, "asset_selector":{"backdrop_alpha":0.55,"cell_size":[190,220],"thumbnail_size":[156,156]} },
|
||||
"palette": { "style_defaults":{"bg_color":"#252B38FA","corner_radius":10,"padding":6}, "dock_width":116, "card_size":[96,94], "icon_size":30, "drag_threshold":8, "selected_border_width":3 },
|
||||
"overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
|
||||
"widgets": { "swatches":["#FFFFFF","#111111","#22C6FF","#FFB300","#33DD77","#FF4D4D"], "size_presets":[{"id":"small","label":"Small","value":20},{"id":"medium","label":"Medium","value":28},{"id":"huge","label":"HUGE","value":40}], "emoji":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/prop_attach_preview.gd" id="1"]
|
||||
|
||||
[node name="PropAttachPreview" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(360, 0)
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,181 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/prop_editor.gd" id="1_editor_script"]
|
||||
[ext_resource type="Script" path="res://scripts/prop_canvas.gd" id="2_canvas_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_canvas"]
|
||||
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.3, 0.3, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[node name="PropEditor" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_editor_script")
|
||||
|
||||
[node name="TopRow" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 44.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="MenuBar" type="MenuBar" parent="TopRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="TopRightControls" type="HBoxContainer" parent="TopRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
offset_top = 44.0
|
||||
offset_bottom = 88.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="TopBar"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
|
||||
text = "Prop Name:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="PropNameEdit" type="LineEdit" parent="TopBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Unnamed Prop"
|
||||
|
||||
[node name="MainLayout" type="HSplitContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 92.0
|
||||
offset_bottom = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="CanvasPanel" type="PanelContainer" parent="MainLayout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
size_flags_stretch_ratio = 2.0
|
||||
custom_minimum_size = Vector2(260, 0)
|
||||
theme_override_styles/panel = SubResource("StyleBox_canvas")
|
||||
|
||||
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ModeBar" type="HBoxContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SizeTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="CanvasWorkspace" type="HSplitContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
split_offset = 520
|
||||
|
||||
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 0
|
||||
clip_contents = true
|
||||
script = ExtResource("2_canvas_script")
|
||||
|
||||
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(300, 0)
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="InspectorScroll" type="ScrollContainer" parent="MainLayout/InspectorColumn"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn/InspectorScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SaveDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Save Prop"
|
||||
size = Vector2i(600, 450)
|
||||
access = 2
|
||||
filters = PackedStringArray("*.prop ; Prop Files")
|
||||
|
||||
[node name="LoadDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Open a Prop"
|
||||
size = Vector2i(600, 450)
|
||||
ok_button_text = "Open"
|
||||
file_mode = 0
|
||||
access = 2
|
||||
filters = PackedStringArray("*.prop ; Prop Files")
|
||||
|
||||
[node name="ErrorDialog" type="AcceptDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Error"
|
||||
|
||||
[node name="StatusBar" type="HBoxContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchor_left = 0.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="SaveStatus" type="Label" parent="StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = ""
|
||||
horizontal_alignment = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CursorCoords" type="Label" parent="StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = ""
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
@@ -1,6 +1,10 @@
|
||||
[gd_scene format=3 uid="uid://dr5ef3l2var2s"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_locomotion.gd" id="2_locomotion"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_ragdoll.gd" id="3_ragdoll"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_action_runner.gd" id="4_runner"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_prop_interaction.gd" id="5_prop_interaction"]
|
||||
|
||||
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
||||
script/source = "@tool
|
||||
@@ -54,11 +58,7 @@ joint_two_bone2d_node = NodePath("Torso/LeftUpperLeg/LeftLowerLeg")
|
||||
bone_index = 1
|
||||
bone2d_node = NodePath("Torso/Head")
|
||||
target_nodepath = NodePath("../IK_Targets/Head")
|
||||
enable_constraint = true
|
||||
constraint_angle_min = -180.0
|
||||
constraint_angle_max = 180.0
|
||||
constraint_angle_invert = false
|
||||
constraint_in_localspace = false
|
||||
enable_constraint = false
|
||||
|
||||
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
||||
resource_local_to_scene = true
|
||||
@@ -325,6 +325,20 @@ _data = {
|
||||
[node name="Master" type="Node2D" unique_id=319103476]
|
||||
script = ExtResource("1_rig_script")
|
||||
|
||||
[node name="RigComponents" type="Node" parent="."]
|
||||
|
||||
[node name="Locomotion" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("2_locomotion")
|
||||
|
||||
[node name="Ragdoll" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("3_ragdoll")
|
||||
|
||||
[node name="ActionRunner" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("4_runner")
|
||||
|
||||
[node name="PropInteraction" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("5_prop_interaction")
|
||||
|
||||
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
||||
|
||||
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
||||
@@ -13,6 +13,7 @@ const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
signal committed(action: Dictionary)
|
||||
signal cancelled()
|
||||
signal target_requested()
|
||||
signal prop_requested()
|
||||
|
||||
var _mode: String = "new"
|
||||
var _initial: Dictionary = {}
|
||||
@@ -21,11 +22,17 @@ var _params_box: VBoxContainer = null
|
||||
var _text_edit: LineEdit = null
|
||||
var _duration_spin: SpinBox = null
|
||||
var _target_label: Label = null
|
||||
var _prop_target_label: Label = null
|
||||
var _target_prop_display_name: String = ""
|
||||
var _set_target_btn: Button = null
|
||||
var _stop_current_check: CheckBox = null
|
||||
var _show_reactive_options: bool = false
|
||||
var _allow_prop_actions: bool = true
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector2 = Vector2.ZERO
|
||||
var _target_prop_id: int = -1
|
||||
var _flight_time_spin: SpinBox = null
|
||||
var _hand_option: OptionButton = null
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
@@ -49,10 +56,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||
func open_new(show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_allow_prop_actions = not show_reactive_options
|
||||
_rebuild_type_options()
|
||||
_mode = "new"
|
||||
_initial = {}
|
||||
_has_target = false
|
||||
_target_pos = Vector2.ZERO
|
||||
_target_prop_id = -1
|
||||
_target_prop_display_name = ""
|
||||
_select_type("walk_to")
|
||||
_rebuild_params("walk_to")
|
||||
title = "Add Action"
|
||||
@@ -62,11 +73,15 @@ func open_new(show_reactive_options: bool = false) -> void:
|
||||
## Opens the editor pre-filled from a flat queue action.
|
||||
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_allow_prop_actions = not show_reactive_options
|
||||
_rebuild_type_options()
|
||||
_mode = "edit"
|
||||
_initial = action.duplicate(true)
|
||||
var type := String(action.get("type", "walk_to"))
|
||||
_has_target = action.has("target") and action["target"] is Vector2
|
||||
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
|
||||
_has_target = (action.has("target") and action["target"] is Vector2) or (action.has("target_position") and action["target_position"] is Vector2)
|
||||
_target_pos = action.get("target_position", action.get("target", Vector2.ZERO)) if _has_target else Vector2.ZERO
|
||||
_target_prop_id = int(action.get("target_prop", -1))
|
||||
_target_prop_display_name = ""
|
||||
_select_type(type)
|
||||
_rebuild_params(type)
|
||||
title = "Edit Action"
|
||||
@@ -80,6 +95,18 @@ func set_walk_target(pos: Vector2) -> void:
|
||||
_update_walk_target_label()
|
||||
popup_centered()
|
||||
|
||||
func set_prop_target(prop: PropBlock, display_name: String = "") -> void:
|
||||
_target_prop_id = prop.get_instance_id() if prop != null and is_instance_valid(prop) else -1
|
||||
_target_prop_display_name = display_name
|
||||
_update_prop_target_label()
|
||||
popup_centered()
|
||||
|
||||
func set_throw_target(pos: Vector2) -> void:
|
||||
_has_target = true
|
||||
_target_pos = pos
|
||||
_update_walk_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
@@ -100,7 +127,7 @@ func _build_ui() -> void:
|
||||
type_row.add_child(type_label)
|
||||
_type_option = OptionButton.new()
|
||||
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
for type: String in ACTION_REGISTRY.types():
|
||||
for type: String in _available_types():
|
||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
_type_option.item_selected.connect(_on_type_selected)
|
||||
type_row.add_child(_type_option)
|
||||
@@ -135,6 +162,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_base_font_size = int(sizes.get("action_editor", 18))
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
@@ -155,19 +185,42 @@ func _apply_font_to(c: Control, size: int) -> void:
|
||||
|
||||
|
||||
func _select_type(type: String) -> void:
|
||||
var idx := ACTION_REGISTRY.types().find(type)
|
||||
var idx := _available_types().find(type)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
_type_option.select(idx)
|
||||
|
||||
|
||||
func _current_type() -> String:
|
||||
var types := ACTION_REGISTRY.types()
|
||||
var types := _available_types()
|
||||
var idx := _type_option.selected
|
||||
if idx < 0 or idx >= types.size():
|
||||
return "walk_to"
|
||||
return types[idx]
|
||||
|
||||
func get_action_type() -> String:
|
||||
return _current_type()
|
||||
|
||||
func _available_types() -> Array[String]:
|
||||
var types: Array[String] = ACTION_REGISTRY.types()
|
||||
if not _allow_prop_actions:
|
||||
types.erase("pick_up_prop")
|
||||
types.erase("drop_prop")
|
||||
types.erase("throw_prop")
|
||||
return types
|
||||
|
||||
func _rebuild_type_options() -> void:
|
||||
if _type_option == null: return
|
||||
_type_option.clear()
|
||||
for type: String in _available_types():
|
||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
|
||||
func get_target_prop_id() -> int:
|
||||
return _target_prop_id
|
||||
|
||||
func get_flight_time() -> float:
|
||||
return _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||
|
||||
|
||||
func _on_type_selected(_index: int) -> void:
|
||||
_rebuild_params(_current_type())
|
||||
@@ -180,8 +233,11 @@ func _rebuild_params(type: String) -> void:
|
||||
_text_edit = null
|
||||
_duration_spin = null
|
||||
_target_label = null
|
||||
_prop_target_label = null
|
||||
_set_target_btn = null
|
||||
_stop_current_check = null
|
||||
_flight_time_spin = null
|
||||
_hand_option = null
|
||||
|
||||
match type:
|
||||
"walk_to":
|
||||
@@ -219,6 +275,24 @@ func _rebuild_params(type: String) -> void:
|
||||
_params_box.add_child(wlabel)
|
||||
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
||||
_params_box.add_child(_duration_spin)
|
||||
"pick_up_prop", "throw_prop":
|
||||
var prop_row := HBoxContainer.new(); _params_box.add_child(prop_row)
|
||||
_prop_target_label = Label.new(); _prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; prop_row.add_child(_prop_target_label)
|
||||
var prop_button := Button.new(); prop_button.text = "🧱 Click prop…"; prop_button.pressed.connect(func() -> void: prop_requested.emit()); prop_row.add_child(prop_button)
|
||||
_update_prop_target_label()
|
||||
if type == "pick_up_prop":
|
||||
var hand_label := Label.new(); hand_label.text = "Hand:"; _params_box.add_child(hand_label)
|
||||
_hand_option = OptionButton.new()
|
||||
for hand: String in ["auto", "left", "right"]: _hand_option.add_item(hand.capitalize())
|
||||
_hand_option.select(maxi(0, ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))))
|
||||
_params_box.add_child(_hand_option)
|
||||
if type == "throw_prop":
|
||||
var target_row := HBoxContainer.new(); _params_box.add_child(target_row)
|
||||
_target_label = Label.new(); _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; target_row.add_child(_target_label)
|
||||
var target_button := Button.new(); target_button.text = "🎯 Click landing point…"; target_button.pressed.connect(func() -> void: target_requested.emit()); target_row.add_child(target_button)
|
||||
_update_walk_target_label()
|
||||
var flight_label := Label.new(); flight_label.text = "Flight (s):"; _params_box.add_child(flight_label)
|
||||
_flight_time_spin = _make_duration_spin(float(_initial.get("flight_time", 0.9))); _flight_time_spin.min_value = 0.35; _flight_time_spin.max_value = 1.5; _params_box.add_child(_flight_time_spin)
|
||||
_:
|
||||
# ragdoll / recover (and any future no-param action) need no fields.
|
||||
pass
|
||||
@@ -243,6 +317,15 @@ func _update_walk_target_label() -> void:
|
||||
else:
|
||||
_target_label.text = "Target: not set"
|
||||
|
||||
func _update_prop_target_label() -> void:
|
||||
if _prop_target_label != null:
|
||||
if _target_prop_id < 0:
|
||||
_prop_target_label.text = "Prop: not set"
|
||||
elif not _target_prop_display_name.is_empty():
|
||||
_prop_target_label.text = "Prop: %s" % _target_prop_display_name
|
||||
else:
|
||||
_prop_target_label.text = "Prop: selected"
|
||||
|
||||
|
||||
func _on_ok_pressed() -> void:
|
||||
var type := _current_type()
|
||||
@@ -261,4 +344,11 @@ func _on_ok_pressed() -> void:
|
||||
action["stop_current"] = _stop_current_check.button_pressed
|
||||
"wait":
|
||||
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||
"pick_up_prop":
|
||||
if _target_prop_id < 0: prop_requested.emit(); return
|
||||
action["target_prop"] = _target_prop_id; action["hand"] = ["auto", "left", "right"][_hand_option.selected] if _hand_option != null else "auto"
|
||||
"throw_prop":
|
||||
if _target_prop_id < 0: prop_requested.emit(); return
|
||||
if not _has_target: target_requested.emit(); return
|
||||
action["target_prop"] = _target_prop_id; action["target_position"] = _target_pos; action["flight_time"] = _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||
committed.emit(action)
|
||||
|
||||
@@ -47,6 +47,9 @@ const ACTION_TEMPLATES := {
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
"pick_up_prop": { "label": "Pick Up Prop", "icon": "🖐️", "params": [{"key": "target_prop", "type": "prop", "required": true}, {"key": "hand", "type": "hand", "default": "auto"}], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false },
|
||||
"drop_prop": { "label": "Drop Prop", "icon": "⬇️", "params": [], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false },
|
||||
"throw_prop": { "label": "Throw Prop", "icon": "🎯", "params": [{"key": "target_prop", "type": "prop", "required": true}, {"key": "target_position", "type": "position", "required": true}, {"key": "flight_time", "type": "float", "default": 0.9}], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false },
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +105,10 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||
params["stop_current"] = true
|
||||
"wait":
|
||||
params["duration"] = float(action.get("duration", 0.0))
|
||||
"pick_up_prop":
|
||||
params["target_prop"] = int(action.get("target_prop", -1)); params["hand"] = String(action.get("hand", "auto"))
|
||||
"throw_prop":
|
||||
params["target_prop"] = int(action.get("target_prop", -1)); params["target_position"] = action.get("target_position", Vector2.ZERO); params["flight_time"] = float(action.get("flight_time", 0.9))
|
||||
return {
|
||||
"type": String(action.get("type", "")),
|
||||
"target": target_id,
|
||||
@@ -126,6 +133,9 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
||||
return speak
|
||||
"wait":
|
||||
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
||||
"pick_up_prop": return {"type": "pick_up_prop", "target_prop": int(params.get("target_prop", -1)), "hand": String(params.get("hand", "auto"))}
|
||||
"drop_prop": return {"type": "drop_prop"}
|
||||
"throw_prop": return {"type": "throw_prop", "target_prop": int(params.get("target_prop", -1)), "target_position": params.get("target_position", Vector2.ZERO), "flight_time": float(params.get("flight_time", 0.9))}
|
||||
_:
|
||||
return { "type": String(rule_action.get("type", "")) }
|
||||
|
||||
@@ -155,6 +165,9 @@ static func summarize(action: Dictionary) -> String:
|
||||
return "Ragdoll"
|
||||
"recover":
|
||||
return "Recover"
|
||||
"pick_up_prop": return "Pick Up Prop"
|
||||
"drop_prop": return "Drop Prop"
|
||||
"throw_prop": return "Throw Prop"
|
||||
_:
|
||||
return type
|
||||
|
||||
|
||||
+31
-43
@@ -10,8 +10,9 @@ const COLUMNS := 4
|
||||
const ROWS := 3
|
||||
const PAGE_SIZE := COLUMNS * ROWS
|
||||
|
||||
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
|
||||
const THUMB_SIZE := Vector2(120.0, 120.0)
|
||||
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
|
||||
const THUMB_SIZE := Vector2(156.0, 156.0)
|
||||
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
|
||||
|
||||
var kind: String = ""
|
||||
|
||||
@@ -22,6 +23,7 @@ var _cell_texrects: Dictionary = {}
|
||||
var _placeholder_tex: ImageTexture = null
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _sandbox_theme: Variant = null
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _grid: GridContainer = %GridContainer
|
||||
@@ -52,8 +54,9 @@ func _on_root_size_changed() -> void:
|
||||
func open(p_kind: String, entries: Array[Dictionary]) -> void:
|
||||
kind = p_kind
|
||||
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
|
||||
_browse_button.visible = kind == "stickman"
|
||||
_refresh_button.visible = kind == "stickman"
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop"
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop"
|
||||
_empty_label.text = "No props found. Browse for a .prop file." if kind == "prop" else "No stickmen found."
|
||||
set_entries(entries)
|
||||
popup_centered()
|
||||
|
||||
@@ -87,6 +90,21 @@ func apply_font(ui_font: Font, emoji_font: Font) -> void:
|
||||
for c: Control in controls:
|
||||
_apply_font_to(c)
|
||||
|
||||
func apply_theme(theme: Variant) -> void:
|
||||
_sandbox_theme = theme
|
||||
if theme != null:
|
||||
self.theme = theme.control_theme()
|
||||
add_theme_stylebox_override("panel", theme.stylebox("popups.asset_selector"))
|
||||
var cell_size: Variant = theme.value("popups.asset_selector.cell_size", CELL_MIN_SIZE)
|
||||
if cell_size is Array and (cell_size as Array).size() == 2:
|
||||
_cell_min_size = Vector2(float(cell_size[0]), float(cell_size[1]))
|
||||
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
|
||||
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
|
||||
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
|
||||
|
||||
var _cell_min_size: Vector2 = CELL_MIN_SIZE
|
||||
var _thumbnail_size: Vector2 = THUMB_SIZE
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if visible and event is InputEventKey:
|
||||
@@ -152,46 +170,16 @@ func _rebuild() -> void:
|
||||
|
||||
func _build_cell(entry: Dictionary) -> Control:
|
||||
var key := _entry_key(entry)
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = CELL_MIN_SIZE
|
||||
cell.toggle_mode = false
|
||||
var cell: Variant = ASSET_CARD.new()
|
||||
# Selector cells inherit the popup theme. Avoid a per-card state override so
|
||||
# opening a selector never implies that one asset is pre-selected.
|
||||
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
|
||||
cell.custom_minimum_size = _cell_min_size
|
||||
cell.focus_mode = Control.FOCUS_NONE
|
||||
cell.pressed.connect(_on_cell_pressed.bind(entry))
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vbox.add_theme_constant_override("separation", 2)
|
||||
cell.add_child(vbox)
|
||||
|
||||
var tex_rect := TextureRect.new()
|
||||
tex_rect.custom_minimum_size = THUMB_SIZE
|
||||
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
|
||||
var tex: Texture2D = _thumbnails.get(key, null)
|
||||
tex_rect.texture = tex if tex != null else _get_placeholder()
|
||||
vbox.add_child(tex_rect)
|
||||
_cell_texrects[key] = tex_rect
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = String(entry.get("name", ""))
|
||||
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
name_label.add_theme_font_size_override("font_size", 14)
|
||||
_apply_font_to(name_label)
|
||||
vbox.add_child(name_label)
|
||||
|
||||
if kind == "prop":
|
||||
var badge := Label.new()
|
||||
badge.text = String(entry.get("material_label", ""))
|
||||
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
badge.add_theme_font_size_override("font_size", 12)
|
||||
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
||||
_apply_font_to(badge)
|
||||
vbox.add_child(badge)
|
||||
cell.set_thumbnail(tex if tex != null else _get_placeholder())
|
||||
_cell_texrects[key] = cell.thumbnail_rect()
|
||||
|
||||
return cell
|
||||
|
||||
@@ -211,7 +199,7 @@ func _apply_font_to(c: Control) -> void:
|
||||
|
||||
func _get_placeholder() -> ImageTexture:
|
||||
if _placeholder_tex == null:
|
||||
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8)
|
||||
var img := Image.create(int(_thumbnail_size.x), int(_thumbnail_size.y), false, Image.FORMAT_RGBA8)
|
||||
img.fill(Color(0.16, 0.16, 0.16, 1.0))
|
||||
_placeholder_tex = ImageTexture.create_from_image(img)
|
||||
return _placeholder_tex
|
||||
|
||||
@@ -25,7 +25,7 @@ const POSE_PATHS: Dictionary = {
|
||||
const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
|
||||
|
||||
## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose;
|
||||
## POSE_STANDING matches master_rig.tscn's authored IK-target defaults. The
|
||||
## POSE_STANDING matches scenes/stickman_rig.tscn's authored IK-target defaults. The
|
||||
## baked stand_up is an authored reference only — runtime recovery tweens the
|
||||
## IK targets directly from the captured ragdoll pose instead.
|
||||
const POSE_DOWN: Dictionary = {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name MissingPropPlaceholder
|
||||
extends Node2D
|
||||
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_prop_path: String = ""
|
||||
const MARKER_SIZE := Vector2(180.0, 54.0)
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
original_entry = entry.duplicate(true)
|
||||
missing_prop_path = String(entry.get("asset_id", ""))
|
||||
queue_redraw()
|
||||
|
||||
func get_area_rect() -> Rect2:
|
||||
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := get_area_rect()
|
||||
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
|
||||
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
|
||||
var asset_name := missing_prop_path.get_file()
|
||||
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 33.0), "Missing prop - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://iwogmwpek8cc
|
||||
@@ -3,7 +3,7 @@ extends Node2D
|
||||
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
|
||||
##
|
||||
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
|
||||
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
|
||||
## factory, spawns a scenes/stickman_rig.tscn instance standing on the flat ground, and
|
||||
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
|
||||
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
|
||||
## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn.
|
||||
@@ -16,7 +16,7 @@ const MIN_ZOOM: float = 0.25
|
||||
const MAX_ZOOM: float = 3.0
|
||||
const ZOOM_STEP: float = 1.10
|
||||
|
||||
const RIG_SCENE := preload("res://master_rig.tscn")
|
||||
const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
|
||||
|
||||
## Preloaded prop scripts: resolved via preload (not the global class registry)
|
||||
## so the harness compiles even when the editor's class cache is stale.
|
||||
@@ -251,7 +251,7 @@ func _build_environment() -> void:
|
||||
func _spawn_rig() -> void:
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
if rig == null:
|
||||
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
||||
push_warning("PhysicsTestHarness: failed to instantiate scenes/stickman_rig.tscn.")
|
||||
return
|
||||
rig.position = RIG_SPAWN_POSITION
|
||||
# Manual recovery only ("Recover Now"): auto-recover is left off so the
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
class_name PropAnchorData
|
||||
extends RefCounted
|
||||
## Normalized, JSON-safe prop-anchor model shared by authoring tools.
|
||||
|
||||
const ORIGIN_ID: String = "origin"
|
||||
const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"]
|
||||
const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"]
|
||||
enum Hand { LEFT, RIGHT }
|
||||
|
||||
static func default_anchors() -> Dictionary:
|
||||
return {ORIGIN_ID: _origin()}
|
||||
|
||||
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if raw is Dictionary:
|
||||
for raw_id: Variant in (raw as Dictionary):
|
||||
if not (raw as Dictionary)[raw_id] is Dictionary:
|
||||
if warn: push_warning("PropAnchorData: skipped malformed anchor.")
|
||||
continue
|
||||
var id: String = normalize_id(str(raw_id), result)
|
||||
if id == ORIGIN_ID:
|
||||
continue
|
||||
var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary
|
||||
var role: String = str(source.get("role", "custom")).to_lower()
|
||||
if not ROLE_STRINGS.has(role) or role == "pivot":
|
||||
if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.")
|
||||
role = "custom"
|
||||
var hand: String = str(source.get("allowed_hands", "both")).to_lower()
|
||||
if not ALLOWED_HAND_STRINGS.has(hand): hand = "both"
|
||||
result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand}
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return result.duplicate(true)
|
||||
|
||||
static func serialize_anchors(anchors: Dictionary) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
var result: Dictionary = {}
|
||||
for id: String in normalized:
|
||||
var anchor: Dictionary = normalized[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))}
|
||||
return result
|
||||
|
||||
static func editable_ids(anchors: Dictionary) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if id != ORIGIN_ID:
|
||||
result.append(id)
|
||||
return result
|
||||
|
||||
static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if preferred_id != ORIGIN_ID and normalized.has(preferred_id):
|
||||
return preferred_id
|
||||
if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip":
|
||||
return "primary_grip"
|
||||
for id: String in editable_ids(normalized):
|
||||
if str(normalized[id].get("role", "")) == "grip":
|
||||
return id
|
||||
var ids: Array[String] = editable_ids(normalized)
|
||||
return ids[0] if not ids.is_empty() else ""
|
||||
|
||||
static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
push_warning("PropAnchorData: ignored non-finite anchor translation.")
|
||||
return normalized
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
anchor["position"] = anchor.get("position", Vector2.ZERO) + delta
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return normalized
|
||||
|
||||
static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos; result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary:
|
||||
if not is_finite(angle_rad): return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad)
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos
|
||||
anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad))
|
||||
result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
|
||||
## Returns a fully normalized scaled copy, or {"ok": false} without changing
|
||||
## the caller's data when the operation cannot be represented safely.
|
||||
static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary:
|
||||
if not is_finite(factor) or factor <= 0.0:
|
||||
return {"ok": false, "anchors": {}}
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor
|
||||
if not is_finite(position.x) or not is_finite(position.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = position
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": normalized}
|
||||
|
||||
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
|
||||
var id: String = candidate.strip_edges().to_lower()
|
||||
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
|
||||
id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_")
|
||||
if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor"
|
||||
id = id.substr(0, 32)
|
||||
if id == ORIGIN_ID and not anchors.has(id): return id
|
||||
var base: String = id; var suffix: int = 2
|
||||
while anchors.has(id) or id == ORIGIN_ID:
|
||||
id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1
|
||||
return id
|
||||
|
||||
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id)
|
||||
return ids
|
||||
|
||||
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in ids_for_role(anchors, "grip"):
|
||||
var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both"))
|
||||
if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id)
|
||||
return result
|
||||
|
||||
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D:
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0)))
|
||||
if mirror_x: position.x = -position.x; angle = PI - angle
|
||||
return Transform2D(angle, position)
|
||||
|
||||
static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D:
|
||||
var effective: Transform2D = local_transform(anchor, mirror_x)
|
||||
var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001))
|
||||
var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale)
|
||||
basis.origin = target_transform.origin - basis.basis_xform(effective.origin)
|
||||
return basis
|
||||
|
||||
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary:
|
||||
var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO
|
||||
var horizontal: bool = bounds.size.x >= bounds.size.y
|
||||
var length: float = bounds.size.x if horizontal else bounds.size.y
|
||||
var angle: float = 0.0 if horizontal else 90.0
|
||||
var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y)
|
||||
var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0)))
|
||||
var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
var out: Dictionary = default_anchors()
|
||||
match preset:
|
||||
"one_handed": out["primary_grip"] = make.call("grip", center)
|
||||
"two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset)
|
||||
"tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint)
|
||||
"melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint)
|
||||
return out
|
||||
|
||||
static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
static func _finite(value: Variant, x: bool = true) -> float:
|
||||
var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value))
|
||||
return n if is_finite(n) else 0.0
|
||||
static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhlmhegchc1cu
|
||||
@@ -0,0 +1,101 @@
|
||||
class_name PropAnchorOverlay
|
||||
extends RefCounted
|
||||
|
||||
signal anchors_changed(anchors: Dictionary)
|
||||
signal anchor_selected(anchor_id: String)
|
||||
|
||||
enum DragMode { NONE, TRANSLATE, ROTATE }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const MARKER_RADIUS: float = 9.0
|
||||
const HIT_RADIUS: float = 15.0
|
||||
const ROTATE_OFFSET: float = 28.0
|
||||
const ROTATION_DOT_RADIUS: float = 5.0
|
||||
const ROTATION_DOT_HIT_RADIUS: float = 12.0
|
||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||
var _selected_id: String = ""
|
||||
var _show_inactive: bool = true
|
||||
var _drag_mode: DragMode = DragMode.NONE
|
||||
var _drag_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
func set_anchor_data(data: Variant) -> void:
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(data)
|
||||
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
|
||||
|
||||
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
|
||||
func select_anchor(anchor_id: String) -> void:
|
||||
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
|
||||
if next_id != _selected_id:
|
||||
_selected_id = next_id
|
||||
anchor_selected.emit(_selected_id)
|
||||
func get_selected_anchor_id() -> String: return _selected_id
|
||||
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
|
||||
|
||||
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if not _visible(id): continue
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
|
||||
return ""
|
||||
|
||||
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
|
||||
# The dot is intentionally outside the marker hit target, so test it before
|
||||
# ordinary anchor selection. This matches the shape transform gizmo model.
|
||||
if not _selected_id.is_empty() and _anchors.has(_selected_id):
|
||||
var selected: Dictionary = _anchors[_selected_id]
|
||||
var role: String = str(selected.get("role", "custom"))
|
||||
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
|
||||
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
|
||||
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
|
||||
_drag_mode = DragMode.ROTATE
|
||||
return true
|
||||
var id: String = hit_test_anchor(world_pos, zoom)
|
||||
if id.is_empty(): return false
|
||||
select_anchor(id)
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var role: String = str(anchor.get("role", "custom"))
|
||||
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
|
||||
return true
|
||||
|
||||
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
|
||||
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
|
||||
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
|
||||
if _drag_mode == DragMode.TRANSLATE:
|
||||
var pos: Vector2 = world_pos + _drag_offset
|
||||
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
|
||||
anchor["position"] = pos
|
||||
else:
|
||||
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
|
||||
if ctrl: angle = snappedf(angle, 15.0)
|
||||
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
|
||||
_anchors[_selected_id] = anchor
|
||||
anchors_changed.emit(get_anchor_data())
|
||||
|
||||
func end_pointer() -> void:
|
||||
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
|
||||
_drag_mode = DragMode.NONE
|
||||
|
||||
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
|
||||
|
||||
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
|
||||
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
|
||||
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
|
||||
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
|
||||
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
|
||||
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
|
||||
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
|
||||
if role == "grip" or role == "emitter":
|
||||
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
|
||||
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
|
||||
if selected:
|
||||
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
|
||||
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
|
||||
canvas.draw_line(pos, dot, color, 2.0 / zoom)
|
||||
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
|
||||
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
|
||||
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj2xh11d1wk7j
|
||||
@@ -0,0 +1,548 @@
|
||||
class_name PropAttachPreview
|
||||
extends PanelContainer
|
||||
## Private editor visualization. It never creates a PropBlock or touches Sandbox state.
|
||||
|
||||
enum PreviewHand { LEFT, RIGHT }
|
||||
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const StickmanFactoryScript := preload("res://scripts/stickman_factory.gd")
|
||||
const StkRigAdapterScript := preload("res://scripts/stk_rig_adapter.gd")
|
||||
const RigScene := preload("res://scenes/stickman_rig.tscn")
|
||||
const RIG_CENTER := Vector2(340.0, 575.0)
|
||||
const TARGET_RADIUS := 14.0
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _viewport_container: SubViewportContainer
|
||||
var _world: Node2D
|
||||
var _rig_root: Node2D
|
||||
var _rig: StickmanRig
|
||||
var _prop: Node2D
|
||||
var _target_marker: Polygon2D
|
||||
var _camera: Camera2D
|
||||
var _hint: Label
|
||||
var _walk_playback_button: Button
|
||||
var _reset_arm_button: Button
|
||||
var _reset_view_button: Button
|
||||
var _hand_option: OptionButton
|
||||
var _motion_option: OptionButton
|
||||
var _shapes: Array[Dictionary] = []
|
||||
var _anchors: Dictionary = {}
|
||||
var _selected: String = ""
|
||||
var _hand: int = PreviewHand.RIGHT
|
||||
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
|
||||
var _walk_paused: bool = true
|
||||
var _manual_arm_override: bool = false
|
||||
# Retained only for old test harnesses; Phase 9c UI uses _walk_paused.
|
||||
var _animate: bool = false
|
||||
var _dragging_target: bool = false
|
||||
var _camera_dragging: bool = false
|
||||
var _last_pointer := Vector2.ZERO
|
||||
var _hand_target_rests_local: Dictionary = {}
|
||||
var _camera_zoom := 1.0
|
||||
var _fitted_zoom := 1.0
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size.x = 360.0
|
||||
var surface := StyleBoxFlat.new()
|
||||
surface.bg_color = Color(0.075, 0.09, 0.12, 1.0)
|
||||
surface.set_border_width_all(0)
|
||||
surface.set_corner_radius_all(5)
|
||||
add_theme_stylebox_override("panel", surface)
|
||||
_build_ui()
|
||||
_build_private_world()
|
||||
_reframe_camera()
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layout := VBoxContainer.new()
|
||||
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_child(layout)
|
||||
var header := FlowContainer.new()
|
||||
layout.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Holding Preview"
|
||||
title.custom_minimum_size.x = 132.0
|
||||
header.add_child(title)
|
||||
_hand_option = OptionButton.new()
|
||||
_hand_option.name = "HandOption"
|
||||
_hand_option.add_item("Left Hand", PreviewHand.LEFT)
|
||||
_hand_option.add_item("Right Hand", PreviewHand.RIGHT)
|
||||
_hand_option.select(PreviewHand.RIGHT)
|
||||
_hand_option.item_selected.connect(set_preview_hand)
|
||||
header.add_child(_hand_option)
|
||||
_motion_option = OptionButton.new()
|
||||
_motion_option.name = "MotionOption"
|
||||
_motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE)
|
||||
_motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT)
|
||||
_motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT)
|
||||
_motion_option.item_selected.connect(set_preview_motion)
|
||||
header.add_child(_motion_option)
|
||||
_hint = Label.new()
|
||||
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
layout.add_child(_hint)
|
||||
_viewport_container = SubViewportContainer.new()
|
||||
# The pane keeps its 360 px width, but its height must yield to the editor's
|
||||
# fixed name/status rows when Attach mode makes the preview visible.
|
||||
_viewport_container.custom_minimum_size = Vector2(360.0, 0.0)
|
||||
_viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_viewport_container.stretch = true
|
||||
_viewport_container.gui_input.connect(_on_viewport_gui_input)
|
||||
layout.add_child(_viewport_container)
|
||||
var footer := FlowContainer.new()
|
||||
layout.add_child(footer)
|
||||
_walk_playback_button = Button.new()
|
||||
_walk_playback_button.name = "WalkPlaybackButton"
|
||||
_walk_playback_button.pressed.connect(toggle_walk_playback)
|
||||
footer.add_child(_walk_playback_button)
|
||||
_reset_arm_button = Button.new()
|
||||
_reset_arm_button.text = "Reset Arm"
|
||||
_reset_arm_button.pressed.connect(reset_arm_pose)
|
||||
footer.add_child(_reset_arm_button)
|
||||
_reset_view_button = Button.new()
|
||||
_reset_view_button.text = "Reset View"
|
||||
_reset_view_button.pressed.connect(_reframe_camera)
|
||||
footer.add_child(_reset_view_button)
|
||||
_apply_hit_target_metrics(36.0)
|
||||
|
||||
func set_ui_hit_target_height(height: float) -> void:
|
||||
_apply_hit_target_metrics(maxf(height, 36.0))
|
||||
|
||||
func _apply_hit_target_metrics(height: float) -> void:
|
||||
for option: OptionButton in [_hand_option, _motion_option]:
|
||||
if option != null:
|
||||
option.custom_minimum_size = Vector2(88.0, height)
|
||||
option.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
option.focus_mode = Control.FOCUS_NONE
|
||||
for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]:
|
||||
if button != null:
|
||||
button.custom_minimum_size = Vector2(112.0, height)
|
||||
button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
|
||||
func _build_private_world() -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.name = "PreviewViewport"
|
||||
_viewport.transparent_bg = false
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_viewport_container.add_child(_viewport)
|
||||
_viewport_container.resized.connect(_resize_viewport)
|
||||
_world = Node2D.new()
|
||||
_world.name = "PreviewWorld"
|
||||
_viewport.add_child(_world)
|
||||
_rig_root = Node2D.new()
|
||||
_rig_root.name = "PreviewRigRoot"
|
||||
_rig_root.position = RIG_CENTER
|
||||
_world.add_child(_rig_root)
|
||||
_rig = RigScene.instantiate() as StickmanRig
|
||||
_rig.set_physics_process(false)
|
||||
_rig_root.add_child(_rig)
|
||||
var data: Dictionary = StickmanFactoryScript.load_stk("res://stickmen/test.stk")
|
||||
if not data.is_empty():
|
||||
StkRigAdapterScript.apply(data, _rig)
|
||||
_prop = Node2D.new()
|
||||
_prop.name = "PreviewProp"
|
||||
_world.add_child(_prop)
|
||||
_target_marker = Polygon2D.new()
|
||||
_target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)])
|
||||
_target_marker.color = Color(0.25, 0.9, 1.0, 0.85)
|
||||
_world.add_child(_target_marker)
|
||||
_camera = Camera2D.new()
|
||||
_camera.name = "PreviewCamera"
|
||||
_world.add_child(_camera)
|
||||
_camera.make_current()
|
||||
_disable_collisions(_rig)
|
||||
call_deferred("_finish_rig_setup")
|
||||
|
||||
func _finish_rig_setup() -> void:
|
||||
var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if player != null:
|
||||
player.stop()
|
||||
_rig.stop_queue()
|
||||
_rig.clear_queue()
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null:
|
||||
_hand_target_rests_local[hand] = target.position
|
||||
_apply_motion_pose()
|
||||
_update_target_marker()
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void:
|
||||
_shapes = shapes.duplicate(true)
|
||||
_anchors = anchors.duplicate(true)
|
||||
_selected = selected_anchor_id
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_selected_anchor(anchor_id: String) -> void:
|
||||
_selected = anchor_id
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_hand(hand: int) -> void:
|
||||
_hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose()
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_motion(motion: int) -> void:
|
||||
_motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose(true)
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
# Compatibility facade for Phase 9a callers. The visible Facing control was
|
||||
# removed in 9c; callers now map to the equivalent preview motion.
|
||||
func set_facing_profile(profile: int) -> void:
|
||||
if profile == StickmanRig.FacingProfile.LEFT:
|
||||
set_preview_motion(PreviewMotion.WALK_LEFT)
|
||||
elif profile == StickmanRig.FacingProfile.RIGHT:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
else:
|
||||
set_preview_motion(PreviewMotion.FRONT_POSE)
|
||||
|
||||
func set_walk_paused(paused: bool) -> void:
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_walk_paused = true
|
||||
_update_walk_button()
|
||||
return
|
||||
var player := _animation_player()
|
||||
_walk_paused = paused
|
||||
if player != null:
|
||||
if paused:
|
||||
player.pause()
|
||||
else:
|
||||
player.play()
|
||||
_manual_arm_override = false if not paused else _manual_arm_override
|
||||
_update_walk_button()
|
||||
|
||||
func toggle_walk_playback() -> void:
|
||||
set_walk_paused(not _walk_paused)
|
||||
|
||||
func set_arm_animation_enabled(enabled: bool) -> void:
|
||||
_animate = enabled
|
||||
if enabled and _motion == PreviewMotion.FRONT_POSE:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
elif not enabled:
|
||||
set_walk_paused(true)
|
||||
|
||||
func reset_arm_pose() -> void:
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
var player := _animation_player()
|
||||
if player != null:
|
||||
player.seek(player.current_animation_position, true)
|
||||
else:
|
||||
_restore_standing_targets()
|
||||
_manual_arm_override = false
|
||||
_animate = false
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func is_preview_available() -> bool:
|
||||
return _rig != null and is_instance_valid(_rig) and _viewport != null
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_preview_available():
|
||||
return
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func _animation_player() -> AnimationPlayer:
|
||||
return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null
|
||||
|
||||
func _restore_standing_targets() -> void:
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null and _hand_target_rests_local.has(hand):
|
||||
target.position = _hand_target_rests_local[hand]
|
||||
|
||||
func _apply_motion_pose(restart: bool = false) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var player := _animation_player()
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
_rig_root.scale = Vector2.ONE
|
||||
if player != null: player.stop(true)
|
||||
_restore_standing_targets()
|
||||
_walk_paused = true
|
||||
else:
|
||||
var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT
|
||||
_rig.set_facing_profile(profile)
|
||||
_rig.scale = Vector2.ONE
|
||||
_rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE
|
||||
if player != null:
|
||||
if restart or player.current_animation != "walk_right":
|
||||
player.play("walk_right")
|
||||
player.seek(0.0, true)
|
||||
else:
|
||||
player.play()
|
||||
_walk_paused = false
|
||||
_enable_arm_ik()
|
||||
_update_walk_button()
|
||||
|
||||
func _update_walk_button() -> void:
|
||||
if _walk_playback_button == null:
|
||||
return
|
||||
var front: bool = _motion == PreviewMotion.FRONT_POSE
|
||||
_walk_playback_button.disabled = front
|
||||
_walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk"
|
||||
_walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else ""
|
||||
|
||||
func _hand_target() -> Marker2D:
|
||||
return _target_for(_hand)
|
||||
|
||||
func _target_for(hand: int) -> Marker2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D
|
||||
|
||||
func _lower_arm() -> Bone2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D
|
||||
|
||||
func _solved_wrist_transform() -> Transform2D:
|
||||
var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D
|
||||
var lower := _lower_arm()
|
||||
if lower == null or lower_visual == null:
|
||||
return Transform2D.IDENTITY
|
||||
var elbow := lower_visual.to_global(Vector2.ZERO)
|
||||
var wrist := lower_visual.to_global(Vector2(0.0, lower.length))
|
||||
var direction := (wrist - elbow).normalized()
|
||||
if direction.length_squared() < 0.0001:
|
||||
return Transform2D.IDENTITY
|
||||
return Transform2D(direction.angle(), wrist)
|
||||
|
||||
func _rebuild_prop() -> void:
|
||||
if _prop == null:
|
||||
return
|
||||
for child: Node in _prop.get_children():
|
||||
child.queue_free()
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor)
|
||||
_update_hand_choices(anchor)
|
||||
_prop.visible = grip_ok
|
||||
if _hint != null:
|
||||
_hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding."
|
||||
if not grip_ok:
|
||||
return
|
||||
for shape: Dictionary in _shapes:
|
||||
_add_shape_visual(shape)
|
||||
_update_prop_transform()
|
||||
|
||||
func _add_shape_visual(shape: Dictionary) -> void:
|
||||
# PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array
|
||||
# directly to PackedVector2Array silently turns every point into zero.
|
||||
var points := _coerce_points(shape.get("points", PackedVector2Array()))
|
||||
if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle":
|
||||
var radius := maxf(float(shape.get("radius", 24.0)), 0.001)
|
||||
var center := Vector2.ZERO
|
||||
for index: int in 24:
|
||||
points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius)
|
||||
if points.is_empty():
|
||||
return
|
||||
var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY)
|
||||
if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)):
|
||||
var line := Line2D.new()
|
||||
line.points = points
|
||||
line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0)
|
||||
line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE)
|
||||
_prop.add_child(line)
|
||||
else:
|
||||
var polygon := Polygon2D.new()
|
||||
polygon.polygon = points
|
||||
polygon.color = color
|
||||
_prop.add_child(polygon)
|
||||
|
||||
func _coerce_points(raw: Variant) -> PackedVector2Array:
|
||||
if raw is PackedVector2Array:
|
||||
return raw as PackedVector2Array
|
||||
var result := PackedVector2Array()
|
||||
if raw is Array:
|
||||
for item: Variant in raw:
|
||||
if item is Vector2:
|
||||
result.append(item as Vector2)
|
||||
elif item is Dictionary:
|
||||
var point: Dictionary = item as Dictionary
|
||||
result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0))))
|
||||
return result
|
||||
|
||||
func _update_prop_transform() -> void:
|
||||
if _prop == null or not _prop.visible:
|
||||
return
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var mirror := _motion == PreviewMotion.WALK_LEFT
|
||||
_prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror)
|
||||
# PreviewProp follows PreviewRigRoot in tree order, so zero remains visible
|
||||
# above the stickman; negative puts it behind and positive remains in front.
|
||||
_prop.z_index = int(anchor.get("z_index_offset", 0))
|
||||
|
||||
func _grip_allows_hand(anchor: Dictionary) -> bool:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT)
|
||||
|
||||
func _update_target_marker() -> void:
|
||||
var target := _hand_target()
|
||||
if target != null and _target_marker != null:
|
||||
_target_marker.global_position = target.global_position
|
||||
_target_marker.visible = _prop == null or _prop.visible
|
||||
|
||||
func _resize_viewport() -> void:
|
||||
if _viewport != null:
|
||||
# SubViewportContainer.stretch owns the viewport size. Resizing it here
|
||||
# fights that ownership and produces an engine warning.
|
||||
_reframe_camera()
|
||||
|
||||
func _on_viewport_gui_input(event: InputEvent) -> void:
|
||||
if _camera == null:
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
var mouse := event as InputEventMouseButton
|
||||
if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
var world_before := _screen_to_world(mouse.position)
|
||||
_camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0)
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
_camera.position += world_before - _screen_to_world(mouse.position)
|
||||
return
|
||||
var world_pos := _screen_to_world(mouse.position)
|
||||
if mouse.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom:
|
||||
_dragging_target = true
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
_manual_arm_override = true
|
||||
elif not mouse.pressed:
|
||||
_dragging_target = false
|
||||
if mouse.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
_camera_dragging = mouse.pressed
|
||||
_last_pointer = mouse.position
|
||||
elif event is InputEventMouseMotion:
|
||||
var motion := event as InputEventMouseMotion
|
||||
if _dragging_target:
|
||||
_set_target_clamped(_screen_to_world(motion.position))
|
||||
elif _camera_dragging:
|
||||
_camera.position -= (motion.position - _last_pointer) / _camera_zoom
|
||||
_last_pointer = motion.position
|
||||
|
||||
func _screen_to_world(screen: Vector2) -> Vector2:
|
||||
return _viewport.get_canvas_transform().affine_inverse() * screen
|
||||
|
||||
func _set_target_clamped(requested: Vector2) -> void:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
if target == null or lower == null:
|
||||
return
|
||||
target.global_position = _clamp_target(requested)
|
||||
|
||||
func _clamp_target(requested: Vector2) -> Vector2:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
var upper := lower.get_parent() as Bone2D if lower != null else null
|
||||
if target == null or lower == null or upper == null:
|
||||
return requested
|
||||
var origin := upper.global_position
|
||||
var offset := requested - origin
|
||||
var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin
|
||||
if offset.length_squared() < 0.0001:
|
||||
offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT
|
||||
var min_reach := absf(upper.length - lower.length) + 8.0
|
||||
var max_reach := maxf(min_reach, upper.length + lower.length - 8.0)
|
||||
return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach)
|
||||
|
||||
func _update_hand_choices(anchor: Dictionary) -> void:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
if allowed == "left_only" and _hand != PreviewHand.LEFT:
|
||||
_hand = PreviewHand.LEFT
|
||||
reset_arm_pose()
|
||||
if allowed == "right_only" and _hand != PreviewHand.RIGHT:
|
||||
_hand = PreviewHand.RIGHT
|
||||
reset_arm_pose()
|
||||
if _hand_option == null:
|
||||
return
|
||||
_hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only")
|
||||
_hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only")
|
||||
_hand_option.select(_hand)
|
||||
|
||||
func _enable_arm_ik() -> void:
|
||||
var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D
|
||||
if skeleton == null:
|
||||
return
|
||||
var stack := skeleton.get_modification_stack()
|
||||
if stack == null:
|
||||
return
|
||||
for index: int in 2:
|
||||
var modification := stack.get_modification(index)
|
||||
if modification is SkeletonModification2DTwoBoneIK:
|
||||
(modification as SkeletonModification2DTwoBoneIK).enabled = true
|
||||
|
||||
func _reframe_camera() -> void:
|
||||
if _camera == null or _viewport == null or _viewport.size.x < 1:
|
||||
return
|
||||
var bounds := _visual_bounds(true)
|
||||
if bounds.size.length_squared() < 0.001:
|
||||
return
|
||||
bounds = bounds.grow(32.0)
|
||||
_camera.position = bounds.get_center()
|
||||
_fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y)
|
||||
_fitted_zoom = maxf(_fitted_zoom, 0.001)
|
||||
_camera_zoom = _fitted_zoom
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
|
||||
func _visual_bounds(full_figure: bool) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure)
|
||||
_collect_visual_points(_prop, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0))
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func get_prop_global_bounds() -> Rect2:
|
||||
return _visual_bounds_for_node(_prop)
|
||||
|
||||
func get_preview_world_rect() -> Rect2:
|
||||
if _viewport == null:
|
||||
return Rect2()
|
||||
var transform := _viewport.get_canvas_transform().affine_inverse()
|
||||
var top_left := transform * Vector2.ZERO
|
||||
var bottom_right := transform * Vector2(_viewport.size)
|
||||
return Rect2(top_left, bottom_right - top_left).abs()
|
||||
|
||||
func get_solved_wrist_transform() -> Transform2D:
|
||||
return _solved_wrist_transform()
|
||||
|
||||
func _visual_bounds_for_node(node: Node) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(node, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2()
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void:
|
||||
if node == null or not (node as CanvasItem).visible:
|
||||
return
|
||||
var include := full_figure or not (node.name.contains("Leg"))
|
||||
if include and node is Polygon2D:
|
||||
for point: Vector2 in (node as Polygon2D).polygon:
|
||||
out.append((node as Polygon2D).to_global(point))
|
||||
if include and node is Line2D:
|
||||
for point: Vector2 in (node as Line2D).points:
|
||||
out.append((node as Line2D).to_global(point))
|
||||
for child: Node in node.get_children():
|
||||
_collect_visual_points(child, out, full_figure)
|
||||
|
||||
func _disable_collisions(node: Node) -> void:
|
||||
if node is CollisionObject2D:
|
||||
var body := node as CollisionObject2D
|
||||
body.collision_layer = 0
|
||||
body.collision_mask = 0
|
||||
for child: Node in node.get_children():
|
||||
_disable_collisions(child)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bthsk33yw5rr8
|
||||
+109
-1
@@ -10,6 +10,9 @@ extends RigidBody2D
|
||||
## properties update the children live. A material preset selector sets mass,
|
||||
## friction/bounce (PhysicsMaterial), and themed visuals.
|
||||
|
||||
const PropVisuals := preload("res://scripts/prop_visuals.gd")
|
||||
const PropAnchorData := preload("res://scripts/prop_anchor_data.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -20,6 +23,11 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||
|
||||
## Stable library provenance used by Sandbox Stage scene files.
|
||||
var spawn_id: String = ""
|
||||
## Runtime interaction metadata. Both values are additive so existing scenes
|
||||
## and built-in templates remain pickup-enabled.
|
||||
var can_be_picked_up: bool = true
|
||||
var anchors: Dictionary = PropAnchorData.default_anchors()
|
||||
var held_by_instance_id: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Child node names
|
||||
@@ -91,6 +99,11 @@ var _outline: Line2D
|
||||
var _collision_polygon: CollisionPolygon2D
|
||||
var _collision_shape: CollisionShape2D
|
||||
var _circle_shape: CircleShape2D
|
||||
var _custom_shapes: bool = false
|
||||
var _visual_local_bounds := Rect2()
|
||||
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
|
||||
var _play_instance_scale: Vector2 = Vector2.ONE
|
||||
var _play_scale_active: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -241,10 +254,105 @@ func _apply_style() -> void:
|
||||
func _apply_material_preset() -> void:
|
||||
mass = mass_for(material_preset)
|
||||
physics_material_override = physics_material(material_preset)
|
||||
if material_preset != MaterialPreset.NONE:
|
||||
if not _custom_shapes and material_preset != MaterialPreset.NONE:
|
||||
fill_color = tint_for(material_preset)
|
||||
outline_color = tint_for(material_preset).darkened(0.55)
|
||||
|
||||
|
||||
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
|
||||
_ensure_children()
|
||||
_custom_shapes = true
|
||||
_polygon.visible = false
|
||||
_outline.visible = false
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
_custom_collision_hull = collision_hull.duplicate()
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals == null:
|
||||
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
|
||||
for child: Node in visuals.get_children(): child.queue_free()
|
||||
_visual_local_bounds = PropVisuals.build(visuals, shapes)
|
||||
_apply_material_preset()
|
||||
|
||||
|
||||
func has_custom_shapes() -> bool:
|
||||
return _custom_shapes
|
||||
|
||||
|
||||
func get_visual_local_bounds() -> Rect2:
|
||||
if _custom_shapes:
|
||||
return _visual_local_bounds
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
return Rect2(Vector2.ONE * -radius, Vector2.ONE * radius * 2.0)
|
||||
if not polygon_points.is_empty():
|
||||
var bounds: Rect2 = Rect2(polygon_points[0], Vector2.ZERO)
|
||||
for point: Vector2 in polygon_points:
|
||||
bounds = bounds.expand(point)
|
||||
return bounds
|
||||
return Rect2()
|
||||
|
||||
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
||||
## the authored uniform scale into child visuals and collision geometry for
|
||||
## PLAY, then restore the root scale on return to EDIT for scene persistence.
|
||||
func begin_play_instance_scale() -> void:
|
||||
if _play_scale_active:
|
||||
return
|
||||
_play_instance_scale = scale
|
||||
if _play_instance_scale.is_equal_approx(Vector2.ONE):
|
||||
return
|
||||
_play_scale_active = true
|
||||
scale = Vector2.ONE
|
||||
_apply_play_scale()
|
||||
|
||||
func end_play_instance_scale() -> void:
|
||||
if not _play_scale_active:
|
||||
return
|
||||
_play_scale_active = false
|
||||
_set_visual_scale(Vector2.ONE)
|
||||
_apply_shape()
|
||||
if _custom_shapes and _collision_polygon != null:
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
scale = _play_instance_scale
|
||||
|
||||
func _apply_play_scale() -> void:
|
||||
_set_visual_scale(_play_instance_scale)
|
||||
if _custom_shapes:
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
|
||||
return
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
# A non-uniform legacy instance scale makes an ellipse. Use the generated
|
||||
# polygon hull while simulating so collision follows both axes exactly.
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
elif _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
|
||||
|
||||
func _set_visual_scale(value: Vector2) -> void:
|
||||
if _polygon != null:
|
||||
_polygon.scale = value
|
||||
if _outline != null:
|
||||
_outline.scale = value
|
||||
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
visuals.scale = value
|
||||
|
||||
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in points:
|
||||
result.append(point * value)
|
||||
return result
|
||||
|
||||
func _circle_loop() -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for index: int in CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
|
||||
result.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static preset factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://lophq30fv72p
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://v8gaqlxoei3a
|
||||
@@ -0,0 +1,339 @@
|
||||
class_name PropFile
|
||||
extends RefCounted
|
||||
## PropFile - Static schema + converters for .prop vector prop assets.
|
||||
##
|
||||
## The file stores the editor's full multi-shape stack (version "1.0") plus
|
||||
## enough provenance for later sandbox placement. flatten_to_payload() converts
|
||||
## the stack into the single-shape payload PropUtils.spawn_prop() already
|
||||
## consumes: frontmost closed polygon -> else circle -> else first shape.
|
||||
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
|
||||
const FILE_VERSION: String = "1.0"
|
||||
const FILE_EXT: String = ".prop"
|
||||
const DEFAULT_DIR: String = "res://props"
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
|
||||
# index == PropBlock.MaterialPreset
|
||||
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
|
||||
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
|
||||
const DEFAULT_RADIUS: float = 24.0
|
||||
const DEFAULT_CIRCLE_SEGMENTS: int = 12
|
||||
|
||||
|
||||
static func preset_to_string(preset: int) -> String:
|
||||
return PRESET_STRINGS[clampi(preset, 0, PRESET_STRINGS.size() - 1)]
|
||||
|
||||
|
||||
static func preset_from_string(s: String) -> int:
|
||||
var idx: int = PRESET_STRINGS.find(s.to_lower())
|
||||
if idx < 0:
|
||||
return PropBlockScript.MaterialPreset.NONE
|
||||
return idx
|
||||
|
||||
|
||||
static func shape_type_to_payload_type(s: String) -> int:
|
||||
if s.to_lower() == "circle":
|
||||
return PropBlockScript.ShapeType.CIRCLE
|
||||
return PropBlockScript.ShapeType.POLYGON
|
||||
|
||||
|
||||
## Sanitize a shape dictionary: polygon/line points run through
|
||||
## TerrainUtils.sanitize_points (grid snap, redundancy/collinear removal,
|
||||
## clockwise enforcement). Circles pass through unchanged (radius-based).
|
||||
static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScript.DEFAULT_GRID_SIZE) -> Dictionary:
|
||||
var result: Dictionary = shape.duplicate(true)
|
||||
var shape_type: String = str(result.get("shape_type", "polygon"))
|
||||
if shape_type == "circle":
|
||||
return result
|
||||
var pts: PackedVector2Array = _coerce_points(result.get("points", PackedVector2Array()))
|
||||
var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, grid_size)
|
||||
var flags: PackedInt32Array = result.get("vertex_flags", PackedInt32Array())
|
||||
var kept_flags := PackedInt32Array()
|
||||
# Keep vertex flags aligned: sanitize only ever drops points, so walk the
|
||||
# cleaned list and retain a flag for each surviving vertex (match by index
|
||||
# order; approximate alignment is acceptable for write-only metadata).
|
||||
kept_flags.resize(cleaned.size())
|
||||
for i: int in mini(cleaned.size(), flags.size()):
|
||||
kept_flags[i] = flags[i]
|
||||
result["points"] = cleaned
|
||||
result["vertex_flags"] = kept_flags
|
||||
return result
|
||||
|
||||
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}, can_be_picked_up: bool = true) -> Dictionary:
|
||||
var serialized: Array = []
|
||||
for shape: Dictionary in shapes:
|
||||
serialized.append(_serialize_shape(shape))
|
||||
var now: String = Time.get_datetime_string_from_system()
|
||||
return {
|
||||
"version": FILE_VERSION,
|
||||
"prop_name": prop_name,
|
||||
"material_preset": preset_to_string(material_preset),
|
||||
"shapes": serialized,
|
||||
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
||||
"can_be_picked_up": can_be_picked_up,
|
||||
"metadata": {"created_at": now, "modified_at": now},
|
||||
}
|
||||
|
||||
|
||||
## Parse a file-level dictionary (or JSON string) into the editor model.
|
||||
## Returns {} on any schema error (with a push_warning), otherwise
|
||||
## { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }.
|
||||
static func from_file_dict(data: Variant) -> Dictionary:
|
||||
if data is String:
|
||||
var parsed: Variant = JSON.parse_string(data as String)
|
||||
if not parsed is Dictionary:
|
||||
push_warning("PropFile: file data is not a valid JSON object.")
|
||||
return {}
|
||||
data = parsed
|
||||
if not data is Dictionary:
|
||||
push_warning("PropFile: file data is not a dictionary.")
|
||||
return {}
|
||||
var dict: Dictionary = data as Dictionary
|
||||
var version: String = str(dict.get("version", ""))
|
||||
if not SUPPORTED_VERSIONS.has(version):
|
||||
push_warning("PropFile: unsupported file version '%s'." % version)
|
||||
return {}
|
||||
|
||||
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
|
||||
var shapes: Array[Dictionary] = []
|
||||
var raw_shapes: Variant = dict.get("shapes", [])
|
||||
if raw_shapes is Array:
|
||||
for item: Variant in raw_shapes as Array:
|
||||
if item is Dictionary:
|
||||
shapes.append(_parse_shape(item as Dictionary, preset))
|
||||
return {
|
||||
"prop_name": str(dict.get("prop_name", "")),
|
||||
"material_preset": preset,
|
||||
"shapes": shapes,
|
||||
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
||||
"can_be_picked_up": dict.get("can_be_picked_up") if dict.get("can_be_picked_up") is bool else true,
|
||||
}
|
||||
|
||||
|
||||
## Deterministic conversion of the multi-shape stack into a single
|
||||
## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle
|
||||
## -> else first shape. Returns {} when shapes is empty.
|
||||
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary:
|
||||
if shapes.is_empty():
|
||||
push_warning("PropFile: cannot flatten an empty shape stack.")
|
||||
return {}
|
||||
var chosen: Dictionary = {}
|
||||
var kind: String = "polygon"
|
||||
# 1. Frontmost closed polygon (scan back to front).
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
var sd: Dictionary = shapes[i]
|
||||
if str(sd.get("shape_type", "polygon")) == "polygon" and bool(sd.get("closed", true)):
|
||||
chosen = sd
|
||||
kind = "polygon"
|
||||
break
|
||||
# 2. Else frontmost circle.
|
||||
if chosen.is_empty():
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
var sd: Dictionary = shapes[i]
|
||||
if str(sd.get("shape_type", "polygon")) == "circle":
|
||||
chosen = sd
|
||||
kind = "circle"
|
||||
break
|
||||
# 3. Else the first shape (even a line) as a polygon.
|
||||
if chosen.is_empty() and not shapes.is_empty():
|
||||
chosen = shapes[0]
|
||||
kind = "polygon"
|
||||
|
||||
var payload: Dictionary = {}
|
||||
if kind == "circle":
|
||||
payload = {
|
||||
"type": PropBlockScript.ShapeType.CIRCLE,
|
||||
"radius": float(chosen.get("radius", DEFAULT_RADIUS)),
|
||||
}
|
||||
else:
|
||||
var pts: PackedVector2Array = _coerce_points(chosen.get("points", PackedVector2Array()))
|
||||
payload = {
|
||||
"type": PropBlockScript.ShapeType.POLYGON,
|
||||
"points": TerrainUtilsScript.sanitize_points(pts),
|
||||
}
|
||||
payload["fill_color"] = _color_from_hex(str(chosen.get("fill_color", "")), PropBlockScript.tint_for(PropBlockScript.MaterialPreset.NONE))
|
||||
payload["outline_color"] = _color_from_hex(str(chosen.get("outline_color", "")), PropBlockScript.DEFAULT_OUTLINE_COLOR)
|
||||
payload["outline_width"] = float(chosen.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH))
|
||||
return payload
|
||||
|
||||
|
||||
## Write `data` (a to_file_dict result) as pretty-printed JSON to `path`.
|
||||
static func save(path: String, data: Dictionary) -> bool:
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not open '%s' for writing." % path)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.close()
|
||||
return true
|
||||
|
||||
|
||||
## Atomic write mirroring StageSceneIO.write_file_safe: write a temp file,
|
||||
## swap the previous file to .bak, rename the temp into place, then remove the
|
||||
## backup. Used by the editor autosave so a crash mid-write cannot corrupt it.
|
||||
static func save_safe(path: String, data: Dictionary) -> bool:
|
||||
var tmp := path + ".tmp"
|
||||
var bak := path + ".bak"
|
||||
var file := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not write temporary file '%s'." % tmp)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.flush()
|
||||
file.close()
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
|
||||
DirAccess.remove_absolute(tmp)
|
||||
return false
|
||||
if DirAccess.rename_absolute(tmp, path) != OK:
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.rename_absolute(bak, path)
|
||||
return false
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
return true
|
||||
|
||||
|
||||
## Read and parse a .prop file. Returns the raw dictionary on success, {}
|
||||
## on read/parse failure (with a push_warning). Schema validation (version,
|
||||
## shapes) happens in from_file_dict.
|
||||
static func load(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("PropFile: '%s' does not exist." % path)
|
||||
return {}
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not open '%s' for reading." % path)
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
push_warning("PropFile: '%s' is not a valid JSON object." % path)
|
||||
return {}
|
||||
return parsed as Dictionary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _serialize_shape(shape: Dictionary) -> Dictionary:
|
||||
# Accept both internal (PackedVector2Array) and export (Array of {x,y})
|
||||
# point formats; a typed assignment would silently zero an Array of dicts.
|
||||
var points: PackedVector2Array = _coerce_points(shape.get("points", PackedVector2Array()))
|
||||
var points_arr: Array = []
|
||||
for pt: Vector2 in points:
|
||||
points_arr.append({"x": pt.x, "y": pt.y})
|
||||
var flags: PackedInt32Array = shape.get("vertex_flags", PackedInt32Array())
|
||||
var flags_arr: Array = []
|
||||
for i: int in flags.size():
|
||||
flags_arr.append(flags[i])
|
||||
var result := {
|
||||
"shape_type": str(shape.get("shape_type", "polygon")),
|
||||
"points": points_arr,
|
||||
"fill_color": str(shape.get("fill_color", "")),
|
||||
"outline_color": str(shape.get("outline_color", "")),
|
||||
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
|
||||
"closed": bool(shape.get("closed", false)),
|
||||
"vertex_flags": flags_arr,
|
||||
"reshaped": bool(shape.get("reshaped", false)),
|
||||
}
|
||||
if str(shape.get("shape_type", "")) == "circle":
|
||||
result["radius"] = float(shape.get("radius", DEFAULT_RADIUS))
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_shape(shape: Dictionary, preset: int) -> Dictionary:
|
||||
var shape_type: String = str(shape.get("shape_type", "polygon"))
|
||||
if not SHAPE_STRINGS.has(shape_type):
|
||||
shape_type = "polygon"
|
||||
|
||||
var pts := PackedVector2Array()
|
||||
var pts_variant: Variant = shape.get("points", [])
|
||||
if pts_variant is Array:
|
||||
for p: Variant in pts_variant as Array:
|
||||
if p is Dictionary:
|
||||
var d: Dictionary = p as Dictionary
|
||||
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
|
||||
elif p is Vector2:
|
||||
pts.append(p as Vector2)
|
||||
elif pts_variant is PackedVector2Array:
|
||||
pts = pts_variant as PackedVector2Array
|
||||
|
||||
var closed: bool
|
||||
if shape.has("closed"):
|
||||
closed = bool(shape.get("closed", false))
|
||||
else:
|
||||
closed = shape_type != "line"
|
||||
|
||||
var radius: float = float(shape.get("radius", DEFAULT_RADIUS))
|
||||
if shape_type == "circle" and pts.is_empty():
|
||||
pts = _circle_points(radius, DEFAULT_CIRCLE_SEGMENTS)
|
||||
|
||||
var vf := PackedInt32Array()
|
||||
var vf_variant: Variant = shape.get("vertex_flags", [])
|
||||
if vf_variant is Array:
|
||||
var vf_arr: Array = vf_variant as Array
|
||||
vf.resize(vf_arr.size())
|
||||
for i: int in vf_arr.size():
|
||||
vf[i] = int(vf_arr[i])
|
||||
elif vf_variant is PackedInt32Array:
|
||||
vf = vf_variant as PackedInt32Array
|
||||
if vf.size() != pts.size():
|
||||
vf.resize(pts.size())
|
||||
|
||||
var tint: Color = PropBlockScript.tint_for(preset)
|
||||
var fill_hex: String = str(shape.get("fill_color", ""))
|
||||
if fill_hex.is_empty():
|
||||
fill_hex = tint.to_html()
|
||||
var outline_hex: String = str(shape.get("outline_color", ""))
|
||||
if outline_hex.is_empty():
|
||||
outline_hex = tint.darkened(0.55).to_html()
|
||||
|
||||
var result := {
|
||||
"shape_type": shape_type,
|
||||
"points": pts,
|
||||
"radius": radius,
|
||||
"fill_color": fill_hex,
|
||||
"outline_color": outline_hex,
|
||||
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
|
||||
"closed": closed,
|
||||
"vertex_flags": vf,
|
||||
"reshaped": bool(shape.get("reshaped", false)),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
static func _circle_points(radius: float, segments: int) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
var seg: int = maxi(segments, 3)
|
||||
for i: int in seg:
|
||||
var angle: float = TAU * float(i) / float(seg)
|
||||
pts.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
static func _color_from_hex(hex: String, fallback: Color) -> Color:
|
||||
if hex.is_empty():
|
||||
return fallback
|
||||
return Color.from_string(hex, fallback)
|
||||
|
||||
|
||||
## Accept either internal (PackedVector2Array) or export (Array of {x,y})
|
||||
## point formats.
|
||||
static func _coerce_points(variant: Variant) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
if variant is PackedVector2Array:
|
||||
pts = variant as PackedVector2Array
|
||||
elif variant is Array:
|
||||
for p: Variant in variant as Array:
|
||||
if p is Dictionary:
|
||||
var d: Dictionary = p as Dictionary
|
||||
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
|
||||
elif p is Vector2:
|
||||
pts.append(p as Vector2)
|
||||
return pts
|
||||
@@ -0,0 +1 @@
|
||||
uid://rbk8by20x7g4
|
||||
+66
-1
@@ -3,6 +3,8 @@ extends RefCounted
|
||||
|
||||
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_FILE := preload("res://scripts/prop_file.gd")
|
||||
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
|
||||
|
||||
static var _templates: Array[Dictionary] = []
|
||||
|
||||
@@ -24,7 +26,64 @@ static func get_entry(id: String) -> Dictionary:
|
||||
for t: Dictionary in get_entries():
|
||||
if String(t["id"]) == id:
|
||||
return t
|
||||
return {}
|
||||
return make_custom_entry(id) if is_custom_path(id) else {}
|
||||
|
||||
|
||||
static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = get_entries().duplicate(true)
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path)
|
||||
return entries
|
||||
var directory: DirAccess = DirAccess.open(dir_path)
|
||||
if directory == null:
|
||||
return entries
|
||||
var paths: Array[String] = []
|
||||
directory.list_dir_begin()
|
||||
var file_name: String = directory.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
|
||||
paths.append(dir_path.path_join(file_name))
|
||||
file_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
|
||||
for path: String in paths:
|
||||
var entry := make_custom_entry(path)
|
||||
if not entry.is_empty(): entries.append(entry)
|
||||
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false))
|
||||
var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower()
|
||||
return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0)
|
||||
return entries
|
||||
|
||||
|
||||
static func make_custom_entry(path: String) -> Dictionary:
|
||||
var normalized_path: String = canonical_path(path)
|
||||
if not is_custom_path(normalized_path): return {}
|
||||
var raw: Dictionary = PROP_FILE.load(normalized_path)
|
||||
if raw.is_empty():
|
||||
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
|
||||
return {}
|
||||
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
|
||||
if parsed.is_empty():
|
||||
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
|
||||
return {}
|
||||
var shapes: Array[Dictionary] = parsed.get("shapes", [])
|
||||
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
|
||||
if shapes.is_empty() or hull.size() < 3:
|
||||
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
|
||||
return {}
|
||||
var name := String(parsed.get("prop_name", "")).strip_edges()
|
||||
if name.is_empty(): name = normalized_path.get_file().get_basename()
|
||||
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
|
||||
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {}), "can_be_picked_up": bool(parsed.get("can_be_picked_up", true))}
|
||||
|
||||
|
||||
static func is_custom_path(path: String) -> bool:
|
||||
return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path())
|
||||
|
||||
|
||||
static func canonical_path(path: String) -> String:
|
||||
return path.replace("\\", "/").simplify_path()
|
||||
|
||||
|
||||
static func get_default_id() -> String:
|
||||
@@ -47,4 +106,10 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
|
||||
"material_preset": preset,
|
||||
"material_label": label,
|
||||
"payload": payload,
|
||||
"is_custom": false,
|
||||
"path": "",
|
||||
"shapes": [],
|
||||
"collision_hull": PackedVector2Array(),
|
||||
"anchors": {},
|
||||
"can_be_picked_up": true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
class_name PropTheme
|
||||
extends RefCounted
|
||||
## Validated, cached presentation data for the Prop Editor.
|
||||
##
|
||||
## Mirrors SandboxTheme's loader mechanics (deep-merge, dotted-path accessors,
|
||||
## warn-once, clamp, invalid-input fallback) but scoped to prop-editor tokens.
|
||||
## Fallback hierarchy: prop_theme.json -> DEFAULT_CONFIG -> hardcoded
|
||||
## PropBlock constants. Physics values (mass/friction/bounce) are deliberately
|
||||
## NOT themeable; PropBlock's static preset tables remain the single source of
|
||||
## truth.
|
||||
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
|
||||
const SCHEMA_VERSION: String = "1.0"
|
||||
const DEFAULT_PATH: String = "res://prop_theme.json"
|
||||
|
||||
const PRESET_KEYS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
|
||||
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"outline_widths": {"default": 2.0, "min": 0.5, "max": 32.0},
|
||||
"material_tints": {
|
||||
"wood": {"fill": "#8C6133", "outline": "#40290F"},
|
||||
"rubber": {"fill": "#E63333", "outline": "#590D0D"},
|
||||
"cardboard": {"fill": "#D9B873", "outline": "#66521D"},
|
||||
"metal": {"fill": "#808C99", "outline": "#262E38"},
|
||||
"none": {"fill": "#8C6133", "outline": "#26201E"},
|
||||
},
|
||||
"primitive_defaults": {
|
||||
"box": {"size_x": 48.0, "size_y": 48.0},
|
||||
"ball": {"radius": 24.0},
|
||||
"plank": {"length": 160.0, "thickness": 16.0},
|
||||
"triangle": {"base": 56.0, "height": 48.0},
|
||||
"line": {"length": 120.0},
|
||||
"circle": {"radius": 40.0, "segments": 12},
|
||||
},
|
||||
"canvas": {
|
||||
"grid_size": 15,
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26",
|
||||
},
|
||||
"ui": {
|
||||
"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", "inspector_background": "#262C39"},
|
||||
},
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _warned: Dictionary = {}
|
||||
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
invalidate_caches()
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_warn_once(path, "PropTheme: '%s' could not be read; using defaults." % path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
_warn_once(path, "PropTheme: '%s' is malformed; using defaults." % path)
|
||||
return false
|
||||
_config = _deep_merge(_config, parsed as Dictionary)
|
||||
return true
|
||||
|
||||
|
||||
func raw_config() -> Dictionary:
|
||||
return _config.duplicate(true)
|
||||
|
||||
|
||||
func value(path: String, fallback: Variant = null) -> Variant:
|
||||
var cursor: Variant = _config
|
||||
for piece: String in path.split("."):
|
||||
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
||||
return fallback
|
||||
cursor = (cursor as Dictionary)[piece]
|
||||
return cursor
|
||||
|
||||
|
||||
func color(path: String, fallback: Color) -> Color:
|
||||
var raw: Variant = value(path, null)
|
||||
if not raw is String:
|
||||
return fallback
|
||||
var text: String = String(raw)
|
||||
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
||||
_warn_once(path, "PropTheme: invalid color at '%s'." % path)
|
||||
return fallback
|
||||
return Color.from_string(text, fallback)
|
||||
|
||||
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
||||
var raw: Variant = value(path, fallback)
|
||||
if not raw is int and not raw is float:
|
||||
_warn_once(path, "PropTheme: invalid number at '%s'." % path)
|
||||
return fallback
|
||||
var n: float = float(raw)
|
||||
if not is_finite(n):
|
||||
return fallback
|
||||
return clampf(n, minimum, maximum)
|
||||
|
||||
|
||||
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
||||
|
||||
|
||||
## Material tint for a PropBlock.MaterialPreset value and channel ("fill" or
|
||||
## "outline"). Hierarchy: theme file -> DEFAULT_CONFIG -> PropBlock.tint_for
|
||||
## (outline's final fallback is the tint darkened 0.55).
|
||||
func material_tint(preset: int, channel: String) -> Color:
|
||||
var key: String = PRESET_KEYS[preset] if preset >= 0 and preset < PRESET_KEYS.size() else "none"
|
||||
var tint: Color = PropBlockScript.tint_for(preset)
|
||||
var fallback: Color = tint if channel == "fill" else tint.darkened(0.55)
|
||||
return color("material_tints.%s.%s" % [key, channel], fallback)
|
||||
|
||||
|
||||
func outline_width_default() -> float:
|
||||
return number("outline_widths.default", PropBlockScript.DEFAULT_OUTLINE_WIDTH, 0.0, 256.0)
|
||||
|
||||
|
||||
func outline_width_min() -> float:
|
||||
return number("outline_widths.min", 0.5, 0.0, 256.0)
|
||||
|
||||
|
||||
func outline_width_max() -> float:
|
||||
return number("outline_widths.max", 32.0, 0.0, 1024.0)
|
||||
|
||||
|
||||
## Primitive default dimension, e.g. primitive_default("box", "size_x").
|
||||
## Falls back to the DEFAULT_CONFIG primitive table (which mirrors the
|
||||
## PropUtils.create_* generator defaults).
|
||||
func primitive_default(primitive: String, key: String, fallback: float = 0.0) -> float:
|
||||
var table: Dictionary = value("primitive_defaults.%s" % primitive, {})
|
||||
if table is Dictionary and (table as Dictionary).has(key):
|
||||
return number("primitive_defaults.%s.%s" % [primitive, key], fallback)
|
||||
var defaults: Dictionary = DEFAULT_CONFIG.get("primitive_defaults", {})
|
||||
var entry: Dictionary = defaults.get(primitive, {})
|
||||
return float(entry.get(key, fallback))
|
||||
|
||||
|
||||
func grid_size() -> int:
|
||||
return integer("canvas.grid_size", 15, 1, 512)
|
||||
|
||||
|
||||
func canvas_background_color() -> Color:
|
||||
return color("canvas.background_color", Color("#1F1F1F"))
|
||||
|
||||
|
||||
func canvas_grid_color() -> Color:
|
||||
return color("canvas.grid_color", Color(1.0, 1.0, 1.0, 0.15))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kid-friendly UI tokens (Round 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func font_body() -> int:
|
||||
return integer("ui.fonts.body", 18, 8, 72)
|
||||
|
||||
|
||||
func font_header() -> int:
|
||||
return integer("ui.fonts.header", 24, 8, 96)
|
||||
|
||||
|
||||
func font_menu() -> int:
|
||||
return integer("ui.fonts.menu", 22, 8, 72)
|
||||
|
||||
|
||||
func font_status() -> int:
|
||||
return integer("ui.fonts.status", 18, 8, 72)
|
||||
|
||||
|
||||
func font_mode() -> int:
|
||||
return integer("ui.fonts.mode", 26, 8, 96)
|
||||
|
||||
|
||||
func font_menu_bar() -> int:
|
||||
return integer("ui.fonts.menu_bar", 22, 8, 72)
|
||||
|
||||
|
||||
func font_prop_name() -> int:
|
||||
return integer("ui.fonts.prop_name", 20, 8, 96)
|
||||
|
||||
|
||||
func font_context_menu() -> int:
|
||||
return integer("ui.fonts.context_menu", 22, 8, 96)
|
||||
|
||||
|
||||
func button_min_height() -> int:
|
||||
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
|
||||
|
||||
|
||||
func row_min_height() -> int:
|
||||
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
|
||||
|
||||
|
||||
func mode_button_min_height() -> int:
|
||||
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
|
||||
|
||||
|
||||
func text_color() -> Color:
|
||||
return color("ui.colors.text", Color("#F2F2F2"))
|
||||
|
||||
|
||||
func text_muted_color() -> Color:
|
||||
return color("ui.colors.text_muted", Color("#9A9A9A"))
|
||||
|
||||
|
||||
func border_color() -> Color:
|
||||
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
|
||||
|
||||
|
||||
func accent_color() -> Color:
|
||||
return color("ui.colors.accent", Color("#4FA3FF"))
|
||||
|
||||
|
||||
func inspector_background_color() -> Color:
|
||||
return color("ui.colors.inspector_background", Color("#262C39"))
|
||||
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
_warned.clear()
|
||||
|
||||
|
||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||
var merged: Dictionary = base.duplicate(true)
|
||||
for key: Variant in overrides:
|
||||
if merged.get(key) is Dictionary and overrides[key] is Dictionary:
|
||||
merged[key] = _deep_merge(merged[key], overrides[key])
|
||||
else:
|
||||
merged[key] = overrides[key]
|
||||
return merged
|
||||
|
||||
|
||||
func _warn_once(key: String, message: String) -> void:
|
||||
if not _warned.has(key):
|
||||
_warned[key] = true
|
||||
push_warning(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qxwnxe3u1o33
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name PropTrajectory
|
||||
extends RefCounted
|
||||
## Shared deterministic ballistic solver for director feedback and playback.
|
||||
|
||||
const MIN_FLIGHT_TIME: float = 0.35
|
||||
const MAX_FLIGHT_TIME: float = 1.5
|
||||
const MAX_LAUNCH_SPEED: float = 900.0
|
||||
|
||||
static func solve_throw_velocity(release_point: Vector2, target_point: Vector2, flight_time: float, gravity_vector: Vector2) -> Dictionary:
|
||||
if not release_point.is_finite() or not target_point.is_finite() or not gravity_vector.is_finite():
|
||||
return {"ok": false, "reason": "non_finite"}
|
||||
if not is_finite(flight_time) or flight_time < MIN_FLIGHT_TIME or flight_time > MAX_FLIGHT_TIME:
|
||||
return {"ok": false, "reason": "flight_time"}
|
||||
var velocity: Vector2 = (target_point - release_point - gravity_vector * 0.5 * flight_time * flight_time) / flight_time
|
||||
if not velocity.is_finite() or velocity.length() > MAX_LAUNCH_SPEED:
|
||||
return {"ok": false, "reason": "speed"}
|
||||
return {"ok": true, "velocity": velocity, "flight_time": flight_time, "reason": ""}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddw3n0jjlduk
|
||||
@@ -0,0 +1,89 @@
|
||||
class_name PropVisuals
|
||||
extends RefCounted
|
||||
## Shared custom-prop drawing and collision approximation.
|
||||
|
||||
const CIRCLE_SEGMENTS: int = 24
|
||||
|
||||
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2:
|
||||
var bounds := Rect2()
|
||||
var has_bounds := false
|
||||
for index: int in shapes.size():
|
||||
var shape: Dictionary = shapes[index]
|
||||
var points := _shape_points(shape)
|
||||
if points.size() < 2:
|
||||
push_warning("PropVisuals: skipped shape with insufficient points.")
|
||||
continue
|
||||
var group := Node2D.new()
|
||||
group.name = "Shape_%d" % index
|
||||
parent.add_child(group)
|
||||
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
|
||||
var fill := Color.from_string(String(shape.get("fill_color", "")), Color.WHITE)
|
||||
var outline := Color.from_string(String(shape.get("outline_color", "")), Color.BLACK)
|
||||
var width: float = maxf(0.0, float(shape.get("outline_width", 2.0)))
|
||||
if closed and points.size() >= 3:
|
||||
var polygon := Polygon2D.new()
|
||||
polygon.name = "Polygon2D"
|
||||
polygon.polygon = points
|
||||
polygon.color = fill
|
||||
group.add_child(polygon)
|
||||
var line := Line2D.new()
|
||||
line.name = "Line2D"
|
||||
var loop: PackedVector2Array = points.duplicate()
|
||||
if closed:
|
||||
loop.append(points[0])
|
||||
line.points = loop
|
||||
line.default_color = outline
|
||||
line.width = width
|
||||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
group.add_child(line)
|
||||
for point: Vector2 in points:
|
||||
if not has_bounds:
|
||||
bounds = Rect2(point, Vector2.ZERO)
|
||||
has_bounds = true
|
||||
else:
|
||||
bounds = bounds.expand(point)
|
||||
return bounds
|
||||
|
||||
|
||||
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array:
|
||||
var points := PackedVector2Array()
|
||||
for shape: Dictionary in shapes:
|
||||
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
|
||||
if closed:
|
||||
points.append_array(_shape_points(shape))
|
||||
if points.size() < 3:
|
||||
return PackedVector2Array()
|
||||
var hull: PackedVector2Array = Geometry2D.convex_hull(points)
|
||||
if hull.size() < 3 or absf(_polygon_area(hull)) <= 0.001:
|
||||
return PackedVector2Array()
|
||||
return hull
|
||||
|
||||
|
||||
static func _shape_points(shape: Dictionary) -> PackedVector2Array:
|
||||
var raw: Variant = shape.get("points", PackedVector2Array())
|
||||
var points := PackedVector2Array()
|
||||
if raw is PackedVector2Array:
|
||||
points = raw as PackedVector2Array
|
||||
elif raw is Array:
|
||||
for item: Variant in raw as Array:
|
||||
if item is Vector2:
|
||||
points.append(item as Vector2)
|
||||
elif item is Dictionary:
|
||||
var data: Dictionary = item as Dictionary
|
||||
points.append(Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))))
|
||||
if points.is_empty() and String(shape.get("shape_type", "")) == "circle":
|
||||
var radius: float = maxf(0.0, float(shape.get("radius", 24.0)))
|
||||
for index: int in CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
|
||||
points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return points
|
||||
|
||||
|
||||
static func _polygon_area(points: PackedVector2Array) -> float:
|
||||
var area: float = 0.0
|
||||
for index: int in points.size():
|
||||
var next: Vector2 = points[(index + 1) % points.size()]
|
||||
area += points[index].cross(next)
|
||||
return area * 0.5
|
||||
@@ -0,0 +1 @@
|
||||
uid://7cehuu3v8tig
|
||||
@@ -191,6 +191,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
if _empty_label != null:
|
||||
_apply_font_to(_empty_label, _row_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
@@ -211,6 +211,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_base_font_size = int(sizes.get("rule_editor", 18))
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
@@ -196,6 +196,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
if _empty_label != null:
|
||||
_apply_font_to(_empty_label, _row_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
+535
-102
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
class_name SandboxTheme
|
||||
extends RefCounted
|
||||
## Validated, cached presentation data for the Sandbox Stage.
|
||||
|
||||
const SCHEMA_VERSION: String = "2.0"
|
||||
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
||||
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
||||
"grid": {"snap_size": 15.0},
|
||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
||||
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
||||
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
||||
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _font_cache: Dictionary = {}
|
||||
var _style_cache: Dictionary = {}
|
||||
var _control_theme: Theme = null
|
||||
var _warned: Dictionary = {}
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
invalidate_caches()
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
|
||||
return false
|
||||
_apply_legacy_aliases(parsed as Dictionary)
|
||||
_config = _deep_merge(_config, parsed as Dictionary)
|
||||
return true
|
||||
|
||||
func raw_config() -> Dictionary:
|
||||
return _config.duplicate(true)
|
||||
|
||||
func value(path: String, fallback: Variant = null) -> Variant:
|
||||
var cursor: Variant = _config
|
||||
for piece: String in path.split("."):
|
||||
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
||||
return fallback
|
||||
cursor = (cursor as Dictionary)[piece]
|
||||
return cursor
|
||||
|
||||
func color(path: String, fallback: Color) -> Color:
|
||||
var raw: Variant = value(path, null)
|
||||
if not raw is String:
|
||||
return fallback
|
||||
var text: String = String(raw)
|
||||
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
||||
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
|
||||
return fallback
|
||||
return Color.from_string(text, fallback)
|
||||
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
||||
var raw: Variant = value(path, fallback)
|
||||
if not raw is int and not raw is float:
|
||||
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
|
||||
return fallback
|
||||
var n: float = float(raw)
|
||||
if not is_finite(n): return fallback
|
||||
return clampf(n, minimum, maximum)
|
||||
|
||||
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
||||
|
||||
func font(component_path: String = "", preset_id: String = "ui") -> Font:
|
||||
var path: String = ""
|
||||
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
|
||||
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
|
||||
if path.is_empty(): path = String(value("fonts.ui_font", ""))
|
||||
if path.is_empty(): return ThemeDB.fallback_font
|
||||
if _font_cache.has(path): return _font_cache[path]
|
||||
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
|
||||
if resource is Font:
|
||||
_font_cache[path] = resource
|
||||
return resource as Font
|
||||
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
|
||||
return ThemeDB.fallback_font
|
||||
|
||||
func emoji_font() -> Font:
|
||||
var path: String = String(value("fonts.emoji_font", ""))
|
||||
if path.is_empty(): return font()
|
||||
return font("", "emoji") if ResourceLoader.exists(path) else font()
|
||||
|
||||
func style(component_path: String) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var section: String = component_path.get_slice(".", 0)
|
||||
result = _deep_merge(result, value("global", {}))
|
||||
result = _deep_merge(result, value(section + ".style_defaults", {}))
|
||||
result = _deep_merge(result, value(component_path, {}))
|
||||
return result
|
||||
|
||||
func stylebox(component_path: String) -> StyleBoxFlat:
|
||||
var cfg: Dictionary = style(component_path)
|
||||
var key: String = JSON.stringify(cfg)
|
||||
if not _style_cache.has(key):
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
|
||||
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
|
||||
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
|
||||
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
|
||||
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
|
||||
box.content_margin_left = padding; box.content_margin_right = padding
|
||||
box.content_margin_top = padding; box.content_margin_bottom = padding
|
||||
_style_cache[key] = box
|
||||
return (_style_cache[key] as StyleBoxFlat).duplicate()
|
||||
|
||||
func control_theme() -> Theme:
|
||||
if _control_theme == null:
|
||||
_control_theme = Theme.new()
|
||||
_control_theme.default_font = font()
|
||||
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
|
||||
return _control_theme
|
||||
|
||||
func swatches() -> Array[Color]:
|
||||
var result: Array[Color] = []
|
||||
for item: Variant in value("widgets.swatches", []):
|
||||
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
|
||||
return result
|
||||
|
||||
func size_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for item: Variant in value("widgets.size_presets", []):
|
||||
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
|
||||
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
|
||||
return result
|
||||
|
||||
func font_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var presets: Dictionary = value("fonts.presets", {})
|
||||
for id: String in presets:
|
||||
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
|
||||
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
|
||||
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
|
||||
return result
|
||||
|
||||
func emoji_items() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
|
||||
return result
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
|
||||
|
||||
func _apply_legacy_aliases(source: Dictionary) -> void:
|
||||
var modes: Dictionary = source.get("mode_colors", {})
|
||||
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
||||
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
||||
var fonts: Dictionary = source.get("fonts", {})
|
||||
var sizes: Dictionary = fonts.get("sizes", {})
|
||||
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
||||
var legacy: String = name + "_font_size"
|
||||
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
||||
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
||||
|
||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||
var merged: Dictionary = base.duplicate(true)
|
||||
for key: Variant in overrides:
|
||||
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
|
||||
else: merged[key] = overrides[key]
|
||||
return merged
|
||||
|
||||
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
|
||||
return Color.from_string(String(cfg.get(key, "")), fallback)
|
||||
|
||||
func _warn_once(key: String, message: String) -> void:
|
||||
if not _warned.has(key): _warned[key] = true; push_warning(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://02tfgtwtgfcw
|
||||
@@ -95,6 +95,21 @@ func set_style(cfg: Dictionary) -> void:
|
||||
mark_dirty()
|
||||
|
||||
|
||||
## Resolved-theme entry point. `set_style` remains for legacy callers/tests.
|
||||
func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void:
|
||||
var legacy: Dictionary = {"fonts": fonts.duplicate(true)}
|
||||
if not fonts.is_empty():
|
||||
var sizes: Dictionary = fonts.get("sizes", fonts)
|
||||
legacy["fonts"] = {
|
||||
"assignment_badge_font_size": sizes.get("assignment_badge", badge_icon_size),
|
||||
"assignment_badge_radius": style.get("badge_radius", badge_radius),
|
||||
"rule_label_font_size": sizes.get("rule_label", rule_label_font_size),
|
||||
"rule_label_bold": fonts.get("flags", {}).get("rule_label_bold", rule_label_bold),
|
||||
"badge_bold": fonts.get("flags", {}).get("badge_bold", badge_bold),
|
||||
}
|
||||
set_style(legacy)
|
||||
|
||||
|
||||
func set_rules(r: Array[Dictionary]) -> void:
|
||||
rules = r
|
||||
mark_dirty()
|
||||
@@ -129,7 +144,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
continue
|
||||
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
@@ -177,7 +192,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
||||
if queue.is_empty():
|
||||
return
|
||||
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
|
||||
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var stack := 0
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
@@ -256,7 +271,7 @@ func _collect_waypoints() -> Array[Vector2]:
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
continue
|
||||
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
@@ -349,7 +364,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
|
||||
var params: Dictionary = trigger.get("params", {})
|
||||
return params.get("waypoint_pos", Vector2.INF)
|
||||
"entered_area":
|
||||
var area := instance_from_id(int(trigger.get("target", -1)))
|
||||
var target_id: int = int(trigger.get("target", -1))
|
||||
if target_id < 0:
|
||||
return _rule_source_position(trigger)
|
||||
var area := instance_from_id(target_id)
|
||||
if area is Node2D and is_instance_valid(area):
|
||||
return (area as Node2D).global_position
|
||||
return _rule_source_position(trigger)
|
||||
@@ -358,7 +376,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
|
||||
|
||||
|
||||
func _rule_source_position(trigger: Dictionary) -> Vector2:
|
||||
var src := instance_from_id(int(trigger.get("source", -1)))
|
||||
var source_id: int = int(trigger.get("source", -1))
|
||||
if source_id < 0:
|
||||
return Vector2.INF
|
||||
var src := instance_from_id(source_id)
|
||||
if src is Node2D and is_instance_valid(src):
|
||||
return (src as Node2D).global_position
|
||||
return Vector2.INF
|
||||
@@ -370,7 +391,10 @@ func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2:
|
||||
var actions: Array = rule.get("actions", [])
|
||||
if actions.is_empty():
|
||||
return fallback
|
||||
var target := instance_from_id(int(actions[0].get("target", -1)))
|
||||
var target_id: int = int(actions[0].get("target", -1))
|
||||
if target_id < 0:
|
||||
return fallback
|
||||
var target := instance_from_id(target_id)
|
||||
if target is Node2D and is_instance_valid(target):
|
||||
return (target as Node2D).global_position
|
||||
return fallback
|
||||
|
||||
+57
-2
@@ -10,12 +10,13 @@ extends Node2D
|
||||
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum Handle { NONE, TRANSLATE, ROTATE }
|
||||
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -39,6 +40,10 @@ const ROTATE_WIDTH := 2.0
|
||||
|
||||
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
||||
const SCALE_CROSS_SIZE_PX := 5.0
|
||||
const SCALE_HIT_RADIUS_PX := 12.0
|
||||
const MIN_SCALE: float = 0.05
|
||||
const MAX_SCALE: float = 20.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
@@ -61,6 +66,10 @@ var _rotate_start_rotation: float = 0.0
|
||||
var _rotate_start_angle: float = 0.0
|
||||
var _rotate_center: Vector2 = Vector2.ZERO
|
||||
var _box_rect: Rect2 = Rect2()
|
||||
var _scale_start: Vector2 = Vector2.ONE
|
||||
var _scale_pivot: Vector2 = Vector2.ZERO
|
||||
var _scale_corner: Vector2 = Vector2.ZERO
|
||||
var _scale_changed: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -99,6 +108,10 @@ func hit_test(world_pos: Vector2) -> Handle:
|
||||
if target == null or not is_instance_valid(target):
|
||||
return Handle.NONE
|
||||
var rect := STAGE_SELECTION.get_world_aabb(target)
|
||||
if target is PropBlockScript:
|
||||
for corner: Vector2 in _corners(rect):
|
||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||
return Handle.SCALE
|
||||
var ring := _rotate_ring_radius(rect)
|
||||
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
||||
return Handle.ROTATE
|
||||
@@ -114,6 +127,14 @@ func begin_drag(world_pos: Vector2) -> void:
|
||||
_rotate_start_rotation = _targets[0].global_rotation
|
||||
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
||||
transform_started.emit(_targets.duplicate())
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var target: Node2D = _targets[0]
|
||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
|
||||
_scale_pivot = target.global_position
|
||||
_scale_corner = _nearest_corner(rect, world_pos)
|
||||
_scale_start = target.scale
|
||||
_scale_changed = false
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
## Begins a translate drag anchored on `node` (used when an object body is
|
||||
@@ -155,16 +176,30 @@ func drag_to(world_pos: Vector2) -> void:
|
||||
if Input.is_key_pressed(KEY_CTRL):
|
||||
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
|
||||
target.global_rotation = new_rotation
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var prop: Node2D = _targets[0]
|
||||
var start: Vector2 = _scale_corner - _scale_pivot
|
||||
var denominator: float = start.length_squared()
|
||||
if denominator > 0.0001:
|
||||
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
|
||||
if is_finite(requested):
|
||||
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
|
||||
if not next_scale.is_equal_approx(prop.scale):
|
||||
prop.scale = next_scale
|
||||
_scale_changed = true
|
||||
_drag_last = world_pos
|
||||
transform_updated.emit(_targets.duplicate())
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func end_drag() -> void:
|
||||
if _dragging != Handle.NONE and not _targets.is_empty():
|
||||
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
|
||||
transform_committed.emit(_targets.duplicate())
|
||||
_dragging = Handle.NONE
|
||||
_drag_anchor = null
|
||||
_scale_changed = false
|
||||
_translate_start_positions.clear()
|
||||
|
||||
|
||||
@@ -194,6 +229,13 @@ func _draw() -> void:
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
|
||||
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
||||
if _targets[0] is PropBlockScript:
|
||||
var half: float = SCALE_CROSS_SIZE_PX / zoom
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
|
||||
if _box_rect.size != Vector2.ZERO:
|
||||
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
|
||||
@@ -215,3 +257,16 @@ func _snap(v: Vector2) -> Vector2:
|
||||
if snap_size <= 0.0:
|
||||
return v
|
||||
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
|
||||
|
||||
func _corners(rect: Rect2) -> Array[Vector2]:
|
||||
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
|
||||
|
||||
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
|
||||
var nearest: Vector2 = rect.position
|
||||
var distance: float = INF
|
||||
for corner: Vector2 in _corners(rect):
|
||||
var candidate: float = corner.distance_squared_to(world_pos)
|
||||
if candidate < distance:
|
||||
distance = candidate
|
||||
nearest = corner
|
||||
return nearest
|
||||
|
||||
@@ -20,14 +20,29 @@ var action_visible: bool = false
|
||||
var action_origin: Vector2 = Vector2.ZERO
|
||||
var action_target: Vector2 = Vector2.ZERO
|
||||
var action_valid: bool = true
|
||||
var action_arc: PackedVector2Array = PackedVector2Array()
|
||||
var action_is_throw: bool = false
|
||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||
var line_width_px: float = 2.0
|
||||
var dash_length_px: float = 8.0
|
||||
var dash_gap_px: float = 5.0
|
||||
|
||||
const DASH_LENGTH_PX := 8.0
|
||||
const DASH_GAP_PX := 5.0
|
||||
const LINE_WIDTH_PX := 2.0
|
||||
const MARKER_RADIUS_PX := 10.0
|
||||
|
||||
func apply_style(style: Dictionary) -> void:
|
||||
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
|
||||
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
|
||||
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
|
||||
var defaults: Dictionary = style.get("style_defaults", {})
|
||||
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
|
||||
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
|
||||
dash_gap_px = maxf(0.0, float(defaults.get("dash_gap", dash_gap_px)))
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if terrain_guide_visible or action_visible:
|
||||
@@ -50,10 +65,24 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
|
||||
## Displays a sampled ballistic arc. `points` must come from the same solver
|
||||
## that validates the eventual physics release.
|
||||
func set_throw_trajectory(origin: Vector2, target: Vector2, points: PackedVector2Array, valid: bool) -> void:
|
||||
action_visible = true
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = points.duplicate()
|
||||
action_is_throw = true
|
||||
|
||||
|
||||
func clear_action() -> void:
|
||||
action_visible = false
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -64,21 +93,37 @@ func _draw() -> void:
|
||||
_draw_cell_marker(terrain_target, guide_line_color)
|
||||
if action_visible:
|
||||
var color := valid_color if action_valid else invalid_color
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
if action_is_throw and action_arc.size() >= 2:
|
||||
_draw_arc_path(action_arc, color)
|
||||
else:
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
_draw_ghost_marker(action_target, color)
|
||||
if not action_valid:
|
||||
_draw_invalid_marker(action_target, color)
|
||||
|
||||
func _draw_arc_path(points: PackedVector2Array, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
for index: int in range(points.size() - 1):
|
||||
draw_line(points[index], points[index + 1], color, line_width_px / zoom, true)
|
||||
|
||||
func _draw_invalid_marker(pos: Vector2, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
var radius: float = MARKER_RADIUS_PX * 1.35 / zoom
|
||||
draw_line(pos - Vector2(radius, radius), pos + Vector2(radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
draw_line(pos + Vector2(radius, -radius), pos + Vector2(-radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||
var zoom := _zoom()
|
||||
var dash := DASH_LENGTH_PX / zoom
|
||||
var gap := DASH_GAP_PX / zoom
|
||||
var dash := dash_length_px / zoom
|
||||
var gap := dash_gap_px / zoom
|
||||
var dir := from.direction_to(to)
|
||||
var total := from.distance_to(to)
|
||||
var dist := 0.0
|
||||
while dist < total:
|
||||
var start := from + dir * dist
|
||||
var len := minf(dash, total - dist)
|
||||
draw_line(start, start + dir * len, color, LINE_WIDTH_PX / zoom, true)
|
||||
draw_line(start, start + dir * len, color, line_width_px / zoom, true)
|
||||
dist += dash + gap
|
||||
|
||||
|
||||
|
||||
@@ -22,25 +22,25 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
||||
var data: Dictionary = value
|
||||
return Vector2(float(data["x"]), float(data["y"]))
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
||||
static func encode_queue(actions: Array[Dictionary], runtime_to_file: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for action: Dictionary in actions:
|
||||
var encoded := _encode_flat_action(action)
|
||||
var encoded := _encode_flat_action(action, runtime_to_file)
|
||||
if encoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
out.append(encoded)
|
||||
return {"actions": out, "warnings": warnings}
|
||||
|
||||
static func decode_queue(entries: Array) -> Dictionary:
|
||||
static func decode_queue(entries: Array, file_to_runtime: Dictionary = {}, unavailable_ids: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for entry: Variant in entries:
|
||||
if not entry is Dictionary:
|
||||
warnings.append("Skipped malformed queue action.")
|
||||
continue
|
||||
var decoded := _decode_flat_action(entry as Dictionary)
|
||||
var decoded := _decode_flat_action(entry as Dictionary, file_to_runtime, unavailable_ids)
|
||||
if decoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
@@ -171,7 +171,7 @@ static func _recover_backup(path: String) -> void:
|
||||
static func _finite_number(value: Variant) -> bool:
|
||||
return (value is float or value is int) and is_finite(float(value))
|
||||
|
||||
static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _encode_flat_action(action: Dictionary, refs: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -180,9 +180,20 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
return {"type": type, "target": vector_to_data(action["target"])}
|
||||
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var prop_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop_id": prop_id, "hand": hand} if prop_id != null and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var target: Variant = action.get("target_position")
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id == null or not target is Vector2 or not target.is_finite() or not _finite_number(time): return {}
|
||||
return {"type": type, "target_prop_id": throw_id, "target_position": vector_to_data(target as Vector2), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _decode_flat_action(action: Dictionary, refs: Dictionary = {}, unavailable: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -195,6 +206,17 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
if bool(action.get("stop_current", false)): out["stop_current"] = true
|
||||
return out
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var pickup_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop": pickup_id, "hand": hand} if pickup_id >= 0 and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id < 0 or not is_vector_data(action.get("target_position")) or not _finite_number(time): return {}
|
||||
if time < 0.35 or time > 1.5: return {}
|
||||
return {"type": type, "target_prop": throw_id, "target_position": data_to_vector(action["target_position"], Vector2.ZERO), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||
@@ -229,12 +251,12 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
|
||||
|
||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action), refs); if flat.is_empty(): return {}
|
||||
var params := flat.duplicate(); params.erase("type")
|
||||
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
||||
|
||||
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
||||
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat, refs, unavailable); if decoded.is_empty(): return {}
|
||||
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
||||
|
||||
@@ -141,6 +141,12 @@ static func get_world_aabb(node: Node2D) -> Rect2:
|
||||
# Duck-typed TriggerArea: expose its centered local rect through the transform.
|
||||
if node.has_method("get_area_rect"):
|
||||
return node.global_transform * (node.call("get_area_rect") as Rect2)
|
||||
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(visuals, visual_acc)
|
||||
if visual_acc["min_x"] <= visual_acc["max_x"]:
|
||||
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
|
||||
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||
if poly != null and not poly.polygon.is_empty():
|
||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||
|
||||
@@ -63,6 +63,18 @@ func get_spawnable_ids() -> Array[String]:
|
||||
return ids
|
||||
|
||||
|
||||
## UI-safe registry view. Callers receive no templates or mutable registry state.
|
||||
func get_palette_entries() -> Array[Dictionary]:
|
||||
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
|
||||
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: Dictionary in _registry:
|
||||
var id: String = String(entry.get("id", ""))
|
||||
var kind: String = String(entry.get("kind", ""))
|
||||
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
|
||||
return result
|
||||
|
||||
|
||||
func get_label(id: String) -> String:
|
||||
var entry := _find_entry(id)
|
||||
return String(entry.get("label", id)) if not entry.is_empty() else id
|
||||
@@ -153,8 +165,19 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
|
||||
if template.is_empty():
|
||||
push_warning("StageSpawner: unknown prop '%s'." % asset_id)
|
||||
return null
|
||||
var prop: PropBlock = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
|
||||
prop.spawn_id = asset_id
|
||||
var prop: PropBlock
|
||||
if bool(template.get("is_custom", false)):
|
||||
prop = PROP_BLOCK.new()
|
||||
prop.name = "PropBlock"
|
||||
prop.position = object_position
|
||||
prop.material_preset = int(template["material_preset"])
|
||||
_world.add_child(prop)
|
||||
prop.set_custom_shapes(template.get("shapes", []) as Array[Dictionary], template.get("collision_hull", PackedVector2Array()) as PackedVector2Array)
|
||||
else:
|
||||
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
|
||||
prop.spawn_id = String(template.get("id", asset_id))
|
||||
prop.anchors = template.get("anchors", {}).duplicate(true)
|
||||
prop.can_be_picked_up = bool(template.get("can_be_picked_up", true))
|
||||
return prop
|
||||
|
||||
|
||||
@@ -190,6 +213,12 @@ static func get_world_aabb(node: Node2D) -> Rect2:
|
||||
# Duck-typed TriggerArea: expose its centered local rect through the transform.
|
||||
if node.has_method("get_area_rect"):
|
||||
return node.global_transform * (node.call("get_area_rect") as Rect2)
|
||||
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(visuals, visual_acc)
|
||||
if visual_acc["min_x"] <= visual_acc["max_x"]:
|
||||
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
|
||||
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||
if poly != null and not poly.polygon.is_empty():
|
||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
class_name StickmanActionRunner
|
||||
extends Node
|
||||
## Owns authored and reactive action execution plus the shared speech bubble.
|
||||
## StickmanRig remains the public facade; this node only calls down into
|
||||
## locomotion and signals requests back to the facade.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
|
||||
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
||||
|
||||
enum RunnerState { IDLE, EXECUTING }
|
||||
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP }
|
||||
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
signal queue_finished
|
||||
signal queue_changed
|
||||
signal speech_finished
|
||||
signal ragdoll_requested(enabled: bool)
|
||||
signal recovery_requested
|
||||
|
||||
var action_queue: Array[Dictionary] = []
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _prop_interaction: StickmanPropInteraction = null
|
||||
var _runner_state: RunnerState = RunnerState.IDLE
|
||||
var _action_phase: ActionPhase = ActionPhase.NONE
|
||||
var _current_index: int = -1
|
||||
var _run_queue: Array[Dictionary] = []
|
||||
var _current_run_item: Dictionary = {}
|
||||
var _run_cursor: int = 0
|
||||
var _phase_timer: float = 0.0
|
||||
var _primary_speech: Dictionary = {}
|
||||
var _overlay_speech: Dictionary = {}
|
||||
var _speech_bubble: Node2D = null
|
||||
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interaction: StickmanPropInteraction = null) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
_prop_interaction = prop_interaction
|
||||
if _prop_interaction != null and not _prop_interaction.interaction_finished.is_connected(_on_interaction_finished):
|
||||
_prop_interaction.interaction_finished.connect(_on_interaction_finished)
|
||||
|
||||
|
||||
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
_update_speech(delta)
|
||||
if _runner_state != RunnerState.EXECUTING or _is_blocked():
|
||||
return
|
||||
match _action_phase:
|
||||
ActionPhase.NONE:
|
||||
_advance_to_next_action()
|
||||
ActionPhase.WALKING:
|
||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||
_finish_action()
|
||||
ActionPhase.SPEAKING:
|
||||
if _primary_speech.is_empty():
|
||||
_finish_action()
|
||||
ActionPhase.WAITING:
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_finish_action()
|
||||
ActionPhase.RAGDOLLING:
|
||||
if ragdoll_at_rest:
|
||||
_finish_action()
|
||||
ActionPhase.RECOVERING:
|
||||
if rig_state == StickmanRig.RigState.ANIMATED:
|
||||
_finish_action()
|
||||
ActionPhase.PROP:
|
||||
if _prop_interaction != null:
|
||||
_prop_interaction.tick(delta)
|
||||
|
||||
|
||||
func speak(text: String, duration: float) -> void:
|
||||
_primary_speech = {"text": text, "time_left": maxf(duration, 0.0)}
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func speak_overlay(text: String, duration: float) -> void:
|
||||
_overlay_speech = {"text": text, "time_left": maxf(duration, 0.0)}
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func hide_speech() -> void:
|
||||
_primary_speech.clear()
|
||||
_overlay_speech.clear()
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
|
||||
func sync_facing(profile: int) -> void:
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
|
||||
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
action_queue.append(action)
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func clear_queue() -> void:
|
||||
action_queue.clear()
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func get_queue() -> Array[Dictionary]:
|
||||
return action_queue.duplicate(true)
|
||||
|
||||
|
||||
func remove_action(index: int) -> void:
|
||||
if index < 0 or index >= action_queue.size():
|
||||
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
|
||||
return
|
||||
action_queue.remove_at(index)
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func insert_action(index: int, action: Dictionary) -> void:
|
||||
action_queue.insert(clampi(index, 0, action_queue.size()), action)
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func queue_size() -> int:
|
||||
return action_queue.size()
|
||||
|
||||
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if actions.is_empty():
|
||||
return
|
||||
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
|
||||
for index: int in action_queue.size():
|
||||
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
|
||||
_run_cursor = 0
|
||||
var primary: Array[Dictionary] = []
|
||||
var takeover_seen: bool = false
|
||||
for action: Dictionary in actions:
|
||||
var role: int = ACTION_REGISTRY.reactive_role(action)
|
||||
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
|
||||
speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
|
||||
continue
|
||||
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
||||
if not takeover_seen:
|
||||
takeover_seen = true
|
||||
_cancel_current_primary()
|
||||
_overlay_speech.clear()
|
||||
hide_speech()
|
||||
var kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue.slice(_run_cursor):
|
||||
if not bool(item.get("reactive", false)):
|
||||
kept.append(item)
|
||||
_run_queue = kept
|
||||
_run_cursor = 0
|
||||
primary.clear()
|
||||
primary.append(action.duplicate(true))
|
||||
if primary.is_empty():
|
||||
return
|
||||
if not takeover_seen:
|
||||
_cancel_current_primary()
|
||||
var injected: Array[Dictionary] = []
|
||||
for action: Dictionary in primary:
|
||||
injected.append({"action": action, "queue_index": -1, "reactive": true})
|
||||
_run_queue = injected + _run_queue.slice(_run_cursor)
|
||||
_run_cursor = 0
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
|
||||
|
||||
func clear_reactive_actions() -> void:
|
||||
var runtime_kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue:
|
||||
if not bool(item.get("reactive", false)):
|
||||
runtime_kept.append(item)
|
||||
_run_queue = runtime_kept
|
||||
_run_cursor = mini(_run_cursor, _run_queue.size())
|
||||
var kept: Array[Dictionary] = []
|
||||
for action: Dictionary in action_queue:
|
||||
if not bool(action.get("reactive", false)):
|
||||
kept.append(action)
|
||||
if kept.size() != action_queue.size():
|
||||
action_queue = kept
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func start_queue() -> void:
|
||||
if _runner_state == RunnerState.EXECUTING:
|
||||
return
|
||||
if action_queue.is_empty():
|
||||
queue_finished.emit()
|
||||
return
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_run_queue.clear()
|
||||
for index: int in action_queue.size():
|
||||
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
|
||||
_run_cursor = 0
|
||||
_current_run_item.clear()
|
||||
|
||||
|
||||
func stop_queue() -> void:
|
||||
_cancel_current_primary()
|
||||
hide_speech()
|
||||
_run_queue.clear()
|
||||
_current_run_item.clear()
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
return _runner_state == RunnerState.EXECUTING
|
||||
|
||||
|
||||
func get_current_index() -> int:
|
||||
return _current_index
|
||||
|
||||
|
||||
func _advance_to_next_action() -> void:
|
||||
if _run_cursor >= _run_queue.size():
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
queue_finished.emit()
|
||||
return
|
||||
_current_run_item = _run_queue[_run_cursor]
|
||||
_run_cursor += 1
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
action_started.emit(action, _current_index)
|
||||
_begin_action(action)
|
||||
|
||||
|
||||
func _begin_action(action: Dictionary) -> void:
|
||||
match String(action.get("type", "")):
|
||||
"walk_to":
|
||||
_action_phase = ActionPhase.WALKING
|
||||
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
|
||||
_finish_action()
|
||||
"speak":
|
||||
_action_phase = ActionPhase.SPEAKING
|
||||
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
|
||||
"wait":
|
||||
_phase_timer = float(action.get("duration", 0.0))
|
||||
_action_phase = ActionPhase.WAITING
|
||||
"ragdoll":
|
||||
_action_phase = ActionPhase.RAGDOLLING
|
||||
ragdoll_requested.emit(true)
|
||||
"recover":
|
||||
_action_phase = ActionPhase.RECOVERING
|
||||
recovery_requested.emit()
|
||||
"pick_up_prop", "drop_prop", "throw_prop":
|
||||
_action_phase = ActionPhase.PROP
|
||||
if _prop_interaction == null or not _prop_interaction.begin_action(action):
|
||||
_finish_action()
|
||||
_:
|
||||
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
|
||||
_finish_action()
|
||||
|
||||
|
||||
func _finish_action() -> void:
|
||||
var finished: Dictionary = _current_run_item.get("action", {})
|
||||
var index: int = int(_current_run_item.get("queue_index", -1))
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
|
||||
|
||||
func _cancel_current_primary() -> void:
|
||||
if _prop_interaction != null:
|
||||
_prop_interaction.cancel()
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(not _is_blocked())
|
||||
_primary_speech.clear()
|
||||
_phase_timer = 0.0
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
func _on_interaction_finished(_succeeded: bool) -> void:
|
||||
if _action_phase == ActionPhase.PROP:
|
||||
_finish_action()
|
||||
|
||||
|
||||
func _is_blocked() -> bool:
|
||||
return _locomotion != null and _locomotion.is_action_blocked()
|
||||
|
||||
|
||||
func _update_speech(delta: float) -> void:
|
||||
var visible_owner_changed: bool = false
|
||||
if not _primary_speech.is_empty() and not _is_blocked():
|
||||
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
|
||||
if float(_primary_speech["time_left"]) <= 0.0:
|
||||
_primary_speech.clear()
|
||||
speech_finished.emit()
|
||||
if not _overlay_speech.is_empty():
|
||||
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
|
||||
if float(_overlay_speech["time_left"]) <= 0.0:
|
||||
_overlay_speech.clear()
|
||||
visible_owner_changed = true
|
||||
speech_finished.emit()
|
||||
if _overlay_speech.is_empty() and _primary_speech.is_empty():
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
elif visible_owner_changed and _overlay_speech.is_empty():
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _show_visible_speech() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
|
||||
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as Node2D
|
||||
_speech_bubble.name = "SpeechBubble"
|
||||
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
|
||||
_rig.add_child(_speech_bubble)
|
||||
sync_facing(_rig.facing_profile)
|
||||
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
|
||||
if not visible_speech.is_empty():
|
||||
_speech_bubble.show_text(String(visible_speech.get("text", "")))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd0gqgr5oyu3g
|
||||
@@ -53,7 +53,7 @@ const FILE_FILTER := "*.stk ; Stickman Files"
|
||||
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
|
||||
|
||||
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
|
||||
# Phase 8: rig proportions (scenes/stickman_rig.tscn rest pose — see spec §2).
|
||||
const PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
## StickmanFactory - Runtime factory for spawning rig instances from .stk files
|
||||
## (Phase 9).
|
||||
##
|
||||
## Loads a versioned .stk JSON dictionary and instantiates a master_rig.tscn,
|
||||
## Loads a versioned .stk JSON dictionary and instantiates scenes/stickman_rig.tscn,
|
||||
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
|
||||
## referenced by the editor.
|
||||
|
||||
@@ -11,7 +11,7 @@ extends RefCounted
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RIG_SCENE := preload("res://master_rig.tscn")
|
||||
const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
class_name StickmanLocomotion
|
||||
extends Node
|
||||
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
|
||||
## StickmanRig schedules this component and exposes its public facade.
|
||||
|
||||
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
||||
|
||||
const TERRAIN_PROBE_UP: float = 96.0
|
||||
const TERRAIN_PROBE_DOWN: float = 700.0
|
||||
const TERRAIN_NORMAL_MIN_Y: float = -0.15
|
||||
const TERRAIN_SUPPORT_RANGE: float = 160.0
|
||||
const TERRAIN_MAX_FOOT_SURFACE_DROP: float = 72.0
|
||||
const TERRAIN_FOOT_GUARD_HEIGHT: float = 24.0
|
||||
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
|
||||
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
|
||||
const TERRAIN_GRAVITY: float = 1400.0
|
||||
const TERRAIN_MAX_FALL_SPEED: float = 1800.0
|
||||
const RECOVERY_PROBE_START_OFFSET: float = 50.0
|
||||
const RECOVERY_PROBE_END_OFFSET: float = 1500.0
|
||||
const RECOVERY_PROBE_TOLERANCE: float = 200.0
|
||||
const RECOVERY_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||||
const AIR_DRAG_X: float = 0.75
|
||||
const AIR_POSE_START_SPEED: float = 240.0
|
||||
const AIR_POSE_FULL_SPEED: float = 1200.0
|
||||
const AIR_POSE_RESPONSE: float = 10.0
|
||||
const AIR_HAND_FORWARD: float = 220.0
|
||||
const AIR_HAND_FORWARD_START_SPEED: float = 80.0
|
||||
const AIR_HAND_FORWARD_FULL_SPEED: float = 520.0
|
||||
const AIR_HAND_LIFT: float = 680.0
|
||||
const AIR_HAND_FLAIL_AMPLITUDE: Vector2 = Vector2(20.0, 16.0)
|
||||
const AIR_FOOT_FLAIL_AMPLITUDE: Vector2 = Vector2(14.0, 10.0)
|
||||
const AIR_FLAIL_HZ: float = 12.0
|
||||
const HARD_LANDING_MIN_FALL_HEIGHT: float = 80.0
|
||||
const HARD_LANDING_MAX_FALL_HEIGHT: float = 900.0
|
||||
const LANDING_COMPRESSION_MIN: float = 32.0
|
||||
const LANDING_COMPRESSION_MAX: float = 160.0
|
||||
const LANDING_HAND_DROP_MIN: float = 270.0
|
||||
const LANDING_HAND_DROP_MAX: float = 520.0
|
||||
const LANDING_COMPRESS_DURATION: float = 0.08
|
||||
const LANDING_RECOVER_DURATION: float = 0.22
|
||||
const PELVIS_MAX_OFFSET: float = 240.0
|
||||
const PELVIS_RESPONSE: float = 10.0
|
||||
const FOOT_OFFSET: Vector2 = Vector2(0.0, -385.0)
|
||||
const NAV_AGENT_LOCAL_POS: Vector2 = Vector2(0.0, 385.0)
|
||||
const ARRIVE_DISTANCE: float = 8.0
|
||||
const NAV_PATH_DESIRED_DISTANCE: float = 8.0
|
||||
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
|
||||
const LATCH_PROBE_MAX_FRAMES: int = 6
|
||||
const WALK_CYCLE_DURATION: float = 0.8
|
||||
|
||||
const HEAD_COLLISION_RADIUS: float = 90.0
|
||||
const TORSO_COLLISION_RADIUS: float = 20.0
|
||||
const ARM_COLLISION_RADIUS: float = 12.0
|
||||
const LEG_COLLISION_RADIUS: float = 14.0
|
||||
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
||||
const COLLISION_LAYER: int = 1
|
||||
const COLLISION_PARTS: Dictionary = {
|
||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
}
|
||||
const REACTION_BASE_ANGLE: float = 0.35
|
||||
const REACTION_REFERENCE_SPEED: float = 400.0
|
||||
const REACTION_MAX_ANGLE: float = 0.6
|
||||
const REACTION_LOCAL_X_EPSILON: float = 0.05
|
||||
const REACTION_TARGETS: Dictionary = {
|
||||
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
||||
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
||||
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
}
|
||||
|
||||
signal arrived(target: Vector2)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
|
||||
var terrain_pelvis_offset: float = 0.0
|
||||
var terrain_air_velocity: Vector2 = Vector2.ZERO
|
||||
var terrain_motion: TerrainMotion = TerrainMotion.DISABLED
|
||||
var terrain_following_enabled: bool = false
|
||||
var _air_time: float = 0.0
|
||||
var air_pose_weight: float = 0.0
|
||||
var air_pose_base: Dictionary = {}
|
||||
var _air_entry_root_y: float = 0.0
|
||||
var air_fall_distance: float = 0.0
|
||||
var _landing_elapsed: float = 0.0
|
||||
var landing_compression: float = 0.0
|
||||
var _landing_hand_drop: float = 0.0
|
||||
var _landing_torso_base: Vector2 = Vector2.ZERO
|
||||
var _landing_hand_start: Dictionary = {}
|
||||
var _landing_contacts: Dictionary = {}
|
||||
var _terrain_paused_walk_animation: bool = false
|
||||
var _nav_agent: NavigationAgent2D = null
|
||||
var _nav_anchor: Node2D = null
|
||||
var _walking: bool = false
|
||||
var _walk_target_feet: Vector2 = Vector2.ZERO
|
||||
var _walk_speed_current: float = 300.0
|
||||
var _walk_mode: String = "nav"
|
||||
var _walk_mode_latched: bool = false
|
||||
var _walk_latch_probe_frames: int = 0
|
||||
var _walk_settle_frames: int = 0
|
||||
var _walk_done: bool = false
|
||||
var _leg_base_markers: Dictionary = {}
|
||||
var _last_grounded_walk_velocity_x: float = 0.0
|
||||
var collision_bodies: Dictionary = {}
|
||||
var collision_lengths: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
var reaction_bones: Dictionary = {}
|
||||
var impact_reactions: Dictionary = {}
|
||||
|
||||
func configure(rig: StickmanRig) -> void:
|
||||
_rig = rig
|
||||
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
|
||||
if base == null:
|
||||
push_warning("StickmanLocomotion: missing AnimationTargets/%s_Base." % leg_name)
|
||||
else:
|
||||
_leg_base_markers[leg_name] = base
|
||||
_nav_anchor = Node2D.new()
|
||||
_nav_anchor.name = "NavigationAgentAnchor"
|
||||
_nav_anchor.position = NAV_AGENT_LOCAL_POS
|
||||
_rig.add_child(_nav_anchor)
|
||||
_nav_agent = NavigationAgent2D.new()
|
||||
_nav_agent.name = "NavigationAgent2D"
|
||||
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
|
||||
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
|
||||
_nav_agent.path_max_distance = 100.0
|
||||
_nav_agent.max_speed = _rig.walk_speed
|
||||
_nav_agent.avoidance_enabled = false
|
||||
_nav_anchor.add_child(_nav_agent)
|
||||
|
||||
|
||||
func setup_collision_bodies() -> void:
|
||||
collision_bodies.clear()
|
||||
collision_lengths.clear()
|
||||
_collision_visuals.clear()
|
||||
reaction_bones.clear()
|
||||
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
|
||||
return
|
||||
for key: String in COLLISION_PARTS:
|
||||
var definition: Dictionary = COLLISION_PARTS[key]
|
||||
var bone: Bone2D = _rig._skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
push_warning("StickmanLocomotion: missing reaction bone '%s'." % str(definition["bone"]))
|
||||
continue
|
||||
var joint_name: String = "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
|
||||
reaction_bones[joint_name] = bone
|
||||
var visual: Node2D = _rig._body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
|
||||
if visual == null:
|
||||
push_warning("StickmanLocomotion: missing collision visual for '%s'." % key)
|
||||
continue
|
||||
var proxy := AnimatableBody2D.new()
|
||||
proxy.name = "CollisionBody"
|
||||
proxy.collision_layer = COLLISION_LAYER
|
||||
proxy.collision_mask = COLLISION_LAYER
|
||||
proxy.sync_to_physics = false
|
||||
proxy.set_meta("stickman_rig", _rig)
|
||||
proxy.set_meta("part_name", key)
|
||||
var shape_node := CollisionShape2D.new()
|
||||
shape_node.name = "CollisionShape2D"
|
||||
if str(definition["shape"]) == "circle":
|
||||
var circle := CircleShape2D.new()
|
||||
var bounds: Dictionary = mounted_visual_bounds_local(visual)
|
||||
if bool(bounds["valid"]):
|
||||
var rect: Rect2 = bounds["rect"] as Rect2
|
||||
shape_node.position = rect.get_center()
|
||||
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
|
||||
else:
|
||||
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
|
||||
circle.radius = float(definition["radius"])
|
||||
shape_node.shape = circle
|
||||
else:
|
||||
var length: float = absf(_rig._head_bone.position.y) if key == "torso" and _rig._head_bone != null else bone.length
|
||||
collision_lengths[key] = length
|
||||
var capsule := CapsuleShape2D.new()
|
||||
capsule.height = length
|
||||
capsule.radius = float(definition["radius"])
|
||||
shape_node.shape = capsule
|
||||
shape_node.position = Vector2(0.0, length * 0.5)
|
||||
proxy.add_child(shape_node)
|
||||
visual.add_child(proxy)
|
||||
proxy.top_level = true
|
||||
proxy.global_transform = visual.global_transform
|
||||
collision_bodies[key] = proxy
|
||||
_collision_visuals[key] = visual
|
||||
|
||||
|
||||
func sync_collision_bodies() -> void:
|
||||
for key: String in collision_bodies:
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
|
||||
var visual: Node2D = _collision_visuals.get(key) as Node2D
|
||||
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
|
||||
proxy.global_transform = visual.global_transform
|
||||
|
||||
|
||||
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
||||
var points: Array[Vector2] = []
|
||||
var nodes: Array[Node] = [part]
|
||||
while not nodes.is_empty():
|
||||
var child: Node = nodes.pop_back()
|
||||
if child is Line2D:
|
||||
for point: Vector2 in (child as Line2D).points:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
elif child is Polygon2D:
|
||||
for point: Vector2 in (child as Polygon2D).polygon:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
for grandchild: Node in child.get_children():
|
||||
nodes.append(grandchild)
|
||||
if points.is_empty():
|
||||
return {"valid": false, "rect": Rect2()}
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return {"valid": true, "rect": rect}
|
||||
|
||||
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||||
var layer: int = COLLISION_LAYER if enabled else 0
|
||||
for proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if proxy != null and is_instance_valid(proxy):
|
||||
proxy.collision_layer = layer
|
||||
proxy.collision_mask = layer
|
||||
|
||||
|
||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
|
||||
return
|
||||
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
|
||||
var joint: String = str(target["joint"])
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
continue
|
||||
var direction: float = collision_contact_direction(part_name, impact_position, impact_direction)
|
||||
if is_zero_approx(direction):
|
||||
var local_dir: Vector2 = bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
|
||||
if is_zero_approx(direction): direction = -1.0 if joint.begins_with("Left") else 1.0
|
||||
var angle: float = direction * minf(REACTION_MAX_ANGLE, REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * float(target["weight"])
|
||||
var old: Dictionary = impact_reactions.get(joint, {})
|
||||
if old.is_empty():
|
||||
impact_reactions[joint] = {"angle": angle, "strength": 1.0, "decay": float(target["decay"]), "base_rotation": bone.rotation}
|
||||
else:
|
||||
if absf(angle) >= absf(float(old.get("angle", 0.0))):
|
||||
old["angle"] = angle
|
||||
old["decay"] = float(target["decay"])
|
||||
old["strength"] = 1.0
|
||||
impact_reactions[joint] = old
|
||||
|
||||
|
||||
func collision_contact_direction(part_name: String, impact_position: Vector2, impact_direction: Vector2) -> float:
|
||||
if not impact_position.is_finite(): return 0.0
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy): return 0.0
|
||||
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null: return 0.0
|
||||
var side: float = proxy.to_local(impact_position).x - shape_node.position.x
|
||||
if absf(side) <= REACTION_LOCAL_X_EPSILON: return 0.0
|
||||
var local_force: Vector2 = proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
return signf(side * local_force.y) if absf(local_force.y) > REACTION_LOCAL_X_EPSILON else signf(side)
|
||||
|
||||
|
||||
func clear_impact_reactions(restore_bones: bool = false) -> void:
|
||||
if restore_bones:
|
||||
for joint: String in impact_reactions:
|
||||
var reaction: Dictionary = impact_reactions[joint]
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
|
||||
bone.rotation = float(reaction["base_rotation"])
|
||||
impact_reactions.clear()
|
||||
|
||||
|
||||
func apply_impact_reactions(delta: float) -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
clear_impact_reactions()
|
||||
return
|
||||
for joint: String in impact_reactions.keys().duplicate():
|
||||
var reaction: Dictionary = impact_reactions[joint]
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
impact_reactions.erase(joint)
|
||||
continue
|
||||
var base_rotation: float = float(reaction.get("base_rotation", bone.rotation))
|
||||
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
|
||||
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
|
||||
if float(reaction["strength"]) <= 0.0:
|
||||
bone.rotation = base_rotation
|
||||
impact_reactions.erase(joint)
|
||||
else:
|
||||
impact_reactions[joint] = reaction
|
||||
|
||||
func tick_movement(delta: float, _rig_state: int) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
terrain_pelvis_offset = 0.0
|
||||
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
|
||||
_last_grounded_walk_velocity_x = 0.0
|
||||
_update_walking(delta)
|
||||
_update_terrain_following(delta)
|
||||
_settle_walk_markers()
|
||||
|
||||
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
|
||||
if _rig != null:
|
||||
apply_impact_reactions(delta)
|
||||
sync_collision_bodies()
|
||||
|
||||
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
|
||||
return false
|
||||
if _rig._is_action_queue_blocked():
|
||||
push_warning("StickmanRig: walk_to ignored during landing impact.")
|
||||
return false
|
||||
if _nav_agent == null or _nav_anchor == null:
|
||||
push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.")
|
||||
return false
|
||||
_walk_target_feet = target
|
||||
_walk_speed_current = speed if speed > 0.0 else _rig.walk_speed
|
||||
_nav_agent.max_speed = _walk_speed_current
|
||||
_nav_agent.target_position = target
|
||||
var dx: float = target.x - _rig.global_position.x
|
||||
if dx < -0.5:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
elif dx > 0.5:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
|
||||
_rig._set_limb_mods_enabled(true)
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"):
|
||||
_rig._anim_player.play("walk_right")
|
||||
_walking = true
|
||||
_walk_done = false
|
||||
_walk_mode = "nav"
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
return true
|
||||
|
||||
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
||||
_rig._anim_player.stop(true)
|
||||
if _nav_agent != null and _nav_anchor != null:
|
||||
_nav_agent.target_position = _nav_anchor.global_position
|
||||
_walking = false
|
||||
_walk_done = false
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
if restore_idle_pose:
|
||||
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
|
||||
|
||||
func is_walking() -> bool:
|
||||
return _walking
|
||||
|
||||
func get_foot_offset() -> Vector2:
|
||||
return FOOT_OFFSET
|
||||
|
||||
func consume_walk_completed() -> bool:
|
||||
if not _walk_done:
|
||||
return false
|
||||
_walk_done = false
|
||||
return true
|
||||
|
||||
func _update_walking(delta: float) -> void:
|
||||
if not _walking:
|
||||
return
|
||||
if _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
cancel_walk(false)
|
||||
return
|
||||
if blocks_walking() or _nav_agent == null:
|
||||
return
|
||||
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
|
||||
return
|
||||
if not _walk_mode_latched:
|
||||
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
|
||||
_walk_latch_probe_frames += 1
|
||||
_nav_agent.get_next_path_position()
|
||||
if _nav_agent.is_target_reachable():
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "nav"
|
||||
else:
|
||||
return
|
||||
else:
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "direct"
|
||||
var next_feet: Vector2 = _nav_agent.get_next_path_position()
|
||||
var final_root: Vector2 = _walk_target_feet + FOOT_OFFSET
|
||||
var dist_to_final: float = absf(_rig.global_position.x - final_root.x)
|
||||
var root_target: Vector2
|
||||
if _walk_mode == "nav":
|
||||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||
_rig.global_position.x = final_root.x
|
||||
_finish_walk()
|
||||
return
|
||||
# A freshly synchronized agent may yield its own feet as the first
|
||||
# waypoint. Treat that zero-progress waypoint as the final segment.
|
||||
var next_progress: float = next_feet.x - _rig.global_position.x
|
||||
var remaining_progress: float = final_root.x - _rig.global_position.x
|
||||
if dist_to_final <= 2.0 * ARRIVE_DISTANCE or remaining_progress * next_progress <= 0.0:
|
||||
root_target = Vector2(final_root.x, _rig.global_position.y)
|
||||
else:
|
||||
root_target = Vector2(next_feet.x, _rig.global_position.y)
|
||||
else:
|
||||
root_target = Vector2(final_root.x, _rig.global_position.y)
|
||||
var before_x: float = _rig.global_position.x
|
||||
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
|
||||
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
|
||||
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
|
||||
_rig.global_position.x = final_root.x
|
||||
_finish_walk()
|
||||
|
||||
func _settle_walk_markers() -> void:
|
||||
if _walk_settle_frames > 0:
|
||||
_walk_settle_frames -= 1
|
||||
_rig._restore_standing_markers()
|
||||
|
||||
func _finish_walk() -> void:
|
||||
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
||||
_rig._anim_player.stop(true)
|
||||
_walking = false
|
||||
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
|
||||
_walk_settle_frames = 0
|
||||
_walk_done = true
|
||||
arrived.emit(_walk_target_feet)
|
||||
|
||||
func begin_terrain_fall() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
terrain_following_enabled = true
|
||||
terrain_pelvis_offset = 0.0
|
||||
_terrain_paused_walk_animation = false
|
||||
_enter_airborne(0.0)
|
||||
_reset_foot_contacts()
|
||||
|
||||
func set_terrain_following_enabled(enabled: bool) -> void:
|
||||
if enabled:
|
||||
if terrain_motion == TerrainMotion.DISABLED:
|
||||
begin_terrain_fall()
|
||||
return
|
||||
terrain_following_enabled = false
|
||||
_cancel_landing_impact(true)
|
||||
_restore_air_pose()
|
||||
terrain_pelvis_offset = 0.0
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
terrain_motion = TerrainMotion.DISABLED
|
||||
_terrain_paused_walk_animation = false
|
||||
_reset_foot_contacts()
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if base != null and target != null:
|
||||
target.position = base.position
|
||||
|
||||
func prepare_for_ragdoll() -> void:
|
||||
if terrain_motion != TerrainMotion.DISABLED:
|
||||
set_terrain_following_enabled(false)
|
||||
|
||||
func reset_for_ragdoll_handoff() -> void:
|
||||
_cancel_landing_impact(false)
|
||||
air_pose_base.clear()
|
||||
air_pose_weight = 0.0
|
||||
_air_time = 0.0
|
||||
_air_entry_root_y = 0.0
|
||||
air_fall_distance = 0.0
|
||||
terrain_motion = TerrainMotion.DISABLED
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
|
||||
func is_action_blocked() -> bool:
|
||||
return terrain_motion == TerrainMotion.LANDING_IMPACT
|
||||
|
||||
func blocks_walking() -> bool:
|
||||
return terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED
|
||||
|
||||
func _reset_foot_contacts() -> void:
|
||||
for leg_name: String in foot_contacts:
|
||||
foot_contacts[leg_name] = {"valid": false, "locked": false}
|
||||
|
||||
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return {"valid": false}
|
||||
var query := PhysicsRayQueryParameters2D.create(world_point + Vector2.UP * TERRAIN_PROBE_UP, world_point + Vector2.DOWN * probe_down)
|
||||
query.collision_mask = 1
|
||||
query.collide_with_areas = false
|
||||
query.exclude = _rig.get_collision_body_rids()
|
||||
var hit: Dictionary = space_state.intersect_ray(query)
|
||||
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
|
||||
return {"valid": false}
|
||||
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
|
||||
if normal.y > TERRAIN_NORMAL_MIN_Y:
|
||||
return {"valid": false}
|
||||
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
|
||||
|
||||
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
|
||||
var best: Dictionary = {"valid": false}
|
||||
for x_offset: float in offsets:
|
||||
var hit: Dictionary = _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
|
||||
if bool(hit.get("valid", false)) and (not bool(best.get("valid", false)) or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y):
|
||||
best = hit
|
||||
return best
|
||||
|
||||
func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
||||
var best_y := bottom_y
|
||||
var best_distance := INF
|
||||
if _rig == null:
|
||||
return best_y
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return best_y
|
||||
for offset: float in RECOVERY_PROBE_X_OFFSETS:
|
||||
var query := PhysicsRayQueryParameters2D.create(
|
||||
Vector2(world_x + offset, top_y - RECOVERY_PROBE_START_OFFSET),
|
||||
Vector2(world_x + offset, bottom_y + RECOVERY_PROBE_END_OFFSET))
|
||||
query.collision_mask = 1
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = exclude
|
||||
var hit := space_state.intersect_ray(query)
|
||||
if hit.is_empty():
|
||||
continue
|
||||
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
|
||||
if hit_position.y < top_y - RECOVERY_PROBE_TOLERANCE or hit_position.y > bottom_y + RECOVERY_PROBE_TOLERANCE:
|
||||
continue
|
||||
var distance := absf(hit_position.y - bottom_y)
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best_y = hit_position.y
|
||||
return best_y
|
||||
|
||||
func _stance_weight(leg_name: String) -> float:
|
||||
if not _walking or _rig._anim_player == null:
|
||||
return 1.0
|
||||
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
|
||||
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
|
||||
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
|
||||
|
||||
func _update_terrain_following(delta: float) -> void:
|
||||
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
|
||||
terrain_pelvis_offset = 0.0
|
||||
return
|
||||
_rig._set_limb_mods_enabled(true)
|
||||
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
||||
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
||||
var center_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
||||
var center_supported: bool = bool(center_probe.get("valid", false)) and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
|
||||
var center_surface_y: float = (center_probe["world_pos"] as Vector2).y if bool(center_probe.get("valid", false)) else INF
|
||||
var solved: Dictionary = {}
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _leg_base_markers[leg_name] as Marker2D
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if base == null or target == null:
|
||||
continue
|
||||
var base_world: Vector2 = base.global_position
|
||||
var probe: Dictionary = _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
|
||||
var probe_delta: float = (probe["world_pos"] as Vector2).y - base_world.y if bool(probe.get("valid", false)) else INF
|
||||
var within_reach: bool = absf(probe_delta) <= PELVIS_MAX_OFFSET
|
||||
if within_reach and center_supported:
|
||||
within_reach = (probe["world_pos"] as Vector2).y <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
|
||||
var stance: float = _stance_weight(leg_name)
|
||||
var contact: Dictionary = foot_contacts[leg_name]
|
||||
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
|
||||
if not bool(contact.get("locked", false)):
|
||||
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
|
||||
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
|
||||
contact["world_pos"] = probe["world_pos"]
|
||||
elif stance < 0.5 or not within_reach:
|
||||
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
|
||||
foot_contacts[leg_name] = contact
|
||||
var floor_world: Vector2 = contact.get("world_pos", base_world) as Vector2 if bool(contact.get("locked", false)) else probe.get("world_pos", base_world) as Vector2
|
||||
solved[leg_name] = {"base_marker": base, "floor": floor_world, "surface": probe.get("world_pos", base_world) if within_reach else base_world, "surface_valid": bool(probe.get("valid", false)) and within_reach, "weight": stance, "target": target}
|
||||
if terrain_motion == TerrainMotion.LANDING_IMPACT:
|
||||
_update_landing_impact(delta)
|
||||
return
|
||||
if terrain_motion == TerrainMotion.AIRBORNE:
|
||||
_update_airborne(delta, solved)
|
||||
return
|
||||
if not center_supported:
|
||||
_enter_airborne(_last_grounded_walk_velocity_x)
|
||||
_update_airborne(delta, solved)
|
||||
return
|
||||
terrain_pelvis_offset = lerpf(0.0, clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET), 1.0 - exp(-PELVIS_RESPONSE * delta))
|
||||
terrain_motion = TerrainMotion.GROUNDED
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
_resume_walk_animation()
|
||||
_rig.global_position.y += terrain_pelvis_offset
|
||||
for leg_name: String in solved:
|
||||
var item: Dictionary = solved[leg_name]
|
||||
var base_after: Vector2 = (item["base_marker"] as Marker2D).global_position
|
||||
var weight: float = float(item["weight"])
|
||||
var surface: Vector2 = item["surface"] as Vector2
|
||||
if bool(item["surface_valid"]):
|
||||
weight = maxf(weight, smoothstep(0.0, 1.0, 1.0 - clampf((surface.y - base_after.y) / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)))
|
||||
var wanted: Vector2 = base_after
|
||||
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, weight)
|
||||
if bool(item["surface_valid"]) and wanted.y > surface.y:
|
||||
wanted.y = surface.y
|
||||
(item["target"] as Marker2D).global_position = wanted
|
||||
|
||||
func _enter_airborne(horizontal_velocity: float) -> void:
|
||||
if terrain_motion != TerrainMotion.AIRBORNE:
|
||||
air_pose_base.clear()
|
||||
for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker != null:
|
||||
air_pose_base[marker_name] = marker.position
|
||||
_air_time = 0.0
|
||||
air_pose_weight = 0.0
|
||||
_air_entry_root_y = _rig.global_position.y
|
||||
air_fall_distance = 0.0
|
||||
terrain_motion = TerrainMotion.AIRBORNE
|
||||
terrain_air_velocity = Vector2(horizontal_velocity, 0.0)
|
||||
if _walking and _rig._anim_player != null and _rig._anim_player.is_playing():
|
||||
_rig._anim_player.pause()
|
||||
_terrain_paused_walk_animation = true
|
||||
_reset_foot_contacts()
|
||||
|
||||
func _update_airborne(delta: float, solved: Dictionary) -> void:
|
||||
terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
|
||||
terrain_air_velocity.y = minf(terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED)
|
||||
_rig.global_position += _sweep_airborne_horizontal_motion(Vector2(terrain_air_velocity.x * delta, 0.0))
|
||||
_rig.global_position.y += terrain_air_velocity.y * delta
|
||||
air_fall_distance = maxf(air_fall_distance, _rig.global_position.y - _air_entry_root_y)
|
||||
_air_time += delta
|
||||
_apply_air_pose(delta)
|
||||
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
||||
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
||||
var landing_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
||||
if bool(center_probe.get("valid", false)) and terrain_air_velocity.y >= 0.0 and landing_delta >= -TERRAIN_SUPPORT_RANGE and landing_delta <= TERRAIN_SUPPORT_RANGE + terrain_air_velocity.y * delta:
|
||||
_rig.global_position.y += landing_delta
|
||||
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
if air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT:
|
||||
_begin_landing(speed, center_probe, solved)
|
||||
else:
|
||||
_restore_air_pose()
|
||||
terrain_motion = TerrainMotion.GROUNDED
|
||||
_resume_walk_animation()
|
||||
|
||||
func _apply_air_pose(delta: float) -> void:
|
||||
if air_pose_base.is_empty():
|
||||
return
|
||||
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
||||
air_pose_weight = lerpf(air_pose_weight, smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed), 1.0 - exp(-AIR_POSE_RESPONSE * delta))
|
||||
var forward_weight: float = smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed)
|
||||
var phase: float = TAU * AIR_FLAIL_HZ * _air_time
|
||||
for marker_name: String in air_pose_base:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var base: Vector2 = air_pose_base[marker_name]
|
||||
var local_phase: float = phase + (PI if marker_name.begins_with("Right") else 0.0)
|
||||
var offset: Vector2
|
||||
if marker_name.ends_with("Hand"):
|
||||
offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * air_pose_weight) + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * air_pose_weight
|
||||
marker.position = base + offset
|
||||
else:
|
||||
offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y)
|
||||
marker.position = base + offset * air_pose_weight
|
||||
|
||||
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
|
||||
if is_zero_approx(motion.x):
|
||||
return motion
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return motion
|
||||
var safe_fraction: float = 1.0
|
||||
var excluded: Array[RID] = []
|
||||
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if own_proxy != null and is_instance_valid(own_proxy):
|
||||
var own_rid: RID = own_proxy.get_rid()
|
||||
if own_rid.is_valid():
|
||||
excluded.append(own_rid)
|
||||
for proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
continue
|
||||
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
continue
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = shape_node.shape
|
||||
query.transform = shape_node.global_transform
|
||||
query.motion = motion
|
||||
query.collision_mask = COLLISION_LAYER
|
||||
query.exclude = excluded
|
||||
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||
if cast.size() >= 1:
|
||||
safe_fraction = minf(safe_fraction, cast[0])
|
||||
if safe_fraction < 0.9999:
|
||||
terrain_air_velocity.x = 0.0
|
||||
return motion * safe_fraction
|
||||
|
||||
func _begin_landing(_impact_speed: float, center_probe: Dictionary, _solved: Dictionary) -> void:
|
||||
terrain_motion = TerrainMotion.LANDING_IMPACT
|
||||
_landing_elapsed = 0.0
|
||||
var weight: float = clampf(inverse_lerp(HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, air_fall_distance), 0.0, 1.0)
|
||||
landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, weight)
|
||||
_landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, weight)
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: _landing_torso_base = torso.position
|
||||
_landing_hand_start.clear(); _landing_contacts.clear()
|
||||
for hand_name: String in ["Left_Hand", "Right_Hand"]:
|
||||
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
||||
if hand != null: _landing_hand_start[hand_name] = hand.position
|
||||
var center_y: float = (center_probe.get("world_pos", _rig.global_position) as Vector2).y
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if target == null: continue
|
||||
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
||||
var probe: Dictionary = _probe_terrain_fan(base.global_position if base != null else target.global_position, TERRAIN_FOOT_PROBE_OFFSETS)
|
||||
var contact: Vector2 = probe["world_pos"] as Vector2 if bool(probe.get("valid", false)) else Vector2(target.global_position.x, center_y)
|
||||
_landing_contacts[leg_name] = contact; target.global_position = contact
|
||||
|
||||
func _update_landing_impact(delta: float) -> void:
|
||||
_landing_elapsed += delta
|
||||
var total: float = LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION
|
||||
var offset: float = landing_compression * (1.0 - pow(1.0 - clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0), 2.0)) if _landing_elapsed <= LANDING_COMPRESS_DURATION else landing_compression * cos(clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) * PI * 0.5)
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base + Vector2.DOWN * offset
|
||||
for leg_name: String in _landing_contacts:
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if target != null: target.global_position = _landing_contacts[leg_name]
|
||||
for hand_name: String in _landing_hand_start:
|
||||
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
||||
if hand != null: hand.position = (_landing_hand_start[hand_name] as Vector2).lerp(air_pose_base.get(hand_name, _landing_hand_start[hand_name]), clampf(_landing_elapsed / total, 0.0, 1.0)) + Vector2.DOWN * _landing_hand_drop * offset / maxf(landing_compression, 0.0001)
|
||||
if _landing_elapsed >= total: _finish_landing_impact()
|
||||
|
||||
func _finish_landing_impact() -> void:
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base
|
||||
_restore_air_pose(); _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0; terrain_motion = TerrainMotion.GROUNDED
|
||||
_resume_walk_animation()
|
||||
|
||||
func _cancel_landing_impact(restore_pose: bool) -> void:
|
||||
if terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose:
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base
|
||||
_landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0
|
||||
|
||||
func _restore_air_pose() -> void:
|
||||
for marker_name: String in air_pose_base:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker != null: marker.position = air_pose_base[marker_name]
|
||||
air_pose_base.clear(); air_pose_weight = 0.0; _air_time = 0.0; _air_entry_root_y = 0.0; air_fall_distance = 0.0
|
||||
|
||||
func _resume_walk_animation() -> void:
|
||||
if _terrain_paused_walk_animation and _walking and _rig._anim_player != null:
|
||||
_rig._anim_player.play()
|
||||
_terrain_paused_walk_animation = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxyogpn8rv8xy
|
||||
@@ -0,0 +1,258 @@
|
||||
class_name StickmanPropInteraction
|
||||
extends Node
|
||||
## Owns the one-prop hand interaction lifecycle for a StickmanRig.
|
||||
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
|
||||
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||
|
||||
const REACH_DURATION: float = 0.18
|
||||
const ARM_REACH: float = 360.0
|
||||
const RELEASE_SPIN: float = 1.5
|
||||
enum State { IDLE, APPROACHING, REACHING, HOLDING, WINDUP }
|
||||
|
||||
signal interaction_finished(succeeded: bool)
|
||||
signal prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _state: State = State.IDLE
|
||||
var _held_prop: PropBlock = null
|
||||
var _reserved_prop: PropBlock = null
|
||||
var _hand: StringName = &"right"
|
||||
var _grip: Dictionary = {}
|
||||
var _saved_hand_target: Vector2 = Vector2.ZERO
|
||||
var _timer: float = 0.0
|
||||
var _pending_action: Dictionary = {}
|
||||
var _saved_physics: Dictionary = {}
|
||||
var _last_rig_rotation: float = 0.0
|
||||
var _angular_snapshot: float = 0.0
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
|
||||
func has_held_prop() -> bool:
|
||||
return _held_prop != null and is_instance_valid(_held_prop)
|
||||
|
||||
func get_held_prop() -> PropBlock:
|
||||
return _held_prop if has_held_prop() else null
|
||||
|
||||
func begin_action(action: Dictionary) -> bool:
|
||||
var type: String = String(action.get("type", ""))
|
||||
match type:
|
||||
"pick_up_prop":
|
||||
return _begin_pickup(action)
|
||||
"drop_prop":
|
||||
if not has_held_prop():
|
||||
return false
|
||||
release_held(&"drop", _rig_velocity(), 0.0, &"prop_dropped")
|
||||
interaction_finished.emit(true)
|
||||
return true
|
||||
"throw_prop":
|
||||
return _begin_throw(action)
|
||||
return false
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if has_held_prop():
|
||||
_update_attachment()
|
||||
if _state == State.APPROACHING:
|
||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
return
|
||||
if _state != State.REACHING and _state != State.WINDUP:
|
||||
return
|
||||
_timer -= delta
|
||||
if _state == State.REACHING:
|
||||
_update_reach_target()
|
||||
if _timer <= 0.0:
|
||||
_complete_pickup()
|
||||
elif _timer <= 0.0:
|
||||
_complete_throw()
|
||||
|
||||
func update_held_attachment(delta: float) -> void:
|
||||
if _rig != null and is_instance_valid(_rig) and delta > 0.0:
|
||||
_angular_snapshot = wrapf(_rig.global_rotation - _last_rig_rotation, -PI, PI) / delta
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
if has_held_prop(): _update_attachment()
|
||||
|
||||
func cancel() -> void:
|
||||
if _state == State.APPROACHING or _state == State.REACHING or _state == State.WINDUP:
|
||||
_restore_hand_target()
|
||||
if _reserved_prop != null and is_instance_valid(_reserved_prop):
|
||||
_reserved_prop.held_by_instance_id = 0
|
||||
_reserved_prop = null
|
||||
_pending_action.clear()
|
||||
_state = State.IDLE
|
||||
|
||||
func release_for_ragdoll() -> void:
|
||||
if has_held_prop():
|
||||
release_held(&"ragdoll", _rig_velocity(), _angular_snapshot if is_finite(_angular_snapshot) else 0.0, &"prop_dropped")
|
||||
|
||||
func release_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||
if has_held_prop():
|
||||
release_held(reason, _rig_velocity(), 0.0, &"prop_dropped")
|
||||
else:
|
||||
cancel()
|
||||
|
||||
func release_held(reason: StringName, linear_velocity: Vector2, angular_velocity: float, event_type: StringName) -> bool:
|
||||
if not has_held_prop():
|
||||
return false
|
||||
var prop: PropBlock = _held_prop
|
||||
_update_attachment()
|
||||
prop.freeze = bool(_saved_physics.get("freeze", false))
|
||||
prop.freeze_mode = int(_saved_physics.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
prop.collision_layer = int(_saved_physics.get("collision_layer", prop.collision_layer))
|
||||
prop.collision_mask = int(_saved_physics.get("collision_mask", prop.collision_mask))
|
||||
prop.z_index = int(_saved_physics.get("z_index", prop.z_index))
|
||||
prop.gravity_scale = float(_saved_physics.get("gravity_scale", prop.gravity_scale))
|
||||
prop.linear_damp = float(_saved_physics.get("linear_damp", prop.linear_damp))
|
||||
prop.angular_damp = float(_saved_physics.get("angular_damp", prop.angular_damp))
|
||||
prop.linear_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
||||
prop.angular_velocity = angular_velocity if is_finite(angular_velocity) else 0.0
|
||||
prop.held_by_instance_id = 0
|
||||
_held_prop = null
|
||||
_reserved_prop = null
|
||||
_saved_physics.clear()
|
||||
_restore_hand_target()
|
||||
_state = State.IDLE
|
||||
prop_transition.emit(event_type, _rig, prop, reason)
|
||||
return true
|
||||
|
||||
static func gravity_for(prop: PropBlock) -> Vector2:
|
||||
var direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
|
||||
var magnitude: float = float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
|
||||
return direction * magnitude * prop.gravity_scale
|
||||
|
||||
func _begin_pickup(action: Dictionary) -> bool:
|
||||
if has_held_prop() or _state != State.IDLE:
|
||||
return false
|
||||
var prop: PropBlock = instance_from_id(int(action.get("target_prop", 0))) as PropBlock
|
||||
if prop == null or not is_instance_valid(prop) or not prop.can_be_picked_up or prop.held_by_instance_id != 0:
|
||||
return false
|
||||
var choice: Dictionary = _choose_grip(prop, String(action.get("hand", "auto")))
|
||||
if choice.is_empty():
|
||||
return false
|
||||
_reserved_prop = prop
|
||||
_reserved_prop.held_by_instance_id = _rig.get_instance_id()
|
||||
_hand = StringName(choice["hand"])
|
||||
_grip = choice["grip"]
|
||||
_saved_hand_target = _hand_marker().global_position
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
_pending_action = action.duplicate(true)
|
||||
var grip_world: Vector2 = _grip_world_point(prop, _grip)
|
||||
if _can_reach(grip_world):
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
else:
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var left: float = prop.to_global(Vector2(bounds.position.x, bounds.get_center().y)).x
|
||||
var right: float = prop.to_global(Vector2(bounds.end.x, bounds.get_center().y)).x
|
||||
var side: float = -1.0 if _rig.global_position.x <= grip_world.x else 1.0
|
||||
var feet: Vector2 = _rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var stand_point: Vector2 = Vector2(left - ARM_REACH * 0.65 if side < 0.0 else right + ARM_REACH * 0.65, feet.y)
|
||||
if _locomotion == null or not _locomotion.walk_to(stand_point):
|
||||
cancel()
|
||||
return false
|
||||
_state = State.APPROACHING
|
||||
return true
|
||||
|
||||
func _complete_pickup() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop) or not _can_reach(_grip_world_point(_reserved_prop, _grip)):
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
_held_prop = _reserved_prop
|
||||
_saved_physics = {"freeze": _held_prop.freeze, "freeze_mode": _held_prop.freeze_mode, "collision_layer": _held_prop.collision_layer, "collision_mask": _held_prop.collision_mask, "z_index": _held_prop.z_index, "gravity_scale": _held_prop.gravity_scale, "linear_damp": _held_prop.linear_damp, "angular_damp": _held_prop.angular_damp}
|
||||
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
_held_prop.freeze = true
|
||||
_held_prop.collision_layer = 0
|
||||
_held_prop.collision_mask = 0
|
||||
_held_prop.linear_velocity = Vector2.ZERO
|
||||
_held_prop.angular_velocity = 0.0
|
||||
_held_prop.z_index += int(_grip.get("z_index_offset", 0))
|
||||
_state = State.HOLDING
|
||||
_update_attachment()
|
||||
prop_transition.emit(&"prop_picked_up", _rig, _held_prop, &"pickup")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _begin_throw(action: Dictionary) -> bool:
|
||||
if not has_held_prop() or _state != State.HOLDING:
|
||||
return false
|
||||
var requested: int = int(action.get("target_prop", _held_prop.get_instance_id()))
|
||||
if requested != _held_prop.get_instance_id() or not action.get("target_position") is Vector2:
|
||||
return false
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, action["target_position"] as Vector2, time, gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
return false
|
||||
_pending_action = action.duplicate(true)
|
||||
_timer = 0.10
|
||||
_state = State.WINDUP
|
||||
return true
|
||||
|
||||
func _complete_throw() -> void:
|
||||
if not has_held_prop() or not _pending_action.get("target_position") is Vector2:
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, _pending_action["target_position"] as Vector2, float(_pending_action.get("flight_time", 0.0)), gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
_restore_hand_target(); _state = State.HOLDING; interaction_finished.emit(false); return
|
||||
release_held(&"throw", solved["velocity"] as Vector2, RELEASE_SPIN, &"prop_thrown")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _choose_grip(prop: PropBlock, requested_hand: String) -> Dictionary:
|
||||
var options: Array[Dictionary] = []
|
||||
for hand_id: String in ["right", "left"]:
|
||||
if requested_hand != "auto" and requested_hand != hand_id:
|
||||
continue
|
||||
var enum_hand: int = PROP_ANCHOR_DATA.Hand.RIGHT if hand_id == "right" else PROP_ANCHOR_DATA.Hand.LEFT
|
||||
for id: String in PROP_ANCHOR_DATA.compatible_grip_ids(prop.anchors, enum_hand):
|
||||
options.append({"hand": hand_id, "grip": prop.anchors[id], "id": id})
|
||||
if options.is_empty():
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var fallback: Dictionary = {"position": bounds.get_center(), "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
for fallback_hand: String in ["right", "left"]:
|
||||
if requested_hand == "auto" or requested_hand == fallback_hand:
|
||||
options.append({"hand": fallback_hand, "grip": fallback, "id": "fallback"})
|
||||
options.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var ad: float = _marker_for(String(a["hand"])).global_position.distance_squared_to(_grip_world_point(prop, a["grip"] as Dictionary))
|
||||
var bd: float = _marker_for(String(b["hand"])).global_position.distance_squared_to(_grip_world_point(prop, b["grip"] as Dictionary))
|
||||
if not is_equal_approx(ad, bd): return ad < bd
|
||||
if String(a["hand"]) != String(b["hand"]): return String(a["hand"]) == "right"
|
||||
return String(a["id"]) < String(b["id"])
|
||||
)
|
||||
return options[0]
|
||||
|
||||
func _update_reach_target() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop):
|
||||
return
|
||||
var goal: Vector2 = _reserved_prop.global_transform * (_grip.get("position", Vector2.ZERO) as Vector2)
|
||||
_hand_marker().global_position = _saved_hand_target.lerp(goal, clampf(1.0 - _timer / REACH_DURATION, 0.0, 1.0))
|
||||
|
||||
func _update_attachment() -> void:
|
||||
if not has_held_prop(): return
|
||||
var wrist: Transform2D = _hand_marker().global_transform
|
||||
_held_prop.global_transform = PROP_ANCHOR_DATA.solve_attachment_transform(wrist, _grip, _held_prop.scale, _rig.facing_profile == StickmanRig.FacingProfile.LEFT)
|
||||
|
||||
func _can_reach(point: Vector2) -> bool:
|
||||
return _hand_marker().global_position.distance_to(point) <= ARM_REACH
|
||||
|
||||
func _grip_world_point(prop: PropBlock, grip: Dictionary) -> Vector2:
|
||||
return prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)
|
||||
|
||||
func _hand_marker() -> Marker2D:
|
||||
return _marker_for(String(_hand))
|
||||
|
||||
func _marker_for(hand: String) -> Marker2D:
|
||||
return _rig._get_ik_marker("Right_Hand" if hand == "right" else "Left_Hand")
|
||||
|
||||
func _restore_hand_target() -> void:
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker != null and is_instance_valid(marker): marker.global_position = _saved_hand_target
|
||||
|
||||
func _rig_velocity() -> Vector2:
|
||||
if _locomotion != null and _locomotion.terrain_air_velocity.is_finite(): return _locomotion.terrain_air_velocity
|
||||
return Vector2.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://p3owck52oug2
|
||||
@@ -0,0 +1,787 @@
|
||||
class_name StickmanRagdoll
|
||||
extends Node
|
||||
## Owns procedural ragdoll physics. StickmanRig remains the public state facade.
|
||||
|
||||
signal recovery_requested()
|
||||
signal rest_state_changed(at_rest: bool)
|
||||
signal state_transition_requested(next_state: int)
|
||||
|
||||
const CONTAINER_NAME: StringName = &"RagdollBodyContainer"
|
||||
const LIMB_RADIUS: float = 8.0
|
||||
const TORSO_RADIUS: float = 12.0
|
||||
const HEAD_RADIUS: float = 100.0
|
||||
const VISUAL_COLOR: Color = Color(0.445488, 0.445488, 0.445488)
|
||||
const CIRCLE_SEGMENTS: int = 32
|
||||
const REST_LINEAR_THRESHOLD: float = 5.0
|
||||
const REST_ANGULAR_THRESHOLD: float = 0.1
|
||||
const STABILIZATION_DELAY: float = 0.1
|
||||
const TARGET_SOFTNESS: float = 0.2
|
||||
const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0)
|
||||
const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
|
||||
const GROUND_PROBE_START_OFFSET := 50.0
|
||||
const GROUND_PROBE_END_OFFSET := 1500.0
|
||||
const GROUND_PROBE_TOLERANCE := 200.0
|
||||
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||||
const STAND_UP_DURATION := 2.0
|
||||
const STAND_POSE: Dictionary = {
|
||||
"Torso": {"pos": Vector2(0, 10), "rot": 0.0},
|
||||
"Head": {"pos": Vector2(100, -614), "rot": 0.0},
|
||||
"Left_Hand": {"pos": Vector2(90, 110), "rot": 0.0},
|
||||
"Right_Hand": {"pos": Vector2(-90, 110), "rot": 0.0},
|
||||
"Left_Leg": {"pos": Vector2(-110, 380), "rot": 0.0},
|
||||
"Right_Leg": {"pos": Vector2(110, 390), "rot": 0.0},
|
||||
}
|
||||
const IK_TARGET_PATHS: Dictionary = {
|
||||
"Torso": "IK_Targets/Torso", "Head": "IK_Targets/Head",
|
||||
"Left_Hand": "IK_Targets/Left_Hand", "Right_Hand": "IK_Targets/Right_Hand",
|
||||
"Left_Leg": "IK_Targets/Left_Leg", "Right_Leg": "IK_Targets/Right_Leg",
|
||||
}
|
||||
const LIMB_BONE_PATHS: Dictionary = {
|
||||
"LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"],
|
||||
"RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"],
|
||||
"LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"],
|
||||
"RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"],
|
||||
}
|
||||
const LIMB_BODY_KEYS: Dictionary = {
|
||||
"LeftArm": ["left_upper_arm", "left_lower_arm"],
|
||||
"RightArm": ["right_upper_arm", "right_lower_arm"],
|
||||
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
|
||||
"RightLeg": ["right_upper_leg", "right_lower_leg"],
|
||||
}
|
||||
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
|
||||
}
|
||||
const RECOVERY_JOINT_PATH: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
|
||||
}
|
||||
|
||||
const BODIES: Array[Dictionary] = [
|
||||
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "radius": TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
|
||||
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "radius": HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
|
||||
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
]
|
||||
const JOINTS: Array[Dictionary] = [
|
||||
{ "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" },
|
||||
]
|
||||
|
||||
## Per-profile elbow/knee limits. Side-profile leg hinges include the straight
|
||||
## pose (0 degrees) and allow both knees to fold toward the same profile-relative
|
||||
## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff.
|
||||
## The component owns these settings because it creates the PinJoint2D network.
|
||||
const JOINT_BAND_LIMITS: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {
|
||||
"left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": -5.0, "upper": 150.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||||
},
|
||||
StickmanRig.FacingProfile.LEFT: {
|
||||
"left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 },
|
||||
},
|
||||
StickmanRig.FacingProfile.RIGHT: {
|
||||
"left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
|
||||
},
|
||||
}
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _skeleton: Skeleton2D = null
|
||||
var _body_container: Node2D = null
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _torso_bone: Bone2D = null
|
||||
var _head_bone: Bone2D = null
|
||||
var _head_look_at: SkeletonModification2DLookAt = null
|
||||
var _limb_bones: Dictionary = {}
|
||||
var _bend_modifications: Dictionary = {}
|
||||
var _root: Node2D = null
|
||||
var _bodies: Dictionary = {}
|
||||
var _previous_position: Vector2 = Vector2.ZERO
|
||||
var _previous_rotation: float = 0.0
|
||||
var _linear_velocity: Vector2 = Vector2.ZERO
|
||||
var _angular_velocity: float = 0.0
|
||||
var _rest_timer: float = 0.0
|
||||
var _stabilize_timer: float = 0.0
|
||||
var _at_rest: bool = false
|
||||
var _captured_pose: Dictionary = {}
|
||||
var _captured_landing_center := Vector2.ZERO
|
||||
var _captured_ground_y := 0.0
|
||||
var _stand_up_tween: Tween = null
|
||||
var _stand_up_generation := 0
|
||||
var _recovery_bones: Array[Dictionary] = []
|
||||
var _pre_ragdoll_state: Dictionary = {}
|
||||
var _probe_top_y := 0.0
|
||||
var _probe_bottom_y := 0.0
|
||||
var _probe_exclude: Array[RID] = []
|
||||
var _spawn_pose: Dictionary = {}
|
||||
var _spawn_limb_rotations: Dictionary = {}
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
|
||||
func initialize(skeleton: Skeleton2D) -> void:
|
||||
_skeleton = skeleton
|
||||
_body_container = _rig.get_node_or_null(NodePath("Body")) as Node2D
|
||||
_anim_player = _rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
|
||||
if _skeleton != null:
|
||||
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
_resolve_limb_bones()
|
||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||
if stack != null:
|
||||
for index: int in stack.modification_count:
|
||||
var modification: SkeletonModification2D = stack.get_modification(index)
|
||||
if modification is SkeletonModification2DLookAt:
|
||||
_head_look_at = modification as SkeletonModification2DLookAt
|
||||
elif modification is SkeletonModification2DTwoBoneIK:
|
||||
var ik := modification as SkeletonModification2DTwoBoneIK
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
if ik.joint_two_bone2d_node == NodePath(String((LIMB_BONE_PATHS[joint] as Array)[1])):
|
||||
_bend_modifications[joint] = ik
|
||||
break
|
||||
_previous_position = _rig.global_position
|
||||
_previous_rotation = _rig.global_rotation
|
||||
|
||||
|
||||
## Runs after the physics step and before drawing. Godot's PinJoint2D can let a
|
||||
## straight knee cross its angular limit during the step; correcting here keeps
|
||||
## that invalid intermediate pose from ever reaching the rendered frame.
|
||||
func _process(_delta: float) -> void:
|
||||
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
_enforce_side_profile_leg_bends()
|
||||
|
||||
func _resolve_limb_bones() -> void:
|
||||
_limb_bones.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var paths: Array = LIMB_BONE_PATHS[joint]
|
||||
var upper := _skeleton.get_node_or_null(NodePath(String(paths[0]))) as Bone2D
|
||||
var lower := _skeleton.get_node_or_null(NodePath(String(paths[1]))) as Bone2D
|
||||
if upper != null and lower != null:
|
||||
_limb_bones[joint] = {"upper": upper, "lower": lower}
|
||||
|
||||
func tick_pre_movement(delta: float, rig_state: int, rest_timeout: float, auto_recover: bool) -> void:
|
||||
if _rig == null: return
|
||||
if delta > 0.0:
|
||||
_linear_velocity = (_rig.global_position - _previous_position) / delta
|
||||
_angular_velocity = wrapf(_rig.global_rotation - _previous_rotation, -PI, PI) / delta
|
||||
_previous_position = _rig.global_position
|
||||
_previous_rotation = _rig.global_rotation
|
||||
if rig_state != StickmanRig.RigState.RAGDOLL: return
|
||||
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
|
||||
if torso == null or not is_instance_valid(torso): return
|
||||
var resting: bool = torso.sleeping or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
|
||||
if resting != _at_rest: _at_rest = resting; rest_state_changed.emit(resting)
|
||||
if not resting: _rest_timer = 0.0; _stabilize_timer = 0.0; return
|
||||
if not auto_recover: _rest_timer = 0.0; return
|
||||
_rest_timer += delta
|
||||
if _rest_timer >= rest_timeout:
|
||||
_stabilize_timer += delta
|
||||
if _stabilize_timer >= STABILIZATION_DELAY: recovery_requested.emit()
|
||||
|
||||
func build() -> bool:
|
||||
if _rig == null or _skeleton == null: return false
|
||||
var parent: Node = _rig.get_parent()
|
||||
if parent == null: parent = _rig.get_tree().current_scene
|
||||
if parent == null: return false
|
||||
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
|
||||
for entry: Dictionary in BODIES: _build_body(entry)
|
||||
_stabilize_side_profile_leg_bends()
|
||||
for entry: Dictionary in JOINTS: _build_joint(entry)
|
||||
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
|
||||
if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity
|
||||
reset_rest_state()
|
||||
return torso != null
|
||||
|
||||
|
||||
## A walk keyframe can put a leg exactly straight. At that singularity the
|
||||
## physics solver has no geometric knee side to preserve, so floating-point
|
||||
## noise can make either knee fold as if the opposite facing profile were in
|
||||
## use. Give only near-straight side-profile legs a small world-space bend
|
||||
## toward the profile's established rear side before the joints are created.
|
||||
func _stabilize_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_delta := SIDE_PROFILE_KNEE_BIAS \
|
||||
if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \
|
||||
else -SIDE_PROFILE_KNEE_BIAS
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD:
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var lower_dir := upper_dir.rotated(desired_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
|
||||
|
||||
## PinJoint2D can cross its angular limit near a perfectly straight chain in
|
||||
## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select
|
||||
## the opposite solution. Normal motion on the intended side remains physical.
|
||||
func _enforce_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0
|
||||
var desired_delta_sign := -desired_side
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var hip := upper.global_position - upper_dir * upper_half
|
||||
var lower_dir_current := Vector2.from_angle(lower.global_rotation)
|
||||
var ankle := lower.global_position + lower_dir_current * lower_half
|
||||
var current_side := (ankle - hip).cross(knee - hip)
|
||||
if current_side * desired_side > 0.0:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
var corrected_delta := desired_delta_sign * minf(
|
||||
maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0))
|
||||
var lower_dir := upper_dir.rotated(corrected_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
lower.angular_velocity = upper.angular_velocity
|
||||
|
||||
func destroy() -> void:
|
||||
if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free()
|
||||
_root = null; _bodies.clear(); reset_rest_state()
|
||||
|
||||
func get_bodies() -> Dictionary: return _bodies
|
||||
func is_at_rest() -> bool: return _at_rest
|
||||
func reset_rest_state() -> void: _at_rest = false; _rest_timer = 0.0; _stabilize_timer = 0.0
|
||||
func apply_velocity_boost(velocity: Vector2) -> void:
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass)
|
||||
|
||||
func enter_ragdoll() -> bool:
|
||||
if _rig == null or _skeleton == null or _body_container == null:
|
||||
push_warning("StickmanRagdoll: cannot enter ragdoll; missing rig nodes.")
|
||||
return false
|
||||
_capture_pre_ragdoll_state()
|
||||
if _locomotion != null:
|
||||
_locomotion.clear_impact_reactions()
|
||||
_locomotion.set_collision_bodies_enabled(false)
|
||||
_locomotion.cancel_walk(false)
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop(true)
|
||||
if not build():
|
||||
return false
|
||||
_body_container.visible = false
|
||||
_body_container.modulate.a = 1.0
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = false
|
||||
reset_rest_state()
|
||||
return true
|
||||
|
||||
func request_recovery() -> bool:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.RAGDOLL:
|
||||
return false
|
||||
_capture_ragdoll_pose()
|
||||
destroy()
|
||||
_reanchor_root_to_landing()
|
||||
state_transition_requested.emit(StickmanRig.RigState.RECOVERING)
|
||||
_snap_skeleton_to_pose()
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
_play_stand_up()
|
||||
return true
|
||||
|
||||
func cancel_recovery() -> void:
|
||||
_stand_up_generation += 1
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
|
||||
func snap_to_standing(previous_state: int) -> void:
|
||||
if _locomotion != null:
|
||||
_locomotion.set_terrain_following_enabled(false)
|
||||
if previous_state == StickmanRig.RigState.RAGDOLL:
|
||||
destroy()
|
||||
elif previous_state == StickmanRig.RigState.RECOVERING:
|
||||
cancel_recovery()
|
||||
if _locomotion != null:
|
||||
_locomotion.clear_impact_reactions(true)
|
||||
_rig._hide_speech()
|
||||
# The factory captures the adapted skeleton's real authored spawn pose. That
|
||||
# is also the pose shown by the placement ghost, so every forced reset must
|
||||
# restore it instead of the legacy, one-size-fits-all STAND_POSE fallback.
|
||||
_restore_standing_markers(true)
|
||||
_rearm_ik_stack()
|
||||
_set_limb_mods_enabled(true)
|
||||
if previous_state == StickmanRig.RigState.ANIMATED:
|
||||
_rig._apply_head_lookat_mirror_mode()
|
||||
else:
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
reset_rest_state()
|
||||
_clear_recovery_capture()
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
_rig._apply_idle_limb_solver_mode(_spawn_limb_rotations)
|
||||
if previous_state != StickmanRig.RigState.ANIMATED:
|
||||
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
|
||||
|
||||
func initialize_spawn_pose_from_skeleton() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||||
if skeleton == null:
|
||||
push_warning("StickmanRagdoll: cannot initialize spawn pose; missing Skeleton2D.")
|
||||
return
|
||||
var pose: Dictionary = {}
|
||||
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
if torso != null:
|
||||
pose["Torso"] = {"pos": _rig.to_local(torso.global_position), "rot": torso.rotation}
|
||||
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head != null:
|
||||
var head_tip := head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
|
||||
pose["Head"] = {"pos": _rig.to_local(head_tip), "rot": 0.0}
|
||||
var endpoints: Dictionary = {
|
||||
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm", "Right_Hand": "Torso/RightUpperArm/RightLowerArm",
|
||||
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg", "Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
|
||||
}
|
||||
for marker_name: String in endpoints:
|
||||
var bone := skeleton.get_node_or_null(NodePath(String(endpoints[marker_name]))) as Bone2D
|
||||
if bone == null:
|
||||
return
|
||||
var endpoint := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
pose[marker_name] = {"pos": _rig.to_local(endpoint), "rot": 0.0}
|
||||
if pose.size() != IK_TARGET_PATHS.size():
|
||||
return
|
||||
for marker_name: String in pose:
|
||||
var marker := _rig.get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
|
||||
if marker == null:
|
||||
return
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
|
||||
var base_marker := _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
|
||||
if base_marker != null:
|
||||
base_marker.position = marker.position
|
||||
_spawn_pose = pose.duplicate(true)
|
||||
_spawn_limb_rotations.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var pair: Array = LIMB_BONE_PATHS[joint]
|
||||
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
|
||||
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
|
||||
if upper != null and lower != null:
|
||||
_spawn_limb_rotations[joint] = {"upper": upper.rotation, "lower": lower.rotation}
|
||||
|
||||
func get_spawn_limb_rotations() -> Dictionary:
|
||||
return _spawn_limb_rotations
|
||||
|
||||
func get_spawn_pose() -> Dictionary:
|
||||
return _spawn_pose.duplicate(true)
|
||||
|
||||
func get_captured_pose() -> Dictionary:
|
||||
return _captured_pose
|
||||
|
||||
func _capture_pre_ragdoll_state() -> void:
|
||||
_pre_ragdoll_state.clear()
|
||||
var markers: Dictionary = {}
|
||||
for marker_name: String in STAND_POSE:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker != null:
|
||||
markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
|
||||
_pre_ragdoll_state["markers"] = markers
|
||||
var rotations: Dictionary = {}
|
||||
for joint: String in _limb_bones:
|
||||
var pair: Dictionary = _limb_bones[joint]
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
rotations[joint] = {"upper": upper.rotation if upper != null else 0.0, "lower": lower.rotation if lower != null else 0.0}
|
||||
_pre_ragdoll_state["limb_rotations"] = rotations
|
||||
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null else 0.0
|
||||
_pre_ragdoll_state["facing_profile"] = int(_rig.facing_profile)
|
||||
var bends: Dictionary = {}
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
bends[joint] = _rig.get_joint_bend_flipped(joint)
|
||||
_pre_ragdoll_state["bends"] = bends
|
||||
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null else false
|
||||
var leg_sides: Dictionary = {}
|
||||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||||
var pair: Dictionary = _limb_bones.get(leg_joint, {})
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
var marker := _get_ik_marker("Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg")
|
||||
if upper == null or lower == null or marker == null:
|
||||
continue
|
||||
var side := (marker.global_position - upper.global_position).cross(lower.global_position - upper.global_position)
|
||||
leg_sides[leg_joint] = {"side": 1.0 if side >= 0.0 else -1.0, "flag": _rig.get_joint_bend_flipped(leg_joint)}
|
||||
_pre_ragdoll_state["leg_sides"] = leg_sides
|
||||
var torso_entry_rot := 0.0
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
torso_entry_rot = torso_marker.rotation
|
||||
var spine_entry_rel := 0.0
|
||||
if _head_bone != null and _torso_bone != null:
|
||||
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - _rig.global_rotation
|
||||
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
|
||||
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||||
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-_rig.global_rotation) - head_entry_chain
|
||||
|
||||
func _capture_ragdoll_pose() -> void:
|
||||
_captured_pose.clear()
|
||||
_captured_landing_center = Vector2.ZERO
|
||||
_captured_ground_y = 0.0
|
||||
var top_y := INF
|
||||
var bottom_y := -INF
|
||||
var exclude_rids: Array[RID] = []
|
||||
for key: String in _bodies:
|
||||
var body := _bodies[key] as RigidBody2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
continue
|
||||
var half := float(body.get_meta("half_height", 0.0))
|
||||
var radius := float(body.get_meta("radius", 0.0))
|
||||
var extent := half + radius
|
||||
top_y = minf(top_y, body.global_position.y - extent)
|
||||
bottom_y = maxf(bottom_y, body.global_position.y + extent)
|
||||
exclude_rids.append(body.get_rid())
|
||||
_captured_pose[key] = {"pos": _rig.to_local(body.global_position), "rot": body.global_rotation - _rig.global_rotation, "half": half}
|
||||
var torso := _bodies.get("torso") as RigidBody2D
|
||||
if torso != null and is_instance_valid(torso):
|
||||
_captured_landing_center = torso.global_position
|
||||
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
|
||||
if is_equal_approx(_captured_ground_y, bottom_y):
|
||||
_captured_ground_y = torso.global_position.y + float(torso.get_meta("radius", 0.0))
|
||||
_probe_top_y = top_y
|
||||
_probe_bottom_y = bottom_y
|
||||
_probe_exclude = exclude_rids
|
||||
|
||||
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
||||
if _locomotion != null:
|
||||
return _locomotion.probe_ground_y(world_x, top_y, bottom_y, exclude)
|
||||
var best_y := bottom_y
|
||||
var space_state := _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return best_y
|
||||
var best_dist := INF
|
||||
for offset: float in GROUND_PROBE_X_OFFSETS:
|
||||
var query := PhysicsRayQueryParameters2D.create(Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
|
||||
query.collision_mask = 1
|
||||
query.exclude = exclude
|
||||
var hit := space_state.intersect_ray(query)
|
||||
if hit.is_empty():
|
||||
continue
|
||||
var y := (hit.get("position", Vector2.ZERO) as Vector2).y
|
||||
if y < top_y - GROUND_PROBE_TOLERANCE or y > bottom_y + GROUND_PROBE_TOLERANCE:
|
||||
continue
|
||||
var distance := absf(y - bottom_y)
|
||||
if distance < best_dist:
|
||||
best_dist = distance
|
||||
best_y = y
|
||||
return best_y
|
||||
|
||||
func _reanchor_root_to_landing() -> void:
|
||||
if not _captured_pose.has("torso"):
|
||||
return
|
||||
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
|
||||
var old_root := _rig.global_position
|
||||
var foot_offset := _locomotion.get_foot_offset() if _locomotion != null else Vector2(0.0, -385.0)
|
||||
_rig.global_position = feet_world + foot_offset
|
||||
var shift := old_root - _rig.global_position
|
||||
var local_shift := _rig.to_local(shift) - _rig.to_local(Vector2.ZERO)
|
||||
for key: String in _captured_pose:
|
||||
var entry: Dictionary = _captured_pose[key]
|
||||
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
|
||||
|
||||
func _clear_recovery_capture() -> void:
|
||||
_captured_pose.clear()
|
||||
_captured_landing_center = Vector2.ZERO
|
||||
_captured_ground_y = 0.0
|
||||
_pre_ragdoll_state.clear()
|
||||
_probe_top_y = 0.0
|
||||
_probe_bottom_y = 0.0
|
||||
_probe_exclude.clear()
|
||||
|
||||
func _chain_angle_from_captured(captured_rel: float) -> float:
|
||||
return -captured_rel if _rig.facing_profile == StickmanRig.FacingProfile.LEFT else captured_rel
|
||||
|
||||
func _wrap_toward(value: float, target: float) -> float:
|
||||
return target + wrapf(value - target, -PI, PI)
|
||||
|
||||
func _natural_joint_target(current: float, target: float, offset: float) -> float:
|
||||
var a := wrapf(current + offset, -PI, PI)
|
||||
var b := wrapf(target + offset, -PI, PI)
|
||||
var delta := wrapf(b - a, -PI, PI)
|
||||
if a * b < 0.0 and absf(a) + absf(b) > PI:
|
||||
delta += TAU if delta < 0.0 else -TAU
|
||||
return current + delta
|
||||
|
||||
func _set_limb_mods_enabled(enabled: bool) -> void:
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
var modification := _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
||||
if modification != null and is_instance_valid(modification):
|
||||
modification.enabled = enabled
|
||||
|
||||
func _restore_head_lookat_state() -> void:
|
||||
if _head_look_at != null and is_instance_valid(_head_look_at):
|
||||
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
|
||||
|
||||
func _rearm_ik_stack() -> void:
|
||||
if _skeleton == null or not is_instance_valid(_skeleton):
|
||||
return
|
||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||
if stack != null:
|
||||
if not stack.get_is_setup():
|
||||
stack.setup()
|
||||
stack.enabled = true
|
||||
_skeleton.set_process_internal(true)
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
|
||||
if driver != null:
|
||||
driver.update_position = true
|
||||
driver.update_rotation = true
|
||||
driver.update_scale = true
|
||||
|
||||
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
|
||||
var pose := _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
|
||||
for marker_name: String in pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
if marker_name == "Torso":
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
|
||||
func _get_ik_marker(marker_name: String) -> Marker2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
var path := String(IK_TARGET_PATHS.get(marker_name, ""))
|
||||
return _rig.get_node_or_null(NodePath(path)) as Marker2D if not path.is_empty() else null
|
||||
|
||||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
var pose: Dictionary = _captured_pose.get(body_key, {})
|
||||
if marker == null or pose.is_empty():
|
||||
return
|
||||
var direction := Vector2.from_angle(float(pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var center_world := _rig.to_global(pose.get("pos", Vector2.ZERO))
|
||||
marker.position = _rig.to_local(center_world + direction * float(pose.get("half", 0.0)))
|
||||
|
||||
func _snap_skeleton_to_pose() -> void:
|
||||
_recovery_bones.clear()
|
||||
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
|
||||
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||||
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||||
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
|
||||
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
|
||||
var theta_t := 0.0
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
var torso_pose: Dictionary = _captured_pose.get("torso", {})
|
||||
if not torso_pose.is_empty():
|
||||
var torso_rel := float(torso_pose.get("rot", 0.0))
|
||||
var spine_dir := Vector2.from_angle(torso_rel + _rig.global_rotation)
|
||||
var center_world := _rig.to_global(torso_pose.get("pos", Vector2.ZERO))
|
||||
torso_marker.position = _rig.to_local(center_world - spine_dir * float(torso_pose.get("half", 0.0)))
|
||||
var raw_rotation := _chain_angle_from_captured(torso_rel) - torso_chain_offset
|
||||
var pre_torso_rotation := float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rotation))
|
||||
theta_t = wrapf(_wrap_toward(raw_rotation, pre_torso_rotation), -PI, PI)
|
||||
torso_marker.rotation = theta_t
|
||||
var head_marker := _get_ik_marker("Head")
|
||||
var head_pose: Dictionary = _captured_pose.get("head", {})
|
||||
if head_marker != null and not head_pose.is_empty():
|
||||
head_marker.position = head_pose.get("pos", head_marker.position)
|
||||
_set_marker_from_body("Left_Hand", "left_lower_arm")
|
||||
_set_marker_from_body("Right_Hand", "right_lower_arm")
|
||||
_set_marker_from_body("Left_Leg", "left_lower_leg")
|
||||
_set_marker_from_body("Right_Leg", "right_lower_leg")
|
||||
for marker_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
var body_key := "left_lower_leg" if marker_name == "Left_Leg" else "right_lower_leg"
|
||||
if marker == null or (_captured_pose.get(body_key, {}) as Dictionary).is_empty():
|
||||
continue
|
||||
var ankle_world := _rig.to_global(marker.position)
|
||||
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
|
||||
if ankle_world.y < ground_y:
|
||||
ankle_world.y = ground_y
|
||||
marker.position = _rig.to_local(ankle_world)
|
||||
if not (_captured_pose.get("torso", {}) as Dictionary).is_empty():
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var pair: Dictionary = _limb_bones.get(joint, {})
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
if upper == null or lower == null:
|
||||
continue
|
||||
var keys: Array = LIMB_BODY_KEYS[joint]
|
||||
var upper_pose: Dictionary = _captured_pose.get(keys[0], {})
|
||||
var lower_pose: Dictionary = _captured_pose.get(keys[1], {})
|
||||
if upper_pose.is_empty() or lower_pose.is_empty():
|
||||
continue
|
||||
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
|
||||
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
|
||||
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
|
||||
upper.rotation = _wrap_toward(upper_chain - theta_t - deg_to_rad(upper.bone_angle), float(pre_pair.get("upper", upper.rotation)))
|
||||
lower.rotation = _wrap_toward(lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), float(pre_pair.get("lower", lower.rotation)))
|
||||
upper.rotation = wrapf(upper.rotation, -PI, PI)
|
||||
lower.rotation = wrapf(lower.rotation, -PI, PI)
|
||||
if joint == "LeftLeg" or joint == "RightLeg":
|
||||
continue
|
||||
var target_upper := upper.rotation + wrapf(float(pre_pair.get("upper", upper.rotation)) - upper.rotation, -PI, PI)
|
||||
var pre_lower := float(pre_pair.get("lower", lower.rotation))
|
||||
var path_override := String((RECOVERY_JOINT_PATH.get(_rig.facing_profile, {}) as Dictionary).get(joint, ""))
|
||||
var target_lower := lower.rotation + wrapf(pre_lower - lower.rotation, -PI, PI) if path_override == "shortest" else _natural_joint_target(lower.rotation, pre_lower, deg_to_rad(lower.bone_angle - upper.bone_angle))
|
||||
_recovery_bones.append({"bone": upper, "target": target_upper})
|
||||
_recovery_bones.append({"bone": lower, "target": target_lower})
|
||||
if not head_pose.is_empty() and _head_bone != null:
|
||||
var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t
|
||||
_head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI)
|
||||
_recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)})
|
||||
_set_limb_mods_enabled(false)
|
||||
if _head_look_at != null:
|
||||
_head_look_at.enabled = false
|
||||
_rearm_ik_stack()
|
||||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||||
var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
|
||||
if modification == null:
|
||||
continue
|
||||
var entry: Dictionary = (_pre_ragdoll_state.get("leg_sides", {}) as Dictionary).get(leg_joint, {})
|
||||
var forced: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(_rig.facing_profile, {})
|
||||
if forced.has(leg_joint):
|
||||
modification.flip_bend_direction = bool(forced[leg_joint])
|
||||
elif not entry.is_empty():
|
||||
var upper_key := "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
|
||||
var lower_key := "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
|
||||
var up_pose: Dictionary = _captured_pose.get(upper_key, {})
|
||||
var low_pose: Dictionary = _captured_pose.get(lower_key, {})
|
||||
if not up_pose.is_empty() and not low_pose.is_empty():
|
||||
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var center_u := _rig.to_global(up_pose.get("pos", Vector2.ZERO))
|
||||
var hip := center_u - dir_u * float(up_pose.get("half", 0.0))
|
||||
var knee := center_u + dir_u * float(up_pose.get("half", 0.0))
|
||||
var ankle := _rig.to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * float(low_pose.get("half", 0.0))
|
||||
var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0
|
||||
modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false))
|
||||
modification.enabled = true
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
|
||||
func _play_stand_up() -> void:
|
||||
_stand_up_generation += 1
|
||||
var generation := _stand_up_generation
|
||||
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
|
||||
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
|
||||
for entry: Dictionary in _recovery_bones:
|
||||
var bone := entry.get("bone") as Bone2D
|
||||
if bone != null:
|
||||
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
|
||||
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
|
||||
|
||||
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||||
var tween := create_tween()
|
||||
tween.set_parallel(true).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
for marker_name: String in target_pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = target_pose[marker_name]
|
||||
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
|
||||
if marker_name == "Torso":
|
||||
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
|
||||
return tween
|
||||
|
||||
func _on_stand_up_finished(generation: int) -> void:
|
||||
if generation != _stand_up_generation or _rig.state != StickmanRig.RigState.RECOVERING:
|
||||
return
|
||||
var recovered_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||||
_rig.set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(_rig.facing_profile))))
|
||||
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
if bends.has(joint):
|
||||
_rig.set_joint_bend_flipped(joint, bool(bends[joint]))
|
||||
_set_limb_mods_enabled(true)
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
_rearm_ik_stack()
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
|
||||
_rig._apply_idle_limb_solver_mode(recovered_rotations)
|
||||
|
||||
func _build_body(entry: Dictionary) -> void:
|
||||
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
|
||||
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
|
||||
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
|
||||
var shape := CollisionShape2D.new()
|
||||
if str(entry["kind"]) == "visual":
|
||||
var visual := _rig.get_node_or_null(NodePath(str(entry["node_path"]))) as Node2D
|
||||
if visual == null: return
|
||||
var circle := CircleShape2D.new(); circle.radius = float(entry["radius"]); shape.shape = circle; body.add_child(shape); body.position = visual.global_position; body.set_meta("half_height", 0.0); body.set_meta("radius", float(entry["radius"]))
|
||||
if not _clone_visuals(body, str(entry["visual_path"])): _add_circle(body, float(entry["radius"]))
|
||||
else:
|
||||
var bone := _skeleton.get_node_or_null(NodePath(str(entry["node_path"]))) as Bone2D
|
||||
if bone == null: return
|
||||
var origin: Vector2 = bone.global_position; var tip: Vector2
|
||||
if key == "torso":
|
||||
var head := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head == null: return
|
||||
tip = head.global_position
|
||||
else: tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
var length: float = origin.distance_to(tip); var capsule := CapsuleShape2D.new(); capsule.height = length; capsule.radius = float(entry["radius"]); shape.shape = capsule; shape.rotation = -PI * 0.5; body.add_child(shape); body.position = (origin + tip) * 0.5; body.rotation = (tip - origin).angle(); body.set_meta("half_height", length * 0.5); body.set_meta("radius", float(entry["radius"]))
|
||||
if not _clone_visuals(body, str(entry["visual_path"])): _add_capsule(body, length, float(entry["radius"]))
|
||||
_root.add_child(body); _bodies[key] = body
|
||||
|
||||
func _build_joint(entry: Dictionary) -> void:
|
||||
var child := _bodies.get(str(entry["child"])) as RigidBody2D; var parent := _bodies.get(str(entry["parent"])) as RigidBody2D; var bone := _skeleton.get_node_or_null(NodePath(str(entry["pin"]))) as Bone2D
|
||||
if child == null or parent == null or bone == null: return
|
||||
var pin := PinJoint2D.new(); pin.name = "RagdollPin_" + str(entry["child"]); pin.position = bone.global_position; _root.add_child(pin); pin.node_a = pin.get_path_to(parent); pin.node_b = pin.get_path_to(child); pin.softness = TARGET_SOFTNESS
|
||||
_apply_joint_limits(pin, str(entry["limit"]), str(entry["child"]))
|
||||
|
||||
func _apply_joint_limits(pin: PinJoint2D, limit: String, child_key: String) -> void:
|
||||
if limit == "shoulder":
|
||||
pin.angular_limit_enabled = true; pin.angular_limit_lower = -deg_to_rad(160.0); pin.angular_limit_upper = deg_to_rad(160.0)
|
||||
elif limit != "neck":
|
||||
var profile: int = int(_rig.facing_profile) if _rig != null else StickmanRig.FacingProfile.FORWARD
|
||||
var band: Dictionary = JOINT_BAND_LIMITS.get(profile, {}).get(child_key, {})
|
||||
if band.is_empty(): band = { "lower": -5.0 if limit == "cw" else -150.0, "upper": 150.0 if limit == "cw" else 5.0 }
|
||||
pin.angular_limit_enabled = true; pin.angular_limit_lower = deg_to_rad(float(band["lower"])); pin.angular_limit_upper = deg_to_rad(float(band["upper"]))
|
||||
else: pin.angular_limit_enabled = false
|
||||
|
||||
func _clone_visuals(body: RigidBody2D, path: String) -> bool:
|
||||
var source := _rig.get_node_or_null(NodePath(path)) as Node2D
|
||||
if source == null: return false
|
||||
var count: int = 0
|
||||
for child: Node in source.get_children():
|
||||
if child is Line2D:
|
||||
var src := child as Line2D; var copy := Line2D.new(); var points := PackedVector2Array()
|
||||
for point: Vector2 in src.points: points.append(body.to_local(src.to_global(point)))
|
||||
copy.points = points; copy.width = src.width; copy.default_color = src.default_color; copy.begin_cap_mode = src.begin_cap_mode; copy.end_cap_mode = src.end_cap_mode; copy.joint_mode = src.joint_mode; copy.closed = src.closed; body.add_child(copy); count += 1
|
||||
elif child is Polygon2D:
|
||||
var src := child as Polygon2D; var copy := Polygon2D.new(); var points := PackedVector2Array()
|
||||
for point: Vector2 in src.polygon: points.append(body.to_local(src.to_global(point)))
|
||||
copy.polygon = points; copy.color = src.color; body.add_child(copy); count += 1
|
||||
return count > 0
|
||||
|
||||
func _add_capsule(body: RigidBody2D, length: float, radius: float) -> void:
|
||||
var line := Line2D.new(); line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]); line.width = radius * 2.0; line.default_color = VISUAL_COLOR; line.begin_cap_mode = Line2D.LINE_CAP_ROUND; line.end_cap_mode = Line2D.LINE_CAP_ROUND; body.add_child(line)
|
||||
func _add_circle(body: RigidBody2D, radius: float) -> void:
|
||||
var polygon := Polygon2D.new(); var points := PackedVector2Array()
|
||||
for index: int in CIRCLE_SEGMENTS: points.append(Vector2.from_angle(TAU * float(index) / float(CIRCLE_SEGMENTS)) * radius)
|
||||
polygon.polygon = points; polygon.color = Color.WHITE; body.add_child(polygon)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bihf4kx2qgr1o
|
||||
+192
-2219
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## StkRigAdapter - Standalone runtime adapter (Phase 8).
|
||||
##
|
||||
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
|
||||
## Fits an instantiated scenes/stickman_rig.tscn to a loaded .stk dictionary: re-fits
|
||||
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
|
||||
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
|
||||
## convention (joint end at the local origin, far end along +Y). The
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Control
|
||||
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
|
||||
##
|
||||
## Loads .stk files via StickmanFactory, spawns a master_rig.tscn instance into
|
||||
## Loads .stk files via StickmanFactory, spawns a scenes/stickman_rig.tscn instance into
|
||||
## a SubViewport, and provides a debug overlay (skeleton bones + IK handles)
|
||||
## plus interactive IK-handle dragging. NOT wired into the editor — run
|
||||
## standalone via F6 on res://scenes/test_harness.tscn.
|
||||
|
||||
@@ -2,6 +2,7 @@ class_name PropThumbnail
|
||||
extends Node
|
||||
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
|
||||
|
||||
const SIZE := Vector2i(200, 200)
|
||||
|
||||
@@ -64,6 +65,20 @@ func render(payload: Dictionary, material_preset: int) -> Texture2D:
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func render_shapes(shapes: Array[Dictionary]) -> Texture2D:
|
||||
_clear_world()
|
||||
var visual := Node2D.new()
|
||||
_world.add_child(visual)
|
||||
var bounds: Rect2 = PROP_VISUALS.build(visual, shapes)
|
||||
_frame_camera(bounds if bounds.size.length_squared() > 0.0 else Rect2(Vector2(-24, -24), Vector2(48, 48)))
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var tex := _viewport.get_texture()
|
||||
visual.queue_free()
|
||||
if tex == null or _is_blank(tex.get_image()): return null
|
||||
return ImageTexture.create_from_image(tex.get_image())
|
||||
|
||||
|
||||
func _compute_points(payload: Dictionary) -> PackedVector2Array:
|
||||
var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON))
|
||||
if shape_type == PROP_BLOCK.ShapeType.CIRCLE:
|
||||
|
||||
@@ -4,6 +4,27 @@ extends RefCounted
|
||||
const STICKMEN_DIR := "user://thumbnails/stickmen"
|
||||
const PROP_DIR := "user://thumbnails/props"
|
||||
const PROP_VERSION := 1
|
||||
var _memory: Dictionary = {}
|
||||
|
||||
func get_texture(cache_path: String) -> Texture2D:
|
||||
if _memory.has(cache_path):
|
||||
return _memory[cache_path] as Texture2D
|
||||
var texture: Texture2D = load_png(cache_path)
|
||||
if texture != null:
|
||||
_memory[cache_path] = texture
|
||||
return texture
|
||||
|
||||
func store_texture(cache_path: String, texture: Texture2D) -> Error:
|
||||
if texture == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
_memory[cache_path] = texture
|
||||
return save_png(texture, cache_path)
|
||||
|
||||
func forget_memory(cache_path: String) -> void:
|
||||
_memory.erase(cache_path)
|
||||
|
||||
func clear_memory() -> void:
|
||||
_memory.clear()
|
||||
|
||||
|
||||
func stickman_key(path: String) -> String:
|
||||
@@ -18,13 +39,27 @@ func prop_png(id: String) -> String:
|
||||
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
|
||||
|
||||
|
||||
func prop_file_key(path: String) -> String:
|
||||
var canonical: String = path.simplify_path()
|
||||
var digest: String = canonical.sha256_text().substr(0, 12)
|
||||
return "file_%s_%d" % [digest, FileAccess.get_modified_time(path)]
|
||||
|
||||
|
||||
func prop_file_png(path: String) -> String:
|
||||
return PROP_DIR + "/" + prop_file_key(path) + ".png"
|
||||
|
||||
|
||||
func load_png(png_path: String) -> Texture2D:
|
||||
if _memory.has(png_path):
|
||||
return _memory[png_path] as Texture2D
|
||||
if not FileAccess.file_exists(png_path):
|
||||
return null
|
||||
var img := Image.load_from_file(png_path)
|
||||
if img == null:
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
var texture: Texture2D = ImageTexture.create_from_image(img)
|
||||
_memory[png_path] = texture
|
||||
return texture
|
||||
|
||||
|
||||
func save_png(tex: Texture2D, png_path: String) -> Error:
|
||||
@@ -34,6 +69,7 @@ func save_png(tex: Texture2D, png_path: String) -> Error:
|
||||
if img == null:
|
||||
return ERR_CANT_CREATE
|
||||
ensure_dir(png_path.get_base_dir())
|
||||
_memory[png_path] = tex
|
||||
return img.save_png(png_path)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ const TRIGGER_TEMPLATES := {
|
||||
"icon": "💥",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_picked_up": { "label": "Picks up a prop", "icon": "🖐️", "target_type": "prop" },
|
||||
"prop_dropped": { "label": "Drops a prop", "icon": "⬇️", "target_type": "prop" },
|
||||
"prop_thrown": { "label": "Throws a prop", "icon": "🎯", "target_type": "prop" },
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
class_name AssetCard
|
||||
extends Button
|
||||
|
||||
signal activated(payload: Dictionary)
|
||||
signal drag_started(payload: Dictionary)
|
||||
|
||||
var _payload: Dictionary = {}
|
||||
var _press_position: Vector2 = Vector2.ZERO
|
||||
var _dragging: bool = false
|
||||
var _drag_threshold: float = 8.0
|
||||
var _icon: TextureRect = null
|
||||
var _glyph: Label = null
|
||||
var _label: Label = null
|
||||
|
||||
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
|
||||
_payload = payload.duplicate(true)
|
||||
_drag_threshold = theme.number("palette.drag_threshold", 8.0, 1.0, 128.0) if theme != null else 8.0
|
||||
custom_minimum_size = Vector2(96, 108)
|
||||
clip_contents = true
|
||||
if theme != null:
|
||||
var size_data: Variant = theme.value("palette.card_size", [96, 94])
|
||||
if size_data is Array and (size_data as Array).size() == 2: custom_minimum_size = Vector2(float(size_data[0]), maxf(108.0, float(size_data[1])))
|
||||
add_theme_stylebox_override("normal", theme.stylebox("palette"))
|
||||
var thumbnail_size: Vector2 = _payload.get("thumbnail_size", Vector2(64, 64))
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
tooltip_text = String(_payload.get("label", ""))
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
|
||||
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
|
||||
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
|
||||
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
|
||||
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
|
||||
|
||||
func set_thumbnail(texture: Texture2D) -> void:
|
||||
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
|
||||
if _glyph != null and texture != null: _glyph.visible = false
|
||||
|
||||
func thumbnail_rect() -> TextureRect:
|
||||
return _icon
|
||||
|
||||
func set_selected(selected: bool) -> void:
|
||||
button_pressed = selected
|
||||
|
||||
func payload() -> Dictionary:
|
||||
return _payload.duplicate(true)
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if event.pressed:
|
||||
_press_position = event.position; _dragging = false
|
||||
elif _dragging:
|
||||
accept_event()
|
||||
elif event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and not _dragging:
|
||||
if event.position.distance_to(_press_position) >= _drag_threshold:
|
||||
_dragging = true; drag_started.emit(payload()); accept_event()
|
||||
|
||||
func _pressed() -> void:
|
||||
if not _dragging: activated.emit(payload())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dayameygn6kdb
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name ColorSwatchSelector
|
||||
extends HBoxContainer
|
||||
signal color_selected(color: Color)
|
||||
var _selected: Color = Color.WHITE
|
||||
var _group: ButtonGroup = ButtonGroup.new()
|
||||
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
_selected = selected
|
||||
for color: Color in colors:
|
||||
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
|
||||
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
|
||||
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
|
||||
button.set_pressed_no_signal(color.is_equal_approx(selected))
|
||||
func set_selected(color: Color, emit_signal: bool = false) -> void:
|
||||
_selected = color
|
||||
if emit_signal: color_selected.emit(color)
|
||||
func get_selected() -> Color: return _selected
|
||||
@@ -0,0 +1 @@
|
||||
uid://djy6yykmgtxk0
|
||||
@@ -0,0 +1,10 @@
|
||||
class_name EmojiQuickGrid
|
||||
extends GridContainer
|
||||
signal emoji_selected(text: String)
|
||||
func configure(items: Array[String], columns: int = 8, theme: Variant = null) -> void:
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
self.columns = maxi(1, columns)
|
||||
for text: String in items:
|
||||
var button := Button.new(); button.text = text; button.custom_minimum_size = Vector2(44,44); button.focus_mode = Control.FOCUS_ALL
|
||||
if theme != null: button.add_theme_font_override("font", theme.emoji_font())
|
||||
button.pressed.connect(func() -> void: emoji_selected.emit(text)); add_child(button)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlstr7bm0x258
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name FontPresetPicker
|
||||
extends HBoxContainer
|
||||
signal preset_selected(id: String)
|
||||
var _selected_id: String = ""
|
||||
var _group: ButtonGroup = ButtonGroup.new()
|
||||
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
_selected_id = selected_id
|
||||
for item: Dictionary in presets:
|
||||
var id: String = String(item.get("id", "")); if id.is_empty(): continue
|
||||
var button := Button.new(); button.text = String(item.get("label", id)); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
|
||||
if item.get("font", null) is Font: button.add_theme_font_override("font", item["font"])
|
||||
button.pressed.connect(func() -> void: set_selected(id, true)); add_child(button); button.set_pressed_no_signal(id == selected_id)
|
||||
func set_selected(id: String, emit_signal: bool = false) -> void:
|
||||
_selected_id = id
|
||||
if emit_signal: preset_selected.emit(id)
|
||||
func get_selected_id() -> String: return _selected_id
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfwkdo5sx6i4h
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name PresetSizeToggle
|
||||
extends HBoxContainer
|
||||
signal preset_selected(id: String, value: int)
|
||||
var _presets: Array[Dictionary] = []
|
||||
var _selected_id: String = ""
|
||||
var _group: ButtonGroup = ButtonGroup.new()
|
||||
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
_presets = presets.duplicate(true); _selected_id = selected_id
|
||||
for item: Dictionary in _presets:
|
||||
if String(item.get("id","")).is_empty(): continue
|
||||
var button := Button.new(); button.text = String(item.get("label", item["id"])); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
|
||||
button.pressed.connect(func() -> void: set_selected(String(item["id"]), true)); add_child(button); button.set_pressed_no_signal(String(item["id"]) == selected_id)
|
||||
if not _presets.is_empty() and _selected_id.is_empty(): _selected_id = String(_presets[0].get("id", ""))
|
||||
func set_selected(id: String, emit_signal: bool = false) -> void:
|
||||
for item: Dictionary in _presets:
|
||||
if String(item.get("id", "")) == id:
|
||||
_selected_id = id
|
||||
if emit_signal: preset_selected.emit(id, int(item.get("value", 0)))
|
||||
return
|
||||
func get_selected_id() -> String: return _selected_id
|
||||
func get_selected_value() -> int:
|
||||
for item: Dictionary in _presets:
|
||||
if String(item.get("id", "")) == _selected_id: return int(item.get("value", 0))
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://fx38yavxtiv4
|
||||
@@ -0,0 +1,151 @@
|
||||
# Phase 10 — Custom Prop Library and Sandbox Integration
|
||||
|
||||
**Status:** Approved for implementation (2026-09-21). Implements the approved choices from `plans/PHASE_10_PROP_INTEGRATION.md`; this document specifies work and does not itself change runtime behavior.
|
||||
|
||||
## 1. Scope and existing seams
|
||||
|
||||
The Sandbox already has the required three-mode physics lifecycle. `StageSpawner.spawn_prop_asset()` assigns `PropBlock.spawn_id`; `SandboxStage._finalize_placed_node()` freezes props and captures authored state; PLAY unfreezes them; EDIT/DIRECT restore authored transforms and clear velocities. Scene files already save prop `asset_id`, transform, scale, and material preset. Reuse those paths for custom props.
|
||||
|
||||
The missing pieces are filesystem-backed prop entries, full layered visuals, a deliberate collision approximation, custom thumbnail keys and rendering, prop Browse/Refresh, and preservation of missing custom props during scene reload. `PropFile.flatten_to_payload()` currently chooses one shape; it remains a compatibility helper for existing single-shape consumers, not the custom prop visual representation.
|
||||
|
||||
Runtime attachment, grip/emitter roles, prop editing inside the Sandbox, and `.prop` schema changes are outside this phase.
|
||||
|
||||
## 2. Architecture and contracts
|
||||
|
||||
```text
|
||||
res://props/*.prop ──scan──> PropLibrary entry ──id/path──> AssetSelector
|
||||
│ │
|
||||
├── parsed shapes ──> thumbnail queue ──> PropThumbnail
|
||||
│ └── ThumbnailCache
|
||||
└── on-demand id lookup ──> StageSpawner ──> PropBlock
|
||||
├── Visuals/Shape_*
|
||||
└── one collision hull
|
||||
.scene asset_id ──> same on-demand lookup ──> prop or MissingPropPlaceholder
|
||||
```
|
||||
|
||||
### 2.1 `PropLibrary` (`class_name PropLibrary extends RefCounted`)
|
||||
|
||||
Preserve `static func get_entries() -> Array[Dictionary]` as the **four built-in templates only**, and preserve `get_ids()` and `get_default_id() == "crate"`. Add:
|
||||
|
||||
```gdscript
|
||||
static func scan(dir_path: String = PropFile.DEFAULT_DIR) -> Array[Dictionary]
|
||||
static func make_custom_entry(path: String) -> Dictionary
|
||||
static func get_entry(id: String) -> Dictionary
|
||||
```
|
||||
|
||||
`scan()` returns built-ins first, then valid `.prop` files in `dir_path`, sorted by case-insensitive display name and path. It does a fresh, non-recursive scan each call. Missing directory means built-ins only and one descriptive warning. `make_custom_entry()` accepts a `res://`, `user://`, or absolute filesystem `.prop` path, loads with `PropFile.load()` and `PropFile.from_file_dict()`, validates renderable geometry and a non-degenerate closed-shape collision hull, and returns `{}` with a path-specific warning on failure. It must not convert an `Array` of `{x,y}` dictionaries directly into `PackedVector2Array`; use the parsed `shapes` returned by `PropFile.from_file_dict()` or a dedicated coercion helper. `get_entry()` checks built-ins first; for a path ID it calls `make_custom_entry()` on demand, so a saved external prop resolves without first opening the selector.
|
||||
|
||||
All entries expose these keys. GDScript cannot statically type dictionary fields; every method signature and local variable around this boundary remains statically typed.
|
||||
|
||||
| Key | Type | Built-in | Custom |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `String` | `"crate"`, etc. | canonical source path |
|
||||
| `name` | `String` | template name | `prop_name`, falling back to filename stem |
|
||||
| `material_preset` | `int` | template preset | parsed file preset |
|
||||
| `material_label` | `String` | existing label | corresponding preset label |
|
||||
| `is_custom` | `bool` | `false` | `true` |
|
||||
| `path` | `String` | `""` | same canonical path as `id` |
|
||||
| `payload` | `Dictionary` | existing primitive payload | compatibility value from `flatten_to_payload()` |
|
||||
| `shapes` | `Array[Dictionary]` | empty | full parsed shape stack, back to front |
|
||||
| `collision_hull` | `PackedVector2Array` | empty | validated local-space hull |
|
||||
|
||||
The canonical custom ID is the normalized source path, including its scheme where applicable. Browse keeps an external absolute path; no file is copied. Scene portability therefore depends on that file remaining at the saved path. No last-selection setting is added.
|
||||
|
||||
### 2.2 Shared visual and collision conversion
|
||||
|
||||
Add `scripts/prop_visuals.gd` (`class_name PropVisuals extends RefCounted`) as a stateless helper used by both runtime and thumbnail rendering:
|
||||
|
||||
```gdscript
|
||||
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2
|
||||
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array
|
||||
```
|
||||
|
||||
`build()` creates one visual group per source shape in array order. Closed polygons and circles receive a `Polygon2D` fill plus a closed `Line2D` outline. Open lines receive only a `Line2D`, with caps/joints matching the editor. Preserve each shape's fill, outline, width, and authored point coordinates. A circle uses its parsed points when present, including hand-reshaped circles; only an absent point list is regenerated from `radius`. The returned local bounds cover every rendered shape; empty or invalid individual shapes are ignored with a warning, while `PropLibrary` rejects an asset with no usable geometry.
|
||||
|
||||
`collision_hull()` collects points from valid **closed** polygon/circle shapes and constructs one convex hull. Open lines never create collision. Reject a hull with fewer than three distinct, non-collinear points or zero area. The hull keeps authored local coordinates without terrain grid snapping. This is a deliberate approximate solid: cavities and concavities are filled, but the body remains stable with one collision node. Do not derive collision from the frontmost shape selected by `flatten_to_payload()`; for example, the briefcase's decorative details must not become its entire collision body.
|
||||
|
||||
`PropBlock` (`class_name PropBlock extends RigidBody2D`) gains:
|
||||
|
||||
```gdscript
|
||||
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void
|
||||
func has_custom_shapes() -> bool
|
||||
func get_visual_local_bounds() -> Rect2
|
||||
```
|
||||
|
||||
Custom nodes retain the existing `PropBlock` root, material mass/friction/bounce, contact signals, and one enabled `CollisionPolygon2D` populated from the hull. Their visual hierarchy is:
|
||||
|
||||
```text
|
||||
PropBlock (RigidBody2D; spawn_id = source path)
|
||||
├── Visuals (Node2D)
|
||||
│ ├── Shape_0 (Node2D) ── Polygon2D? + Line2D
|
||||
│ └── Shape_1 ...
|
||||
├── CollisionPolygon2D (one convex hull)
|
||||
└── existing primitive children (disabled/hidden for custom)
|
||||
```
|
||||
|
||||
For custom props, changing `material_preset` applies physics properties but **does not replace authored per-shape colors**. Existing built-ins keep their current preset tinting and primitive structure. Reapplying the preset during scene load must preserve this distinction. `StageSpawner.spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock` branches on `is_custom`, uses `PropBlock.set_custom_shapes()` for custom entries, sets `spawn_id = asset_id`, and returns `null` if validation fails. `PropUtils.spawn_prop()` remains the built-in primitive path.
|
||||
|
||||
`StageSelection.get_world_aabb()` and `StageSpawner.get_world_aabb()` must union all custom visual points (or transform `get_visual_local_bounds()`), rather than stop at the root `Polygon2D`. This keeps selection, gizmos, ghost placement, trigger overlap, and grid indexing aligned with the full visible prop. The current AABB hit-test remains approximate.
|
||||
|
||||
### 2.3 Thumbnail pipeline
|
||||
|
||||
`PropThumbnail` keeps `render(payload: Dictionary, material_preset: int) -> Texture2D` for built-ins and adds `render_shapes(shapes: Array[Dictionary]) -> Texture2D` for custom props. Both are async render paths driven by the existing `SandboxStage._thumbnail_queue`, at most one queued render at a time. The custom path calls `PropVisuals.build()` and frames the camera around the **entire** stack; it never flattens the stack or recolors it from the material preset. A null/blank headless capture leaves the selector placeholder and does not poison the disk cache.
|
||||
|
||||
`ThumbnailCache.prop_png(id)` remains built-in-only. `prop_file_key(path)`/`prop_file_png(path)` include a hash of the canonical **full path** and the file modification time, with a `file_` prefix distinct from built-in keys. Two `chair.prop` files in different directories cannot share a PNG key. On Refresh, the stage recomputes keys and queues only missing images. Cleanup may remove superseded custom PNGs for known canonical paths after successful replacement; it must not delete built-in PNGs or cache files for unrelated external paths. No global cache sweep is required.
|
||||
|
||||
### 2.4 Selector and stage signals
|
||||
|
||||
`AssetSelector` keeps the existing typed signals `item_selected(entry: Dictionary)`, `browse_requested()`, `refresh_requested()`, and `cancelled()`. In `open("prop", entries)`, show Browse and Refresh, and use a prop-specific empty label. The selector's `_entry_key()` already uses `id` for props, so path IDs work without an additional key field. `set_entries()` still resets pagination and clears displayed thumbnail references; the stage repopulates them from cache or queue.
|
||||
|
||||
`SandboxStage` calls `PropLibrary.scan()` whenever the prop selector opens or Refresh is pressed. Browse sets the existing `FileDialog` title/filter to `.prop` according to `_selector_kind`; stickman browsing retains `.stk`. A valid browsed prop selects its path ID and enters prop placement. An invalid file shows a concise error/toast and leaves the selector open. Refresh updates the open grid, invalidates stale thumbnail jobs through the existing generation counter, and preserves the selected prop ID when still resolvable. Custom entries choose `prop_file_png(path)`; built-ins choose `prop_png(id)`.
|
||||
|
||||
### 2.5 Scene reload and missing custom assets
|
||||
|
||||
Add `scripts/missing_prop_placeholder.gd` (`class_name MissingPropPlaceholder extends Node2D`) with typed `original_entry: Dictionary`, `missing_prop_path: String`, `configure(entry: Dictionary) -> void`, and `get_area_rect() -> Rect2`. It displays an inert missing-asset marker and retains a deep copy of the original prop scene entry. The loader creates it **only for a missing/invalid path-valued custom prop ID**; unknown legacy template IDs continue to warn and skip. It applies the saved transform and scale and adds the placeholder to the World in the original object order.
|
||||
|
||||
The placeholder is intentionally entered into `file_to_runtime` as an available object ID, so `StageSceneIO.decode_rules()` maps existing rule references to its instance ID. It does **not** belong in the `unavailable` set that currently makes rules disappear for missing stickmen. Its `Node2D` type prevents `PropBlock` physics/contact handling; stage trigger/action execution must treat it as inert. `_collect_scene_data()` recognizes `MissingPropPlaceholder` as a `prop`, begins from `original_entry.duplicate(true)`, updates the current file ID and transform/scale, and serializes its original `asset_id` and material. The normal runtime-to-file map includes its ID so `StageSceneIO.encode_rules()` writes the preserved references. Deleting a placeholder follows normal object/rule cleanup. Reopening the scene retries the path and replaces it with a real prop when available.
|
||||
|
||||
Example scene flow:
|
||||
|
||||
```text
|
||||
load prop_1 -> path missing -> placeholder instance 842 -> rule source = 842
|
||||
save -> object prop_1 still has original asset_id -> rule source_id = prop_1
|
||||
restore file, reload -> real PropBlock instance 913 -> rule source = 913
|
||||
```
|
||||
|
||||
This extends preservation specifically to missing custom props; the existing missing-stickman skip policy remains outside this phase.
|
||||
|
||||
## 3. Mode and data flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
EDIT --> DIRECT: select Direct
|
||||
EDIT --> PLAY: select Play
|
||||
DIRECT --> EDIT: select Edit
|
||||
DIRECT --> PLAY: select Play
|
||||
PLAY --> EDIT: freeze + restore authored pose
|
||||
PLAY --> DIRECT: freeze + restore authored pose
|
||||
```
|
||||
|
||||
The custom prop goes through existing `_finalize_placed_node()`, `_save_object_state()`, `_enter_play_mode()`, and `_enter_edit_shared()` methods. Do not add a second authored-position metadata system. Stage save uses `prop.spawn_id` (the custom path) as `asset_id`. EDIT and DIRECT keep the body kinematically frozen; PLAY unfreezes it; leaving PLAY restores position/rotation and zeros linear/angular velocity. Current scale remains a scene-level transform. Scene load's material override must update physics without tinting custom artwork.
|
||||
|
||||
## 4. Verification and acceptance
|
||||
|
||||
Add a focused `tests/test_phase10_prop_integration.gd` headless suite and update relevant existing suites. Assertions must test values and behavior, not only node counts.
|
||||
|
||||
1. **Library:** scan returns four built-ins plus the valid checked-in `.prop` entries in deterministic order; invalid version, corrupt JSON, missing file, empty/line-only, and degenerate closed geometry are skipped with warnings. `get_entries()` remains four templates, preserving `tests/test_phase3b_library.gd`; add an explicit scan test rather than changing that assertion. A custom ID resolves by path without prior scan.
|
||||
2. **Geometry:** a multi-shape sample such as `briefcase.prop` spawns all authored layers, including open outlines where present, with original per-shape colors and point values after JSON parse. It has exactly one enabled collision hull that covers the intended closed-shape extrema; lines do not expand collision. `get_world_aabb()` and selection bounds include all visual layers. Built-in crate and ball remain visually/physically unchanged.
|
||||
3. **Thumbnails:** the custom render path receives every layer and computes full-stack bounds; an interactive capture shows their colors and order. Two equal basenames from different folders have distinct cache paths, and changing mtime changes the key. A headless null capture is harmless. The stage queue still drains sequentially and ignores jobs from an older selector generation.
|
||||
4. **Selector:** prop Browse/Refresh are visible; stickman Browse retains the `.stk` filter. Browse accepts a valid external `.prop` as a path ID; invalid browse keeps the selector open. Refresh finds a new file, drops a removed file from the grid, and does not replace a still-valid selected prop. Pagination remains correct past 12 items.
|
||||
5. **Scene persistence:** save/reload retains a custom prop's path ID, material, position, rotation, scale, and full visuals. With its source temporarily absent, load creates a placeholder carrying the original entry; save/reload preserves that entry and a rule referring to it. Restoring the file and reopening resolves a real prop and the rule reference. Existing built-in prop and missing-stickman behavior stays covered by `tests/test_phase5_file_features.gd`.
|
||||
6. **Lifecycle:** in EDIT/DIRECT the custom `RigidBody2D` is frozen; in PLAY it simulates and reports contacts; after PLAY→EDIT and PLAY→DIRECT it returns to the authored transform with zero velocities. Use a physics-frame runtime test and a brief interactive Sandbox check for full-layer artwork, collision, selection, Browse, and reload.
|
||||
|
||||
Run the affected headless suites plus Godot 4.7.1 `--headless --check-only` on changed scripts. Test thumbnail pixels interactively because headless `SubViewport` capture can be blank. The target scene remains `res://scenes/sandbox_stage.tscn`; no new UI scene is required beyond the existing selector's code-controlled visibility/text.
|
||||
|
||||
## 5. Architectural trade-offs
|
||||
|
||||
- A single convex collision hull is cheap and stable, but fills gaps and concavities. Full layered visual fidelity is independent of this approximation.
|
||||
- External Browse paths support immediate use without copying, but moving the source file breaks reload until restored. The placeholder prevents scene and rule data loss during that interval.
|
||||
- Dictionary entries match the existing libraries and `.prop` JSON boundary; introducing custom `Resource` assets would add conversion and migration work without improving this phase's data flow.
|
||||
- Path-plus-mtime thumbnail keys prevent same-name cross-folder collisions. Filesystems with coarse timestamp resolution can still miss two edits within one timestamp tick; content hashing is a possible later upgrade.
|
||||
- Missing-prop rule references remain serialized through inert placeholders. Rules cannot fire from absent geometry; existing missing-stickman rules continue to follow their current skip behavior.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Phase 10a — Prop Scaling
|
||||
|
||||
Status: **implemented and user verified** (amended and verified 2026-09-22).
|
||||
|
||||
Source: `plans/PHASE_10a_PROP_SCALING.md`. Phase 10a adds two distinct operations:
|
||||
permanent size changes to a `.prop` document in the Prop Editor, and reversible
|
||||
uniform instance scaling of one `PropBlock` in Sandbox EDIT mode. The final UI uses
|
||||
origin-based Normalize Size and direct whole-prop Arrange gestures; it does not expose
|
||||
a numeric document-scale control. Sandbox keeps single-prop corner handles that scale
|
||||
around the prop's local origin.
|
||||
|
||||
## 1. Existing contracts and scope
|
||||
|
||||
- `PropCanvas` stores every shape's `PackedVector2Array` points and computes the
|
||||
union bounds. `PropEditor` owns the matching anchor dictionary, dirty/autosave
|
||||
flow, inspector, and the mode-specific top-bar controls. `PropAnchorData`
|
||||
always synthesizes a fixed `origin` at `(0, 0)`.
|
||||
- Circles carry both `points` and `radius`; both must remain consistent after a
|
||||
document-wide uniform scale. Keep `shape_type`, `closed`, `vertex_flags`,
|
||||
`reshaped`, fill, outline color, and material preset unchanged.
|
||||
- `StageGizmos` currently has translation and rotation gestures and emits
|
||||
`transform_started`, `transform_updated`, and `transform_committed`.
|
||||
`SandboxStage` already uses these signals to sync frozen bodies, save authored
|
||||
transforms, rebuild spatial cells, and mark the scene dirty.
|
||||
- `SandboxStage._collect_scene_data()` already writes prop `scale`; scene loading
|
||||
already reads it. `_restore_authored_state()` restores authored position and
|
||||
rotation and zeroes rigid-body velocities without changing scale. No `.prop`
|
||||
or `.scene` version change is required.
|
||||
|
||||
In scope: document scale/normalize controls, a passive 1:1 guide in the Prop
|
||||
Editor, prop-only Sandbox scale handles, and focused verification. Keep the
|
||||
existing built-in/custom prop rendering and one-hull custom collision policy.
|
||||
Do not add Sandbox numeric scale fields, presets, new authoring modes, or
|
||||
document-wide undo/redo. Debt item #37 continues to track undo.
|
||||
|
||||
The 2026-09-22 amendment replaces the visible, Arrange-only **Resize Whole
|
||||
Prop** toggle with **Transform Whole Prop**. One union gizmo moves, rotates,
|
||||
or uniformly resizes the complete document. It does not change Sandbox scaling.
|
||||
|
||||
## 2. Class and API contracts
|
||||
|
||||
| Class / file | Required contract |
|
||||
| --- | --- |
|
||||
| `class_name PropCanvas extends Control` (`scripts/prop_canvas.gd`) | Keep `can_scale_all_shapes()` / `scale_all_shapes()`, and add equivalent validated document helpers `can_translate_all_shapes(delta: Vector2) -> bool`, `translate_all_shapes(delta: Vector2) -> bool`, `can_rotate_all_shapes(angle_rad: float) -> bool`, and `rotate_all_shapes(angle_rad: float) -> bool`. Successful commit helpers mutate every shape and emit `shape_changed(shapes: Array[Dictionary])` exactly once; preflight/no-op/failure never mutates or emits. Add the transient whole-prop tool API in §3.4. `get_shapes_bounds() -> Rect2` remains authoritative for its union gizmo. |
|
||||
| `class_name PropAnchorData extends RefCounted` (`scripts/prop_anchor_data.gd`) | Keep pure `try_scale_non_origin()`, and add pure `try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary` and `try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary`, each returning `{ "ok": bool, "anchors": Dictionary }`. Translation changes editable positions only. Rotation rotates editable positions about `(0,0)` and adds the angle to their wrapped `rotation_deg`. Every helper regenerates canonical `origin` unchanged and rejects invalid results without partially modifying input. Existing `translated_non_origin()` may delegate to the validated helper. |
|
||||
| `class_name PropEditor extends Control` (`scripts/prop_editor.gd`) | Own three atomic document transactions: `_translate_document(delta: Vector2) -> bool`, `_rotate_document(angle_rad: float) -> bool`, and existing `_scale_document(factor: float) -> bool`. Each prevalidates shapes and anchors, applies the anchor copy, commits canvas geometry once, rolls anchors back on an unexpected canvas failure, and refreshes inspector/preview/buttons through the existing shape-change path. Connect the typed request signals in §3.4. Persist only guide visibility in editor settings. |
|
||||
| `class_name StageGizmos extends Node2D` (`scripts/stage_gizmos.gd`) | Extend `enum Handle` with `SCALE`. Draw and hit-test four corner crosses only when `_targets.size() == 1` and `_targets[0] is PropBlock`; keep hit radii and strokes in screen pixels via camera zoom. Capture start `scale: Vector2`, world origin, selected corner, and cursor at gesture start. Use one positive uniform multiplier through the drag; emit the existing transform signals. |
|
||||
| `class_name SandboxStage extends Node2D` (`scripts/sandbox_stage.gd`) | Keep the current EDIT pointer routing and existing transform-signal handlers. Route scale gestures through the same physics-sync and authored-state commit path as move/rotate. No save/load or PLAY→EDIT reset rewrite is needed unless verification exposes a defect. |
|
||||
| `class_name StageSelection extends RefCounted` (`scripts/stage_selection.gd`) | Its existing `get_world_aabb(node: Node2D) -> Rect2` transforms visual points to world space. Verify scaled/rotated built-in and custom props; change the method only if a specific failing case appears. |
|
||||
|
||||
No new persistent shape fields are required. The whole-prop tool adds three
|
||||
typed commit-request signals. Use static type hints for new fields, parameters,
|
||||
local values where inference is ambiguous, and return values. The signal
|
||||
interfaces are:
|
||||
|
||||
```gdscript
|
||||
signal shape_changed(shapes: Array[Dictionary])
|
||||
signal transform_started(nodes: Array[Node2D])
|
||||
signal transform_updated(nodes: Array[Node2D])
|
||||
signal transform_committed(nodes: Array[Node2D])
|
||||
signal whole_prop_move_requested(delta: Vector2)
|
||||
signal whole_prop_rotate_requested(angle_rad: float)
|
||||
signal whole_prop_resize_requested(factor: float)
|
||||
```
|
||||
|
||||
## 3. Prop Editor document transaction
|
||||
|
||||
### 3.1 Explicit scale and 96 × 96 normalization
|
||||
|
||||
Do not expose a numeric scale textbox or Apply Scale button. In Arrange, the
|
||||
mode top bar presents **Transform Whole Prop**, **📏 Normalize Size**, **🎯 Center
|
||||
Prop**, and **Show Stickman Guide**, in that order. Normalize Size is a
|
||||
document operation, not saved UI state: it uniformly fits valid shape-point
|
||||
bounds to a 96 px maximum dimension around `(0, 0)`, preserves proportional
|
||||
anchor offsets, and leaves outline width unchanged. Disable it when there is no
|
||||
valid shape geometry. Transform Whole Prop supplies direct move, rotate, and
|
||||
uniform resize gestures; its detailed snapshot and commit contract is in §3.4.
|
||||
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while
|
||||
Center Prop and Show Stickman Guide remain available.
|
||||
|
||||
Normalize uses the shape-point union already returned by `get_shapes_bounds()`:
|
||||
|
||||
```gdscript
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
var factor: float = 96.0 / extent
|
||||
```
|
||||
|
||||
For a nonempty finite bounds with `extent > 0`, apply this factor uniformly
|
||||
around `(0, 0)`. The resulting maximum geometry extent is 96 world pixels;
|
||||
the smaller dimension retains its aspect ratio. A line with one nonzero
|
||||
dimension is valid. Empty or point-only geometry, zero extent, nonfinite
|
||||
values, or an output coordinate/radius that cannot be represented safely make
|
||||
the action a no-op. Do not silently clamp the factor and miss the 96 px target.
|
||||
Use a reasonable coordinate safety cap documented in code rather than relying
|
||||
on floating-point overflow. Normalization does **not** call **Center Prop**:
|
||||
off-origin content stays off-origin at its new proportional offset.
|
||||
|
||||
### 3.2 Geometry and anchor invariants
|
||||
|
||||
The transaction computes its result from a deep copy before updating either
|
||||
owner. Transform every point of every polygon, line, and circle as `point *=
|
||||
factor`. For every circle, also set `radius *= factor`. Do not regenerate
|
||||
circle points: that would erase an intentionally reshaped circle or move its
|
||||
center. Preserve `reshaped` exactly as stored. Keep `vertex_flags` in their
|
||||
original index order. Outline width is a style value and stays unchanged;
|
||||
the 96 px target measures shape points, not stroke thickness. Preserve the
|
||||
canonical origin while multiplying all editable anchor positions by the same
|
||||
factor. Anchor rotations and metadata stay unchanged.
|
||||
|
||||
Call `_canvas.can_scale_all_shapes(factor)` and
|
||||
`PropAnchorData.try_scale_non_origin(_anchors, factor)` before changing either
|
||||
owner. A failed preflight leaves both unchanged. Apply the prevalidated anchor
|
||||
copy without emitting a separate document edit, then commit the canvas shapes
|
||||
with the same factor; this synchronous path must use the same canvas validation
|
||||
as preflight. Let its one `shape_changed` emission use the existing
|
||||
`PropEditor._on_canvas_shape_changed()` dirty/autosave/preview refresh path.
|
||||
Refresh selected anchor controls and any disabled button state afterward.
|
||||
The scale operation must not reset pan/zoom, selection, edit mode, clipboard,
|
||||
or guide visibility. Both successful actions mark the document dirty once;
|
||||
changing the guide toggle does not.
|
||||
|
||||
Example with factor `2`:
|
||||
|
||||
```text
|
||||
Before: circle points centered at (20, 10), radius 12;
|
||||
primary_grip.position = (8, -3), origin = (0, 0)
|
||||
After: circle points centered at (40, 20), radius 24;
|
||||
primary_grip.position = (16, -6), origin = (0, 0)
|
||||
```
|
||||
|
||||
Save, autosave, recovery, and load keep the existing `.prop` v1.0 shape and
|
||||
anchor representation. `PropFile._serialize_shape()` must continue to coerce
|
||||
export-format point dictionaries; never assign JSON arrays directly to a
|
||||
`PackedVector2Array`.
|
||||
|
||||
### 3.3 Stickman scale guide
|
||||
|
||||
Add a **Show Stickman Guide** `CheckBox` in the Prop Editor canvas controls.
|
||||
Default is OFF for a fresh settings file. Persist the Boolean as
|
||||
`show_stickman_guide` in `user://prop_editor_settings.json` alongside
|
||||
`recent_props`; a missing key means `false`, and old settings files remain
|
||||
valid. Apply the loaded value during `_ready()` after the controls exist.
|
||||
Toggling calls `PropCanvas.set_show_stickman_guide()`, saves settings, and
|
||||
redraws, without changing `_dirty`, autosave, `.prop` data, or scene data.
|
||||
|
||||
Draw the same 1:1 head circle and bone-line reference already described by
|
||||
`WholeStickmanPreview.GUIDE_JOINTS`, `GUIDE_BONES`, and
|
||||
`GUIDE_HEAD_RADIUS`. Prefer preloading that source's constants or extracting
|
||||
a shared constant-only guide source if compilation requires it; do not copy
|
||||
the numerical joint table into a second implementation. Draw raw master
|
||||
coordinates with `Hips = Vector2.ZERO` after `_draw_grid()` and before user
|
||||
shapes, under the canvas's existing pan/zoom transform. This keeps a 96 px
|
||||
prop directly comparable with the approximately 940 px standing figure in
|
||||
every view. Use muted translucent strokes with width divided by `_zoom` for
|
||||
legibility; keep the guide noninteractive and out of hit-testing, shape bounds,
|
||||
thumbnails, and `.prop` serialization. Existing origin marker and selection
|
||||
gizmos remain above it. New/load/reset do not forcibly change its saved
|
||||
visibility preference.
|
||||
|
||||
### 3.4 Arrange-only Transform Whole Prop tool (2026-09-22 decision)
|
||||
|
||||
**Interaction and scope.** Replace **↗ Resize Whole Prop** with one prominent
|
||||
toggle labeled **✥ Transform Whole Prop** in the Arrange mode top bar. It is available only in Arrange
|
||||
(`EditMode.TRANSFORM`) when valid shape geometry exists. It starts OFF, remains
|
||||
armed after each successful gesture, is session-only, and is excluded from
|
||||
settings and `.prop` data. This extends the already learned per-shape Arrange
|
||||
mapping to the full document:
|
||||
|
||||
| Target | Gesture | Whole-document result |
|
||||
| --- | --- | --- |
|
||||
| Any visible shape body | Drag | Move all shapes and editable anchors. Transparent gaps inside the union box do nothing. |
|
||||
| Dot below union bounds | Drag | Rotate all shapes and editable anchors about canonical `(0,0)`. Ctrl snaps to 15°. |
|
||||
| Any of four union corners | Drag | Uniformly resize all shapes and editable anchors about canonical `(0,0)`. |
|
||||
|
||||
Hit-test in the strict order **corner → rotation dot → visible shape body**.
|
||||
This keeps small handles usable when they overlap geometry. Suppress per-shape
|
||||
selection, translation/rotation/scale gizmos, and right-click menus while the
|
||||
tool is armed. Preserve `_selected_shape_idx` internally and restore its
|
||||
selection/inspector when the toggle turns OFF. Existing wheel/pinch zoom and
|
||||
middle-button/touchpad pan remain available during the tool.
|
||||
|
||||
Draw one two-tone union-bounds outline, four large constant-screen-size corner
|
||||
crosses (at least 12 px hit radius), and the familiar constant-screen-size
|
||||
rotation dot below the union AABB. Keep the canonical origin marker visible;
|
||||
if **Show Other Markers** hid it, restore that preference when the tool exits.
|
||||
Also draw small passive markers for every non-origin anchor, with grip markers
|
||||
clearly visible. During a gesture, the union outline and markers follow the
|
||||
temporary preview. These markers are noninteractive and excluded from bounds,
|
||||
hit-testing, thumbnails, and serialization. The tooltip must say that body,
|
||||
dot, and corners move, rotate, and resize the whole prop including grip markers.
|
||||
|
||||
**Typed Canvas API and signals.** Replace resize-specific tool methods with:
|
||||
|
||||
```gdscript
|
||||
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
|
||||
|
||||
func set_whole_prop_transform_enabled(enabled: bool) -> void
|
||||
func is_whole_prop_transform_enabled() -> bool
|
||||
func is_whole_prop_transform_dragging() -> bool
|
||||
func cancel_whole_prop_transform() -> void
|
||||
|
||||
signal whole_prop_move_requested(delta: Vector2)
|
||||
signal whole_prop_rotate_requested(angle_rad: float)
|
||||
signal whole_prop_resize_requested(factor: float)
|
||||
```
|
||||
|
||||
`set_whole_prop_transform_enabled(false)` cancels a live preview before
|
||||
disarming. `cancel_whole_prop_transform()` clears the active drag, snapshots,
|
||||
preview arrays, accumulated rotation, and pending payload without changing
|
||||
authoritative data or the armed state. `PropEditor` connects the three signals
|
||||
to `_translate_document`, `_rotate_document`, and `_scale_document`. Do not
|
||||
emit `anchors_changed` for these document gestures.
|
||||
|
||||
The implementation also retains resize-named compatibility aliases internally
|
||||
(`set_whole_prop_resize_enabled`, `is_whole_prop_resize_enabled`,
|
||||
`is_whole_prop_resize_dragging`, and `cancel_whole_prop_resize`) so existing
|
||||
controller paths continue to target the same Transform Whole Prop state. They
|
||||
are not separate user-facing tools.
|
||||
|
||||
**Snapshot and preview contract.** On pointer-down, capture deep starting
|
||||
copies of `shapes: Array[Dictionary]` and normalized anchors, starting union
|
||||
bounds, pointer, selected handle, and any gesture-specific state. Render all
|
||||
motion from those starting copies into temporary preview shape/anchor arrays.
|
||||
Do not replace authoritative `shapes` or editor `_anchors` before release;
|
||||
therefore `get_shape_data()`, save, autosave, and recovery always see the
|
||||
pre-drag document if their timer runs during a drag. Preview motion can redraw
|
||||
each input event but never emits edit signals or arms autosave.
|
||||
|
||||
All gestures preserve shape type, layer order, `closed`, `vertex_flags`,
|
||||
`reshaped`, fill/outline, and outline width, plus anchor role, z offset,
|
||||
allowed hands, and IDs. Canonical `origin` remains at `(0,0)` with rotation
|
||||
`0°`. Circle behavior is explicit: move and rotate transform its stored points
|
||||
but leave `radius` unchanged; resize transforms its points and multiplies
|
||||
`radius`. Never regenerate circle points.
|
||||
|
||||
**Move math.** Capture `start_pointer`; compute from the baseline every frame:
|
||||
|
||||
```gdscript
|
||||
var delta: Vector2 = current_pointer - start_pointer
|
||||
if snap_enabled:
|
||||
delta = Vector2(snappedf(delta.x, grid_size), snappedf(delta.y, grid_size))
|
||||
preview_point = start_point + delta
|
||||
preview_anchor.position = start_anchor.position + delta
|
||||
```
|
||||
|
||||
Snap the **delta**, so all layers and anchors retain their relative placement
|
||||
even when the starting document is off-grid. Anchor `rotation_deg` and circle
|
||||
radius remain unchanged. Moving the whole prop means moving authored geometry
|
||||
and editable anchor markers relative to the fixed document pivot; the canonical
|
||||
origin itself does not translate. A zero delta is a no-op.
|
||||
|
||||
**Rotate math.** The pivot is always canonical `Vector2.ZERO`, independent of
|
||||
the union-bounds center. Capture the pointer angle around origin and accumulate
|
||||
wrapped frame deltas so crossing `-PI/PI` cannot jump:
|
||||
|
||||
```gdscript
|
||||
var frame_delta: float = wrapf(current_angle - previous_angle, -PI, PI)
|
||||
total_angle += frame_delta
|
||||
previous_angle = current_angle
|
||||
var preview_angle: float = snappedf(total_angle, deg_to_rad(15.0)) if ctrl_pressed else total_angle
|
||||
preview_point = start_point.rotated(preview_angle)
|
||||
preview_anchor.position = start_anchor.position.rotated(preview_angle)
|
||||
preview_anchor.rotation_deg = wrapf(start_anchor.rotation_deg + rad_to_deg(preview_angle), -180.0, 180.0)
|
||||
```
|
||||
|
||||
Reject pointer-down or motion whose angle around origin is nonfinite or too
|
||||
close to origin for stable measurement. Always render the captured baseline
|
||||
rotated by `preview_angle`; never rotate the prior preview. The rotation dot's
|
||||
union-bounds location is only an input affordance and does not change the pivot.
|
||||
Circle radius and `reshaped` remain unchanged. An approximately zero angle is
|
||||
a no-op.
|
||||
|
||||
**Resize math.** Preserve the implemented uniform resize rule. Compute the
|
||||
gesture direction from the starting union center and selected starting corner,
|
||||
but measure pointer travel from the exact captured press position:
|
||||
|
||||
```gdscript
|
||||
var direction: Vector2 = start_corner - start_bounds.get_center()
|
||||
var pointer_delta: Vector2 = current_pointer - start_pointer
|
||||
var factor: float = 1.0 + pointer_delta.dot(direction) / direction.length_squared()
|
||||
```
|
||||
|
||||
The factor is exactly `1.0` on pointer-down even when the press is off the
|
||||
handle center. Apply it to points, circle radii, and editable anchor positions
|
||||
about `(0,0)`; anchor rotations stay unchanged. Clamp to positive finite
|
||||
`0.01..10.0`, satisfy canvas coordinate/radius and anchor preflight, and never
|
||||
cross zero or flip geometry. Reject a near-zero direction denominator. An
|
||||
approximately `1.0` factor is a no-op.
|
||||
|
||||
**Commit and cancellation.** On left release, capture the final payload, clear
|
||||
the temporary preview, and emit exactly one matching typed request signal.
|
||||
The controller transaction prevalidates geometry and anchors, applies both
|
||||
atomically, and lets the canvas's single `shape_changed` emission mark dirty,
|
||||
arm autosave once, refresh inspector/preview/buttons, and retain the armed
|
||||
Transform Whole Prop tool. An invalid/no-op release emits nothing. On an
|
||||
unexpected canvas commit failure, restore the original anchors and leave the
|
||||
document unchanged.
|
||||
|
||||
Esc during a drag cancels only that preview and keeps the tool armed. Esc while
|
||||
armed but idle turns the tool OFF. Toggling OFF, changing to Reshape/Attach,
|
||||
New, Load, or Clear cancels a live preview and disarms before changing mode or
|
||||
document. Route Esc through `PropEditor._unhandled_key_input()` while respecting
|
||||
dialogs and text fields, and consume it. A committed gesture is not reverted by
|
||||
Esc because document-wide UndoRedo remains deferred under debt #37. Each move,
|
||||
rotate, or resize gesture must become one future shapes-plus-anchors undo unit.
|
||||
|
||||
```text
|
||||
Arrange, shape scope Transform Whole Prop ON
|
||||
selected-shape bbox all-shape union bbox
|
||||
body = move one shape body = move entire document
|
||||
dot = rotate one shape dot = rotate entire document
|
||||
corners = resize one shape corners = resize entire document
|
||||
|
||||
pointer down → deep snapshots → temporary preview from baseline
|
||||
→ release → one typed controller transaction
|
||||
→ shape_changed once → dirty/autosave once → tool remains armed
|
||||
↘ Esc/mode/data change → discard preview, no dirty change
|
||||
```
|
||||
|
||||
## 4. Sandbox Stage instance gesture
|
||||
|
||||
### 4.1 Visual and input contract
|
||||
|
||||
When exactly one `PropBlock` is selected in EDIT, draw four crosses on the
|
||||
corners of `StageSelection.get_world_aabb(prop)`. Use a contrasting backing
|
||||
and bright core so they remain visible on light/dark props, and a 12 screen px
|
||||
hit radius. Handle hit-testing precedes the existing rotate ring, then normal
|
||||
body selection; an active placement mode keeps the current gizmo precedence.
|
||||
No scale handles appear for multi-selection, terrain, trigger areas,
|
||||
stickmen, missing-prop placeholders, DIRECT, or PLAY. The existing rotation
|
||||
ring and body translation remain available.
|
||||
|
||||
On pointer-down, record the selected corner's world point, the prop's
|
||||
`global_position` as pivot, and its starting `scale: Vector2`. For each drag
|
||||
update, project the cursor vector onto the start-corner vector around the
|
||||
fixed pivot:
|
||||
|
||||
```gdscript
|
||||
var start: Vector2 = start_corner_world - pivot_world
|
||||
var current: Vector2 = cursor_world - pivot_world
|
||||
var requested: float = current.dot(start) / start.length_squared()
|
||||
var new_scale: Vector2 = start_scale * positive_clamped_multiplier
|
||||
```
|
||||
|
||||
Guard near-zero denominators/nonfinite values. Clamp the multiplier so the
|
||||
absolute values of both final scale components stay within `0.05..20.0` and
|
||||
never cross zero; retain the signs of any previously saved scale. The gesture
|
||||
multiplies both axes by **the same positive factor**, preserving the instance's
|
||||
starting aspect ratio even if an older `.scene` contains nonuniform scale.
|
||||
It changes only `prop.scale`, never `global_position`, `global_rotation`,
|
||||
template vertices, radius, anchors, `material_preset`, or `mass`. Compute from
|
||||
the captured starting scale on every motion event rather than multiplying the
|
||||
previous frame, avoiding drift. Draw from the updated world AABB while moving.
|
||||
One release emits one `transform_committed` and enters the existing dirty/save
|
||||
pipeline. No motion or invalid gesture must not produce a scale edit; keep
|
||||
the existing move/rotate behavior unless a focused regression needs repair.
|
||||
|
||||
### 4.2 Physics and lifecycle
|
||||
|
||||
The `PropBlock` root is a `RigidBody2D`; its child visuals and collision
|
||||
geometry inherit its node transform. `material_preset` remains the sole mass
|
||||
source (`PropBlock.mass_for()`), so scale must not recalculate mass. During
|
||||
EDIT drag, use the existing `_on_gizmo_transform_updated()`/
|
||||
`_sync_editable_physics_transform()` path to keep the frozen physics body in
|
||||
sync. On commit, `_on_transform_committed()` saves authored state and rebuilds
|
||||
grid cells. PLAY may move/rotate the prop physically; returning to EDIT must
|
||||
restore authored position/rotation and zero velocities while retaining the
|
||||
authored scale. Saving during PLAY must serialize authored position/rotation
|
||||
and the selected scale as the current code already does.
|
||||
|
||||
Godot warns that scaling collision shapes can produce unexpected physics
|
||||
behavior. Verify polygon and circle contacts at several positive scales in
|
||||
the project engine, including a rotated prop and PLAY→EDIT→PLAY. If the
|
||||
`RigidBody2D` root transform does not give matching collisions, adapt the
|
||||
per-instance collision construction while keeping the scene's scale field,
|
||||
visual size, preset mass, and source `.prop` unchanged. A visually scaled prop
|
||||
with stale collision is not an acceptable implementation.
|
||||
|
||||
## 5. Data and signal flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Whole-prop body, dot, or corner drag] --> V[Snapshot-only live preview]
|
||||
V -->|one typed request on release| E[PropEditor validates transaction]
|
||||
N[Normalize Size] --> E
|
||||
E --> A[PropAnchorData transforms non-origin anchors]
|
||||
E --> C[PropCanvas transforms every shape]
|
||||
C -->|shape_changed once| D[Dirty + autosave + preview refresh]
|
||||
D --> P[Existing PropFile v1.0 save]
|
||||
P --> L[Sandbox PropBlock instance]
|
||||
G[StageGizmos prop corner drag] -->|uniform prop.scale| S[Stage transform signals]
|
||||
S --> F[Frozen-body sync + authored state + dirty]
|
||||
F --> I[Existing .scene scale field]
|
||||
```
|
||||
|
||||
```text
|
||||
Sandbox EDIT selection
|
||||
├─ one PropBlock → AABB + rotate ring + four scale crosses
|
||||
└─ any other selection → existing AABB/rotate behavior, no scale crosses
|
||||
|
||||
PLAY → EDIT → PLAY
|
||||
authored {position, rotation, scale} → simulate → restore position/rotation
|
||||
and zero velocities → retain authored scale → simulate at retained size
|
||||
```
|
||||
|
||||
Example persisted values remain additive to existing formats:
|
||||
|
||||
```json
|
||||
{"version":"1.0","shapes":[{"shape_type":"circle","points":[{"x":24,"y":0},{"x":0,"y":24},{"x":-24,"y":0},{"x":0,"y":-24}],"radius":24,"reshaped":false}],"anchors":{"origin":{"role":"pivot","x":0,"y":0},"primary_grip":{"role":"grip","x":12,"y":-4}}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type":"prop","file_id":"prop_1","asset_id":"res://props/ball.prop","position":{"x":100,"y":200},"rotation":0,"scale":{"x":1.5,"y":1.5},"material_preset":2}
|
||||
```
|
||||
|
||||
The JSON fragments show only the fields relevant to scaling; ordinary
|
||||
serializer fields remain present in real files.
|
||||
|
||||
## 6. Implementation order and acceptance
|
||||
|
||||
1. Keep the existing scale transaction, guide, and whole-prop resize behavior;
|
||||
add validated geometry/anchor move and rotate helpers plus atomic editor
|
||||
transactions.
|
||||
2. Rename and extend the Arrange-only toggle/gizmo with body move and rotation
|
||||
dot gestures, retaining snapshot preview and resize behavior.
|
||||
3. Implement prop-only Stage scale crosses and pointer gesture through the
|
||||
existing transform signal flow. Leave persistence/lifecycle code alone
|
||||
unless an assertion fails.
|
||||
4. Run Godot headless syntax checks for every changed script and focused
|
||||
headless tests. Per project instructions, use
|
||||
`Godot_v4.7.1-stable_win64_console.exe --headless --check-only --script
|
||||
res://scripts/<changed>.gd --path C:\Godot4\stickman`; if class caches need
|
||||
refresh, run a headless editor scan first, then repeat checks. Fix failures
|
||||
before handoff.
|
||||
|
||||
Acceptance checks:
|
||||
|
||||
- A `200%` document-scale transaction doubles all point coordinates, circle radii, and
|
||||
editable anchor offsets, keeps origin `(0,0)`, and produces one dirty edit.
|
||||
A reshaped circle remains reshaped; style widths, flags, and order remain.
|
||||
- Normalize a 192 × 48 bounds to 96 × 24; a nonzero off-origin center scales
|
||||
proportionally and is not silently centered. Invalid/empty geometry is a
|
||||
no-op. Save/load and autosave/recovery retain exact point and anchor values.
|
||||
- The guide starts hidden with no settings file, can be enabled in all modes,
|
||||
stays aligned with world origin through pan/zoom, survives editor restart
|
||||
through settings, and never enters a `.prop` file or a thumbnail.
|
||||
- In Arrange, **Transform Whole Prop** shows one union outline, four large
|
||||
corners, and a rotation dot. Body drag moves all points and non-origin
|
||||
anchors; with Snap ON, its baseline-derived delta lands on the current grid.
|
||||
Circle radius and anchor angles do not change. Canonical origin stays fixed.
|
||||
- Rotation-dot drag rotates all points and anchor positions about `(0,0)` and
|
||||
adds the same wrapped angle to every editable anchor's `rotation_deg`.
|
||||
Circle radius/primitive state stay fixed, crossing ±180° is continuous, and
|
||||
Ctrl produces 15° increments.
|
||||
- Corner drag retains the verified uniform origin-based resize behavior,
|
||||
including circle radius and anchor positions, without changing anchor angle.
|
||||
- During every gesture, authoritative shape/anchor export stays at the baseline
|
||||
while the union outline, shapes, and visible markers show preview copies.
|
||||
Release performs one matching editor transaction, one `shape_changed`, and
|
||||
one dirty/autosave arm. No-op release and cancellation emit nothing.
|
||||
- Esc cancels an active drag but leaves the tool armed; idle Esc, toggle OFF,
|
||||
mode switch, New, Load, or Clear cancel and disarm without a dirty edit. The
|
||||
tool stays armed after a successful commit. Turning it OFF restores the
|
||||
former selected shape and per-shape Arrange behavior.
|
||||
- Focused headless tests assert numeric point/radius/anchor values, anchor-angle
|
||||
wrapping, snap deltas, signal counts, baseline immutability during preview,
|
||||
resize regression, no-op/cancel behavior, and selection/mode restoration.
|
||||
- Exactly one selected prop exposes four usable crosses. Dragging retains its
|
||||
aspect ratio and pivot, never flips it, changes collision bounds along with
|
||||
visuals, and leaves material mass unchanged. Other object types and multiple
|
||||
selection do not expose scale handles.
|
||||
- Save/reload retains prop scale. PLAY→EDIT restores position/rotation and
|
||||
zeroes velocities while retaining scale; a second PLAY collides at the
|
||||
retained size. Existing rotation, translation, selection, and custom-prop
|
||||
rendering still work.
|
||||
|
||||
## 7. Trade-offs and deferred work
|
||||
|
||||
- Origin-based sizing is predictable for anchor offsets and preserves the
|
||||
fixed `origin`, but an off-origin prop moves relative to its bbox center
|
||||
when resized. **Center Prop** remains the explicit remedy; no auto-centering.
|
||||
- Geometry bounds exclude outline stroke and physics center of mass, matching
|
||||
current Center Prop semantics and debt item #39.
|
||||
- Stage scaling uses one rigid-body root transform and the existing `.scene`
|
||||
scale field. Physics accuracy must be proved in the project engine; a
|
||||
per-instance collider adjustment is a contingency, not a separate user UI.
|
||||
- No document-wide undo is added; debt item #37 remains open.
|
||||
- One visible **Transform Whole Prop** toggle avoids three competing subtools,
|
||||
a hidden modifier key, or a fourth primary mode. Its body/dot/corner mapping
|
||||
mirrors per-shape Arrange. Each completed move, rotate, or resize is one
|
||||
future undo unit; Esc provides cancellation before commit, while existing
|
||||
save/recovery protects committed work until document-wide undo is designed.
|
||||
- Move translates authored geometry and non-origin anchors relative to the
|
||||
fixed canonical pivot. Rotate uses that same pivot and changes anchor
|
||||
orientation as well as position. This consistency benefits runtime mounting,
|
||||
though an off-origin prop may orbit widely; **Center Prop** remains explicit.
|
||||
|
||||
The focused Phase 10a suite and Prop Editor startup smoke test passed, and the
|
||||
user verified the final Prop Editor layout and controls in the Godot Editor.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Phase 11 — Interactive Props and Event Rules
|
||||
|
||||
Status: **approved — implementation complete, awaiting Editor verification** (2026-09-23).
|
||||
|
||||
Source: plans/PHASE_11_PROP_INTERACTION.md, refined through the spec-pipeline architecture round.
|
||||
|
||||
## 1. Accepted decisions
|
||||
|
||||
1. Deliver the work as gated milestones. Milestone 11A adds the interaction foundation, pickup/drop, and their triggers. Milestone 11B adds deterministic aimed throws and the trajectory preview. Milestone 11C adds ragdoll disarm and lifecycle hardening. Each milestone must work before the next begins.
|
||||
2. Valid props are pickup-enabled by default and use authored grip anchors when available. An explicit **can_be_picked_up** capability lets a prop such as a tree opt out. Props without a compatible authored grip use a deterministic bounds-center fallback.
|
||||
3. Throws target an exact point and save the selected arc/flight-time setting. The same solver drives the preview and PLAY release. A finite launch-speed cap and flight-time range reject targets that would require an implausible throw.
|
||||
|
||||
These decisions are requirements for implementation.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### 2.1 Milestone 11A — pickup, hold, drop, and triggers
|
||||
|
||||
- Add the can_be_picked_up asset capability to .prop data and the Prop Editor. Missing values default to true for old .prop files. Existing built-in props remain pickup-enabled; future built-ins can opt out in their library entry.
|
||||
- Carry normalized grip anchors and the pickup capability through PropLibrary, StageSpawner, and PropBlock. Custom props currently lose their anchors at the library boundary; this closes that gap.
|
||||
- Add a dedicated StickmanPropInteraction component under RigComponents. It owns one held prop, the selected hand and grip, saved IK target state, the pickup/approach lifecycle, and release.
|
||||
- Add pick_up_prop and drop_prop queue actions. The queue runner delegates these actions to the component and waits for completion. Auto-approach is a private subgoal that calls the existing horizontal walk_to behavior; it does not insert a visible or saved queue action.
|
||||
- Extend Director action editing/target capture so the actor and a specific prop instance can be selected. Prop identity uses a stage object file ID, not spawn_id, because several instances can share one asset.
|
||||
- Extend StageSceneIO queue serialization to map runtime prop instance IDs to and from scene file IDs. Preserve scene version 1.0 with additive action fields. A missing target drops only the unresolved action with a warning, following the existing unresolved-reference policy.
|
||||
- Add prop_picked_up and prop_dropped trigger types. Emit them from the actual successful state transition, with source = acting StickmanRig and target = affected PropBlock. Drop includes manual release; ragdoll disarm is added in 11C.
|
||||
- Add the trigger choices to TriggerRegistry and RuleEditor. Existing consequences remain the current stickman action set; prop-targeted consequence actions are out of scope.
|
||||
|
||||
### 2.2 Milestone 11B — aimed throw
|
||||
|
||||
- Add throw_prop with a specific prop instance, exact world target, and flight_time/arc setting.
|
||||
- In DIRECT, capture the landing point through the stage's existing target-capture flow and show a live parabolic preview. The action is saved in the actor's queue and runs in PLAY; there is no free-aim input during playback.
|
||||
- Use one pure trajectory solver for preview and release. It uses the release point, target, flight time, and the live 2D gravity vector scaled by the prop's gravity_scale.
|
||||
- Initial tuning envelope: minimum flight time 0.35 s, maximum flight time 1.5 s, and maximum launch speed 900 px/s. Treat these as named tunable constants. Validate finite inputs and reject a target whose required velocity exceeds the speed cap. Do not clamp the velocity, since that would miss the stored target.
|
||||
- The Director preview marks an invalid target clearly and does not commit the action until it is valid. Changing the arc setting re-solves both preview and stored velocity from the same function.
|
||||
- Release uses the computed initial velocity directly as PropBlock.linear_velocity, not as an unscaled impulse. This keeps a selected trajectory independent of material mass. Apply angular spin as a separate bounded setting or deterministic default; it must not alter the linear landing target.
|
||||
- Add prop_thrown, emitted once at the successful physics release. It uses the same source/target direction as the other prop triggers.
|
||||
|
||||
### 2.3 Milestone 11C — ragdoll disarm and lifecycle hardening
|
||||
|
||||
- Before StickmanRagdoll begins building bodies, StickmanRig asks StickmanPropInteraction to release any held prop. The release must happen before the animated body is hidden and before the rig pose is handed to physics.
|
||||
- Transfer a finite linear and angular momentum snapshot from the rig to the released PropBlock. Add a typed snapshot API at the rig/ragdoll boundary if needed; capture values before the ragdoll transition changes the source pose.
|
||||
- Emit exactly one prop_dropped event with a ragdoll release reason.
|
||||
- Release safely on PLAY exit and actor/prop deletion. PLAY exit restores the stage's authored transforms through the existing authored-state snapshot. Invalid references must never leave a prop frozen or an IK marker targeting a freed node.
|
||||
- Cover interruption during approach, reach, windup, or release without duplicate triggers or stale physics state.
|
||||
|
||||
### 2.4 Explicitly deferred
|
||||
|
||||
- Two-hand carries, inventory/equipment slots, multiple held props per rig, melee/use actions, prop-targeted rule consequences, and AI choice of which prop to use.
|
||||
- Slope-aware approach, full obstacle avoidance, or a general 2D path/reach planner. Approach uses the existing horizontal locomotion and reports a clean failure if it cannot reach a valid stand point.
|
||||
- Multiplayer, network replication, persistent physics state, and new scene-file versioning.
|
||||
- A dedicated stage event registry node. SandboxStage remains the event dispatcher and matcher.
|
||||
- Ragdolling a held prop together with a character; a held prop is disarmed into ordinary world physics before ragdoll construction.
|
||||
|
||||
## 3. Existing architecture constraints
|
||||
|
||||
- StickmanRig is the public facade. It owns RigComponents/Locomotion, Ragdoll, and ActionRunner; prop interaction belongs beside those components rather than in the facade or a second stage-only controller.
|
||||
- StickmanActionRunner uses action phases and emits action_started/action_finished. A prop phase must preserve queue order and action lifecycle signals. Internal approach steps must not appear as authored queue rows.
|
||||
- Locomotion is horizontal, feet-based, and exposes walk_to. The pickup stand point is beside the prop bounds on the ground plane. If navigation cannot reach it, the pickup action fails without attaching.
|
||||
- DIRECT authors queue actions while PLAY executes them. Props are frozen for authored editing and simulate in PLAY. Prop actions are authored in DIRECT and remain deterministic in PLAY.
|
||||
- PropAnchorData.compatible_grip_ids() and solve_attachment_transform() already provide hand compatibility and mirror-aware grip alignment. Reuse those helpers. PropAttachPreview is the reference for sampling solved wrist endpoints and converting between the wrist and authored grip.
|
||||
- There is no StageEventRegistry node. TriggerRegistry describes trigger metadata; SandboxStage owns _handle_event(), matching, and reactive action dispatch.
|
||||
- StageSceneIO currently serializes queues without an object-reference map. Rules already encode runtime object references to scene file IDs; queue serialization must gain the equivalent mapping for prop targets.
|
||||
- PropBlock is a RigidBody2D and remains parented under World during a hold. Do not use RemoteTransform2D reparenting for runtime socket attachment.
|
||||
|
||||
## 4. Data model and asset compatibility
|
||||
|
||||
### 4.1 Pickup capability
|
||||
|
||||
Add **can_be_picked_up: bool** to the PropFile in-memory model and additive top-level .prop field:
|
||||
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "Oak Tree",
|
||||
"can_be_picked_up": false,
|
||||
"anchors": {},
|
||||
"shapes": []
|
||||
}
|
||||
|
||||
PropFile.to_file_dict() always writes the boolean. PropFile.from_file_dict() reads only an actual boolean; absent or malformed values become true. Keep FILE_VERSION at 1.0, matching the project's existing additive-field policy. Loading an old file does not dirty it merely because the default was supplied.
|
||||
|
||||
Add a kid-facing “Can be picked up” checkbox to the Prop Editor's general prop settings. It is persisted through normal save, save-as, autosave, recovery, and New. This capability is asset metadata; it does not change the prop's collision, mass, or gravity.
|
||||
|
||||
PropLibrary.make_custom_entry() must preserve can_be_picked_up and normalized anchors. Built-in templates include an explicit value, initially true for crate, ball, plank, and triangle. StageSpawner transfers both fields to PropBlock. PropBlock exposes a typed runtime property with a safe default of true for scenes and callers that do not come through StageSpawner.
|
||||
|
||||
### 4.2 Grip resolution
|
||||
|
||||
For an eligible prop, pickup resolves a hand/grip pair in this order:
|
||||
|
||||
1. Enumerate authored grip anchors compatible with each candidate hand using PropAnchorData.compatible_grip_ids().
|
||||
2. For hand: "left" or "right", choose the compatible grip that requires the least reach for that hand. For hand: "auto", compare compatible hand/grip pairs and choose the shortest valid reach. Break ties deterministically in favor of the right hand, then lexicographic grip ID.
|
||||
3. If no authored grip is compatible, synthesize a runtime-only grip at the center of PropBlock's full visual local bounds, with zero rotation and both hands allowed. Do not serialize this fallback into the asset.
|
||||
4. Reject pickup immediately if can_be_picked_up is false, the prop is invalid/already held, the rig already holds a different prop, or no arm can reach after approach. Report a short Director status message and finish the action cleanly.
|
||||
|
||||
Only one prop per rig and one rig per prop are supported in this phase. The check and hold reservation occur synchronously before the reach animation begins, so two queued actors cannot both acquire one prop.
|
||||
|
||||
## 5. Runtime ownership and interaction lifecycle
|
||||
|
||||
### 5.1 Component boundary
|
||||
|
||||
Add scripts/stickman_prop_interaction.gd and a PropInteraction child to scenes/stickman_rig.tscn under RigComponents. StickmanRig resolves/configures it, exposes narrow public forwarding methods or typed signals where the stage needs access, and invokes release before ragdoll handoff.
|
||||
|
||||
The component owns:
|
||||
|
||||
- A small state machine for idle, approaching, reaching, holding, winding up, and recovery.
|
||||
- Held PropBlock, chosen hand/grip ID, saved IK target/rest pose, and any interpolation timers.
|
||||
- A single release kernel used by drop, throw, ragdoll, mode exit, and object cleanup.
|
||||
- The authoritative attachment transform update and its physics-state snapshot.
|
||||
|
||||
Suggested typed completion contract:
|
||||
|
||||
signal interaction_finished(action: Dictionary, succeeded: bool)
|
||||
signal prop_action_executed(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||
|
||||
Use the project's normal typed-signal style if names differ, but the contract must distinguish failed actions from successful transitions and must emit each transition once.
|
||||
|
||||
### 5.2 Pickup and hold
|
||||
|
||||
1. Resolve and reserve the target prop. If the rig is farther than its current arm reach, compute a stand point adjacent to the prop's left or right bounds, on the ground plane, and call the existing locomotion.walk_to(). Select the side that is reachable and requires less horizontal travel; use a deterministic tie-break.
|
||||
2. When locomotion reports arrival, recompute reach from current bone/wrist positions. If the target is still outside the solver's safe arm reach, fail cleanly rather than repeatedly walking or forcing a stretched IK limb.
|
||||
3. Interpolate the selected IK hand marker toward the authored grip's current world position for the configured reach duration. Preserve the opposite hand target. Cache the pre-interaction hand target once so release restores the actual locomotion/idle pose.
|
||||
4. At reach completion, solve the prop transform from the solved wrist transform and selected anchor with PropAnchorData.solve_attachment_transform(). Freeze the prop, keep it under World, suppress collisions against that rig's animated collision proxies, and latch the hand target.
|
||||
5. While holding, update the prop's global transform from the solved wrist and grip using the same helper. Apply the anchor's z_index_offset relative to the holder while preserving the prop's authored z state for restoration.
|
||||
6. Prop movement follows the hand during locomotion. The prop remains frozen and does not participate in collision contacts while held.
|
||||
|
||||
The exact process callback/order must be selected during implementation by matching the solved wrist update order. The attachment may never lag a frame behind a visible wrist or feed the target marker back into itself.
|
||||
|
||||
### 5.3 Drop and release
|
||||
|
||||
The central release operation accepts a reason and a linear/angular velocity payload. It is idempotent and:
|
||||
|
||||
- Validates and clears the held-prop reference and hand latch.
|
||||
- Restores the cached freeze mode, collision layer/mask/exceptions, z state, and saved gravity/damping values.
|
||||
- Sets final global transform, linear_velocity, and angular_velocity before enabling dynamic simulation.
|
||||
- Restores the selected hand marker smoothly to its cached pose.
|
||||
- Emits the successful release event exactly once after the prop is in a valid world-physics state.
|
||||
|
||||
Manual drop uses the rig's current finite root/locomotion velocity and a neutral angular velocity unless a deterministic small spin is approved during implementation. An interrupted pickup before the hold latch cancels the reach and restores the marker without emitting picked-up or dropped. Stopping a queue after a completed pickup does not silently drop the held prop; only a drop action or a lifecycle release does.
|
||||
|
||||
PLAY exit releases first, then the existing authored-state restore returns prop and rig transforms to the EDIT/DIRECT snapshot. Deleting the held prop clears the hold and hand target without attempting to restore a freed node. Deleting the holder drops the valid prop safely before the rig leaves World.
|
||||
|
||||
## 6. Action and scene-file contracts
|
||||
|
||||
### 6.1 Runtime payloads
|
||||
|
||||
Runtime actions use node instance IDs; an asset path or spawn_id is never used to choose among duplicate prop instances.
|
||||
|
||||
{ "type": "pick_up_prop", "target_prop": 123456, "hand": "auto" }
|
||||
{ "type": "drop_prop" }
|
||||
{ "type": "throw_prop", "target_prop": 123456, "target_position": Vector2(...), "flight_time": 0.9 }
|
||||
|
||||
hand accepts auto, left, or right. flight_time is stored in seconds and validated against the configured bounds. Drop acts on the rig's currently held prop and carries no prop ID.
|
||||
|
||||
### 6.2 Saved payloads
|
||||
|
||||
StageSceneIO maps target_prop through the same scene-object reference tables used by rules:
|
||||
|
||||
{ "type": "pick_up_prop", "target_prop_id": "prop_3", "hand": "auto" }
|
||||
{ "type": "drop_prop" }
|
||||
{ "type": "throw_prop", "target_prop_id": "prop_3", "target_position": { "x": 500, "y": 80 }, "flight_time": 0.9 }
|
||||
|
||||
Queue encode/decode receives the relevant runtime-to-file and file-to-runtime maps. Reject non-finite target coordinates, invalid hand values, non-finite/out-of-range flight times, missing prop references, and a throw target that fails the same solver validation. Invalid actions are skipped with the existing queue warning mechanism; unrelated actions and the rest of the queue remain loadable.
|
||||
|
||||
ActionRegistry, ActionEditor, QueuePanel, stage action capture, and ActionRegistry summaries expose the new actions. The queue editor's prop picker selects an object instance from World and displays its current asset name plus a stable per-scene ordinal so duplicate crates are distinguishable.
|
||||
|
||||
### 6.3 Trigger/event contract
|
||||
|
||||
Register:
|
||||
|
||||
| Type | Source | Target | Firing point |
|
||||
| --- | --- | --- | --- |
|
||||
| prop_picked_up | acting StickmanRig | acquired PropBlock | Hold latch succeeds and the prop is aligned to the wrist |
|
||||
| prop_dropped | acting StickmanRig | released PropBlock | Manual drop or lifecycle disarm completes |
|
||||
| prop_thrown | acting StickmanRig | released PropBlock | Throw velocity is assigned and physics is enabled |
|
||||
|
||||
The event dictionary includes type, source, target, event position, and reason/action data. Rule matching honors exact runtime source and target IDs and wildcard -1 values as it does for existing events. The trigger UI identifies the target kind as prop. prop_picked_up and prop_dropped are added in 11A; prop_thrown is added in 11B. A ragdoll release is prop_dropped with reason ragdoll, not a second new trigger type.
|
||||
|
||||
Emit transitions from StickmanPropInteraction through StickmanRig to SandboxStage. Do not infer pickup/drop/throw from action_finished: failed actions also finish, while lifecycle releases may not have a matching queue action.
|
||||
|
||||
## 7. Throw solver and Director preview
|
||||
|
||||
### 7.1 Solver
|
||||
|
||||
Create one pure helper, owned by StickmanPropInteraction or a small stateless PropTrajectory utility:
|
||||
|
||||
solve_throw_velocity(release_point, target_point, flight_time, gravity_vector) -> result
|
||||
|
||||
For time t, return:
|
||||
|
||||
velocity = (target_point - release_point - 0.5 * gravity_vector * t * t) / t
|
||||
|
||||
The result contains ok, velocity, flight_time, and an error reason. Reject zero/negative time, non-finite vectors, time outside 0.35–1.5 seconds, or velocity magnitude over 900 px/s. Resolve gravity using the current project 2D gravity direction and magnitude multiplied by PropBlock.gravity_scale. Preview and release call this exact helper.
|
||||
|
||||
The initial cap and range are intentionally tied to the project's pixel scale: with standard downward gravity near 980 px/s², a same-height throw at the speed cap has a theoretical range of about 826 px, approximately one current stickman height. The speed cap is the hard physical bound; the flight-time bound prevents long, slow, implausible arcs. Keep both values named and tunable, and show invalidity instead of silently modifying the authored target.
|
||||
|
||||
### 7.2 Playback release
|
||||
|
||||
The throw uses a brief IK windup, then at its release moment:
|
||||
|
||||
1. Sample the current solved wrist transform.
|
||||
2. Solve velocity again from the stored exact target, current release point, flight_time, and live gravity.
|
||||
3. If the result is no longer valid (for example, the actor moved after authoring), abort with a concise message and restore the arm. Do not emit prop_thrown.
|
||||
4. Release through the central kernel, set the computed linear velocity directly, apply the bounded spin, and emit prop_thrown.
|
||||
5. Blend the arm back to its cached rest target.
|
||||
|
||||
Windup/recovery are target-marker tweens rather than a new animation resource or body-rotation system. The plan's approximate 100 ms windup, 150 ms release, and 50 ms follow-through are initial tuning targets, not hard timing compatibility requirements.
|
||||
|
||||
### 7.3 Director capture
|
||||
|
||||
Throw target capture is available while authoring a queue in DIRECT. The reticle shows the solved arc from the current expected release point to the pointer. A compact arc control stores flight_time. Invalid targets use the existing stage feedback/status path, are visibly distinct, and cannot be committed. The saved queue stores target position and flight_time; playback never reads the mouse.
|
||||
|
||||
## 8. Ragdoll and lifecycle order
|
||||
|
||||
StickmanRig.set_ragdoll(true) performs this order:
|
||||
|
||||
1. Ask StickmanPropInteraction for a finite rig momentum snapshot while the animated pose and root movement data are still available.
|
||||
2. Release a held prop through the central release kernel with that snapshot and reason ragdoll.
|
||||
3. Emit prop_dropped once through SandboxStage so matching rules can enqueue their normal reactive actions.
|
||||
4. Continue the existing locomotion handoff and StickmanRagdoll.enter_ragdoll() path.
|
||||
|
||||
The release precedes body hiding and ragdoll construction. If ragdoll construction fails after a release, the prop remains safely released; it is never refrozen or rebound implicitly.
|
||||
|
||||
On PLAY exit, release any held prop before _restore_authored_state(). If queue/actor cleanup occurs while approaching or reaching, cancel the private subgoal and restore marker state. If the actor is removed while holding, drop into World using its last valid transform and finite velocity. All cleanup paths are idempotent.
|
||||
|
||||
## 9. Acceptance and user verification
|
||||
|
||||
### Milestone 11A
|
||||
|
||||
- Old v1.0 .prop assets load pickup-enabled and keep their existing anchors; a saved false capability survives Prop Editor save/load/autosave.
|
||||
- A custom grip anchor reaches the hand with authored rotation, scale, mirror, and allowed-hand rules respected. Props without compatible grips use the deterministic bounds-center fallback.
|
||||
- A non-pickup prop fails with feedback and never freezes. Existing built-ins remain eligible.
|
||||
- Picking a nearby prop reaches, aligns, freezes, and holds it through walking. Dropping restores physics and collision behavior, hand pose, z state, and movement velocity.
|
||||
- A distant target uses one private horizontal walk_to subgoal before reaching. An unreachable target ends cleanly with no queue mutation or stale hold.
|
||||
- Duplicate instances of one asset can be independently targeted, save/reload retains the target instance, and unresolved IDs do not corrupt other queue rows.
|
||||
- Pickup/drop triggers fire once only after successful transitions, identify the correct source and prop, and execute matching rules.
|
||||
- DIRECT only authors the actions; PLAY runs them. PLAY exit restores the authored scene transforms.
|
||||
|
||||
### Milestone 11B
|
||||
|
||||
- The live preview and playback compute the same velocity for the same release point, target, flight time, gravity, and prop scale.
|
||||
- Valid targets land at the stored point when unobstructed within normal physics tolerance. Gravity scale is respected.
|
||||
- Targets outside the speed or time envelope are visibly invalid and cannot be saved. A prop whose release point changes enough during playback to exceed the cap aborts without a throw event.
|
||||
- Throw velocity is mass-independent. prop_thrown fires once at release; no throw event fires on cancellation or invalid playback.
|
||||
|
||||
### Milestone 11C
|
||||
|
||||
- Entering ragdoll releases a held prop before kinematic body hiding and the physics handoff. The prop receives finite rig linear/angular motion and then responds to gravity/collisions.
|
||||
- Ragdoll disarm fires one prop_dropped event with reason ragdoll.
|
||||
- PLAY exit, deleted prop, deleted holder, queue cancellation, and invalid/stale references leave no frozen prop, dangling target, duplicate event, or lost authored state.
|
||||
|
||||
Manual verification takes place in the Godot editor and Sandbox Stage. Implementation must run the skill-prescribed headless per-script syntax check for changed scripts. Documentation/README updates are deferred until the user confirms: “Feature verified and working.”
|
||||
|
||||
## 10. Trade-offs and known limits
|
||||
|
||||
- The internal approach is horizontal because that is the existing locomotion contract. It will not find a route around an obstacle; it fails cleanly when the current navigation cannot reach the chosen stand point.
|
||||
- A bounds-center fallback keeps built-ins and older props usable without inventing saved anchor data. Authors can add explicit grip anchors for better hand placement.
|
||||
- One held prop per rig keeps state ownership simple. Two-hand grips are authored metadata but simultaneous two-hand solving remains deferred.
|
||||
- A speed cap plus maximum flight time makes aiming deterministic and prevents arbitrary-distance throws. The initial cap is a tuning value that should be adjusted only against the visible stage/rig scale.
|
||||
- Keeping the prop under World avoids fragile RemoteTransform2D paths and reparenting transforms, but requires a correctly ordered transform update from the solved wrist.
|
||||
- Trigger matching is edge-triggered at the semantic transition. Rules may enqueue reactive stickman actions; this phase does not add prop manipulation as a rule consequence.
|
||||
@@ -0,0 +1,375 @@
|
||||
# Phase 6a — Airborne Momentum, Procedural Air Pose, and Landing Absorption
|
||||
|
||||
Status: implemented and user-verified (2026-09-17).
|
||||
|
||||
Source proposal: `plans/PHASE_6a_AIRBORNE_LANDING.md`.
|
||||
|
||||
## 1. Scope and verified implementation decisions
|
||||
|
||||
Phase 6a extends the existing PLAY-only terrain controller in `StickmanRig`. It does not add a
|
||||
new public action, change saved queue payloads, add a new `RigState`, or alter the navigation
|
||||
mesh. The feature has three active internal terrain-motion phases plus the disabled value:
|
||||
|
||||
```gdscript
|
||||
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
||||
```
|
||||
|
||||
`TerrainMotion` replaces `_terrain_airborne` as the terrain-phase authority;
|
||||
`_terrain_following_enabled` remains the PLAY lifecycle enable flag. `RigState` remains the
|
||||
authoritative high-level puppet state
|
||||
(`ANIMATED`, `RAGDOLL`, `RECOVERING`); terrain motion runs only while `state == ANIMATED`.
|
||||
|
||||
The approved implementation resolves the design choices as follows:
|
||||
|
||||
- Extreme animated falls do **not** automatically become ragdolls in this phase. Auto-ragdoll
|
||||
would silently change director intent and, because PLAY currently sets `auto_recover = false`,
|
||||
could strand a rig. Explicit queued/reactive/direct ragdoll requests remain authoritative.
|
||||
- Airborne lateral contact is a swept stop: move only through the collision-safe fraction and
|
||||
set horizontal air velocity to zero. There is no bounce, wall slide, or ledge grab.
|
||||
- The root remains upright on slopes. At impact, both lower-leg targets lock independently to
|
||||
their own upward terrain samples, so a ramp or stair may produce unequal foot heights while
|
||||
the torso compresses vertically.
|
||||
- Queue work cannot start or advance during `LANDING_IMPACT`. Incoming sequential/takeover work
|
||||
is retained and runs afterward; speech overlays remain non-blocking and continue on their
|
||||
existing overlay clock. A direct `set_ragdoll(true)` is the one physical preemption and cancels
|
||||
landing absorption before the normal ragdoll handoff.
|
||||
- The air pose is additive to a live pose captured at takeoff. It therefore preserves arbitrary
|
||||
walk/idle/custom marker poses instead of replacing hands and feet with a factory pose.
|
||||
|
||||
## 2. Targets and ownership
|
||||
|
||||
| Target | Change |
|
||||
| --- | --- |
|
||||
| `scripts/stickman_rig.gd` (`StickmanRig`) | Owns the terrain-motion enum, two-axis air velocity, swept horizontal movement, air-pose capture/solve, height-scaled landing impact timeline, independent landing contacts, and queue barrier. |
|
||||
| `tests/test_phase6a_airborne_landing.gd` | Focused headless regression suite for state transitions, signed takeoff momentum, forward-before-lift air pose, exact restoration, height-scaled landing, impact hand drop, queue barrier, and ragdoll cleanup. It passes 24/24 assertions. |
|
||||
| `master_rig.tscn` | No change. Existing `IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg,Torso}` are sufficient. |
|
||||
| `scripts/sandbox_stage.gd` | No behavioral change expected. Existing PLAY order (`begin_terrain_fall()` then `start_queue()`) and EDIT/DIRECT cleanup (`stop_queue()`, disable terrain following, `snap_to_standing()`) remain the lifecycle contract. |
|
||||
| Layout/theme | No UI, scene-layout, or theme update. |
|
||||
|
||||
## 3. Static contracts
|
||||
|
||||
The shipped tuning constants sit beside the existing terrain constants. They are production
|
||||
defaults, not saved settings:
|
||||
|
||||
```gdscript
|
||||
const AIR_DRAG_X := 0.75 # exponential damping coefficient, s^-1
|
||||
const AIR_POSE_START_SPEED := 240.0 # downward px/s
|
||||
const AIR_POSE_FULL_SPEED := 1200.0 # downward px/s
|
||||
const AIR_POSE_RESPONSE := 10.0 # exponential blend response, s^-1
|
||||
const AIR_HAND_FORWARD := 220.0 # rig-local +X px at full reach
|
||||
const AIR_HAND_FORWARD_START_SPEED := 80.0 # downward px/s
|
||||
const AIR_HAND_FORWARD_FULL_SPEED := 520.0 # downward px/s
|
||||
const AIR_HAND_LIFT := 680.0 # rig-local px upward at full weight
|
||||
const AIR_HAND_FLAIL_AMPLITUDE := Vector2(20.0, 16.0)
|
||||
const AIR_FOOT_FLAIL_AMPLITUDE := Vector2(14.0, 10.0)
|
||||
const AIR_FLAIL_HZ := 12.0
|
||||
|
||||
const HARD_LANDING_MIN_FALL_HEIGHT := 80.0 # vertical fall px
|
||||
const HARD_LANDING_MAX_FALL_HEIGHT := 900.0 # vertical fall px
|
||||
const LANDING_COMPRESSION_MIN := 32.0 # torso-marker px downward
|
||||
const LANDING_COMPRESSION_MAX := 160.0
|
||||
const LANDING_HAND_DROP_MIN := 270.0 # hand-marker px downward
|
||||
const LANDING_HAND_DROP_MAX := 520.0
|
||||
const LANDING_COMPRESS_DURATION := 0.08
|
||||
const LANDING_RECOVER_DURATION := 0.22
|
||||
```
|
||||
|
||||
`AIR_DRAG_X` exponentially damps signed horizontal takeoff velocity; the shipped `0.75` keeps
|
||||
the arc visibly strong. The main air-pose weight ramps from zero to one over downward speeds
|
||||
`240..1200` px/s and follows speed changes with response `10`. Hands first reach in canonical
|
||||
rig-local `+X` over `80..520` px/s, then lift up to `680` px; the root's LEFT mirror makes the
|
||||
same local pose face screen-left. Hand/foot flail amplitudes are `(x, y)` rig-local pixels and
|
||||
run at 12 Hz with opposite phase on the two sides.
|
||||
|
||||
Hard-landing reaction is driven by vertical fall distance, not impact speed: below 80 px the
|
||||
contact is soft; from 80..900 px compression and hand drop interpolate linearly; at 900 px or
|
||||
more they clamp to 160 px torso compression and 520 px downward hand drop. The impact timeline
|
||||
compresses for 0.08 s and recovers for 0.22 s (0.30 s total).
|
||||
|
||||
Existing `TERRAIN_GRAVITY`, `TERRAIN_MAX_FALL_SPEED`, upward-normal filtering, probe fans,
|
||||
`TERRAIN_SUPPORT_RANGE`, `PELVIS_MAX_OFFSET`, and feet/root coordinate conventions remain the
|
||||
single sources of truth.
|
||||
|
||||
Replace `_terrain_vertical_velocity` with `_terrain_air_velocity: Vector2`. Add:
|
||||
|
||||
```gdscript
|
||||
var _terrain_motion: TerrainMotion = TerrainMotion.DISABLED
|
||||
var _last_grounded_walk_velocity_x: float = 0.0
|
||||
var _air_time: float = 0.0
|
||||
var _air_pose_weight: float = 0.0
|
||||
var _air_pose_base: Dictionary = {} # marker name -> captured local Vector2
|
||||
|
||||
var _landing_elapsed: float = 0.0
|
||||
var _landing_impact_speed: float = 0.0
|
||||
var _landing_compression: float = 0.0
|
||||
var _landing_torso_base: Vector2 = Vector2.ZERO
|
||||
var _landing_hand_start: Dictionary = {} # hand marker name -> local Vector2
|
||||
var _landing_contacts: Dictionary = {} # leg marker name -> world Vector2
|
||||
```
|
||||
|
||||
Required private signatures:
|
||||
|
||||
```gdscript
|
||||
func _enter_airborne(horizontal_velocity: float) -> void
|
||||
func _update_airborne(delta: float, center_probe: Dictionary, solved: Dictionary) -> void
|
||||
func _apply_air_pose(delta: float) -> void
|
||||
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2
|
||||
func _begin_landing(impact_speed: float, center_probe: Dictionary, solved: Dictionary) -> void
|
||||
func _update_landing_impact(delta: float) -> void
|
||||
func _finish_landing_impact() -> void
|
||||
func _cancel_landing_impact(restore_pose: bool) -> void
|
||||
func _restore_air_pose() -> void
|
||||
func _is_action_queue_blocked() -> bool
|
||||
```
|
||||
|
||||
Public method signatures and existing signals remain unchanged. No landing signal is added;
|
||||
landing is an internal locomotion detail, and `action_started`, `action_finished`, `arrived`, and
|
||||
`queue_finished` must retain their current meanings and cardinality.
|
||||
|
||||
## 4. State and frame flow
|
||||
|
||||
`_physics_process()` keeps its current broad order, with explicit terrain ownership:
|
||||
|
||||
```text
|
||||
track root momentum
|
||||
ragdoll rest update
|
||||
clear one-frame pelvis correction
|
||||
walking (grounded only; records actual signed X velocity)
|
||||
terrain controller
|
||||
AIRBORNE -> swept X, gravity Y, air pose, landing detection
|
||||
GROUNDED -> existing foot/stance/pelvis solve
|
||||
LANDING_IMPACT-> locked feet, torso/hand recovery timeline
|
||||
settle markers
|
||||
speech clocks (primary clock frozen only by the landing barrier; overlay clock continues)
|
||||
queue runner (returns immediately while barrier is active)
|
||||
head pin / flinch / collision-proxy sync
|
||||
```
|
||||
|
||||
Transitions:
|
||||
|
||||
```text
|
||||
DISABLED --begin_terrain_fall()------------------------------> AIRBORNE
|
||||
AIRBORNE --valid descending center support-------------------> GROUNDED (soft contact)
|
||||
AIRBORNE --fall distance >= HARD_LANDING_MIN_FALL_HEIGHT-----> LANDING_IMPACT
|
||||
GROUNDED --center support lost-------------------------------> AIRBORNE
|
||||
LANDING_IMPACT --0.08 s compress + 0.22 s recover-----------> GROUNDED
|
||||
any terrain phase --set_terrain_following_enabled(false)----> DISABLED
|
||||
AIRBORNE/LANDING_IMPACT --set_ragdoll(true)-----------------> cancel terrain pose, then RAGDOLL
|
||||
```
|
||||
|
||||
`begin_terrain_fall()` always starts in `AIRBORNE` with zero air velocity. A rig placed above
|
||||
ground therefore drops vertically; a rig that walks off a ledge inherits movement from the last
|
||||
grounded walking frame.
|
||||
|
||||
For compatibility, `snap_to_terrain()` remains an alias of `begin_terrain_fall()`, and
|
||||
`set_terrain_following_enabled(true)` calls `begin_terrain_fall()` only when currently
|
||||
`DISABLED` (it is otherwise idempotent). Passing `false` always performs the full transition to
|
||||
`DISABLED` and cleanup described below.
|
||||
|
||||
## 5. Airborne momentum and collision
|
||||
|
||||
### 5.1 Takeoff velocity
|
||||
|
||||
`_update_walking()` records its actual signed displacement each grounded physics frame:
|
||||
|
||||
```gdscript
|
||||
_last_grounded_walk_velocity_x = (global_position.x - before_x) / delta
|
||||
```
|
||||
|
||||
Record zero when walking performs no horizontal displacement. On the first supported-to-
|
||||
unsupported transition, `_enter_airborne()` receives that value and captures the live limb
|
||||
marker pose once. It must not recapture on later airborne frames.
|
||||
|
||||
Each airborne frame uses stable exponential damping and the existing gravity cap:
|
||||
|
||||
```gdscript
|
||||
_terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
|
||||
_terrain_air_velocity.y = minf(
|
||||
_terrain_air_velocity.y + TERRAIN_GRAVITY * delta,
|
||||
TERRAIN_MAX_FALL_SPEED)
|
||||
```
|
||||
|
||||
Apply the collision-safe horizontal displacement before vertical displacement. The sweep must
|
||||
reuse the already-built animated collision proxy shapes/transforms and exclude every proxy RID
|
||||
belonging to this rig. Test the desired `Vector2(vx * delta, 0)` against collision mask 1, take
|
||||
the minimum safe fraction across the valid proxy shapes, move by that fraction, and set `vx = 0`
|
||||
when the fraction is less than 1. Missing world/proxy data degrades to the requested movement
|
||||
with one `StickmanRig:` warning rather than crashing. This deliberately covers TerrainBlock,
|
||||
props, and other animated rig proxies without introducing a second body-size approximation.
|
||||
|
||||
There is no airborne navigation update: `_update_walking()` remains paused in `AIRBORNE` and
|
||||
`LANDING_IMPACT`. The active `walk_to` action stays active, and navigation resumes from the
|
||||
actual landing X after recovery. Overshooting a waypoint in the air is allowed; the existing
|
||||
nav/direct steering corrects it after landing.
|
||||
|
||||
### 5.2 Swept landing detection
|
||||
|
||||
Keep the existing upward-facing TerrainBlock filter. While descending, a valid center surface is
|
||||
a landing candidate when its signed Y delta is no farther than the normal support range plus the
|
||||
current frame's downward displacement. This swept allowance prevents a low-frame-rate fall from
|
||||
crossing a thin ramp between samples. Upward motion or a surface above the accepted support band
|
||||
does not count as a landing.
|
||||
|
||||
On contact, capture `impact_speed = maxf(_terrain_air_velocity.y, 0.0)` **before** clearing air
|
||||
velocity. Apply the full center support correction immediately, set air velocity to zero, and
|
||||
stop lateral momentum; Phase 6a does not preserve a slope-tangent ground velocity.
|
||||
|
||||
## 6. Procedural air pose and exact restoration
|
||||
|
||||
At takeoff capture local positions for `Left_Hand`, `Right_Hand`, `Left_Leg`, and `Right_Leg`.
|
||||
The walk animation is already paused by the terrain controller, so these values are stable and
|
||||
also preserve a non-locomotion/custom pose.
|
||||
|
||||
Compute:
|
||||
|
||||
```gdscript
|
||||
target_weight = smoothstep(
|
||||
AIR_POSE_START_SPEED,
|
||||
AIR_POSE_FULL_SPEED,
|
||||
maxf(_terrain_air_velocity.y, 0.0))
|
||||
_air_pose_weight = lerpf(
|
||||
_air_pose_weight,
|
||||
target_weight,
|
||||
1.0 - exp(-AIR_POSE_RESPONSE * delta))
|
||||
phase = TAU * AIR_FLAIL_HZ * _air_time
|
||||
```
|
||||
|
||||
Hands receive an upward/away-from-center drag offset plus elliptical sine/cosine flutter. The
|
||||
right hand uses `phase + PI`, so the hands are anti-phased. Feet receive only the smaller flutter
|
||||
offsets, also anti-phased. All offsets are multiplied by `_air_pose_weight` and are assigned as
|
||||
`captured_base + offset` each frame (never accumulated). Because offsets are rig-local, the root
|
||||
X mirror naturally mirrors the effect for LEFT without side-specific world-space code.
|
||||
|
||||
On a soft landing, restore all four captured marker positions exactly and clear the snapshot. On
|
||||
an impact landing, feet switch immediately to terrain contacts; hands blend from their touchdown
|
||||
positions back to their captured values across the complete 0.30 s impact timeline. Completion
|
||||
writes exact captured values before clearing state. No residual procedural offset may survive
|
||||
landing, terrain disable, `snap_to_standing()`, or ragdoll entry.
|
||||
|
||||
## 7. Landing absorption and slopes
|
||||
|
||||
For `_air_fall_distance < HARD_LANDING_MIN_FALL_HEIGHT`, transition directly to `GROUNDED`,
|
||||
restore the air pose, and resume a paused walk animation. No queue barrier or compression is
|
||||
created.
|
||||
|
||||
Otherwise map the clamped vertical fall distance linearly from
|
||||
`[HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT]` to
|
||||
`[LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX]` and independently to
|
||||
`[LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX]`, then enter `LANDING_IMPACT`. The measured
|
||||
downward impact speed is captured for the landing event, but does not select the reaction depth.
|
||||
|
||||
At entry:
|
||||
|
||||
1. Keep the walk animation paused.
|
||||
2. Capture the current `IK_Targets/Torso.position` as `_landing_torso_base`.
|
||||
3. Capture each valid per-foot terrain sample independently in world space. If one foot fan is
|
||||
invalid, use that foot's X with the accepted center surface Y as its deterministic fallback.
|
||||
4. Mark both foot contacts locked and write the live leg targets to those world contacts.
|
||||
|
||||
During the first 0.08 s, move only the torso marker downward from base to the computed depth with
|
||||
a quadratic ease-out. During the following 0.22 s, move it back to base with sine-out. Reassert
|
||||
both foot target global positions every frame; the root stays upright and stationary. Hands begin
|
||||
at their touchdown positions, move sharply downward by the height-scaled hand-drop amount during
|
||||
compression, and blend back to their captured takeoff positions over the full 0.30 s timeline.
|
||||
Thus a slope/stair keeps two unequal ankle heights and lets TwoBoneIK absorb the torso motion as
|
||||
knee flexion. Do not rotate the root to the surface normal and do not average the two foot
|
||||
heights.
|
||||
|
||||
At completion, write the exact torso base and hand bases, clear landing contacts/timers, change
|
||||
to `GROUNDED`, then resume the paused walk animation. The normal Phase 6 stance/swing solver owns
|
||||
the next frame.
|
||||
|
||||
This is implemented as a physics-time elapsed-state solver, not a `Tween`. That keeps queue pause,
|
||||
terrain disable, ragdoll interruption, and headless tests deterministic and avoids stale tween
|
||||
callbacks writing markers after a mode change.
|
||||
|
||||
## 8. Queue and interruption semantics
|
||||
|
||||
`_is_action_queue_blocked()` is true only in `LANDING_IMPACT`.
|
||||
|
||||
- `_update_runner(delta)` returns before its phase match while blocked. `WAITING` time does not
|
||||
decrement; `NONE` does not start the next item; `WALKING` cannot finish; ragdoll/recover items
|
||||
cannot start.
|
||||
- The primary speech clock is frozen while blocked. Overlay speech remains independent and
|
||||
continues to count down/display as today.
|
||||
- `enqueue_reactive()` may still update `_run_queue`. Sequential/takeover items are inserted with
|
||||
the existing authored-future preservation rules but cannot start until landing completes.
|
||||
If cancellation touches a walk pose during impact, `_cancel_current_primary()` must skip marker
|
||||
restoration; the landing solver owns those markers until release.
|
||||
- `stop_queue()` cancels queue work but does not cancel physical landing while PLAY continues.
|
||||
- Direct `walk_to()` during `LANDING_IMPACT` is rejected with the normal `StickmanRig:` warning;
|
||||
queued walking waits behind the barrier.
|
||||
- Direct `set_ragdoll(true)` cancels landing state with `restore_pose = false`, preserves the
|
||||
current solved impact pose, and then performs the existing instant ragdoll handoff. Queued or
|
||||
reactive ragdoll actions wait behind the landing barrier like other primary actions.
|
||||
- `set_terrain_following_enabled(false)` and `snap_to_standing()` cancel the impact, restore/clear
|
||||
procedural markers, clear velocity and contacts, and leave no deferred completion work.
|
||||
|
||||
Existing action signals do not fire for the physical landing itself. A held action emits exactly
|
||||
one normal `action_finished` when it later completes, and `queue_finished` still emits once only
|
||||
after the runtime lane is exhausted.
|
||||
|
||||
## 9. Null safety and interaction boundaries
|
||||
|
||||
- Missing required IK markers disable only the corresponding procedural channel and issue a
|
||||
prefixed warning; terrain gravity and queue execution must not crash.
|
||||
- Collision-proxy sweeps are read-only physics queries. They do not replace proxy synchronization
|
||||
or Phase 4.5 collision reactions.
|
||||
- RAGDOLL and RECOVERING never run the air/landing solvers. Existing ragdoll momentum capture sees
|
||||
the root's actual air movement through `_track_momentum()`.
|
||||
- The feature is PLAY-only through the existing stage lifecycle. It never changes authored
|
||||
transforms, `.scene` serialization, action registry payloads, or editor UI.
|
||||
|
||||
## 10. Implementation validation
|
||||
|
||||
The implemented feature was syntax-checked and exercised with the focused Phase 6a suite:
|
||||
|
||||
```powershell
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman"
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase6a_airborne_landing.gd" --verbose
|
||||
```
|
||||
|
||||
Results on 2026-09-17:
|
||||
|
||||
- `scripts/stickman_rig.gd` headless syntax check: passed.
|
||||
- `tests/test_phase6a_airborne_landing.gd`: **24/24 assertions passed**.
|
||||
- Existing `tests/test_phase4b_terrain.gd`: **8/8 assertions passed**.
|
||||
- User editor verification: forward-before-lift arm motion, stronger curved momentum, height-scaled
|
||||
hard landing, impact arm drop, and clean handoff/reset behavior confirmed.
|
||||
|
||||
The focused suite covers:
|
||||
|
||||
1. `DISABLED -> AIRBORNE`, signed takeoff velocity, full-speed pose offsets, exact air-pose
|
||||
restoration, and grounded idle velocity reset.
|
||||
2. Forward hand reach before lift, subsequent hand lift, exponential air drag, and direct ragdoll
|
||||
cleanup from airborne motion.
|
||||
3. Minimum and maximum fall-height mapping to compression, queue blocking during impact, deepest
|
||||
compression at 0.08 s, sharp downward hand reaction, stale-contact rejection, exact torso/hand
|
||||
restoration at 0.30 s, and barrier release.
|
||||
|
||||
Broader terrain, collision-proxy, queue, and slope regressions remain covered by the existing
|
||||
Phase 4b/4.5 suites and the manual editor scenarios below; they are not duplicated in the focused
|
||||
24-assertion script.
|
||||
|
||||
Manual Godot-editor verification after automated tests:
|
||||
|
||||
- walk off both sides of a raised platform and observe a curved, decaying lateral trajectory;
|
||||
- land at low, medium, and maximum fall speeds on flat ground, both ramp directions, and a stair
|
||||
edge; confirm visible air flail, knee compression, independent feet, and no marker snap;
|
||||
- queue `walk_to -> wait -> speak`, fall during the walk, and confirm strict order after recovery;
|
||||
- inject reactive sequential and overlay actions during compression; and
|
||||
- switch PLAY to DIRECT/EDIT and trigger ragdoll during compression, confirming clean immediate
|
||||
ownership handoffs and no later pose write.
|
||||
|
||||
## 11. Architectural alternatives and deferred work
|
||||
|
||||
- A `CharacterBody2D` root would provide `move_and_slide()` and richer wall/slope response, but it
|
||||
conflicts with the current Node2D rig root, separately synchronized ten-part proxies, ragdoll
|
||||
handoff, and authored transform restoration. Reuse proxy-shape sweeps for this bounded phase.
|
||||
- Adding `AnimationTargets/*_Base` nodes for both hands would generalize layered animation
|
||||
ownership, but takeoff capture already preserves the live custom pose and avoids a scene/clip
|
||||
migration. Revisit only if procedural arm layers must run while the base animation keeps playing.
|
||||
- Automatic ragdoll escalation, bounce, sliding, root slope tilt, air control, and ledge grabs are
|
||||
intentionally deferred; each changes director-visible behavior beyond landing polish.
|
||||
- Ten proxy casts per airborne rig per physics frame are acceptable at current sandbox scale but
|
||||
are recorded as a scaling trade-off in `docs/tech_debt_and_optimizations.md`.
|
||||
@@ -0,0 +1,536 @@
|
||||
# Phase 7 UI Polish — Architecture Specification
|
||||
|
||||
Status: **proposed; awaiting user approval**
|
||||
Source plan: `plans/PHASE_7_UI_POLISH.md`
|
||||
Target: Godot 4.7.1 Sandbox Stage only. The configured Stickman Editor main scene is not changed.
|
||||
|
||||
## 1. Scope and current-state audit
|
||||
|
||||
Phase 7 is an incremental polish pass, not a rewrite of `SandboxStage`. The stage scene remains deliberately small and the runtime UI remains owned by `SandboxStage._build_ui()`. Existing tests also access several stage fields directly, so moving the full UI into a new scene would add migration risk without improving the requested behavior.
|
||||
|
||||
### Already implemented; preserve and restyle
|
||||
|
||||
| Plan item | Existing implementation |
|
||||
|---|---|
|
||||
| Three mode controls | `_mode_buttons` in `scripts/sandbox_stage.gd` |
|
||||
| Mode badge, status text, live world coordinates | `_mode_badge`, `_status_label`, `_status_cursor_coords` |
|
||||
| Mode cursor and target reticle | `_apply_cursor()`, `_build_action_cursor()` |
|
||||
| Cursor tooltip and direct-action trajectory | `_tooltip`, `StagePlacementOverlay.set_action_trajectory()` |
|
||||
| Terrain AABB stride, Bresenham painting, Shift lock | `_terrain_stride()`, `_bresenham_cells()`, `_update_terrain_drag()` |
|
||||
| Three-state terrain ghosts | `_classify_cell()`, `_refresh_terrain_ghosts()` |
|
||||
| Asset selector with thumbnail cards and dimmer | `asset_selector.tscn`, `asset_selector.gd`, `_selector_dim` |
|
||||
| Sequential off-screen thumbnail rendering and disk cache | stage `_thumbnail_queue`; `scripts/thumbnails/*` |
|
||||
| Popup and editor font overrides | `_apply_popup_theme()` and widget `apply_font()` methods |
|
||||
|
||||
These behaviors are integration points, not new Phase 7 systems. Their public behavior and existing headless tests must continue to pass.
|
||||
|
||||
### In Phase 7
|
||||
|
||||
1. Replace the stage-local JSON parsing and scattered style constants with a validated, cached `SandboxTheme` service and schema version 2.
|
||||
2. Give the existing mode switcher, viewport frame, badge, status bar, tooltips, popups, dialogs, selector, and overlays a coherent theme-driven appearance.
|
||||
3. Replace the top-row spawn text buttons with a scrollable graphical card palette. Clicking keeps the existing persistent placement behavior; dragging a card performs one placement.
|
||||
4. Harden the existing thumbnail cache with a memory tier and stale-request protection. Rendering remains serialized on the main thread.
|
||||
5. Add reusable color, size, font-preset, and emoji controls, with stable typed APIs, ready for later parameter dialogs.
|
||||
|
||||
### Explicitly deferred
|
||||
|
||||
- Speech bubble geometry/styles, custom speech rendering, and changes to persisted `speak` actions. Section 10 defines the future contract, but Phase 7 does not emit or consume it.
|
||||
- Embedding the four new reusable widgets into the Speak dialog. That is the first consumer in the future speech-customization phase.
|
||||
- True background-thread thumbnail rendering. Godot scene/viewport rendering remains main-thread work; Phase 7 limits generation to one job at a time and avoids duplicate/stale work.
|
||||
- Dragging cards out of the modal `AssetSelector`. Direct drag uses the currently selected Prop or Stickman card in the persistent palette; the modal remains click-to-select.
|
||||
- New bitmap icon art. Phase 7 uses registry-provided emoji glyphs plus existing generated thumbnails. Art assets can replace them later without changing card APIs.
|
||||
- A full `.tscn` conversion of the code-built Sandbox UI.
|
||||
|
||||
## 2. Files
|
||||
|
||||
### Add
|
||||
|
||||
- `scripts/sandbox_theme.gd` — `class_name SandboxTheme`, schema validation, legacy aliases, typed accessors, font/style/theme caches.
|
||||
- `scripts/ui/asset_card.gd` — `class_name AssetCard`, reusable palette/selector card and pointer-drag threshold handling.
|
||||
- `scripts/ui/color_swatch_selector.gd` — `class_name ColorSwatchSelector`.
|
||||
- `scripts/ui/preset_size_toggle.gd` — `class_name PresetSizeToggle`.
|
||||
- `scripts/ui/font_preset_picker.gd` — `class_name FontPresetPicker`.
|
||||
- `scripts/ui/emoji_quick_grid.gd` — `class_name EmojiQuickGrid`.
|
||||
- `tests/test_phase7_theme.gd` — schema, fallback, alias, and resource/cache behavior.
|
||||
- `tests/test_phase7_widgets.gd` — widget data contracts and signals.
|
||||
- `tests/test_phase7_palette.gd` — card activation/drag state, mode visibility, and placement behavior.
|
||||
|
||||
The reusable controls are script-built `Control` subclasses, not new scenes. They have no project-specific node paths and can be instantiated in any popup.
|
||||
|
||||
### Change
|
||||
|
||||
- `sandbox_theme.json` — migrate the committed theme to schema `2.0`.
|
||||
- `scripts/sandbox_stage.gd` — own `SandboxTheme`; build/stylize the chrome and palette; route card drag input; use theme accessors; apply theme to popups/dialogs; add thumbnail request generation tokens.
|
||||
- `scripts/stage_spawner.gd` — expose registry-backed palette descriptors without exposing `_registry`.
|
||||
- `scripts/asset_selector.gd` — build cells with `AssetCard`; accept a `SandboxTheme`; keep current signals and pagination.
|
||||
- `scenes/asset_selector.tscn` — name layout containers used for theme spacing/margins and remove hard-coded visual values now owned by the theme. Node unique names and popup behavior remain compatible.
|
||||
- `scripts/thumbnails/thumbnail_cache.gd` — add an in-memory texture tier and versioned cache API while preserving current path helpers.
|
||||
- `scripts/stage_placement_overlay.gd` — accept resolved overlay colors/metrics through one style method; drawing/input behavior is unchanged.
|
||||
- `scripts/stage_director_visuals.gd` — consume a resolved style dictionary rather than reading raw schema internals; preserve `set_style(Dictionary)` as a compatibility wrapper.
|
||||
- `scripts/action_editor.gd`, `scripts/rule_editor.gd`, `scripts/queue_panel.gd`, `scripts/rule_panel.gd` — add `apply_theme(theme: SandboxTheme)` wrappers and retain current `apply_font(...)` methods for existing tests/callers.
|
||||
- Existing Phase 3b/3c/4b tests only where assertions must reflect the always-visible mode frame or v2 key names; behavioral coverage is not removed.
|
||||
|
||||
### Not changed in Phase 7
|
||||
|
||||
- `scenes/sandbox_stage.tscn`
|
||||
- `scripts/action_registry.gd`
|
||||
- `scripts/stage_scene_io.gd`
|
||||
- `scripts/stickman_rig.gd`
|
||||
- `scripts/stickman_speech_bubble.gd`
|
||||
|
||||
## 3. `sandbox_theme.json` schema 2.0
|
||||
|
||||
The JSON remains hand-editable. Colors are HTML strings (`#RRGGBB` or `#RRGGBBAA`), resource paths are strings, dimensions are logical screen pixels, and preset IDs are stable lowercase strings.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"global": {
|
||||
"font_preset": "ui",
|
||||
"font_size": 18,
|
||||
"text_color": "#F5F7FA",
|
||||
"muted_text_color": "#AAB2C0",
|
||||
"bg_color": "#171B24F2",
|
||||
"surface_color": "#252B38FA",
|
||||
"border_color": "#FFFFFF33",
|
||||
"border_width": 1,
|
||||
"corner_radius": 10,
|
||||
"padding": 8,
|
||||
"spacing": 8
|
||||
},
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"ui_font_bold": "",
|
||||
"ui_font_italic": "",
|
||||
"presets": {
|
||||
"ui": { "label": "Standard", "path": "" },
|
||||
"comic": { "label": "Comic", "path": "" },
|
||||
"pixel": { "label": "Pixel", "path": "" },
|
||||
"scary": { "label": "Scary", "path": "" }
|
||||
},
|
||||
"sizes": {
|
||||
"action_popup": 24,
|
||||
"action_popup_emoji": 22,
|
||||
"assignment_badge": 20,
|
||||
"rule_label": 16,
|
||||
"status_pill": 16,
|
||||
"tooltip": 18,
|
||||
"queue_panel": 18,
|
||||
"rule_panel": 18,
|
||||
"action_editor": 18,
|
||||
"rule_editor": 18,
|
||||
"panel_row": 16,
|
||||
"panel_title": 18,
|
||||
"asset_label": 14,
|
||||
"asset_badge": 12
|
||||
},
|
||||
"flags": {
|
||||
"panel_title_bold": true,
|
||||
"rule_label_bold": false,
|
||||
"badge_bold": true,
|
||||
"action_popup_bold": false,
|
||||
"action_popup_italic": false
|
||||
}
|
||||
},
|
||||
"mode_colors": {
|
||||
"edit": "#22C6FF",
|
||||
"direct": "#FFB300",
|
||||
"play": "#33DD77",
|
||||
"guide": "#22C6FF"
|
||||
},
|
||||
"grid": { "snap_size": 15.0 },
|
||||
"chrome": {
|
||||
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
|
||||
"top_bar": { "height": 48 },
|
||||
"status_bar": { "height": 32 },
|
||||
"mode_switcher": { "segment_min_width": 104 },
|
||||
"mode_frame": { "border_width": 3 },
|
||||
"mode_badge": { "corner_radius": 12, "padding": 10 },
|
||||
"tooltip": { "bg_color": "#101010E6", "border_width": 1 }
|
||||
},
|
||||
"popups": {
|
||||
"style_defaults": { "bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12 },
|
||||
"action_menu": { "font_size": 24 },
|
||||
"dialog": {},
|
||||
"asset_selector": { "backdrop_alpha": 0.55 }
|
||||
},
|
||||
"palette": {
|
||||
"style_defaults": { "bg_color": "#252B38FA", "corner_radius": 10, "padding": 6 },
|
||||
"dock_width": 116,
|
||||
"card_size": [96, 94],
|
||||
"icon_size": 30,
|
||||
"drag_threshold": 8,
|
||||
"selected_border_width": 3
|
||||
},
|
||||
"overlays": {
|
||||
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
|
||||
"terrain_guide": { "color": "#22C6FF" },
|
||||
"valid": { "color": "#4DE66680" },
|
||||
"same_type": { "color": "#FFFFFF26" },
|
||||
"invalid": { "color": "#FF4D4D80" }
|
||||
},
|
||||
"widgets": {
|
||||
"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"],
|
||||
"size_presets": [
|
||||
{ "id": "small", "label": "Small", "value": 20 },
|
||||
{ "id": "medium", "label": "Medium", "value": 28 },
|
||||
{ "id": "huge", "label": "HUGE", "value": 40 }
|
||||
],
|
||||
"emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]
|
||||
},
|
||||
"speech_bubbles": {
|
||||
"style_defaults": {
|
||||
"font_preset": "ui",
|
||||
"size_preset": "medium",
|
||||
"color": "#FFFFFFFF",
|
||||
"text_color": "#111111FF",
|
||||
"border_color": "#00000099",
|
||||
"border_width": 2,
|
||||
"corner_radius": 8,
|
||||
"padding": 14
|
||||
},
|
||||
"styles": {
|
||||
"standard": { "label": "Standard", "geometry": "rounded" },
|
||||
"yell": { "label": "Yell", "geometry": "burst" },
|
||||
"thought": { "label": "Thought", "geometry": "cloud" },
|
||||
"whisper": { "label": "Whisper", "geometry": "dashed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Array values such as `card_size` must contain exactly two finite numbers. Unsupported or malformed values fall back independently; one bad field does not discard the rest of the file.
|
||||
|
||||
### Resolution order
|
||||
|
||||
For a component such as `popups.action_menu`, the service deep-merges, from lowest to highest priority:
|
||||
|
||||
1. Hard-coded `SandboxTheme.DEFAULT_CONFIG` value.
|
||||
2. Loaded `global` value.
|
||||
3. Loaded subsystem `popups.style_defaults` value.
|
||||
4. Loaded component `popups.action_menu` value.
|
||||
|
||||
Font resolution is: component `font_preset`/explicit path → subsystem default → `global.font_preset` → `fonts.ui_font` → `ThemeDB.fallback_font`. An unavailable named preset falls back to `ui`. Emoji font resolution is `fonts.emoji_font` → resolved UI font → `ThemeDB.fallback_font`.
|
||||
|
||||
### Validation, warnings, and caching
|
||||
|
||||
- Missing, unreadable, malformed, or non-dictionary JSON produces one non-blocking `push_warning` and a complete default config.
|
||||
- A wrong type, non-finite number, bad color, or missing/non-Font resource warns once per key and falls back only that key.
|
||||
- Unknown keys are retained in `raw_config()` for forward compatibility but are ignored by typed accessors.
|
||||
- Fonts are loaded once per normalized non-empty resource path in `_font_cache`.
|
||||
- Resolved `StyleBoxFlat` templates are cached by the canonical resolved style dictionary. `stylebox()` returns a duplicate so mode-specific color mutation cannot leak into another control.
|
||||
- The generated Godot `Theme` is cached. Call `invalidate_caches()` only after an explicit reload; Phase 7 does not add live file watching.
|
||||
- No theme resource failure may prevent Sandbox startup.
|
||||
|
||||
## 4. `SandboxTheme` public contract
|
||||
|
||||
```gdscript
|
||||
class_name SandboxTheme
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_VERSION: String = "2.0"
|
||||
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool
|
||||
func raw_config() -> Dictionary
|
||||
func value(path: String, fallback: Variant = null) -> Variant
|
||||
func color(path: String, fallback: Color) -> Color
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
|
||||
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int
|
||||
func font(component_path: String = "", preset_id: String = "ui") -> Font
|
||||
func emoji_font() -> Font
|
||||
func style(component_path: String) -> Dictionary
|
||||
func stylebox(component_path: String) -> StyleBoxFlat
|
||||
func control_theme() -> Theme
|
||||
func swatches() -> Array[Color]
|
||||
func size_presets() -> Array[Dictionary]
|
||||
func font_presets() -> Array[Dictionary]
|
||||
func emoji_items() -> Array[String]
|
||||
func invalidate_caches() -> void
|
||||
```
|
||||
|
||||
`raw_config()` returns a deep duplicate. The preset array entries returned by `size_presets()` are `{id: String, label: String, value: int}`; font preset entries are `{id: String, label: String, font: Font}`. Invalid entries are omitted, while a built-in medium size and UI font are always available.
|
||||
|
||||
No reload signal is needed in Phase 7 because the theme loads once before UI construction. A later live-theme editor may add `theme_reloaded()` without changing the accessor API.
|
||||
|
||||
## 5. Palette descriptors and controls
|
||||
|
||||
### `StageSpawner` API
|
||||
|
||||
Add:
|
||||
|
||||
```gdscript
|
||||
func get_palette_entries() -> Array[Dictionary]
|
||||
```
|
||||
|
||||
Each returned deep-duplicated entry has this shape:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"placement_id": String, # existing id accepted by set_placement_mode()
|
||||
"kind": String, # "terrain" | "prop" | "stickman" | "area"
|
||||
"label": String,
|
||||
"icon": String, # emoji fallback, not a resource path
|
||||
"category": String, # visible badge/group label
|
||||
"opens_selector": bool
|
||||
}
|
||||
```
|
||||
|
||||
Registry defaults are Ground/🧱/Terrain, Ramp/📐/Terrain, Step/🪜/Terrain, Prop/📦/Props, Stickman/🧍/Cast, and Area/🎯/Events. `get_spawnable_ids()` and `get_label()` remain unchanged.
|
||||
|
||||
### `AssetCard`
|
||||
|
||||
```gdscript
|
||||
class_name AssetCard
|
||||
extends Button
|
||||
|
||||
signal activated(payload: Dictionary)
|
||||
signal drag_started(payload: Dictionary)
|
||||
|
||||
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: SandboxTheme = null) -> void
|
||||
func set_thumbnail(texture: Texture2D) -> void
|
||||
func set_selected(selected: bool) -> void
|
||||
func payload() -> Dictionary
|
||||
```
|
||||
|
||||
The payload is deep-copied on input and signal emission. The card owns icon/thumbnail, label, and optional category badge children. It emits `activated` on a release that stayed below `palette.drag_threshold`; after the threshold it emits `drag_started` once and suppresses activation. It does not mutate stage or spawner state directly.
|
||||
|
||||
### Reusable widget APIs
|
||||
|
||||
```gdscript
|
||||
# ColorSwatchSelector
|
||||
signal color_selected(color: Color)
|
||||
func configure(colors: Array[Color], selected: Color, theme: SandboxTheme = null) -> void
|
||||
func set_selected(color: Color, emit_signal: bool = false) -> void
|
||||
func get_selected() -> Color
|
||||
|
||||
# PresetSizeToggle
|
||||
signal preset_selected(id: String, value: int)
|
||||
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
|
||||
func set_selected(id: String, emit_signal: bool = false) -> void
|
||||
func get_selected_id() -> String
|
||||
func get_selected_value() -> int
|
||||
|
||||
# FontPresetPicker
|
||||
signal preset_selected(id: String)
|
||||
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
|
||||
func set_selected(id: String, emit_signal: bool = false) -> void
|
||||
func get_selected_id() -> String
|
||||
|
||||
# EmojiQuickGrid
|
||||
signal emoji_selected(text: String)
|
||||
func configure(items: Array[String], columns: int = 8, theme: SandboxTheme = null) -> void
|
||||
```
|
||||
|
||||
All configure methods replace prior children, tolerate empty inputs, use a `ButtonGroup` for exclusive preset selection, expose keyboard focus, and meet a minimum 44×44 logical-pixel pointer target.
|
||||
|
||||
## 6. Node ownership and layout
|
||||
|
||||
`SandboxStage` continues to create one `CanvasLayer` named `UI`. Its runtime ownership becomes:
|
||||
|
||||
```text
|
||||
SandboxStage (Node2D; state and orchestration)
|
||||
├── GridLayer / World / GhostHolder / PlacementOverlay / DirectorVisuals (world space)
|
||||
└── UI (CanvasLayer)
|
||||
├── ModeFrame (Panel; full rect; mouse ignore)
|
||||
├── TopBar (PanelContainer)
|
||||
│ └── FileMenu + ModeSwitcher + edit/direct utility controls
|
||||
├── PaletteDock (PanelContainer; left, below TopBar and above StatusBar)
|
||||
│ └── ScrollContainer/PaletteCards (VBoxContainer of AssetCard)
|
||||
├── ModeBadge (PanelContainer; mouse ignore)
|
||||
├── ActionTooltip (Label; mouse ignore)
|
||||
├── BottomStatusBar (PanelContainer)
|
||||
├── SelectorDim (ColorRect; visible only with selector)
|
||||
└── Popups/dialogs/selectors/editors
|
||||
```
|
||||
|
||||
`PaletteDock` is visible only in EDIT. Existing grid/snap/size controls remain in `TopBar`; spawn buttons leave the top row. `_palette_buttons` is retained and maps placement IDs to `AssetCard` (a `Button` subclass), preserving callers that inspect `button_pressed`/`visible`.
|
||||
|
||||
The mode segments share a `ButtonGroup`, have adjacent rounded styles, and retain `_on_mode_segment_toggled`. `_mode_frame` is visible in all three modes and uses the active accent; it remains behind all chrome and ignores mouse input. The badge remains below the top bar and displays the existing text.
|
||||
|
||||
The stage assigns `theme.control_theme()` to the UI root and explicitly to Window-derived popups/dialogs that do not reliably inherit across a native/embedded window boundary. Component styleboxes are then applied only where a named component override is needed.
|
||||
|
||||
## 7. Input and signal flow
|
||||
|
||||
### Click placement
|
||||
|
||||
1. `AssetCard.activated(payload)` reaches `SandboxStage._on_palette_card_activated(payload)`.
|
||||
2. Terrain/Area calls existing `set_placement_mode(placement_id)`.
|
||||
3. Prop/Stickman opens the existing selector; selecting an entry updates the spawner and arms the existing `prop`/`stickman` placement ID.
|
||||
4. LMB remains repeated placement and RMB/Escape cancels, matching existing behavior.
|
||||
|
||||
### Direct card drag
|
||||
|
||||
Godot Control drag-and-drop is not used because the drop surface is a `Node2D` world and a full-screen `Control` would compete with existing `_unhandled_input` selection/gizmo routing.
|
||||
|
||||
1. `AssetCard.drag_started(payload)` calls `_begin_palette_drag(payload)`.
|
||||
2. The stage stores `_palette_drag_payload`, snapshots the prior placement ID, resolves current Prop/Stickman selection, and creates the normal collision-disabled placement ghost.
|
||||
3. While a palette drag is active, `SandboxStage._input` handles mouse motion and LMB release before pan/zoom routing. Tooltip/modal/rule input is not entered.
|
||||
4. A release is valid only inside the stage viewport and outside visible interactive chrome (`gui_get_hovered_control() == null` or a mouse-ignore overlay). Valid release places exactly one object at `_viewport_to_world(event.position)`, using existing snap/spawn/finalize/signals/dirty-state code.
|
||||
5. Release over UI, RMB, Escape, mode change, selector open, or focus loss cancels and frees the drag ghost.
|
||||
6. A card drag is one-shot and restores the previous persistent placement selection. It never starts terrain line-painting; terrain drag-painting still starts from a stage press after click-arming the tool.
|
||||
|
||||
Add private state and helpers in `sandbox_stage.gd`:
|
||||
|
||||
```gdscript
|
||||
var _palette_drag_payload: Dictionary = {}
|
||||
var _palette_drag_previous_id: String = ""
|
||||
|
||||
func _on_palette_card_activated(payload: Dictionary) -> void
|
||||
func _begin_palette_drag(payload: Dictionary) -> void
|
||||
func _update_palette_drag(viewport_pos: Vector2) -> void
|
||||
func _finish_palette_drag(viewport_pos: Vector2) -> void
|
||||
func _cancel_palette_drag() -> void
|
||||
func _is_stage_drop_position(viewport_pos: Vector2) -> bool
|
||||
```
|
||||
|
||||
Priority while active is palette drag → modal suppression → pan/zoom → existing world tools. Existing rule/capture/terrain/gizmo priorities remain unchanged when no palette drag is active.
|
||||
|
||||
## 8. Thumbnail pipeline
|
||||
|
||||
The existing renderer nodes and queue remain stage-owned. Do not introduce threads.
|
||||
|
||||
Extend `ThumbnailCache` with:
|
||||
|
||||
```gdscript
|
||||
func get_texture(cache_path: String) -> Texture2D
|
||||
func store_texture(cache_path: String, texture: Texture2D) -> Error
|
||||
func forget_memory(cache_path: String) -> void
|
||||
func clear_memory() -> void
|
||||
```
|
||||
|
||||
`get_texture()` checks an in-memory dictionary, then PNG, and memoizes success. `store_texture()` memoizes and persists. Existing `load_png()`/`save_png()` remain as compatibility wrappers.
|
||||
|
||||
The stage increments `_thumbnail_generation: int` whenever selector entries change or the selector closes. Each queued job carries `{generation, kind, entry, cache_path}`. After each `await`, results update the selector only when the generation still matches, the selector is open, and the entry is still present. Cache writes may complete for a stale UI generation because they remain useful. Queue keys prevent duplicate work in the same generation. One job at a time preserves frame stability and the existing renderer assumptions.
|
||||
|
||||
## 9. Overlay, popup, and dialog integration
|
||||
|
||||
- `StagePlacementOverlay.apply_style(style: Dictionary)` receives resolved guide/valid/invalid metrics. Existing public setters and screen-constant line math remain intact.
|
||||
- The three terrain ghost modulates come from `overlays.valid`, `overlays.same_type`, and `overlays.invalid`; classification values and commit rules do not change.
|
||||
- `StageDirectorVisuals.apply_style(style: Dictionary, fonts: Dictionary)` receives already-resolved sizes/colors/fonts. Keep `set_style(Dictionary)` as a legacy wrapper through Phase 7 tests.
|
||||
- All PopupMenus use `popups.action_menu`; Accept/Confirmation/File dialogs use `popups.dialog`; `AssetSelector` uses `popups.asset_selector`.
|
||||
- `_apply_popup_theme()` remains temporarily as a wrapper around `SandboxTheme` so existing direct tests do not break.
|
||||
- Tooltip positioning continues clamping to the visible viewport. Palette dock bounds are also treated as UI, so a tooltip never becomes a valid world drop region.
|
||||
|
||||
## 10. Future speech customization contract (blueprint only)
|
||||
|
||||
When implemented in a later phase, a flat queue action will optionally carry one nested visual object:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"type": "speak",
|
||||
"text": String,
|
||||
"duration": float,
|
||||
"stop_current": bool, # optional, existing
|
||||
"visual": { # optional; absent means theme defaults
|
||||
"font_preset": String,
|
||||
"size_preset": String,
|
||||
"color": String, # HTML RGBA
|
||||
"bubble_style": String # standard/yell/thought/whisper
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rule actions will store the same `visual` dictionary under their existing `params`. `ActionRegistry.to_rule_action()`/`from_rule_action()` will copy it unchanged, `StageSceneIO` will validate known scalar fields while preserving absence, and `SpeechBubble.show_text()` will eventually accept a style dictionary. Unknown preset/style IDs must resolve to theme defaults rather than invalidating the action.
|
||||
|
||||
Phase 7 only reserves the theme IDs and supplies the reusable controls. It deliberately does not modify save files or runtime speech behavior.
|
||||
|
||||
## 11. Compatibility and migration
|
||||
|
||||
- Theme schema `1.0`, missing version, and `2.0` are accepted. Unsupported future major versions warn and use recognized keys plus defaults.
|
||||
- Legacy aliases are applied before validation:
|
||||
- `mode_colors.edit_accent/direct_accent/play_accent/guide_line` → `mode_colors.edit/direct/play/guide`.
|
||||
- Existing flat `fonts.*_font_size` keys → matching `fonts.sizes.*` keys.
|
||||
- `fonts.action_popup.{size,bold,italic}` → `fonts.sizes.action_popup` and `fonts.flags.action_popup_*`.
|
||||
- Existing bold flags and `assignment_badge_radius` retain their current meaning.
|
||||
- A v2 key wins when both v2 and its legacy alias exist.
|
||||
- No automatic write-back of user theme files occurs.
|
||||
- Existing `user://sandbox_settings.json` values win over theme defaults. If a settings key is absent, the loaded theme value—not the old hard-coded constant—is used.
|
||||
- `.scene` serialization remains byte-contract compatible because Phase 7 changes no scene payload fields.
|
||||
- Existing public stage signals (`mode_changed`, `object_placed`, selection/deletion signals), `AssetSelector` signals, placement IDs, and action dictionaries remain unchanged.
|
||||
- Existing private test-visible fields remain present. `_palette_buttons` values remain `Button`-compatible.
|
||||
- Missing emoji/custom fonts degrade to `ThemeDB.fallback_font`; labels stay usable even if a glyph renders as a fallback box.
|
||||
|
||||
## 12. Delivery phases and acceptance criteria
|
||||
|
||||
### 7.1 Theme service and schema
|
||||
|
||||
- `SandboxTheme` loads the committed v2 file and produces complete resolved values.
|
||||
- Missing/malformed files, invalid individual keys, and missing font paths never abort startup.
|
||||
- v1 aliases reproduce current sizes/colors.
|
||||
- Font/style/theme caches reuse resources; caller mutation of one returned stylebox does not affect another.
|
||||
- Existing grid setting precedence is preserved with the corrected theme fallback for missing settings keys.
|
||||
|
||||
### 7.2 Chrome and mode polish
|
||||
|
||||
- Mode segments are one exclusive group with clear active accent and keyboard focus.
|
||||
- Frame, badge, and status bar update immediately in EDIT/DIRECT/PLAY; the frame uses each mode color in every mode.
|
||||
- Existing cursor, tooltip, target trajectory, status coordinate, and Escape behavior pass unchanged.
|
||||
- At 1280×720, top/bottom chrome and the palette do not cover each other; at smaller windows, the palette scrolls instead of overflowing.
|
||||
|
||||
### 7.3 Reusable widgets
|
||||
|
||||
- All four controls configure from the theme contract, rebuild safely, emit exactly once per user choice, and expose their selected value.
|
||||
- Empty/malformed preset arrays fall back safely.
|
||||
- Every interactive item has keyboard focus and a minimum 44×44 target.
|
||||
|
||||
### 7.4 Palette, selector, and thumbnail cache
|
||||
|
||||
- Six registry-driven graphical cards appear only in EDIT and preserve `_palette_buttons` state semantics.
|
||||
- Click-arming, modal Prop/Stickman selection, repeated placement, RMB/Escape cancellation, and mode transitions behave as before.
|
||||
- Dragging a palette card over the stage places exactly one item; release over UI/cancel places none and leaves no ghost.
|
||||
- The selector retains pagination, dimming, Browse/Refresh, generated previews, and click selection.
|
||||
- Cached textures are reused from memory/disk, duplicate jobs are suppressed, and stale async completion never updates a closed or repopulated selector.
|
||||
|
||||
### 7.5 Popup/overlay consistency and regression
|
||||
|
||||
- Popup menus, dialogs, panels, selector cards, tooltip, terrain guides, ghosts, and action trajectories use the resolved theme.
|
||||
- Terrain painting still uses template AABB stride, Bresenham cells, three classifications, Shift axis lock, and skip-on-conflict commits.
|
||||
- Queue/rule/action editor behavior and font APIs remain compatible.
|
||||
- No changes occur to speech rendering or saved action schemas.
|
||||
|
||||
## 13. Validation strategy
|
||||
|
||||
### Headless tests
|
||||
|
||||
Run the new suites plus the regression suites most directly touched:
|
||||
|
||||
```powershell
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_theme.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_widgets.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_palette.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_stage.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_logic.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_terrain.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b1_fixes.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_library.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_ui_fixes.gd
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3c_bugfix.gd
|
||||
```
|
||||
|
||||
Run `--check-only` on every added/changed `.gd` script before those suites. Tests should exercise public methods/signals where possible; source-text assertions are only a last resort for engine behavior that cannot be driven headlessly.
|
||||
|
||||
### User verification in Godot (spec-pipeline hand-off)
|
||||
|
||||
After implementation and green headless tests, stop and ask the user to verify:
|
||||
|
||||
1. Resize the Sandbox Stage from 1280×720 down to a small window and confirm chrome/palette remain usable.
|
||||
2. Switch all modes and confirm frame, badge, segment, status, cursor, and visibility changes.
|
||||
3. Click-arm and drag-drop each card type; cancel by UI release, RMB, Escape, and a mode change.
|
||||
4. Open Prop and Stickman selectors twice to confirm thumbnail reuse and no visible stale updates.
|
||||
5. Exercise Direct-mode Walk To and rule targets to verify tooltip/trajectory/reticle layering.
|
||||
6. Terrain-paint empty, same-type, and conflicting cells with and without Shift.
|
||||
7. Open Speak/Wait/Rename, queue/rule editors, and context menus to judge font size, contrast, and emoji fallback.
|
||||
|
||||
Do not update README/project documentation until the user explicitly confirms the feature works.
|
||||
|
||||
## 14. Design decisions and trade-offs requiring approval
|
||||
|
||||
1. **Keep the Sandbox UI code-built.** This minimizes churn in a 4,000-line controller and preserves existing test-visible fields. Replacing it with a complete UI scene is deferred.
|
||||
2. **Use a dedicated theme service, not raw dictionary reads or a JSON-to-Theme singleton.** The stage owns one instance, making tests deterministic and avoiding global state; a cached Godot `Theme` still supplies normal Control inheritance.
|
||||
3. **Use custom card pointer capture for world drops.** A full-screen Control drop target risks intercepting the existing Node2D world input stack. Drag placement is intentionally one-shot; click placement remains persistent.
|
||||
4. **Show the mode frame in all modes.** The plan says it reflects the active mode; the current implementation shows it only in DIRECT. Phase 7 changes that behavior.
|
||||
5. **Reserve but do not implement speech customization.** Theme presets and reusable controls land now, while action persistence/runtime rendering stay unchanged until a dedicated speech phase.
|
||||
6. **Serialized, cached thumbnail work is preferred over threading.** It is safer with Godot viewports and already matches the working architecture; memory caching and generation tokens address the actual stutter/stale-update risks.
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
# Phase 8 — Rig Relocation and Runtime Component Modularization
|
||||
|
||||
Status: architecture proposed; awaiting user approval before implementation.
|
||||
|
||||
Source proposal: `plans/PHASE_8_RIG_MODULARIZATION.md`.
|
||||
|
||||
## 1. Outcome and architectural correction
|
||||
|
||||
Move the authored rig scene from `res://master_rig.tscn` to
|
||||
`res://scenes/stickman_rig.tscn` while preserving its existing scene UID
|
||||
(`uid://dr5ef3l2var2s`). Split the 3,159-line `StickmanRig` controller into three child
|
||||
components, but keep `StickmanRig` as the only public facade used by the factory, stage,
|
||||
harnesses, scene I/O, and existing gameplay code.
|
||||
|
||||
One boundary in the source plan should change before implementation: navigation and
|
||||
`walk_to()` belong in `StickmanLocomotion`, not `StickmanActionRunner`. Walking, terrain
|
||||
following, ledge momentum, landing, animation pause/resume, and root displacement share one
|
||||
state machine and one `AnimationPlayer`. Splitting path following from terrain motion would
|
||||
create two writers for the rig transform and require a per-frame synchronization protocol.
|
||||
`StickmanActionRunner` should orchestrate actions and call the locomotion API; it should not
|
||||
implement movement.
|
||||
|
||||
The extraction is structural only. It must not change movement tuning, recovery math, action
|
||||
payloads, signal timing, saved scene data, or editor UI.
|
||||
|
||||
## 2. Targets and ownership
|
||||
|
||||
| Target | Ownership after Phase 8 |
|
||||
| --- | --- |
|
||||
| `scenes/stickman_rig.tscn` | Renamed rig scene. Keeps the existing `Master`, `Body`, `Skeleton2D`, `IK_Targets`, `AnimationTargets`, `AnimationPlayer`, and `AnimationTree` paths. Adds a nonvisual `RigComponents` branch. |
|
||||
| `scripts/stickman_rig.gd` (`StickmanRig`) | Public compatibility facade; authoritative `RigState`; facing profile, bend flags, IK modification resolution, body z-order, root mirror, head pin, shared node lookup, component wiring, deterministic physics scheduling, and public signal forwarding. |
|
||||
| `scripts/stickman_locomotion.gd` (`StickmanLocomotion`) | Navigation agent, walking, terrain probes/following, airborne and landing states, root movement, gait/foot contacts, animated collision proxies, airborne sweeps, and impact reactions. |
|
||||
| `scripts/stickman_ragdoll.gd` (`StickmanRagdoll`) | Momentum tracking, ragdoll definitions and procedural bodies/joints, rest detection, pre-ragdoll/captured-pose data, recovery snap/tween, and ragdoll impulse application. |
|
||||
| `scripts/stickman_action_runner.gd` (`StickmanActionRunner`) | Authored/runtime action queues, reactive injection, action phase machine, wait timing, speech bubble and overlay arbitration, and action/queue/speech signals. |
|
||||
| `scripts/stickman_factory.gd` | Loads the relocated scene; return type and `spawn*` API stay unchanged. |
|
||||
| `scripts/stk_rig_adapter.gd` | No node-path changes. Comments reference the relocated scene. It continues to adapt the root before `_ready()`. |
|
||||
| `scripts/create_animations.gd`, harnesses, tests, active docs | Update live scene-path literals/comments and component-aware tests. |
|
||||
|
||||
`source_stk_path` remains on the root facade because it is instance provenance consumed by
|
||||
stage serialization, not behavior owned by a component.
|
||||
|
||||
No layout, theme, visible node, or user-facing UI change is required.
|
||||
|
||||
## 3. Scene structure
|
||||
|
||||
The existing visual and skeleton hierarchy must remain byte-for-byte equivalent apart from the
|
||||
new component branch and external component script resources. Adapter and harness node paths
|
||||
therefore remain valid.
|
||||
|
||||
```text
|
||||
Master: Node2D [StickmanRig]
|
||||
├── RigComponents: Node
|
||||
│ ├── Locomotion: Node [StickmanLocomotion]
|
||||
│ ├── Ragdoll: Node [StickmanRagdoll]
|
||||
│ └── ActionRunner: Node [StickmanActionRunner]
|
||||
├── Body: Node2D
|
||||
│ └── Body / Head / 8 limb visual containers (unchanged)
|
||||
├── Skeleton2D (unchanged)
|
||||
│ └── Torso/Head + 8 limb Bone2D nodes
|
||||
├── RayCast_Ground (unchanged; legacy)
|
||||
├── IK_Targets (unchanged)
|
||||
├── AnimationTargets (unchanged)
|
||||
├── AnimationPlayer (unchanged)
|
||||
└── AnimationTree (unchanged)
|
||||
|
||||
world/stage parent
|
||||
└── RagdollBodyContainer (runtime only; unchanged location)
|
||||
├── 10 RigidBody2D
|
||||
└── 9 PinJoint2D
|
||||
```
|
||||
|
||||
The components are authored children rather than created in `_ready()`. Missing component nodes
|
||||
produce one `StickmanRig:` warning per missing node and disable only that subsystem. The root must
|
||||
never manufacture a fallback component silently because that would hide a broken scene.
|
||||
|
||||
## 4. Dependency direction and state authority
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C[StickmanRig facade/coordinator]
|
||||
L[StickmanLocomotion]
|
||||
R[StickmanRagdoll]
|
||||
A[StickmanActionRunner]
|
||||
U[Stage / Factory / Harness / Scene IO]
|
||||
|
||||
U -->|existing public API| C
|
||||
C -->|configure + deterministic ticks| L
|
||||
C -->|configure + commands| R
|
||||
C -->|configure + commands| A
|
||||
A -->|walk/speech-independent movement query| L
|
||||
A -->|ragdoll_requested / recovery_requested| C
|
||||
R -->|state_transition_requested| C
|
||||
R -->|ground query + recovery reanchor| L
|
||||
L -->|arrived / facing_requested| C
|
||||
C -->|forwarded public signals| U
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. `StickmanRig.state` is the only high-level state authority. Components receive the current
|
||||
state during commands/ticks and never write it directly.
|
||||
2. `StickmanActionRunner` has no reference to `StickmanRagdoll`; it requests transitions through
|
||||
root-connected signals. This allows queue tests without constructing physics bodies.
|
||||
3. `StickmanRagdoll` may synchronously call only the narrow locomotion recovery/query API; it
|
||||
must not inspect locomotion private fields.
|
||||
4. `StickmanLocomotion` requests facing changes by signal so the facade remains the profile and
|
||||
mirror authority.
|
||||
5. The components do not implement `_physics_process()`. The root is the sole scheduler. This
|
||||
preserves the current update order and avoids relying on Godot sibling processing order.
|
||||
|
||||
## 5. Static component contracts
|
||||
|
||||
The signatures below are the implementation contract. Internal helper names may be retained
|
||||
during mechanical extraction, but cross-component calls must use only these methods/signals.
|
||||
|
||||
### 5.1 `StickmanLocomotion`
|
||||
|
||||
```gdscript
|
||||
class_name StickmanLocomotion
|
||||
extends Node
|
||||
|
||||
signal arrived(target: Vector2)
|
||||
signal facing_requested(profile: int)
|
||||
|
||||
func configure(rig: StickmanRig) -> void
|
||||
func tick_movement(delta: float, rig_state: int) -> void
|
||||
func tick_post_skeleton(delta: float, rig_state: int) -> void
|
||||
|
||||
func walk_to(target: Vector2, speed: float = -1.0) -> bool
|
||||
func cancel_walk(restore_idle_pose: bool = true) -> void
|
||||
func is_walking() -> bool
|
||||
func consume_walk_completed() -> bool
|
||||
|
||||
func begin_terrain_fall() -> void
|
||||
func set_terrain_following_enabled(enabled: bool) -> void
|
||||
func is_action_blocked() -> bool
|
||||
func prepare_for_ragdoll() -> void
|
||||
func restore_after_recovery() -> void
|
||||
|
||||
func probe_ground_y(
|
||||
world_x: float,
|
||||
top_y: float,
|
||||
bottom_y: float,
|
||||
exclude: Array[RID]
|
||||
) -> float
|
||||
func reanchor_for_recovery(feet_world: Vector2, foot_offset: Vector2) -> Vector2
|
||||
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void
|
||||
func apply_collision_reaction(
|
||||
part_name: String,
|
||||
normal: Vector2,
|
||||
impact_speed: float,
|
||||
contact_position: Vector2 = Vector2.INF
|
||||
) -> void
|
||||
```
|
||||
|
||||
`tick_movement()` owns the old clear-pelvis → walking → terrain → settle sequence.
|
||||
`tick_post_skeleton()` owns impact-reaction application and collision-proxy synchronization,
|
||||
which must remain after the facade's head pin. `reanchor_for_recovery()` moves the root and
|
||||
returns the mirror-aware rig-local shift used by `StickmanRagdoll` to rebase captured body data.
|
||||
|
||||
The locomotion component is the source of truth for `TerrainMotion`, navigation, terrain/air
|
||||
tuning constants, collision proxy tables, and reaction constants. Compatibility aliases may
|
||||
remain on `StickmanRig` for one phase, but they must point to component constants rather than
|
||||
duplicate values.
|
||||
|
||||
### 5.2 `StickmanRagdoll`
|
||||
|
||||
```gdscript
|
||||
class_name StickmanRagdoll
|
||||
extends Node
|
||||
|
||||
signal state_transition_requested(next_state: int)
|
||||
signal rest_state_changed(at_rest: bool)
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
|
||||
func tick_pre_movement(delta: float, rig_state: int) -> void
|
||||
|
||||
func enter_ragdoll() -> bool
|
||||
func request_recovery() -> bool
|
||||
func snap_to_standing() -> bool
|
||||
func cancel_recovery() -> void
|
||||
func apply_velocity_boost(velocity: Vector2) -> void
|
||||
func is_at_rest() -> bool
|
||||
func initialize_spawn_pose_from_skeleton() -> void
|
||||
```
|
||||
|
||||
`enter_ragdoll()` and recovery methods return whether they actually accepted the transition.
|
||||
They perform the physical handoff before emitting `state_transition_requested`; the root then
|
||||
commits `state` and emits the legacy `state_changed` signal exactly once. The component owns the
|
||||
existing `RAGDOLL_BODIES`, `RAGDOLL_JOINTS`, per-profile joint bands, recovery overrides,
|
||||
captured dictionaries, tween generation, and procedural container lifetime.
|
||||
|
||||
The root provides narrow internal skeleton services used by recovery:
|
||||
|
||||
```gdscript
|
||||
func _get_skeleton() -> Skeleton2D
|
||||
func _get_body_container() -> Node2D
|
||||
func _get_animation_player() -> AnimationPlayer
|
||||
func _get_ik_marker(name: String) -> Marker2D
|
||||
func _get_limb_bones() -> Dictionary
|
||||
func _get_bend_modifications() -> Dictionary
|
||||
func _get_head_bone() -> Bone2D
|
||||
func _get_head_look_at() -> SkeletonModification2DLookAt
|
||||
func _set_limb_mods_enabled(enabled: bool) -> void
|
||||
func _restore_head_lookat_state() -> void
|
||||
func _rearm_ik_stack() -> void
|
||||
```
|
||||
|
||||
These methods are internal collaboration points, not new consumer APIs.
|
||||
|
||||
### 5.3 `StickmanActionRunner`
|
||||
|
||||
```gdscript
|
||||
class_name StickmanActionRunner
|
||||
extends Node
|
||||
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
signal queue_finished
|
||||
signal queue_changed
|
||||
signal speech_finished
|
||||
signal ragdoll_requested(enabled: bool)
|
||||
signal recovery_requested
|
||||
|
||||
var action_queue: Array[Dictionary] = []
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
|
||||
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void
|
||||
|
||||
func speak(text: String, duration: float) -> void
|
||||
func queue_action(action: Dictionary) -> void
|
||||
func clear_queue() -> void
|
||||
func get_queue() -> Array[Dictionary]
|
||||
func remove_action(index: int) -> void
|
||||
func insert_action(index: int, action: Dictionary) -> void
|
||||
func queue_size() -> int
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void
|
||||
func clear_reactive_actions() -> void
|
||||
func start_queue() -> void
|
||||
func stop_queue() -> void
|
||||
func is_queue_running() -> bool
|
||||
func sync_facing(profile: int) -> void
|
||||
```
|
||||
|
||||
`RunnerState` and `ActionPhase` are component-owned. Root keeps enum aliases/definitions only
|
||||
where required for source compatibility. The component owns both primary and overlay speech,
|
||||
creates `SpeechBubble` under `Master` (not under `RigComponents`), and counter-mirrors it through
|
||||
`sync_facing()`.
|
||||
|
||||
## 6. Root compatibility facade
|
||||
|
||||
All current consumer-facing members remain available on `StickmanRig` with unchanged names,
|
||||
parameters, return types, and signal cardinality.
|
||||
|
||||
| Existing root surface | Delegates to / remains in |
|
||||
| --- | --- |
|
||||
| `set_facing_profile`, `get_facing_profile`, bend getters/setters, bend joint queries | Root |
|
||||
| `state`, `state_changed`, `set_ragdoll`, `toggle_ragdoll`, `is_in_ragdoll`, `request_recovery`, `snap_to_standing`, `apply_ragdoll_velocity_boost`, `is_ragdoll_at_rest`, `initialize_spawn_pose_from_skeleton` | Root facade → Ragdoll |
|
||||
| `walk_to`, `is_walking`, `arrived`, `begin_terrain_fall`, `snap_to_terrain`, `set_terrain_following_enabled` | Root facade → Locomotion |
|
||||
| `set_collision_bodies_enabled`, `apply_collision_reaction` | Root facade → Locomotion |
|
||||
| `action_queue`, queue mutation/query methods, reactive methods, `start_queue`, `stop_queue`, `is_queue_running`, `speak` | Root facade → ActionRunner |
|
||||
| `action_started`, `action_finished`, `queue_finished`, `queue_changed`, `speech_finished` | Child signal forwarded by root |
|
||||
| `facing_profile`, four bend exports, `rest_timeout`, `auto_recover`, `walk_speed` | Remain root exports; values synchronize to the owning component during `_ready()` and in setters afterward. |
|
||||
| `source_stk_path` | Root |
|
||||
|
||||
`action_queue` must be a delegating property because tests, scene loading, and older callers may
|
||||
read or assign it directly:
|
||||
|
||||
```gdscript
|
||||
var action_queue: Array[Dictionary]:
|
||||
get:
|
||||
return _action_runner.action_queue if _action_runner != null else []
|
||||
set(value):
|
||||
if _action_runner != null:
|
||||
_action_runner.action_queue = value
|
||||
else:
|
||||
_pending_action_queue = value
|
||||
```
|
||||
|
||||
Direct assignment keeps today's behavior and does not emit `queue_changed`; mutation methods do.
|
||||
Pre-`_ready()` export/property writes are stored on the facade and pushed to components after
|
||||
configuration, matching the existing pre-ready setter guard.
|
||||
|
||||
Private root fields are not compatibility API. Existing tests that inspect `_ragdoll_bodies`,
|
||||
`_terrain_motion`, `_run_queue`, or other private internals must move to the owning component.
|
||||
Do not keep duplicate mutable private state on the root merely to satisfy tests.
|
||||
|
||||
## 7. Physics order and state transitions
|
||||
|
||||
The root retains a single `_physics_process()` scheduler:
|
||||
|
||||
```text
|
||||
Ragdoll.tick_pre_movement
|
||||
├── cache root momentum
|
||||
└── update ragdoll rest detection
|
||||
Locomotion.tick_movement
|
||||
├── clear one-frame pelvis correction
|
||||
├── update walk/navigation
|
||||
├── update terrain/air/landing
|
||||
└── settle walk markers
|
||||
ActionRunner.tick
|
||||
├── update speech clocks
|
||||
└── update action state machine
|
||||
StickmanRig core
|
||||
└── pin mirrored head rotation
|
||||
Locomotion.tick_post_skeleton
|
||||
├── apply impact reactions
|
||||
└── sync animated collision proxies
|
||||
```
|
||||
|
||||
Ragdoll transition flow:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> ANIMATED
|
||||
ANIMATED --> RAGDOLL: root.set_ragdoll(true)\nlocomotion cleanup → build physics → request state
|
||||
RECOVERING --> RAGDOLL: root.set_ragdoll(true)\ncancel tween → rebuild physics
|
||||
RAGDOLL --> RECOVERING: request_recovery()\ncapture → destroy physics → reanchor → snap
|
||||
RECOVERING --> ANIMATED: recovery tween finished
|
||||
RAGDOLL --> ANIMATED: snap_to_standing()
|
||||
RECOVERING --> ANIMATED: snap_to_standing()
|
||||
```
|
||||
|
||||
The facade rejects illegal transition requests before delegating. A component signal can request
|
||||
a state commit, but `_set_state(next_state)` on the facade is the only function that mutates the
|
||||
root `state` and emits `state_changed`.
|
||||
|
||||
## 8. Practical data flows
|
||||
|
||||
### 8.1 Authored queue action
|
||||
|
||||
```gdscript
|
||||
rig.queue_action({"type": "walk_to", "target": Vector2(640, 320), "speed": 300.0})
|
||||
rig.queue_action({"type": "speak", "text": "Made it!", "duration": 2.0})
|
||||
rig.start_queue()
|
||||
```
|
||||
|
||||
```text
|
||||
StickmanRig.queue_action
|
||||
→ ActionRunner.action_queue
|
||||
→ root forwards queue_changed
|
||||
ActionRunner begins walk_to
|
||||
→ Locomotion.walk_to
|
||||
→ Locomotion arrived(target)
|
||||
→ root forwards arrived(target)
|
||||
→ ActionRunner observes consume_walk_completed() and emits action_finished
|
||||
```
|
||||
|
||||
The queue dictionary shape and deep-copy behavior remain unchanged.
|
||||
|
||||
### 8.2 Ragdoll recovery landing handoff
|
||||
|
||||
```text
|
||||
Ragdoll captures torso world center + body pose
|
||||
→ Locomotion.probe_ground_y(x, top, bottom, ragdoll_rids)
|
||||
→ Ragdoll computes feet_world
|
||||
→ Locomotion.reanchor_for_recovery(feet_world, FOOT_OFFSET)
|
||||
moves Master and returns mirror-aware local_shift
|
||||
→ Ragdoll rebases captured positions by local_shift
|
||||
→ Ragdoll snap-solves skeleton and requests RECOVERING
|
||||
```
|
||||
|
||||
The existing capture payload stays internal and unchanged:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"torso": {"pos": Vector2(...), "rot": 0.42, "half": 195.0},
|
||||
"head": {"pos": Vector2(...), "rot": -0.10, "half": 100.0},
|
||||
# eight limb entries...
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Relocation policy
|
||||
|
||||
Implementation moves the scene once with its UID intact, then updates live references in the
|
||||
same change:
|
||||
|
||||
- `scripts/stickman_factory.gd`
|
||||
- `scripts/physics_test_harness.gd`
|
||||
- `tests/test_phase6a_airborne_landing.gd`
|
||||
- any additional literal discovered by `rg --fixed-strings 'res://master_rig.tscn'`
|
||||
- active comments in `stickman_rig.gd`, `stk_rig_adapter.gd`, `test_harness.gd`, and
|
||||
`create_animations.gd`
|
||||
- implementation-facing comments in the changed scripts
|
||||
|
||||
After the user verifies the implementation in Godot, the documentation phase updates active
|
||||
project docs (`README.md`, `AGENTS.md`, `RIGGING.md`, and the source plan). Historical specs
|
||||
should not receive a blind search/replace because many statements describe the path that existed
|
||||
when that phase shipped. Add a short relocation note where a historical file is still used as
|
||||
live guidance; do not rewrite historical line references or decisions.
|
||||
|
||||
Acceptance requires:
|
||||
|
||||
```powershell
|
||||
rg --fixed-strings 'res://master_rig.tscn' scripts scenes tests project.godot
|
||||
```
|
||||
|
||||
to return no live code/scene/test matches. Human-readable historical mentions may remain when
|
||||
clearly historical.
|
||||
|
||||
## 10. Migration sequence
|
||||
|
||||
1. **Characterize first.** Run the current focused rig suites and record baselines before moving
|
||||
code. Add no behavior changes to the extraction patch.
|
||||
2. **Add component nodes/scripts without relocating the scene.** Wire configuration, root signal
|
||||
forwarding, and the central scheduler. The scene still loads from the old path during this
|
||||
step.
|
||||
3. **Extract action/speech orchestration.** It has the smallest physics surface. Keep all root
|
||||
queue methods as delegates and migrate private runner tests.
|
||||
4. **Extract locomotion plus animated collision.** Move navigation, terrain/air/landing, proxy,
|
||||
and flinch state together so only one component writes the animated root/markers.
|
||||
5. **Extract ragdoll/recovery.** Connect the narrow ground-query/reanchor API and retain the exact
|
||||
instant-handoff ordering.
|
||||
6. **Slim and verify the facade.** Remove duplicate mutable state/constants, leaving aliases only
|
||||
where compatibility requires them. Verify every root public method and signal.
|
||||
7. **Relocate the scene last.** Move to `scenes/stickman_rig.tscn`, preserve the UID, update every
|
||||
live preload/path/comment, force one headless editor rescan for new `class_name` scripts, then
|
||||
run static checks and all focused suites.
|
||||
8. **Stop for editor verification.** Per `spec-pipeline`, do not update README, AGENTS, RIGGING,
|
||||
the source plan, or historical documentation until the user confirms the feature works in
|
||||
Godot. Documentation cleanup is the manually triggered Phase 4.
|
||||
|
||||
## 11. Verification plan
|
||||
|
||||
### Static and scene-load checks
|
||||
|
||||
```powershell
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --editor --path "C:\Godot4\stickman" --quit
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman"
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_locomotion.gd" --path "C:\Godot4\stickman"
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_ragdoll.gd" --path "C:\Godot4\stickman"
|
||||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_action_runner.gd" --path "C:\Godot4\stickman"
|
||||
```
|
||||
|
||||
Add `tests/test_phase8_rig_modularization.gd` covering:
|
||||
|
||||
1. relocated scene loads by new path and retains UID/node paths;
|
||||
2. all three typed component children resolve;
|
||||
3. root public methods delegate and root signals emit once;
|
||||
4. missing optional/component dependency warns instead of crashing;
|
||||
5. queue/speech execution works while no ragdoll bodies exist;
|
||||
6. locomotion walking/terrain ticks work while no ragdoll bodies exist;
|
||||
7. ragdoll enter/recover/snap keeps state order and ten-body/nine-joint cardinality;
|
||||
8. root exports synchronize before and after `_ready()`;
|
||||
9. direct `action_queue` assignment retains compatibility semantics; and
|
||||
10. repeated spawn/free leaves no world-level `RagdollBodyContainer` orphan.
|
||||
|
||||
Run the focused regressions most coupled to the extraction:
|
||||
|
||||
- `tests/test_phase3c_walk_recovery.gd`
|
||||
- `tests/test_phase4_final_bugs.gd`
|
||||
- `tests/test_phase4b_walk.gd`
|
||||
- `tests/test_phase4b_terrain.gd`
|
||||
- `tests/test_phase45_collision_bodies.gd`
|
||||
- `tests/test_phase6a_airborne_landing.gd`
|
||||
- `tests/test_phase5_file_features.gd`
|
||||
- `tests/test_cli_scene.gd`
|
||||
|
||||
Tests should inspect `$RigComponents/Locomotion`, `$RigComponents/Ragdoll`, or
|
||||
`$RigComponents/ActionRunner` for subsystem internals instead of requiring stale private mirrors
|
||||
on `StickmanRig`.
|
||||
|
||||
Manual Godot verification after automated checks:
|
||||
|
||||
- factory/harness loads an `.stk` and all body geometry follows the same bones;
|
||||
- LEFT/RIGHT/FORWARD, head mirror pin, bend menus, and z-order remain unchanged;
|
||||
- queue `walk_to → speak → wait → ragdoll → recover` completes with one signal per event;
|
||||
- walking over ramps, ledge fall, hard landing, prop collision/flinch, and queue landing barrier;
|
||||
- ragdoll knock-up, automatic/manual recovery, recovery interruption, and snap-to-standing;
|
||||
- Sandbox EDIT/DIRECT/PLAY round trip and scene save/load provenance/queue persistence.
|
||||
|
||||
## 12. Risks and guardrails
|
||||
|
||||
| Risk | Guardrail |
|
||||
| --- | --- |
|
||||
| Godot child processing order changes behavior | Components have no physics callbacks; root calls ordered tick methods. |
|
||||
| Walk and terrain both write the root | They live in one locomotion component and retain the current internal order. |
|
||||
| State duplicated between root and ragdoll | Root is sole `RigState` authority; component requests transitions. |
|
||||
| Signals double-fire through forwarding | Child emits subsystem signal; root forwarding is the only consumer-facing emission. Add cardinality tests. |
|
||||
| Recovery reaches through locomotion internals | Only `probe_ground_y()` and `reanchor_for_recovery()` cross that boundary. |
|
||||
| Adapter runs before component `_ready()` | Components do not cache geometry until root `_ready()`; adapter node paths remain unchanged. |
|
||||
| Scene move breaks preload before scripts update | Relocation and all live path updates land atomically; UID is asserted. |
|
||||
| New `class_name` cache is stale in CLI | Run one `--headless --editor --quit` rescan before `--check-only`. |
|
||||
| Private-test migration masks regressions | Add behavior assertions through root API/signals plus component-local focused assertions. |
|
||||
| Broad doc replacement falsifies old specs | Update active docs; annotate historical specs instead of blind replacement. |
|
||||
|
||||
## 13. Decisions requiring approval
|
||||
|
||||
1. **Approve movement ownership change:** `StickmanLocomotion` owns navigation and `walk_to()`;
|
||||
`StickmanActionRunner` only orchestrates it. This intentionally differs from the source plan.
|
||||
2. **Approve animated collision ownership:** collision proxies, airborne sweeps, and impact
|
||||
reactions move with locomotion, avoiding a fourth component in this phase.
|
||||
3. **Approve compatibility scope:** preserve all root public APIs/signals/exports, including direct
|
||||
`action_queue`; migrate tests off private root fields rather than keeping duplicate private
|
||||
mirrors.
|
||||
4. **Approve documentation policy:** after editor verification, update active documentation and
|
||||
preserve historical specs with relocation notes instead of blanket rewriting their historical
|
||||
references.
|
||||
|
||||
Implementation must not begin until these decisions are approved.
|
||||
@@ -0,0 +1,554 @@
|
||||
# Phase 9 — Prop Editor (Vector Prop Authoring Tool)
|
||||
|
||||
Status: **approved** (2026-09-18; all seven decisions and the five open choices resolved per user approval).
|
||||
|
||||
Source plan: `plans/PHASE_9_PROP_EDITOR.md` (four phases: theme/tokens → vector geometry & vertex editing → physics inspector → thumbnail caching).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and proposed implementation decisions
|
||||
|
||||
This phase adds a **standalone Prop Editor** — the authoring counterpart to the Stickman
|
||||
Editor — for creating, editing, and saving vector prop assets (`.prop` files). It mirrors the
|
||||
existing `stickman_editor.gd` / `body_part_panel.gd` conventions (menu bar, save/load/clear,
|
||||
JSON serialization, canvas with zoom/pan/vertex-edit/gizmos, status bar) but adapts the canvas
|
||||
shape model to prop geometry (polygon / circle / line primitives, per-shape fill + outline +
|
||||
width, material presets) and adds the missing transform gizmos (translate / rotate / scale).
|
||||
|
||||
The seven design decisions below are **approved** (2026-09-18). Each is stated with its
|
||||
rationale and its approved resolution.
|
||||
|
||||
### Decision 1 — Standalone editor scene (recommended)
|
||||
|
||||
**Recommendation:** Add a new standalone `scenes/prop_editor.tscn` + `scripts/prop_editor.gd`
|
||||
(`class_name PropEditor extends Control`), launched via **F6**, **not** the configured
|
||||
`run/main_scene`.
|
||||
|
||||
**Rationale:**
|
||||
- Matches the established pattern: the stickman editor is a standalone `Control` scene; the
|
||||
sandbox stage is a separate scene/tool. The prop editor is an *authoring* tool, not a
|
||||
sandbox concern.
|
||||
- Keeps `sandbox_stage.gd` (already a large controller owning EDIT/DIRECT/PLAY, asset selector,
|
||||
rule builder, terrain, director) decoupled from authoring UI.
|
||||
- Nothing in the plan (Tasks 1.1–4.2) references sandbox placement; `.prop` files only need to
|
||||
round-trip into `PropUtils` *payloads* so placement can come later (see Decision 2).
|
||||
|
||||
**Node layout** (minimal `.tscn` shell, `%UniqueName` markers, UI built in code — mirrors
|
||||
`scenes/stickman_editor.tscn` and `scenes/asset_selector.tscn`):
|
||||
|
||||
```text
|
||||
PropEditor (Control) [script = scripts/prop_editor.gd]
|
||||
├── %MenuBar (MenuBar) File / Edit / View
|
||||
├── %TopBar (HBoxContainer) y = 32..64
|
||||
│ ├── NameLabel (Label "Prop Name:")
|
||||
│ └── %PropNameEdit (LineEdit, placeholder "Unnamed Prop")
|
||||
├── %MainLayout (HBoxContainer) y = 68..-28
|
||||
│ ├── %CanvasPanel (PanelContainer, stretch 2) styled dark bg (StyleBoxFlat)
|
||||
│ │ └── %PropCanvas (Control) [script = scripts/prop_canvas.gd]
|
||||
│ └── %InspectorColumn (VBoxContainer, stretch 1, ~300 px)
|
||||
│ └── %InspectorList (VBoxContainer) controls built in code (Task 1.3 / 3.x)
|
||||
├── %StatusBar (HBoxContainer) bottom 28 px
|
||||
│ ├── %CursorCoords (Label) "X: ### Y: ###"
|
||||
│ └── %SaveStatus (Label) material preset summary / dirty flag
|
||||
├── %SaveDialog (FileDialog, filters "*.prop ; Prop Files")
|
||||
├── %LoadDialog (FileDialog, filters "*.prop ; Prop Files")
|
||||
├── %ClearConfirmDialog (ConfirmationDialog)
|
||||
└── %ErrorDialog (AcceptDialog)
|
||||
```
|
||||
|
||||
### Decision 2 — `.prop` file format: versioned JSON, multi-shape, PropUtils-compatible
|
||||
|
||||
**Recommendation:** A versioned JSON asset at `res://props/*.prop` (new `props/` directory),
|
||||
version `"1.0"`, storing a **multi-shape stack** for full editor fidelity **plus** a
|
||||
**pure flattening converter** so any saved prop can later be placed via the existing
|
||||
`PropUtils.spawn_prop(container, pos, payload, material_preset)`.
|
||||
|
||||
**Rationale:**
|
||||
- The plan (Tasks 2.1, 2.4) is unambiguous: props are built from a **shape stack** with
|
||||
z-order ("Bring Forward"/"Send Back"), mirror, and clipboard — exactly like `BodyPartPanel`.
|
||||
- `PropBlock`/`PropUtils` are single-shape (`type` + `points|radius` + colors). So the file
|
||||
stores the editor's full `shapes[]` and a **deterministic static converter**
|
||||
`PropFile.flatten_to_payload(shapes)` produces the single payload the runtime already consumes.
|
||||
- Backward/forward compatible: the file is versioned and unknown/missing keys fall back
|
||||
gracefully (AGENTS.md rule: never change the meaning of an existing key).
|
||||
|
||||
The exact schema and the flattening rule are in Section 3.
|
||||
|
||||
### Decision 3 — `prop_theme.json` schema + fallback hierarchy
|
||||
|
||||
**Recommendation:** A committed `res://prop_theme.json` (top-level `res://`, matching the
|
||||
existing `res://sandbox_theme.json`), loaded once at editor startup by a new
|
||||
`scripts/prop_theme.gd` (`class_name PropTheme extends RefCounted`) modeled on
|
||||
`SandboxTheme`. Fallback hierarchy: **custom file → `PropTheme.DEFAULT_CONFIG` → hardcoded
|
||||
constants** (see Section 3 diagram).
|
||||
|
||||
**Rationale:**
|
||||
- `SandboxTheme` already implements the exact loader mechanics this needs (deep-merge,
|
||||
`value()`/`color()`/`number()` accessors, warn-once, clamp, invalid-input fallback). A
|
||||
parallel `PropTheme` keeps the two theme surfaces independently versioned and avoids
|
||||
entangling the sandbox theme with prop-editor tokens.
|
||||
- Physics values are deliberately **not** themeable (Decision 5) — only visual tokens and
|
||||
primitive defaults come from the theme.
|
||||
|
||||
### Decision 4 — Dedicated `PropCanvas` (recommended) vs reusing `BodyPartPanel`
|
||||
|
||||
**Recommendation:** A new `scripts/prop_canvas.gd` (`class_name PropCanvas extends Control`)
|
||||
that mirrors `BodyPartPanel`'s zoom/pan/vertex-edit/z-order behavior but adds prop-specific
|
||||
needs.
|
||||
|
||||
**Rationale:**
|
||||
- `BodyPartPanel` is a `PanelContainer` with an embedded title label + single-`Color` picker
|
||||
and a stickman shape schema (`shape_type` ∈ `"line"|"rectangle"|"circle"`, one `color` hex
|
||||
per shape). Props need **per-shape `fill_color` + `outline_color` + `outline_width`**, a
|
||||
`"polygon"` primitive, and **translate/rotate/scale gizmos** (Task 2.5) that `BodyPartPanel`
|
||||
does not implement (it only drags vertices/shape position).
|
||||
- `BodyPartPanel` delegates copy/paste to the editor; the prop canvas owns a local clipboard
|
||||
(Task 2.7). Reusing it would require invasive branching.
|
||||
|
||||
Full public API in Section 3.
|
||||
|
||||
### Decision 5 — Reuse `PropBlock` static preset tables as the single source of truth
|
||||
|
||||
**Recommendation:** `PropBlock.mass_for()`, `PropBlock.physics_material()`, and
|
||||
`PropBlock.tint_for()` remain the single source of truth for Task 1.2 / 3.1. `prop_theme.json`
|
||||
provides only **visual overrides / fallbacks** (material tint colors, outline width, primitive
|
||||
dimensions) and **never** duplicates mass/friction/bounce.
|
||||
|
||||
**Rationale:**
|
||||
- Avoids a second copy of preset physics that could drift from the runtime `PropBlock`.
|
||||
- Keeps "what does a WOOD prop weigh/feel like" in one place; the theme is presentation-only.
|
||||
|
||||
### Decision 6 — Reuse `PropThumbnail` + `ThumbnailCache`; mtime-keyed custom-prop PNGs
|
||||
|
||||
**Recommendation:** Reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail`) and
|
||||
`scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) for Tasks 4.1/4.2. The editor calls
|
||||
`PropThumbnail.render(payload, material_preset)` then `ThumbnailCache.save_png(...)` on every
|
||||
save. Add **one small extension** to `ThumbnailCache`: mtime-keyed paths for custom `.prop`
|
||||
files (`prop_file_key(path)` / `prop_file_png(path)`), mirroring the existing
|
||||
`stickman_key()`/`stickman_png()`, so re-edited custom props invalidate their cached PNG.
|
||||
|
||||
**Rationale:**
|
||||
- `PropThumbnail` already renders the exact payload + preset a saved prop produces, into a
|
||||
200×200 transparent texture. No second renderer is needed.
|
||||
- Built-in template thumbnails stay keyed by `id_v<PROP_VERSION>` (`prop_png(id)`); custom
|
||||
`.prop` files are keyed by basename + mtime (they can be edited and re-saved, so version
|
||||
alone is insufficient).
|
||||
|
||||
Save-side flow in Section 3.
|
||||
|
||||
### Decision 7 — Headless tests for all non-drag behavior; manual verification for gizmo drags
|
||||
|
||||
**Recommendation:** Four headless suites under `tests/` (schema round-trip, theme fallback,
|
||||
canvas math incl. sanitize/clipboard/mirror/z-order, thumbnail cache keying). Interactive
|
||||
vertex/gizmo dragging (Tasks 2.2, 2.5) is manual verification only, because pointer-drag
|
||||
geometry cannot be asserted headlessly and matches existing practice (`test_phase3b_library.gd`
|
||||
etc.).
|
||||
|
||||
Details in Section 4 and the per-suite coverage in Section 2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Targets and ownership
|
||||
|
||||
| Target | Kind | Change |
|
||||
| --- | --- | --- |
|
||||
| `scenes/prop_editor.tscn` | **new** | Minimal shell: `%MenuBar`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%PropCanvas`/`%InspectorColumn`/`%InspectorList`, `%StatusBar`/`%CursorCoords`/`%SaveStatus`, `%SaveDialog`, `%LoadDialog`, `%ClearConfirmDialog`, `%ErrorDialog`. |
|
||||
| `scripts/prop_editor.gd` (`PropEditor`) | **new** | Main controller: menu bar, save/load/clear, JSON (de)serialization, inspector build + signal fan-out, material-preset application, thumbnail save. |
|
||||
| `scripts/prop_canvas.gd` (`PropCanvas`) | **new** | Multi-shape canvas: zoom/pan, vertex drag, insert/remove vertex, z-order, mirror, clipboard, translate/rotate/scale gizmos, grid/snap, sanitize-on-commit, cursor hit-testing. |
|
||||
| `scripts/prop_theme.gd` (`PropTheme`) | **new** | Theme loader (deep-merge, validated accessors, warn-once), mirrors `SandboxTheme`. |
|
||||
| `scripts/prop_file.gd` (`PropFile`) | **new** | Static `.prop` schema: `to_file_dict`/`from_file_dict`, `save`/`load`, preset string⇄enum, `sanitize_shape`, `flatten_to_payload`. |
|
||||
| `res://prop_theme.json` | **new** | Committed theme file (schema `"1.0"`); editable presentation tokens. |
|
||||
| `scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) | **modified** | Add `prop_file_key(path)` / `prop_file_png(path)` (mtime-keyed custom-prop PNGs). No change to existing `prop_png`/`stickman_*`. |
|
||||
| `scripts/prop_block.gd` (`PropBlock`) | **unchanged** | `mass_for`/`physics_material`/`tint_for`, `ShapeType`, `MaterialPreset`, color/width constants reused as-is. |
|
||||
| `scripts/prop_utils.gd` (`PropUtils`) | **unchanged** | `spawn_prop`/`_apply_shape_payload`/`create_*` consumed by `PropFile.flatten_to_payload`. |
|
||||
| `tests/test_prop_editor_file.gd` | **new** | `.prop` schema: `from_file_dict`/`to_file_dict` round-trip, version rejection, missing-key defaults, preset string⇄enum, `flatten_to_payload` (polygon/circle/line, empty, z-order preference), `sanitize_shape`. |
|
||||
| `tests/test_prop_editor_theme.gd` | **new** | `PropTheme` fallback hierarchy (file → `DEFAULT_CONFIG` → hardcoded), invalid color/number fallback, physics-not-themeable assertion. |
|
||||
| `tests/test_prop_editor_canvas.gd` | **new** | `PropCanvas` math: insert/remove vertex + vertex-flags, mirror X/Y about bbox center, `bring_forward`/`send_back`, clipboard deep-copy + `CLIPBOARD_PASTE_OFFSET`, sanitize round-trip via `TerrainUtils.sanitize_points`, scale/rotate transform math. |
|
||||
| `tests/test_prop_editor_thumb.gd` | **new** | `ThumbnailCache.prop_file_key`/`prop_file_png` formatting, `prop_png` unchanged, `save_png`→`load_png` round-trip, missing-file null. |
|
||||
| `docs/tech_debt_and_optimizations.md` | **modified** | Log the multi-shape→single-`PropBlock` flattening trade-off and the mtime-keyed custom-prop thumbnail cache note. |
|
||||
|
||||
**Layout/theme note:** the inspector column width, canvas default size, and dialog sizes are
|
||||
open choices (Section 5) — recorded as recommended defaults in Section 3, not baked into the
|
||||
shell.
|
||||
|
||||
---
|
||||
|
||||
## 3. Static contracts
|
||||
|
||||
### 3.1 `.prop` file schema (version `"1.0"`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "My Crate",
|
||||
"material_preset": "wood",
|
||||
"shapes": [
|
||||
{
|
||||
"shape_type": "polygon",
|
||||
"points": [
|
||||
{ "x": -24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": 24.0 },
|
||||
{ "x": -24.0, "y": 24.0 }
|
||||
],
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [0, 0, 0, 0]
|
||||
},
|
||||
{
|
||||
"shape_type": "circle",
|
||||
"radius": 24.0,
|
||||
"fill_color": "#E63333",
|
||||
"outline_color": "#590D0D",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": "2026-09-18T10:00:00",
|
||||
"modified_at": "2026-09-18T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field contract:
|
||||
|
||||
| Key | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `version` | string | `"1.0"`. Unknown → load error (see `SUPPORTED_VERSIONS`). |
|
||||
| `prop_name` | string | Display name; empty falls back to filename basename. |
|
||||
| `material_preset` | string | `"none"\|"wood"\|"rubber"\|"cardboard"\|"metal"`; missing/invalid → `"none"`. |
|
||||
| `shapes` | array of objects | Z-order = array order (index 0 = back, last = front). |
|
||||
| `shapes[i].shape_type` | string | `"polygon"\|"circle"\|"line"`. Missing → `"polygon"`. |
|
||||
| `shapes[i].points` | array of `{x,y}` | Polygon/line vertex list. Missing → `[]`. |
|
||||
| `shapes[i].radius` | float | Circle only; default `24.0`. |
|
||||
| `shapes[i].fill_color` / `outline_color` | `#RRGGBB` / `#RRGGBBAA` string | Defaults from material tint (Section 3.3). |
|
||||
| `shapes[i].outline_width` | float | Default `PropBlock.DEFAULT_OUTLINE_WIDTH` (2.0). |
|
||||
| `shapes[i].closed` | bool | Polygon/circle `true`; line `false`. |
|
||||
| `shapes[i].vertex_flags` | array of int | `0` = original, `1` = user-created (mirrors stickman editor). Length == `points.size()` (circles: empty). |
|
||||
| `metadata` | object | Write-only provenance; ignored on load. |
|
||||
|
||||
### 3.2 `prop_theme.json` schema (version `"1.0"`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"outline_widths": { "default": 2.0, "min": 0.5, "max": 32.0 },
|
||||
"material_tints": {
|
||||
"wood": { "fill": "#8C6133", "outline": "#40290F" },
|
||||
"rubber": { "fill": "#E63333", "outline": "#590D0D" },
|
||||
"cardboard": { "fill": "#D9B873", "outline": "#66521D" },
|
||||
"metal": { "fill": "#808C99", "outline": "#262E38" },
|
||||
"none": { "fill": "#8C6133", "outline": "#26201E" }
|
||||
},
|
||||
"primitive_defaults": {
|
||||
"box": { "size_x": 48.0, "size_y": 48.0 },
|
||||
"ball": { "radius": 24.0 },
|
||||
"plank": { "length": 160.0, "thickness": 16.0 },
|
||||
"triangle": { "base": 56.0, "height": 48.0 },
|
||||
"line": { "length": 120.0 },
|
||||
"circle": { "radius": 40.0, "segments": 12 }
|
||||
},
|
||||
"canvas": {
|
||||
"grid_size": 15,
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fallback hierarchy** (only visual + defaults are themed; physics is fixed):
|
||||
|
||||
```text
|
||||
material fill/outline tint:
|
||||
prop_theme.json material_tints.<preset>.fill/outline (valid color string)
|
||||
│ missing / invalid
|
||||
▼
|
||||
PropTheme.DEFAULT_CONFIG material_tints.<preset>.fill/outline
|
||||
│
|
||||
▼
|
||||
PropBlock.tint_for(preset) ← hardcoded single source of truth (never overridden by theme)
|
||||
|
||||
outline width default:
|
||||
prop_theme.json outline_widths.default → DEFAULT_CONFIG → PropBlock.DEFAULT_OUTLINE_WIDTH (2.0)
|
||||
|
||||
primitive default dimension (box/ball/plank/triangle/line/circle):
|
||||
prop_theme.json primitive_defaults.<name>.<key> → DEFAULT_CONFIG → PropUtils.create_* defaults
|
||||
|
||||
physics (mass / friction / bounce):
|
||||
PropBlock.mass_for(preset) / PropBlock.physics_material(preset) — NEVER themeable
|
||||
```
|
||||
|
||||
### 3.3 Shared constants and enums
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd — editor shape type superset (superset of PropBlock.ShapeType)
|
||||
enum ShapeType { POLYGON, CIRCLE, LINE }
|
||||
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } # mirrors stickman editor
|
||||
|
||||
# gizmo / canvas constants (mirror BodyPartPanel where applicable)
|
||||
const HANDLE_RADIUS := 3.0
|
||||
const HANDLE_RADIUS_SELECTION := 8.0
|
||||
const HANDLE_HOLLOW_SIZE := 6.0
|
||||
const EDGE_HIT_THRESHOLD := 12.0
|
||||
const ROTATE_RING_RADIUS := 40.0 # selection rotate ring (screen-constant / zoom)
|
||||
const SCALE_HANDLE_DISTANCE := 8.0 # corner scale-handle reach
|
||||
const ROTATION_SNAP_DEG := 15.0 # Ctrl-snap (deg_to_rad(15.0))
|
||||
const CLIPBOARD_PASTE_OFFSET := Vector2(16, 16)
|
||||
const DEFAULT_CIRCLE_SEGMENTS := 12 # editor circle primitives
|
||||
const MIN_ZOOM := 0.3
|
||||
const MAX_ZOOM := 3.0
|
||||
const ZOOM_STEP := 1.10
|
||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
||||
|
||||
# prop_block.gd — existing authority, reused unchanged
|
||||
# enum ShapeType { POLYGON, CIRCLE }
|
||||
# enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||
# const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
|
||||
# const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
|
||||
# const DEFAULT_OUTLINE_WIDTH := 2.0
|
||||
# static func physics_material(preset) -> PhysicsMaterial
|
||||
# static func mass_for(preset) -> float
|
||||
# static func tint_for(preset) -> Color
|
||||
```
|
||||
|
||||
### 3.4 `PropFile` — static schema + converters
|
||||
|
||||
```gdscript
|
||||
class_name PropFile
|
||||
extends RefCounted
|
||||
|
||||
const FILE_VERSION := "1.0"
|
||||
const FILE_EXT := ".prop"
|
||||
const DEFAULT_DIR := "res://props"
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
|
||||
# index == PropBlock.MaterialPreset
|
||||
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
|
||||
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
|
||||
|
||||
static func preset_to_string(preset: int) -> String # clamp into range; NONE fallback
|
||||
static func preset_from_string(s: String) -> int # unknown -> MaterialPreset.NONE
|
||||
static func shape_type_to_payload_type(s: String) -> int # "circle"->CIRCLE else POLYGON
|
||||
static func sanitize_shape(shape: Dictionary) -> Dictionary # sanitize polygon/line points via TerrainUtils
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary
|
||||
static func from_file_dict(data: Variant) -> Dictionary
|
||||
# returns {} on error; else { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }
|
||||
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary
|
||||
# returns a single PropUtils.spawn_prop payload {type, points|radius, fill_color, outline_color, outline_width}
|
||||
static func save(path: String, data: Dictionary) -> bool # FileAccess WRITE + JSON.stringify
|
||||
static func load(path: String) -> Dictionary # {} on read/parse failure (push_warning)
|
||||
```
|
||||
|
||||
**`flatten_to_payload` rule (deterministic, testable):**
|
||||
|
||||
1. Prefer the **frontmost closed polygon** — scan `shapes` from last to first, pick the first
|
||||
with `shape_type == "polygon"` and `closed == true`. Its sanitized `points` become
|
||||
`{ "type": POLYGON, "points": ... }`.
|
||||
2. Else prefer a `circle` shape (last→first) → `{ "type": CIRCLE, "radius": ... }`.
|
||||
3. Else fall back to the **first shape's** points as a polygon (even if a line).
|
||||
4. `fill_color`/`outline_color`/`outline_width` come from the chosen shape; a missing color
|
||||
falls back to the material tint (`PropBlock.tint_for`) / `DEFAULT_OUTLINE_COLOR`.
|
||||
5. Empty `shapes` → `{}` (caller skips spawn, with one warning).
|
||||
|
||||
Lines are editor-only (open, unfilled) and never become collision geometry (see Section 5).
|
||||
|
||||
### 3.5 `PropTheme` — validated loader (mirrors `SandboxTheme`)
|
||||
|
||||
```gdscript
|
||||
class_name PropTheme
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_VERSION := "1.0"
|
||||
const DEFAULT_PATH := "res://prop_theme.json"
|
||||
const DEFAULT_CONFIG: Dictionary = { /* mirrors committed prop_theme.json — see 3.2 */ }
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool
|
||||
func value(path: String, fallback: Variant = null) -> Variant # dotted-path access
|
||||
func color(path: String, fallback: Color) -> Color # #RRGGBB[AA] validation + warn-once
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
|
||||
func material_tint(preset: int, channel: String) -> Color # channel "fill"|"outline"
|
||||
func outline_width_default() -> float
|
||||
func primitive_default(primitive: String, key: String) -> float # e.g. ("box","size_x")
|
||||
func grid_size() -> int
|
||||
```
|
||||
|
||||
`material_tint(preset, channel)` implements the hierarchy in 3.2: theme file →
|
||||
`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)` (and `.darkened(0.55)` for the outline
|
||||
channel's final fallback, matching `PropBlock._apply_material_preset()`).
|
||||
|
||||
### 3.6 `PropCanvas` — multi-shape canvas + gizmos
|
||||
|
||||
```gdscript
|
||||
class_name PropCanvas
|
||||
extends Control
|
||||
|
||||
signal shape_changed(shapes: Array[Dictionary])
|
||||
signal shape_selected(idx: int)
|
||||
|
||||
# public API
|
||||
func set_shape_data(shapes: Array[Dictionary]) -> void # import; resets zoom/pan/selection
|
||||
func get_shape_data() -> Array[Dictionary] # export (plain dicts, {x,y} points)
|
||||
func clear() -> void # no shape_changed emit (matches clear_shape)
|
||||
func select(idx: int) -> void
|
||||
func deselect() -> void
|
||||
func get_selected_index() -> int
|
||||
func set_grid_size(size: int) -> void
|
||||
func set_snap_enabled(enabled: bool) -> void
|
||||
func reset_view() -> void
|
||||
func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary
|
||||
# primitive ∈ "box"|"ball"|"plank"|"triangle"|"line"|"circle"; appends; returns the new shape
|
||||
func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void # flags it USER_CREATED
|
||||
func remove_vertex(shape_idx: int, vertex_idx: int) -> void # <2 vertices -> drop shape
|
||||
func bring_forward(idx: int) -> void
|
||||
func send_back(idx: int) -> void
|
||||
func mirror_x(idx: int) -> void # invert X about bbox center
|
||||
func mirror_y(idx: int) -> void # invert Y about bbox center
|
||||
func copy_shape(idx: int) -> Dictionary # deep duplicate (duplicate(true))
|
||||
func paste_shape(source: Dictionary, target_pos: Vector2) -> void # offset by CLIPBOARD_PASTE_OFFSET
|
||||
func translate_selected(delta: Vector2) -> void
|
||||
func rotate_selected(angle_rad: float) -> void # Ctrl snap applied by the drag handler
|
||||
func scale_selected(factor: Vector2) -> void # Ctrl → uniform (locked aspect)
|
||||
func is_cursor_over_drawing(global_pos: Vector2) -> bool
|
||||
func global_to_world(global_pos: Vector2) -> Vector2
|
||||
func set_has_clipboard(state: bool) -> void # enables "Paste" in context menu
|
||||
```
|
||||
|
||||
Internal shape dictionary (editor-space, mirrors `BodyPartPanel`):
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"shape_type": "polygon", # "polygon"|"circle"|"line"
|
||||
"points": PackedVector2Array(), # polygon/line only
|
||||
"radius": 24.0, # circle only
|
||||
"fill_color": "#8C6133", # hex string
|
||||
"outline_color": "#40290F", # hex string
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": PackedInt32Array(), # 0=original, 1=user-created
|
||||
}
|
||||
```
|
||||
|
||||
Input handling order (mirrors `BodyPartPanel._on_drawing_area_gui_input`): magnify/pinch zoom
|
||||
→ pan gesture → middle-mouse pan → wheel zoom (cursor-centered) → **gizmo hit-test** (rotate
|
||||
ring / scale corner on the selected shape) → right-click context menu (vertex = "Remove
|
||||
Point"; outline = "Create Point"; else "Copy/Paste/Delete/Mirror X/Mirror Y/Bring
|
||||
Forward/Send Back"; empty canvas = "Add Box/Ball/Plank/Triangle/Line/Circle") → left-click
|
||||
vertex drag / shape drag.
|
||||
|
||||
### 3.7 `PropEditor` — controller
|
||||
|
||||
```gdscript
|
||||
class_name PropEditor
|
||||
extends Control
|
||||
|
||||
const PROP_FILE := preload("res://scripts/prop_file.gd")
|
||||
const PROP_THEME := preload("res://scripts/prop_theme.gd")
|
||||
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||||
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
||||
|
||||
@onready var _menu_bar: MenuBar = %MenuBar
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||
@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog
|
||||
@onready var _error_dialog: AcceptDialog = %ErrorDialog
|
||||
@onready var _cursor_coords: Label = %CursorCoords
|
||||
@onready var _save_status: Label = %SaveStatus
|
||||
|
||||
func _ready() -> void
|
||||
func _setup_menu_bar() -> void # File (Save/Load/Clear), Edit (Configure Grid/Snap), View (Reset Views)
|
||||
func _build_inspector() -> void # builds %InspectorList children (Task 1.3 / 3.1 / 3.2)
|
||||
func _on_save_file_selected(path: String) -> void
|
||||
func _on_load_file_selected(path: String) -> void
|
||||
func _on_clear_confirmed() -> void
|
||||
func _build_json_data() -> Dictionary # PropFile.to_file_dict(...)
|
||||
func _apply_json_data(json: Variant) -> String # "" on success, else error text
|
||||
func _refresh_inspector_from_selection() -> void
|
||||
func _apply_material_preset(preset: int) -> void
|
||||
func _save_prop_thumbnail() -> void # async (await render)
|
||||
```
|
||||
|
||||
**Save-side thumbnail flow** (Task 4.2):
|
||||
|
||||
```text
|
||||
Save "res://props/mycrate.prop"
|
||||
1. PropFile.save(path, data) # write .prop
|
||||
2. payload = PropFile.flatten_to_payload(shapes)
|
||||
3. tex = await _prop_thumbnail.render(payload, material_preset)
|
||||
4. if tex != null:
|
||||
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(path)) # 200x200, user://thumbnails/props/mycrate_<mtime>.png
|
||||
5. (headless / blank) tex == null → skip silently (placeholder on load side)
|
||||
```
|
||||
|
||||
`ThumbnailCache` extension:
|
||||
|
||||
```gdscript
|
||||
func prop_file_key(path: String) -> String: # "<basename>_<mtime>"
|
||||
func prop_file_png(path: String) -> String: # PROP_DIR + "/" + key + ".png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Task-to-implementation mapping
|
||||
|
||||
| Plan task | Fulfilled by | Acceptance criteria |
|
||||
| --- | --- | --- |
|
||||
| **1.1** Prop design-token schema | `scripts/prop_theme.gd` + `res://prop_theme.json` | File parses and loads on `PropEditor._ready()`; missing keys fall back (file → `DEFAULT_CONFIG` → hardcoded) with no runtime error. |
|
||||
| **1.2** Material preset visual utility | `PropEditor._apply_material_preset()` reading `PropBlock.mass_for`/`physics_material`/`tint_for` (via `PropTheme.material_tint` override) | Selecting a preset populates default mass/friction/bounce (from `PropBlock`) and fill/outline tint. |
|
||||
| **1.3** Inspector theme | `PropEditor._build_inspector()` applying a local `Theme` (from `PropTheme`) to `OptionButton`/`SpinBox`/`ColorPicker`/`Button` | Controls match styling; no global GUI theme or sandbox scene mutation. |
|
||||
| **2.1** Multi-shape creation & primitive controls | `PropCanvas.add_primitive()` + inspector mode-specific params (radius for circle; vertex list for polygon) | Adding a shape appends to the stack; switching primitive modes exposes `radius` vs `points` params. |
|
||||
| **2.2** Vertex drag & reshape | `PropCanvas` vertex hit-test + `InputEventMouseMotion` drag (mirrors `BodyPartPanel._handle_left_press`/`_on_drawing_area_gui_input`) | Dragging updates geometry live; release commits (emits `shape_changed`). |
|
||||
| **2.3** Contextual vertex insert/delete | `PropCanvas.insert_vertex()` / `remove_vertex()` via right-click "Create Point"/"Remove Point" | Insert adds a `USER_CREATED` vertex at edge midpoint; delete closes the segment; <2 vertices drops the shape. |
|
||||
| **2.4** Selection, context menu, z-order | `PropCanvas` hit-testing + context menu + `bring_forward()`/`send_back()` | Left-click highlights bbox/handles; Delete removes; z-order alters draw order. |
|
||||
| **2.5** Translate/rotate/scale gizmos | `PropCanvas` gizmo hit-test + drag handlers (`translate_selected`/`rotate_selected`/`scale_selected`); Ctrl = 15° rotate snap + uniform scale | Drag translates all vertices; Ctrl snaps rotation to 15°; Ctrl locks aspect ratio on scale. |
|
||||
| **2.6** Mirror engine | `PropCanvas.mirror_x()`/`mirror_y()` (invert about bbox center) | Mirror X/Y invert coordinates about the shape center. |
|
||||
| **2.7** Clipboard | `PropCanvas.copy_shape()` (deep dup) + `paste_shape()` (offset `CLIPBOARD_PASTE_OFFSET`) + `set_has_clipboard()` | Copy stores a deep duplicate; paste inserts an offset duplicate; "Paste" disabled when empty. |
|
||||
| **2.8** Live vector sync | `PropCanvas` → `shape_changed` → `PropEditor` inspector writes fill/outline/width back into the shape dict + `queue_redraw` | Modifying fill/outline/width reflects instantly on canvas. |
|
||||
| **2.9** Sanitization | `PropCanvas` commit path + `PropFile.sanitize_shape()` calling `TerrainUtils.sanitize_points()` | Committed polygons are grid-snapped, redundant/collinear-stripped, clockwise. |
|
||||
| **3.1** Material preset picker | Inspector `OptionButton` → `PropEditor._apply_material_preset()` | Changing preset updates displayed mass/friction/restitution (from `PropBlock`). |
|
||||
| **3.2** Color swatches | Inspector `ColorPickerButton`s for fill + outline (override preset colors) | Custom fill/outline override preset tint and update stroke/fill live. |
|
||||
| **4.1** Thumbnail generator | reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail.render`) | Renders off-screen 200×200 transparent PNG without affecting main editor. |
|
||||
| **4.2** Disk cache pipeline | `PropEditor._save_prop_thumbnail()` + `ThumbnailCache.prop_file_png`/`save_png`/`load_png` | Save exports `<basename>_<mtime>.png`; load reads cached PNG if source unchanged (mtime key). |
|
||||
|
||||
---
|
||||
|
||||
## 5. Design choices — user-approved resolutions
|
||||
|
||||
1. **Multi-shape → single `PropBlock` flattening.** **Approved rule:** frontmost closed polygon
|
||||
→ else circle → else first shape (spec 3.4, `flatten_to_payload`).
|
||||
2. **Line primitives and physics.** **Approved:** lines are editor-only (drawn, never flattened
|
||||
into collision geometry).
|
||||
3. **Sandbox asset-selector integration.** **Approved direction:** `.prop` files will later
|
||||
appear in the sandbox "Prop" selector; **not** wired this phase.
|
||||
4. **Menu-bar entry in the main editor.** **Approved:** F6-standalone only this phase (no
|
||||
"Prop Editor…" item in the stickman editor menu bar).
|
||||
5. **Default canvas / window size.** **Approved:** canvas 1200×800 with a ~300 px inspector
|
||||
column; window resizable, no size persistence.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architectural alternatives and deferred work
|
||||
|
||||
- **`Control`-based editor vs an in-world Node2D tool:** an in-world (`Node2D`) prop editor
|
||||
would share the sandbox camera/zoom, but props are authored assets, not stage objects; the
|
||||
`Control` canvas reuses the proven `BodyPartPanel` zoom/pan/input model and keeps the file
|
||||
format independent of stage transforms.
|
||||
- **Reuse `BodyPartPanel` directly:** rejected (Decision 4) — the prop shape model (fill +
|
||||
outline + width, polygon primitives, transform gizmos) diverges enough that a dedicated
|
||||
`PropCanvas` is cheaper than branching.
|
||||
- **Physics theme tokens:** deliberately excluded from `prop_theme.json` (Decision 5). If
|
||||
per-project physics tuning is ever wanted, a separate, explicit physics-override file is
|
||||
preferable to silently changing runtime feel via a visual theme.
|
||||
- **Multi-shape `PropBlock`:** a future phase could compose multiple shapes into one prop
|
||||
(multiple `Polygon2D`/`Line2D` children + merged collision). Until then the single-shape
|
||||
`PropBlock` + flattening converter is the contract; recorded in the tech-debt log.
|
||||
|
||||
---
|
||||
|
||||
*Validation will follow the existing pattern: per-script `--check-only`, then each
|
||||
`tests/test_prop_editor_*.gd` headless suite printing PASS/FAIL and `quit(0/1)`. Interactive
|
||||
vertex/gizmo drags are manual F6 verification.*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user