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 fbce638bc5 Refactor stickman rig and ragdoll mechanics; enhance pose restoration and stability
- Disabled constraints for the stickman rig's head bone to allow for smoother animations.
- Improved mode transitions in the sandbox stage to preserve stickman poses between EDIT and DIRECT modes.
- Added side profile stabilization for knee bends in the ragdoll component to prevent unnatural poses during physics interactions.
- Enhanced the snap-to-standing functionality to restore the stickman's original pose accurately.
- Introduced new tests for validating pose preservation during mode transitions and ragdoll behavior during walking animations.
- Created a new scene for testing stickman bending and ragdoll transitions.
2026-09-18 22:44:48 -04:00
81 changed files with 9549 additions and 183 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."_
+25 -2
View File
@@ -242,6 +242,13 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
`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
@@ -317,6 +324,11 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
~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"]`,
@@ -897,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 +
@@ -973,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
@@ -1245,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
+8 -1
View File
@@ -885,7 +885,14 @@ Each entry in the `shapes` array:
## File format (`.prop`)
The **Prop Editor** (Phase 9) 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).
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
{
+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.
+19
View File
@@ -45,6 +45,16 @@ This document tracks known technical debt, optimization opportunities, and minor
| 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) |
---
@@ -88,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_
+206
View File
@@ -0,0 +1,206 @@
{
"version": "1.0",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 328.366180419922,
"y": -711.875732421875
},
"zoom": 0.375657439231873
},
"objects": [
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -336.0,
"y": -972.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "walk_to",
"target": {
"x": 696.0,
"y": -612.0
}
},
{
"type": "ragdoll"
},
{
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": -167.4599609375,
"y": -596.639892578125
}
},
{
"type": "ragdoll"
},
{
"type": "recover"
},
{
"type": "walk_to",
"target": {
"x": -792.1015625,
"y": -615.075927734375
}
}
]
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -384.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -192.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": 0.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": 192.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 384.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 576.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 768.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 960.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 1152.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 1344.0,
"y": -576.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1789757195,
"modified_unix": 1789779264.018,
"autosave": false,
"source_path": ""
}
}
+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.
+119
View File
@@ -77,3 +77,122 @@ Update path references across project markdown files:
3. **Test Harness Verification**: Run `res://scenes/test_harness.tscn` via **F6** to ensure walk, speak, wait, and ragdoll actions execute cleanly.
4. **Automated Suite**: Run the full project unit test suite in `tests/` to confirm all assertions pass.
---
### **Completion Record (verified implementation)**
Phase 8 is implemented. The relocated scene is `res://scenes/stickman_rig.tscn`; its root `Master` node is a `StickmanRig` facade over three authored component children. The facade remains the compatibility boundary for callers while each component owns one subsystem:
```text
Master (StickmanRig)
├── RigComponents
│ ├── Locomotion (StickmanLocomotion)
│ │ ├── terrain probing / ground following
│ │ ├── walking and airborne motion
│ │ └── collision proxies and landing reactions
│ ├── Ragdoll (StickmanRagdoll)
│ │ ├── procedural RigidBody2D + PinJoint2D graph
│ │ ├── rest detection and momentum handoff
│ │ └── capture, re-anchor, and stand-up recovery
│ └── ActionRunner (StickmanActionRunner)
│ ├── queued walk / speak / wait actions
│ └── ragdoll and recovery requests
├── Body/* mounted visuals, driven by RemoteTransform2D nodes
├── Skeleton2D/Torso/* Bone2D chain and IK modifications
├── IK_Targets/* six animation/recovery markers
└── AnimationPlayer canonical walk clips and authored animation data
```
`StickmanRig` owns cross-cutting state (`ANIMATED`, `RAGDOLL`, `RECOVERING`), facing profiles, bend flags, body z-order, and signal/API forwarding. `StickmanRagdoll` owns physics construction and recovery; `StickmanLocomotion` owns terrain/motion; `StickmanActionRunner` owns director queue execution. Component references are resolved and configured in `StickmanRig._ready()` and are null-guarded with warnings.
#### Ragdoll handoff and recovery
The runtime handoff preserves the current solved IK pose before physics takes ownership:
```text
ANIMATED
│ set_ragdoll(true)
▼
capture pre-ragdoll markers, bone rotations, facing, bend flags, knee sides
│ build bodies from current bone endpoints; copy mounted Body/* visuals
▼
RAGDOLL ── rest + timeout ──▶ capture body pose + probe terrain
│ destroy physics graph
│ re-anchor root at captured landing feet
▼
RECOVERING ── tween markers/bones toward captured entry pose ──▶ ANIMATED
```
The important dictionaries have these shapes (vector values are runtime `Vector2`s):
```gdscript
_spawn_pose = {
"Torso": {"pos": captured_torso_pos, "rot": captured_torso_rotation},
"Head": {"pos": captured_head_tip, "rot": 0.0},
"Left_Hand": {"pos": captured_left_wrist, "rot": 0.0},
"Right_Hand": {"pos": captured_right_wrist, "rot": 0.0},
"Left_Leg": {"pos": captured_left_ankle, "rot": 0.0},
"Right_Leg": {"pos": captured_right_ankle, "rot": 0.0},
}
_captured_pose["left_lower_leg"] = {
"pos": rig_local_body_position,
"rot": body_global_rotation - rig_global_rotation,
"half": capsule_half_length,
}
```
`_spawn_pose` is initialized from the adapted skeleton by `StickmanFactory.spawn_from_data()` and is the placement ghost's authored pose; the values therefore depend on the loaded `.stk`, rather than on `STAND_POSE`. `_pre_ragdoll_state` is the recovery endpoint: it stores the six markers, limb/head rotations, facing profile, bend flags, head LookAt state, knee-side information, and chain-space offsets. `_captured_pose` is the fallen physics pose used to reconstruct the skeleton and landing position.
#### Stage mode flow and pose preservation
EDIT and DIRECT are two views of the same frozen, non-simulating world. The mode transition receives `previous_mode` so it can avoid an unnecessary reset:
```text
EDIT ◄──────────────► DIRECT
│ │
└──────────┬───────────┘
▼
PLAY
│
set_mode(EDIT or DIRECT)
│
▼
cleanup + restore spawn pose
```
| Transition | Stage behavior | Rig pose behavior |
|---|---|---|
| EDIT → DIRECT | Hide edit controls; keep world frozen | Preserve exact live pose and IK markers |
| DIRECT → EDIT | Restore edit controls; keep world frozen | Preserve exact live pose and IK markers |
| EDIT/DIRECT → PLAY | Enable simulation, terrain following, and action queues | Begin from the current authored/editor pose |
| PLAY → EDIT/DIRECT | Stop queues, disable terrain following, freeze physics, restore object transforms | `snap_to_standing()` restores the factory-captured `_spawn_pose` |
The root cause of the verified regression was that `snap_to_standing()` restored the legacy hardcoded `STAND_POSE`. That generic pose lowered the arms and rotated the head whenever PLAY cleanup ran. The central fix is to restore `_spawn_pose` when it exists, with `STAND_POSE` retained only as a compatibility fallback for rigs that have no captured spawn pose. Ragdoll recovery is separate: its stand-up tween still targets `_pre_ragdoll_state`, so recovery returns to the pose captured at ragdoll entry rather than the factory pose.
#### Facing, walk-phase bend stabilization, and head tracking
`FacingProfile.LEFT` is a whole-rig X mirror (`Master.scale.x = -1`); RIGHT and FORWARD use identity scale. Runtime walking always plays the canonical `walk_right` clip and mirrors it for LEFT. The profile still applies provisional per-joint bend flags and z-order tables. For side profiles, a walk keyframe can place a leg exactly straight, leaving PinJoint2D with no geometric knee side. The ragdoll component therefore:
- permits the straight hinge boundary (`0°`) and the intended fold range (`±150°`) for LEFT/RIGHT profiles;
- adds only a small 3° profile-relative bias at the near-straight ragdoll handoff; and
- checks each rendered RAGDOLL frame, correcting only an actual wrong-side knee crossing while preserving normal physical motion.
Head tracking is owned by the `SkeletonModification2DLookAt` targeting `IK_Targets/Head`. RIGHT/FORWARD enable it. LEFT disables LookAt and pins the canonical head rotation because the whole-rig mirror must remain mirror-invariant; this is intentional rather than a missing target update. The scene's authored head constraint is disabled so the active LookAt can follow moved markers without the old constraint interpretation pinning it.
#### Regression coverage
The completed implementation was checked with focused headless tests and the existing suites:
| Test | Result | Coverage |
|---|---:|---|
| `tests/test_phase8_rig_modularization.gd` | 23/23 | relocated scene, component ownership, head tracking, facing flags, ragdoll facade |
| `tests/test_phase8_edit_direct_pose.gd` | 45/45 | EDIT↔DIRECT round trip and EDIT→PLAY→DIRECT pose/marker restoration |
| `tests/test_phase8_ragdoll_walk_handoff.gd` | 80/80 | LEFT/RIGHT profiles across multiple walk animation phases and physics frames |
| `tests/test_phase45_collision_bodies.gd` | 223/223 | collision proxies, reactions, and PLAY cleanup to authored pose |
| `tests/test_phase3c_walk_recovery.gd` | 185/185 | walk, ragdoll, recovery, and action integration |
| `tests/test_phase6a_airborne_landing.gd` | 24/24 | airborne pose, landing, and ragdoll cleanup |
| `tests/test_phase4b_stage.gd` | 53/53 | stage mode UI/state transitions and pending-action cleanup |
The plan's original acceptance criteria remain the historical intent; this record captures the as-built ownership and the follow-up fixes verified after user testing.
+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.
+2 -1
View File
@@ -41,7 +41,8 @@
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF"
"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"
}
+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"
}
+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")
+32 -8
View File
@@ -28,7 +28,7 @@ script = ExtResource("1_editor_script")
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
offset_bottom = 44.0
grow_horizontal = 2
[node name="MenuBar" type="MenuBar" parent="TopRow"]
@@ -45,8 +45,8 @@ theme_override_constants/separation = 8
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
offset_top = 44.0
offset_bottom = 88.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
@@ -61,12 +61,13 @@ layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Prop"
[node name="MainLayout" type="HBoxContainer" parent="."]
[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 = 68.0
offset_top = 92.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
@@ -78,6 +79,7 @@ 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"]
@@ -89,7 +91,24 @@ unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea"]
[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
@@ -98,13 +117,18 @@ mouse_filter = 0
clip_contents = true
script = ExtResource("2_canvas_script")
[node name="InspectorColumn" type="VBoxContainer" parent="MainLayout"]
[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="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn"]
[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
+5 -5
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
@@ -57,11 +58,7 @@ joint_two_bone2d_node = NodePath("Torso/LeftUpperLeg/LeftLowerLeg")
bone_index = 1
bone2d_node = NodePath("Torso/Head")
target_nodepath = NodePath("../IK_Targets/Head")
enable_constraint = true
constraint_angle_min = -180.0
constraint_angle_max = 180.0
constraint_angle_invert = false
constraint_in_localspace = false
enable_constraint = false
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
resource_local_to_scene = true
@@ -339,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
# ---------------------------------------------------------------------------
+449 -14
View File
@@ -28,6 +28,11 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
## Emitted only when the edit mode actually changes.
signal mode_changed(mode: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
signal whole_prop_resize_requested(factor: float)
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
# ---------------------------------------------------------------------------
# Enums / constants
@@ -37,11 +42,12 @@ enum ShapeType { POLYGON, CIRCLE, LINE }
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }
enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
## public math APIs stay mode-agnostic.
enum EditMode { TRANSFORM, POINTS }
enum EditMode { TRANSFORM, POINTS, ATTACH }
const HANDLE_RADIUS: float = 3.0
const HANDLE_RADIUS_SELECTION: float = 8.0
@@ -62,9 +68,13 @@ const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7)
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const PropFileScript := preload("res://scripts/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/whole_stickman_preview.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -112,6 +122,30 @@ var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _context_menu: PopupMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
var _show_origin_marker: bool = true
var _show_stickman_guide: bool = false
var _whole_prop_resize_enabled: bool = false
var _whole_prop_dragging: bool = false
var _whole_prop_start_shapes: Array[Dictionary] = []
var _whole_prop_preview_shapes: Array[Dictionary] = []
var _whole_prop_start_anchors: Dictionary = {}
var _whole_prop_preview_anchors: Dictionary = {}
var _whole_prop_start_bounds: Rect2 = Rect2()
var _whole_prop_start_pointer: Vector2 = Vector2.ZERO
var _whole_prop_corner: Vector2 = Vector2.ZERO
var _whole_prop_factor: float = 1.0
var _whole_prop_drag: WholePropDrag = WholePropDrag.NONE
var _whole_prop_delta: Vector2 = Vector2.ZERO
var _whole_prop_total_angle: float = 0.0
var _whole_prop_previous_angle: float = 0.0
var _whole_prop_preview_angle: float = 0.0
const SCALE_COORDINATE_LIMIT: float = 1000000.0
const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0
const WHOLE_PROP_FACTOR_MIN: float = 0.01
const WHOLE_PROP_FACTOR_MAX: float = 10.0
# Drag state
var _drag_mode: DragMode = DragMode.NONE
@@ -142,6 +176,9 @@ func _ready() -> void:
draw.connect(_on_canvas_draw)
if not gui_input.is_connected(_on_canvas_gui_input):
gui_input.connect(_on_canvas_gui_input)
_anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
_context_menu = PopupMenu.new()
_context_menu.name = "ContextMenu"
add_child(_context_menu)
@@ -162,10 +199,12 @@ func _process(delta: float) -> void:
# ---------------------------------------------------------------------------
func set_edit_mode(mode: int) -> void:
var m: int = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
if m == _edit_mode:
return
_edit_mode = m
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
_dragging_vertex = -1
queue_redraw()
@@ -175,6 +214,162 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
queue_redraw()
func get_anchor_data() -> Dictionary:
return _anchor_overlay.get_anchor_data() if _anchor_overlay != null else {}
func select_anchor(anchor_id: String) -> void:
if _anchor_overlay != null: _anchor_overlay.select_anchor(anchor_id); queue_redraw()
func get_selected_anchor_id() -> String:
return _anchor_overlay.get_selected_anchor_id() if _anchor_overlay != null else ""
func set_show_inactive_anchors(enabled: bool) -> void:
if _anchor_overlay != null: _anchor_overlay.set_show_inactive(enabled); queue_redraw()
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
func set_whole_prop_resize_enabled(enabled: bool) -> void:
if _whole_prop_resize_enabled == enabled:
return
cancel_whole_prop_resize()
_whole_prop_resize_enabled = enabled and has_shape_geometry() and _edit_mode == EditMode.TRANSFORM
queue_redraw()
func set_whole_prop_transform_enabled(enabled: bool) -> void:
set_whole_prop_resize_enabled(enabled)
func is_whole_prop_resize_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_transform_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_resize_dragging() -> bool:
return _whole_prop_dragging
func is_whole_prop_transform_dragging() -> bool:
return _whole_prop_dragging
func cancel_whole_prop_resize() -> void:
_whole_prop_dragging = false
_whole_prop_preview_shapes.clear()
_whole_prop_preview_anchors.clear()
_whole_prop_start_shapes.clear()
_whole_prop_start_anchors.clear()
_whole_prop_factor = 1.0
_whole_prop_drag = WholePropDrag.NONE
_whole_prop_delta = Vector2.ZERO
_whole_prop_total_angle = 0.0
_whole_prop_preview_angle = 0.0
queue_redraw()
func cancel_whole_prop_transform() -> void:
cancel_whole_prop_resize()
func set_show_stickman_guide(enabled: bool) -> void:
_show_stickman_guide = enabled
queue_redraw()
func get_show_stickman_guide() -> bool:
return _show_stickman_guide
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
if not _shape_has_geometry(shape): continue
var rect: Rect2 = _shape_bbox(shape)
if not found: result = rect; found = true
else: result = result.merge(rect)
return result
func has_shape_geometry() -> bool:
for shape: Dictionary in shapes:
if _shape_has_geometry(shape): return true
return false
func can_scale_all_shapes(factor: float) -> bool:
if not is_finite(factor) or factor <= 0.0 or is_equal_approx(factor, 1.0) or not has_shape_geometry():
return false
for shape: Dictionary in shapes:
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for point: Vector2 in points:
var scaled: Vector2 = point * factor
if not is_finite(scaled.x) or not is_finite(scaled.y) or absf(scaled.x) > SCALE_COORDINATE_LIMIT or absf(scaled.y) > SCALE_COORDINATE_LIMIT:
return false
if str(shape.get("shape_type", "")) == "circle":
var radius: float = float(shape.get("radius", 0.0)) * factor
if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT:
return false
return true
func can_translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var moved: Vector2 = point + delta
if not is_finite(moved.x) or not is_finite(moved.y) or absf(moved.x) > SCALE_COORDINATE_LIMIT or absf(moved.y) > SCALE_COORDINATE_LIMIT: return false
return true
func can_rotate_all_shapes(angle_rad: float) -> bool:
if not has_shape_geometry() or not is_finite(angle_rad) or is_zero_approx(angle_rad): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var rotated: Vector2 = point.rotated(angle_rad)
if not is_finite(rotated.x) or not is_finite(rotated.y) or absf(rotated.x) > SCALE_COORDINATE_LIMIT or absf(rotated.y) > SCALE_COORDINATE_LIMIT: return false
return true
func rotate_all_shapes(angle_rad: float) -> bool:
if not can_rotate_all_shapes(angle_rad): return false
for i: int in shapes.size():
var sd: Dictionary = shapes[i].duplicate(true); var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle_rad)
sd["points"] = pts; shapes[i] = sd
queue_redraw(); shape_changed.emit(get_shape_data()); return true
func scale_all_shapes(factor: float) -> bool:
if not can_scale_all_shapes(factor):
return false
for i: int in shapes.size():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for i: int in shapes.size():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size(): points[j] += delta
shape["points"] = points
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func focus_world_point(world_point: Vector2) -> void:
if not is_finite(world_point.x) or not is_finite(world_point.y): return
_pan_offset = size * 0.5 - world_point * _zoom
queue_redraw()
func set_origin_marker_color(color: Color) -> void:
_origin_marker_color = color
queue_redraw()
# ---------------------------------------------------------------------------
# Public API — shape data
@@ -183,6 +378,7 @@ func get_edit_mode() -> int:
func set_shape_data(data: Variant) -> void:
## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y}
## format) or a single Dictionary.
set_whole_prop_resize_enabled(false)
shapes.clear()
if data is Array:
for item: Variant in data as Array:
@@ -193,8 +389,7 @@ func set_shape_data(data: Variant) -> void:
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
reset_view()
queue_redraw()
@@ -208,13 +403,12 @@ func get_shape_data() -> Array[Dictionary]:
func clear() -> void:
## Remove all shapes without emitting shape_changed.
set_whole_prop_resize_enabled(false)
shapes.clear()
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
reset_view()
func select(idx: int) -> void:
@@ -566,8 +760,7 @@ func get_snap_enabled() -> bool:
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
focus_world_point(Vector2.ZERO)
func set_has_clipboard(state: bool) -> void:
@@ -595,20 +788,78 @@ func global_to_world(global_pos: Vector2) -> Vector2:
func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
_draw_stickman_guide()
if shapes.is_empty():
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5))
var font := get_theme_default_font()
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
return
for i: int in shapes.size():
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
var drawn_shapes: Array[Dictionary] = _whole_prop_preview_shapes if _whole_prop_dragging else shapes
for i: int in drawn_shapes.size():
var selected: bool = not _whole_prop_resize_enabled and i == _selected_shape_idx
_draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1)
_draw_origin_marker()
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
if _whole_prop_resize_enabled:
_draw_whole_prop_resize_gizmos()
elif _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
_draw_selection_gizmos(shapes[_selected_shape_idx])
if _edit_mode == EditMode.ATTACH and _anchor_overlay != null:
_anchor_overlay.draw_gizmos(self, _zoom)
func _draw_origin_marker() -> void:
if not _show_origin_marker and not _whole_prop_resize_enabled:
return
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
var radius: float = 6.0 * inv_zoom
var halo: Color = Color(0.0, 0.0, 0.0, 0.75)
draw_circle(Vector2.ZERO, radius + 2.0 * inv_zoom, halo, false, 3.0 * inv_zoom)
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), halo, 3.0 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), halo, 3.0 * inv_zoom)
draw_circle(Vector2.ZERO, radius, _origin_marker_color, false, 1.25 * inv_zoom)
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom)
func _draw_stickman_guide() -> void:
if not _show_stickman_guide:
return
var width: float = 2.0 / maxf(_zoom, 0.001)
var color: Color = Color(0.75, 0.82, 0.9, 0.22)
var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS
for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES:
var first: String = str(bone[0])
var second: String = str(bone[1])
if joints.has(first) and joints.has(second):
draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width)
if joints.has("Head"):
draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width)
func _draw_whole_prop_resize_gizmos() -> void:
var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
draw_rect(bounds, SELECTION_HALO_COLOR, false, 4.0 / _zoom)
draw_rect(bounds, SELECTION_CORE_COLOR, false, 2.0 / _zoom)
var half: float = 7.0 / _zoom
for corner: Vector2 in _rect_corners(bounds):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_CORE_COLOR, 2.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_CORE_COLOR, 2.0 / _zoom)
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR)
draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR)
var anchors: Dictionary = _whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()
for id: String in anchors:
if id == "origin":
continue
var anchor: Dictionary = anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var color: Color = Color(1.0, 0.85, 0.2, 0.9) if str(anchor.get("role", "")) == "grip" else Color(0.8, 0.8, 0.8, 0.7)
draw_circle(pos, 4.0 / _zoom, color)
func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
@@ -737,6 +988,21 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
var world_pos := _screen_to_world(mb.position)
if _edit_mode == EditMode.ATTACH:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed: _anchor_overlay.begin_pointer(world_pos, _zoom)
else: _anchor_overlay.end_pointer()
return
if mb.button_index == MOUSE_BUTTON_RIGHT: return
if _whole_prop_resize_enabled:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_begin_whole_prop_resize(world_pos)
else:
_end_whole_prop_resize()
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
return
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb, world_pos)
return
@@ -753,6 +1019,13 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
_pan_start = mm.position
queue_redraw()
return
if _edit_mode == EditMode.ATTACH:
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
return
if _whole_prop_resize_enabled:
if _whole_prop_dragging:
_update_whole_prop_resize(_screen_to_world(mm.position), mm.ctrl_pressed)
return
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
@@ -1142,6 +1415,161 @@ func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vect
func _emit_changed() -> void:
shape_changed.emit(get_shape_data())
func _begin_whole_prop_resize(world_pos: Vector2) -> void:
var bounds: Rect2 = get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
var corner: Vector2 = _whole_prop_hit_corner(bounds, world_pos)
var drag: WholePropDrag = WholePropDrag.SCALE if corner != Vector2.INF else WholePropDrag.NONE
if drag == WholePropDrag.NONE:
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rotation_dot) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom: drag = WholePropDrag.ROTATE
if drag == WholePropDrag.NONE:
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_in_shape(i, world_pos): drag = WholePropDrag.MOVE; break
if drag == WholePropDrag.NONE: return
_whole_prop_dragging = true
_whole_prop_start_shapes = shapes.duplicate(true)
_whole_prop_start_anchors = get_anchor_data().duplicate(true)
_whole_prop_start_bounds = bounds
_whole_prop_start_pointer = world_pos
_whole_prop_corner = corner
_whole_prop_drag = drag
if drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: cancel_whole_prop_resize(); return
_whole_prop_previous_angle = world_pos.angle()
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
func _update_whole_prop_resize(world_pos: Vector2, ctrl_pressed: bool = false) -> void:
if _whole_prop_drag == WholePropDrag.MOVE:
var delta: Vector2 = world_pos - _whole_prop_start_pointer
if _snap_enabled: delta = Vector2(snappedf(delta.x, float(_grid_size)), snappedf(delta.y, float(_grid_size)))
if delta.is_zero_approx():
_whole_prop_delta = Vector2.ZERO
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_translate_all_shapes(delta) or not bool(PropAnchorDataScript.try_translate_non_origin(_whole_prop_start_anchors, delta).get("ok", false)): return
_whole_prop_delta = delta; _whole_prop_preview_shapes = _translated_shape_copy(_whole_prop_start_shapes, delta); _whole_prop_preview_anchors = _translated_anchor_copy(_whole_prop_start_anchors, delta); queue_redraw(); return
if _whole_prop_drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: return
var current: float = world_pos.angle(); _whole_prop_total_angle += wrapf(current - _whole_prop_previous_angle, -PI, PI); _whole_prop_previous_angle = current
var angle: float = snappedf(_whole_prop_total_angle, deg_to_rad(15.0)) if ctrl_pressed else _whole_prop_total_angle
if is_zero_approx(angle):
_whole_prop_preview_angle = 0.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_rotate_all_shapes(angle) or not bool(PropAnchorDataScript.try_rotate_non_origin(_whole_prop_start_anchors, angle).get("ok", false)): return
_whole_prop_preview_angle = angle
_whole_prop_preview_shapes = _rotated_shape_copy(_whole_prop_start_shapes, angle); _whole_prop_preview_anchors = _rotated_anchor_copy(_whole_prop_start_anchors, angle); queue_redraw(); return
var direction: Vector2 = _whole_prop_corner - _whole_prop_start_bounds.get_center()
var denominator: float = direction.length_squared()
if denominator <= 0.0001:
return
var requested: float = 1.0 + (world_pos - _whole_prop_start_pointer).dot(direction) / denominator
var factor: float = clampf(requested, WHOLE_PROP_FACTOR_MIN, WHOLE_PROP_FACTOR_MAX)
if not is_finite(factor):
return
if is_equal_approx(factor, 1.0):
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_scale_all_shapes(factor) or not bool(PropAnchorDataScript.try_scale_non_origin(_whole_prop_start_anchors, factor).get("ok", false)):
return
_whole_prop_factor = factor
_whole_prop_preview_shapes = _scaled_shape_copy(_whole_prop_start_shapes, factor)
_whole_prop_preview_anchors = _scaled_anchor_copy(_whole_prop_start_anchors, factor)
queue_redraw()
func _end_whole_prop_resize() -> void:
if not _whole_prop_dragging:
return
var factor: float = _whole_prop_factor; var drag: WholePropDrag = _whole_prop_drag; var delta: Vector2 = _whole_prop_delta; var angle: float = _whole_prop_preview_angle
cancel_whole_prop_resize()
if drag == WholePropDrag.MOVE and not delta.is_zero_approx(): whole_prop_move_requested.emit(delta)
elif drag == WholePropDrag.ROTATE and not is_zero_approx(angle): whole_prop_rotate_requested.emit(angle)
elif drag == WholePropDrag.SCALE and not is_equal_approx(factor, 1.0):
whole_prop_resize_requested.emit(factor)
func _scaled_shape_copy(source: Array[Dictionary], factor: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var shape: Dictionary = result[i]
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
result[i] = shape
return result
func _scaled_anchor_copy(source: Dictionary, factor: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id == "origin":
continue
var anchor: Dictionary = result[id]
anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * factor
result[id] = anchor
return result
func _translated_shape_copy(source: Array[Dictionary], delta: Vector2) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] += delta
sd["points"] = pts; result[i] = sd
return result
func _translated_anchor_copy(source: Dictionary, delta: Vector2) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin": var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) + delta; result[id] = anchor
return result
func _rotated_shape_copy(source: Array[Dictionary], angle: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle)
sd["points"] = pts; result[i] = sd
return result
func _rotated_anchor_copy(source: Dictionary, angle: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin":
var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle); anchor["rotation_deg"] = wrapf(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle), -180.0, 180.0); result[id] = anchor
return result
func _whole_preview_bounds() -> Rect2:
var found: bool = false
var result: Rect2 = Rect2()
for shape: Dictionary in _whole_prop_preview_shapes:
var bounds: Rect2 = _shape_bbox(shape)
if not found:
result = bounds
found = true
else:
result = result.merge(bounds)
return result
func _rect_corners(bounds: Rect2) -> Array[Vector2]:
return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)]
func _whole_prop_hit_corner(bounds: Rect2, world_pos: Vector2) -> Vector2:
var threshold: float = WHOLE_PROP_HANDLE_HIT_RADIUS / _zoom
for corner: Vector2 in _rect_corners(bounds):
if world_pos.distance_to(corner) <= threshold:
return corner
return Vector2.INF
func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
@@ -1203,6 +1631,13 @@ func _shape_bbox(sd: Dictionary) -> Rect2:
bbox = bbox.expand(pt)
return bbox
func _shape_has_geometry(sd: Dictionary) -> bool:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
if pts.size() < 2: return false
for point: Vector2 in pts:
if not is_finite(point.x) or not is_finite(point.y): return false
return true
func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int:
if shape_idx < 0 or shape_idx >= shapes.size():
+561 -34
View File
@@ -16,20 +16,26 @@ const PropBlockScript := preload("res://scripts/prop_block.gd")
const PropCanvasScript := preload("res://scripts/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd")
const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/prop_attach_preview.tscn")
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"]
const EDITABLE_ROLE_STRINGS: PackedStringArray = ["grip", "emitter", "impact", "mount", "custom"]
# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode.
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"]
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"]
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"]
const UNTITLED_NAME: String = "Untitled"
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
const AUTOSAVE_DEBOUNCE: float = 1.0
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const NORMALIZED_MAX_EXTENT: float = 96.0
const MENU_NEW := 0
const MENU_LOAD := 2
@@ -67,6 +73,11 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
result.resize(max_count)
return result
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
if not has_geometry: return false
var threshold: float = maxf(OFF_ORIGIN_WARNING_MIN_DISTANCE, OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length())
return bounds.get_center().length() > threshold
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@@ -76,6 +87,12 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@onready var _main_layout: HSplitContainer = %MainLayout
@onready var _canvas_panel: PanelContainer = %CanvasPanel
@onready var _inspector_column: PanelContainer = %InspectorColumn
@onready var _inspector_list: VBoxContainer = %InspectorList
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@@ -92,15 +109,20 @@ var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
var _clipboard: Dictionary = {}
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _can_be_picked_up: bool = true
var _selected_anchor_id: String = ""
var _thumb: PropThumbnail
var _thumb_cache: ThumbnailCache
var _ui_theme: Theme
# Inspector controls
var _preset_option: OptionButton
var _material_section: VBoxContainer
var _mass_label: Label
var _friction_label: Label
var _bounce_label: Label
var _can_be_picked_up_check: CheckBox
var _fill_button: ColorPickerButton
var _outline_button: ColorPickerButton
var _width_spin: SpinBox
@@ -119,19 +141,46 @@ var _updating_inspector: bool = false
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
var _normalize_size_button: Button
var _show_stickman_guide_check: CheckBox
var _show_stickman_guide: bool = false
var _whole_prop_resize_button: Button
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
var _move_button: Button
var _points_button: Button
var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer
var _anchor_section: VBoxContainer
var _anchor_list: ItemList
var _anchor_role: OptionButton
var _anchor_hand: OptionButton
var _anchor_x: SpinBox
var _anchor_y: SpinBox
var _anchor_rotation: SpinBox
var _anchor_z: SpinBox
var _rename_anchor_button: Button
var _delete_anchor_button: Button
var _attach_preview: Control
var _show_preview_check: CheckBox
var _inactive_anchors_check: CheckBox
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
var _last_canvas_mode: int = PropCanvasScript.EditMode.TRANSFORM
var _inspector_split_initialized: bool = false
# Exit/new dialog + autosave (Round 3/4)
var _exit_dialog: ConfirmationDialog
var _off_origin_save_dialog: ConfirmationDialog
var _pending_quit: bool = false
var _pending_new: bool = false
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
var _autosave_remaining: float = 0.0
# Recent props (Round 5)
@@ -155,7 +204,14 @@ func _ready() -> void:
_setup_menu_bar()
_build_mode_toolbar()
_build_top_right_controls()
_build_size_tools()
_build_inspector()
_build_attach_tools()
_attach_preview = PropAttachPreviewScene.instantiate() as Control
_canvas_workspace.add_child(_attach_preview)
_apply_control_theme(_attach_preview)
_attach_preview.call("set_ui_hit_target_height", float(_theme.button_min_height()))
_attach_preview.visible = false
_setup_dialogs()
_canvas.shape_changed.connect(_on_canvas_shape_changed)
@@ -163,10 +219,20 @@ func _ready() -> void:
_canvas.shape_copy_requested.connect(_on_canvas_copy_requested)
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
_canvas.mode_changed.connect(_on_canvas_mode_changed)
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
_canvas.set_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color()
origin_marker_color.a = 0.75
_canvas.set_origin_marker_color(origin_marker_color)
_canvas.set_show_stickman_guide(_show_stickman_guide)
_prop_name_edit.text_changed.connect(_on_name_changed)
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
@@ -180,6 +246,9 @@ func _ready() -> void:
_sync_mode_controls(_canvas.get_edit_mode())
_update_save_status()
_update_cursor_coords()
_canvas.call_deferred("reset_view")
call_deferred("_initialize_inspector_split")
_refresh_center_prop_button()
_check_autosave_recovery()
@@ -195,6 +264,16 @@ func _process(delta: float) -> void:
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
_set_whole_prop_resize_enabled(false)
get_viewport().set_input_as_handled()
return
if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE):
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if focus_owner == null or focus_owner is Label:
@@ -233,10 +312,13 @@ func _build_mode_toolbar() -> void:
_mode_group = ButtonGroup.new()
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
_attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH)
_move_button.pressed.connect(_on_move_mode_pressed)
_points_button.pressed.connect(_on_points_mode_pressed)
_attach_button.pressed.connect(_on_attach_mode_pressed)
_move_button.theme = _ui_theme
_points_button.theme = _ui_theme
_attach_button.theme = _ui_theme
func _make_mode_button(mode: int) -> Button:
@@ -261,27 +343,68 @@ func _on_move_mode_pressed() -> void:
func _on_points_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS)
func _on_attach_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.ATTACH)
func _on_canvas_mode_changed(mode: int) -> void:
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
_last_canvas_mode = mode
_sync_mode_buttons(mode)
_sync_mode_controls(mode)
func _sync_mode_buttons(mode: int) -> void:
if _move_button == null or _points_button == null:
if _move_button == null or _points_button == null or _attach_button == null:
return
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
_points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS)
_attach_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.ATTACH)
var accent: Color = _theme.accent_color()
_move_button.add_theme_color_override("font_pressed_color", accent)
_points_button.add_theme_color_override("font_pressed_color", accent)
_attach_button.add_theme_color_override("font_pressed_color", accent)
func _sync_mode_controls(mode: int) -> void:
if _whole_prop_resize_button != null:
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _normalize_size_button != null:
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
if _add_section != null:
_add_section.visible = mode != PropCanvasScript.EditMode.POINTS
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode != PropCanvasScript.EditMode.POINTS
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
if _anchor_section != null:
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH
if _attach_preview != null:
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
if _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _build_attach_tools() -> void:
_show_preview_check = CheckBox.new()
_show_preview_check.text = "Show Stickman Preview"
_show_preview_check.button_pressed = true
_show_preview_check.toggled.connect(func(_enabled: bool) -> void: _sync_mode_controls(_canvas.get_edit_mode()))
_attach_tools.add_child(_show_preview_check)
_inactive_anchors_check = CheckBox.new()
_inactive_anchors_check.text = "Show Other Markers"
_inactive_anchors_check.button_pressed = true
_inactive_anchors_check.toggled.connect(func(enabled: bool) -> void:
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled))
_attach_tools.add_child(_inactive_anchors_check)
# ---------------------------------------------------------------------------
@@ -322,6 +445,18 @@ func _build_control_theme() -> void:
_ui_theme.set_stylebox("hover", "Button", panel_dark)
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
_ui_theme.set_stylebox("focus", "Button", panel)
var inspector_surface := StyleBoxFlat.new()
inspector_surface.bg_color = _theme.inspector_background_color()
inspector_surface.border_color = _theme.border_color()
inspector_surface.set_border_width_all(1)
inspector_surface.content_margin_left = 8
inspector_surface.content_margin_right = 8
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _theme.border_color().lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_ui_theme.set_stylebox("normal", "OptionButton", panel)
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
@@ -428,6 +563,7 @@ func _push_recent(path: String) -> void:
func _load_settings() -> void:
_recent_props.clear()
_show_stickman_guide = false
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
@@ -437,6 +573,7 @@ func _load_settings() -> void:
file.close()
if not parsed is Dictionary:
return
_show_stickman_guide = bool((parsed as Dictionary).get("show_stickman_guide", false))
var raw_list: Variant = (parsed as Dictionary).get("recent_props", [])
if raw_list is Array:
for entry: Variant in raw_list as Array:
@@ -445,7 +582,7 @@ func _load_settings() -> void:
func _save_settings() -> void:
var data := {"version": 1, "recent_props": _recent_props}
var data := {"version": 1, "recent_props": _recent_props, "show_stickman_guide": _show_stickman_guide}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file == null:
return
@@ -457,6 +594,35 @@ func _setup_dialogs() -> void:
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog)
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void:
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
return
var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled:
ok_button.pressed.emit()
func _initialize_inspector_split() -> void:
if _inspector_split_initialized or _main_layout == null:
return
var inspector_minimum: float = _inspector_column.get_combined_minimum_size().x
if _main_layout.size.x <= inspector_minimum:
call_deferred("_initialize_inspector_split")
return
_main_layout.split_offset = maxi(0, int(_main_layout.size.x - inspector_minimum - _main_layout.get_theme_constant("separation")))
_inspector_split_initialized = true
func _build_top_right_controls() -> void:
@@ -470,13 +636,41 @@ func _build_top_right_controls() -> void:
_grid_spin.max_value = 512
_grid_spin.value = _theme.grid_size()
_grid_spin.custom_minimum_size.x = 90.0
_grid_spin.custom_minimum_size.y = float(_theme.button_min_height())
_grid_spin.value_changed.connect(_on_top_grid_changed)
_top_right.add_child(_grid_spin)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = false
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
_snap_check.toggled.connect(_on_top_snap_toggled)
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
_size_tools.add_child(_whole_prop_resize_button)
_normalize_size_button = Button.new()
_normalize_size_button.text = "📏 Normalize Size"
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
_size_tools.add_child(_normalize_size_button)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
_size_tools.add_child(_center_prop_button)
_show_stickman_guide_check = CheckBox.new()
_show_stickman_guide_check.text = "Show Stickman Guide"
_show_stickman_guide_check.button_pressed = _show_stickman_guide
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
_size_tools.add_child(_show_stickman_guide_check)
_refresh_scale_buttons()
func _on_top_grid_changed(value: float) -> void:
@@ -486,6 +680,35 @@ func _on_top_grid_changed(value: float) -> void:
func _on_top_snap_toggled(pressed: bool) -> void:
_canvas.set_snap_enabled(pressed)
func _on_normalize_size_pressed() -> void:
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if is_finite(extent) and extent > 0.0:
_scale_document(NORMALIZED_MAX_EXTENT / extent)
_refresh_scale_buttons()
func _on_stickman_guide_toggled(enabled: bool) -> void:
_show_stickman_guide = enabled
_canvas.set_show_stickman_guide(enabled)
_save_settings()
func _set_whole_prop_resize_enabled(enabled: bool) -> void:
var allowed: bool = enabled and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM and _canvas.has_shape_geometry()
_canvas.set_whole_prop_resize_enabled(allowed)
if _whole_prop_resize_button != null:
_whole_prop_resize_button.set_pressed_no_signal(allowed)
_sync_mode_controls(_canvas.get_edit_mode())
func _on_whole_prop_resize_requested(factor: float) -> void:
_scale_document(factor)
_refresh_center_prop_button()
func _on_whole_prop_move_requested(delta: Vector2) -> void:
_translate_document(delta)
func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
_rotate_document(angle_rad)
func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new()
@@ -499,6 +722,18 @@ func _build_exit_dialog() -> void:
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
add_child(_off_origin_save_dialog)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false))
_off_origin_save_dialog.canceled.connect(_cancel_pending_save_request)
func _notification(what: int) -> void:
@@ -531,19 +766,7 @@ func _on_exit_save() -> void:
if _current_path.is_empty():
_save_dialog.popup_centered()
return
await _save_and_finish(_current_path)
func _save_and_finish(path: String) -> void:
var ok: bool = await _save_to(path, false)
if not ok:
return
if _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
_begin_save_request(_current_path, false)
func _on_exit_custom_action(action: StringName) -> void:
@@ -561,20 +784,30 @@ func _on_exit_custom_action(action: StringName) -> void:
func _on_exit_cancel() -> void:
_pending_quit = false
_pending_new = false
_cancel_pending_save_request()
func _do_new() -> void:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_can_be_picked_up = true
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = true
_preset_option.selected = _material_preset
_update_physics_labels()
_canvas.clear()
_anchors = PropAnchorDataScript.default_anchors()
_selected_anchor_id = ""
_canvas.set_anchor_data(_anchors)
_clipboard = {}
_canvas.set_has_clipboard(false)
_current_path = ""
_dirty = false
_clear_autosave()
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_update_save_status()
@@ -588,7 +821,7 @@ func _on_menu_id_pressed(id: int) -> void:
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
await _save_to(_current_path)
_begin_save_request(_current_path, true)
MENU_SAVE_AS:
_save_dialog.popup_centered()
MENU_RESET_VIEWS:
@@ -604,10 +837,13 @@ func _build_inspector() -> void:
var row_height: int = _theme.row_min_height()
# Material preset section
_material_section = VBoxContainer.new()
_material_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_material_section)
var material_title := Label.new()
material_title.text = "Material"
material_title.add_theme_font_size_override("font_size", header_size)
_inspector_list.add_child(material_title)
_material_section.add_child(material_title)
_preset_option = OptionButton.new()
for label: String in PRESET_LABELS:
@@ -615,14 +851,21 @@ func _build_inspector() -> void:
_preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
_preset_option.item_selected.connect(_on_preset_selected)
_inspector_list.add_child(_preset_option)
_material_section.add_child(_preset_option)
_mass_label = Label.new()
_friction_label = Label.new()
_bounce_label = Label.new()
_inspector_list.add_child(_mass_label)
_inspector_list.add_child(_friction_label)
_inspector_list.add_child(_bounce_label)
_material_section.add_child(_mass_label)
_material_section.add_child(_friction_label)
_material_section.add_child(_bounce_label)
_can_be_picked_up_check = CheckBox.new()
_can_be_picked_up_check.text = "Can be picked up"
_can_be_picked_up_check.button_pressed = _can_be_picked_up
_can_be_picked_up_check.toggled.connect(func(enabled: bool) -> void:
_can_be_picked_up = enabled
_mark_dirty())
_material_section.add_child(_can_be_picked_up_check)
_update_physics_labels()
# Selected-shape section
@@ -736,6 +979,160 @@ func _build_inspector() -> void:
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
_build_anchor_inspector()
func _build_anchor_inspector() -> void:
_anchor_section = VBoxContainer.new()
_anchor_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_anchor_section)
var title := Label.new(); title.text = "Anchors"; title.add_theme_font_size_override("font_size", _theme.font_header()); _anchor_section.add_child(title)
_anchor_list = ItemList.new(); _anchor_list.custom_minimum_size.y = 140.0; _anchor_list.item_selected.connect(func(index: int) -> void: _select_anchor(_anchor_list.get_item_text(index))); _anchor_section.add_child(_anchor_list)
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
button.pressed.connect(_on_rename_anchor_pressed)
else:
_delete_anchor_button = button
button.pressed.connect(_on_delete_anchor_pressed)
_anchor_role = _anchor_option("Role", EDITABLE_ROLE_STRINGS, _on_anchor_role_selected)
_anchor_hand = _anchor_option("Allowed Hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS, _on_anchor_hand_selected)
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
row.add_child(option)
return option
func _anchor_spin(label_text: String, minimum: float, maximum: float, callback: Callable) -> SpinBox:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var spin := SpinBox.new()
spin.min_value = minimum
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
func _refresh_anchor_inspector() -> void:
if _anchor_list == null: return
_updating_inspector = true; _anchor_list.clear()
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
for id: String in PropAnchorDataScript.editable_ids(_anchors): _anchor_list.add_item(id); if id == _selected_anchor_id: _anchor_list.select(_anchor_list.item_count - 1)
var anchor: Dictionary = _anchors.get(_selected_anchor_id, {})
var editable: bool = not anchor.is_empty()
if not anchor.is_empty():
var role_index: int = EDITABLE_ROLE_STRINGS.find(str(anchor.get("role", "custom")))
_anchor_role.select(role_index if role_index >= 0 else EDITABLE_ROLE_STRINGS.find("custom"))
_anchor_hand.select(PropAnchorDataScript.ALLOWED_HAND_STRINGS.find(str(anchor.get("allowed_hands", "both"))))
var pos: Vector2 = anchor.get("position", Vector2.ZERO); _anchor_x.value = pos.x; _anchor_y.value = pos.y; _anchor_rotation.value = float(anchor.get("rotation_deg", 0.0)); _anchor_z.value = int(anchor.get("z_index_offset", 0))
# OptionButton and Button inherit BaseButton and use `disabled`; SpinBox is a
# Range control and gates editing through `editable` in Godot 4.
for option: OptionButton in [_anchor_role, _anchor_hand]:
option.disabled = not editable
option.tooltip_text = "Add or select an anchor." if not editable else ""
for spin: SpinBox in [_anchor_x, _anchor_y, _anchor_rotation, _anchor_z]:
spin.editable = editable
spin.tooltip_text = "Add or select an anchor." if not editable else ""
if _rename_anchor_button != null:
_rename_anchor_button.disabled = not editable
if _delete_anchor_button != null:
_delete_anchor_button.disabled = not editable
_updating_inspector = false
func _select_anchor(id: String) -> void:
if id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(id): _selected_anchor_id = id; _canvas.select_anchor(id); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_add_anchor_pressed() -> void:
var id: String = PropAnchorDataScript.normalize_id("anchor", _anchors); var bounds: Rect2 = _canvas.get_shapes_bounds(); _anchors[id] = {"role":"custom", "position": bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO, "rotation_deg":0.0, "z_index_offset":0, "allowed_hands":"both"}; _selected_anchor_id = id; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector()
func _on_rename_anchor_pressed() -> void:
if _selected_anchor_id.is_empty() or not _anchors.has(_selected_anchor_id) or _rename_anchor_dialog == null:
return
_rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
_rename_anchor_edit.call_deferred("grab_focus")
_rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null:
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
_clear_pending_anchor_rename()
return
var remaining: Dictionary = _anchors.duplicate(true)
var payload: Dictionary = remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id: String = PropAnchorDataScript.normalize_id(_rename_anchor_edit.text, remaining)
var old_id: String = _rename_anchor_source_id
_clear_pending_anchor_rename()
if new_id == old_id:
return
remaining[new_id] = payload
_anchors = PropAnchorDataScript.normalize_anchors(remaining)
_selected_anchor_id = new_id
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(new_id)
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _clear_pending_anchor_rename() -> void:
_rename_anchor_source_id = ""
func _on_delete_anchor_pressed() -> void:
if _selected_anchor_id.is_empty(): return
var ids: Array[String] = PropAnchorDataScript.editable_ids(_anchors)
var old_index: int = ids.find(_selected_anchor_id)
_anchors.erase(_selected_anchor_id)
ids = PropAnchorDataScript.editable_ids(_anchors)
_selected_anchor_id = ids[clampi(old_index - 1, 0, ids.size() - 1)] if not ids.is_empty() else ""
_canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_anchor_role_selected(index: int) -> void:
if index >= 0 and index < EDITABLE_ROLE_STRINGS.size():
_set_anchor_field("role", EDITABLE_ROLE_STRINGS[index])
func _on_anchor_hand_selected(index: int) -> void: _set_anchor_field("allowed_hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS[index])
func _on_anchor_x_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(value, _anchor_y.value))
func _on_anchor_y_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(_anchor_x.value, value))
func _on_anchor_rotation_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("rotation_deg", value)
func _on_anchor_z_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("z_index_offset", int(value))
func _set_anchor_field(field: String, value: Variant) -> void:
if _updating_inspector or _selected_anchor_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_anchor_id].duplicate(true); anchor[field] = value; _anchors[_selected_anchor_id] = anchor; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_anchor_preset(preset: String) -> void: _anchors = PropAnchorDataScript.preset_layout(preset, _canvas.get_shapes_bounds()); _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors); _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_control_theme(node: Node) -> void:
@@ -855,7 +1252,7 @@ func _on_delete_shape_pressed() -> void:
func _refresh_inspector_from_selection() -> void:
_updating_inspector = true
var idx := _canvas.get_selected_index()
_shape_section.visible = idx >= 0
_shape_section.visible = idx >= 0 and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_edit_mode() != PropCanvasScript.EditMode.ATTACH
if idx >= 0 and idx < _canvas.shapes.size():
var sd: Dictionary = _canvas.shapes[idx]
_fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY)
@@ -885,6 +1282,97 @@ func _refresh_inspector_from_selection() -> void:
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
func _on_canvas_anchors_changed(anchors: Dictionary) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(anchors)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _canvas.get_selected_anchor_id())
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _on_canvas_anchor_selected(anchor_id: String) -> void:
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
func _sync_attach_preview() -> void:
if _attach_preview != null and _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _refresh_center_prop_button() -> void:
if _center_prop_button == null: return
var bounds: Rect2 = _canvas.get_shapes_bounds()
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
_refresh_scale_buttons()
func _refresh_scale_buttons() -> void:
if _normalize_size_button == null:
return
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
if _whole_prop_resize_button != null:
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
func _scale_document(factor: float) -> bool:
if not _canvas.can_scale_all_shapes(factor):
return false
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_anchors, factor)
if not bool(anchor_result.get("ok", false)):
return false
var scaled_anchors: Dictionary = anchor_result.get("anchors", {})
var original_anchors: Dictionary = _anchors
_anchors = scaled_anchors
_canvas.set_anchor_data(_anchors)
if not _canvas.scale_all_shapes(factor):
_anchors = original_anchors
_canvas.set_anchor_data(_anchors)
return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_refresh_scale_buttons()
_sync_attach_preview()
return true
func _translate_document(delta: Vector2) -> bool:
if not _canvas.can_translate_all_shapes(delta): return false
var result: Dictionary = PropAnchorDataScript.try_translate_non_origin(_anchors, delta)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _rotate_document(angle_rad: float) -> bool:
if not _canvas.can_rotate_all_shapes(angle_rad): return false
var result: Dictionary = PropAnchorDataScript.try_rotate_non_origin(_anchors, angle_rad)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.rotate_all_shapes(angle_rad): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _center_prop_on_origin(focus_view: bool = true) -> bool:
if not _canvas.has_shape_geometry(): return false
var bounds: Rect2 = _canvas.get_shapes_bounds()
var delta: Vector2 = -bounds.get_center()
if not is_finite(delta.x) or not is_finite(delta.y): return false
if delta.is_zero_approx():
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return false
_anchors = PropAnchorDataScript.translated_non_origin(_anchors, delta)
_canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return true
func _on_canvas_shape_selected(_idx: int) -> void:
@@ -977,23 +1465,34 @@ func _check_autosave_recovery() -> void:
# ---------------------------------------------------------------------------
func _build_json_data() -> Dictionary:
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data())
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _can_be_picked_up)
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
var model: Dictionary = PropFileScript.from_file_dict(json)
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
_prop_name_edit.text = str(model.get("prop_name", ""))
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
_can_be_picked_up = bool(model.get("can_be_picked_up", true))
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = _can_be_picked_up
if _preset_option != null:
_preset_option.selected = _material_preset
_update_physics_labels()
var shapes: Array = model.get("shapes", [])
_canvas.set_shape_data(shapes)
_anchors = PropAnchorDataScript.normalize_anchors(model.get("anchors", {}))
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_dirty = is_recovery
_autosave_remaining = 0.0
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_canvas.call_deferred("reset_view")
_update_save_status()
return ""
@@ -1025,16 +1524,44 @@ func _save_prop_thumbnail() -> void:
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path))
func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
_off_origin_save_dialog.popup_centered()
return
await _execute_pending_save(false)
func _execute_pending_save(center_first: bool) -> void:
var path: String = _pending_save_path
var with_thumbnail: bool = _pending_save_with_thumbnail
if path.is_empty():
_cancel_pending_save_request()
return
if center_first: _center_prop_on_origin(true)
var ok: bool = await _save_to(path, with_thumbnail)
if not ok:
_cancel_pending_save_request()
return
_pending_save_path = ""
_pending_save_with_thumbnail = true
if _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
func _cancel_pending_save_request() -> void:
_pending_save_path = ""
_pending_save_with_thumbnail = true
_pending_quit = false
_pending_new = false
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 or _pending_new))
if ok and _pending_quit:
_pending_quit = false
get_tree().quit()
elif ok and _pending_new:
_pending_new = false
_do_new()
_begin_save_request(path, not (_pending_quit or _pending_new))
func _load_from(path: String) -> void:
+6 -1
View File
@@ -9,6 +9,7 @@ extends RefCounted
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"
@@ -61,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary:
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))
@@ -71,6 +72,8 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
"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},
}
@@ -105,6 +108,8 @@ static func from_file_dict(data: Variant) -> Dictionary:
"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,
}
+65
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 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,
}
+5 -1
View File
@@ -42,7 +42,7 @@ const DEFAULT_CONFIG: Dictionary = {
"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"},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
@@ -218,6 +218,10 @@ 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()
+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
+205 -32
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
@@ -644,6 +654,7 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
var previous_mode: StageMode = current_mode
if mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
@@ -652,9 +663,9 @@ func set_mode(mode: StageMode) -> void:
_cancel_palette_drag()
match mode:
StageMode.EDIT:
_enter_edit_mode()
_enter_edit_mode(previous_mode)
StageMode.DIRECT:
_enter_direct_mode()
_enter_direct_mode(previous_mode)
StageMode.PLAY:
_enter_play_mode()
_sync_mode_buttons()
@@ -667,11 +678,12 @@ func set_mode(mode: StageMode) -> void:
func _enter_edit_shared() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up
# tween glide), stopping any director queue first.
# Restore stickmen to their authored spawn/ghost pose (no stand-up tween
# glide), stopping any director queue first.
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()
@@ -685,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.
@@ -697,7 +711,10 @@ func _enter_edit_shared() -> void:
_collision_pairs.clear()
func _enter_edit_mode() -> void:
func _enter_edit_mode(previous_mode: StageMode) -> void:
# EDIT and DIRECT share the same frozen world. PLAY still needs the complete
# simulation cleanup and authored-pose restore before editing resumes.
if previous_mode != StageMode.DIRECT:
_enter_edit_shared()
_clear_director_pending()
_set_edit_controls_visible(true)
@@ -730,10 +747,14 @@ func _clear_director_pending() -> void:
_director_visuals.clear_edit_waypoint()
## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo
## side effects, but with the spawner/grid controls hidden, the director hint
## shown, and any placement/selection/pending-target cleared.
func _enter_direct_mode() -> void:
## Direct is internally an "edit-with-direct" state: the same frozen world,
## but with the spawner/grid controls hidden, the director hint shown, and any
## placement/selection/pending-target cleared.
func _enter_direct_mode(previous_mode: StageMode) -> void:
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
# placed stickman pose between them; a transition from PLAY still needs the
# full cleanup and authored-pose restore.
if previous_mode != StageMode.EDIT:
_enter_edit_shared()
set_placement_mode("")
_selection.clear_selection()
@@ -758,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():
@@ -801,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:
@@ -856,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
@@ -1219,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)
@@ -1259,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)))
@@ -1278,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
@@ -1294,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)
@@ -1336,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", ""))
@@ -1347,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)
@@ -1393,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)
@@ -2007,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)
@@ -2186,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))
@@ -2400,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
@@ -2441,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"])
@@ -2477,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:
@@ -2500,12 +2576,16 @@ func _drain_thumbnail_queue() -> void:
tex = await _stickman_thumb.render(data)
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else:
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)
@@ -2833,10 +2913,16 @@ 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)
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()
@@ -2961,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:
@@ -2970,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
@@ -3010,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 ""
@@ -3080,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()
@@ -3088,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()
@@ -3258,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": {} })
@@ -3372,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
@@ -3586,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()
@@ -3621,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():
@@ -4023,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
+32
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
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
+91 -10
View File
@@ -16,6 +16,8 @@ const REST_LINEAR_THRESHOLD: float = 5.0
const REST_ANGULAR_THRESHOLD: float = 0.1
const STABILIZATION_DELAY: float = 0.1
const TARGET_SOFTNESS: float = 0.2
const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0)
const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
const GROUND_PROBE_TOLERANCE := 200.0
@@ -65,8 +67,10 @@ const JOINTS: Array[Dictionary] = [
{ "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" },
]
## Per-profile elbow/knee limits retained from the monolithic rig. The component
## owns these construction settings because it creates the PinJoint2D network.
## Per-profile elbow/knee limits. Side-profile leg hinges include the straight
## pose (0 degrees) and allow both knees to fold toward the same profile-relative
## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff.
## The component owns these settings because it creates the PinJoint2D network.
const JOINT_BAND_LIMITS: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {
"left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
@@ -74,11 +78,11 @@ const JOINT_BAND_LIMITS: Dictionary = {
},
StickmanRig.FacingProfile.LEFT: {
"left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -100.0, "upper": -90.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
"left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 },
},
StickmanRig.FacingProfile.RIGHT: {
"left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": 90.0, "upper": 100.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
"left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
},
}
@@ -141,6 +145,14 @@ func initialize(skeleton: Skeleton2D) -> void:
_previous_position = _rig.global_position
_previous_rotation = _rig.global_rotation
## Runs after the physics step and before drawing. Godot's PinJoint2D can let a
## straight knee cross its angular limit during the step; correcting here keeps
## that invalid intermediate pose from ever reaching the rendered frame.
func _process(_delta: float) -> void:
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
_enforce_side_profile_leg_bends()
func _resolve_limb_bones() -> void:
_limb_bones.clear()
for joint: String in LIMB_BONE_PATHS:
@@ -176,12 +188,75 @@ func build() -> bool:
if parent == null: return false
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
for entry: Dictionary in BODIES: _build_body(entry)
_stabilize_side_profile_leg_bends()
for entry: Dictionary in JOINTS: _build_joint(entry)
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity
reset_rest_state()
return torso != null
## A walk keyframe can put a leg exactly straight. At that singularity the
## physics solver has no geometric knee side to preserve, so floating-point
## noise can make either knee fold as if the opposite facing profile were in
## use. Give only near-straight side-profile legs a small world-space bend
## toward the profile's established rear side before the joints are created.
func _stabilize_side_profile_leg_bends() -> void:
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
return
var desired_delta := SIDE_PROFILE_KNEE_BIAS \
if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \
else -SIDE_PROFILE_KNEE_BIAS
for prefix: String in ["left", "right"]:
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
if upper == null or lower == null:
continue
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD:
continue
var upper_half := float(upper.get_meta("half_height", 0.0))
var lower_half := float(lower.get_meta("half_height", 0.0))
var upper_dir := Vector2.from_angle(upper.global_rotation)
var knee := upper.global_position + upper_dir * upper_half
var lower_dir := upper_dir.rotated(desired_delta)
lower.global_rotation = lower_dir.angle()
lower.global_position = knee + lower_dir * lower_half
## PinJoint2D can cross its angular limit near a perfectly straight chain in
## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select
## the opposite solution. Normal motion on the intended side remains physical.
func _enforce_side_profile_leg_bends() -> void:
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
return
var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0
var desired_delta_sign := -desired_side
for prefix: String in ["left", "right"]:
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var upper_half := float(upper.get_meta("half_height", 0.0))
var lower_half := float(lower.get_meta("half_height", 0.0))
var upper_dir := Vector2.from_angle(upper.global_rotation)
var knee := upper.global_position + upper_dir * upper_half
var hip := upper.global_position - upper_dir * upper_half
var lower_dir_current := Vector2.from_angle(lower.global_rotation)
var ankle := lower.global_position + lower_dir_current * lower_half
var current_side := (ankle - hip).cross(knee - hip)
if current_side * desired_side > 0.0:
continue
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
var corrected_delta := desired_delta_sign * minf(
maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0))
var lower_dir := upper_dir.rotated(corrected_delta)
lower.global_rotation = lower_dir.angle()
lower.global_position = knee + lower_dir * lower_half
lower.angular_velocity = upper.angular_velocity
func destroy() -> void:
if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free()
_root = null; _bodies.clear(); reset_rest_state()
@@ -242,7 +317,10 @@ func snap_to_standing(previous_state: int) -> void:
if _locomotion != null:
_locomotion.clear_impact_reactions(true)
_rig._hide_speech()
_restore_standing_markers(false)
# The factory captures the adapted skeleton's real authored spawn pose. That
# is also the pose shown by the placement ghost, so every forced reset must
# restore it instead of the legacy, one-size-fits-all STAND_POSE fallback.
_restore_standing_markers(true)
_rearm_ik_stack()
_set_limb_mods_enabled(true)
if previous_state == StickmanRig.RigState.ANIMATED:
@@ -311,6 +389,9 @@ func initialize_spawn_pose_from_skeleton() -> void:
func get_spawn_limb_rotations() -> Dictionary:
return _spawn_limb_rotations
func get_spawn_pose() -> Dictionary:
return _spawn_pose.duplicate(true)
func get_captured_pose() -> Dictionary:
return _captured_pose
@@ -568,13 +649,10 @@ func _snap_skeleton_to_pose() -> void:
var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t
_head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI)
_recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)})
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
_rearm_ik_stack()
_set_limb_mods_enabled(false)
if _head_look_at != null:
_head_look_at.enabled = false
_rearm_ik_stack()
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
if modification == null:
@@ -598,6 +676,9 @@ func _snap_skeleton_to_pose() -> void:
var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0
modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false))
modification.enabled = true
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
func _play_stand_up() -> void:
_stand_up_generation += 1
@@ -641,8 +722,8 @@ func _on_stand_up_finished(generation: int) -> void:
_body_container.modulate.a = 1.0
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
_rig._apply_idle_limb_solver_mode(recovered_rotations)
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
_rig._apply_idle_limb_solver_mode(recovered_rotations)
func _build_body(entry: Dictionary) -> void:
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
+28 -11
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:
@@ -479,9 +489,9 @@ func request_recovery() -> void:
_ragdoll_component.request_recovery()
## Instantly snaps the rig back to its authored standing pose — no stand-up
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
## position/state on return to EDIT (instead of animating the recovery glide).
## Instantly restores the rig's factory-captured spawn/ghost pose — no stand-up
## tween. Used by the sandbox stage so a stickman reappears in its authored
## starting pose when leaving PLAY (instead of using the generic fallback pose).
func snap_to_standing() -> void:
if _ragdoll_component != null:
_ragdoll_component.snap_to_standing(int(state))
@@ -620,7 +630,10 @@ func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
and state == RigState.ANIMATED and not is_walking()
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
_set_limb_mods_enabled(not pin_mirrored_idle)
if not pin_mirrored_idle:
# Recovery supplies the exact pre-ragdoll rotations. Write them immediately
# even for profiles that keep IK enabled so the completion frame cannot show
# the recovery-side knee solution while the solver waits for its next tick.
if not pin_mirrored_idle and rotations.is_empty():
return
for joint: String in source:
var bones: Dictionary = _limb_bones.get(joint, {})
@@ -658,18 +671,15 @@ func _capture_limb_rotations() -> Dictionary:
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
## authored canonical local rotation (0), so the head is a rigid mirror of the
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
## because re-enabling the IK stack during recovery would otherwise let the
## LookAt flip the bone again.
## every physics frame while the kinematic puppet is ANIMATED; recovery disables
## LookAt separately while its stand-up tween drives the head bone.
func _apply_head_lookat_mirror_mode() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
var mirrored := facing_profile == FacingProfile.LEFT
_head_look_at.enabled = false#not mirrored
_head_look_at.enabled = not mirrored
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = 0.0
elif not mirrored:
_head_bone.rotation = 0
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
@@ -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:
+3 -1
View File
@@ -40,7 +40,9 @@ func prop_png(id: String) -> String:
func prop_file_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
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:
+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.
+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
+5 -3
View File
@@ -183,6 +183,8 @@ func _test_reactions(rig: StickmanRig) -> void:
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
var authored: Dictionary = stage._authored[rig.get_instance_id()]
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var authored_markers := ragdoll.get_spawn_pose()
stage.set_mode(stage.StageMode.PLAY)
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
rig.global_position += Vector2(220.0, -90.0)
@@ -201,9 +203,9 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
for marker_name: String in StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
var expected: Dictionary = StickmanRagdoll.STAND_POSE[marker_name]
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name)
_check(absf(angle_difference(torso_marker.rotation, float(StickmanRagdoll.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
var expected: Dictionary = authored_markers[marker_name]
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
func _test_ghost_collision(stage: Node2D) -> void:
+122
View File
@@ -0,0 +1,122 @@
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const BONE_PATHS: Array[String] = [
"Skeleton2D/Torso/Head",
"Skeleton2D/Torso/LeftUpperArm",
"Skeleton2D/Torso/LeftUpperArm/LeftLowerArm",
"Skeleton2D/Torso/RightUpperArm",
"Skeleton2D/Torso/RightUpperArm/RightLowerArm",
]
const MARKER_NAMES: Array[String] = ["Head", "Left_Hand", "Right_Hand", "Torso"]
const ROTATION_TOLERANCE := 0.0001
const POSITION_TOLERANCE := 0.01
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var stage := STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
stage._snap_enabled = false
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO)
stage._finalize_placed_node(rig)
for frame in 3:
await process_frame
var pose_before := _capture_pose(rig)
var markers_before := _capture_markers(rig)
stage.set_mode(stage.StageMode.DIRECT)
for frame in 3:
await process_frame
var pose_after := _capture_pose(rig)
var markers_after := _capture_markers(rig)
_check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT")
for path: String in BONE_PATHS:
var before: Dictionary = pose_before[path]
var after: Dictionary = pose_after[path]
_check(absf(angle_difference(float(before["rotation"]), float(after["rotation"]))) <= ROTATION_TOLERANCE,
"EDIT to DIRECT preserves %s rotation" % path.get_file())
_check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to DIRECT preserves %s position" % path.get_file())
for marker_name: String in MARKER_NAMES:
var before: Dictionary = markers_before[marker_name]
var after: Dictionary = markers_after[marker_name]
_check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to DIRECT preserves %s IK marker" % marker_name)
stage.set_mode(stage.StageMode.EDIT)
for frame in 3:
await process_frame
var pose_round_trip := _capture_pose(rig)
var markers_round_trip := _capture_markers(rig)
_check(stage.current_mode == stage.StageMode.EDIT, "stage returns to EDIT")
for path: String in BONE_PATHS:
var before: Dictionary = pose_before[path]
var round_trip: Dictionary = pose_round_trip[path]
_check(absf(angle_difference(float(before["rotation"]), float(round_trip["rotation"]))) <= ROTATION_TOLERANCE,
"EDIT to DIRECT to EDIT preserves %s rotation" % path.get_file())
_check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to DIRECT to EDIT preserves %s position" % path.get_file())
for marker_name: String in MARKER_NAMES:
var before: Dictionary = markers_before[marker_name]
var round_trip: Dictionary = markers_round_trip[marker_name]
_check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to DIRECT to EDIT preserves %s IK marker" % marker_name)
stage.set_mode(stage.StageMode.PLAY)
for frame in 3:
await physics_frame
stage.set_mode(stage.StageMode.DIRECT)
for frame in 4:
await physics_frame
var pose_after_play := _capture_pose(rig)
var markers_after_play := _capture_markers(rig)
_check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT after PLAY")
for path: String in BONE_PATHS:
var before: Dictionary = pose_before[path]
var after_play: Dictionary = pose_after_play[path]
_check(absf(angle_difference(float(before["rotation"]), float(after_play["rotation"]))) <= ROTATION_TOLERANCE,
"EDIT to PLAY to DIRECT restores %s rotation" % path.get_file())
_check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to PLAY to DIRECT restores %s position" % path.get_file())
for marker_name: String in MARKER_NAMES:
var before: Dictionary = markers_before[marker_name]
var after_play: Dictionary = markers_after_play[marker_name]
_check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE,
"EDIT to PLAY to DIRECT restores %s IK marker" % marker_name)
stage.queue_free()
await process_frame
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 _capture_pose(rig: StickmanRig) -> Dictionary:
var result := {}
for path: String in BONE_PATHS:
var bone := rig.get_node(path) as Bone2D
result[path] = {"rotation": bone.global_rotation, "position": bone.global_position}
return result
func _capture_markers(rig: StickmanRig) -> Dictionary:
var result := {}
for marker_name: String in MARKER_NAMES:
var marker := rig.get_node("IK_Targets/" + marker_name) as Marker2D
result[marker_name] = {"position": marker.position, "rotation": marker.rotation}
return result
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
@@ -0,0 +1 @@
uid://d4q7x1m8n2vkp
+90
View File
@@ -0,0 +1,90 @@
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const WALK_PHASES: Array[float] = [0.0, 0.08, 0.16, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72]
const SETTLE_FRAMES := 30
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
for profile: int in [StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.LEFT]:
for walk_phase: float in WALK_PHASES:
await _check_handoff(profile, walk_phase)
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_handoff(profile: int, walk_phase: float) -> void:
var stage := STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
stage._snap_enabled = false
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO)
rig.auto_recover = false
var destination_x := 500.0 if profile == StickmanRig.FacingProfile.RIGHT else -500.0
rig.walk_to(Vector2(destination_x, 0.0))
var animation := rig.get_node("AnimationPlayer") as AnimationPlayer
animation.seek(walk_phase, true)
await process_frame
await physics_frame
var label := "%s @ %.2fs" % [StickmanRig.FacingProfile.keys()[profile], walk_phase]
_check(rig.get_facing_profile() == profile, "%s keeps its facing profile before ragdoll" % label)
rig.set_ragdoll(true)
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var expected_side := -1.0 if profile == StickmanRig.FacingProfile.RIGHT else 1.0
_check(_both_legs_match(_ragdoll_leg_sides(ragdoll), expected_side),
"%s enters ragdoll with both knees on the profile side" % label)
for frame in SETTLE_FRAMES:
await physics_frame
await process_frame
# SceneTree.process_frame is emitted before Node._process callbacks. Wait one
# more idle frame so the rendered pose includes the post-physics knee guard.
await process_frame
var settled_sides := _ragdoll_leg_sides(ragdoll)
_check(_both_legs_match(settled_sides, expected_side),
"%s keeps both knees on the profile side for %d frames (sides %s)"
% [label, SETTLE_FRAMES, str(settled_sides)])
_check(rig.get_facing_profile() == profile, "%s never changes facing profile" % label)
stage.queue_free()
await process_frame
func _ragdoll_leg_sides(ragdoll: StickmanRagdoll) -> Dictionary:
var bodies := ragdoll.get_bodies()
var result := {}
for prefix: String in ["left", "right"]:
var upper := bodies.get(prefix + "_upper_leg") as RigidBody2D
var lower := bodies.get(prefix + "_lower_leg") as RigidBody2D
var upper_half := float(upper.get_meta("half_height", 0.0))
var lower_half := float(lower.get_meta("half_height", 0.0))
var upper_dir := Vector2.from_angle(upper.global_rotation)
var lower_dir := Vector2.from_angle(lower.global_rotation)
var hip := upper.global_position - upper_dir * upper_half
var knee := upper.global_position + upper_dir * upper_half
var ankle := lower.global_position + lower_dir * lower_half
result["LeftLeg" if prefix == "left" else "RightLeg"] = \
signf((ankle - hip).cross(knee - hip))
return result
func _both_legs_match(sides: Dictionary, expected_side: float) -> bool:
return float(sides.get("LeftLeg", 0.0)) == expected_side \
and float(sides.get("RightLeg", 0.0)) == expected_side
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
@@ -0,0 +1 @@
uid://dsuireu51jue0
+27
View File
@@ -32,6 +32,33 @@ func _run() -> void:
_check(runner != null, "typed ActionRunner component resolves")
_check(rig.get_locomotion() == locomotion, "root locomotion facade exposes authored component")
var head_look_at := rig._head_look_at as SkeletonModification2DLookAt
var head_bone := rig.get_node("Skeleton2D/Torso/Head") as Bone2D
var head_target := rig.get_node("IK_Targets/Head") as Marker2D
_check(head_look_at != null and head_look_at.enabled,
"FORWARD profile enables head marker tracking")
var head_rotation_before := head_bone.rotation
head_target.position += Vector2(-400.0, 180.0)
for frame in 4:
await process_frame
_check(absf(wrapf(head_bone.rotation - head_rotation_before, -PI, PI)) > 0.1,
"head bone follows a moved Head marker (before %.3f after %.3f target %s)"
% [head_rotation_before, head_bone.rotation, str(head_target.global_position)])
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
_check(not head_look_at.enabled, "LEFT mirror disables non-mirror-invariant head LookAt")
rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
_check(head_look_at.enabled, "RIGHT profile re-enables head marker tracking")
var left_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.LEFT]
var right_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.RIGHT]
for leg_key: String in ["left_lower_leg", "right_lower_leg"]:
var left_band: Dictionary = left_bands[leg_key]
var right_band: Dictionary = right_bands[leg_key]
_check(float(left_band["lower"]) < 0.0 and float(left_band["upper"]) >= 0.0,
"LEFT %s hinge includes straight and bends backward" % leg_key)
_check(float(right_band["lower"]) <= 0.0 and float(right_band["upper"]) > 0.0,
"RIGHT %s hinge includes straight and bends backward" % leg_key)
var assigned: Array[Dictionary] = [{"type": "wait", "duration": 0.25}]
rig.action_queue = assigned
_check(runner.action_queue.size() == 1, "direct root action_queue assignment delegates")
+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
+2 -1
View File
@@ -52,7 +52,8 @@ func _test_window_title() -> void:
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("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "✏️ 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")
+2 -2
View File
@@ -47,9 +47,9 @@ func _test_prop_file_keys() -> void:
f.close()
var key: String = cache.prop_file_key(path)
var mtime: int = FileAccess.get_modified_time(path)
check("prop_file_key format", key == "prop_thumb_key_test_%d" % mtime)
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") == "nope_0")
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)
+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())