Author SHA1 Message Date
ryan 043f114083 Implement interactive prop handling for StickmanRig
- Added PropTrajectory class for deterministic ballistic calculations.
- Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw).
- Implemented grip resolution and attachment logic for props.
- Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown).
- Defined specifications for prop interaction lifecycle and event rules.
- Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
2026-09-23 21:26:13 -04:00
ryan 0a7f97cec7 Implement Phase 10a - Prop Scaling
- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode.
- Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features.
- Implemented document transactions for scaling, including normalization to a 96 px maximum dimension.
- Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props.
- Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions.
- Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
2026-09-22 22:23:31 -04:00
ryan 97f3730413 feat: Integrate custom prop library and sandbox functionality
- Added support for custom props in the sandbox, including loading, saving, and rendering.
- Implemented `PropLibrary` for managing custom prop entries and scanning for `.prop` files.
- Introduced `PropVisuals` for shared visual and collision representation of custom props.
- Enhanced `StageSpawner` to handle spawning of custom props with layered visuals and collision hulls.
- Created `MissingPropPlaceholder` to manage missing custom props during scene reloads.
- Updated thumbnail generation to support custom props with unique keys based on file paths.
- Added tests for custom prop lifecycle, authored visual values, and missing prop handling.
- Refactored existing code to accommodate new prop functionalities and ensure backward compatibility.
2026-09-21 23:13:35 -04:00
ryan 00abacc7b2 Add regression tests for prop attachment, centering, and editor UX
- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation.
- Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold.
- Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness.
- Added corresponding .uid files for each test.
- Created sync_sources.py script to manage markdown file synchronization with the notebook.
- Added .gitignore for virtual environment files.
2026-09-21 16:48:24 -04:00
ryan e3689c86c8 refactor: Update architect and spec-pipeline documentation for clarity and detail; add new Prop Editor plan 2026-09-20 00:26:11 -04:00
ryan ed9a2d0d18 Merge remote-tracking branch 'origin/feature/prop-editor' 2026-09-19 23:57:12 -04:00
ryan b66ded5f60 Add headless regression tests for Prop Editor functionality
- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality.
- Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management.
- Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens.
- Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
2026-09-19 21:26:03 -04:00
94 changed files with 15814 additions and 82 deletions
+39 -15
View File
@@ -1,28 +1,52 @@
name = "architect"
description = "Defines system requirements, evaluates architectural choices, and suggests technical improvements 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 before designing.
2. **Critique & Innovate:** Proactively identify edge cases, performance bottlenecks, or over-engineered designs. Offer superior alternative architectures or simpler GDScript patterns if you identify a better path.
3. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
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`.
## Workflow
---
1. **Scope, Critique & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. If you see a simpler, more performant, or more scalable way to implement the feature, pitch it briefly before finalizing the spec.
2. **Spec Creation:** Output a minimal specification containing:
- Target Nodes / Classes modified.
- Signal interfaces & static type contracts.
## TWO-PHASE WORKFLOW
### 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.
- **Visual Diagrams & Schematics:** Include ASCII diagrams or Mermaid charts illustrating node hierarchies, state transitions, or signal flow interactions.
- **Concrete Examples & Data Flows:** Provide code/data examples demonstrating practical usage, sample dictionary shapes, or step-by-step state transformations.
- **Architectural Suggestions & Alternatives:** (Optional/Required if applicable) Highlight alternative approaches, potential pitfalls, or future-proofing ideas that improve the feature.
3. **Handoff:** Save to `specs/[feature-name].md` and display a summary including key diagrams and practical examples, then 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.
"""
+13 -8
View File
@@ -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."_
+284 -2
View File
@@ -14,6 +14,8 @@
- `<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()`.
@@ -37,6 +39,11 @@ props, live stickman rigs, action queues, and events. The editor remains the con
`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/`
@@ -147,6 +154,240 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
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`
@@ -668,6 +909,11 @@ PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_wi
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 +
@@ -744,7 +990,10 @@ KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ra
`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
@@ -1016,7 +1265,10 @@ page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect
`_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
@@ -1066,6 +1318,13 @@ page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect
(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
@@ -1093,6 +1352,29 @@ rotation, scale}`.
- 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
+91 -2
View File
@@ -883,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:
@@ -911,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 |
@@ -929,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. |
@@ -1057,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).
+639
View File
@@ -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.
+26
View File
@@ -38,6 +38,23 @@ This document tracks known technical debt, optimization opportunities, and minor
| 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) |
---
@@ -81,7 +98,16 @@ This document tracks known technical debt, optimization opportunities, and minor
| 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_
+255
View File
@@ -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": ""
}
}
+130
View File
@@ -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.
+118
View File
@@ -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.
+251
View File
@@ -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.
+151
View File
@@ -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.
+167
View File
@@ -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.
+48
View File
@@ -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"
}
}
}
+166
View File
@@ -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
View File
@@ -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"
}
+177
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+181
View File
@@ -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
+4
View File
@@ -4,6 +4,7 @@
[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
@@ -335,6 +336,9 @@ 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]
+92 -5
View File
@@ -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)
@@ -158,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())
@@ -183,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":
@@ -222,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
@@ -246,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()
@@ -264,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)
+13
View File
@@ -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
+3 -2
View File
@@ -54,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()
+21
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://iwogmwpek8cc
+174
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bhlmhegchc1cu
+101
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cj2xh11d1wk7j
+548
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bthsk33yw5rr8
+109 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://lophq30fv72p
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://v8gaqlxoei3a
+339
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://rbk8by20x7g4
+66 -1
View File
@@ -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,
}
+242
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://qxwnxe3u1o33
+17
View File
@@ -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": ""}
+1
View File
@@ -0,0 +1 @@
uid://ddw3n0jjlduk
+89
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://7cehuu3v8tig
+192 -27
View File
@@ -39,6 +39,8 @@ const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -100,7 +102,10 @@ const TRIG_ACTION_FINISHED := 1
const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
const TRIG_PROP_PICKED_UP := 5
const TRIG_PROP_DROPPED := 6
const TRIG_PROP_THROWN := 7
const TRIG_BACK := 8
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
@@ -391,6 +396,11 @@ var _action_editor_rig: StickmanRig = null
var _action_editor_actor_id: int = -1
var _action_editor_restore_queue: bool = false
var _pending_rule_action_flat: Dictionary = {}
## Active only while the Action Editor is capturing a throw landing point.
## Kept separately from generic position capture so walk/rule capture remains
## unchanged and preview/commit share one explicit contract.
var _throw_capture_prop_id: int = -1
var _throw_capture_flight_time: float = 0.9
## Rule editor came from the rule panel (restore it on close).
var _rule_editor_from_panel: bool = false
@@ -673,6 +683,7 @@ func _enter_edit_shared() -> void:
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.release_held_prop_for_lifecycle(&"play_exit")
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
@@ -686,6 +697,8 @@ func _enter_edit_shared() -> void:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
if node is PropBlock:
(node as PropBlock).end_play_instance_scale()
_restore_authored_state()
# The freeze/teleport can take a physics frame or two to settle in the engine,
# so re-assert the authored transform on the next few physics frames.
@@ -766,6 +779,8 @@ func _enter_play_mode() -> void:
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
if node is PropBlock:
(node as PropBlock).begin_play_instance_scale()
(node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable():
@@ -809,6 +824,8 @@ func _restore_authored_state() -> void:
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if entry.has("scale"):
node.scale = entry["scale"]
if node is STICKMAN_RIG and entry.has("facing_profile"):
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
if node is RigidBody2D:
@@ -864,6 +881,9 @@ func _finalize_placed_node(node: Node2D) -> void:
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
var interaction: StickmanPropInteraction = rig.get_prop_interaction()
if interaction != null:
interaction.prop_transition.connect(_on_rig_prop_transition)
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
@@ -1227,6 +1247,16 @@ func delete_selected() -> void:
var nav_changed := false
for node: Node2D in selected:
if is_instance_valid(node):
# Disarm before either endpoint leaves World so a held body is never
# left frozen or referenced by a surviving IK marker.
if node is StickmanRig:
(node as StickmanRig).release_held_prop_for_lifecycle(&"holder_deleted")
elif node is PropBlock:
for child: Node in _world.get_children():
if child is StickmanRig:
var interaction: StickmanPropInteraction = (child as StickmanRig).get_prop_interaction()
if interaction != null and interaction.get_held_prop() == node:
interaction.release_for_lifecycle(&"prop_deleted")
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
@@ -1267,18 +1297,31 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
# Queue actions may point at a prop that appears later in World. Build the
# complete runtime→file map before encoding any individual rig queue.
for node: Node in _world.get_children():
if not node is Node2D: continue
var map_node: Node2D = node as Node2D
var map_type: String = ""
if map_node is TerrainBlock: map_type = "terrain"
elif map_node is PropBlock or map_node.get_script() == MISSING_PROP_PLACEHOLDER: map_type = "prop"
elif map_node is STICKMAN_RIG or map_node.get_script() == MISSING_STICKMAN_PLACEHOLDER: map_type = "stickman"
elif map_node is TriggerArea: map_type = "area"
if map_type.is_empty(): continue
counters[map_type] = int(counters[map_type]) + 1
runtime_to_file[map_node.get_instance_id()] = "%s_%d" % [map_type, int(counters[map_type])]
counters = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for node: Node in _world.get_children():
if not node is Node2D: continue
var n := node as Node2D
var type := ""
if n is TerrainBlock: type = "terrain"
elif n is PropBlock: type = "prop"
elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop"
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
elif n is TriggerArea: type = "area"
else: continue
counters[type] = int(counters[type]) + 1
var file_id := "%s_%d" % [type, int(counters[type])]
runtime_to_file[n.get_instance_id()] = file_id
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
var pos: Vector2 = authored.get("position", n.global_position)
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
@@ -1286,10 +1329,14 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
if type == "terrain":
entry["asset_id"] = (n as TerrainBlock).spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop.scale)
var prop_scale: Vector2 = authored.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
elif type == "area":
var area := n as TriggerArea
@@ -1302,7 +1349,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var rig := n as StickmanRig
entry["stk_path"] = rig.source_stk_path
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue(), runtime_to_file)
entry["action_queue"] = queue_result["actions"]
objects.append(entry)
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
@@ -1344,7 +1391,7 @@ func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
var was_suppressed := _suppress_dirty; _suppress_dirty = true
_reset_scene_content()
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []; var pending_queues: Array[Dictionary] = []
for raw: Variant in data.get("objects", []):
if not raw is Dictionary: warnings.append("Malformed object"); continue
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
@@ -1355,7 +1402,11 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
var node: Node2D = null
match type:
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
"prop": node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
"prop":
node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
prop_placeholder.configure(entry); prop_placeholder.position = pos; _world.add_child(prop_placeholder); node = prop_placeholder
"area": node = _spawner.spawn_area(pos)
"stickman":
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
@@ -1401,14 +1452,16 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
warnings.append(warning)
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
pending_queues.append({"rig": node, "entries": raw_queue})
else:
warnings.append("Invalid stickman action queue")
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
for pending: Dictionary in pending_queues:
var queued_rig: StickmanRig = pending.get("rig") as StickmanRig
if queued_rig == null or not is_instance_valid(queued_rig): continue
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(pending.get("entries", []) as Array, file_to_runtime, unavailable)
for warning: String in queue.get("warnings", []): warnings.append(warning)
for action: Dictionary in queue.get("actions", []): queued_rig.queue_action(action)
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
@@ -2015,6 +2068,9 @@ func _build_ui() -> void:
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_item("🖐️ Picks up a prop", TRIG_PROP_PICKED_UP)
_trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED)
_trigger_popup.add_item("🎯 Throws a prop", TRIG_PROP_THROWN)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
@@ -2194,6 +2250,7 @@ func _build_ui() -> void:
_action_editor.committed.connect(_on_action_editor_committed)
_action_editor.cancelled.connect(_on_action_editor_cancelled)
_action_editor.target_requested.connect(_on_action_editor_target_requested)
_action_editor.prop_requested.connect(_on_action_editor_prop_requested)
ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
@@ -2408,7 +2465,7 @@ func _open_selector(kind: String) -> void:
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
else:
entries = PROP_LIBRARY.get_entries()
entries = PROP_LIBRARY.scan()
if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0])
return
@@ -2449,18 +2506,29 @@ func _on_selector_cancelled() -> void:
func _on_browse_requested() -> void:
if _browse_dialog != null:
if _selector_kind == "prop":
_browse_dialog.title = "Open .prop"
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
_browse_dialog.title = "Open .stk"
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void:
var entry := _stickman_library.make_entry(path)
var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
if entry.is_empty():
_show_toast("Could not load stickman: " + path)
_show_toast("Could not load %s: %s" % [_selector_kind, path])
return
_on_asset_selected(entry)
func _on_refresh_requested() -> void:
if _selector_kind == "prop":
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
_selector.set_entries(prop_entries)
_load_or_enqueue_thumbnails("prop", prop_entries)
return
var entries: Array[Dictionary] = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
@@ -2485,7 +2553,7 @@ func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> vo
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else:
var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id)
var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
@@ -2509,11 +2577,15 @@ func _drain_thumbnail_queue() -> void:
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if bool(entry.get("is_custom", false)):
tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
_thumbnail_busy = false
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex)
@@ -2841,11 +2913,17 @@ func _update_action_overlay() -> void:
var awaiting := _is_awaiting_click()
if _placement_overlay != null:
if awaiting:
var origin := _action_origin()
var throw_capture: bool = _is_throw_capture_active()
var origin: Vector2 = _throw_release_point() if throw_capture else _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
if _snap_enabled and (_capture_kind == CaptureKind.POSITION or _rule_step == RuleStep.ACTION_POSITION):
target = _snap_to_grid(target)
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
if throw_capture:
var solution: Dictionary = _solve_throw_capture(target)
var arc: PackedVector2Array = _sample_throw_arc(origin, solution) if bool(solution.get("ok", false)) else PackedVector2Array([origin, target])
_placement_overlay.set_throw_trajectory(origin, target, arc, bool(solution.get("ok", false)))
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
@@ -2969,7 +3047,7 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
@@ -2978,6 +3056,12 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP:
_rule_hint = "Click the prop to watch being picked up (Esc or RMB to cancel)"
TRIG_PROP_DROPPED:
_rule_hint = "Click the prop to watch being dropped (Esc or RMB to cancel)"
TRIG_PROP_THROWN:
_rule_hint = "Click the prop to watch being thrown (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
@@ -3018,6 +3102,12 @@ func _trigger_type_string(id: int) -> String:
return "entered_area"
TRIG_COLLIDED:
return "collided"
TRIG_PROP_PICKED_UP:
return "prop_picked_up"
TRIG_PROP_DROPPED:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
_:
return ""
@@ -3088,7 +3178,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
else:
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status()
"collided":
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
@@ -3096,7 +3186,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_rule_hint = "Click the prop to use for this trigger (Esc or RMB to cancel)"
_refresh_status()
@@ -3266,6 +3356,11 @@ func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
func _on_rig_prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName) -> void:
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
return
_handle_event({"type": String(action_type), "source": rig, "target": prop, "position": prop.global_position, "action": {"type": String(action_type), "reason": String(reason)}})
func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
@@ -3380,6 +3475,8 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
return _ids_match(source_id, source) and _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
"prop_picked_up", "prop_dropped", "prop_thrown":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
@@ -3594,6 +3691,8 @@ func _end_capture() -> void:
_capture_hint = ""
_capture_callback = Callable()
_capture_cancel = Callable()
_throw_capture_prop_id = -1
_throw_capture_flight_time = 0.9
_apply_cursor()
_refresh_status()
@@ -3629,6 +3728,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
if value == null:
_refresh_status()
return
if _capture_kind == CaptureKind.POSITION and _is_throw_capture_active() and not bool(_solve_throw_capture(value as Vector2).get("ok", false)):
_show_toast("That throw is too far for this flight time.")
_refresh_status()
return
var cb := _capture_callback
_end_capture()
if cb.is_valid():
@@ -4031,18 +4134,80 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
var is_throw: bool = _action_editor != null and _action_editor.get_action_type() == "throw_prop"
if is_throw:
_throw_capture_prop_id = _action_editor.get_target_prop_id()
_throw_capture_flight_time = _action_editor.get_flight_time()
_begin_capture(CaptureKind.POSITION, "🎯 Click where the prop should land (Esc or RMB to cancel)" if is_throw else "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_throw_target if is_throw else _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_walk_target(value)
func _cb_editor_throw_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(value)
func _on_action_editor_prop_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.PROP, "🧱 Click the prop to use (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
func _cb_editor_prop_target(value: PropBlock) -> void:
if _action_editor != null:
_action_editor.set_prop_target(value, _prop_action_target_label(value))
func _prop_action_target_label(prop: PropBlock) -> String:
if prop == null or not is_instance_valid(prop):
return ""
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
var label: String = String(entry.get("name", ""))
if label.is_empty():
label = prop.spawn_id.get_file().get_basename() if prop.spawn_id.contains(".prop") else prop.spawn_id.capitalize()
if label.is_empty():
label = "Prop"
var ordinal: int = 0
for node: Node in _world.get_children():
if node is PropBlock and (node as PropBlock).spawn_id == prop.spawn_id:
ordinal += 1
if node == prop:
break
return "%s #%d" % [label, maxi(1, ordinal)]
func _cb_editor_walk_target_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _is_throw_capture_active() -> bool:
return _capture_kind == CaptureKind.POSITION and _throw_capture_prop_id >= 0
func _throw_release_point() -> Vector2:
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
var marker: Marker2D = _action_editor_rig._get_ik_marker("Right_Hand")
if marker != null and is_instance_valid(marker): return marker.global_position
return _action_editor_rig.global_position
return Vector2.ZERO
func _solve_throw_capture(target: Vector2) -> Dictionary:
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
if prop == null or not is_instance_valid(prop): return {"ok": false, "reason": "missing_prop"}
return PROP_TRAJECTORY.solve_throw_velocity(_throw_release_point(), target, _throw_capture_flight_time, StickmanPropInteraction.gravity_for(prop))
func _sample_throw_arc(origin: Vector2, solution: Dictionary) -> PackedVector2Array:
var points := PackedVector2Array()
if not bool(solution.get("ok", false)): return points
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
if prop == null or not is_instance_valid(prop): return points
var velocity: Vector2 = solution.get("velocity", Vector2.ZERO)
var duration: float = float(solution.get("flight_time", 0.0))
var gravity: Vector2 = StickmanPropInteraction.gravity_for(prop)
const SEGMENTS: int = 24
for index: int in range(SEGMENTS + 1):
var time: float = duration * float(index) / float(SEGMENTS)
points.append(origin + velocity * time + gravity * 0.5 * time * time)
return points
func _restore_queue_panel() -> void:
if _action_editor_restore_queue and _queue_panel != null:
+12 -3
View File
@@ -364,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)
@@ -373,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
@@ -385,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
View File
@@ -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
+33 -1
View File
@@ -20,6 +20,8 @@ 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
@@ -63,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()
@@ -77,8 +93,24 @@ 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:
+30 -8
View File
@@ -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)
+6
View File
@@ -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)
+19 -2
View File
@@ -165,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
@@ -202,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)
+19 -2
View File
@@ -9,7 +9,7 @@ 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 }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP }
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
@@ -22,6 +22,7 @@ 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
@@ -34,9 +35,12 @@ var _overlay_speech: Dictionary = {}
var _speech_bubble: Node2D = null
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
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:
@@ -62,6 +66,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
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:
@@ -241,6 +248,10 @@ func _begin_action(action: Dictionary) -> void:
"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()
@@ -257,6 +268,8 @@ func _finish_action() -> void:
func _cancel_current_primary() -> void:
if _prop_interaction != null:
_prop_interaction.cancel()
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
_primary_speech.clear()
@@ -267,6 +280,10 @@ func _cancel_current_primary() -> void:
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()
+258
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://p3owck52oug2
+18 -1
View File
@@ -225,6 +225,7 @@ var source_stk_path: String = ""
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _action_runner_component: StickmanActionRunner = null
var _prop_interaction_component: StickmanPropInteraction = null
# ---------------------------------------------------------------------------
# Lifecycle
@@ -234,12 +235,15 @@ func _ready() -> void:
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
_prop_interaction_component = get_node_or_null(NodePath("RigComponents/PropInteraction")) as StickmanPropInteraction
if _locomotion_component == null:
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
if _ragdoll_component == null:
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
if _action_runner_component == null:
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
if _prop_interaction_component == null:
push_warning("StickmanRig: missing RigComponents/PropInteraction; prop interaction boundary unavailable.")
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
@@ -248,7 +252,7 @@ func _ready() -> void:
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
_ragdoll_component.state_transition_requested.connect(_set_state)
if _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_component)
_action_runner_component.configure(self, _locomotion_component, _prop_interaction_component)
_action_runner_component.action_queue = _pending_action_queue
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
@@ -257,6 +261,8 @@ func _ready() -> void:
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
if _prop_interaction_component != null:
_prop_interaction_component.configure(self, _locomotion_component)
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
@@ -313,6 +319,8 @@ func _physics_process(delta: float) -> void:
_pin_mirrored_head_rotation()
if _locomotion_component != null:
_locomotion_component.tick_post_skeleton(delta, int(state))
if _prop_interaction_component != null:
_prop_interaction_component.update_held_attachment(delta)
func get_locomotion() -> StickmanLocomotion:
@@ -450,6 +458,8 @@ func _set_state(next_state: int) -> void:
func set_ragdoll(enabled: bool) -> void:
if enabled:
if _prop_interaction_component != null:
_prop_interaction_component.release_for_ragdoll()
if _locomotion_component != null:
_locomotion_component.reset_for_ragdoll_handoff()
match state:
@@ -805,6 +815,13 @@ func stop_queue() -> void:
if _action_runner_component != null:
_action_runner_component.stop_queue()
func get_prop_interaction() -> StickmanPropInteraction:
return _prop_interaction_component
func release_held_prop_for_lifecycle(reason: StringName = &"lifecycle") -> void:
if _prop_interaction_component != null:
_prop_interaction_component.release_for_lifecycle(reason)
func is_queue_running() -> bool:
return _action_runner_component != null and _action_runner_component.is_queue_running()
+15
View File
@@ -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:
+10
View File
@@ -39,6 +39,16 @@ 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
+3
View File
@@ -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" },
}
+151
View File
@@ -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.
+499
View File
@@ -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.
+259
View File
@@ -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.
+554
View File
@@ -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.*
+447
View File
@@ -0,0 +1,447 @@
# Phase 9 Prop Editor — Round 2 (Usability & Interaction Fixes)
Status: **approved** (2026-09-18; all open choices resolved — two deviations: "Create Point"
stays **outline-only**, and `prop_theme.json` keeps `version` **"1.0"**).
Source: five user feedback points against the shipped Round 1 Prop Editor
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `prop_theme.json`).
---
## 0. Feedback summary (verbatim intent)
1. **Rotation is very hard** — replace the ring-above-bbox with the stickman editor's
bottom rotation dot (small dot below the bbox bottom-center, generous hit radius,
incremental-angle drag, keep Ctrl 15° snap).
2. **Vertex node circles are too large and should stay constant screen-size** — diagnose
what is actually read as "too large" and shrink the offending gizmos; keep vertex handles
at the stickman editor's 3 px.
3. **"Create Point" along a segment does nothing** — diagnose the true root cause and give
immediate visible feedback; decide whether it is available anywhere on the shape.
4. **Mode separation: vertex editing vs transform editing** — design an explicit interaction
model (evaluate ≥2 models, recommend one), with a canvas-owned mode + public API.
5. **Kid-friendly UI** — bigger text, friendlier plain-language copy, larger hit targets,
higher-contrast widget styling, via a `ui` section in `prop_theme.json` (local-theme-only).
---
## 1. Diagnoses
### 1.1 Point 3 — "Create Point" appears to do nothing (root cause + evidence)
The wiring is correct (`MENU_CREATE_POINT` → `_create_point_on_edge` → `insert_vertex`), so the
failure is perceptual, not mechanical. Evidence from `scripts/prop_canvas.gd`:
1. **Collinear insertion (primary cause).** `_create_point_on_edge` (lines 951–975) inserts the
new vertex at the **exact segment midpoint**:
```gdscript
var midpoint: Vector2 = (pts[best_edge] + pts[(best_edge + 1) % pts.size()]) * 0.5
insert_vertex(_selected_shape_idx, insert_idx, midpoint)
```
On any straight edge (box, plank, triangle side, line) the midpoint lies **on the same line**
as its two neighbours. The polygon fill and outline are therefore geometrically identical
before/after the insert — nothing changes visually. (The inserted vertex is collinear and
would also be stripped by `sanitize_shape()` on the next vertex/rotate/scale commit, since
`TerrainUtils.sanitize_points` drops collinear vertices.)
2. **The new handle is small and low-contrast.** `insert_vertex` flags the vertex
`USER_CREATED` (line 298), and `_draw_shape` renders it as a hollow rectangle of
`HANDLE_HOLLOW_SIZE / _zoom * 0.5` (≈3 px half-size) in **white** (`HANDLE_COLOR`, line
578). Over a light fill or at small zoom this is nearly invisible, so even the handle is not
noticed. (Note: the stickman editor color-matches these to the shape — see 1.2.)
3. **Discoverability.** "Create Point" only appears when `_is_point_near_outline` is true
(line 730), i.e. a right-click within `EDGE_HIT_THRESHOLD / _zoom` (12 px) of an outline
edge. Right-clicking the body of a filled shape shows Copy/Paste/Delete instead — a kid
right-clicks the *middle* of the shape and never sees the option.
**Recommendation (see §2.4 / §4.3):** insert at the **cursor's projection onto the nearest
segment** (clamped), **auto-select the shape**, **emit a `vertex_created` signal**, and flash
the new vertex with a short pulsing highlight. Auto-begin-drag is evaluated as an alternative
in §5. **User-approved resolution:** "Create Point" stays **outline-only** (the current
`EDGE_HIT_THRESHOLD / _zoom` gating is kept).
### 1.2 Point 2 — what is actually read as "too large" (evidence)
The vertex handles are **not** the problem: they already divide by `_zoom` (3 px constant,
lines 35, 576). The two things a user actually reads as "giant vertex circles" are the other
two gizmos drawn on selection (`_draw_selection_gizmos`, lines 582–595):
| Gizmo | Current draw | Effective on-screen size | Read as |
| --- | --- | --- | --- |
| Corner **scale handles** | `draw_circle(corner, SCALE_HANDLE_DISTANCE / _zoom, …)` (line 590) | **8 px radius filled circles** (16 px diameter) at the four bbox corners | four "giant vertex handles" |
| **Rotate ring** | `draw_arc(ring_center, ROTATE_RING_RADIUS / _zoom, …)` (line 595) + connector line | **40 px-radius ring** floating above the bbox | another "giant handle" |
| Vertex handles | `draw_circle(pts[j], HANDLE_RADIUS / _zoom, …)` (line 576) | 3 px — already correct | — |
Two further divergences from the stickman editor worth fixing while here:
- Vertex handles are drawn **white** (`HANDLE_COLOR`, line 574) instead of **color-matched**
to the shape (`body_part_panel.gd` lines 399–407: `handle_color := color`, yellow while
dragging). Color-matching makes 3 px handles legible and distinct from gizmos.
- The rotate ring's hit test (line 1097–1103) is a fuzzy `maxf(12/zoom, ring*0.3)` circle —
generous but at an awkward location (above the shape), which is why rotation is hard (point 1).
---
## 2. Static contracts
### 2.1 Constants — rotation dot + scale crosses (mirror `whole_stickman_preview.gd`)
Replace the ring/scale-circle constants. New values are **exactly** the stickman editor's
(`whole_stickman_preview.gd` lines 31–35):
```gdscript
# prop_canvas.gd — REMOVE:
# const ROTATE_RING_RADIUS: float = 40.0
# const SCALE_HANDLE_DISTANCE: float = 8.0
# (keep HANDLE_RADIUS / HANDLE_RADIUS_SELECTION / HANDLE_HOLLOW_SIZE / EDGE_HIT_THRESHOLD)
# prop_canvas.gd — ADD (mirror whole_stickman_preview exactly):
const ROTATION_CIRCLE_RADIUS: float = 5.0 # draw radius, divided by _zoom
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
const ROTATION_CIRCLE_OFFSET: float = 26.0 # bbox bottom-center -> dot center (world)
const SCALE_CROSS_SIZE: float = 5.0 # cross arm length, divided by _zoom
const SCALE_CROSS_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
```
**Drawing** (`_draw_selection_gizmos`):
```gdscript
var b: Rect2 = _shape_bbox(sd)
if b.size == Vector2.ZERO:
return
# Bounding box (unchanged)
draw_rect(b, GIZMO_COLOR, false, 1.5 / _zoom)
# Rotation dot BELOW the bbox bottom-center (was the ring above)
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, GIZMO_COLOR)
# Corner scale CROSSES (was filled circles)
var cross_half := SCALE_CROSS_SIZE / _zoom
for corner: Vector2 in [b.position, Vector2(b.end.x, b.position.y), b.end, Vector2(b.position.x, b.end.y)]:
draw_line(corner + Vector2(-cross_half, 0), corner + Vector2(cross_half, 0), GIZMO_COLOR, 1.5 / _zoom)
draw_line(corner + Vector2(0, -cross_half), corner + Vector2(0, cross_half), GIZMO_COLOR, 1.5 / _zoom)
```
**Rotation dot hit-test** (replaces `_hit_test_rotate_ring`):
```gdscript
func _hit_test_rotation_dot(sd: Dictionary, world_pos: Vector2) -> bool:
var b: Rect2 = _shape_bbox(sd)
if b.size == Vector2.ZERO:
return false
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
return world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom
```
**Scale cross hit-test** (replaces `_hit_test_scale_corner` threshold):
```gdscript
var threshold: float = SCALE_CROSS_HIT_RADIUS / _zoom # was SCALE_HANDLE_DISTANCE / _zoom
```
### 2.2 Rotation drag — incremental angle accumulation (point 1, hard-to-rotate fix)
The current drag is **absolute-from-start** (`_rotate_start_angle` set at press, `delta =
current_angle − start` each frame, lines 765–770, 841–843) — it jumps when the cursor crosses
±180°. Adopt the stickman editor's incremental accumulation
(`whole_stickman_preview.gd` lines 767–794):
```gdscript
# State (replace _rotate_start_angle):
var _rotate_total_delta: float = 0.0
var _rotate_prev_angle: float = 0.0
# On press (_handle_left_press, rotation branch):
_drag_mode = DragMode.ROTATE
_rotate_center = _shape_bbox(sd).get_center()
_rotate_total_delta = 0.0
_rotate_prev_angle = rad_to_deg(_rotate_center.angle_to_point(world_pos))
_gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array()))
# On motion (_handle_drag_motion, DragMode.ROTATE):
var current_angle := rad_to_deg(_rotate_center.angle_to_point(world_pos))
var frame_delta := current_angle - _rotate_prev_angle
if frame_delta > 180.0:
frame_delta -= 360.0
elif frame_delta < -180.0:
frame_delta += 360.0
_rotate_total_delta += frame_delta
_rotate_prev_angle = current_angle
var delta := deg_to_rad(_rotate_total_delta)
if ctrl_pressed:
delta = snappedf(delta, deg_to_rad(ROTATION_SNAP_DEG))
# apply delta to _gizmo_base_points about _rotate_center (unchanged math)
```
### 2.3 Mode separation — enum, state, signals, public API (point 4)
**Recommended model: (a) two explicit modes** with a kid-friendly segmented toggle. Rationale
and rejected alternatives in §2.3.1 and §5.
```gdscript
# prop_canvas.gd
enum EditMode { TRANSFORM, POINTS } # kid-facing labels: "Move & Resize" / "Edit Points"
signal mode_changed(mode: int) # emitted only when the mode actually changes
var _edit_mode: EditMode = EditMode.TRANSFORM # default on load
func set_edit_mode(mode: int) -> void:
var m: int = TRANSFORM if mode != POINTS else POINTS # clamp anything unknown to TRANSFORM
if m == _edit_mode:
return
_edit_mode = m
_drag_mode = DragMode.NONE # cancel any in-progress drag
_dragging_vertex = -1
queue_redraw()
mode_changed.emit(_edit_mode)
func get_edit_mode() -> int:
return _edit_mode
```
**Mode ↔ existing public API mapping** (the mode gates *input*, not the math APIs — tests and
programmatic callers keep working in either mode):
| API | Mode behaviour |
| --- | --- |
| `insert_vertex` / `remove_vertex` | mode-agnostic (programmatic); only the **context menu** hides "Create/Remove Point" outside POINTS mode |
| `translate_selected` / `rotate_selected` / `scale_selected` | mode-agnostic (programmatic; tests) |
| `sanitize_shape`, `set_shape_data`, `clear`, `copy_shape`, `paste_shape`, `mirror_x/y`, `bring_forward/send_back` | mode-agnostic |
| `select` / `deselect` / `get_selected_index` | mode-agnostic; do **not** change the mode |
| `set_shape_data` / `clear` | do **not** reset the mode (tool preference persists, like snap) — see §5 |
**Drawing rules per mode** (`_draw_shape` + `_draw_selection_gizmos`):
| Element | TRANSFORM | POINTS |
| --- | --- | --- |
| Bounding box (`draw_rect`) | ✔ | — (replaced by a light selection outline) |
| Rotation dot | ✔ | — |
| Scale crosses | ✔ | — |
| Vertex handles (color-matched circles / hollow rects) | — | ✔ |
| Selection outline polyline (white, `2.5 / _zoom`) | ✔ (light) | ✔ |
**Hit-test priority per mode** (`_handle_left_press`):
```text
TRANSFORM (default):
1. rotation dot -> DragMode.ROTATE
2. scale cross corner -> DragMode.SCALE
3. shape body (front→back, fill/outline) -> DragMode.TRANSLATE + select
4. empty -> deselect
POINTS:
1. vertex (selected shape first, then others front→back) -> DragMode.VERTEX
2. shape body -> select only (NO translate) # see §5 open choice
3. empty -> deselect
```
**Right-click context menu per mode** (`_handle_right_click`):
```text
POINTS:
on a vertex -> "Remove Point" (only item)
on a shape -> "Create Point" + Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back
empty canvas -> Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste if clipboard)
TRANSFORM:
on a shape -> Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back (no Create/Remove Point)
empty canvas -> Add primitives (+ Paste if clipboard)
```
### 2.3.1 Model evaluation (point 4)
- **(a) Two explicit modes + segmented toggle — RECOMMENDED.** Predictable, no keyboard, no
ambiguity between "grab a vertex" and "move the whole shape". Mode state is canvas-owned with
a public API (`set_edit_mode`/`get_edit_mode` + `mode_changed`), so the editor's toggle drives
it and the headless suite can assert it. Cost: the kid must learn two modes and switch; the
toggle lives next to the canvas (always visible) and the default (`TRANSFORM`) matches the
most common first action ("move my shape").
- **(b) Automatic context switching — rejected.** Clicking a vertex enters points mode, clicking
the body transforms. A vertex sits *on* the body, so the two hit targets overlap; accidental
vertex drags while trying to move a shape (and vice-versa) are exactly the conflict the user
reported. Harder to assert headlessly, and the "current mode" is invisible until you miss.
- **(c) Per-selection mode + inspector "Edit Points" button — rejected as primary.** Clean, but
the affordance is hidden in the inspector column and changes per selection, which is less
discoverable for 7–12 year-olds than an always-visible toggle. (Its "Edit Points" button is
kept as an optional secondary affordance — see §5.)
### 2.4 Create Point — new public API + feedback (point 3)
```gdscript
signal vertex_created(shape_idx: int, vertex_idx: int)
# Returns the new vertex index, or -1 if no segment/point could be inserted.
func create_point_on_edge(world_pos: Vector2) -> int:
# find nearest segment (existing _point_to_segment_distance loop, closed-aware)
# projection := a + ab * clampf(ap.dot(ab)/ab_len_sq, 0, 1) # cursor projected onto segment
# insert_idx := best_edge + 1 (closed wrap: pts.size())
insert_vertex(_selected_shape_idx, insert_idx, projection)
vertex_created.emit(_selected_shape_idx, insert_idx)
_flash_vertex = insert_idx
_flash_remaining = FLASH_DURATION # §2.4.1
queue_redraw()
return insert_idx
```
`_create_point_on_edge` (private) is refactored to delegate to `create_point_on_edge` (public),
so the projection + signal + flash path is directly testable.
```gdscript
const FLASH_DURATION: float = 0.6 # seconds
const FLASH_RING_RADIUS: float = 12.0 # max ring radius, divided by _zoom
var _flash_vertex: int = -1
var _flash_remaining: float = 0.0
```
**2.4.1 Flash feedback (recommended over auto-begin-drag).** `_process(delta)` decrements
`_flash_remaining`; `_draw_shape` draws a pulsing ring around the flashing vertex:
```gdscript
if j == _flash_vertex and _flash_remaining > 0.0:
var t := 1.0 - _flash_remaining / FLASH_DURATION
var ring_r := FLASH_RING_RADIUS * (0.5 + 0.5 * t) / _zoom
draw_arc(pts[j], ring_r, 0.0, TAU, 24, Color.YELLOW, 2.0 / _zoom)
```
Rationale: a flash + visible (color-matched, §2.1) hollow handle is non-modal and
unmistakeable; auto-begin-drag after a right-click menu choice can feel like the vertex "ran
away" or the tool got stuck. (Auto-drag remains an open choice — §5.)
### 2.5 Kid-friendly UI — theme additions, accessors, copy (point 5)
**Schema version: user-approved to stay `"1.0"`** (the new top-level `ui` section is additive;
no existing-key meaning changes). `PropTheme.load_from` is already version-agnostic (it
deep-merges and never rejects on `version`), so an older file without `ui` still loads with
fallbacks.
New section (committed `res://prop_theme.json` and `PropTheme.DEFAULT_CONFIG`):
```json
"ui": {
"fonts": {
"body": 16,
"header": 20,
"menu": 16,
"status": 14
},
"hit_targets": {
"button_min_height": 36,
"row_min_height": 36
},
"colors": {
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF"
}
}
```
New `PropTheme` accessors (clamped, fallback → `DEFAULT_CONFIG`):
```gdscript
func font_body() -> int: return integer("ui.fonts.body", 16, 8, 72)
func font_header() -> int: return integer("ui.fonts.header", 20, 8, 96)
func font_menu() -> int: return integer("ui.fonts.menu", 16, 8, 72)
func font_status() -> int: return integer("ui.fonts.status", 14, 8, 72)
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 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"))
```
`prop_editor.gd` `_build_control_theme()` replaces the hardcoded `14`/`16` font sizes and the
hardcoded text/border colors with these tokens; `_build_inspector()` applies `button_min_height`
to buttons and `row_min_height` to inspector rows (`custom_minimum_size.y`). Local `Theme`
only — **no global GUI mutation**.
**Copy changes (plain-language, no emojis):**
| Current | Proposed |
| --- | --- |
| "Add" (button) | "Add a Shape" |
| "Delete Shape" (inspector button) | "Erase Shape" |
| "Delete" (context menu) | "Erase" |
| "Mirror X" / "Mirror Y" (context menu) | "Flip Left-Right" / "Flip Up-Down" |
| "Create Point" / "Remove Point" | keep ("Create Point" / "Remove Point") |
| "Bring Forward" / "Send Back" | keep ("Bring Forward" / "Send Back") |
| "Selected Shape" / "Add Shape" (section headers) | keep, but rendered at `font_header` |
("Erase" vs "Delete", and whether to also relabel Mirror as "Flip", are open choices — §5.)
---
## 3. Task-to-implementation mapping & acceptance criteria
| # | Feedback point | Target | Acceptance criteria |
| --- | --- | --- | --- |
| 1 | Hard rotation | `scripts/prop_canvas.gd` | `ROTATE_RING_RADIUS` removed; `ROTATION_CIRCLE_RADIUS/HIT_RADIUS/OFFSET` = `5.0/12.0/26.0`; dot drawn below bbox bottom-center at `ROTATION_CIRCLE_RADIUS / _zoom`; hit radius `ROTATION_CIRCLE_HIT_RADIUS / _zoom`; drag uses incremental accumulation (no ±180° jump); Ctrl snaps 15°. |
| 2 | Giant circles | `scripts/prop_canvas.gd` | `SCALE_HANDLE_DISTANCE` removed; corner handles drawn as `SCALE_CROSS_SIZE / _zoom` crosses; vertex handles stay `HANDLE_RADIUS / _zoom` (3 px) and become **color-matched** (yellow while dragging), user-created = hollow rect. |
| 3 | Create Point | `scripts/prop_canvas.gd` | `create_point_on_edge(world_pos) -> int` inserts at the **cursor projection** (clamped), emits `vertex_created`, flashes the vertex 0.6 s; "Create Point" remains available **near the outline only** (`EDGE_HIT_THRESHOLD / _zoom`, unchanged — user decision); `_create_point_on_edge` delegates to it. |
| 4 | Mode separation | `scripts/prop_canvas.gd` + `scripts/prop_editor.gd` | `enum EditMode { TRANSFORM, POINTS }`; default `TRANSFORM`; `set_edit_mode`/`get_edit_mode`/`mode_changed`; drawing + hit-test + context menu obey §2.3; editor renders a segmented toggle ("Move & Resize" / "Edit Points") that drives `set_edit_mode` and syncs from `mode_changed`; shape-editor controls unaffected in both modes. |
| 5 | Kid-friendly UI | `scripts/prop_editor.gd` + `scripts/prop_theme.gd` + `prop_theme.json` | `ui` section + accessors per §2.5; font sizes/tokens drive `_build_control_theme`/`_build_inspector` (no hardcoded 14/16); min-height hit targets applied; copy table applied; local theme only. |
**Test updates — `tests/test_prop_editor_canvas.gd` (headless; public API under test):**
- **New `_test_edit_mode()`**: default `get_edit_mode() == TRANSFORM`; `set_edit_mode(POINTS)`
→ `get_edit_mode() == POINTS` and `mode_changed` fires exactly once; re-setting the same mode
fires **no** extra `mode_changed`; unknown value clamps to `TRANSFORM`; `select`/`deselect`/
`clear` leave the mode unchanged.
- **New `_test_gizmo_constants()`**: assert `ROTATION_CIRCLE_RADIUS == 5.0`,
`ROTATION_CIRCLE_HIT_RADIUS == 12.0`, `ROTATION_CIRCLE_OFFSET == 26.0`,
`SCALE_CROSS_SIZE == 5.0`, `SCALE_CROSS_HIT_RADIUS == 12.0`, `HANDLE_RADIUS == 3.0`; assert
`ROTATE_RING_RADIUS` and `SCALE_HANDLE_DISTANCE` are absent from
`PROP_CANVAS.get_script().get_script_constant_map()`.
- **New `_test_create_point_on_edge()`**: box at `(100,100)`; `create_point_on_edge(Vector2
(124, 76))` (off-midpoint, above the top edge) → returned index ≥ 0, inserted point == the
cursor's projection onto the top edge (not the midpoint `(124,76)` itself and not
`(124,100)`), `vertex_flags[idx] == USER_CREATED`, and `vertex_created` emitted with the
right `(shape_idx, vertex_idx)`.
- Existing suites unchanged (all existing APIs remain mode-agnostic).
**Manual F6 verification:** drag the bottom dot in a full circle (no ±180° flip); scale
crosses; create a point then drag it to kink a rectangle edge; toggle modes and confirm
gizmos/handles swap and body-drag is inert in POINTS mode; confirm fonts/targets.
---
## 4. Files modified
| File | Changes |
| --- | --- |
| `scripts/prop_canvas.gd` | Rotation dot + incremental drag; scale crosses; color-matched vertex handles; `EditMode` enum + `set_edit_mode`/`get_edit_mode`/`mode_changed`; mode-gated drawing/hit-test/context-menu; `create_point_on_edge` + `vertex_created` + flash state; "Create Point" anywhere on shape. |
| `scripts/prop_editor.gd` | Segmented mode toggle + `mode_changed` wiring; theme-driven fonts/colors/hit-targets (replace hardcoded 14/16); copy changes. |
| `scripts/prop_theme.gd` | `ui` section in `DEFAULT_CONFIG` + accessors (§2.5). |
| `prop_theme.json` | `version` stays `"1.0"` (user decision); add `ui` section. |
| `tests/test_prop_editor_canvas.gd` | Add `_test_edit_mode`, `_test_gizmo_constants`, `_test_create_point_on_edge`. |
| `docs/tech_debt_and_optimizations.md` | Log the explicit-mode interaction decision and the "Create Point" feedback design. |
---
## 5. Design choices — user-approved resolutions
1. **Mode names / labels.** **Approved:** `"Move & Resize"` (TRANSFORM) and `"Edit Points"` (POINTS).
2. **Default mode on load.** **Approved:** `TRANSFORM` ("Move & Resize").
3. **Mode persistence across Clear/Load.** **Approved:** mode persists (tool preference, like snap).
4. **Shape-body drag in POINTS mode.** **Approved:** select-only (no translate).
5. **Create Point feedback.** **Approved:** insert at cursor projection + auto-select + flash ring (0.6 s).
6. **Create Point availability.** **Approved (deviation):** keep **outline-only** (`EDGE_HIT_THRESHOLD / _zoom` gating unchanged).
7. **Theme schema version.** **Approved (deviation):** stay `"1.0"` (additive `ui` section only).
8. **"Erase" vs "Delete", Mirror→"Flip" relabel.** **Approved:** "Erase Shape"/"Erase", "Flip Left-Right"/"Flip Up-Down"; "Create Point"/"Remove Point"/"Bring Forward"/"Send Back" unchanged.
9. **Mode toggle placement.** **Approved:** a segmented two-button toolbar row above the canvas (always visible), synced to `mode_changed`.
---
## 6. Implementation order
1. `prop_theme.gd` + `prop_theme.json` (theme tokens + accessors) — independent of canvas work.
2. `prop_canvas.gd` — constants, rotation dot + incremental drag, scale crosses, color-matched
handles (points 1 & 2).
3. `prop_canvas.gd` — `create_point_on_edge` + `vertex_created` + flash + "anywhere on shape"
(point 3).
4. `prop_canvas.gd` — `EditMode` + gating (point 4).
5. `prop_editor.gd` — mode toggle + theme-driven fonts/targets + copy (points 4 & 5).
6. `tests/test_prop_editor_canvas.gd` — three new suites.
7. Parse check + headless run + manual F6 verification; docs/tech-debt note.
+694
View File
@@ -0,0 +1,694 @@
# Phase 9 Prop Editor — Round 3 (Reshape-Mode Menu, Window Chrome, Exit & Autosave)
Status: **approved** (2026-09-18; all nine open choices resolved as proposed).
Source: twelve user feedback points against the shipped Round 2 Prop Editor
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `scripts/prop_theme.gd`,
`prop_theme.json`, `scenes/prop_editor.tscn`). Autosave/exit reference:
`scripts/sandbox_stage.gd` + `scripts/stage_scene_io.gd`.
---
## 0. Feedback summary (verbatim intent)
1. In "Edit Points" mode, right-clicking some segments shows the add-shape menu
instead of Create Point — a segment right-click should always offer creating a point.
2. A created point sometimes appears on a different segment; it must appear where the
cursor was when created.
3. In "Edit Points" mode, the RMB menu must ONLY offer point editing: "Create Point"
(segment) / "Remove Point" (vertex). No Copy/Erase/Flip/z-order items.
4. In "Edit Points" mode, do NOT allow adding shapes (empty-canvas RMB shows nothing).
5. In "Edit Points" mode, the inspector must NOT allow adding or erasing a shape
(hide the Add-Shape section and the Erase Shape button; sync on `mode_changed`).
6. Rename modes: "Move & Resize" → **"Arrange"**, "Edit Points" → **"Reshape"**
(enum values stay TRANSFORM/POINTS — only labels/emoji change).
7. Mode toggle becomes a segmented **[ Arrange | Reshape ]** pair **centered** in the
ModeBar, with a **bigger font** and a **related emoji** on each button.
8. "View → Reset Views" becomes **"View → Reset View"** (singular).
9. File/Edit/View menu bar + items get a **bigger font** (theme-driven) plus
**kid-friendly emoji** in labels.
10. **Window title** = `[Prop name][filename.prop][*]`: name (default **"Untitled"**),
filename (basename with `.prop`, only when saved), `*` when modified.
11. **Exit dialog**: intercept window close while modified; **Discard / Save / Cancel**
with emoji; async save-before-quit.
12. **Autosave/recovery**: temp prop file on modification, debounced; startup restore
prompt; explicit clear semantics.
---
## 1. Diagnoses (verified root causes)
### 1.1 Point 2 — created point lands on a different segment
`PropCanvas._get_context_world_pos()` (`scripts/prop_canvas.gd:1102-1108`) discards any
stored click position whose world coordinates fall outside the visible `[0,0 .. size/zoom]`
rect and replaces it with the **canvas center**:
```gdscript
func _get_context_world_pos() -> Vector2:
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2(100.0, 60.0))
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100.0, 60.0)
var world_size := size / _zoom
if world_pos.x < 0.0 or world_pos.y < 0.0 or world_pos.x > world_size.x or world_pos.y > world_size.y:
return _screen_to_world(size * 0.5)
return world_pos
```
Negative world coordinates are **normal after panning** (shapes live around the world
origin). Right-clicking a segment at e.g. `(-40, 120)` stores a valid world position that is
then thrown away, so `create_point_on_edge` (called from `_on_context_menu_id_pressed`,
`MENU_CREATE_POINT`, line 993) projects the **canvas center** onto whatever segment is nearest
*it* — a different segment. **Fix:** remove the bounds fallback entirely (the stored position
is always valid world space); only guard non-finite values.
### 1.2 Point 1 — right-clicking some segments shows the Add-Shape menu
In POINTS mode, `_handle_right_click` (lines 744-813) targets a shape only through
`_is_point_in_shape` (lines 774-778). For **closed** shapes `_is_point_in_shape` is
**fill-only** (`Geometry2D.is_point_in_polygon`, lines 1168-1169); for open lines it is a
distance-to-outline test. A right-click **on a segment** that lies slightly outside the fill
(concave corners, a 12-gon circle's edge chord, just beyond a rectangle edge) therefore falls
through to the empty-canvas **Add-Shape** menu (lines 802-813). The existing "Create Point"
branch (line 786) is only reachable **after** `_is_point_in_shape` already returned true, so
it cannot rescue the case. For open lines, a click beyond `EDGE_HIT_THRESHOLD / _zoom` also
falls through. **Fix:** in POINTS mode, replace the fill-only shape gate with
**segment-proximity targeting** (see §2.1). Combined with points 3/4 (the empty-canvas Add
menu disappears in POINTS mode), the entire fall-through disappears.
---
## 2. Static contracts
### 2.1 POINTS-mode right-click menu matrix + segment-proximity targeting
New public helper (directly testable):
```gdscript
# prop_canvas.gd — returns the frontmost shape whose segment is within
# EDGE_HIT_THRESHOLD / _zoom of world_pos, else -1.
func hit_test_segment(world_pos: Vector2) -> int:
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_near_outline(i, world_pos):
return i
return -1
```
`_is_point_near_outline` already measures distance to the **segments only**
(`_distance_to_shape_pts`, lines 1183-1189), so this is the segment-proximity test the fix
needs.
Revised `_handle_right_click` right-click decision table:
| Mode | Click target | Menu shown |
| --- | --- | --- |
| POINTS | on a vertex (selected first, then others front→back) | **Remove Point** (only) |
| POINTS | on/near a **segment** (within `EDGE_HIT_THRESHOLD/zoom`, front→back) | **Create Point** (only); shape is selected first |
| POINTS | interior body of a closed shape (inside fill, *not* near any segment) | **nothing**; shape is selected (no menu pops) |
| POINTS | empty canvas | **nothing** (no Add-Shape menu) |
| TRANSFORM | on a shape body (fill/outline, front→back) | Copy / Paste / Erase / Flip L-R / Flip U-D / Bring Forward / Send Back (unchanged) |
| TRANSFORM | empty canvas | Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste) (unchanged) |
Revised `_handle_right_click` shape (POINTS branch only; TRANSFORM branch is unchanged):
```gdscript
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
# --- POINTS mode: point editing only ---
if _edit_mode == EditMode.POINTS:
# 1. Vertex (selected first, then others front->back) -> Remove Point
var context_shape := -1
var context_vertex := -1
if _selected_shape_idx >= 0:
var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos)
if hit_idx >= 0:
context_shape = _selected_shape_idx
context_vertex = hit_idx
for i: int in range(shapes.size() - 1, -1, -1):
if context_shape >= 0:
break
if i == _selected_shape_idx:
continue
var hit_idx := _hit_test_vertex(i, world_pos)
if hit_idx >= 0:
context_shape = i
context_vertex = hit_idx
_context_menu.clear()
if context_shape >= 0 and context_vertex >= 0:
_context_menu.set_meta("context_shape", context_shape)
_context_menu.set_meta("context_vertex", context_vertex)
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
return
# 2. Segment proximity -> Create Point (selects the shape)
var seg_shape := hit_test_segment(world_pos)
if seg_shape >= 0:
_selected_shape_idx = seg_shape
_context_menu.set_meta("context_shape", seg_shape)
_context_menu.set_meta("click_position", world_pos)
queue_redraw()
shape_selected.emit(seg_shape)
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
return
# 3. Interior body: select only, NO menu
var body_shape := -1
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_in_shape(i, world_pos):
body_shape = i
break
if body_shape >= 0:
_selected_shape_idx = body_shape
queue_redraw()
shape_selected.emit(body_shape)
return # empty canvas -> no menu either
# --- TRANSFORM mode: existing shape-hit / empty-canvas-add logic (unchanged) ---
...
```
Notes:
- The vertex branch keeps precedence over the segment branch (a vertex sits at a segment end).
- `_on_context_menu_id_pressed` already routes `MENU_CREATE_POINT` → `create_point_on_edge(
_get_context_world_pos())`; the `context_shape` meta is set on the menu so the selected shape
is the one `create_point_on_edge` operates on.
- Programmatic APIs (`insert_vertex`, `remove_vertex`, `create_point_on_edge`,
`hit_test_segment`, `set_edit_mode`/`get_edit_mode`) remain mode-agnostic; only the context
menu gates them.
### 2.2 `_get_context_world_pos` fix (Point 2)
```gdscript
func _get_context_world_pos() -> Vector2:
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2.ZERO)
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2.ZERO
# The stored click position is always a valid world-space coordinate
# (from _screen_to_world at click time). Negative coords are normal after
# panning — no bounds clamp. Only guard non-finite values.
if not is_finite(world_pos.x) or not is_finite(world_pos.y):
return _screen_to_world(size * 0.5)
return world_pos
```
This also fixes TRANSFORM-mode empty-canvas Add placement (a click far off the origin now
places the primitive there instead of silently snapping to center).
### 2.3 Mode labels/emoji — location, centering, font token (Points 6, 7)
**Location decision:** the canvas keeps only the enum (`enum EditMode { TRANSFORM, POINTS }`).
All human-facing strings (mode labels/emoji, menu labels/emoji, dialog copy) live in
`prop_editor.gd` — the UI controller. The canvas stays a string-free widget; the label mapping
is a pure static function for headless testing.
```gdscript
# prop_editor.gd
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] # index == EditMode
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
static func mode_button_text(mode: int) -> String:
var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1)
return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]]
```
Emoji choice (kid-friendly, distinct, justified):
| Mode | Emoji | Label | Rationale |
| --- | --- | --- | --- |
| TRANSFORM | 🖐️ | Arrange | open hand = "grab and move things around" (translate/rotate/scale gizmos) |
| POINTS | ✏️ | Reshape | pencil = "draw / edit points" (vertex handles, Create/Remove Point) |
**Toolbar centering.** The `%ModeBar` `HBoxContainer` (`scenes/prop_editor.tscn` line 78) is
the row above the canvas. Set `_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER` in
`_ready()` so the segmented pair sits centered in the canvas width (interpreting "centered
vertically" as "centered in the bar" — see §6 open choices). `_make_mode_button` keeps
`toggle_mode` + shared `ButtonGroup` (the segmented affordance), drops `SIZE_SHRINK_BEGIN` in
favor of the container alignment, and gains a bigger font + min height.
**Font/hit-target tokens.** Add to `PropTheme.DEFAULT_CONFIG` + committed `prop_theme.json`
(additive; `version` stays `"1.0"`):
```json
"ui": {
"fonts": { "body": 16, "header": 20, "menu": 18, "menu_bar": 18, "mode": 20, "status": 14 },
"hit_targets": { "button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 44 }
}
```
New `PropTheme` accessors (clamped, fallback to `DEFAULT_CONFIG`):
```gdscript
func font_mode() -> int: return integer("ui.fonts.mode", 20, 8, 96)
func font_menu_bar() -> int: return integer("ui.fonts.menu_bar", 18, 8, 72)
func mode_button_min_height() -> int: return integer("ui.hit_targets.mode_button_min_height", 44, 16, 200)
```
`_make_mode_button` applies `add_theme_font_size_override("font_size", _theme.font_mode())`
and `custom_minimum_size.y = float(_theme.mode_button_min_height())`, and sets
`text = mode_button_text(mode)`.
### 2.4 Menu bar emoji + font tokens (Points 8, 9)
`_build_control_theme()` adds a `MenuBar` font size; `_setup_menu_bar()` rewrites titles/items:
```gdscript
# _build_control_theme() addition:
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
# (PopupMenu items already use font_menu, now default 18.)
# _setup_menu_bar() rewrite (emoji + singular "Reset View"):
_menu_bar.set_menu_title(idx_file, "📁 File")
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
file_menu.add_item("🗑️ Clear", MENU_CLEAR)
_menu_bar.set_menu_title(idx_edit, "🔧 Edit")
edit_menu.add_item("📏 Configure Grid / Snap...", MENU_GRID_SETTINGS)
_menu_bar.set_menu_title(idx_view, "👁️ View")
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) # was "Reset Views"
```
Proposed menu emoji set (unique within the menu bar; 💾/🗑️ reappear in the exit dialog by
design — same meaning):
| Label | Emoji | Justification |
| --- | --- | --- |
| File | 📁 | folder container = file operations |
| Save | 💾 | floppy disk = classic save (matches exit-dialog Save) |
| Save As… | 📄 | page = "write to a new document" |
| Load… | 📂 | open folder = open existing |
| Clear | 🗑️ | trash = discard content (matches exit-dialog Discard) |
| Edit | 🔧 | wrench = tools/settings |
| Configure Grid / Snap… | 📏 | ruler = measurement/grid |
| View | 👁️ | eye = visibility |
| Reset View | 🔍 | magnifier = look again / reset |
Emoji-in-labels is an established pattern in this codebase (sandbox uses `💾 Save`,
`🗑️ Discard`, `📋 Edit Queue…`, `⚡ Edit Rules…`), so it renders via the project's fallback
font.
### 2.5 Window title builder + update points (Point 10)
Pure, headless-testable builder in `prop_editor.gd`:
```gdscript
const UNTITLED_NAME := "Untitled"
static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String:
var name := prop_name.strip_edges()
if name.is_empty():
name = UNTITLED_NAME
var title := name
if not file_path.is_empty():
title += " — " + file_path.get_file() # get_file() keeps the ".prop" extension
if dirty:
title += " *"
return title
```
Examples:
| name | file_path | dirty | title |
| --- | --- | --- | --- |
| `""` | `""` | false | `Untitled` |
| `"My Crate"` | `""` | true | `My Crate *` |
| `"My Crate"` | `res://props/mycrate.prop` | false | `My Crate — mycrate.prop` |
| `""` | `res://props/mycrate.prop` | true | `Untitled — mycrate.prop *` |
Update points — one choke point:
```gdscript
func _update_window_title() -> void:
if not is_inside_tree():
return
get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty)
func _update_save_status() -> void:
... existing status-bar text ...
_update_window_title() # single re-derivation site
```
`_update_save_status()` is already invoked on every relevant transition (`_on_name_changed`,
`_on_canvas_shape_changed`, `_on_add_pressed`, `_save_to`, `_load_from`,
`_on_clear_confirmed`, `_apply_json_data`), and `_ready()` calls it once — so the title stays
in sync for free. **Extension enforcement:** `_on_save_file_selected` normalizes the path to
`.prop` (mirroring sandbox `_on_scene_save_path_selected`) so `get_file()` always includes the
extension:
```gdscript
func _on_save_file_selected(path: String) -> void:
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
...
```
### 2.6 Exit dialog flow (Point 11)
Mirrors sandbox (`auto_accept_quit = false` + `NOTIFICATION_WM_CLOSE_REQUEST` +
save-then-quit). The save path is **async only because of the thumbnail render**, so the
exit-save path **skips the thumbnail** (cache nicety, not data) — see §6.
```gdscript
# state
var _exit_dialog: ConfirmationDialog
var _pending_quit: bool = false
# _ready(): add
get_tree().auto_accept_quit = false
# _setup_dialogs(): build (same _ui_theme as _clear_confirm)
_exit_dialog = ConfirmationDialog.new()
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
_exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel" # or add_button(..., true, "cancel")
_exit_dialog.theme = _ui_theme
add_child(_exit_dialog)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_close()
func _request_close() -> void:
if not _dirty:
get_tree().quit()
return
_pending_quit = true
_exit_dialog.popup_centered()
func _on_exit_save() -> void:
_exit_dialog.hide()
if _current_path.is_empty():
_save_dialog.popup_centered() # _pending_quit stays true
return
await _save_and_maybe_quit(_current_path)
func _save_and_maybe_quit(path: String) -> void:
var ok: bool = await _save_to(path, false) # skip thumbnail on exit-save
if ok and _pending_quit:
_pending_quit = false
get_tree().quit()
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
_clear_autosave()
_pending_quit = false
get_tree().quit()
func _on_exit_cancel() -> void:
_pending_quit = false # stay in the editor
# _on_save_file_selected (save dialog path) — compose with pending-quit:
func _on_save_file_selected(path: String) -> void:
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
var ok: bool = await _save_to(path, not _pending_quit) # thumbnail only on normal save
if _pending_quit and ok:
_pending_quit = false
get_tree().quit()
```
`_save_to` changes signature to return success and accept a thumbnail flag:
```gdscript
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
var ok: bool = PropFileScript.save(path, _build_json_data())
if not ok:
_show_error("Could not save the prop to '%s'." % path)
return false
_current_path = path
_dirty = false
_clear_autosave()
_update_save_status()
if with_thumbnail:
await _save_prop_thumbnail()
return true
```
Flow summary:
```text
Window close (NOTIFICATION_WM_CLOSE_REQUEST)
├─ clean -> quit()
└─ dirty -> _exit_dialog
├─ ❌ Cancel -> _pending_quit=false, stay
├─ 🗑️ Discard -> clear autosave, quit()
└─ 💾 Save
├─ no _current_path -> Save dialog; on select -> save(skip thumb) -> quit
└─ has _current_path -> save(skip thumb) -> quit (or show error, no quit)
```
`_ask_confirm`-style theming already exists: the exit dialog uses the same `_ui_theme` and
`ConfirmationDialog` shape as `_clear_confirm` (built in `_setup_dialogs()`), and gets the
same `⚠️` title convention as the sandbox's unsaved-changes dialog.
### 2.7 Autosave / recovery (Point 12)
Mirrors `sandbox_stage.gd` (`AUTOSAVE_PATH`, debounce-free write-on-modify, `write_file_safe`,
`_check_autosave_recovery`, stale-check, clear-on-save/load). A small atomic writer is added to
`PropFile` (the scene IO's `write_file_safe` is sandbox-specific):
```gdscript
# prop_file.gd — atomic write mirroring StageSceneIO.write_file_safe.
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
```
Editor constants + state:
```gdscript
const AUTOSAVE_PATH := "user://prop_editor_autosave.prop"
const AUTOSAVE_DEBOUNCE := 1.0
var _autosave_remaining: float = 0.0 # > 0 = counting down to a write
```
Dirty setpoints go through a single helper that schedules the autosave:
```gdscript
func _mark_dirty() -> void:
_dirty = true
_update_save_status()
_autosave_remaining = AUTOSAVE_DEBOUNCE
# _on_canvas_shape_changed / _on_name_changed / _on_add_pressed -> _mark_dirty()
```
Debounce tick (appended to the existing `_process`):
```gdscript
func _process(delta: float) -> void:
_update_cursor_coords()
if _autosave_remaining > 0.0:
_autosave_remaining -= delta
if _autosave_remaining <= 0.0:
_autosave_remaining = 0.0
_write_autosave()
func _write_autosave() -> void:
if not _dirty:
return
var data := _build_json_data()
data["metadata"]["autosave"] = true
data["metadata"]["source_path"] = _current_path
data["metadata"]["modified_unix"] = Time.get_unix_time_from_system()
PropFileScript.save_safe(AUTOSAVE_PATH, data)
func _clear_autosave() -> void:
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
```
The autosave never triggers another autosave (it writes the file, does not touch `_dirty` or
emit `shape_changed`); `_apply_json_data`/`clear` set `_dirty = false` and do not schedule.
Startup recovery (called from `_ready()` after dialogs are built; mirrors
`sandbox._check_autosave_recovery`, lines 1482-1500):
```gdscript
func _check_autosave_recovery() -> void:
if not FileAccess.file_exists(AUTOSAVE_PATH):
return
var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH)
if raw.is_empty():
_clear_autosave(); return
var raw_meta: Variant = raw.get("metadata", {})
var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {}
if not bool(metadata.get("autosave", false)):
_clear_autosave(); return
var source_path := String(metadata.get("source_path", ""))
# Stale if the saved source exists and is newer-or-equal than the autosave.
if not source_path.is_empty() and FileAccess.file_exists(source_path) \
and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
_clear_autosave(); return
var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
add_child(dialog)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
_current_path = source_path
_update_save_status()
dialog.queue_free())
dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"discard":
_clear_autosave()
dialog.queue_free())
dialog.popup_centered()
```
`_apply_json_data` gains an `is_recovery` flag so a restored prop stays **dirty** (its content
is newer than any saved source):
```gdscript
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
...
_dirty = is_recovery # recovery keeps dirty; normal load clears
...
```
**Autosave clear semantics (explicit table):**
| Event | Autosave cleared? |
| --- | --- |
| Successful manual save (`_save_to`, non-autosave) | **yes** |
| Successful load (`_load_from`) | **yes** |
| File → Clear confirmed (`_on_clear_confirmed`) | **yes** |
| Exit dialog Discard → quit | **yes** |
| Exit dialog Save → successful save → quit | **yes** (via `_save_to`) |
| Recovery prompt Discard | **yes** |
| Recovery prompt Restore | **no** (kept until a manual save/load/clear) |
| Autosave is stale (source newer) at startup | **yes** |
---
## 3. Task-to-implementation mapping & acceptance criteria
| # | Feedback point | Target | Acceptance criteria |
| --- | --- | --- | --- |
| 1 | Segment right-click → Create Point | `prop_canvas.gd` | `hit_test_segment(world_pos) -> int` public; `_handle_right_click` POINTS branch uses segment proximity (not fill-only) per §2.1; Create Point offered for any segment within `EDGE_HIT_THRESHOLD/zoom`; vertex still takes precedence (Remove Point). |
| 2 | Created point on correct segment | `prop_canvas.gd` | `_get_context_world_pos` no longer rejects negative/out-of-bounds coords; only non-finite guard; created vertex == cursor projection onto nearest segment. |
| 3 | POINTS menu = point editing only | `prop_canvas.gd` | POINTS right-click matrix per §2.1: vertex→Remove Point, segment→Create Point, interior/empty→no menu; no Copy/Erase/Flip/z-order items in POINTS mode. |
| 4 | No add menu in POINTS | `prop_canvas.gd` | POINTS empty-canvas right-click pops nothing (early return before the Add-Shape block). |
| 5 | Inspector hides Add/Erase in POINTS | `prop_editor.gd` | `_add_section` VBoxContainer wraps the Add-Shape section; `_sync_mode_controls(mode)` hides `_add_section` + `_delete_button` in POINTS and shows them in TRANSFORM; called from `_on_canvas_mode_changed` and `_ready()`. |
| 6 | Rename modes | `prop_editor.gd` | `MODE_LABELS = ["Arrange","Reshape"]`; `mode_button_text()` returns emoji + label; enum values unchanged; no canvas change. |
| 7 | Segmented centered pair, bigger font, emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | `_mode_bar.alignment = ALIGNMENT_CENTER`; `_make_mode_button` uses `font_mode()` + `mode_button_min_height()` + `mode_button_text(mode)`; tokens `ui.fonts.mode` (20), `ui.hit_targets.mode_button_min_height` (44) present in `DEFAULT_CONFIG` + JSON. |
| 8 | "Reset View" singular | `prop_editor.gd` | `view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)` (label only). |
| 9 | Menu font + emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | MenuBar font token `ui.fonts.menu_bar` (18) applied in `_build_control_theme`; `font_menu` bumped to 18; titles/items per §2.4 emoji table. |
| 10 | Window title | `prop_editor.gd` | `build_window_title(name, path, dirty)` static pure; `_update_window_title()` called from `_update_save_status()`; `get_window().title` set; `.prop` extension enforced in `_on_save_file_selected`. |
| 11 | Exit dialog | `prop_editor.gd` | `auto_accept_quit = false` in `_ready()`; `_notification(NOTIFICATION_WM_CLOSE_REQUEST)`; themed exit dialog (💾/🗑️/❌); `_save_to(path, with_thumbnail) -> bool`; save-then-quit sequencing; Cancel stays; save failure aborts quit. |
| 12 | Autosave/recovery | `prop_editor.gd` + `prop_file.gd` | `AUTOSAVE_PATH`, `AUTOSAVE_DEBOUNCE = 1.0`, `_mark_dirty()` schedules, `_write_autosave()` (metadata.autosave + source_path + modified_unix), `PropFile.save_safe` atomic write, `_check_autosave_recovery()` prompt, `_apply_json_data(is_recovery)`, clear-semantics table. |
---
## 4. Files modified
| File | Changes |
| --- | --- |
| `scripts/prop_canvas.gd` | `hit_test_segment` (public); `_handle_right_click` POINTS matrix; `_get_context_world_pos` bounds-fix. |
| `scripts/prop_editor.gd` | Mode labels/emoji + `mode_button_text`; toolbar centering + font; menu emoji + "Reset View"; window title builder + update points; exit dialog + `auto_accept_quit` + `_notification`; autosave (path/debounce/write/recovery/clear); `_save_to(path, with_thumbnail) -> bool`; `_apply_json_data(is_recovery)`; `_add_section` wrapper + `_sync_mode_controls`. |
| `scripts/prop_theme.gd` | `font_mode()`, `font_menu_bar()`, `mode_button_min_height()`; `DEFAULT_CONFIG` `ui` additions; `font_menu` default 16→18. |
| `prop_theme.json` | Additive `ui` keys (`fonts.mode`, `fonts.menu_bar`, `fonts.menu` 18, `hit_targets.mode_button_min_height`); `version` stays `"1.0"`. |
| `scripts/prop_file.gd` | `save_safe(path, data) -> bool` atomic write. |
| `tests/test_prop_editor_canvas.gd` | Add `_test_segment_hit_test` (and optionally `_test_context_world_pos` if the helper is exposed). |
| `tests/test_prop_editor_round3.gd` | **new** — title builder, `mode_button_text`, `PropFile.save_safe` round-trip, autosave metadata. |
| `docs/tech_debt_and_optimizations.md` | Log exit-save thumbnail skip + autosave debounce + mode-label location decisions. |
---
## 5. Test coverage
**Headless — `tests/test_prop_editor_canvas.gd` (existing suite, public API):**
- New `_test_segment_hit_test()`: box at `(100,100)` (bbox `(76,76)-(124,124)`);
`hit_test_segment(Vector2(110, 70))` (6 px above top edge, within threshold) → `0`;
`hit_test_segment(Vector2(100, 100))` (interior, ~24 px from every edge) → `-1`;
`hit_test_segment(Vector2(400, 400))` → `-1`.
- Existing `_test_create_point_on_edge` already asserts projection + `vertex_created`; the
Round 3 fix (stored click position) is verified by a new direct `_get_context_world_pos`
assertion only if that helper is exposed (optional — otherwise manual F6 covers it).
**Headless — `tests/test_prop_editor_round3.gd` (new; static funcs only, no scene instantiation):**
```gdscript
const PE := preload("res://scripts/prop_editor.gd")
const PF := preload("res://scripts/prop_file.gd")
# Title builder (pure static)
PE.build_window_title("", "", false) == "Untitled"
PE.build_window_title("My Crate", "", true) == "My Crate *"
PE.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop"
PE.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *"
# Mode labels/emoji (pure static, clamping)
PE.mode_button_text(0) == "🖐️ Arrange"
PE.mode_button_text(1) == "✏️ Reshape"
PE.mode_button_text(99) == "✏️ Reshape" # clamps to last
# save_safe atomic round-trip (PropFile)
var p := "user://round3_autosave_test.prop"
var d := {"version":"1.0","prop_name":"X","material_preset":"none","shapes":[],
"metadata":{"autosave":true,"source_path":"","modified_unix":123}}
PF.save_safe(p, d) == true
PF.load(p) deep-equals d (JSON round-trip)
FileAccess.file_exists(p + ".tmp") == false and FileAccess.file_exists(p + ".bak") == false
# overwrite path works, still no leftover
```
**Manual F6 verification (not headless-assertable):** emoji glyph rendering; toolbar centering
+ font/height visually; POINTS-mode right-click feel (segment→Create Point, vertex→Remove
Point, interior/empty→nothing); Create Point at a **negative** world coordinate after panning
(regression for Point 2); inspector Add/Erase hide on mode switch; window title updates across
name/save/load/clear/dirty; exit dialog Save/Discard/Cancel incl. save-then-quit; autosave
recovery prompt on a simulated unclean shutdown (kill the process with edits, relaunch).
---
## 6. Design choices — user-approved resolutions
1. **Emoji sets.** **Approved as proposed:** buttons 🖐️ Arrange / ✏️ Reshape; menus 📁💾📄📂🗑️🔧📏👁️🔍; exit dialog 💾 Save / 🗑️ Discard / ❌ Cancel; restore 🔄 Restore.
2. **"Untitled" vs "Unnamed Prop".** **Approved:** window title default **"Untitled"**; status bar keeps **"Unnamed Prop"**.
3. **Filename with extension.** **Approved:** with `.prop` (`get_file()`), extension enforced on save (`.prp` treated as a typo for `.prop`).
4. **Autosave debounce.** **Approved:** **1.0 s**.
5. **Restore flow.** **Approved:** **prompt** with 🔄 Restore / 🗑️ Discard (mirrors the sandbox).
6. **Autosave clear semantics.** **Approved:** per §2.7 table (clear on save/load/clear/discard-quit; keep after Restore; drop stale autosave at startup).
7. **Exit-save thumbnail.** **Approved:** **skip** the thumbnail on exit-save.
8. **Mode button height.** **Approved:** **44 px** (`mode_button_min_height`) with `font_mode` 20.
9. **Centering.** **Approved:** horizontally center the segmented pair in the ModeBar row (`ALIGNMENT_CENTER`).
---
*Validation follows the existing pattern: per-script `--check-only`, then
`tests/test_prop_editor_canvas.gd` and `tests/test_prop_editor_round3.gd` headless suites
printing PASS/FAIL and `quit(0/1)`. Interactive feel (emoji, centering, exit dialog, autosave
recovery) is manual F6 verification.*
+52
View File
@@ -0,0 +1,52 @@
# Phase 9 Prop Editor — Round 4 (Selection Feedback, Bigger Defaults, Status/Menus, New Flow)
Status: **implemented** (2026-09-19; direct UI round — no open design choices; all nine
feedback points applied).
Source: nine user feedback points against the shipped Round 3 Prop Editor.
## Feedback summary (verbatim intent)
1. The Arrange/Reshape selected state was invisible right after clicking until the cursor
moved away; make the selected button clearly outlined.
2. Mode buttons, File/View menu bar, and menu items need bigger defaults — theme-adjustable.
3. Inspector fonts and the Prop Name input need bigger defaults — theme-adjustable.
4. Swap the status bar: title left, (X, Y) coordinates right; bigger default text.
5. Remove the "Ball" shape — circle covers it.
6. RMB context menus need a bigger default font — theme-adjustable.
7. When the cursor leaves the canvas frame, coordinates read "X: -, Y: -" instead of blank.
8. Replace Edit → Configure Grid / Snap with a grid-size spinbox + snap toggle on the right
side of the menu bar row (sandbox-stage pattern).
9. Replace File → Clear with File → New at the top of the File menu, prompting to save edited
props first.
## Diagnoses
- **Point 1 (selection invisibility):** the Button `focus` stylebox draws after `pressed`, and
the theme set `focus` = same style as `normal`, masking the pressed look until focus left
(cursor moved away). Fix: mode buttons use `focus_mode = FOCUS_NONE`, and the theme's Button
`pressed` stylebox now has an accent-colored 2 px border (`panel_pressed`) so the selected
mode reads as an outlined box.
## Changes
| Target | Change |
| --- | --- |
| `prop_theme.json` + `scripts/prop_theme.gd` | Defaults bumped: body 18, header 24, menu 22, menu_bar 22, mode 26, status 18; new tokens `ui.fonts.prop_name` (20) and `ui.fonts.context_menu` (22); `mode_button_min_height` 44 → 56. All remain theme-adjustable. |
| `scripts/prop_canvas.gd` | Removed the "Add Ball" context-menu item (the `"ball"` primitive stays programmatically available); new `set_context_menu_font_size(size)` applies a font-size override to the RMB menu. |
| `scripts/prop_editor.gd` | `panel_pressed` accent-border stylebox; mode buttons `FOCUS_NONE`; `LineEdit` theme font + `font_prop_name` override on the name edit; status bar text swapped (title left via `%SaveStatus`, coords right via `%CursorCoords`) with "X: - Y: -" fallback; `PRIMITIVE_IDS/LABELS` drop "ball"; context-menu font wired from `font_context_menu`; top-right `Grid:` label + SpinBox + `Snap` CheckBox built in `%TopRightControls` (grid dialog + Edit menu removed); File menu is now 🆕 New / 💾 Save / 📄 Save As… / 📂 Load…; `_request_new()` reuses the unsaved-changes dialog (`_pending_new` flag; Save → save-then-new, Discard → new, Cancel → stay); `_do_new()` replaces the old clear flow. |
| `scenes/prop_editor.tscn` | New `%TopRow` HBox (y 0..32) containing `%MenuBar` (expand) + `%TopRightControls`; status bar children swapped (`%SaveStatus` left, `%CursorCoords` right); `%ClearConfirmDialog` removed. |
| `tests/test_prop_editor_theme.gd` | Updated to the new font/height defaults + new tokens. |
## Verification
- `--check-only` clean on all four scripts; all five headless suites green
(file 64, theme 53, canvas 87, thumb 10, round3 17); scene smoke (instantiate + title) passes.
- Manual F6: selected-mode outline; font sizes; swapped status bar with dashes; grid/snap
controls on the menu row; File → New save prompt; no Ball in any Add menu.
## Notes / tech debt
- Recorded in `docs/tech_debt_and_optimizations.md` (row 32): mode buttons drop keyboard focus
(`FOCUS_NONE`) so the focus stylebox can't mask the pressed state — revisit if keyboard
accessibility for the mode toggle is ever required.
+49
View File
@@ -0,0 +1,49 @@
# Phase 9 Prop Editor — Round 5 (File → Recent + save-serialization fix)
Status: **implemented** (2026-09-19; direct bugfix + feature round).
Source: two user feedback points against the shipped Round 4 Prop Editor.
## Feedback summary
1. File → Recent: show the last 5 opened props (sandbox-stage pattern).
2. Loading `props/eye.prop` draws nothing (the prop name shows, geometry is blank).
## Diagnosis (point 2 — root cause)
`PropFile._serialize_shape()` declared
```gdscript
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
```
but the editor always serializes **export-format** shapes whose `points` is an `Array` of
`{x, y}` dictionaries. The implicit `Array → PackedVector2Array` conversion cannot construct
`Vector2` from a dictionary and **silently yields `Vector2.ZERO` per element**, preserving the
count but zeroing every coordinate. Every save therefore wrote all points as `(0, 0)` —
verified in `props/eye.prop` and `props/box.prop` (all `x: 0.0, y: 0.0`), which load as a
degenerate, invisible shape. The round-1 tests only asserted point *counts* and dict *shape*,
so the corruption slipped through.
**Fix:** `_serialize_shape()` now uses the existing `_coerce_points()` helper (which handles
both `PackedVector2Array` and `Array`-of-`{x,y}` inputs). Regression assertions were added so
point **values** are verified through `to_file_dict` and the full round-trip.
The two already-saved user files (`props/eye.prop`, `props/box.prop`) are unrecoverable
(every coordinate was zeroed at save time) and must be re-authored.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_file.gd` | `_serialize_shape` uses `_coerce_points` (bug fix). |
| `scripts/prop_editor.gd` | `SETTINGS_PATH := "user://prop_editor_settings.json"`, `RECENT_MAX := 5`, `_recent_props`; `update_recent_list(list, path, max)` static (dedupe + front-insert + trim); 🕑 Recent submenu in the File menu (`RecentProps` child popup, updated on `about_to_popup`, missing files pruned, "(No recent props)" placeholder); `_load_settings`/`_save_settings`; `_push_recent(path)` on every successful load; missing recent → error dialog + removal. |
| `tests/test_prop_editor_file.gd` | New value-preservation assertions (serialized `x == 64`, round-trip `(64,0)`/`(64,64)`). |
| `tests/test_prop_editor_round3.gd` | New `_test_recent_list` (dedupe, front-insert, trim). |
## Verification
- `--check-only` clean; all five suites green: file 66, theme 53, canvas 87, thumb 10, round3 21.
- Manual F6: save a prop with shapes at distinct coordinates, reload — geometry must match;
File → 🕑 Recent lists the last 5 opens and opens them; missing recent file shows the error
and prunes itself.
+51
View File
@@ -0,0 +1,51 @@
# Phase 9 Prop Editor — Round 6 (Recent-on-save, Reshaped Flag, Selection Contrast)
Status: **implemented** (2026-09-19; direct round — no open design choices).
Source: three user feedback points against the shipped Round 5 Prop Editor.
## Feedback summary
1. Saving a file should add it to File → Recent when it isn't there yet.
2. A circle's inspector Radius field becomes meaningless once the circle's vertices are
reshaped; every primitive needs a `reshaped` flag so primitive-specific inspector fields
hide/disable after reshaping.
3. A white shape in front of a white shape makes the white selection outline/box invisible;
the selection color should contrast with the shape's fill.
## Design decisions
- **Recent-on-save:** `_save_to()` calls `_push_recent(path)` on success (dedupe +
front-insert + trim, same helper as load — a save is recent activity).
- **`reshaped` flag:** new boolean on every shape dict (internal + export + `.prop` schema,
default `false`; additive and backward compatible — old files load as `false`). Set to
`true` by: `insert_vertex`, `remove_vertex`, vertex-drag motion, `create_point_on_edge`
(via insert), and **non-uniform scaling of a circle** (both the gizmo drag and
`scale_selected`); cleared by `set_shape_radius` (which regenerates points from the radius).
Translate/rotate/mirror/uniform-scale do **not** set it — they preserve primitive-ness.
The inspector hides the Radius row and shows a muted "✏️ This shape was reshaped" note when
the selected shape's flag is set (the flag exists on every primitive so future
primitive-specific fields can gate on it the same way).
- **Selection contrast:** new `PropCanvas.contrast_color_for(fill_hex) -> Color` — dark
(`#1F1F1F`) for light fills (luminance > 0.5), white otherwise. Applied to the selection
polyline and all gizmo drawing (bbox, rotation dot, scale crosses), which were previously
hard white and vanished on white shapes. Vertex handles stay fill-colored (stickman-editor
parity, Round 2 decision); the Create-Point flash stays yellow.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_editor.gd` | `_save_to` pushes the path to recents on success; `_reshaped_note` label; `_refresh_inspector_from_selection` gates `_radius_row.visible` on `not reshaped` and shows the note when reshaped. |
| `scripts/prop_canvas.gd` | `"reshaped"` in `_make_shape_dict`/`_coerce_shape`/`_export_shape`; mutation sites set/clear it; `contrast_color_for` static; selection polyline + gizmos use the contrast color. |
| `scripts/prop_file.gd` | `"reshaped"` serialized/parsed (default `false`). |
| `tests/test_prop_editor_canvas.gd` | New `_test_reshaped_flag` (insert/remove/radius-reset/scale/mirror/translate/rotate matrix) and `_test_selection_contrast`. |
| `tests/test_prop_editor_file.gd` | Round-trip `reshaped` preservation + default-false assertions. |
## Verification
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
round3 21).
- Manual F6: save a new prop → appears in File → 🕑 Recent; reshape a circle (drag a vertex,
create a point, or stretch non-uniformly) → Radius row disappears + muted note; select a
white shape → dark outline/box/dot/crosses.
+47
View File
@@ -0,0 +1,47 @@
# Phase 9 Prop Editor — Round 7 (Two-Tone Halo Selection)
Status: **implemented** (2026-09-19; direct follow-up on the Round 6 selection-contrast fix).
Source: one user feedback point — the Round 6 contrast approach did not fix the reported
case.
## Diagnosis
Round 6 made the selection color adaptive to the *selected shape's fill* (dark on light
fills, white on dark fills). That solves "white shape selected on a dark canvas" but not the
reported case: a shape sitting **in front of a white shape** — the selection outline, bounding
box, rotation dot, and scale crosses extend beyond the selected shape onto the white shape
behind it, so a white (or fill-mismatched) selection still blends into the white background.
Fill-based contrast fundamentally cannot know what is behind the shape.
## Fix
Every selection element now uses a **two-tone halo**: a dark backing pass (`SELECTION_HALO_COLOR`,
black at 0.85 alpha, ~2 px wider) drawn first, then a bright core pass (`SELECTION_CORE_COLOR`,
white, the original width) on top. The pair is visible on any background — the dark halo
separates the bright core from light/white surroundings, and the bright core separates it from
dark surroundings. Applied to:
- the selection polyline over the shape outline,
- the bounding box (dark 3.5/zoom pass + white 1.5/zoom pass),
- the rotation dot (dark ring at radius + 2, white dot at radius),
- the four scale crosses (dark 3.5 pass + white 1.5 pass),
- vertex handles in Reshape mode (a 1.5 px dark ring behind each handle).
The Round 6 `contrast_color_for` helper was removed. `SELECTION_COLOR`/`GIZMO_COLOR` were
replaced by `SELECTION_CORE_COLOR`/`SELECTION_HALO_COLOR`.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_canvas.gd` | Halo constants; two-pass drawing for selection polyline, bbox, rotation dot, scale crosses, vertex handles; `contrast_color_for` removed. |
| `tests/test_prop_editor_canvas.gd` | `_test_selection_contrast` replaced by `_test_selection_halo` (halo is dark, core is bright). |
## Verification
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
round3 21).
- Manual F6: select a dark shape in front of a white shape, and a white shape in front of a
white shape — the outline/box/dot/crosses must be visible in both; repeat in Reshape mode
for the vertex handles.
+877
View File
@@ -0,0 +1,877 @@
# Phase 9a — Role-Based Prop Anchors and IK Authoring Preview
Status: **proposed — awaiting user sign-off** (2026-09-20).
Source: `plans/PHASE_9a_PROP_ATTACH.md`, refined through the spec-pipeline architecture
clarification round.
## 1. Accepted decisions
The user approved these three architecture decisions:
1. **Prop Editor-only phase.** This phase implements anchor authoring, `.prop` round-trip,
role queries, and an isolated rig-backed IK holding preview. All Sandbox Stage integration,
runtime loading, attachment, gameplay, physics, and scene persistence are future work.
2. **Fixed origin pivot.** Every prop has exactly one reserved anchor named `origin`, with
role `pivot`, at `(0, 0)`. It cannot be deleted, renamed, moved, or assigned another role.
Legacy `.prop` files synthesize it on load.
3. **One-hand authoring preview.** The selected grip follows the solved wrist endpoint of an
isolated preview rig. Secondary grips remain serialized/queryable and may be previewed one at
a time, but never drive the opposite hand.
These are requirements, not implementation suggestions.
## 2. Scope
### 2.1 In scope
- Extend the existing `[ Arrange | Reshape ]` toolbar and mode enum with one new `Attach`
mode. Arrange and Reshape retain their current labels, behavior, and tests.
- Add a normalized role-based anchor dictionary to `.prop` v1.0.
- Preserve legacy `.prop` compatibility by synthesizing the fixed `origin` pivot.
- Add anchor list/property editing, add/delete/rename, role selection, quick presets, and
role-specific canvas gizmos.
- Add anchor translation, grid snapping, rotation handles, and an isolated rig-backed preview
where a stickman visibly holds the prop while its selected arm is posed through IK.
- Include anchors in ordinary save/load, Save As, autosave, recovery, and New.
- Add pure role/hand query and transform helpers that the editor preview consumes and future
gameplay systems may reuse.
- Add headless regression coverage plus manual Prop Editor checks.
### 2.2 Explicitly deferred
- **All Sandbox Stage/runtime changes:** custom `.prop` loading/spawning, selector integration,
stage provenance/persistence, hand sockets, `StickmanPropAttachment`, equip/release, held-prop
z ordering, collision/freeze handling, ragdoll/drop behavior, and runtime mirroring.
- Secondary-hand IK and simultaneous two-hand constraint solving in gameplay. The editor preview
remains one-hand-at-a-time.
- Automatic grip choice, reach-to-pickup animation, pickup menus, director actions, or action
queue schema changes.
- Gameplay behavior for `grip`, `emitter`, `impact`, `mount`, or `custom` anchors. This phase
authors and previews metadata only.
- Projectile, particle, light, melee, accessory, throw, or center-of-mass systems.
- Full multi-shape runtime rendering/collision. The existing runtime flattening contract is not
modified; the editor preview may show the full authoring stack.
- Document-wide undo/redo. The Prop Editor currently has no undo system; adding anchor-only
undo would make shape and anchor editing inconsistent.
- Any `.scene` schema or save/load change.
## 3. Existing constraints
- `PropCanvas.EditMode` currently has only `TRANSFORM` and `POINTS`, and
`set_edit_mode()` clamps every non-POINTS value to TRANSFORM.
- `PropEditor` derives every save/autosave from `_build_json_data()`, so anchor document state
must enter through that single path.
- `PropFile.FILE_VERSION` is `"1.0"`; additive fields such as `reshaped` already use
backward-compatible v1.0 evolution.
- `PropBlock` and `PropUtils.spawn_prop()` are single-shape runtime objects, but Phase 9a does not
change or consume them. The editor preview uses non-physics visuals built from all shapes.
- `StickmanRig` contains runtime locomotion/ragdoll/action components. The editor preview may
instantiate it only inside a private SubViewport and must disable gameplay processing and
collisions; no rig/runtime class or scene is modified by this phase.
- `StickmanRig` mirrors its root on X for LEFT, while Godot TwoBoneIK is not mirror-invariant.
The preview must isolate reflection outside the positive-scale solving skeleton.
- `IK_Targets/Left_Hand` and `Right_Hand` are solver goals. Preview attachment must sample the
solved lower-arm visual endpoint, not pretend the target marker itself is the wrist.
## 4. `.prop` schema and normalization
### 4.1 Version policy
Keep `PropFile.FILE_VERSION == "1.0"` and `SUPPORTED_VERSIONS == ["1.0"]`. `anchors` is an
additive field. A missing or malformed `anchors` value is normalized to the mandatory origin
rather than rejecting an otherwise valid legacy file.
`PropFile.to_file_dict()` always emits `anchors`, including the fixed origin. Loading an old
file does not mark the editor dirty merely because the in-memory default was synthesized; its
next explicit save writes the new field.
### 4.2 File representation
```json
{
"version": "1.0",
"prop_name": "Arcane Staff",
"material_preset": "wood",
"shapes": [],
"anchors": {
"origin": {
"role": "pivot",
"x": 0.0,
"y": 0.0,
"rotation_deg": 0.0,
"z_index_offset": 0,
"allowed_hands": "both"
},
"primary_grip": {
"role": "grip",
"x": 0.0,
"y": 18.0,
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both"
},
"crystal_head": {
"role": "emitter",
"x": 0.0,
"y": -48.0,
"rotation_deg": -90.0,
"z_index_offset": 0,
"allowed_hands": "both"
}
},
"metadata": {}
}
```
Every serialized anchor uses the same six fields. Uniform entries make validation, UI binding,
and future consumers deterministic even when a field is not currently meaningful for a role.
### 4.3 Internal representation
The normalized editor model remains a `Dictionary` keyed by anchor ID, matching the project's
JSON-first asset conventions:
```gdscript
{
"primary_grip": {
"role": "grip",
"position": Vector2(0.0, 18.0),
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both",
},
}
```
Do not assign JSON `{x, y}` dictionaries directly into typed vector arrays or vector fields.
Parse `x` and `y` explicitly, following the existing `_coerce_points()` serialization lesson.
### 4.4 Registry and validation rules
- Roles: `pivot`, `grip`, `emitter`, `impact`, `mount`, `custom`.
- Allowed hands: `both`, `left_only`, `right_only`.
- IDs use lowercase snake case: `^[a-z][a-z0-9_]{0,31}$`.
- UI input is normalized by trimming, lowercasing, replacing runs of non-alphanumeric
characters with `_`, trimming `_`, and adding `_2`, `_3`, ... until unique.
- Position values must be finite; invalid values become `0.0`.
- `rotation_deg` must be finite and is wrapped into `[-180.0, 180.0)`.
- `z_index_offset` is clamped to `[-5, 5]`.
- Unknown roles become `custom`; unknown hand values become `both`.
- The `origin` entry is overwritten with the canonical fixed pivot on every normalization.
- Any non-origin entry claiming role `pivot` is preserved but downgraded to `custom`, with one
warning for that normalization pass. No user data coordinates are discarded.
- Malformed non-dictionary entries are skipped with a warning.
Anchor position is always in canonical prop-local pixels. `rotation_deg` is the direction of
the anchor's local **+X axis**. For a grip, +X points from elbow toward wrist; for an emitter,
it is the output direction. It is orientation metadata, not an instruction to rotate the prop's
stored geometry.
### 4.5 `PropAnchorData` contract
Add `scripts/prop_anchor_data.gd`:
```gdscript
class_name PropAnchorData
extends RefCounted
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
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary
static func serialize_anchors(anchors: Dictionary) -> Dictionary
static func normalize_id(candidate: String, anchors: Dictionary) -> String
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary
```
All returned dictionaries/arrays that expose document state are deep duplicates. Callers must
not be able to mutate the codec's or canvas's state by retaining a reference.
When `mirror_x` is true, local position becomes `(-x, y)` and the +X direction angle becomes
`PI - angle`. The canonical stored anchor is never mutated.
`solve_attachment_transform()` returns a positive-determinant prop transform satisfying
`prop_transform * effective_anchor_transform == target_transform` for translation and +X
direction. Normalize `positive_scale` to finite absolute components greater than `0.0001`, compute
`prop_angle = target_transform.get_rotation() - effective_anchor_angle`, build the prop basis
from `prop_angle` and that positive scale, then set:
```gdscript
var prop_origin := (
target_transform.origin
- prop_basis.basis_xform(effective_anchor_position)
)
```
The helper ignores scale/skew on `target_transform`; callers supply a pure rotation/translation
target. Mirroring is represented only by the effective anchor plus separately mirrored preview
geometry, never by a negative prop-node scale.
### 4.6 `PropFile` API changes
```gdscript
static func to_file_dict(
prop_name: String,
material_preset: int,
shapes: Array[Dictionary],
anchors: Dictionary = {}
) -> Dictionary
static func from_file_dict(data: Variant) -> Dictionary
```
`from_file_dict()` returns:
```gdscript
{
"prop_name": String,
"material_preset": int,
"shapes": Array[Dictionary],
"anchors": Dictionary,
}
```
`flatten_to_payload()` remains shape-only and unchanged. Anchors are provenance/interaction
metadata, not collision geometry.
## 5. Prop Editor architecture
### 5.1 Node layout
Update `scenes/prop_editor.tscn` without changing unique-name lookup contracts:
```text
PropEditor
├── TopRow
│ ├── MenuBar
│ └── TopRightControls # existing Grid + global Snap source
├── TopBar
├── MainLayout
│ ├── CanvasPanel
│ │ └── CanvasArea
│ │ ├── ModeBar # existing Arrange/Reshape + new Attach
│ │ ├── AttachTools # visible only in Attach
│ │ └── CanvasWorkspace (HSplitContainer)
│ │ ├── PropCanvas # existing authoring canvas
│ │ └── PropAttachPreview # rig-backed holding preview; Attach only
│ └── InspectorColumn
│ └── InspectorScroll # NEW ScrollContainer
│ └── InspectorList # keep %InspectorList
├── SaveDialog / LoadDialog / ErrorDialog
└── StatusBar
```
`AttachTools` contains:
- `Show Stickman Preview` (`CheckBox`, default on, session-only tool state; collapses/opens the
preview side of `CanvasWorkspace`);
- `Snap Anchors` (`CheckBox`, bound bidirectionally to the same `_snap_enabled` value as the
existing top-right Snap checkbox);
- `Show Inactive Gizmos` (`CheckBox`, default on, session-only tool state).
The second Snap control is a synchronized view of one setting, not independent state. Changing
either checkbox updates both without signal recursion and calls `PropCanvas.set_snap_enabled()`.
### 5.2 Modes
Preserve the existing Arrange (`TRANSFORM`) and Reshape (`POINTS`) values and append Attach:
```gdscript
enum EditMode { TRANSFORM, POINTS, ATTACH }
```
`set_edit_mode()` clamps only values outside that enum. Do not renumber or reinterpret the first
two values. ATTACH persists across clear/load just like the existing modes. Append `Attach` /
`📌` to the controller-owned mode-label arrays; do not rebuild or rename Arrange/Reshape. Keep
all three buttons in the existing `ButtonGroup`, with `focus_mode = FOCUS_NONE` to preserve the
current selected styling.
Mode behavior:
| Behavior | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| Shape body translate | yes | select only | no |
| Shape transform gizmos | yes | no | no |
| Vertex handles/menu | no | yes | no |
| Shape right-click menu | yes | reshape matrix | no |
| Anchor gizmos/input | no | no | yes |
| Wheel/pinch/middle-pan | yes | yes | yes |
Shape selection may remain remembered while Attach is active, but no selection outline, shape
gizmo, or vertex handle is drawn in that mode. Switching back restores the remembered shape
selection.
### 5.3 Composed anchor interaction helper
Add `scripts/prop_anchor_overlay.gd`:
```gdscript
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive(enabled: bool) -> void
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String
func begin_pointer(world_pos: Vector2, zoom: float) -> bool
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void
func end_pointer() -> void
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void
```
This helper owns anchor selection/drag state and math, while `PropCanvas` remains the only owner
of pan, zoom, coordinate conversion, and `_gui_input`. The canvas routes left-button events to
the helper only in ATTACH and re-emits its two signals as public `PropCanvas` signals. The helper
draws only authoring gizmos; the isolated rig preview in §5.5 owns all pose visualization.
Add to `PropCanvas`:
```gdscript
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive_anchors(enabled: bool) -> void
func get_shapes_bounds() -> Rect2
```
An empty shape stack must not early-return before anchor drawing. Attach mode still shows the
origin and other anchors on an empty canvas.
### 5.4 Drawing and hit targets
Use code-drawn vector markers; do not add bitmap icon dependencies.
| Role | Marker | Core color |
| --- | --- | --- |
| pivot | crosshair | yellow |
| grip | hand-like circle + short palm bars | cyan |
| emitter | target reticle + direction arrow | red |
| impact | diamond + slash | bright pink |
| mount | ring + center dot | purple |
| custom | hexagon | light gray |
- Draw a dark halo behind every marker/line, matching the existing selection visibility rule.
- Marker radius and hit radius are constant screen sizes (`value / zoom`).
- Draw the selected marker last, with a yellow outer selection ring.
- When `Show Inactive Gizmos` is off, draw/hit-test only the selected anchor. If no anchor is
selected, continue showing the origin so the canvas is not visually empty.
- Pivot is selectable but never draggable and never receives a rotation handle.
- A selected `grip` or `emitter` shows a rotation ring and handle.
- Translation snaps each coordinate with `snappedf(value, grid_size)` only when the shared Snap
setting is enabled.
- Rotation is free by default; Ctrl snaps to 15-degree increments, consistent with Arrange.
- Emit `anchors_changed` during live drag for inspector feedback. Mouse release sanitizes the
final position/angle and emits once more only if sanitization changed it.
- Right click has no Attach-mode context menu; list/inspector buttons own add/delete/rename.
### 5.5 Rig-backed holding and IK preview
The preview must show a recognizable stickman holding the current prop, not a symbolic hand.
It is a separate authoring visualization with its own `SubViewport`, world, rig instance, camera,
pose state, and non-physics prop visual. It reuses the actual rig scene, `StkRigAdapter`, arm IK,
and the pure anchor-alignment math, while remaining isolated from Sandbox/gameplay state.
#### 5.5.1 Class and scene contract
Add `scripts/prop_attach_preview.gd` and `scenes/prop_attach_preview.tscn`:
```gdscript
class_name PropAttachPreview
extends PanelContainer
enum PreviewHand { LEFT, RIGHT }
enum FrameMode { UPPER_BODY, FULL_FIGURE }
func set_document(
shapes: Array[Dictionary],
anchors: Dictionary,
selected_anchor_id: String
) -> void
func set_selected_anchor(anchor_id: String) -> void
func set_preview_hand(hand: int) -> void
func set_facing_profile(profile: int) -> void
func set_arm_animation_enabled(enabled: bool) -> void
func reset_arm_pose() -> void
func frame_view(mode: int) -> void
func is_preview_available() -> bool
```
```text
PropAttachPreview (PanelContainer)
└── PreviewLayout (VBoxContainer)
├── PreviewHeader (HBoxContainer)
│ ├── Title: "Holding Preview"
│ ├── HandOption: Left / Right
│ ├── FacingOption: Forward / Left / Right
│ └── FrameOption: Upper Body / Full Figure
├── PreviewHint (Label)
├── PreviewViewportContainer (SubViewportContainer)
│ └── PreviewViewport (SubViewport)
│ └── PreviewWorld (Node2D)
│ ├── PreviewRigRoot (Node2D; preview-only mirror wrapper)
│ │ └── PreviewRig (dynamically instanced for this editor preview)
│ ├── PreviewProp (Node2D; generated visual only)
│ ├── PreviewOverlay (Node2D; target handle/instructions)
│ └── PreviewCamera (Camera2D)
└── PreviewFooter (HBoxContainer)
├── AnimateArm (CheckBox)
├── ResetArm (Button)
└── ResetView (Button)
```
The preview panel has a `360 px` minimum width and is the right pane of the resizable
`CanvasWorkspace` split. It is visible only in Attach mode and while `Show Stickman Preview` is
enabled. Hiding it must not change document data, selected anchor, or pose state outside this
private preview.
#### 5.5.2 Safe rig reuse and isolation
Create one private preview rig when the preview enters the tree:
1. Load `res://stickmen/test.stk` through `StickmanFactory.load_stk()`.
2. Instantiate `res://scenes/stickman_rig.tscn`, call `set_physics_process(false)` on the rig
before it enters the tree, and apply valid `.stk` data through `StkRigAdapter.apply()`. If the
file cannot be loaded, keep the scene's authored master visuals rather than failing the editor.
3. Add the rig only to `PreviewViewport`'s private `PreviewWorld`; after `_ready()`, stop its
`AnimationPlayer`, call `stop_queue()` then `clear_queue()`, retain the default `ANIMATED`
state, and set every descendant `CollisionObject2D` layer and mask to `0`.
4. Do not set the whole rig subtree to `PROCESS_MODE_DISABLED`, because that would also stop the
skeleton solve. The root physics callback is the gameplay tick boundary; authored component
children have no autonomous process callback.
5. Keep the `Skeleton2D` modification stack and `RemoteTransform2D` drivers active so moving an
IK target solves the real arm and updates the real Body visuals.
The preview instance is never added to Sandbox `World`, never registered with `StageSpawner`,
never serialized, and never connected to stage rules or signals. It must not create a
`PropBlock`, hand socket, or runtime attachment component. Preview pose/facing/hand settings are
session-only UI state. The preview receives deep copies from `PropEditor`, so no preview mutation
can alter the document except through the existing anchor inspector/canvas signals.
This boundary intentionally reuses visual rig/IK behavior but not gameplay ownership, physics,
ragdoll, locomotion, or action execution.
#### 5.5.3 Prop visual and shared alignment math
`PreviewProp` is rebuilt from the editor's complete `shapes[]` stack using one `Polygon2D` or
`Line2D` per shape, in document order. It has no collision node or physics body and therefore
shows all authored shapes even though the staged runtime still flattens collision/render payloads.
Coalesce repeated shape/anchor signals into at most one rebuild/alignment per rendered frame.
Add one pure helper so editor alignment is deterministic, directly testable, and reusable by a
future runtime design if that later specification adopts the same convention:
```gdscript
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
```
This belongs on `PropAnchorData`. Phase 9a has exactly one production caller
(`PropAttachPreview`). Keeping the transform pure and independent of preview nodes avoids
coupling the schema to the rig scene. A future runtime phase may reuse it only after separately
validating the convention. The preview may mirror its non-physics geometry directly, but
canonical document shapes/anchors remain unchanged.
Do not add HandSocket nodes to the runtime rig. After the skeleton/RemoteTransform update, derive
the preview wrist transform from existing nodes:
1. Resolve `Body/LeftLowerArm` or `Body/RightLowerArm` as the solved hanging-frame visual.
2. Resolve the matching lower `Bone2D` and read its fitted `length`.
3. Compute elbow = `lower_visual.to_global(Vector2.ZERO)` and wrist =
`lower_visual.to_global(Vector2(0, lower_length))`.
4. Direction = `(wrist - elbow).normalized()`; build a proper (positive determinant) socket
transform whose +X axis follows that direction and whose origin is wrist.
5. Pass that transform to `solve_attachment_transform()`.
`StkRigAdapter` already mounts lower-arm visuals into the +Y hanging frame and fits their extent
to the bone length, so this samples the visible endpoint without changing the rig or trusting the
IK goal marker as the solved wrist. If the nodes/direction are invalid, hide the preview prop and
show a non-fatal preview-unavailable hint.
When the selected entry is a grip, align it to this derived wrist transform. A one-frame
engine-order delay is acceptable while dragging; the following frame must be exact. When no grip
is selected, keep the rig visible, hide the prop visual, and show
`Select a grip anchor to preview holding.`
Preview `z_index_offset` relative to the rig root: negative behind, positive in front, and zero
placed immediately before the rig so the hand/body stays visible. This is an authoring convention
only; exact runtime layering is deliberately left to the future runtime specification.
#### 5.5.4 Hand and facing behavior
- Default preview hand is RIGHT and default facing is FORWARD.
- `left_only` forces LEFT and disables RIGHT in `HandOption`.
- `right_only` forces RIGHT and disables LEFT.
- `both` enables both choices and remembers the last session choice.
- Selecting any grip—including `secondary_grip`—previews that one grip as the active one-hand
attachment. It never moves the opposite hand or consumes another grip.
- Changing hand moves only the newly active hand target; first reset both hand targets to their
captured rest positions so poses do not leak between hand choices.
- Facing uses the preview rig's established `FacingProfile`, bend flags, and Body child ordering.
It does not write the document or affect a runtime rig.
- LEFT must exercise `PropAnchorData`'s effective-anchor mirroring so authors can evaluate the
intended facing behavior before a future runtime implementation exists.
The runtime rig deliberately disables idle limb IK under its negative-X LEFT root because Godot's
TwoBoneIK solver is not mirror-invariant. The preview must not bypass that known constraint by
forcing the solver under a reflected skeleton. For LEFT preview:
1. call `PreviewRig.set_facing_profile(LEFT)` to reuse bend flags and Body ordering;
2. restore `PreviewRig.scale = Vector2.ONE`;
3. mirror `PreviewRigRoot.scale.x = -1` so reflection occurs *after* the positive-scale skeleton
solves;
4. explicitly enable the two arm `SkeletonModification2DTwoBoneIK` entries for preview posing;
5. keep `PreviewProp` outside `PreviewRigRoot`, mirror its generated geometry, and align it to the
reflected socket with `solve_attachment_transform(..., mirror_x = true)`.
RIGHT/FORWARD use identity `PreviewRigRoot` and `mirror_x = false`. This establishes the editor's
facing convention while keeping the isolated preview solver stable. Tests must assert that LEFT
leaves `PreviewRig`'s local scale positive and puts the reflection only on the wrapper.
This preserves the approved one-hand authoring scope while allowing every authored grip to be
visually tested with either compatible anatomical hand.
#### 5.5.5 IK interaction
Draw a constant-screen-size cyan target handle at the active
`IK_Targets/Left_Hand` or `IK_Targets/Right_Hand` marker. The user may drag this handle inside the
preview viewport; the real TwoBoneIK modification bends the arm and the preview prop follows the
solved wrist socket.
Clamp manual and animated targets to the reachable annulus around the active upper-arm shoulder:
```text
min reach = abs(upper_length - lower_length) + 8 px
max reach = upper_length + lower_length - 8 px
```
If the drag vector is zero, use the captured rest-pose direction before clamping. This avoids
solver folding/jitter without inventing a second IK implementation.
`Animate Arm` provides a hands-off movement check:
- period: `3.0 s`, sine-eased ping-pong;
- endpoint A: the captured rest target;
- endpoint B: rest target plus `(outward * 120, -120)`, clamped to the same annulus;
- anatomical outward sign: LEFT `+1`, RIGHT `-1` in unmirrored rig-local space;
- starting a manual drag turns animation off;
- Reset Arm turns animation off and restores both hand targets to their captured rest values.
The inactive hand remains at rest. Head, torso, and leg targets remain untouched. No animation
clip plays while the arm target is manipulated.
#### 5.5.6 Camera and input
- `PreviewViewport.size` follows `PreviewViewportContainer.size`.
- Default frame is `UPPER_BODY`, fitting the union of head/torso/arms and current prop with a
`32 px` screen margin.
- `FULL_FIGURE` fits a preview-local union of the transformed `Body/*` visual bounds and current
prop AABB with the same margin. `PropAttachPreview` owns this bounds helper; it must not import
or call `StageSpawner`.
- The local bounds helper walks visible descendants recursively: transform each `Polygon2D.polygon`
point and each `Line2D.points` entry into `PreviewWorld` coordinates, expanding line bounds by
half the drawn width. `UPPER_BODY` filters to Head/Torso/arm branches; `FULL_FIGURE` includes
every `Body/*` branch.
- Camera framing is recalculated on preview resize, explicit Reset View, frame-mode change, or a
document geometry rebuild—not every arm-animation frame. This prevents zoom pumping.
- Mouse wheel performs cursor-centered preview zoom, clamped `[0.3, 3.0]` relative to the fitted
zoom. Middle mouse pans. These controls affect only the preview camera.
- Left drag moves only the active IK handle; clicks elsewhere do not select/edit anchors.
- The parent `PropCanvas` keeps its existing pan/zoom/input unchanged.
Convert pointer positions through the preview viewport rather than the authoring canvas:
```gdscript
var preview_world_pos := (
preview_viewport.get_canvas_transform().affine_inverse()
* preview_container_local_position
)
```
The viewport size must match the container's integer size (clamped to at least `1 × 1`), so this
mapping remains stable after split/window resize.
The preview overlay shows a short hint: `Drag the cyan hand target to test the grip.` It may show
the active anchor ID and hand, but it must not show gameplay/action controls.
### 5.6 Inspector
`PropEditor` owns normalized document state:
```gdscript
var _anchors: Dictionary = PropAnchorData.default_anchors()
var _selected_anchor_id: String = PropAnchorData.ORIGIN_ID
```
The existing Material section stays visible. In ATTACH, hide Selected Shape and Add Shape, and
show a new Anchor section containing:
1. `ItemList` of anchor IDs (the list itself scrolls);
2. `Add Anchor`, `Rename`, `Delete` buttons;
3. Role `OptionButton`;
4. X/Y `SpinBox` values (`-100000..100000`, step `1`);
5. Rotation `SpinBox` (`-180..<180`, step `1`) plus a matching slider if it can share the same
no-recursion update guard;
6. Z Offset `SpinBox` (`-5..5`, integer);
7. Allowed Hands `OptionButton`;
8. Quick Presets buttons: One-Handed, Two-Handed, Tool / Nozzle, Melee Edge.
For `origin`, Rename/Delete and every property control are disabled. Role-specific relevance is
communicated by muted labels, but the uniform data remains editable for non-origin anchors.
Add creates a uniquely named `anchor`, selects it, and defaults to role `custom` at the center of
the current shape bounds (or zero when empty). Delete selects the preceding list entry, falling
back to `origin`. Rename uses the ID normalizer and immediately updates selection.
Quick presets replace all non-origin anchors. If any exist, use a confirmation dialog before
replacement. Layout is derived from the union shape AABB:
- major axis = X when width >= height, else Y;
- direction angle = `0°` for X or `90°` for Y;
- center = bounds center, or zero for empty bounds;
- positive endpoint = right-center or bottom-center;
- quarter offset = `min(major_length * 0.2, 32)`.
Preset contents:
| Preset | Generated anchors |
| --- | --- |
| One-Handed | `primary_grip` at center |
| Two-Handed | `primary_grip` and `secondary_grip` at center ± quarter offset |
| Tool / Nozzle | `primary_grip` at center, `emitter` at positive endpoint |
| Melee Edge | `primary_grip` at center, `impact` at positive endpoint |
All generated anchors use the major-axis direction, `both`, and z offset `0`.
### 5.7 Dirty state, New, save, and recovery
- `_build_json_data()` passes `_anchors` to `PropFile.to_file_dict()`.
- `_apply_json_data()` assigns the normalized model anchors to both `_anchors` and canvas.
- `_do_new()` resets anchors to `default_anchors()` and selects `origin`.
- Canvas `anchors_changed` updates `_anchors`, calls `_mark_dirty()`, and refreshes inspector.
- After shape/anchor/selection changes, pass deep copies to
`PropAttachPreview.set_document()`/`set_selected_anchor()`; the preview coalesces visual work.
- Preview visibility, preview hand, facing, arm animation, camera, inactive-gizmo visibility,
and mode changes are tool state and do not mark the document dirty.
- Add/delete/rename/preset/property changes mark dirty through the same centralized path.
- Existing debounced autosave automatically includes anchors through `_build_json_data()`.
- Recovery restores anchors and keeps the document dirty, matching shape recovery.
- Thumbnails remain geometry-only; changing only anchors may update the `.prop` mtime/cache key
on save, but anchors are not drawn into the thumbnail.
## 6. Future Sandbox/runtime scope — not a Phase 9a deliverable
Phase 9a stops at the `.prop` anchor contract and editor-only preview. It must not modify
`PropBlock`, `PropUtils`, `StageSpawner`, `SandboxStage`, `StageSceneIO`, `StickmanRig`,
`StkRigAdapter`, `scenes/stickman_rig.tscn`, collision/physics behavior, director actions, or
runtime tests.
A later, separately approved specification may consume the saved anchor contract to design:
- custom `.prop` discovery, selector UI, spawning, provenance, missing-asset handling, and stage
scene persistence;
- a runtime held-prop visual/physics representation that addresses the existing multi-shape
flattening limitation;
- explicit wrist sockets or another stable solved-wrist API;
- one-hand and two-hand equip/release ownership;
- facing/mirroring and per-limb draw-order behavior;
- collision masks, freeze/reparent strategy, throwing, ragdoll/drop lifecycle, and save semantics;
- gameplay consumers for emitter/impact/mount/custom roles.
The future runtime spec should reuse the pure normalized anchor data and transform convention
where appropriate, but must validate them against runtime physics and animation rather than
treating the editor preview as authoritative gameplay behavior.
## 7. Signal and data flow
### 7.1 Editor
```mermaid
flowchart TD
Input[Canvas/Inspector input] --> Overlay[PropAnchorOverlay]
Overlay -->|anchors_changed deep copy| Canvas[PropCanvas]
Canvas -->|anchors_changed| Editor[PropEditor document state]
Editor --> Dirty[_mark_dirty + autosave debounce]
Editor --> Inspector[Anchor inspector refresh]
Editor -->|deep-copied shapes/anchors/selection| Preview[PropAttachPreview]
Preview --> PreviewRig[Private SubViewport rig + arm IK]
Preview --> PreviewProp[Non-physics full-shape visual]
Editor --> Save[_build_json_data]
Save --> Codec[PropFile.to_file_dict]
Codec --> Disk[.prop JSON]
```
The diagram ends at the `.prop` file and private preview. No arrow reaches Sandbox/runtime state.
## 8. Theme and UX requirements
- Reuse `PropTheme` font sizes, minimum row heights, text, muted text, border, and accent tokens.
- Do not mutate the global project theme.
- `InspectorScroll` fills the inspector column vertically and scrolls when the anchor controls do
not fit.
- `PropAttachPreview` uses the same local `_ui_theme` tokens as the editor; the viewport
background uses `canvas_background_color()` and the panel/header use existing border/text/
muted/accent tokens.
- The preview split is user-resizable, never shrinks below `360 px` while open, and collapses
completely when hidden so existing Arrange/Reshape canvas space is unchanged.
- Anchor list/buttons meet the existing theme's minimum hit height.
- Disabled origin controls remain readable using muted text, with tooltip: “Origin is the fixed
prop pivot.”
- Add/delete/rename/preset actions must keep keyboard focus from causing the mode-selected style
regression; mode buttons remain `FOCUS_NONE`.
- Status cursor coordinates continue to report prop world coordinates in Attach mode.
- No new image assets are required.
## 9. Tests and engine validation
### 9.1 New headless suite
Add `tests/test_phase9a_prop_attach.gd` (`extends SceneTree`, PASS/FAIL, `quit(0/1)`) covering:
1. default anchors contain only canonical origin;
2. legacy file without anchors synthesizes origin;
3. origin tampering is overwritten and extra pivot roles downgrade to custom;
4. finite/default/clamp/wrap validation;
5. ID normalization and collision suffixes;
6. serialize/parse round-trip asserts coordinate and rotation **values**, not only counts;
7. role and allowed-hand queries;
8. X-mirrored anchor position/direction math;
9. all four quick-preset layouts for horizontal, vertical, and empty bounds;
10. `PropCanvas.EditMode.ATTACH` persists through clear/load and blocks shape drag routing;
11. fixed origin cannot begin translate/rotate;
12. snapped and unsnapped anchor translation plus Ctrl rotation snapping;
13. preview rig lives under its private `SubViewport` world and adds nothing to a Sandbox world;
14. preview disables rig physics ticking/collisions and animation playback while leaving the
Skeleton2D modification stack enabled;
15. preview visual creates one node per authored shape and never creates `PropBlock` or collision;
16. `left_only`/`right_only` force the compatible preview hand while `both` permits either;
17. the derived wrist transform (lower-arm visual origin plus fitted lower-bone length) maps the
selected anchor to the solved wrist within tolerance;
18. manual preview target clamping respects derived arm reach and leaves the inactive target at
its captured rest position;
19. arm auto-motion changes only the active hand and Reset Arm restores both rest targets;
20. FORWARD/RIGHT/LEFT use the same editor transform solver; LEFT reflection lives on
`PreviewRigRoot`, the rig's local scale stays positive, and arm IK remains enabled;
21. selecting `secondary_grip` previews it with exactly one hand and never moves the other;
22. frame-mode/reset/resize recalculate camera framing, while arm-motion ticks do not change
fitted zoom.
### 9.2 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`. Because the editor-only preview instantiates
the existing rig without changing its runtime contracts, also run the rig modularization suite
as a read-only compatibility regression. At minimum:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase8_rig_modularization.gd`
No Sandbox Stage suite is part of Phase 9a validation because this phase does not modify or
integrate with Sandbox/runtime systems.
### 9.3 Static checks
Run Godot 4.7.1 `--headless --check-only` for every changed/new script, especially:
- `prop_anchor_data.gd`
- `prop_anchor_overlay.gd`
- `prop_attach_preview.gd`
- `prop_file.gd`
- `prop_canvas.gd`
- `prop_editor.gd`
- the new test suite
If new `class_name` entries are not in the global script cache, run one headless editor rescan
before per-script checks, per technical-debt item #22.
## 10. Manual acceptance checklist
### Prop Editor (F6 `res://scenes/prop_editor.tscn`)
- Existing Arrange/Reshape behavior and labels are unchanged; the appended Attach button renders
and all three selected states remain visible.
- Attach mode shows anchors while hiding every shape selection/vertex/transform gizmo.
- Pan, wheel zoom, pinch, and Reset View remain correct.
- Add/rename/delete work; origin controls are disabled and origin cannot be dragged.
- Anchor translation stays under the cursor at multiple zoom levels and snaps from either Snap
checkbox.
- Grip/emitter rotation ring works; Ctrl snaps 15 degrees.
- Inactive toggle and role marker colors/icons are legible over light and dark props.
- Opening the Stickman Preview shows the real default rig visibly holding the complete multi-shape
prop at the selected grip; selecting a non-grip shows the instructional empty state.
- Left/Right hand choice obeys `allowed_hands`; any selected secondary grip is previewed as a
one-hand grip and the opposite hand stays at rest.
- Dragging the cyan target visibly bends the actual IK arm and keeps the prop locked to the solved
wrist; Animate Arm sweeps smoothly, manual drag cancels it, and Reset Arm restores the pose.
- Forward/Right/Left preview facing catches mirrored anchor offsets without modifying document or
any state outside the private preview.
- Upper Body / Full Figure framing, preview wheel zoom, middle pan, Reset View, split resize, and
show/hide work without affecting the authoring canvas camera.
- Preview z offset visibly swaps the prop behind/in front according to the editor preview
convention.
- Inspector scrolls at a small window size.
- Save/load and crash-recovery preserve anchors exactly; legacy props open with origin.
- Quick presets match horizontal and vertical prop bounds.
## 11. Architectural trade-offs
| Decision | Benefit | Cost / follow-up |
| --- | --- | --- |
| Additive `.prop` v1.0 anchors | Old files remain valid; no migration branch | Version alone cannot advertise anchor capability; consumers query presence/defaults |
| Fixed origin pivot | Stable coordinates and no geometry/COM migration | User cannot choose a custom physics pivot in this phase |
| JSON dictionary + static codec | Matches current project and is easy to inspect | GDScript cannot statically type dictionary fields; normalization boundary is essential |
| Composed anchor helper | Keeps anchor state/hit math out of the already large canvas | Canvas remains the input/coordinate owner and must route Attach events explicitly |
| Isolated rig-backed preview | Shows real IK bending, facing, custom stickman visuals, and the full authored prop | Adds a private SubViewport/rig instance and requires strict disabling of gameplay processing/collision |
| One-hand-at-a-time preview | Keeps anchor evaluation focused and matches the approved authoring scope | It does not solve or demonstrate two-hand constraints |
| Derived visual wrist, no sockets | Reuses the fitted rig while avoiding any rig scene/runtime contract change | Depends on the lower-arm visual's established hanging-frame convention and fitted lower-bone length |
| Editor-only facing convention | Exposes mirrored offset mistakes during authoring without touching gameplay state | A future runtime design must validate facing and draw order independently |
| Full multi-shape preview | Faithfully shows the authored prop inside the editor | It deliberately does not prove how a future runtime representation will render or collide |
| No anchor-only undo | Consistent editor expectations | Full shape + anchor document undo remains future work |
## 12. Implementation order
1. `PropAnchorData` and schema tests.
2. `PropFile` round-trip and legacy normalization.
3. Append Attach to the existing mode system; add overlay math/drawing, inspector, presets, and
save/autosave integration while preserving Arrange/Reshape.
4. Add the isolated rig-backed `PropAttachPreview`, shared editor transform solver, derived wrist
transform, IK interaction, and camera/layout behavior without changing rig/runtime files.
5. Complete save/autosave/recovery integration, the editor-only headless suite, static checks,
and manual hand-off.
Implementation must stop after engine validation and ask the user to verify in Godot. README or
other product documentation updates wait until the user confirms the feature works.
+651
View File
@@ -0,0 +1,651 @@
# Phase 9b — Prop Origin, Centering, and Off-Origin Save Guard
## 1. Status and scope
This specification is the approved design for a focused Prop Editor follow-up to Phase 9a. It
does not implement Sandbox Stage or runtime prop behavior.
Phase 9b makes the canonical prop origin useful without presenting it as a fake-editable anchor:
- hide `origin` from the editable anchor list and all anchor interaction;
- retain and serialize the canonical `origin` at `(0,0)` for schema compatibility;
- draw a subtle, non-interactive `(0,0)` marker in Arrange, Reshape, and Attach;
- add an always-visible `🎯 Center Prop` command beside Grid/Snap;
- center the complete authored document by translating every shape and every non-origin anchor by
the negative union-bounds center;
- focus the authoring camera on `(0,0)` after centering without changing zoom;
- center `(0,0)` in the authoring view on startup, New, load/recovery view reset, and explicit
`Reset View`;
- warn during explicit saves when a non-empty prop is unusually far from the origin;
- never recenter automatically after ordinary shape or anchor edits.
The existing `.prop` file version remains `1.0`. No file migration, Sandbox integration, runtime
attachment, physics-center calculation, or undo/redo system is part of Phase 9b.
## 2. Approved decisions
1. The command is an always-visible `🎯 Center Prop` button in `TopRightControls`, immediately
after the existing Snap control. Its tooltip is `Move the whole prop and its anchors to (0,0).`
2. Centering focuses `(0,0)` at the canvas center while preserving the current zoom. Startup,
New, all existing document-load view resets, and `View → Reset View` put `(0,0)` at the canvas
center; Reset View also restores zoom to `1.0`.
3. An explicit save warns when:
```text
distance(bounds center, origin) > max(1024 px, 2 × bounds diagonal)
```
The warning offers `🎯 Center & Save`, `💾 Save Anyway`, and `❌ Cancel`. Autosave is silent.
4. Centering is opt-in only. Adding, moving, reshaping, loading, and saving never silently alter
document coordinates.
5. The centering point is the axis-aligned union-bounds center of displayed shape geometry, not a
polygon centroid, visual-stroke center, center of mass, or physics pivot.
6. There is no Phase 9b-only undo stack. The command is one explicit document mutation and uses
the existing dirty/autosave/recovery pipeline.
## 3. Existing contracts preserved
- `PropEditor` remains the document coordinator and owns `_anchors`, dirty state, autosave, save
dialogs, and the holding-preview synchronization.
- `PropCanvas` continues to own internal typed shape data, selection, pan/zoom, shape drawing, and
shape mutation.
- `PropAnchorOverlay` remains responsible only for editable non-origin anchor markers and input.
- `PropAnchorData` remains the normalization and JSON-safe anchor helper.
- `PropFile` continues to serialize normalized anchors, including canonical `origin`, and to load
legacy files without an `anchors` section by synthesizing it.
- Arrange, Reshape, and Attach mode behavior is otherwise unchanged.
- Existing grid/snap state does not affect centering. Centering uses the exact bounds-center delta.
- The holding preview stays editor-only. No Sandbox/runtime file or class may be modified.
## 4. Target files and typed contracts
### 4.1 `scripts/prop_anchor_data.gd` — `class_name PropAnchorData`
Add pure static helpers so list filtering, selection fallback, and document translation share one
normalization boundary:
```gdscript
static func editable_ids(anchors: Dictionary) -> Array[String]
static func preferred_editable_id(
anchors: Dictionary,
preferred_id: String = ""
) -> String
static func translated_non_origin(
anchors: Dictionary,
delta: Vector2
) -> Dictionary
```
Contract:
- `editable_ids()` returns normalized IDs except `ORIGIN_ID`, preserving normalized dictionary
iteration order.
- `preferred_editable_id()` returns a valid non-origin `preferred_id` first; otherwise
`primary_grip`, the first grip, the first remaining editable ID, or `""`.
- `translated_non_origin()` returns a deep normalized copy. It adds `delta` only to each
non-origin `position`; roles, rotation, Z offset, and hand compatibility are unchanged. The
returned `origin` is canonical and exactly `Vector2.ZERO`.
- Non-finite deltas are rejected by returning the normalized input unchanged and warning once per
call site or by an equivalent finite guard. UI paths must never supply such a delta.
`default_anchors()`, `normalize_anchors()`, and `serialize_anchors()` keep their existing public
behavior. The origin remains stored because it is the explicit local-coordinate contract; hiding
it is an editor presentation decision.
### 4.2 `scripts/prop_anchor_overlay.gd` — `class_name PropAnchorOverlay`
Change the overlay's selection contract:
```gdscript
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
```
- `_selected_id` defaults to `""`, not `origin`.
- `select_anchor("")` clears selection and emits `anchor_selected("")` only when the selection
changes.
- `select_anchor("origin")` must not make origin editable; it clears or ignores selection.
- `set_anchor_data()` preserves a valid selected non-origin ID. If it disappears, it clears the
selection rather than falling back to origin.
- `hit_test_anchor()`, `_visible()`, `begin_pointer()`, and `draw_gizmos()` always exclude origin.
- Existing marker drawing, translation, rotation, snapping, and
`anchors_changed(anchors: Dictionary)` payloads remain unchanged for editable anchors.
The universal origin marker does not move into this class because it is not an anchor tool.
### 4.3 `scripts/prop_canvas.gd` — `class_name PropCanvas`
Add or tighten these public APIs:
```gdscript
func has_shape_geometry() -> bool
func get_shapes_bounds() -> Rect2
func translate_all_shapes(delta: Vector2) -> bool
func focus_world_point(world_point: Vector2) -> void
func set_origin_marker_color(color: Color) -> void
func reset_view() -> void
```
#### Geometry presence and bounds
`has_shape_geometry()` is true when at least one shape has at least two finite internal points and
is therefore drawable by the current canvas.
`get_shapes_bounds()` returns the axis-aligned union of valid, drawable internal point geometry:
| Shape | Bounds source |
| --- | --- |
| Polygon | All stored vertices |
| Line | All stored endpoints/vertices |
| Circle | Its stored/generated perimeter points |
Every shape imported by `set_shape_data()` already passes through `_coerce_shape()`; a legacy or
external circle with radius but no points receives generated perimeter points there. Bounds must
skip empty/non-drawable point sets rather than treating `Rect2()` as geometry at the origin. The
union excludes outline width. This makes centering match the geometry the author manipulates and
prevents a malformed empty shape from pulling a distant prop's center toward zero.
For no drawable geometry, `get_shapes_bounds()` returns `Rect2()` and callers must consult
`has_shape_geometry()` before interpreting it.
#### Bulk shape translation
`translate_all_shapes(delta)`:
- returns `false` for no drawable geometry, non-finite delta, or an approximately zero delta;
- adds `delta` to every point in every shape with points;
- does not snap, sanitize, reverse winding, regenerate primitives, or change Z order;
- preserves `_selected_shape_idx`, canvas mode, view state, circle `radius`, `reshaped`,
`vertex_flags`, fill/outline fields, and `closed`;
- queues one redraw and emits exactly one `shape_changed(get_shape_data())` after success;
- returns `true` after a successful mutation.
The controller updates its anchors before calling this API. The existing synchronous shape signal
then becomes the one dirty/autosave notification for the complete document transaction.
#### Camera behavior
`focus_world_point(world_point)` preserves `_zoom` and derives:
```gdscript
_pan_offset = size * 0.5 - world_point * _zoom
```
`reset_view()` sets `_zoom = 1.0`, focuses `Vector2.ZERO`, and redraws. `clear()` and the existing
shape-data load reset route use this revised Reset View behavior. Startup calls Reset View after
the canvas has a valid arranged size (deferred by one frame only if its size is still zero).
Window resize does not continually recenter the camera. Once initialized, pan/zoom remains tool
state until New/load/reset/center or direct navigation changes it.
#### Origin marker
`_on_canvas_draw()` draws the marker in every `EditMode`, independent of shape or anchor state.
The existing empty-canvas hint must no longer return before marker drawing.
The marker is a small halo-backed cross/ring plus `(0,0)` label at world `Vector2.ZERO`:
- core radius/cross half-size: approximately `6` screen px;
- core width: `1–1.5` screen px;
- dark halo: approximately `2` screen px wider;
- label offset: approximately `(10, -8)` screen px;
- label size: approximately `12` screen px;
- all world dimensions, offsets, widths, and font size account for `_zoom`, keeping screen size
effectively constant over `[0.3, 3.0]`;
- core color comes from `set_origin_marker_color()`, initialized by `PropEditor` from
`PropTheme.text_muted_color()` with a subtle alpha;
- drawing occurs after ordinary shape geometry and before active transform/anchor gizmos;
- there is no marker hit test, `Control` child, signal, mouse filter, or drag path.
### 4.4 `scripts/prop_editor.gd` — `extends Control`
Add constants and state with explicit types:
```gdscript
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
var _center_prop_button: Button
var _off_origin_save_dialog: ConfirmationDialog
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
```
Continue using `_pending_quit` and `_pending_new` as the post-save completion intent. Do not add a
second conflicting completion enum unless implementation proves it makes those existing flows
simpler; there must still be one authoritative pending completion state.
Add focused helpers:
```gdscript
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool
func _center_prop_on_origin(focus_view: bool = true) -> bool
func _refresh_center_prop_button() -> void
func _begin_save_request(path: String, with_thumbnail: bool) -> void
func _execute_pending_save(center_first: bool) -> void
func _cancel_pending_save_request() -> void
```
`should_warn_off_origin()` uses a strict `>` comparison:
```gdscript
if not has_geometry:
return false
var center_distance: float = bounds.get_center().length()
var threshold: float = maxf(
OFF_ORIGIN_WARNING_MIN_DISTANCE,
OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length()
)
return center_distance > threshold
```
### 4.5 `scripts/prop_file.gd` — `class_name PropFile`
No schema or API change is required. Tests must prove that:
- `to_file_dict()` still serializes canonical origin plus every editable anchor;
- `from_file_dict()` still synthesizes origin for legacy `.prop` v1.0 files without anchors;
- centered coordinate values round-trip without changing radius, flags, or anchor metadata.
### 4.6 `scenes/prop_editor.tscn` and theme
No new image asset is needed. The button and warning dialog are built by `PropEditor`, matching
existing dynamic UI construction.
- `🎯 Center Prop` is added after Snap in `%TopRightControls`.
- The button uses the local `_ui_theme`, `button_min_height()`, full-rectangle mouse filtering,
and a concise tooltip. It stays visible in all modes.
- It is disabled when there is no drawable geometry or the bounds center is approximately zero;
it remains visible so the feature is discoverable.
- Refresh enabled state after New, load/recovery, shape change, and successful center.
- The save-warning dialog uses the same local theme. It must not mutate the global GUI theme.
- If minimum window width becomes tight, the existing adjustable Inspector remains the first
source of reclaimed space. Phase 9b must not let the new button force the Inspector offscreen.
## 5. Document centering transaction
### 5.1 Exact state transformation
Given:
```gdscript
bounds = Rect2(Vector2(400, 200), Vector2(200, 100))
center = Vector2(500, 250)
delta = Vector2(-500, -250)
```
The command applies:
```text
shape point (430, 215) → (-70, -35)
primary_grip (510, 245) → ( 10, -5)
emitter (590, 250) → ( 90, 0)
origin (0, 0) → ( 0, 0)
```
All relative vectors are preserved:
```text
(emitter - primary_grip) before == (emitter - primary_grip) after
(shape point - primary_grip) before == (shape point - primary_grip) after
```
### 5.2 Controller sequence
```mermaid
flowchart TD
Click[Center Prop / Center & Save] --> HasGeometry{Drawable geometry?}
HasGeometry -- No --> NoOp[Return false; no dirty state]
HasGeometry -- Yes --> Bounds[Read union bounds once]
Bounds --> Delta[delta = -bounds.center]
Delta --> Meaningful{Meaningful finite delta?}
Meaningful -- No --> Focus[Focus origin if requested]
Meaningful -- Yes --> Anchors[Translate normalized non-origin anchors]
Anchors --> SetAnchors[Set editor + canvas anchor state]
SetAnchors --> Shapes[Canvas translates all points; emits once]
Shapes --> Dirty[Existing handler marks dirty + arms autosave]
Dirty --> Refresh[Refresh inspector, button, and holding preview once]
Refresh --> Focus
```
`_center_prop_on_origin()` must read bounds once before any mutation. It must not calculate a new
center after translating shapes or anchors.
The operation preserves:
- shape and editable-anchor selection;
- shape/anchor ordering;
- active mode;
- current zoom;
- clipboard;
- grid/snap settings;
- material and prop name;
- preview hand/facing/animation preferences.
If the old selection is missing or is legacy `origin`, normalize it through
`preferred_editable_id()`. With no editable anchors, select `""` and let the holding preview show
its existing “select a grip” state.
### 5.3 Dirty, autosave, and preview behavior
- A successful non-zero center produces one logical dirty transition through the single
`shape_changed` emission.
- `_autosave_remaining` is armed once with the existing debounce.
- The editor refreshes the shape inspector and anchor inspector after both halves of the
transaction are in place.
- `_sync_attach_preview()` runs once after shapes and anchors match. In Attach mode, the held prop
must remain on the same grip and merely receive recentered local coordinates.
- A zero-delta or empty command does not mark dirty or write autosave.
- Centering does not immediately write the `.prop`; Save remains explicit.
## 6. Origin visibility and anchor-list behavior
### 6.1 Inspector population
`_refresh_anchor_inspector()` populates `%AnchorList` only from
`PropAnchorData.editable_ids(_anchors)`. It must never infer selection by reading item text for an
origin row because that row no longer exists.
When `_selected_anchor_id == ""`:
- no item is selected;
- Role, Allowed Hands, X, Y, Rotation, and Z Offset are disabled;
- Rename and Delete are disabled;
- a muted hint reads `Add or select an anchor.`;
- Add Anchor and preset buttons remain enabled.
The old disabled-origin tooltip and disabled-origin detail state are removed from the inspector.
The canvas `(0,0)` marker replaces their educational purpose.
### 6.2 Selection transitions
| Transition | Resulting editable selection |
| --- | --- |
| New/empty document | `""` |
| Load/recovery | Preserve valid preferred ID if supplied; otherwise `primary_grip`, first grip, first editable, or `""` |
| One-/Two-Handed preset | `primary_grip` |
| Tool/Nozzle or Melee preset | `primary_grip` |
| Add Anchor | New anchor ID |
| Rename | Renamed ID |
| Delete | Previous visible row, else next visible row, else `""` |
| Center Prop | Preserve current valid editable ID; otherwise preferred fallback |
The holding preview receives `""` when nothing editable is selected. It must never receive origin
as the active attachment anchor.
## 7. Save preflight and pending-state flow
### 7.1 Entry points
Every user-requested save routes through `_begin_save_request(path, with_thumbnail)`:
- File → Save with an existing path;
- Save As after the file dialog returns and the `.prop` extension is normalized;
- save-before-quit;
- save-before-New.
`_save_to()` remains the low-level writer and never opens the warning itself. `_write_autosave()`
continues calling `PropFile.save_safe()` directly and never warns or centers.
### 7.2 Warning dialog
The dialog text must state both the condition and the non-destructive options, for example:
```text
This prop is far from (0,0). Centering it keeps its shapes and anchors together.
What would you like to do?
```
Buttons:
- OK: `🎯 Center & Save`
- custom action `save_anyway`: `💾 Save Anyway`
- Cancel: `❌ Cancel`
Flow:
```mermaid
stateDiagram-v2
[*] --> SaveRequest
SaveRequest --> SaveDirect: empty or within threshold
SaveRequest --> Warning: unusually far
Warning --> Center: Center & Save
Center --> SaveDirect: one center transaction
Warning --> SaveDirect: Save Anyway
Warning --> Editor: Cancel
SaveDirect --> Editor: save failed
SaveDirect --> Editor: normal save succeeded
SaveDirect --> Quit: pending quit + success
SaveDirect --> NewDocument: pending New + success
```
Rules:
- `_pending_save_path` and `_pending_save_with_thumbnail` are set before the warning opens.
- Center & Save invokes `_center_prop_on_origin(true)` and then calls `_save_to()` directly so the
same request cannot warn a second time.
- Save Anyway calls `_save_to()` without coordinate mutation.
- Cancel clears the pending save payload and clears `_pending_quit`/`_pending_new`, returning to
the editor.
- A failed save reports the existing error and clears all pending completion state so a later
unrelated save cannot unexpectedly quit or create a new document.
- A successful ordinary save clears the pending save payload normally.
- A successful pending quit exits; a successful pending New invokes `_do_new()`.
- Warning evaluation uses the same corrected bounds helper as Center Prop and presets. Empty props
do not warn.
- Choosing Save Anyway is scoped to one request. A later explicit save evaluates the rule again.
- Save warning state is tool state and is not serialized.
## 8. Layout and drawing schematic
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📁 File 👁 View Grid [15] Snap [🎯 Center Prop] │
├─────────────────────────────────────────────────────────────────────────────┤
│ Prop Name [...............................................................] │
├───────────────────────────────────────────────────────┬─────────────────────┤
│ [🖐 Arrange] [✏ Reshape] [📌 Attach] │ Inspector │
│ │ │
│ authoring canvas │ Attach list excludes│
│ │ canonical origin │
│ ──○── (0,0) ← passive marker │ │
│ │ │
│ shapes / selection / editable anchor gizmos │ │
└───────────────────────────────────────────────────────┴─────────────────────┘
```
Drawing order inside `PropCanvas`:
```text
grid
→ empty-canvas hint (when applicable)
→ shape geometry and existing selection rendering
→ passive origin marker
→ Arrange transform gizmos OR Attach editable-anchor gizmos
```
The origin marker is never part of the input-routing diagram.
## 9. Signal and state flow
No new public signal is required.
```mermaid
flowchart LR
Button[Center Prop] --> Editor[PropEditor transaction]
Editor --> AnchorData[PropAnchorData.translated_non_origin]
Editor -->|set_anchor_data| Canvas[PropCanvas]
Editor -->|translate_all_shapes| Canvas
Canvas -->|shape_changed: Array Dictionary| Editor
Editor --> Dirty[dirty + autosave debounce]
Editor --> Inspector[refresh inspector]
Editor --> Preview[set_document shapes + anchors + selection]
Editor --> Camera[focus_world_point 0,0]
```
Existing signal payloads remain:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal shape_selected(idx: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
`anchor_selected("")` is now a valid “no editable anchor selected” payload.
## 10. Edge cases
- **Empty prop:** marker is visible; Center Prop is disabled; save never warns; origin still saves.
- **Only malformed/empty shapes:** treated as no drawable geometry for centering and warning.
- **Single line:** endpoints participate in bounds; their midpoint becomes `(0,0)` after center.
- **Circle:** translate perimeter points only; preserve radius and `reshaped`; no regeneration.
- **Mixed shapes:** compute one union before translating anything, regardless of Z order.
- **Very large prop:** relative threshold prevents an automatic warning merely because its center
is moderately offset compared with its own diagonal.
- **Already centered:** command is disabled/no-op and does not dirty.
- **No custom anchors:** shapes center normally; origin stays fixed; selection remains empty.
- **Legacy file without anchors:** load synthesizes origin, hides it from the list, and exposes the
passive marker; no migration prompt.
- **Legacy selection of origin:** immediately resolves through editable fallback or `""`.
- **Anchor far outside geometry:** it receives the same delta as geometry; it is deliberately not
included in bounds calculation because shapes define the prop's authored center.
- **Snap enabled:** ignored by the bulk translation; exact centering wins over grid alignment.
- **Save during pending quit/New:** warning completion follows the original intent only after
successful save; Cancel returns safely to the current document.
- **Autosave:** writes current coordinates without warning or centering.
- **Pan/zoom:** normal edits never recenter. Center preserves zoom and focuses origin. Reset View
restores zoom `1.0` and focuses origin.
## 11. Automated validation
Add `tests/test_phase9b_prop_centering.gd` as a headless `SceneTree` suite. It must assert values,
not only counts.
### 11.1 Data helpers
1. `editable_ids()` never contains origin.
2. Preferred selection honors a valid current ID, then primary grip, first grip, first editable,
then empty.
3. `translated_non_origin()` preserves all non-position fields and fixes origin at exact zero.
4. Legacy missing-anchor data still normalizes to canonical origin.
5. Serialization still writes canonical origin and `.prop` version remains `1.0`.
### 11.2 Bounds and translation
6. Polygon, line, and circle points all contribute to one expected union AABB.
7. Empty/malformed point sets do not merge an artificial `(0,0)` into distant bounds.
8. Center delta is the exact negative bounds center.
9. Every exported point value changes by exactly delta.
10. Vertex flags, radius, `reshaped`, colors, closed state, and shape order remain unchanged.
11. Shape selection, mode, and zoom remain unchanged by translation.
12. One successful translation emits exactly one `shape_changed`; empty/zero translations emit
none and return false.
13. Grid snap does not alter the center delta.
### 11.3 Editor integration
14. Anchor ItemList excludes origin and its first empty state has no selection.
15. Add/preset/load/delete transitions follow the selection table in §6.2.
16. Overlay hit-testing at `(0,0)` cannot select origin, even with inactive gizmos shown.
17. Centering updates shapes and non-origin anchors together while origin stays zero.
18. Centering marks dirty once, arms autosave, refreshes preview document data, and keeps a valid
editable anchor selected.
19. Empty/already-centered commands do not mark dirty.
20. Centering focuses origin while preserving zoom.
21. startup/New/load/reset place origin at canvas center; Reset View uses zoom `1.0`.
22. Center button is present in all modes and disabled only for empty/already-centered geometry.
### 11.4 Save preflight
23. `should_warn_off_origin(false, Rect2())` is false.
24. Boundary equality does not warn; values just beyond the computed threshold do.
25. The `1024` floor and `2 × diagonal` relative branch are both exercised.
26. Save Anyway does not alter points or anchors.
27. Center & Save applies one center transaction and does not re-open the warning.
28. Cancel clears save/quit/New pending state without writing.
29. Autosave remains warning-free and preserves off-origin coordinates.
30. Successful and failed save completion cannot leave stale pending state.
### 11.5 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`, especially:
- `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`
- `tests/test_phase9a_prop_attach.gd`
Update Phase 9a expectations that currently select or expose origin in the inspector/overlay. Do
not weaken its schema assertions: origin remains canonical and serialized.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 12. Manual acceptance checklist
Launch `res://scenes/prop_editor.tscn` with F6 and verify:
1. `(0,0)` starts centered and is shown as a subtle fixed-size marker.
2. The marker remains visible at its world location in Arrange, Reshape, and Attach.
3. It stays approximately the same screen size through min/max zoom and never highlights,
selects, or drags.
4. Reset View restores zoom and places origin in the canvas center.
5. `🎯 Center Prop` remains visible beside Grid/Snap in all modes and its complete rectangle is
clickable.
6. A prop authored far away moves so the union AABB center is exactly on the marker.
7. After centering, current zoom is unchanged and the marker/prop are centered in the view.
8. Mixed polygon, line, and circle props center together without deformation or Z-order change.
9. Every non-origin anchor moves with the prop; rotations, Z offsets, roles, and allowed hands do
not change.
10. A selected grip stays selected and the holding preview continues showing the same prop-to-hand
alignment before and after centering.
11. Origin is absent from the anchor list and cannot be clicked in Attach mode.
12. A new prop with no anchors shows the passive marker and `Add or select an anchor.` state.
13. Presets select `primary_grip`; deleting the final editable anchor leaves no selection.
14. Empty and already-centered documents keep the Center button visible but disabled.
15. Ordinary move/reshape/anchor edits never auto-center the document.
16. A moderately offset or large intentional prop inside the approved threshold saves normally.
17. A small prop beyond the threshold opens the three-choice warning.
18. Center & Save recenters, focuses, and saves; Save Anyway retains original coordinates;
Cancel writes nothing.
19. Save-before-quit and save-before-New honor the same warning and completion behavior.
20. Autosave/recovery never opens the warning and preserves the current coordinates.
21. Save/load round-trips centered points, flags, circle state, and anchors exactly.
22. A legacy `.prop` without anchors opens without migration UI, hides synthesized origin from the
list, and still displays the passive marker.
## 13. Architectural trade-offs and deferred items
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Keep serialized fixed origin but hide it | Stable explicit file contract without fake editing UX | A future movable/custom pivot requires a separately versioned design |
| AABB center from internal points | Deterministic across polygon/line/circle and matches canvas geometry | Not a centroid, painted-outline center, or physical center of mass |
| Controller-coordinated transaction | Preserves current shape/anchor ownership and produces one dirty event | Requires disciplined ordering between anchor update and one bulk canvas mutation |
| Passive canvas drawing | Constant-size, allocation-free, and incapable of intercepting input | Visual behavior needs manual acceptance because headless pixel capture is unreliable |
| Heuristic explicit-save warning | Catches accidental remote authoring without blocking autosave | Intentional off-origin props are asked again on each explicit save |
| Focus origin after center | The centered prop remains visible and authoring around the pivot is natural | Center command intentionally changes camera pan, though not zoom |
| No center-specific undo | Avoids an inconsistent partial undo system | Users cannot reverse centering through Ctrl+Z until document-wide undo exists |
Deferred beyond Phase 9b:
- configurable/movable pivots or multiple origins;
- center of mass, collision-based bounds, or material-weighted centering;
- outline-width-aware visual centering;
- persistent “do not warn again” per asset;
- document-wide undo/redo;
- Sandbox/runtime consumption of origin or anchors;
- automatic recenter-on-edit behavior (explicitly rejected).
## 14. Implementation order and pipeline stop
1. Add/test pure anchor helpers and remove origin from overlay interaction.
2. Correct bounds, add bulk translation/focus APIs, and draw the universal marker.
3. Add editor selection fallback, inspector empty state, Center button, and atomic transaction.
4. Route explicit save entry points through the warning/pending-state flow.
5. Add Phase 9b tests, update Phase 9a expectations, run all regressions and static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. Documentation changes happen
only after the user confirms Phase 9b is verified and working, per the spec-pipeline.
+638
View File
@@ -0,0 +1,638 @@
# Phase 9c — Prop Editor UX and Walking Hold Preview
Status: **approved specification — ready for implementation** (2026-09-21).
Phase 9c is a Prop Editor-only refinement discovered during Phase 9a/9b user verification. It
does not amend their saved-file contracts and does not add Sandbox Stage or gameplay behavior.
## 1. Approved decisions
The user approved these interaction choices:
1. Replace the preview's separate Facing/Frame controls with one **Preview Motion** selector:
`Front Pose`, `Walk Left`, and `Walk Right`, plus a dynamic `Pause Walk` / `Resume Walk`
button.
2. Rename anchors through a modal, focused text-entry dialog. The current automatic
`old_name_renamed` behavior is removed.
3. Rename `Show Inactive Gizmos` to **Show Other Markers**. When off, it hides the passive
origin marker and all unselected anchor markers while the selected editable anchor remains
visible.
This work is named Phase 9c because Phase 9a and 9b are already implemented and verified. It
changes preview motion ownership and established UI contracts rather than retroactively changing
those completed specifications.
## 2. Complete requested refinement set
| # | Requirement | Phase 9c contract |
| --- | --- | --- |
| 1 | Rename accepts a name | Open a modal LineEdit dialog, normalize the entered value, preserve the anchor payload, and select the renamed anchor. |
| 2 | Remove `(0,0)` copy | Keep only the small passive ring/cross at the canonical origin. |
| 3 | Inspector background `#262c39` | Add `ui.colors.inspector_background` to `prop_theme.json` and a validated accessor. |
| 4 | No add-shape hint in Reshape | The empty-canvas right-click hint is Arrange-only. |
| 5 | Clear hand names | Preview items read `Left Hand` and `Right Hand`. |
| 6 | Remove ineffective frame choice | Remove Upper Body/Full Figure and always frame the complete rig plus visible prop. |
| 7 | Replace synthetic arm animation | Preview Front Pose, Walk Left, and Walk Right; the old control becomes Pause/Resume Walk. |
| 8 | Preserve manual arm IK | The cyan target remains draggable; starting a drag pauses gait playback first. |
| 9 | Hide Material in Attach | Group the full Material UI and hide it only in Attach. |
| 10 | Remove duplicate Snap Anchors | Top-row Snap is the sole snap control and source of truth. |
| 11 | Preview checked on Attach entry | Every transition into Attach checks and shows the preview; the user may hide it for that Attach session. |
| 12 | Make marker visibility meaningful | Show Other Markers controls passive origin plus unselected anchor gizmos. |
| 13 | Fix clipped Grid/Snap | Use container-driven top rows, themed minimum heights, and vertical margins. |
| 14 | Remove preview blue border | Retain the dark preview surface but use no colored border. |
## 3. Scope and preserved contracts
### 3.1 In scope
- `PropEditor`, `PropCanvas`, `PropAttachPreview`, Prop Editor scene layout, and PropTheme UI
presentation.
- Preview-only use of the existing `stickman_rig.tscn` animation and IK nodes.
- Regression tests for all new UI state and preview ownership behavior.
### 3.2 Explicitly out of scope
- Any Sandbox Stage, `PropBlock`, spawner, runtime equip/release, physics, collision, terrain,
director, or action-queue change.
- Navigation or actual root translation in the holding preview. Walking is an in-place visual
gait check.
- A new animation asset, animation blending layer, or simultaneous animation/manual ownership
of the same hand IK target.
- `.prop` schema/version changes. Anchor IDs remain the persisted names; no separate display-name
field is added.
- Persistent UI preferences for preview motion, playback, marker visibility, or preview
visibility.
- Document-wide undo/redo, already tracked by debt item #37.
### 3.3 Saved-document behavior
Only a successful anchor rename mutates document data. It marks dirty once, arms the existing
autosave debounce once, refreshes the inspector/canvas/preview, and is included automatically in
ordinary save, Save As, autosave, recovery, and load round trips.
Preview motion, paused state, chosen preview hand, Show Stickman Preview, and Show Other Markers
are session-only tool state. Changing them must not mark dirty, arm autosave, or alter serialized
shape/anchor data. Hiding the Material section never changes the current material preset.
## 4. Existing rig and animation constraints
`stickman_rig.tscn` contains looped `walk_left` and `walk_right` clips. The actual
`StickmanLocomotion.walk_to()` path selects a LEFT/RIGHT facing profile and plays `walk_right`
for both directions. LEFT is represented through the rig's facing/mirroring policy, not by
playing `walk_left` during runtime locomotion.
Phase 9c must follow that proven runtime gait rule:
- `Walk Right`: RIGHT facing, positive preview wrapper, play `walk_right`.
- `Walk Left`: LEFT facing, keep the solving skeleton positive, mirror `PreviewRigRoot`, play
`walk_right`.
- `Front Pose`: FORWARD facing, player stopped, authored standing targets restored.
The existing `walk_left` asset is not deleted or modified. The preview must not call
`StickmanRig.walk_to()`: doing so would introduce NavigationServer/map dependencies and unwanted
root translation. It directly owns only its private AnimationPlayer.
The existing isolation contract remains mandatory:
- rig physics processing disabled;
- action queue stopped and cleared;
- no terrain following or navigation request;
- all collision layers/masks disabled;
- preview prop contains visual CanvasItems only;
- no reference to Sandbox Stage state.
## 5. Target files and typed contracts
| File | Required change |
| --- | --- |
| `scripts/prop_editor.gd` | Rename dialog; mode visibility; one snap control; Attach-entry preview default; marker toggle routing; theme/layout application. |
| `scripts/prop_canvas.gd` | Arrange-only empty hint; origin visibility API; remove origin text. |
| `scripts/prop_attach_preview.gd` | Replace synthetic motion and Frame/Facing UI with the approved motion state machine; preserve manual IK and wrist attachment. |
| `scripts/prop_theme.gd` | Add validated inspector background accessor/fallback. |
| `prop_theme.json` | Add `ui.colors.inspector_background: "#262C39"`. |
| `scenes/prop_editor.tscn` | Container-driven editor chrome and responsive top-row margins/minimum sizing. |
| `scenes/prop_attach_preview.tscn` | No structural requirement beyond retaining the scripted PanelContainer shell. |
| `tests/test_phase9c_prop_editor_ux.gd` | New Phase 9c unit/integration suite. |
| Existing Prop Editor tests | Replace assertions for removed controls and add token/layout regressions where they naturally belong. |
No change is required in `prop_file.gd`, `prop_anchor_data.gd`, `stickman_rig.gd`,
`stickman_locomotion.gd`, or `stickman_rig.tscn` unless implementation uncovers a verified defect
independent of this feature. Such a discovery must be reported rather than folded into Phase 9c.
## 6. Node and layout design
Keep dialogs as direct children of the editor root. Put visible editor chrome under one
full-rect `VBoxContainer` so font and hit-target tokens determine row height instead of fixed
32 px offsets:
```text
PropEditor (Control)
├── EditorChrome (VBoxContainer, full rect)
│ ├── TopRow (HBoxContainer, min height >= button_min_height + margins)
│ │ ├── MenuBar (expand)
│ │ └── TopRightMargin (MarginContainer)
│ │ └── TopRightControls (HBoxContainer)
│ │ ├── Grid label
│ │ ├── Grid SpinBox
│ │ ├── Snap CheckBox
│ │ └── Center Prop Button
│ ├── TopBar (HBoxContainer, themed minimum height)
│ │ ├── Prop Name label
│ │ └── PropNameEdit
│ ├── MainLayout (HSplitContainer, expand/fill)
│ │ ├── CanvasPanel
│ │ │ └── CanvasArea
│ │ │ ├── ModeBar
│ │ │ ├── AttachTools (FlowContainer)
│ │ │ │ ├── Show Stickman Preview
│ │ │ │ └── Show Other Markers
│ │ │ └── CanvasWorkspace (HSplitContainer)
│ │ │ ├── PropCanvas
│ │ │ └── PropAttachPreview
│ │ └── InspectorColumn
│ │ └── InspectorScroll
│ │ └── InspectorList
│ │ ├── MaterialSection
│ │ ├── ShapeSection
│ │ ├── AddSection
│ │ └── AnchorSection
│ └── StatusBar
├── SaveDialog / LoadDialog / ErrorDialog
├── ExitDialog / RecoveryDialog / OffOriginSaveDialog
└── RenameAnchorDialog
```
Existing unique names remain unique so controller lookup continues through `%NodeName`.
Path-based tests must migrate to unique lookup or the revised hierarchy instead of forcing the
old fixed layout to remain.
`TopRightMargin` uses small vertical margins (nominally 4 px top and bottom). Grid SpinBox,
Snap CheckBox, Center Prop, and both top rows use at least `PropTheme.button_min_height()`. The
layout must remain unclipped at the committed 18–22 px font tokens and when a custom theme raises
those font sizes. Attach tools use a `FlowContainer` so the two longer kid-facing labels wrap
instead of extending outside a narrow canvas pane.
## 7. Theme and surface styling
Extend both `PropTheme.DEFAULT_CONFIG` and the committed JSON:
```json
{
"ui": {
"colors": {
"inspector_background": "#262C39"
}
}
}
```
Add:
```gdscript
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
```
The InspectorColumn panel style derives its background only from this accessor. Keep the subtle
theme border and split grabber so the adjustable inspector boundary remains legible.
`PropAttachPreview` retains its dark surface but sets all border widths to zero. It must not use
the accent color as a frame border. Pane separation is supplied by the split grabber and the
contrast between preview and inspector backgrounds.
## 8. Anchor rename workflow
### 8.1 Dialog contract
Add typed state:
```gdscript
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
```
`Rename` is enabled only for a selected editable non-origin anchor. Pressing it:
1. stores the selected ID in `_rename_anchor_source_id`;
2. fills the LineEdit with that ID;
3. opens the dialog;
4. defers focus and selects all text so typing replaces the old name.
The dialog title is `Rename Anchor`, confirmation text is `Rename`, and cancel closes without
mutation. The confirm button is disabled when the trimmed entry is empty.
### 8.2 Commit algorithm
On confirm:
```gdscript
var remaining := _anchors.duplicate(true)
var value := remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id := PropAnchorData.normalize_id(_rename_anchor_edit.text, remaining)
```
- Human input is normalized with the existing lowercase-snake-case rules.
- A collision receives the existing deterministic `_2`, `_3`, ... suffix.
- Renaming to the same normalized ID is a no-op: do not dirty or autosave.
- Otherwise insert the unchanged payload at `new_id`, normalize anchors once, replace selection,
update canvas selection, mark dirty once, refresh the inspector, and sync the preview once.
- Position, role, allowed hands, rotation, and Z offset must remain byte/value equivalent.
- Clear `_rename_anchor_source_id` on confirmed commit, cancel, hide, New, and load so a stale
dialog cannot rename another document's anchor.
No `display_name` field is introduced. The normalized ID displayed in the list is the saved ID.
## 9. Marker and canvas behavior
### 9.1 Passive origin marker
Add to `PropCanvas`:
```gdscript
var _show_origin_marker: bool = true
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
```
`_draw_origin_marker()` retains its constant-screen-size dark halo, ring, and cross, but removes
the font lookup and `(0,0)` `draw_string()` call. It remains non-interactive and is drawn at
world `Vector2.ZERO` in Arrange, Reshape, and Attach whenever `_show_origin_marker` is true.
### 9.2 Show Other Markers
The Attach toolbar contains one default-on `Show Other Markers` CheckBox. Its callback routes
the same value to:
```gdscript
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled)
```
The anchor overlay continues to draw and hit-test the selected editable anchor when false while
hiding every unselected editable anchor. The origin marker follows the setting in all modes,
including after leaving Attach. The setting is session-only and is not reset by switching modes,
New, load, or save.
### 9.3 Empty-canvas hint
Draw `Right-click to add a shape` only when all are true:
- `shapes.is_empty()`;
- mode is `EditMode.TRANSFORM` (Arrange);
- the canvas font is available.
Reshape and Attach show no add-shape hint. The origin marker is evaluated independently afterward.
## 10. Inspector and mode synchronization
Wrap the existing Material title, preset OptionButton, mass, friction, and bounce labels in
`_material_section: VBoxContainer`.
Mode visibility is authoritative in `_sync_mode_controls(mode)`:
| Section/control | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| MaterialSection | visible | visible | hidden |
| ShapeSection | selected shape only | selected shape only | hidden |
| AddSection / Erase Shape | visible | hidden | hidden |
| AnchorSection / AttachTools | hidden | hidden | visible |
| Holding Preview | hidden | hidden | visible when checked |
Delete `_snap_anchors_check`, `_on_snap_anchors_toggled()`, and the reverse synchronization from
`_on_top_snap_toggled()`. The existing top Snap CheckBox remains the only writer of
`PropCanvas.set_snap_enabled()`. Anchor drag already consumes the canvas's shared snap state and
grid size; this behavior must not be duplicated in the controller.
Track the last canvas mode. On a real transition from Arrange/Reshape into Attach:
```gdscript
_show_preview_check.set_pressed_no_signal(true)
```
then show/sync the preview. Toggling it off while already in Attach stays off until the user
leaves and re-enters Attach. Repeated inspector refreshes or document updates while remaining in
Attach must not force it back on.
## 11. Holding-preview UI
The dynamic preview hierarchy becomes:
```text
PropAttachPreview
└── VBoxContainer
├── Header (FlowContainer)
│ ├── Holding Preview title
│ ├── HandOption: Left Hand / Right Hand
│ └── MotionOption: Front Pose / Walk Left / Walk Right
├── HintLabel
├── SubViewportContainer
└── Footer (FlowContainer)
├── WalkPlaybackButton: Pause Walk / Resume Walk
├── Reset Arm
└── Reset View
```
Remove `_facing_option`, `_frame_option`, `FrameMode`, and `_animate_check`. The initial hand
remains Right Hand. The initial motion is Front Pose. The control hit rectangles continue to use
the full themed minimum height and `MOUSE_FILTER_STOP`.
The motion choice is a preview tool preference: `set_document()` does not reset it, so authors
can compare multiple props in the same walk. Opening a fresh editor instance begins at Front
Pose. New/load do not serialize or dirty this state.
## 12. Preview motion and IK ownership state machine
### 12.1 Typed state/API
```gdscript
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
var _walk_paused: bool = true
var _manual_arm_override: bool = false
var _hand_target_rests_local: Dictionary = {}
func set_preview_motion(motion: int) -> void
func set_walk_paused(paused: bool) -> void
func toggle_walk_playback() -> void
func reset_arm_pose() -> void
```
Standing hand target rest values are captured as each Marker2D's **local `position`**, never as
global positions. Animation tracks also write these local positions, so reset remains correct
under the LEFT preview-wrapper mirror.
### 12.2 State transitions
```mermaid
stateDiagram-v2
[*] --> FrontPose
FrontPose --> WalkPlaying: choose Walk Left / Walk Right
WalkPlaying --> WalkPaused: Pause Walk
WalkPlaying --> WalkPaused: begin cyan-target drag
WalkPaused --> WalkPlaying: Resume Walk
WalkPaused --> FrontPose: choose Front Pose
WalkPlaying --> FrontPose: choose Front Pose
WalkPlaying --> WalkPlaying: switch walk direction
WalkPaused --> WalkPlaying: switch walk direction (restart selected gait)
```
Exact transition behavior:
- **Front Pose entry:** stop AnimationPlayer with reset, set FORWARD profile/wrapper, restore all
standing target local positions, enable arm IK, set `_walk_paused = true`, clear manual
override, update marker/prop, and reframe.
- **Walk Left/Right entry:** clear manual override, apply the requested profile/wrapper, enable
arm IK, start `walk_right` from time `0.0`, set `_walk_paused = false`, update button copy,
and reframe. Changing between walk directions restarts at `0.0` for deterministic pose and
mirror state.
- **Pause:** call AnimationPlayer pause without resetting targets or playhead. The currently
solved pose freezes; button becomes `Resume Walk`.
- **Resume:** resume the current walk clip from its saved playhead, clear manual override, and
change the button to `Pause Walk`.
- **Manual drag begin:** if walking, pause first; then set manual override true and allow the
selected hand target to be clamped/dragged. Mouse release ends pointer capture but intentionally
leaves walking paused and preserves the authored inspection pose.
- **Reset Arm in Front:** restore both hand targets from local standing rests, clear manual
override, remain stopped.
- **Reset Arm during a paused walk:** re-seek/reapply the current animation playhead so the
dragged target returns to the frozen gait pose, pause again, clear manual override, and keep
the same direction/playhead.
- **Reset Arm during a playing walk:** first pause, then apply the paused-walk rule.
- **Hand selection/forced compatibility change:** keep the current motion direction and playback
state, clear any manual override by reapplying the current front/gait pose, switch the cyan
marker and prop binding, and do not restart a playing clip unless reapplication requires it.
`Pause Walk` is disabled in Front Pose and carries a tooltip such as `Choose Walk Left or Walk
Right to preview walking.` `Reset Arm` remains enabled when a compatible grip is visible.
### 12.3 Frame ownership and prop following
```mermaid
flowchart LR
Motion[Preview Motion selector] --> Player[Private AnimationPlayer]
Player --> Targets[IK target Marker2D positions]
Drag[Cyan target drag] -->|pauses player, then owns selected target| Targets
Targets --> IK[TwoBoneIK solve]
IK --> Wrist[Solved lower-arm visual endpoint]
Wrist --> Solve[PropAnchorData.solve_attachment_transform]
Solve --> Prop[Full multi-shape preview prop]
```
There is exactly one writer for the selected hand target at a time. Phase 9c deliberately does
not blend manual offsets additively over a moving animation. This avoids frame-order fights
between AnimationPlayer and pointer input.
Every preview frame still updates the cyan marker and derives the held prop transform from
`_solved_wrist_transform()`, never directly from the IK target. It therefore follows arm swing,
paused gait, and manual IK poses. Direction changes retain the positive-scale solver/wrapper
mirror strategy and never mutate stored anchor coordinates.
## 13. Camera behavior
Delete the upper-body filtering branch. `_reframe_camera()` always unions:
- every visible rig polygon/line, including legs;
- every visible authored prop polygon/line/circle visualization.
Grow the union by the existing safe margin, center the Camera2D, and fit it to the viewport.
`Reset View` calls this one function. Wheel zoom and middle-mouse pan remain unchanged. Resizing
the preview pane may refit as it does today. The removed frame choice carries no persisted state.
## 14. Signals and update flow
No public document signal payload changes:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
Rename flow:
```text
Rename button
-> RenameAnchorDialog + focused LineEdit
-> normalize entered ID against anchors excluding old ID
-> replace dictionary key, retain payload
-> canvas.set_anchor_data + canvas.select_anchor
-> mark dirty once
-> refresh anchor inspector
-> preview.set_document
```
Preview controls communicate internally through direct typed methods because the preview owns
the complete private visualization. No global signal bus or runtime component is introduced.
## 15. Edge cases
- No selected editable anchor: Rename remains disabled and cannot open a stale source ID.
- Rename entry empty/whitespace: confirmation disabled.
- Rename normalizes to current ID: no-op and no dirty event.
- Rename collides after normalization: deterministic numeric suffix; no existing anchor replaced.
- Rename while autosave countdown exists: one ordinary document mutation resets the debounce.
- Origin: never appears in the editable list and cannot be renamed.
- No shapes in Reshape: blank grid plus optional origin marker; no add-shape hint.
- Show Other Markers off with no selection: all editable markers and origin are hidden.
- Show Other Markers off with selection: selected marker and its rotation dot remain visible and
hit-testable; all others and origin are hidden.
- Preview hidden, then Attach exited/re-entered: re-entry checks and shows it again.
- Attach document has no compatible grip: preview shows its existing selection guidance; gait
controls may animate the rig, but prop and cyan hold target remain unavailable as appropriate.
- Hand restriction forces another hand during a paused/manual pose: current preview pose is
reapplied before binding the new hand, preventing stale global target placement.
- Walk paused at loop boundary: resuming remains looped and does not restart unless direction
changes.
- Prop extends far beyond the figure: full framing includes it; no separate frame selector.
- Custom large UI fonts: top controls grow through containers instead of clipping.
- Invalid/missing new theme token: accessor returns exact `#262C39` fallback and warns only under
the existing PropTheme rules.
## 16. Automated validation
Add `tests/test_phase9c_prop_editor_ux.gd` as a headless SceneTree suite and update affected
existing suites. Assertions must verify state and values, not only node counts.
### 16.1 Rename
1. Rename opens a ConfirmationDialog with the current ID selected in a focused LineEdit.
2. Empty/whitespace input disables confirmation.
3. `Main Handle` becomes `main_handle` and preserves every anchor payload field.
4. A collision becomes `main_handle_2` without replacing the existing anchor.
5. Same-normalized-name commit emits no dirty mutation.
6. Successful rename updates list selection, canvas selection, preview selected ID, save output,
and load round trip.
7. Cancel/New/load clears pending rename source and does not mutate the document.
### 16.2 Canvas, modes, and markers
8. Origin drawing has no `(0,0)` string path and remains present by default in all three modes.
9. Show Other Markers false hides origin and unselected anchors in draw/hit-test while preserving
selected anchor interaction.
10. Arrange alone shows the empty-canvas right-click hint.
11. Material is visible in Arrange/Reshape and hidden in Attach without changing preset.
12. Attach toolbar contains no Snap Anchors control; top Snap still snaps anchor dragging by the
configured Grid value.
13. A real transition into Attach checks/shows preview; ordinary refresh while hidden does not
re-check it; leaving and re-entering does.
### 16.3 Theme/layout
14. Default and committed theme return exact inspector background `#262C39`.
15. Custom valid override works; invalid value falls back under current validation.
16. Inspector surface consumes the accessor.
17. Preview panel border widths are all zero.
18. Top rows and Grid/Snap controls meet themed minimum height after layout; their visible rects
are not clipped by parents.
19. Inspector and preview remain adjustable through the existing split panes.
### 16.4 Preview motion and IK
20. Hand items are exactly `Left Hand` and `Right Hand`.
21. Motion items are exactly `Front Pose`, `Walk Left`, and `Walk Right`; no FrameOption or
FacingOption remains.
22. Initial state is FORWARD Front Pose with stopped player and local standing targets.
23. Walk Right selects RIGHT and loop-plays the proven gait clip in place.
24. Walk Left selects LEFT, keeps the solving rig positive, mirrors only PreviewRigRoot, and
loop-plays the proven gait clip in place.
25. Pause preserves current animation position and target values; Resume advances from it.
26. Direction change restarts deterministically at `0.0`.
27. Starting cyan-target drag pauses gait before writing the selected target and leaves it paused
after release.
28. Reset Arm restores standing local target in Front and the frozen animation sample in Walk.
29. Switching/forcing hand keeps motion/play state but clears stale manual override.
30. Held prop continues to map the selected grip exactly to the solved wrist over several walk
frames and after a manual drag.
31. Preview root does not translate during gait; queues stay empty, physics stays disabled,
collision layers/masks stay zero, and preview prop children are non-physics CanvasItems.
32. Reset View frames the complete figure and intersects the visible held-prop bounds.
### 16.5 Regression commands
Run all Prop Editor suites from `AGENTS.md`, particularly:
- `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`
- `tests/test_phase9a_prop_attach.gd`
- `tests/test_phase9b_prop_centering.gd`
Update old assertions that explicitly expect Animate Arm, FrameOption, FacingOption, Snap Anchors,
the blue preview border, or `(0,0)` label. Do not weaken anchor round-trip, origin
canonicalization, wrist alignment, preview isolation, or centering tests.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 17. Manual acceptance checklist
Launch `res://scenes/prop_editor.tscn` with F6 and verify:
1. Select an editable anchor, click Rename, type a new name, and confirm the entered normalized
name appears; it no longer appends `_renamed` automatically.
2. Rename to a name already in use and confirm a safe numeric suffix is applied.
3. Cancel Rename and confirm nothing changes; rename/save/reload and confirm persistence.
4. The passive center marker has no `(0,0)` text in Arrange, Reshape, or Attach.
5. `Show Other Markers` off hides that passive marker and unselected anchors but retains the
selected anchor and rotation dot; turn it on and confirm they return.
6. Inspector background is visibly `#262C39` and distinct from canvas/preview.
7. Empty Arrange shows `Right-click to add a shape`; empty Reshape does not.
8. Material controls are hidden in Attach and return unchanged in Arrange/Reshape.
9. Attach contains only Show Stickman Preview and Show Other Markers; no Snap Anchors duplicate.
10. Top Snap with a non-default Grid value still snaps an anchor drag.
11. Hide the preview, leave Attach, and re-enter; it is checked and shown again.
12. Grid and Snap have comfortable vertical padding and are not clipped at top or bottom.
13. Resize the outer inspector split and inner preview split in both directions.
14. Preview has a dark surface without a blue border.
15. Hand choices read Left Hand and Right Hand.
16. There is no Upper Body/Full Figure or separate Facing control.
17. Initial preview is a static Front Pose.
18. Choose Walk Right and confirm an in-place full-body walking gait while the complete prop
follows the holding wrist.
19. Choose Walk Left and confirm the stickman faces/walks left without inverted limbs and the
complete prop still follows the wrist.
20. Pause Walk freezes the exact pose and changes to Resume Walk; Resume continues rather than
jumping to the beginning.
21. While walking, drag the cyan hand target. Walking pauses first, the arm follows the pointer,
the prop follows the solved wrist, and the pose remains paused on release.
22. Reset Arm restores the appropriate front/frozen-gait arm pose without changing anchor data.
23. Switch Left Hand/Right Hand and test a `both` grip plus left-only/right-only restrictions.
24. Reset View includes the entire figure and visible prop; wheel zoom and middle-pan still work.
25. Change preview controls and verify the document does not become dirty.
26. Confirm save/load/autosave/recovery retain shapes, material, and renamed anchors exactly.
## 18. Architectural trade-offs
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Separate Phase 9c | Preserves completed 9a/9b history and gives behavior changes focused regressions | User guide spans another verified phase before its next update |
| One Preview Motion selector | Removes ineffective/ambiguous combinations and is kid-readable | Facing is no longer independently previewable without gait |
| Runtime gait rule, preview in place | Matches current gameplay direction/mirror semantics without navigation dependencies | Not a terrain, speed, arrival, or root-motion simulation |
| Pause before manual IK | One deterministic owner writes the hand target | No live additive hand posing while gait continues |
| Local rest targets | Stable under wrapper mirroring and matches animation track coordinates | Existing global-rest code must be replaced carefully |
| Full figure only | One reliable frame and fewer controls | Authors cannot request a tighter automatic upper-body crop; wheel zoom remains available |
| Modal rename | Focused, touch-friendly, and hard to trigger accidentally | One additional dialog lifecycle must be cleared on document changes |
| One snap source | Eliminates contradictory UI state | Attach toolbar no longer exposes snap next to anchors; top control must remain visible |
| Show Other Markers controls origin too | Toggle has visible meaning even with one anchor | A setting changed in Attach also affects the passive marker in other modes |
| Container-driven chrome | Adapts to theme/font changes and prevents clipping | Scene hierarchy and path-based tests require a targeted update |
## 19. Implementation order and pipeline handoff
1. Add theme token/accessor and convert editor chrome to responsive containers.
2. Group mode sections; remove duplicate/obsolete controls; implement marker visibility and hint
rules.
3. Add the rename dialog and document transaction tests.
4. Replace preview synthetic motion with the exact PreviewMotion/playback/IK state machine.
5. Add Phase 9c tests, update affected expectations, run all regressions/static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. After the user confirms
Phase 9c is verified and working, the writer must revise `docs/prop_editor_user_guide.md` to
remove the old automatic Rename, Snap Anchors, Facing, Frame, and Animate Arm instructions and
document the approved Phase 9c controls.
+134
View File
@@ -0,0 +1,134 @@
extends SceneTree
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
const TEMP_PROP_PATH := "user://phase10_custom_integration.prop"
var failures: int = 0
var checks: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_write_temp_prop()
var builtins: Array[Dictionary] = PROP_LIBRARY.get_entries()
_check(builtins.size() == 4, "built-in templates remain four entries")
var entries: Array[Dictionary] = PROP_LIBRARY.scan()
_check(entries.size() == 8, "scan returns built-ins and checked-in custom props")
var entry: Dictionary = PROP_LIBRARY.get_entry("res://props/briefcase.prop")
_check(not entry.is_empty() and bool(entry.get("is_custom", false)), "custom path resolves without selector scan")
_check((entry.get("shapes", []) as Array).size() > 1, "custom entry preserves the full shape stack")
var hull: PackedVector2Array = entry.get("collision_hull", PackedVector2Array())
_check(hull.size() >= 3, "custom entry has a valid collision hull")
var world: Node2D = Node2D.new()
root.add_child(world)
var spawner: StageSpawner = STAGE_SPAWNER.new(world)
var prop: PropBlock = spawner.spawn_prop_asset("res://props/briefcase.prop", Vector2(100, 50))
_check(prop != null and prop.has_custom_shapes(), "spawner creates layered custom PropBlock")
if prop != null:
var visuals: Node2D = prop.get_node_or_null(NodePath("Visuals")) as Node2D
_check(visuals != null and visuals.get_child_count() == (entry.get("shapes", []) as Array).size(), "each authored shape has one visual group")
var collision: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
_check(collision != null and collision.polygon.size() >= 3 and not collision.disabled, "custom prop uses one enabled collision hull")
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
_check(aabb.size.x > 0.0 and aabb.size.y > 0.0, "selection bounds include custom visuals")
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
_check(cache.prop_file_png("res://props/briefcase.prop") != cache.prop_file_png("user://briefcase.prop"), "thumbnail keys include complete canonical path")
world.queue_free()
await process_frame
await _test_authored_visual_values()
await _test_missing_prop_round_trip()
await _test_custom_prop_lifecycle()
DirAccess.remove_absolute(TEMP_PROP_PATH)
print("RESULT: %d checks, %d failures" % [checks, failures])
quit(1 if failures > 0 else 0)
func _check(condition: bool, label: String) -> void:
checks += 1
if condition: print("PASS: " + label)
else:
failures += 1
print("FAIL: " + label)
func _write_temp_prop() -> void:
var shapes: Array[Dictionary] = [
{"shape_type": "polygon", "points": PackedVector2Array([Vector2(-30, -20), Vector2(30, -20), Vector2(30, 20), Vector2(-30, 20)]), "fill_color": "#123456FF", "outline_color": "#ABCDEF80", "outline_width": 3.5, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": false},
{"shape_type": "line", "points": PackedVector2Array([Vector2(-45, 35), Vector2(45, 35)]), "fill_color": "#00000000", "outline_color": "#FEDCBAFF", "outline_width": 5.0, "closed": false, "vertex_flags": PackedInt32Array([0, 0]), "reshaped": false},
]
_check(PROP_FILE.save(TEMP_PROP_PATH, PROP_FILE.to_file_dict("Temporary Test Prop", PROP_BLOCK.MaterialPreset.METAL, shapes)), "temporary custom prop writes")
func _test_authored_visual_values() -> void:
var world: Node2D = Node2D.new()
root.add_child(world)
var spawner: StageSpawner = STAGE_SPAWNER.new(world)
var prop: PropBlock = spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2.ZERO)
_check(prop != null and prop.has_custom_shapes(), "temporary custom prop spawns as layered visual")
if prop != null:
var shape_zero: Node2D = prop.get_node_or_null(NodePath("Visuals/Shape_0")) as Node2D
var fill: Polygon2D = shape_zero.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
var closed_outline: Line2D = shape_zero.get_node_or_null(NodePath("Line2D")) as Line2D
_check(fill != null and fill.color.is_equal_approx(Color("#123456FF")), "custom fill retains authored color")
_check(closed_outline != null and closed_outline.default_color.is_equal_approx(Color("#ABCDEF80")) and is_equal_approx(closed_outline.width, 3.5), "custom outline retains authored color and width")
_check(closed_outline != null and closed_outline.points.size() == 5 and closed_outline.points[0].is_equal_approx(Vector2(-30, -20)) and closed_outline.points[4].is_equal_approx(Vector2(-30, -20)), "closed shape outline repeats authored first point")
var line_outline: Line2D = prop.get_node_or_null(NodePath("Visuals/Shape_1/Line2D")) as Line2D
_check(line_outline != null and line_outline.points.size() == 2 and line_outline.points[0].is_equal_approx(Vector2(-45, 35)) and line_outline.points[1].is_equal_approx(Vector2(45, 35)) and line_outline.default_color.is_equal_approx(Color("#FEDCBAFF")), "open line retains authored points and outline")
world.queue_free()
await process_frame
func _test_missing_prop_round_trip() -> void:
var stage: Node2D = STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
await process_frame
var source: Dictionary = {"version": "1.0", "scene_name": "Missing Custom", "camera": {}, "metadata": {}, "objects": [{"file_id": "prop_1", "type": "prop", "asset_id": TEMP_PROP_PATH, "position": {"x": 42.0, "y": 75.0}, "rotation": 15.0, "scale": {"x": 1.2, "y": 0.8}, "material_preset": PROP_BLOCK.MaterialPreset.WOOD}], "event_rules": [{"id": 1, "trigger": {"type": "action_finished", "source_id": "prop_1", "target_id": null, "params": {}}, "actions": [{"type": "wait", "target_id": null, "params": {"duration": 0.1}}]}]}
DirAccess.remove_absolute(TEMP_PROP_PATH)
_check(stage._apply_scene_data(source, "user://phase10_missing.scene", false), "missing custom-prop scene loads")
var placeholder: Node2D = stage._world.get_child(0) as Node2D
_check(placeholder != null and placeholder.get_script() == MISSING_PROP_PLACEHOLDER, "missing custom prop becomes placeholder")
var preserved: Dictionary = stage._collect_scene_data()
var object_entry: Dictionary = (preserved.get("objects", []) as Array)[0]
_check(String(object_entry.get("asset_id", "")) == TEMP_PROP_PATH and (preserved.get("event_rules", []) as Array).size() == 1 and String(((preserved["event_rules"] as Array)[0]["trigger"] as Dictionary).get("source_id", "")) == "prop_1", "placeholder save preserves asset path and rule reference")
_write_temp_prop()
_check(stage._apply_scene_data(preserved, "user://phase10_missing.scene", false), "scene reloads after custom prop is restored")
var restored: PropBlock = stage._world.get_child(0) as PropBlock
_check(restored != null and restored.has_custom_shapes() and stage._event_rules.size() == 1 and int((stage._event_rules[0]["trigger"] as Dictionary).get("source", -1)) == restored.get_instance_id(), "restored custom prop replaces placeholder and resolves rule")
stage.queue_free()
await process_frame
func _test_custom_prop_lifecycle() -> void:
var stage: Node2D = STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
await process_frame
var prop: PropBlock = stage._spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2(160, -40))
prop.rotation = deg_to_rad(20.0)
prop.scale = Vector2(1.5, 1.5)
stage._finalize_placed_node(prop)
var authored_position: Vector2 = prop.global_position
var authored_rotation: float = prop.global_rotation
var authored_scale: Vector2 = prop.scale
_check(prop.freeze, "custom prop is frozen in EDIT")
stage.set_mode(stage.StageMode.PLAY)
_check(not prop.freeze, "custom prop unfreezes in PLAY")
var play_scene: Dictionary = stage._collect_scene_data()
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
_check(saved_scale.is_equal_approx(authored_scale), "save during PLAY preserves authored prop scale")
prop.global_position += Vector2(90, 70)
prop.global_rotation += 0.4
prop.linear_velocity = Vector2(50, 20)
prop.angular_velocity = 2.0
stage.set_mode(stage.StageMode.EDIT)
_check(prop.freeze and prop.global_position.is_equal_approx(authored_position) and is_equal_approx(prop.global_rotation, authored_rotation) and prop.scale.is_equal_approx(authored_scale) and prop.linear_velocity.is_zero_approx() and is_zero_approx(prop.angular_velocity), "PLAY to EDIT restores authored pose, keeps scale, and clears velocity")
stage.queue_free()
await process_frame
@@ -0,0 +1 @@
uid://elssi38n02h2
+193
View File
@@ -0,0 +1,193 @@
extends SceneTree
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
const PROP_ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
var failures: int = 0
var checks: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var canvas: PropCanvas = PROP_CANVAS.new()
root.add_child(canvas)
canvas.set_shape_data([{"shape_type": "circle", "points": PackedVector2Array([Vector2(8, 4), Vector2(4, 8), Vector2(0, 4), Vector2(4, 0)]), "radius": 4.0, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": true}])
var signal_state: Dictionary = {"count": 0}
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: signal_state["count"] = int(signal_state["count"]) + 1)
_check(canvas.can_scale_all_shapes(2.0), "valid document scale preflights")
_check(canvas.scale_all_shapes(2.0), "valid document scale commits")
var scaled: Dictionary = canvas.shapes[0]
var points: PackedVector2Array = scaled["points"]
_check(points[0].is_equal_approx(Vector2(16, 8)) and is_equal_approx(float(scaled["radius"]), 8.0) and bool(scaled["reshaped"]), "scale preserves circle points, radius, and reshaped flag")
_check(int(signal_state["count"]) == 1, "document scale emits one shape change")
var identity_result: bool = canvas.scale_all_shapes(1.0)
_check(not identity_result, "identity scale emits no edit")
var anchors: Dictionary = {"origin": {"role": "pivot", "position": Vector2(12, 4)}, "primary_grip": {"role": "grip", "position": Vector2(8, -3), "rotation_deg": 20.0, "z_index_offset": 2, "allowed_hands": "left_only"}}
var anchor_result: Dictionary = PROP_ANCHORS.try_scale_non_origin(anchors, 2.0)
var grip: Dictionary = anchor_result["anchors"]["primary_grip"]
_check(bool(anchor_result["ok"]) and (grip["position"] as Vector2).is_equal_approx(Vector2(16, -6)) and (anchor_result["anchors"]["origin"] as Dictionary)["position"] == Vector2.ZERO, "anchor scale preserves metadata and canonical origin")
_check(not bool(PROP_ANCHORS.try_scale_non_origin(anchors, -1.0)["ok"]), "invalid anchor scale is rejected")
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(96, 48), Vector2(288, 48), Vector2(288, 96), Vector2(96, 96)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
var normalization_factor: float = 96.0 / maxf(canvas.get_shapes_bounds().size.x, canvas.get_shapes_bounds().size.y)
_check(canvas.scale_all_shapes(normalization_factor), "normalization scale commits")
var normalized_bounds: Rect2 = canvas.get_shapes_bounds()
_check(is_equal_approx(maxf(normalized_bounds.size.x, normalized_bounds.size.y), 96.0) and normalized_bounds.get_center().is_equal_approx(Vector2(96, 36)), "normalization reaches 96 px without centering off-origin geometry")
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(10, 10), Vector2(30, 10), Vector2(30, 30), Vector2(10, 30)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
canvas.set_anchor_data({"origin": {"role": "pivot", "position": Vector2.ZERO}, "primary_grip": {"role": "grip", "position": Vector2(8, 4)}})
var whole_resize: Dictionary = {"count": 0, "factor": 0.0}
canvas.whole_prop_resize_requested.connect(func(factor: float) -> void: whole_resize["count"] = int(whole_resize["count"]) + 1; whole_resize["factor"] = factor)
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
_check(canvas.shapes[0]["points"] is PackedVector2Array and (canvas.shapes[0]["points"] as PackedVector2Array)[2].is_equal_approx(Vector2(30, 30)), "whole-prop drag previews without mutating document geometry")
canvas._end_whole_prop_resize()
_check(int(whole_resize["count"]) == 1 and float(whole_resize["factor"]) > 1.0, "whole-prop drag requests one uniform commit on release")
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
canvas._update_whole_prop_resize(Vector2(30, 30))
canvas._end_whole_prop_resize()
_check(int(whole_resize["count"]) == 1, "whole-prop drag returning to start does not commit")
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
canvas.cancel_whole_prop_resize()
_check(not canvas.is_whole_prop_resize_dragging() and int(whole_resize["count"]) == 1, "whole-prop cancellation discards preview without commit")
var whole_move: Dictionary = {"count": 0, "delta": Vector2.ZERO}
var whole_rotate: Dictionary = {"count": 0, "angle": 0.0}
canvas._zoom = 1.0
canvas.whole_prop_move_requested.connect(func(delta: Vector2) -> void: whole_move["count"] = int(whole_move["count"]) + 1; whole_move["delta"] = delta)
canvas.whole_prop_rotate_requested.connect(func(angle: float) -> void: whole_rotate["count"] = int(whole_rotate["count"]) + 1; whole_rotate["angle"] = angle)
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
canvas._whole_prop_delta = Vector2(11, 9)
canvas._end_whole_prop_resize()
_check(int(whole_move["count"]) == 1 and (whole_move["delta"] as Vector2).is_equal_approx(Vector2(11, 9)), "whole-prop body drag requests baseline move")
canvas.set_snap_enabled(true)
canvas.set_grid_size(15)
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
canvas._whole_prop_start_pointer = Vector2.ZERO
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._update_whole_prop_resize(Vector2(17, 13))
var moved_preview: PackedVector2Array = canvas._whole_prop_preview_shapes[0]["points"]
var moved_anchor: Vector2 = (canvas._whole_prop_preview_anchors["primary_grip"] as Dictionary)["position"]
_check(moved_preview[0].is_equal_approx(Vector2(25, 25)) and moved_anchor.is_equal_approx(Vector2(23, 19)) and (canvas.shapes[0]["points"] as PackedVector2Array)[0].is_equal_approx(Vector2(10, 10)), "whole-prop move previews snapped baseline delta without mutation")
canvas._update_whole_prop_resize(Vector2.ZERO)
canvas._end_whole_prop_resize()
_check(int(whole_move["count"]) == 1, "whole-prop move returning to start does not commit")
canvas.set_snap_enabled(false)
canvas._begin_whole_prop_resize(Vector2(20, 56))
canvas._update_whole_prop_resize(Vector2(-56, 20))
canvas._end_whole_prop_resize()
_check(int(whole_rotate["count"]) == 1 and absf(float(whole_rotate["angle"])) > 0.1, "whole-prop rotation dot requests wrapped rotation")
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._whole_prop_previous_angle = 3.13
canvas._whole_prop_total_angle = 0.0
canvas._update_whole_prop_resize(Vector2(cos(-3.13), sin(-3.13)))
var wrapped_angle: float = canvas._whole_prop_preview_angle
var rotated_anchor: Dictionary = canvas._whole_prop_preview_anchors["primary_grip"]
_check(absf(wrapped_angle) < 0.1 and not (rotated_anchor["position"] as Vector2).is_equal_approx(Vector2(8, 4)) and not is_equal_approx(float(rotated_anchor["rotation_deg"]), 0.0), "whole-prop rotation wraps across pi and rotates anchor position and angle")
canvas._update_whole_prop_resize(Vector2(cos(3.13), sin(3.13)))
canvas._end_whole_prop_resize()
_check(int(whole_rotate["count"]) == 1, "whole-prop rotation returning to start does not commit")
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._whole_prop_previous_angle = 0.0
canvas._whole_prop_total_angle = 0.0
canvas._update_whole_prop_resize(Vector2(cos(deg_to_rad(22.0)), sin(deg_to_rad(22.0))), true)
var snapped_preview_angle: float = canvas._whole_prop_preview_angle
canvas._end_whole_prop_resize()
_check(is_equal_approx(snapped_preview_angle, deg_to_rad(15.0)) and is_equal_approx(float(whole_rotate["angle"]), deg_to_rad(15.0)) and int(whole_rotate["count"]) == 2, "Ctrl rotation commits the 15-degree preview angle")
var prop: PropBlock = PROP_BLOCK.new()
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
prop.freeze = true
root.add_child(prop)
await process_frame
var gizmos: StageGizmos = STAGE_GIZMOS.new()
root.add_child(gizmos)
gizmos.set_targets([prop])
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
var corner: Vector2 = bounds.end
_check(gizmos.hit_test(corner) == STAGE_GIZMOS.Handle.SCALE, "single prop exposes scale corner")
gizmos.begin_drag(corner)
gizmos.end_drag()
_check(not gizmos.is_dragging(), "stationary scale click ends without a persistent drag")
var initial_scale: Vector2 = prop.scale
gizmos.begin_drag(corner)
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
gizmos.end_drag()
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
await physics_frame
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
polygon_inside.position = prop.global_position + Vector2(19.0, 0.0)
var polygon_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
polygon_outside.position = prop.global_position + Vector2(21.0, 0.0)
var polygon_inside_hits: Array[Dictionary] = space.intersect_point(polygon_inside)
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
prop.global_rotation = deg_to_rad(20.0)
prop.begin_play_instance_scale()
prop.freeze = false
for frame: int in 3:
await physics_frame
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
var rotated_local_outside: Vector2 = Vector2(21.0, 0.0).rotated(prop.global_rotation)
polygon_inside.position = prop.global_position + rotated_local_inside
polygon_outside.position = prop.global_position + rotated_local_outside
_check(not space.intersect_point(polygon_inside).is_empty() and space.intersect_point(polygon_outside).is_empty(), "dynamic rotated polygon collision retains its scaled extent")
var circle: PropBlock = PROP_BLOCK.new()
circle.shape_type = PROP_BLOCK.ShapeType.CIRCLE
circle.radius = 10.0
circle.global_position = Vector2(100.0, 100.0)
circle.scale = Vector2(2.0, 2.0)
circle.freeze = true
root.add_child(circle)
await physics_frame
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
var circle_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
circle.begin_play_instance_scale()
circle.freeze = false
for frame: int in 3:
await physics_frame
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "dynamic circle collision retains its scaled extent")
circle.freeze = true
circle.end_play_instance_scale()
circle.scale = Vector2(2.0, 1.0)
circle.begin_play_instance_scale()
await physics_frame
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
var ellipse_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
ellipse_outside.position = circle.global_position + Vector2(0.0, 11.0)
_check(not space.intersect_point(ellipse_inside).is_empty() and space.intersect_point(ellipse_outside).is_empty(), "legacy non-uniform circle scale uses matching ellipse collision")
circle.queue_free()
prop.queue_free()
gizmos.queue_free()
canvas.queue_free()
print("RESULT: %d checks, %d failures" % [checks, failures])
quit(1 if failures > 0 else 0)
func _check(condition: bool, label: String) -> void:
checks += 1
if condition:
print("PASS: " + label)
else:
failures += 1
print("FAIL: " + label)
+1
View File
@@ -0,0 +1 @@
uid://c0vrrhknni6hi
+178
View File
@@ -0,0 +1,178 @@
extends SceneTree
## Phase 9a editor-only regression: anchor schema, alignment math, and preview isolation.
const ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PREVIEW_SCENE := preload("res://scenes/prop_attach_preview.tscn")
const EDITOR_SCENE := preload("res://scenes/prop_editor.tscn")
const OVERLAY := preload("res://scripts/prop_anchor_overlay.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_anchor_round_trip()
_test_anchor_validation_and_presets()
_test_alignment_and_mirror()
_test_anchor_rotation_overlay()
await _test_private_preview()
await _test_editor_anchor_inspector_constructs()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failures == 0 else "%d FAILED" % _failures, _checks])
quit(0 if _failures == 0 else 1)
func _check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: " + label)
else:
_failures += 1
print("FAIL: " + label)
func _test_anchor_round_trip() -> void:
var raw := {"primary_grip": {"role": "grip", "x": 12.0, "y": -8.0, "rotation_deg": 90.0, "z_index_offset": 2, "allowed_hands": "right_only"}}
var normalized: Dictionary = ANCHORS.normalize_anchors(raw)
_check("legacy/default origin is synthesized", normalized.has("origin") and normalized["origin"].get("position") == Vector2.ZERO)
_check("grip remains compatible only with right", ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.RIGHT).has("primary_grip") and not ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.LEFT).has("primary_grip"))
var file_data: Dictionary = PROP_FILE.to_file_dict("Grip", 0, [], normalized)
var loaded: Dictionary = PROP_FILE.from_file_dict(file_data)
_check("file anchors round trip", loaded.get("anchors", {}).get("primary_grip", {}).get("position") == Vector2(12.0, -8.0))
func _test_anchor_validation_and_presets() -> void:
var raw := {"origin": {"role": "grip", "x": 50}, "Bad ID!!": {"role": "pivot", "position": Vector2(4, 5), "rotation_deg": 721.0, "z_index_offset": 99}}
var normalized: Dictionary = ANCHORS.normalize_anchors(raw, false)
_check("origin is immutable canonical pivot", normalized["origin"].get("role") == "pivot" and normalized["origin"].get("position") == Vector2.ZERO)
_check("IDs normalize and non-origin pivots downgrade", normalized.has("bad_id") and normalized["bad_id"].get("role") == "custom")
_check("rotation and z sanitize", is_equal_approx(float(normalized["bad_id"].get("rotation_deg")), 1.0) and int(normalized["bad_id"].get("z_index_offset")) == 5)
_check("duplicate ID gets suffix", ANCHORS.normalize_id("bad id", normalized) == "bad_id_2")
for preset: String in ["one_handed", "two_handed", "tool_nozzle", "melee_edge"]:
var layout: Dictionary = ANCHORS.preset_layout(preset, Rect2(-50, -10, 100, 20))
_check("preset %s retains origin" % preset, layout.has("origin") and layout["origin"].get("role") == "pivot")
func _test_alignment_and_mirror() -> void:
var anchor := {"position": Vector2(20.0, 5.0), "rotation_deg": 30.0}
var target := Transform2D(deg_to_rad(70.0), Vector2(300.0, 200.0))
var solved := ANCHORS.solve_attachment_transform(target, anchor, Vector2(2.0, 3.0), false)
var effective := ANCHORS.local_transform(anchor)
_check("attachment maps anchor origin to wrist", solved * effective.origin == target.origin)
_check("attachment keeps positive determinant", solved.determinant() > 0.0)
var mirrored := ANCHORS.local_transform(anchor, true)
_check("mirror reflects anchor X without mutation", mirrored.origin == Vector2(-20.0, 5.0) and anchor.get("position") == Vector2(20.0, 5.0))
func _test_anchor_rotation_overlay() -> void:
var overlay := OVERLAY.new()
overlay.set_anchor_data({"grip": {"role": "grip", "position": Vector2(10.0, 10.0), "rotation_deg": 0.0}})
overlay.select_anchor("grip")
_check("rotation dot starts outside translate marker", overlay.begin_pointer(Vector2(10.0, 38.0), 1.0) and int(overlay.get("_drag_mode")) == 2)
overlay.update_pointer(Vector2(110.0, 36.8), 1.0, false, 1, true)
overlay.end_pointer()
var grip: Dictionary = overlay.get_anchor_data()["grip"]
_check("rotation dot Ctrl snaps to 15 degrees", is_equal_approx(float(grip.get("rotation_deg")), 15.0))
overlay.select_anchor("origin")
_check("fixed origin has no rotation drag", not overlay.begin_pointer(Vector2(0.0, 28.0), 1.0) and int(overlay.get("_drag_mode")) != 2)
func _test_private_preview() -> void:
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
await process_frame
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "x": 0.0, "y": 0.0, "rotation_deg": 0.0, "z_index_offset": 1, "allowed_hands": "both"}})
var shapes: Array[Dictionary] = [{"shape_type": 0, "points": PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(0, 15)]), "fill_color": "#ff0000", "closed": true}]
preview.call("set_document", shapes, anchors, "grip")
_check("preview creates a private rig viewport", bool(preview.call("is_preview_available")))
var rig_root := preview.get("_rig_root") as Node2D
var prop := preview.get("_prop") as Node2D
_check("preview rig keeps positive scale", rig_root != null and rig_root.scale.x > 0.0)
_check("preview is visual-only", prop != null and prop.get_child_count() == 1 and not (prop.get_child(0) is CollisionObject2D))
preview.call("set_facing_profile", 0)
var rig := preview.get("_rig") as Node2D
_check("left reflection is only on wrapper", rig != null and rig.scale.x > 0.0 and rig_root != null and rig_root.scale.x < 0.0)
preview.call("set_preview_hand", 0)
_check("secondary/any compatible grip can use left", int(preview.get("_hand")) == 0)
preview.call("set_document", shapes, ANCHORS.normalize_anchors({"grip": {"role": "grip", "allowed_hands": "right_only"}}), "grip")
_check("right-only grip forces compatible hand", int(preview.get("_hand")) == 1)
var clamped := preview.call("_clamp_target", Vector2.ZERO) as Vector2
_check("reach clamp returns finite target", is_finite(clamped.x) and is_finite(clamped.y))
preview.call("reset_arm_pose")
_check("reset disables animation", not bool(preview.get("_animate")))
preview.queue_free()
func _test_editor_anchor_inspector_constructs() -> void:
var editor := EDITOR_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
await process_frame
var role := editor.get("_anchor_role") as OptionButton
var x_spin := editor.get("_anchor_x") as SpinBox
var rename := editor.get("_rename_anchor_button") as Button
var delete_button := editor.get("_delete_anchor_button") as Button
var anchor_list := editor.get("_anchor_list") as ItemList
_check("editor constructs an empty editable-anchor inspector", role != null and x_spin != null and anchor_list != null)
_check("no-anchor option fields are disabled", role != null and role.disabled)
_check("no-anchor numeric fields use SpinBox editable gate", x_spin != null and not x_spin.editable)
_check("no-anchor rename/delete actions are disabled", rename != null and delete_button != null and rename.disabled and delete_button.disabled)
_check("canonical origin is hidden from editable anchor list", anchor_list != null and anchor_list.item_count == 0)
var workspace := editor.get_node_or_null("MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace") as HSplitContainer
var main_split := editor.get_node_or_null("MainLayout") as HSplitContainer
_check("preview workspace is a bidirectional split", workspace != null and workspace.get_child_count() == 2 and workspace.get_child(1).custom_minimum_size.x >= 360.0)
_check("inspector is user-resizable", main_split != null and main_split.get_child_count() == 2)
var canvas := editor.get("_canvas") as PropCanvas
canvas.add_primitive("box", Vector2(0.0, 80.0), {"size_x": 160.0, "size_y": 45.0, "fill_color": "#9b6b34"})
canvas.add_primitive("circle", Vector2(55.0, 0.0), {"radius": 18.0, "fill_color": "#ffffff"})
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "position": Vector2.ZERO, "z_index_offset": 1, "allowed_hands": "both"}})
editor.set("_anchors", anchors)
canvas.set_anchor_data(anchors)
editor.call("_select_anchor", "grip")
editor.call("_on_attach_mode_pressed")
editor.call("_sync_attach_preview")
await process_frame
var preview := editor.get("_attach_preview") as Control
preview.call("_update_prop_transform")
var prop := preview.get("_prop") as Node2D if preview != null else null
_check("editor document flow builds every preview shape", prop != null and prop.visible and prop.get_child_count() == 2)
var prop_bounds := preview.call("get_prop_global_bounds") as Rect2
var view_bounds := preview.call("get_preview_world_rect") as Rect2
var wrist := preview.call("get_solved_wrist_transform") as Transform2D
var grip: Dictionary = anchors["grip"]
_check("preview prop has finite visible global bounds", prop_bounds.size.x > 0.0 and prop_bounds.size.y > 0.0 and is_finite(prop_bounds.position.x) and is_finite(prop_bounds.end.y))
_check("selected grip maps exactly to solved wrist", (prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)).is_equal_approx(wrist.origin))
var before_motion := prop.global_position
preview.call("set_arm_animation_enabled", true)
await process_frame
preview.call("_update_prop_transform")
var moved_wrist := preview.call("get_solved_wrist_transform") as Transform2D
_check("held prop follows solved wrist during IK motion", prop.global_position.distance_to(before_motion) > 0.001 and prop.global_position.is_equal_approx(moved_wrist.origin))
_check("framed viewport intersects held prop", view_bounds.intersects(prop_bounds))
_check("preview preserves authored polygon and circle colors", prop.get_child(0) is Polygon2D and (prop.get_child(0) as Polygon2D).color == Color.from_string("#9b6b34", Color.GRAY) and prop.get_child(1) is Polygon2D and (prop.get_child(1) as Polygon2D).color == Color.WHITE)
var rotation_spin := editor.get("_anchor_rotation") as SpinBox
var z_spin := editor.get("_anchor_z") as SpinBox
rotation_spin.value = 47.0
z_spin.value = 3.0
await process_frame
var edited_anchors: Dictionary = editor.get("_anchors")
var edited_grip: Dictionary = edited_anchors["grip"]
_check("inspector rotation and z persist to selected anchor", is_equal_approx(float(edited_grip.get("rotation_deg")), 47.0) and int(edited_grip.get("z_index_offset")) == 3)
_check("inspector refresh retains rotation and z values", is_equal_approx(rotation_spin.value, 47.0) and is_equal_approx(z_spin.value, 3.0))
var round_trip: Dictionary = PROP_FILE.from_file_dict(PROP_FILE.to_file_dict("Grip", 0, [], edited_anchors))
_check("rotation and z save/load round trip", is_equal_approx(float(round_trip["anchors"]["grip"].get("rotation_deg")), 47.0) and int(round_trip["anchors"]["grip"].get("z_index_offset")) == 3)
_check("preview reacts to z edit", prop.z_index == 3)
var preview_style := preview.get_theme_stylebox("panel") as StyleBoxFlat
var inspector := editor.get_node_or_null("MainLayout/InspectorColumn") as PanelContainer
var inspector_style := inspector.get_theme_stylebox("panel") as StyleBoxFlat if inspector != null else null
_check("preview and inspector have distinct surfaces without blue frame", preview_style != null and inspector_style != null and preview_style.bg_color != inspector_style.bg_color and preview_style.border_width_left == 0)
var playback := preview.get("_walk_playback_button") as Button
var reset_arm := preview.get("_reset_arm_button") as Button
var reset_view := preview.get("_reset_view_button") as Button
var hand_option := preview.get("_hand_option") as OptionButton
_check("preview footer controls own full hit rectangles", _has_full_hit_rect(playback) and _has_full_hit_rect(reset_arm) and _has_full_hit_rect(reset_view))
_check("preview header options own full hit rectangles", _has_full_hit_rect(hand_option))
preview.call("set_preview_motion", 2)
preview.call("toggle_walk_playback")
_check("walk playback pauses through its button state", playback != null and playback.text == "Resume Walk" and bool(preview.get("_walk_paused")))
editor.queue_free()
func _has_full_hit_rect(control: Control) -> bool:
return control != null and control.mouse_filter == Control.MOUSE_FILTER_STOP and control.size.x >= control.custom_minimum_size.x and control.size.y >= control.custom_minimum_size.y and Rect2(Vector2.ZERO, control.size).has_point(Vector2(control.size.x - 2.0, control.size.y * 0.5))
+1
View File
@@ -0,0 +1 @@
uid://c4e5j6k0jm0hw
+78
View File
@@ -0,0 +1,78 @@
extends SceneTree
## Phase 9b regression: canonical origin presentation, bounds, centering, and save threshold.
const ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const CANVAS_SCRIPT := preload("res://scripts/prop_canvas.gd")
const EDITOR := preload("res://scripts/prop_editor.gd")
var _checks: int = 0
var _failures: int = 0
var _shape_change_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_anchor_helpers()
await _test_canvas_bounds_and_translation()
_test_warning_threshold()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failures == 0 else "%d FAILED" % _failures, _checks])
quit(0 if _failures == 0 else 1)
func _check(label: String, condition: bool) -> void:
_checks += 1
if condition: print("PASS: " + label)
else:
_failures += 1
print("FAIL: " + label)
func _test_anchor_helpers() -> void:
var anchors: Dictionary = ANCHORS.normalize_anchors({
"primary_grip": {"role": "grip", "position": Vector2(400.0, 200.0), "rotation_deg": 30.0, "z_index_offset": 2, "allowed_hands": "left_only"},
"emitter": {"role": "emitter", "position": Vector2(450.0, 220.0), "rotation_deg": -15.0, "z_index_offset": -1, "allowed_hands": "both"},
})
_check("editable IDs exclude canonical origin", ANCHORS.editable_ids(anchors) == ["primary_grip", "emitter"])
_check("preferred anchor favors primary grip", ANCHORS.preferred_editable_id(anchors) == "primary_grip")
var moved: Dictionary = ANCHORS.translated_non_origin(anchors, Vector2(-400.0, -200.0))
_check("translated anchors preserve origin and metadata", moved["origin"].get("position") == Vector2.ZERO and moved["primary_grip"].get("position") == Vector2.ZERO and is_equal_approx(float(moved["primary_grip"].get("rotation_deg")), 30.0) and int(moved["emitter"].get("z_index_offset")) == -1)
_check("translated anchors preserve relative vectors", moved["emitter"].get("position") - moved["primary_grip"].get("position") == anchors["emitter"].get("position") - anchors["primary_grip"].get("position"))
func _test_canvas_bounds_and_translation() -> void:
var canvas := CANVAS_SCRIPT.new() as Control
canvas.size = Vector2(800.0, 600.0)
canvas.visible = false
root.add_child(canvas)
await process_frame
canvas.call("set_shape_data", [
{"shape_type": "polygon", "points": [Vector2(400.0, 200.0), Vector2(600.0, 200.0), Vector2(500.0, 300.0)], "closed": true, "vertex_flags": [0, 0, 0]},
{"shape_type": "line", "points": [Vector2(620.0, 260.0), Vector2(680.0, 260.0)], "closed": false, "vertex_flags": [0, 0]},
{"shape_type": "circle", "points": [], "radius": 30.0, "closed": true, "vertex_flags": []},
{"shape_type": "polygon", "points": [], "closed": true, "vertex_flags": []},
])
# Place the generated circle explicitly, verifying its perimeter participates in bounds.
var circle: Dictionary = canvas.shapes[2]
circle["points"] = PackedVector2Array([Vector2(770.0, 200.0), Vector2(830.0, 200.0), Vector2(800.0, 230.0), Vector2(800.0, 170.0)])
canvas.shapes[2] = circle
var bounds: Rect2 = canvas.call("get_shapes_bounds") as Rect2
_check("bounds union includes polygon line and circle geometry", canvas.call("has_shape_geometry") and bounds.position == Vector2(400.0, 170.0) and bounds.end == Vector2(830.0, 300.0))
_shape_change_count = 0
canvas.shape_changed.connect(_on_shape_changed)
var selected_before: int = canvas.call("get_selected_index") as int
var did_move: bool = bool(canvas.call("translate_all_shapes", Vector2(-615.0, -235.0)))
_check("bulk translate commits once", did_move and _shape_change_count == 1)
var after: Rect2 = canvas.call("get_shapes_bounds") as Rect2
_check("bulk translation centers exact union bounds", after.get_center().is_zero_approx())
var rejected_zero: bool = not bool(canvas.call("translate_all_shapes", Vector2.ZERO))
_check("bulk translation retains selection and rejects zero delta", int(canvas.call("get_selected_index")) == selected_before and rejected_zero and _shape_change_count == 1)
canvas.call("focus_world_point", Vector2.ZERO)
_check("focus centers origin without changing zoom", (canvas.get("_pan_offset") as Vector2).is_equal_approx(canvas.size * 0.5) and is_equal_approx(float(canvas.get("_zoom")), 1.0))
canvas.queue_free()
func _on_shape_changed(_shapes: Array[Dictionary]) -> void:
_shape_change_count += 1
func _test_warning_threshold() -> void:
_check("empty geometry never warns", not EDITOR.should_warn_off_origin(false, Rect2(5000.0, 0.0, 20.0, 20.0)))
_check("strict threshold does not warn at 1024", not EDITOR.should_warn_off_origin(true, Rect2(1014.0, -10.0, 20.0, 20.0)))
_check("far small prop warns", EDITOR.should_warn_off_origin(true, Rect2(1025.0, -10.0, 20.0, 20.0)))
_check("large bounds use diagonal-scaled threshold", not EDITOR.should_warn_off_origin(true, Rect2(1500.0, -500.0, 2000.0, 1000.0)))
+1
View File
@@ -0,0 +1 @@
uid://qpqqxm4acs2g
+111
View File
@@ -0,0 +1,111 @@
extends SceneTree
const THEME := preload("res://scripts/prop_theme.gd")
const CANVAS_SCENE := preload("res://scenes/prop_editor.tscn")
const PREVIEW_SCENE := preload("res://scenes/prop_attach_preview.tscn")
const PROP_FILE := preload("res://scripts/prop_file.gd")
var _failed: int = 0
var _count: int = 0
func _init() -> void:
call_deferred("_run")
func _check(label: String, value: bool) -> void:
_count += 1
if value:
print("PASS: %s" % label)
else:
_failed += 1
push_error("FAIL: %s" % label)
func _run() -> void:
var theme := THEME.new()
_check("default inspector background is #262C39", theme.inspector_background_color() == Color("#262C39"))
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
await process_frame
var hand := preview.find_child("HandOption", true, false) as OptionButton
var motion := preview.find_child("MotionOption", true, false) as OptionButton
_check("hand labels are kid-readable", hand != null and hand.get_item_text(0) == "Left Hand" and hand.get_item_text(1) == "Right Hand")
_check("motion selector has approved choices", motion != null and motion.item_count == 3 and motion.get_item_text(0) == "Front Pose" and motion.get_item_text(1) == "Walk Left" and motion.get_item_text(2) == "Walk Right")
_check("obsolete preview controls removed", preview.find_child("FacingOption", true, false) == null and preview.find_child("FrameOption", true, false) == null)
preview.call("set_preview_motion", 2)
_check("walk right enters active gait", int(preview.get("_motion")) == 2 and not bool(preview.get("_walk_paused")))
preview.call("toggle_walk_playback")
_check("walk toggle pauses gait", bool(preview.get("_walk_paused")))
preview.call("set_preview_motion", 0)
_check("front pose stops gait", int(preview.get("_motion")) == 0 and bool(preview.get("_walk_paused")))
preview.queue_free()
var editor := CANVAS_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
var canvas := editor.get("_canvas") as PropCanvas
var attach_tools := editor.get("_attach_tools") as FlowContainer
var marker := editor.get("_inactive_anchors_check") as CheckBox
_check("attach tools are wrapping flow controls", attach_tools != null)
_check("marker toggle is meaningful wording", marker != null and marker.text == "Show Other Markers")
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
var material := editor.get("_material_section") as Control
_check("material hides in Attach", material != null and not material.visible)
canvas.set_edit_mode(PropCanvas.EditMode.TRANSFORM)
_check("material returns in Arrange", material != null and material.visible)
var anchors: Dictionary = {"grip": {"role": "grip", "position": Vector2(10.0, 20.0), "rotation_deg": 12.0, "z_index_offset": 2, "allowed_hands": "both"}}
editor.set("_anchors", anchors)
canvas.set_anchor_data(anchors)
editor.call("_select_anchor", "grip")
var roles := editor.get("_anchor_role") as OptionButton
_check("editable role list omits pivot and maps safely", roles != null and roles.item_count == 5 and roles.get_item_text(0) == "grip" and roles.get_item_text(4) == "custom")
editor.call("_on_rename_anchor_pressed")
var dialog := editor.get("_rename_anchor_dialog") as ConfirmationDialog
var rename_edit := editor.get("_rename_anchor_edit") as LineEdit
rename_edit.text = "Main Handle"
dialog.get_ok_button().emit_signal("pressed")
await process_frame
var renamed: Dictionary = editor.get("_anchors")
_check("dialog confirmation renames normalized anchor", renamed.has("main_handle") and not renamed.has("grip") and str(renamed["main_handle"].get("role")) == "grip")
renamed["other"] = {"role": "custom", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
editor.set("_anchors", renamed)
canvas.set_anchor_data(renamed)
editor.call("_select_anchor", "other")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Main Handle"
dialog.get_ok_button().emit_signal("pressed")
await process_frame
var collision_renamed: Dictionary = editor.get("_anchors")
_check("dialog confirmation uses collision suffix", collision_renamed.has("main_handle") and collision_renamed.has("main_handle_2"))
editor.call("_select_anchor", "main_handle_2")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Entered Name"
rename_edit.text_submitted.emit(rename_edit.text)
await process_frame
_check("Enter in rename field confirms typed name", (editor.get("_anchors") as Dictionary).has("entered_name") and not (editor.get("_anchors") as Dictionary).has("main_handle_2"))
editor.call("_select_anchor", "entered_name")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Cancelled Name"
dialog.emit_signal("canceled")
await process_frame
_check("dialog cancel leaves anchor unchanged", (editor.get("_anchors") as Dictionary).has("entered_name") and str(editor.get("_rename_anchor_source_id")).is_empty())
var main_split := editor.get("_main_layout") as HSplitContainer
var inspector := editor.get("_inspector_column") as PanelContainer
_check("inspector starts at minimum usable width", main_split != null and inspector != null and inspector.size.x >= inspector.get_combined_minimum_size().x)
var eye_data: Dictionary = PROP_FILE.load("res://props/eye.prop")
_check("eye prop fixture is available", not eye_data.is_empty())
editor.call("_apply_json_data", eye_data)
editor.size = Vector2(1200.0, 600.0)
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
await process_frame
var top_bar := editor.get_node("TopBar") as Control
var status_bar := editor.get_node("StatusBar") as Control
var holding_preview := editor.get("_attach_preview") as Control
_check("Attach layout stays between name and status bars", main_split.get_global_rect().position.y >= top_bar.get_global_rect().end.y - 1.0 and holding_preview.get_global_rect().end.y <= status_bar.get_global_rect().position.y + 1.0)
var preview_container := holding_preview.get("_viewport_container") as SubViewportContainer
var preview_viewport := holding_preview.get("_viewport") as SubViewport
_check("preview viewport follows flexible pane height", preview_container != null and preview_viewport != null and preview_container.size.y > 0.0 and absf(float(preview_viewport.size.y) - preview_container.size.y) <= 1.0)
editor.queue_free()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failed == 0 else "%d FAILED" % _failed, _count])
quit(0 if _failed == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://ofaxq8iw1n1c
+387
View File
@@ -0,0 +1,387 @@
# Phase 9 Prop Editor - headless regression checks for PropCanvas math
# (vertex insert/remove, mirror, z-order, clipboard, transforms, sanitize).
# No GUI event injection; the public math APIs are driven directly.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_canvas.gd"
extends SceneTree
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
var _checks: int = 0
var _failures: int = 0
var _changed_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP CANVAS TEST ===")
await _test_primitives()
await _test_vertex_insert_remove()
await _test_mirror()
await _test_z_order()
await _test_clipboard()
await _test_transforms()
await _test_sanitize_and_radius()
await _test_import_export_and_clear()
await _test_edit_mode()
await _test_gizmo_constants()
await _test_create_point_on_edge()
await _test_segment_hit_test()
await _test_reshaped_flag()
await _test_selection_halo()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _new_canvas() -> PropCanvas:
var canvas: PropCanvas = PROP_CANVAS.new()
root.add_child(canvas)
canvas.size = Vector2(800, 600)
_changed_count = 0
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: _changed_count += 1)
return canvas
func _free_canvas(canvas: PropCanvas) -> void:
canvas.queue_free()
await process_frame
func _bbox(pts: Array) -> Rect2:
var first: Dictionary = pts[0]
var bbox := Rect2(Vector2(float(first.get("x", 0.0)), float(first.get("y", 0.0))), Vector2.ZERO)
for p: Dictionary in pts:
bbox = bbox.expand(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
return bbox
func _export_points(sd: Dictionary) -> Array:
return sd.get("points", []) as Array
func _test_primitives() -> void:
var canvas: PropCanvas = _new_canvas()
var box: Dictionary = canvas.add_primitive("box", Vector2(100, 100))
check("box appended", canvas.shapes.size() == 1)
check("box type", str(box.get("shape_type", "")) == "polygon")
check("box 4 points", _export_points(box).size() == 4)
check("box closed", bool(box.get("closed", false)))
check("add emits shape_changed", _changed_count == 1)
check("box selected", canvas.get_selected_index() == 0)
var big: Dictionary = canvas.add_primitive("box", Vector2(200, 200), {"size_x": 100.0, "size_y": 100.0})
var big_bbox: Rect2 = _bbox(_export_points(big))
check("box params respected", is_equal_approx(big_bbox.size.x, 100.0) and is_equal_approx(big_bbox.size.y, 100.0))
var ball: Dictionary = canvas.add_primitive("ball", Vector2(300, 300))
check("ball type", str(ball.get("shape_type", "")) == "circle")
check("ball radius default", is_equal_approx(float(ball.get("radius", 0.0)), 24.0))
check("ball points 12", _export_points(ball).size() == 12)
var line: Dictionary = canvas.add_primitive("line", Vector2(400, 400))
check("line type", str(line.get("shape_type", "")) == "line")
check("line open", not bool(line.get("closed", true)))
check("line 2 points", _export_points(line).size() == 2)
var plank: Dictionary = canvas.add_primitive("plank", Vector2(500, 500))
check("plank polygon", str(plank.get("shape_type", "")) == "polygon")
var triangle: Dictionary = canvas.add_primitive("triangle", Vector2(600, 600))
check("triangle 3 points", _export_points(triangle).size() == 3)
check("triangle closed", bool(triangle.get("closed", false)))
var circle: Dictionary = canvas.add_primitive("circle", Vector2(700, 700), {"radius": 50.0})
check("circle radius param", is_equal_approx(float(circle.get("radius", 0.0)), 50.0))
await _free_canvas(canvas)
func _test_vertex_insert_remove() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.insert_vertex(0, 2, Vector2(124, 100))
check("insert grows point list", _export_points(canvas.get_shape_data()[0]).size() == 5)
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[2]) == 1)
check("original vertices stay 0", int(flags[0]) == 0 and int(flags[4]) == 0)
canvas.remove_vertex(0, 2)
check("remove shrinks point list", _export_points(canvas.get_shape_data()[0]).size() == 4)
check("flags aligned after remove", (canvas.get_shape_data()[0].get("vertex_flags", []) as Array).size() == 4)
canvas.remove_vertex(0, 0)
canvas.remove_vertex(0, 0)
check("polygon with 2 points becomes open", bool(canvas.shapes[0].get("closed", true)) == false)
canvas.remove_vertex(0, 0)
check("below 2 vertices drops shape", canvas.shapes.size() == 0)
check("selection cleared after drop", canvas.get_selected_index() == -1)
await _free_canvas(canvas)
func _test_mirror() -> void:
var canvas: PropCanvas = _new_canvas()
var tri: Dictionary = canvas.add_primitive("triangle", Vector2(100, 100))
var before: Array = _export_points(tri)
var bbox: Rect2 = _bbox(before)
canvas.mirror_x(0)
var after_x: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_x flips x about center", is_equal_approx(float(after_x[0].get("x", 0.0)), 2.0 * bbox.get_center().x - float(before[0].get("x", 0.0))))
check("mirror_x keeps y", is_equal_approx(float(after_x[0].get("y", 0.0)), float(before[0].get("y", 0.0))))
check("mirror_x keeps bbox center", _bbox(after_x).get_center().is_equal_approx(bbox.get_center()))
canvas.mirror_y(0)
var after_y: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_y flips y about center", is_equal_approx(float(after_y[0].get("y", 0.0)), 2.0 * bbox.get_center().y - float(after_x[0].get("y", 0.0))))
check("mirror_y keeps bbox center", _bbox(after_y).get_center().is_equal_approx(bbox.get_center()))
await _free_canvas(canvas)
func _test_z_order() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(0, 0), {"fill_color": "#FF0000", "outline_color": "#000000"})
canvas.add_primitive("ball", Vector2(100, 0), {"fill_color": "#00FF00", "outline_color": "#000000"})
canvas.add_primitive("line", Vector2(200, 0), {"fill_color": "#0000FF", "outline_color": "#000000"})
canvas.bring_forward(1)
check("bring_forward swaps up", str(canvas.get_shape_data()[1].get("shape_type", "")) == "line")
check("bring_forward swaps down", str(canvas.get_shape_data()[2].get("shape_type", "")) == "circle")
canvas.send_back(2)
check("send_back swaps down", str(canvas.get_shape_data()[1].get("shape_type", "")) == "circle")
canvas.bring_forward(2)
check("bring_forward at top is no-op", str(canvas.get_shape_data()[2].get("shape_type", "")) == "line")
canvas.send_back(0)
check("send_back at bottom is no-op", str(canvas.get_shape_data()[0].get("shape_type", "")) == "polygon")
await _free_canvas(canvas)
func _test_clipboard() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(50, 50), {"fill_color": "#123456", "outline_color": "#000000"})
var copied: Dictionary = canvas.copy_shape(0)
check("copy returns deep dict", not copied.is_empty() and copied.has("points"))
canvas.set_shape_fill_color(0, "#ABCDEF")
check("copy is independent", str(copied.get("fill_color", "")) == "#123456")
canvas.paste_shape(copied, Vector2(100, 100))
check("paste appends", canvas.shapes.size() == 2)
var pasted_pts: Array = _export_points(canvas.get_shape_data()[1])
var pasted_bbox: Rect2 = _bbox(pasted_pts)
check("paste offsets by (16,16)", pasted_bbox.get_center().is_equal_approx(Vector2(116, 116)))
check("paste preserves color", str(canvas.get_shape_data()[1].get("fill_color", "")) == "#123456")
check("paste selects new shape", canvas.get_selected_index() == 1)
await _free_canvas(canvas)
func _test_transforms() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.translate_selected(Vector2(10, -5))
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("translate moves all vertices", _bbox(pts).get_center().is_equal_approx(Vector2(110, 95)))
canvas.add_primitive("box", Vector2(200, 200))
canvas.select(1)
canvas.rotate_selected(PI / 2.0)
var rotated_pts: Array = _export_points(canvas.get_shape_data()[1])
# Box corners at (224,224) rotate 90 degrees ccw about (200,200) -> (176,224).
var found: bool = false
for p: Dictionary in rotated_pts:
if Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))).is_equal_approx(Vector2(176, 224)):
found = true
check("rotate 90 degrees about bbox center", found)
check("rotate keeps bbox center", _bbox(rotated_pts).get_center().is_equal_approx(Vector2(200, 200)))
canvas.scale_selected(Vector2(2.0, 1.0))
var scaled_pts: Array = _export_points(canvas.get_shape_data()[1])
var scaled_bbox: Rect2 = _bbox(scaled_pts)
check("scale stretches width", is_equal_approx(scaled_bbox.size.x, 96.0))
check("scale keeps height", is_equal_approx(scaled_bbox.size.y, 48.0))
await _free_canvas(canvas)
func _test_sanitize_and_radius() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_grid_size(16)
canvas.set_snap_enabled(true)
# Rectangle with one collinear point and one off-grid point; sanitize must
# grid-snap, drop the collinear vertex, and enforce clockwise winding.
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [
{"x": 0.0, "y": 0.0},
{"x": 16.0, "y": 0.0},
{"x": 32.0, "y": 0.0},
{"x": 32.0, "y": 32.0},
{"x": 0.0, "y": 33.0},
],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}])
canvas.sanitize_shape(0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("sanitize drops collinear vertex", pts.size() == 4)
var packed := PackedVector2Array()
for p: Dictionary in pts:
packed.append(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(packed))
var min_y: float = INF
var max_y: float = -INF
for v: Vector2 in packed:
min_y = minf(min_y, v.y)
max_y = maxf(max_y, v.y)
check("sanitize grid-snaps", is_equal_approx(min_y, 0.0) and is_equal_approx(max_y, 32.0))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("sanitize keeps flags aligned", flags.size() == 4)
var circle: Dictionary = canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
var before_count: int = _export_points(circle).size()
canvas.sanitize_shape(1)
check("sanitize skips circles", _export_points(canvas.get_shape_data()[1]).size() == before_count)
canvas.set_shape_radius(1, 80.0)
var radius_pts: Array = _export_points(canvas.get_shape_data()[1])
check("set_shape_radius resizes points", is_equal_approx(float(canvas.get_shape_data()[1].get("radius", 0.0)), 80.0))
var radius_bbox: Rect2 = _bbox(radius_pts)
check("radius bbox matches", is_equal_approx(radius_bbox.size.x, 160.0) and is_equal_approx(radius_bbox.size.y, 160.0))
await _free_canvas(canvas)
func _test_import_export_and_clear() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [{"x": 0.0, "y": 0.0}, {"x": 10.0, "y": 0.0}, {"x": 10.0, "y": 10.0}],
"fill_color": "#111111",
"outline_color": "#222222",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0],
}])
check("import plain {x,y} data", canvas.shapes.size() == 1)
check("import preserves flags", (canvas.shapes[0].get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var exported: Array[Dictionary] = canvas.get_shape_data()
check("export plain format", exported.size() == 1 and (exported[0].get("points", []) as Array)[0] is Dictionary)
var before_changed: int = _changed_count
canvas.clear()
check("clear empties shapes", canvas.shapes.is_empty())
check("clear emits no shape_changed", _changed_count == before_changed)
check("export after clear empty", canvas.get_shape_data().is_empty())
await _free_canvas(canvas)
func _test_edit_mode() -> void:
var canvas: PropCanvas = _new_canvas()
check("default mode is TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
var mode_fires: Array = [0]
canvas.mode_changed.connect(func(_mode: int) -> void: mode_fires[0] += 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("set_edit_mode(POINTS) applies", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
check("mode_changed fired once on change", int(mode_fires[0]) == 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("re-setting same mode does not fire", int(mode_fires[0]) == 1)
canvas.set_edit_mode(99)
check("unknown mode clamps to TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
check("clamp fires mode_changed", int(mode_fires[0]) == 2)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
canvas.add_primitive("box", Vector2(100, 100))
canvas.clear()
check("clear keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.set_shape_data([])
check("set_shape_data keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.select(0)
canvas.deselect()
check("select/deselect keep the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
await _free_canvas(canvas)
func _test_gizmo_constants() -> void:
check("rotation dot radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_RADIUS, 5.0))
check("rotation dot hit radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_HIT_RADIUS, 12.0))
check("rotation dot offset constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_OFFSET, 26.0))
check("scale cross size constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_SIZE, 5.0))
check("scale cross hit radius constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_HIT_RADIUS, 12.0))
check("vertex handle radius constant", is_equal_approx(PROP_CANVAS.HANDLE_RADIUS, 3.0))
var constant_map: Dictionary = (PROP_CANVAS as Script).get_script_constant_map()
check("rotate ring constant removed", not constant_map.has("ROTATE_RING_RADIUS"))
check("scale circle constant removed", not constant_map.has("SCALE_HANDLE_DISTANCE"))
func _test_create_point_on_edge() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
var created: Array = []
canvas.vertex_created.connect(func(s_idx: int, v_idx: int) -> void: created.append([s_idx, v_idx]))
# Cursor at (110, 60): the nearest segment is the top edge (76,76)-(124,76);
# the projection onto it is (110, 76) - NOT the edge midpoint (100, 76).
var idx: int = canvas.create_point_on_edge(Vector2(110, 60))
check("create_point_on_edge returns valid index", idx >= 0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("create point grows the vertex list", pts.size() == 5)
var inserted: Dictionary = pts[idx]
check("inserted point is the cursor projection", Vector2(float(inserted.get("x", 0.0)), float(inserted.get("y", 0.0))).is_equal_approx(Vector2(110, 76)))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[idx]) == 1)
check("vertex_created emitted with shape and vertex index", created.size() == 1 and int(created[0][0]) == 0 and int(created[0][1]) == idx)
await _free_canvas(canvas)
func _test_segment_hit_test() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
check("segment hit above top edge", canvas.hit_test_segment(Vector2(110, 70)) == 0)
check("segment hit left of left edge", canvas.hit_test_segment(Vector2(70, 90)) == 0)
check("segment hit misses interior", canvas.hit_test_segment(Vector2(100, 100)) == -1)
check("segment hit misses empty canvas", canvas.hit_test_segment(Vector2(400, 400)) == -1)
canvas.add_primitive("line", Vector2(300, 300))
check("segment hit on open line", canvas.hit_test_segment(Vector2(300, 306)) == 1)
canvas.select(1)
check("segment hit frontmost wins", canvas.hit_test_segment(Vector2(300, 306)) == 1)
await _free_canvas(canvas)
func _test_reshaped_flag() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("circle", Vector2(100, 100), {"radius": 40.0})
check("new circle not reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
check("export carries reshaped false", not bool(canvas.get_shape_data()[0].get("reshaped", true)))
canvas.insert_vertex(0, 0, Vector2(60, 60))
check("insert vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.set_shape_radius(0, 60.0)
check("set_shape_radius clears reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
canvas.remove_vertex(0, 0)
check("remove vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
canvas.select(1)
canvas.scale_selected(Vector2(2.0, 2.0))
check("uniform scale keeps circle primitive", not bool(canvas.shapes[1].get("reshaped", true)))
canvas.scale_selected(Vector2(2.0, 1.0))
check("non-uniform scale reshapes circle", bool(canvas.shapes[1].get("reshaped", false)))
canvas.add_primitive("box", Vector2(500, 500))
canvas.select(2)
canvas.translate_selected(Vector2(10, 0))
check("translate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.rotate_selected(0.5)
check("rotate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.mirror_x(2)
check("mirror does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.insert_vertex(2, 0, Vector2(400, 400))
check("vertex insert reshapes polygon", bool(canvas.shapes[2].get("reshaped", false)))
await _free_canvas(canvas)
func _test_selection_halo() -> void:
check("selection halo is dark", PROP_CANVAS.SELECTION_HALO_COLOR.get_luminance() < 0.3)
check("selection core is bright", PROP_CANVAS.SELECTION_CORE_COLOR.get_luminance() > 0.7)
+1
View File
@@ -0,0 +1 @@
uid://yr00a8ban8vp
+252
View File
@@ -0,0 +1,252 @@
# Phase 9 Prop Editor - headless regression checks for the .prop schema
# (PropFile: round-trip, version rejection, defaults, flattening, sanitize).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_file.gd"
extends SceneTree
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP FILE TEST ===")
await _test_preset_conversion()
await _test_to_file_dict()
await _test_round_trip()
await _test_version_rejection()
await _test_missing_key_defaults()
await _test_flattening()
await _test_sanitize_shape()
await _test_save_load()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _hex(v: Vector2) -> Dictionary:
return {"x": v.x, "y": v.y}
func _box_shape(points: Array, fill: String = "#112233", outline: String = "#445566") -> Dictionary:
return {
"shape_type": "polygon",
"points": points,
"fill_color": fill,
"outline_color": outline,
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 0, 0],
}
func _test_preset_conversion() -> void:
for i: int in PROP_FILE.PRESET_STRINGS.size():
var s: String = PROP_FILE.preset_to_string(i)
check("preset_to_string(%d) == PRESET_STRINGS[%d]" % [i, i], s == PROP_FILE.PRESET_STRINGS[i])
check("preset_from_string round trip %s" % s, PROP_FILE.preset_from_string(s) == i)
check("preset_from_string unknown -> NONE", PROP_FILE.preset_from_string("banana") == PROP_BLOCK.MaterialPreset.NONE)
check("preset_from_string case-insensitive", PROP_FILE.preset_from_string("WOOD") == PROP_BLOCK.MaterialPreset.WOOD)
check("preset_to_string clamps low", PROP_FILE.preset_to_string(-5) == PROP_FILE.PRESET_STRINGS[0])
check("preset_to_string clamps high", PROP_FILE.preset_to_string(99) == PROP_FILE.PRESET_STRINGS[PROP_FILE.PRESET_STRINGS.size() - 1])
check("shape_type_to_payload_type circle", PROP_FILE.shape_type_to_payload_type("circle") == PROP_BLOCK.ShapeType.CIRCLE)
check("shape_type_to_payload_type polygon", PROP_FILE.shape_type_to_payload_type("polygon") == PROP_BLOCK.ShapeType.POLYGON)
check("shape_type_to_payload_type unknown", PROP_FILE.shape_type_to_payload_type("line") == PROP_BLOCK.ShapeType.POLYGON)
func _test_to_file_dict() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("My Crate", PROP_BLOCK.MaterialPreset.WOOD, shapes)
check("to_file_dict version", str(data.get("version", "")) == PROP_FILE.FILE_VERSION)
check("to_file_dict prop_name", str(data.get("prop_name", "")) == "My Crate")
check("to_file_dict preset string", str(data.get("material_preset", "")) == "wood")
check("to_file_dict shapes count", (data.get("shapes", []) as Array).size() == 1)
var meta: Dictionary = data.get("metadata", {})
check("to_file_dict metadata present", not meta.is_empty())
var serialized: Dictionary = (data.get("shapes", []) as Array)[0]
check("serialized points are {x,y} dicts", serialized.get("points", []) is Array and ((serialized.get("points", []) as Array)[0] as Dictionary).has("x"))
check("serialized point values preserved", is_equal_approx(float(((serialized.get("points", []) as Array)[1] as Dictionary).get("x", -1.0)), 64.0))
check("serialized flags array", (serialized.get("vertex_flags", []) as Array).size() == 4)
func _test_round_trip() -> void:
var shapes: Array[Dictionary] = []
shapes.append(_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))]))
shapes.append({
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 3.0,
"closed": true,
"vertex_flags": [],
})
shapes.append({
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#00FF00",
"outline_color": "#0000FF",
"outline_width": 1.5,
"closed": false,
"vertex_flags": [0, 0],
"reshaped": true,
})
var data: Dictionary = PROP_FILE.to_file_dict("Round Trip", PROP_BLOCK.MaterialPreset.METAL, shapes)
var model: Dictionary = PROP_FILE.from_file_dict(data)
check("round trip non-empty", not model.is_empty())
check("round trip name", str(model.get("prop_name", "")) == "Round Trip")
check("round trip preset", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.METAL)
var parsed: Array = model.get("shapes", [])
check("round trip shapes count", parsed.size() == 3)
var poly: Dictionary = parsed[0]
check("round trip polygon points", (poly.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("round trip polygon point values", ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[1]).is_equal_approx(Vector2(64, 0)) and ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[2]).is_equal_approx(Vector2(64, 64)))
check("round trip polygon closed", bool(poly.get("closed", false)) == true)
check("round trip polygon fill", str(poly.get("fill_color", "")) == "#112233")
var circ: Dictionary = parsed[1]
check("round trip circle type", str(circ.get("shape_type", "")) == "circle")
check("round trip circle radius", is_equal_approx(float(circ.get("radius", 0.0)), 40.0))
check("round trip circle points rebuilt", (circ.get("points", PackedVector2Array()) as PackedVector2Array).size() == PROP_FILE.DEFAULT_CIRCLE_SEGMENTS)
var line: Dictionary = parsed[2]
check("round trip line closed", bool(line.get("closed", true)) == false)
check("round trip line width", is_equal_approx(float(line.get("outline_width", 0.0)), 1.5))
check("round trip reshaped preserved", bool(line.get("reshaped", false)) == true)
check("round trip reshaped defaults false", bool(poly.get("reshaped", true)) == false)
func _test_version_rejection() -> void:
check("reject missing version", PROP_FILE.from_file_dict({"prop_name": "x"}).is_empty())
check("reject future version", PROP_FILE.from_file_dict({"version": "9.9", "shapes": []}).is_empty())
check("reject non-dict data", PROP_FILE.from_file_dict([1, 2, 3]).is_empty())
check("accept json string", not PROP_FILE.from_file_dict(JSON.stringify({"version": "1.0", "shapes": []})).is_empty())
func _test_missing_key_defaults() -> void:
var data := {
"version": "1.0",
"shapes": [
{"shape_type": "polygon", "points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(16, 16))]},
{"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0))]},
],
}
var model: Dictionary = PROP_FILE.from_file_dict(data)
var shapes: Array = model.get("shapes", [])
check("defaults shapes parsed", shapes.size() == 2)
check("default preset NONE", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.NONE)
var s0: Dictionary = shapes[0]
check("default closed for polygon", bool(s0.get("closed", false)) == true)
check("default fill from tint", str(s0.get("fill_color", "")).to_lower() == PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.NONE).to_html().to_lower())
check("default outline darkened", str(s0.get("outline_color", "")) != "")
check("default width", is_equal_approx(float(s0.get("outline_width", 0.0)), 2.0))
check("default flags sized", (s0.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var s1: Dictionary = shapes[1]
check("missing shape_type -> polygon", str(s1.get("shape_type", "")) == "polygon")
func _test_flattening() -> void:
check("flatten empty -> {}", PROP_FILE.flatten_to_payload([]).is_empty())
var line_shape := {
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#AABBCC",
"outline_color": "#112233",
"outline_width": 2.0,
"closed": false,
"vertex_flags": [0, 0],
}
var poly_back := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))], "#101010", "#202020")
var poly_front := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))], "#FEFEFE", "#0F0F0F")
var circle_shape := {
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
var payload: Dictionary = PROP_FILE.flatten_to_payload([line_shape, poly_back, poly_front])
check("flatten prefers frontmost closed polygon", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON and (payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#FEFEFE")))
payload = PROP_FILE.flatten_to_payload([line_shape, circle_shape])
check("flatten falls back to circle", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.CIRCLE)
check("flatten circle radius", is_equal_approx(float(payload.get("radius", 0.0)), 40.0))
payload = PROP_FILE.flatten_to_payload([line_shape])
check("flatten line -> polygon payload", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON)
var pts: PackedVector2Array = payload.get("points", PackedVector2Array())
check("flatten line points sanitized to grid", pts.size() == 2 and pts[0].x == 0.0 and pts[1].x == 64.0)
var z_payload: Dictionary = PROP_FILE.flatten_to_payload([poly_front, poly_back])
check("flatten z-order: last closed polygon wins", (z_payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#101010")))
func _test_sanitize_shape() -> void:
var dirty := {
"shape_type": "polygon",
"points": [_hex(Vector2(16, 16)), _hex(Vector2(16, 0)), _hex(Vector2(16, 0)), _hex(Vector2(0, 0)), _hex(Vector2(0, 16))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 1, 0, 0],
}
var cleaned: Dictionary = PROP_FILE.sanitize_shape(dirty)
var pts: PackedVector2Array = cleaned.get("points", PackedVector2Array())
check("sanitize drops consecutive duplicate", pts.size() == 4)
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(pts))
var collinear := {
"shape_type": "polygon",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}
var cleaned2: Dictionary = PROP_FILE.sanitize_shape(collinear)
check("sanitize drops collinear middle vertex", (cleaned2.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("sanitize flags aligned", (cleaned2.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 4)
var circle := {
"shape_type": "circle",
"points": [],
"radius": 24.0,
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
check("sanitize passes circles through", is_equal_approx(float(PROP_FILE.sanitize_shape(circle).get("radius", 0.0)), 24.0))
func _test_save_load() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("Save Test", PROP_BLOCK.MaterialPreset.RUBBER, shapes)
var path := "user://prop_editor_test_save.prop"
check("save returns true", PROP_FILE.save(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("load returns dict", not loaded.is_empty())
check("load matches", loaded.get("prop_name", "") == "Save Test" and loaded.get("material_preset", "") == "rubber")
check("load missing -> {}", PROP_FILE.load("user://definitely_missing.prop").is_empty())
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://drq2g06sh58xs
+90
View File
@@ -0,0 +1,90 @@
# Phase 9 Prop Editor Round 3 - headless regression checks for the pure static
# helpers (window title, mode button text) and PropFile.save_safe atomic writes.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_round3.gd"
extends SceneTree
const PROP_EDITOR := preload("res://scripts/prop_editor.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP EDITOR ROUND 3 TEST ===")
await _test_window_title()
await _test_mode_button_text()
await _test_save_safe()
await _test_recent_list()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_window_title() -> void:
check("untitled no path no dirty", PROP_EDITOR.build_window_title("", "", false) == "Untitled")
check("name dirty", PROP_EDITOR.build_window_title("My Crate", "", true) == "My Crate *")
check("name path clean", PROP_EDITOR.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop")
check("untitled path dirty", PROP_EDITOR.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *")
check("whitespace name falls back", PROP_EDITOR.build_window_title(" ", "", false) == "Untitled")
check("extension kept in title", PROP_EDITOR.build_window_title("X", "res://props/ball.prop", false).ends_with("ball.prop"))
func _test_mode_button_text() -> void:
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
check("reshape label", PROP_EDITOR.mode_button_text(1) == "✏️ Reshape")
check("attach label", PROP_EDITOR.mode_button_text(2) == "📌 Attach")
check("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "📌 Attach")
check("negative clamps to first", PROP_EDITOR.mode_button_text(-5) == "🖐️ Arrange")
func _test_save_safe() -> void:
var path := "user://round3_autosave_test.prop"
var data := {
"version": "1.0",
"prop_name": "X",
"material_preset": "none",
"shapes": [],
"metadata": {"autosave": true, "source_path": "", "modified_unix": 123},
}
check("save_safe first write", PROP_FILE.save_safe(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("save_safe round trip", not loaded.is_empty() and loaded.get("prop_name", "") == "X" and bool((loaded.get("metadata", {}) as Dictionary).get("autosave", false)))
check("no tmp leftover", not FileAccess.file_exists(path + ".tmp"))
check("no bak leftover", not FileAccess.file_exists(path + ".bak"))
data["prop_name"] = "Y"
check("save_safe overwrite", PROP_FILE.save_safe(path, data))
var loaded2: Dictionary = PROP_FILE.load(path)
check("overwrite persisted", loaded2.get("prop_name", "") == "Y")
check("no leftovers after overwrite", not FileAccess.file_exists(path + ".tmp") and not FileAccess.file_exists(path + ".bak"))
DirAccess.remove_absolute(path)
func _test_recent_list() -> void:
var list: Array = PROP_EDITOR.update_recent_list([], "res://props/a.prop", 5)
check("recent empty list inserts", list == ["res://props/a.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/a.prop"], "res://props/a.prop", 5)
check("recent dedupe moves to front", list == ["res://props/a.prop", "res://props/b.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop"], "res://props/a.prop", 5)
check("recent insert keeps most-recent-first", list == ["res://props/a.prop", "res://props/b.prop", "res://props/c.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop", "res://props/d.prop", "res://props/e.prop", "res://props/f.prop"], "res://props/a.prop", 5)
check("recent trims to max", list.size() == 5 and list[0] == "res://props/a.prop" and list[4] == "res://props/e.prop")
+1
View File
@@ -0,0 +1 @@
uid://bc82lccw6jtf
+170
View File
@@ -0,0 +1,170 @@
# Phase 9 Prop Editor - headless regression checks for PropTheme
# (fallback hierarchy, invalid-input fallback, physics-not-themeable).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_theme.gd"
extends SceneTree
const PROP_THEME := preload("res://scripts/prop_theme.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THEME TEST ===")
await _test_default_file()
await _test_missing_file_fallback()
await _test_custom_override_and_fallback()
await _test_invalid_color_fallback()
await _test_invalid_number_fallback()
await _test_physics_not_themeable()
await _test_primitive_defaults()
await _test_preset_clamp()
await _test_ui_tokens()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_default_file() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from(PROP_THEME.DEFAULT_PATH)
check("committed theme loads", loaded)
check("grid_size default 15", theme.grid_size() == 15)
check("outline width default 2.0", is_equal_approx(theme.outline_width_default(), 2.0))
check("outline width min 0.5", is_equal_approx(theme.outline_width_min(), 0.5))
check("outline width max 32.0", is_equal_approx(theme.outline_width_max(), 32.0))
check("wood fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("wood outline from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(Color.from_string("#40290F", Color.BLACK)))
check("none fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("canvas bg color", theme.canvas_background_color().is_equal_approx(Color.from_string("#1F1F1F", Color.BLACK)))
func _test_missing_file_fallback() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from("res://definitely_missing_theme.json")
check("missing file reports false", not loaded)
check("missing file falls back to defaults", theme.grid_size() == 15 and is_equal_approx(theme.outline_width_default(), 2.0))
check("missing file keeps material tints", theme.material_tint(PROP_BLOCK.MaterialPreset.RUBBER, "fill").is_equal_approx(Color.from_string("#E63333", Color.BLACK)))
func _write_user_theme(text: String, path: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _test_custom_override_and_fallback() -> void:
var path := "user://prop_theme_test_custom.json"
_write_user_theme('{"version":"1.0","canvas":{"grid_size":33},"outline_widths":{"default":5.0}}', path)
var theme: PropTheme = PROP_THEME.new()
check("custom theme loads", theme.load_from(path))
check("custom grid override", theme.grid_size() == 33)
check("custom width override", is_equal_approx(theme.outline_width_default(), 5.0))
check("unoverridden key falls back", theme.material_tint(PROP_BLOCK.MaterialPreset.METAL, "fill").is_equal_approx(Color.from_string("#808C99", Color.BLACK)))
check("unoverridden primitive falls back", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
DirAccess.remove_absolute(path)
func _test_invalid_color_fallback() -> void:
var path := "user://prop_theme_test_badcolor.json"
_write_user_theme('{"version":"1.0","material_tints":{"wood":{"fill":"not-a-color","outline":"also bad"}}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid fill falls back to PropBlock tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD)))
check("invalid outline falls back to darkened tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD).darkened(0.55)))
DirAccess.remove_absolute(path)
func _test_invalid_number_fallback() -> void:
var path := "user://prop_theme_test_badnum.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":"wide"}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid number falls back", is_equal_approx(theme.outline_width_default(), 2.0))
check("number clamp low", is_equal_approx(theme.number("outline_widths.default", 2.0, 1.0, 10.0), 2.0))
var path2 := "user://prop_theme_test_clamp.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":500}}', path2)
var theme2: PropTheme = PROP_THEME.new()
theme2.load_from(path2)
check("number clamp high", is_equal_approx(theme2.number("outline_widths.default", 2.0, -INF, 32.0), 32.0))
DirAccess.remove_absolute(path)
DirAccess.remove_absolute(path2)
func _test_physics_not_themeable() -> void:
check("mass lives in PropBlock", is_equal_approx(PROP_BLOCK.mass_for(PROP_BLOCK.MaterialPreset.WOOD), 3.0))
check("bounce lives in PropBlock", is_equal_approx(PROP_BLOCK.physics_material(PROP_BLOCK.MaterialPreset.RUBBER).bounce, 0.85))
var theme: PropTheme = PROP_THEME.new()
check("theme default config has no physics section", not theme.value("physics", null) is Dictionary)
var path := "user://prop_theme_test_physics.json"
_write_user_theme('{"version":"1.0","physics":{"wood":{"mass":99.0}}}', path)
theme.load_from(path)
check("attempted physics override has no effect on accessors", is_equal_approx(theme.outline_width_default(), 2.0) and theme.grid_size() == 15)
DirAccess.remove_absolute(path)
func _test_primitive_defaults() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("box size_x default", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
check("ball radius default", is_equal_approx(theme.primitive_default("ball", "radius"), 24.0))
check("plank length default", is_equal_approx(theme.primitive_default("plank", "length"), 160.0))
check("triangle height default", is_equal_approx(theme.primitive_default("triangle", "height"), 48.0))
check("circle segments default", is_equal_approx(theme.primitive_default("circle", "segments"), 12.0))
check("unknown key uses caller fallback", is_equal_approx(theme.primitive_default("box", "radius", 7.5), 7.5))
check("unknown primitive uses caller fallback", is_equal_approx(theme.primitive_default("blob", "size_x", 3.0), 3.0))
func _test_preset_clamp() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("out-of-range preset clamps to NONE", theme.material_tint(999, "fill").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill")))
check("negative preset clamps to NONE", theme.material_tint(-3, "outline").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "outline")))
func _test_ui_tokens() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("font_body 18", theme.font_body() == 18)
check("font_header 24", theme.font_header() == 24)
check("font_menu 22", theme.font_menu() == 22)
check("font_menu_bar 22", theme.font_menu_bar() == 22)
check("font_mode 26", theme.font_mode() == 26)
check("font_status 18", theme.font_status() == 18)
check("font_prop_name 20", theme.font_prop_name() == 20)
check("font_context_menu 22", theme.font_context_menu() == 22)
check("button_min_height 36", theme.button_min_height() == 36)
check("row_min_height 36", theme.row_min_height() == 36)
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
var path := "user://prop_theme_test_ui.json"
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
var custom: PropTheme = PROP_THEME.new()
custom.load_from(path)
check("ui font override", custom.font_body() == 24)
check("unoverridden ui token falls back", custom.font_header() == 24)
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://bo7bwalcfi8x7
+80
View File
@@ -0,0 +1,80 @@
# Phase 9 Prop Editor - headless regression checks for the ThumbnailCache
# extension (prop_file_key / prop_file_png) and PNG round-trip.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_thumb.gd"
extends SceneTree
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THUMB TEST ===")
await _test_prop_file_keys()
await _test_builtin_prop_png_unchanged()
await _test_png_round_trip()
await _test_missing_file()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_prop_file_keys() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var path := "user://prop_thumb_key_test.prop"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("{}")
f.close()
var key: String = cache.prop_file_key(path)
var mtime: int = FileAccess.get_modified_time(path)
check("prop_file_key includes file namespace and mtime", key.begins_with("file_") and key.ends_with("_%d" % mtime))
check("prop_file_png uses PROP_DIR", cache.prop_file_png(path) == "user://thumbnails/props/%s.png" % key)
check("prop_file_key missing file still formats", cache.prop_file_key("res://nope.prop").begins_with("file_") and cache.prop_file_key("res://nope.prop").ends_with("_0"))
DirAccess.remove_absolute(path)
func _test_builtin_prop_png_unchanged() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("prop_png keeps version key", cache.prop_png("crate") == "user://thumbnails/props/crate_v%d.png" % THUMBNAIL_CACHE.PROP_VERSION)
check("stickman_png unchanged", cache.stickman_png("abc_123") == "user://thumbnails/stickmen/abc_123.png")
func _test_png_round_trip() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var img := Image.create_empty(4, 4, false, Image.FORMAT_RGBA8)
img.fill(Color(1.0, 0.0, 0.0, 1.0))
var tex := ImageTexture.create_from_image(img)
var png_path := "user://thumbnails/props/test_roundtrip_v9.png"
var err: Error = cache.save_png(tex, png_path)
check("save_png succeeds", err == OK)
check("png exists on disk", FileAccess.file_exists(png_path))
cache.clear_memory()
var loaded: Texture2D = cache.load_png(png_path)
check("load_png round trip", loaded != null and loaded.get_size() == Vector2(4, 4))
check("save_png null texture rejected", cache.save_png(null, "user://thumbnails/props/null_v9.png") != OK)
DirAccess.remove_absolute(png_path)
func _test_missing_file() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("load_png missing -> null", cache.load_png("user://thumbnails/props/definitely_missing.png") == null)
+1
View File
@@ -0,0 +1 @@
uid://bkmhs37lfr7em
+1
View File
@@ -0,0 +1 @@
.venv/
+98
View File
@@ -0,0 +1,98 @@
import asyncio
from pathlib import Path
from notebooklm import NotebookLMClient
RELATIVE_TARGETS = [
"../../AGENTS.md",
"../../README.md",
"../../plans",
"../../specs"
]
NOTEBOOK_TITLE = "Stickman Studio"
def gather_markdown_files(base_path: Path):
"""Recursively collect all .md files from specified relative paths."""
files_to_upload = []
for rel_path in RELATIVE_TARGETS:
target = (base_path / rel_path).resolve()
if target.is_file() and target.suffix.lower() in [".md", ".markdown"]:
files_to_upload.append(target)
elif target.is_dir():
for md_file in target.rglob("*.md"):
files_to_upload.append(md_file)
else:
print(f"⚠️ Target path not found: {target}")
return list(dict.fromkeys(files_to_upload))
async def main():
script_dir = Path(__file__).parent.resolve()
async with await NotebookLMClient.from_storage() as client:
# 1. Locate 'Stickman Studio'
print(f"🔍 Searching for notebook: '{NOTEBOOK_TITLE}'...")
notebooks = await client.notebooks.list()
target_notebook = next(
(nb for nb in notebooks if getattr(nb, 'title', None) == NOTEBOOK_TITLE or getattr(nb, 'display_name', None) == NOTEBOOK_TITLE),
None
)
if not target_notebook:
print(f"❌ Could not find notebook named '{NOTEBOOK_TITLE}'.")
return
notebook_id = getattr(target_notebook, 'id', None) or getattr(target_notebook, 'name', '')
print(f"✅ Found notebook ID: {notebook_id}")
# 2. Safely filter and delete ONLY non-chat file sources
existing_sources = await client.sources.list(notebook_id=notebook_id)
sources_to_delete = []
for source in existing_sources:
# Check source properties to identify saved chats
mime_type = getattr(source, 'original_mime_type', None) or getattr(source, 'originalMimeType', '')
source_type = getattr(source, 'source_type', None) or getattr(source, 'type', '')
# SAFEGUARD: Keep any source identified as a chat note
is_chat = 'chat' in str(mime_type).lower() or 'chat' in str(source_type).lower()
if is_chat:
source_title = getattr(source, 'title', None) or getattr(source, 'display_name', 'Chat')
print(f"🛡️ Preserving past chat: '{source_title}'")
else:
sources_to_delete.append(source)
if sources_to_delete:
print(f"\n🗑️ Removing {len(sources_to_delete)} file sources (keeping chats intact)...")
for source in sources_to_delete:
source_id = getattr(source, 'id', None) or getattr(source, 'name', '')
source_title = getattr(source, 'title', None) or getattr(source, 'display_name', 'File Source')
await client.sources.delete(notebook_id=notebook_id, source_id=source_id)
print(f" - Removed: {source_title}")
else:
print("ℹ️ No old file sources to remove.")
# 3. Collect target Markdown files
md_files = gather_markdown_files(script_dir)
print(f"\n📁 Found {len(md_files)} local markdown files to attach.")
# 4. Upload Markdown files
for md_path in md_files:
rel_display = md_path.relative_to(script_dir.parent.parent)
print(f" ⬆️ Uploading: {rel_display}")
await client.sources.add_file(
notebook_id=notebook_id,
file_path=str(md_path)
)
print(f"\n🎉 Sync complete! All local docs updated without touching your chat notes.")
if __name__ == "__main__":
asyncio.run(main())