Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043f114083 | ||
|
|
0a7f97cec7 | ||
|
|
97f3730413 | ||
|
|
00abacc7b2 | ||
|
|
e3689c86c8 |
@@ -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.
|
||||
"""
|
||||
@@ -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."_
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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_
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# Architectural Plan: Dynamic `.prop` Loading & Sandbox Integration
|
||||
|
||||
This document outlines the architectural plan to transition `PropLibrary` from a static registry into a dynamic filesystem-backed library (matching `StickmanLibrary`) and integrate user-created `.prop` files into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture Overview
|
||||
|
||||
The system bridges custom prop files produced by the Prop Editor into the sandbox physics simulation through four primary layers:
|
||||
|
||||
1. **Library & File Scanning (`PropLibrary`):** Scans `res://props/` for `*.prop` files, deserializes them via `PropFile`, flattens multi-shape hierarchies into collision payloads, and merges them with built-in templates.
|
||||
|
||||
2. **Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`):** Computes cache keys using file modification timestamps (`mtime`), renders visual previews offscreen, and streams textures to the selector grid.
|
||||
|
||||
3. **UI Integration (`AssetSelector` & `SandboxStage`):** Presents custom props alongside built-in presets in a unified grid, offering folder scanning, ad-hoc file browsing, and palette live-refresh.
|
||||
|
||||
4. **World Spawning & Physics Lifecycle (`StageSpawner` & `PropBlock`):** Instantiates `RigidBody2D` nodes configured with custom collision geometry and material physical properties (mass, friction, bounce), tracking authored positions across sandbox mode transitions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Specifications
|
||||
|
||||
### A. Library Layer (`res://scripts/prop_library.gd`)
|
||||
|
||||
- **Dual-Source Registry:** Maintain built-in templates (Crate, Ball, Plank, Triangle) as default presets while scanning `res://props/*.prop` on demand.
|
||||
|
||||
- **Entry Model Schema:** Standardize entry dictionaries returned to the UI:
|
||||
- **Identifier (`id`):** Resource path (`res://props/chair.prop`) for custom files, or string key (`crate`) for templates.
|
||||
|
||||
- **Display Name (`name`):** Extracted from `.prop` metadata, with fallback to capitalized file basename.
|
||||
|
||||
- **Material Preset & Label:** Physical properties index (Wood, Metal, Rubber, Cardboard) and human-readable label.
|
||||
|
||||
- **Payload (`payload`):** Flattened shape and collision dictionary generated via `PropFile.flatten_to_payload()`.
|
||||
|
||||
- **Provenance Flags:** Boolean flag indicating whether the entry is custom and string path reference for scene serialization.
|
||||
|
||||
- **Error Handling:** Skip invalid or corrupted `.prop` files gracefully while outputting descriptive warnings.
|
||||
|
||||
### B. Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`)
|
||||
|
||||
- **Dynamic Keying Strategy:**
|
||||
- Built-in prop templates compute keys using internal version constants.
|
||||
|
||||
- Custom `.prop` files compute keys combining file basename and filesystem modification timestamp (`FileAccess.get_modified_time`).
|
||||
|
||||
- **Renderer Extension (`PropThumbnail`):** Extend offscreen rendering to support composite shapes and custom vector styling (fill color, outline width, polygons, circles) rather than single primitive shapes.
|
||||
|
||||
- **Stale Cache Cleanup:** Automatically purge superseded PNG thumbnails when custom `.prop` files are edited or deleted.
|
||||
|
||||
### C. World Spawning Pipeline (`StageSpawner`)
|
||||
|
||||
- **Payload Forwarding:** Pass the entry payload directly to `PropUtils.spawn_prop()`, which generates the corresponding `PropBlock` node with `CollisionPolygon2D` or `CollisionShape2D` nodes.
|
||||
|
||||
- **Provenance Tagging:** Store `spawn_id` and source path metadata on spawned instances to support scene serialization and reloading.
|
||||
|
||||
- **Authored Pose Capture:** Save placement positions to instance metadata (`_authored_position`, `_authored_rotation`) during **Edit** mode.
|
||||
|
||||
### D. UI & Sandbox Integration (`AssetSelector` & `SandboxStage`)
|
||||
|
||||
- **Grid Population:** Trigger `PropLibrary.scan()` when opening the prop selector, populating grid cells with thumbnails and display names.
|
||||
|
||||
- **Browse & Refresh Integration:** Connect the "Browse…" button to a file dialog for arbitrary `*.prop` selection, and the "Refresh" button to re-scan `res://props/`.
|
||||
|
||||
- **Lazy Thumbnail Processing:** Process offscreen thumbnail generation non-blockingly during stage frame processing to keep UI interactions responsive.
|
||||
|
||||
---
|
||||
|
||||
## 3. Physics & Sandbox Lifecycle Integration
|
||||
|
||||
Custom `.prop` instances must follow the existing 3-mode sandbox state machine:
|
||||
|
||||
| Stage Mode | Prop Physics State | Interaction Capabilities | Reset Behavior |
|
||||
| ---------- | -------------------------------------------------- | ------------------------ | -------------- |
|
||||
| **Edit** | RigidBody frozen (`freeze = true`, kinematic mode) |
|
||||
|
||||
| Move, rotate, scale, or delete via selection gizmos
|
||||
|
||||
| N/A (Authored state recorded on movement)
|
||||
|
||||
|
|
||||
| **Direct** | RigidBody frozen (`freeze = true`, kinematic mode)
|
||||
|
||||
| Gizmos active for rule/trigger assignment; spawner palette hidden
|
||||
|
||||
| N/A
|
||||
|
||||
|
|
||||
| **Play** | RigidBody unfrozen (`freeze = false`)
|
||||
|
||||
| Full physical simulation, collisions with terrain/stickmen/props
|
||||
|
||||
| Mode exit restores initial authored position/rotation and zeroes linear/angular velocity
|
||||
|
||||
|
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
```
|
||||
[ Phase 1: Library & Data Layer ]
|
||||
└─ Extend PropLibrary (res://props/*.prop scanning + PropFile parsing)
|
||||
└─ Define unified Prop Entry Dictionary schema
|
||||
|
||||
[ Phase 2: Thumbnail Pipeline ]
|
||||
└─ Update PropThumbnail to render multi-shape custom payloads
|
||||
└─ Implement modification timestamp cache invalidation in ThumbnailCache
|
||||
|
||||
[ Phase 3: Spawning & UI Integration ]
|
||||
└─ Update StageSpawner to tag custom prop provenance
|
||||
└─ Wire AssetSelector grid refresh and file dialog browsing
|
||||
|
||||
[ Phase 4: Stage Lifecycle & Verification ]
|
||||
└─ Verify Edit -> Play -> Edit position resets for custom props
|
||||
└─ Validate scene saving/loading persistence
|
||||
|
||||
```
|
||||
|
||||
### Milestone Checklist for Architect Agent
|
||||
|
||||
1. **Directory Scanning:** `PropLibrary` reads `res://props/` and extracts payloads via `PropFile`.
|
||||
|
||||
2. **Visual Previews:** `PropThumbnail` renders multi-polygon custom vector props to textures.
|
||||
|
||||
3. **Palette Display:** `AssetSelector` displays merged built-in templates and `.prop` files.
|
||||
|
||||
4. **Physics Interaction:** Custom props unfreeze during **Play** mode, participating in physics simulation and collision events.
|
||||
|
||||
5. **State Reset:** Props return to their original authored transforms when switching from **Play** back to **Edit** mode.
|
||||
@@ -0,0 +1,118 @@
|
||||
Here is the revised architectural specification incorporating your exact adjustments for your architect agent:
|
||||
|
||||
---
|
||||
|
||||
# Architectural Spec: Dual-Layer Prop Scaling System (Revised)
|
||||
|
||||
Status: **implemented and user verified** (2026-09-22).
|
||||
|
||||
This specification outlines the architecture for supporting prop scaling across two layers: permanent template scaling in the **Prop Editor** and contextual instance scaling in the **Sandbox Stage**.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture & Division of Responsibilities
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PROP EDITOR (Phase 9) │
|
||||
│ • Permanent template geometry mutation │
|
||||
│ • Vertex & radius coordinate transformations │
|
||||
│ • Anchor offset auto-scaling │
|
||||
│ • Stickman guide silhouette (Default: OFF) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│ Saves .prop file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SANDBOX STAGE (Phase 10) │
|
||||
│ • Contextual instance scaling via corner gizmos only │
|
||||
│ • Locked aspect ratio (uniform scaling) │
|
||||
│ • Physics mass decoupled from visual scale │
|
||||
└────────────────────────────┴────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
| Layer | Target Entity | Operation Type | Scaling Behavior | Data Persistence |
|
||||
| ----------------- | ----------------------------- | --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| **Prop Editor** | Template Asset (`.prop`) | Permanent geometry mutation | Bakes scale into raw vector vertices, radii, and anchor offsets. | Persists into `.prop` file. |
|
||||
| **Sandbox Stage** | Object Instance (`PropBlock`) | Node transform scaling | Pure gizmo scaling with **locked aspect ratio**. | Persists into `.scene` file (`scale: { x, y }`). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Prop Editor Implementation (Template Sizing)
|
||||
|
||||
### A. Document Geometry Scale Tool
|
||||
|
||||
- **Scope:** Modifies raw geometry arrays (`points` for polygons/lines, `radius` for circles) in the shape stack.
|
||||
|
||||
- **Anchor Recalculation:** All registered anchor offset positions (`x`, `y`) automatically multiply by the scale factor so grip, pivot, and emitter points maintain exact relative alignment.
|
||||
|
||||
- **Normalize Size Action:** Adds a canvas action (📏 **Normalize Size**) alongside 🎯 **Center Prop** that computes the bounding box union of all shapes and uniformly scales the document to fit within a standard $96 \times 96\text{ px}$ canvas box.
|
||||
|
||||
### B. Stickman Visual Reference Silhouette
|
||||
|
||||
- **Overlay Guide:** Renders a semi-transparent 1:1 Stickman figure silhouette on the canvas background for scale context against full character rigs ($\approx 336 \times 940\text{ px}$).
|
||||
|
||||
- **Default State:** Toggleable via the UI / canvas controls and **defaults to OFF**.
|
||||
|
||||
- **View Aid Only:** Non-selectable, non-interactive visual guide that is excluded from `.prop` file serialization.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sandbox Stage Implementation (Instance Sizing)
|
||||
|
||||
### A. Gizmo-Driven Scaling (No UI SpinBoxes / No Presets)
|
||||
|
||||
- **Corner Scale Handles:** Extends `StageGizmos` in `EDIT` mode to render corner cross handles on selected prop bounding boxes.
|
||||
|
||||
- **Aspect Ratio Locking:** Dragging any corner handle scales the object while strictly **locking the aspect ratio** (uniform scaling).
|
||||
|
||||
- **Streamlined UI:** No numeric scale SpinBoxes or preset buttons (`S`/`M`/`L`) are added to the stage UI; all scaling is performed intuitively directly on the canvas via gizmo manipulation.
|
||||
|
||||
### B. Mode Lifecycle & Physics Rules
|
||||
|
||||
- **Decoupled Mass Contract:** Scaling a prop adjusts node visual geometry (`Polygon2D`, `Line2D`) and collision bounds (`CollisionPolygon2D`, `CollisionShape2D`). RigidBody `mass` remains independent, governed strictly by the assigned Material Preset (Wood, Metal, Rubber, Cardboard).
|
||||
|
||||
- **Lifecycle State Handling (`PLAY` $\rightarrow$ `EDIT`):** Transitioning from `PLAY` back to `EDIT` mode restores the authored `position` and `rotation` and zeroes linear/angular velocity, but **does not reset or alter the node scale**. The authored scale set during editing is retained as-is.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Roadmap
|
||||
|
||||
1. **Phase A (Prop Editor Tooling):**
|
||||
|
||||
- Implement document-wide geometry and anchor scaling logic in `PropEditor`.
|
||||
|
||||
- Add the toggleable Stickman Reference Silhouette (**default OFF**) and "Normalize Size" canvas button.
|
||||
|
||||
2. **Phase B (Sandbox Gizmo Scaling):**
|
||||
|
||||
- Extend `StageGizmos` with corner scale handles that lock aspect ratio during drag operations.
|
||||
|
||||
- Ensure `StageSelection` AABB bounding boxes recalculate accurately to account for scaled instances.
|
||||
|
||||
3. **Phase C (Persistence & Verification):**
|
||||
|
||||
- Verify scene save/load serialization of scaled `PropBlock` instances.
|
||||
|
||||
- Confirm that `EDIT` $\rightarrow$ `PLAY` $\rightarrow$ `EDIT` mode switches restore authored position/rotation while keeping object scale intact.
|
||||
|
||||
## 5. Verified final Prop Editor controls
|
||||
|
||||
The Arrange mode top bar presents these controls in order:
|
||||
|
||||
1. **Transform Whole Prop**
|
||||
2. **Normalize Size**
|
||||
3. **Center Prop**
|
||||
4. **Show Stickman Guide**
|
||||
|
||||
The numeric scale textbox and Apply Scale button were removed. Transform Whole
|
||||
Prop previews and commits whole-document move, rotation, and uniform resize
|
||||
gestures around the canonical origin. Grid Snap rounds move deltas, Ctrl snaps
|
||||
rotation to 15-degree increments, editable anchors follow the geometry, and Esc
|
||||
cancels an active preview. Normalize Size fits the shape-point bounds to a 96 px
|
||||
maximum dimension. Transform Whole Prop and Normalize Size are hidden in Reshape
|
||||
and Attach; Center Prop and Show Stickman Guide remain available in all modes.
|
||||
|
||||
Sandbox instance scaling remains a separate prop-only uniform corner-gizmo
|
||||
operation. It retains the authored scene scale, preset-governed mass, and
|
||||
PLAY-to-EDIT position/rotation restoration behavior.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Architecture Specification: Interactive Prop System & Event Engine
|
||||
|
||||
This specification details the technical architecture for bringing interactive prop capabilities—including auto-approach, IK socket binding, parabolic throwing, ragdoll disarming, and "When..." rule triggers—into the Sandbox Stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview & Layer Interactions
|
||||
|
||||
Prop interactions bridge five core subsystems across the runtime stack:
|
||||
|
||||
```
|
||||
[ Director UI / Target Selector ]
|
||||
│
|
||||
▼
|
||||
[ StickmanActionRunner ] ──(Auto-Approach)──► [ StickmanLocomotion ]
|
||||
│
|
||||
▼
|
||||
[ StickmanRig IK ] ──(Socket Binding)──► [ PropBlock Physics ]
|
||||
│
|
||||
▼
|
||||
[ StageEventRegistry ] ──("When..." Rules)──► [ Reactive Actions ]
|
||||
|
||||
```
|
||||
|
||||
1. **Director UI & Target Selection:** Captures spatial throw targets and enqueues prop interaction commands.
|
||||
|
||||
2. **Action Runner & Locomotion:** Evaluates spatial reach; automatically inserts `walk_to` sub-goals when props are out of arm's reach.
|
||||
|
||||
3. **IK & Bone Socketing:** Extends arm IK targets toward prop `grip` anchors and manages `RemoteTransform2D` binding.
|
||||
|
||||
4. **Physics Lifecycle:** Unfreezes/freezes `PropBlock` bodies and inherits velocity during release, throw, or ragdoll disarm transitions.
|
||||
|
||||
5. **Event Registry:** Emits and evaluates edge-triggered prop rules within the stage's "When → Then" engine.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Action Queue & Auto-Approach (`walk_to`)
|
||||
|
||||
To ensure smooth director interaction, the action queue automatically resolves spatial gaps between the stickman and target props.
|
||||
|
||||
### Action Payload Schemas
|
||||
|
||||
- **`pick_up_prop`**: `{ "type": "pick_up_prop", "target_prop_id": String, "hand": "auto" | "left" | "right" }`
|
||||
|
||||
- **`drop_prop`**: `{ "type": "drop_prop" }`
|
||||
|
||||
- **`throw_prop`**: `{ "type": "throw_prop", "target_position": Vector2, "force": float }`
|
||||
|
||||
### Smart Approach Execution
|
||||
|
||||
When `StickmanActionRunner` receives a `pick_up_prop` payload:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ pick_up_prop Action Start │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ Distance = |rig.position - prop.position| │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
Is Distance > Reach Radius (e.g. 40px)?
|
||||
┌───────────────────────┴───────────────────────┐
|
||||
│ │
|
||||
[ YES ] [ NO ]
|
||||
│ │
|
||||
┌─────────────────▼─────────────────┐ ┌─────────────────▼─────────────────┐
|
||||
│ 1. Pause pick_up_prop sequence │ │ 1. Skip locomotion step │
|
||||
│ 2. Prepend walk_to(prop_offset) │ │ 2. Trigger arm IK reach animation │
|
||||
│ 3. Resume pickup on arrival │ └───────────────────────────────────┘
|
||||
└───────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Distance Check:** Evaluates global horizontal/spatial distance between the stickman root and prop `origin` pivot.
|
||||
|
||||
2. **Sub-Goal Injection:** If outside reach radius, `StickmanActionRunner` pauses the pickup sequence and internally issues a `walk_to` command to a stand point adjacent to the prop.
|
||||
|
||||
3. **Sequence Resume:** Upon receiving `StickmanLocomotion.arrived_at_waypoint`, the runner resumes the pickup sequence and triggers arm IK extension.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pickup, Drop, and Throw Pipelines
|
||||
|
||||
### A. Prop Pickup Sequence & IK Socketing
|
||||
|
||||
1. **IK Reach Phase (150 ms):** `TwoBoneIK` targets for the selected arm interpolate toward the world position of the prop's `grip` anchor.
|
||||
|
||||
2. **Socket Attachment:**
|
||||
|
||||
- Set `PropBlock.freeze = true` (kinematic mode) to suspend environmental physics forces.
|
||||
|
||||
- Instantiate or activate a `RemoteTransform2D` node attached to the lower arm / wrist joint, targeting the `PropBlock`.
|
||||
|
||||
- Apply anchor local offsets (`x, y, rotation_deg, z_index_offset`) to align the prop grip directly within the hand geometry.
|
||||
|
||||
3. **Holding Stance Latch:** Lock upper limb IK targets into a carried stance during subsequent walk cycles.
|
||||
|
||||
### B. Prop Drop Sequence
|
||||
|
||||
1. **Unbinding:** Clear `RemoteTransform2D` target and reparent `PropBlock` back to the stage world root.
|
||||
|
||||
2. **Physics Activation:** Set `PropBlock.freeze = false` to re-enable rigid body simulation, gravity, and collision shapes.
|
||||
|
||||
3. **Inertia Inheritance:** Set `PropBlock.linear_velocity` equal to the stickman's current movement velocity (`_last_grounded_walk_velocity_x`) so dropped items retain forward momentum.
|
||||
|
||||
4. **Arm Recovery:** Blend wrist IK targets smoothly back to standard locomotion/idle rest positions.
|
||||
|
||||
### C. Prop Throw Sequence & Trajectory Selection
|
||||
|
||||
#### Director Target UI Flow
|
||||
|
||||
- **Targeting Mode:** Selecting "Throw Prop" in Director/Play mode activates an aim reticle cursor and dynamic arc preview.
|
||||
|
||||
- **Trajectory Math:** Given hand release point $P_0$, target landing point $P_t$, base speed $v_0$, and gravity $g$:
|
||||
|
||||
$$\theta = \arctan\left(\frac{v_0^2 \pm \sqrt{v_0^4 - g(g \cdot x^2 + 2 y \cdot v_0^2)}}{g \cdot x}\right)$$
|
||||
|
||||
Calculates exact initial launch velocity components $V_{\text{launch}} = (v_0 \cos\theta, v_0 \sin\theta)$.
|
||||
|
||||
#### Keyframe Throw Animation Arc
|
||||
|
||||
```
|
||||
[ Windup: 100ms ] ──► [ Release Frame: 150ms ] ──► [ Follow-Through: 50ms ]
|
||||
- Torso tilts -15° - Arm sweeps to angle - Arm extends along arc
|
||||
- Arm pulls backward - Prop unfreezes + impulse - Blends back to idle
|
||||
|
||||
```
|
||||
|
||||
1. **Windup (100 ms):** Torso rotates backward opposite the launch vector while the arm pulls back.
|
||||
2. **Release Frame (150 ms):**
|
||||
|
||||
- Arm sweeps rapidly along the calculated launch angle $\theta$.
|
||||
- Unbind `RemoteTransform2D` and reparent prop to world root.
|
||||
|
||||
- Set `PropBlock.freeze = false` and execute `apply_central_impulse(V_launch)`.
|
||||
|
||||
- Apply rotational torque proportional to the distance between hand grip and prop center-of-mass.
|
||||
|
||||
3. **Follow-Through (50 ms):** Arm extends toward launch vector, then smoothly dampens back to default idle/walking pose.
|
||||
|
||||
---
|
||||
|
||||
## 4. Physics & State Machine Lifecycle (Ragdoll Disarm Handoff)
|
||||
|
||||
When `StickmanRig.set_ragdoll(true)` is executed (via impact, command, or keypress), any held prop must be immediately disarmed before kinematic nodes are hidden and ragdoll bodies are built.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ StickmanRig.set_ragdoll(true) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 1. Pre-Ragdoll Intercept │
|
||||
│ Query if held_prop != null │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 2. Execute Disarm Handoff │
|
||||
│ - Clear RemoteTransform2D binding │
|
||||
│ - Reparent PropBlock to world root │
|
||||
│ - Set prop.freeze = false │
|
||||
│ - Apply cached momentum (_track_momentum) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 3. Build Physics Ragdoll │
|
||||
│ Hide animated body, build RigidBody2D │
|
||||
│ network at current solved bone positions │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
1. **Pre-Ragdoll Intercept:** Prior to clearing collision proxies or hiding body meshes, check for an active `held_prop` binding.
|
||||
|
||||
2. **Physics Unbind:**
|
||||
|
||||
- Detach the `RemoteTransform2D` socket.
|
||||
|
||||
- Reparent prop to the world root and set `PropBlock.freeze = false`.
|
||||
|
||||
- Transfer cached rig momentum (`_track_momentum()`) directly to `PropBlock.linear_velocity` and `angular_velocity` so the prop tumbles naturally alongside the falling figure.
|
||||
|
||||
3. **Ragdoll Construction:** Proceed with standard `_build_ragdoll()` processing at current solved bone transforms.
|
||||
|
||||
---
|
||||
|
||||
## 5. Event System Integration ("When..." Triggers)
|
||||
|
||||
Three new edge-triggered prop event types are added to `StageEventRegistry` (`sandbox_stage.gd`) to support the director's "When → Then" mental model.
|
||||
|
||||
### Prop Trigger Specifications
|
||||
|
||||
| Trigger Type | Source Object | Target Object | Firing Condition |
|
||||
| ---------------- | --------------- | --------------- | ------------------------------------------------------------------------------- |
|
||||
| `prop_picked_up` | Stickman / Prop | Prop / Stickman | Emitted as soon as IK arm reach completes and `RemoteTransform2D` socket locks. |
|
||||
|
||||
|
|
||||
| `prop_dropped` | Stickman / Prop | Prop / Stickman | Emitted when a stickman drops a prop manually or via ragdoll disarm.
|
||||
|
||||
|
|
||||
| `prop_thrown` | Stickman / Prop | Prop / Stickman | Emitted at the exact release frame of a throw animation arc.
|
||||
|
||||
|
|
||||
|
||||
### Signal Routing & Evaluation
|
||||
|
||||
1. `StickmanActionRunner` emits `prop_action_executed(action_type, stickman, prop)` upon completing or releasing an anchor attachment.
|
||||
|
||||
2. `StageEventRegistry` matches active stage rules where `rule.trigger.type` equals `prop_picked_up`, `prop_dropped`, or `prop_thrown`.
|
||||
|
||||
3. Matching rules trigger reactive actions on target stickmen or props (e.g., _When Stickman A picks up Sword X $\rightarrow$ Stickman B ragdolls_).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Roadmap for Architect Agent
|
||||
|
||||
```
|
||||
[ Phase 1: Action Queue & Locomotion ]
|
||||
├─ Add pick_up_prop, drop_prop, and throw_prop to StickmanActionRunner
|
||||
└─ Implement distance evaluation & auto-enqueueing of walk_to sub-goals
|
||||
|
||||
[ Phase 2: Socket Attachment & IK Handshake ]
|
||||
├─ Implement RemoteTransform2D socketing to wrist joint with grip offsets
|
||||
└─ Implement holding stance arm pose blending
|
||||
|
||||
[ Phase 3: Throw Mechanics & Trajectory UI ]
|
||||
├─ Add trajectory solver & reticle target selector to Director UI
|
||||
└─ Build throw animation timeline (windup -> release impulse -> follow-through)
|
||||
|
||||
[ Phase 4: State Machine Integration & Disarm ]
|
||||
├─ Intercept held props during set_ragdoll(true) transitions
|
||||
└─ Transfer cached linear/angular momentum to disarmed prop physics body
|
||||
|
||||
[ Phase 5: Event System Expansion ]
|
||||
├─ Register prop_picked_up, prop_dropped, prop_thrown triggers in StageEventRegistry
|
||||
└─ Wire action signals to trigger "When -> Then" rule evaluations
|
||||
|
||||
```
|
||||
|
||||
### Architectural Acceptance Criteria Checklist
|
||||
|
||||
- [ ] **Auto-Approach:** Requesting pickup of a distant prop automatically causes the stickman to walk to the prop before reaching out.
|
||||
|
||||
- [ ] **Grip Alignment:** Hand attaches precisely to the prop's authored `grip` anchor, respecting local position, rotation, and Z-index offsets.
|
||||
|
||||
- [ ] **Trajectory Accuracy:** Thrown props follow the calculated parabolic arc toward the director's targeted ground point.
|
||||
|
||||
- [ ] **Ragdoll Disarm:** Entering ragdoll mode immediately detaches held props and applies rig momentum so items tumble realistically.
|
||||
|
||||
- [ ] **Event Rule Engine:** Rules configured with `When prop_picked_up`, `When prop_dropped`, or `When prop_thrown` execute downstream actions reliably.
|
||||
@@ -0,0 +1,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
@@ -41,7 +41,8 @@
|
||||
"text": "#F2F2F2",
|
||||
"text_muted": "#9A9A9A",
|
||||
"border": "#FFFFFF33",
|
||||
"accent": "#4FA3FF"
|
||||
"accent": "#4FA3FF",
|
||||
"inspector_background": "#262C39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"anchors": {
|
||||
"origin": {
|
||||
"allowed_hands": "both",
|
||||
"role": "pivot",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z_index_offset": 0
|
||||
},
|
||||
"primary_grip": {
|
||||
"allowed_hands": "both",
|
||||
"role": "grip",
|
||||
"rotation_deg": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z_index_offset": 1
|
||||
}
|
||||
},
|
||||
"material_preset": "rubber",
|
||||
"metadata": {
|
||||
"created_at": "2026-09-22T00:26:21",
|
||||
"modified_at": "2026-09-22T00:26:21"
|
||||
},
|
||||
"prop_name": "Baseball",
|
||||
"shapes": [
|
||||
{
|
||||
"closed": true,
|
||||
"fill_color": "#F5F5F0FF",
|
||||
"outline_color": "#A0A0A0FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 25.1999988555908,
|
||||
"y": 0.0
|
||||
},
|
||||
{
|
||||
"x": 21.8231983184814,
|
||||
"y": 12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": 12.5999994277954,
|
||||
"y": 21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": 25.1999988555908
|
||||
},
|
||||
{
|
||||
"x": -12.5999994277954,
|
||||
"y": 21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": -21.8231983184814,
|
||||
"y": 12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": -25.1999988555908,
|
||||
"y": 0.0
|
||||
},
|
||||
{
|
||||
"x": -21.8231983184814,
|
||||
"y": -12.5999994277954
|
||||
},
|
||||
{
|
||||
"x": -12.5999994277954,
|
||||
"y": -21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": -25.1999988555908
|
||||
},
|
||||
{
|
||||
"x": 12.5999994277954,
|
||||
"y": -21.8231983184814
|
||||
},
|
||||
{
|
||||
"x": 21.8231983184814,
|
||||
"y": -12.5999994277954
|
||||
}
|
||||
],
|
||||
"radius": 25.1996288299561,
|
||||
"reshaped": false,
|
||||
"shape_type": "circle",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": false,
|
||||
"fill_color": "#00000000",
|
||||
"outline_color": "#D62828FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": -8.64000034332275,
|
||||
"y": -20.1599998474121
|
||||
},
|
||||
{
|
||||
"x": -15.8400001525879,
|
||||
"y": -10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": -15.8400001525879,
|
||||
"y": 10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": -8.64000034332275,
|
||||
"y": 20.1599998474121
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "line",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"closed": false,
|
||||
"fill_color": "#00000000",
|
||||
"outline_color": "#D62828FF",
|
||||
"outline_width": 2.0,
|
||||
"points": [
|
||||
{
|
||||
"x": 8.64000034332275,
|
||||
"y": -20.1599998474121
|
||||
},
|
||||
{
|
||||
"x": 15.8400001525879,
|
||||
"y": -10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": 15.8400001525879,
|
||||
"y": 10.0799999237061
|
||||
},
|
||||
{
|
||||
"x": 8.64000034332275,
|
||||
"y": 20.1599998474121
|
||||
}
|
||||
],
|
||||
"reshaped": true,
|
||||
"shape_type": "line",
|
||||
"vertex_flags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[ext_resource type="Script" path="res://scripts/stickman_locomotion.gd" id="2_locomotion"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_ragdoll.gd" id="3_ragdoll"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_action_runner.gd" id="4_runner"]
|
||||
[ext_resource type="Script" path="res://scripts/stickman_prop_interaction.gd" id="5_prop_interaction"]
|
||||
|
||||
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
||||
script/source = "@tool
|
||||
@@ -335,6 +336,9 @@ script = ExtResource("3_ragdoll")
|
||||
[node name="ActionRunner" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("4_runner")
|
||||
|
||||
[node name="PropInteraction" type="Node" parent="RigComponents"]
|
||||
script = ExtResource("5_prop_interaction")
|
||||
|
||||
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
||||
|
||||
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name MissingPropPlaceholder
|
||||
extends Node2D
|
||||
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_prop_path: String = ""
|
||||
const MARKER_SIZE := Vector2(180.0, 54.0)
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
original_entry = entry.duplicate(true)
|
||||
missing_prop_path = String(entry.get("asset_id", ""))
|
||||
queue_redraw()
|
||||
|
||||
func get_area_rect() -> Rect2:
|
||||
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := get_area_rect()
|
||||
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
|
||||
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
|
||||
var asset_name := missing_prop_path.get_file()
|
||||
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 33.0), "Missing prop - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://iwogmwpek8cc
|
||||
@@ -0,0 +1,174 @@
|
||||
class_name PropAnchorData
|
||||
extends RefCounted
|
||||
## Normalized, JSON-safe prop-anchor model shared by authoring tools.
|
||||
|
||||
const ORIGIN_ID: String = "origin"
|
||||
const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"]
|
||||
const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"]
|
||||
enum Hand { LEFT, RIGHT }
|
||||
|
||||
static func default_anchors() -> Dictionary:
|
||||
return {ORIGIN_ID: _origin()}
|
||||
|
||||
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if raw is Dictionary:
|
||||
for raw_id: Variant in (raw as Dictionary):
|
||||
if not (raw as Dictionary)[raw_id] is Dictionary:
|
||||
if warn: push_warning("PropAnchorData: skipped malformed anchor.")
|
||||
continue
|
||||
var id: String = normalize_id(str(raw_id), result)
|
||||
if id == ORIGIN_ID:
|
||||
continue
|
||||
var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary
|
||||
var role: String = str(source.get("role", "custom")).to_lower()
|
||||
if not ROLE_STRINGS.has(role) or role == "pivot":
|
||||
if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.")
|
||||
role = "custom"
|
||||
var hand: String = str(source.get("allowed_hands", "both")).to_lower()
|
||||
if not ALLOWED_HAND_STRINGS.has(hand): hand = "both"
|
||||
result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand}
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return result.duplicate(true)
|
||||
|
||||
static func serialize_anchors(anchors: Dictionary) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
var result: Dictionary = {}
|
||||
for id: String in normalized:
|
||||
var anchor: Dictionary = normalized[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))}
|
||||
return result
|
||||
|
||||
static func editable_ids(anchors: Dictionary) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if id != ORIGIN_ID:
|
||||
result.append(id)
|
||||
return result
|
||||
|
||||
static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if preferred_id != ORIGIN_ID and normalized.has(preferred_id):
|
||||
return preferred_id
|
||||
if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip":
|
||||
return "primary_grip"
|
||||
for id: String in editable_ids(normalized):
|
||||
if str(normalized[id].get("role", "")) == "grip":
|
||||
return id
|
||||
var ids: Array[String] = editable_ids(normalized)
|
||||
return ids[0] if not ids.is_empty() else ""
|
||||
|
||||
static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
push_warning("PropAnchorData: ignored non-finite anchor translation.")
|
||||
return normalized
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
anchor["position"] = anchor.get("position", Vector2.ZERO) + delta
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return normalized
|
||||
|
||||
static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos; result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary:
|
||||
if not is_finite(angle_rad): return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad)
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos
|
||||
anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad))
|
||||
result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
|
||||
## Returns a fully normalized scaled copy, or {"ok": false} without changing
|
||||
## the caller's data when the operation cannot be represented safely.
|
||||
static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary:
|
||||
if not is_finite(factor) or factor <= 0.0:
|
||||
return {"ok": false, "anchors": {}}
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor
|
||||
if not is_finite(position.x) or not is_finite(position.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = position
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": normalized}
|
||||
|
||||
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
|
||||
var id: String = candidate.strip_edges().to_lower()
|
||||
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
|
||||
id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_")
|
||||
if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor"
|
||||
id = id.substr(0, 32)
|
||||
if id == ORIGIN_ID and not anchors.has(id): return id
|
||||
var base: String = id; var suffix: int = 2
|
||||
while anchors.has(id) or id == ORIGIN_ID:
|
||||
id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1
|
||||
return id
|
||||
|
||||
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id)
|
||||
return ids
|
||||
|
||||
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in ids_for_role(anchors, "grip"):
|
||||
var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both"))
|
||||
if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id)
|
||||
return result
|
||||
|
||||
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D:
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0)))
|
||||
if mirror_x: position.x = -position.x; angle = PI - angle
|
||||
return Transform2D(angle, position)
|
||||
|
||||
static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D:
|
||||
var effective: Transform2D = local_transform(anchor, mirror_x)
|
||||
var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001))
|
||||
var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale)
|
||||
basis.origin = target_transform.origin - basis.basis_xform(effective.origin)
|
||||
return basis
|
||||
|
||||
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary:
|
||||
var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO
|
||||
var horizontal: bool = bounds.size.x >= bounds.size.y
|
||||
var length: float = bounds.size.x if horizontal else bounds.size.y
|
||||
var angle: float = 0.0 if horizontal else 90.0
|
||||
var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y)
|
||||
var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0)))
|
||||
var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
var out: Dictionary = default_anchors()
|
||||
match preset:
|
||||
"one_handed": out["primary_grip"] = make.call("grip", center)
|
||||
"two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset)
|
||||
"tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint)
|
||||
"melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint)
|
||||
return out
|
||||
|
||||
static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
static func _finite(value: Variant, x: bool = true) -> float:
|
||||
var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value))
|
||||
return n if is_finite(n) else 0.0
|
||||
static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhlmhegchc1cu
|
||||
@@ -0,0 +1,101 @@
|
||||
class_name PropAnchorOverlay
|
||||
extends RefCounted
|
||||
|
||||
signal anchors_changed(anchors: Dictionary)
|
||||
signal anchor_selected(anchor_id: String)
|
||||
|
||||
enum DragMode { NONE, TRANSLATE, ROTATE }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const MARKER_RADIUS: float = 9.0
|
||||
const HIT_RADIUS: float = 15.0
|
||||
const ROTATE_OFFSET: float = 28.0
|
||||
const ROTATION_DOT_RADIUS: float = 5.0
|
||||
const ROTATION_DOT_HIT_RADIUS: float = 12.0
|
||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||
var _selected_id: String = ""
|
||||
var _show_inactive: bool = true
|
||||
var _drag_mode: DragMode = DragMode.NONE
|
||||
var _drag_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
func set_anchor_data(data: Variant) -> void:
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(data)
|
||||
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
|
||||
|
||||
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
|
||||
func select_anchor(anchor_id: String) -> void:
|
||||
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
|
||||
if next_id != _selected_id:
|
||||
_selected_id = next_id
|
||||
anchor_selected.emit(_selected_id)
|
||||
func get_selected_anchor_id() -> String: return _selected_id
|
||||
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
|
||||
|
||||
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if not _visible(id): continue
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
|
||||
return ""
|
||||
|
||||
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
|
||||
# The dot is intentionally outside the marker hit target, so test it before
|
||||
# ordinary anchor selection. This matches the shape transform gizmo model.
|
||||
if not _selected_id.is_empty() and _anchors.has(_selected_id):
|
||||
var selected: Dictionary = _anchors[_selected_id]
|
||||
var role: String = str(selected.get("role", "custom"))
|
||||
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
|
||||
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
|
||||
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
|
||||
_drag_mode = DragMode.ROTATE
|
||||
return true
|
||||
var id: String = hit_test_anchor(world_pos, zoom)
|
||||
if id.is_empty(): return false
|
||||
select_anchor(id)
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var role: String = str(anchor.get("role", "custom"))
|
||||
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
|
||||
return true
|
||||
|
||||
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
|
||||
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
|
||||
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
|
||||
if _drag_mode == DragMode.TRANSLATE:
|
||||
var pos: Vector2 = world_pos + _drag_offset
|
||||
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
|
||||
anchor["position"] = pos
|
||||
else:
|
||||
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
|
||||
if ctrl: angle = snappedf(angle, 15.0)
|
||||
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
|
||||
_anchors[_selected_id] = anchor
|
||||
anchors_changed.emit(get_anchor_data())
|
||||
|
||||
func end_pointer() -> void:
|
||||
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
|
||||
_drag_mode = DragMode.NONE
|
||||
|
||||
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
|
||||
|
||||
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
|
||||
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
|
||||
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
|
||||
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
|
||||
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
|
||||
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
|
||||
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
|
||||
if role == "grip" or role == "emitter":
|
||||
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
|
||||
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
|
||||
if selected:
|
||||
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
|
||||
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
|
||||
canvas.draw_line(pos, dot, color, 2.0 / zoom)
|
||||
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
|
||||
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
|
||||
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj2xh11d1wk7j
|
||||
@@ -0,0 +1,548 @@
|
||||
class_name PropAttachPreview
|
||||
extends PanelContainer
|
||||
## Private editor visualization. It never creates a PropBlock or touches Sandbox state.
|
||||
|
||||
enum PreviewHand { LEFT, RIGHT }
|
||||
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const StickmanFactoryScript := preload("res://scripts/stickman_factory.gd")
|
||||
const StkRigAdapterScript := preload("res://scripts/stk_rig_adapter.gd")
|
||||
const RigScene := preload("res://scenes/stickman_rig.tscn")
|
||||
const RIG_CENTER := Vector2(340.0, 575.0)
|
||||
const TARGET_RADIUS := 14.0
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _viewport_container: SubViewportContainer
|
||||
var _world: Node2D
|
||||
var _rig_root: Node2D
|
||||
var _rig: StickmanRig
|
||||
var _prop: Node2D
|
||||
var _target_marker: Polygon2D
|
||||
var _camera: Camera2D
|
||||
var _hint: Label
|
||||
var _walk_playback_button: Button
|
||||
var _reset_arm_button: Button
|
||||
var _reset_view_button: Button
|
||||
var _hand_option: OptionButton
|
||||
var _motion_option: OptionButton
|
||||
var _shapes: Array[Dictionary] = []
|
||||
var _anchors: Dictionary = {}
|
||||
var _selected: String = ""
|
||||
var _hand: int = PreviewHand.RIGHT
|
||||
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
|
||||
var _walk_paused: bool = true
|
||||
var _manual_arm_override: bool = false
|
||||
# Retained only for old test harnesses; Phase 9c UI uses _walk_paused.
|
||||
var _animate: bool = false
|
||||
var _dragging_target: bool = false
|
||||
var _camera_dragging: bool = false
|
||||
var _last_pointer := Vector2.ZERO
|
||||
var _hand_target_rests_local: Dictionary = {}
|
||||
var _camera_zoom := 1.0
|
||||
var _fitted_zoom := 1.0
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size.x = 360.0
|
||||
var surface := StyleBoxFlat.new()
|
||||
surface.bg_color = Color(0.075, 0.09, 0.12, 1.0)
|
||||
surface.set_border_width_all(0)
|
||||
surface.set_corner_radius_all(5)
|
||||
add_theme_stylebox_override("panel", surface)
|
||||
_build_ui()
|
||||
_build_private_world()
|
||||
_reframe_camera()
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layout := VBoxContainer.new()
|
||||
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_child(layout)
|
||||
var header := FlowContainer.new()
|
||||
layout.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Holding Preview"
|
||||
title.custom_minimum_size.x = 132.0
|
||||
header.add_child(title)
|
||||
_hand_option = OptionButton.new()
|
||||
_hand_option.name = "HandOption"
|
||||
_hand_option.add_item("Left Hand", PreviewHand.LEFT)
|
||||
_hand_option.add_item("Right Hand", PreviewHand.RIGHT)
|
||||
_hand_option.select(PreviewHand.RIGHT)
|
||||
_hand_option.item_selected.connect(set_preview_hand)
|
||||
header.add_child(_hand_option)
|
||||
_motion_option = OptionButton.new()
|
||||
_motion_option.name = "MotionOption"
|
||||
_motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE)
|
||||
_motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT)
|
||||
_motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT)
|
||||
_motion_option.item_selected.connect(set_preview_motion)
|
||||
header.add_child(_motion_option)
|
||||
_hint = Label.new()
|
||||
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
layout.add_child(_hint)
|
||||
_viewport_container = SubViewportContainer.new()
|
||||
# The pane keeps its 360 px width, but its height must yield to the editor's
|
||||
# fixed name/status rows when Attach mode makes the preview visible.
|
||||
_viewport_container.custom_minimum_size = Vector2(360.0, 0.0)
|
||||
_viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_viewport_container.stretch = true
|
||||
_viewport_container.gui_input.connect(_on_viewport_gui_input)
|
||||
layout.add_child(_viewport_container)
|
||||
var footer := FlowContainer.new()
|
||||
layout.add_child(footer)
|
||||
_walk_playback_button = Button.new()
|
||||
_walk_playback_button.name = "WalkPlaybackButton"
|
||||
_walk_playback_button.pressed.connect(toggle_walk_playback)
|
||||
footer.add_child(_walk_playback_button)
|
||||
_reset_arm_button = Button.new()
|
||||
_reset_arm_button.text = "Reset Arm"
|
||||
_reset_arm_button.pressed.connect(reset_arm_pose)
|
||||
footer.add_child(_reset_arm_button)
|
||||
_reset_view_button = Button.new()
|
||||
_reset_view_button.text = "Reset View"
|
||||
_reset_view_button.pressed.connect(_reframe_camera)
|
||||
footer.add_child(_reset_view_button)
|
||||
_apply_hit_target_metrics(36.0)
|
||||
|
||||
func set_ui_hit_target_height(height: float) -> void:
|
||||
_apply_hit_target_metrics(maxf(height, 36.0))
|
||||
|
||||
func _apply_hit_target_metrics(height: float) -> void:
|
||||
for option: OptionButton in [_hand_option, _motion_option]:
|
||||
if option != null:
|
||||
option.custom_minimum_size = Vector2(88.0, height)
|
||||
option.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
option.focus_mode = Control.FOCUS_NONE
|
||||
for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]:
|
||||
if button != null:
|
||||
button.custom_minimum_size = Vector2(112.0, height)
|
||||
button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
|
||||
func _build_private_world() -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.name = "PreviewViewport"
|
||||
_viewport.transparent_bg = false
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_viewport_container.add_child(_viewport)
|
||||
_viewport_container.resized.connect(_resize_viewport)
|
||||
_world = Node2D.new()
|
||||
_world.name = "PreviewWorld"
|
||||
_viewport.add_child(_world)
|
||||
_rig_root = Node2D.new()
|
||||
_rig_root.name = "PreviewRigRoot"
|
||||
_rig_root.position = RIG_CENTER
|
||||
_world.add_child(_rig_root)
|
||||
_rig = RigScene.instantiate() as StickmanRig
|
||||
_rig.set_physics_process(false)
|
||||
_rig_root.add_child(_rig)
|
||||
var data: Dictionary = StickmanFactoryScript.load_stk("res://stickmen/test.stk")
|
||||
if not data.is_empty():
|
||||
StkRigAdapterScript.apply(data, _rig)
|
||||
_prop = Node2D.new()
|
||||
_prop.name = "PreviewProp"
|
||||
_world.add_child(_prop)
|
||||
_target_marker = Polygon2D.new()
|
||||
_target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)])
|
||||
_target_marker.color = Color(0.25, 0.9, 1.0, 0.85)
|
||||
_world.add_child(_target_marker)
|
||||
_camera = Camera2D.new()
|
||||
_camera.name = "PreviewCamera"
|
||||
_world.add_child(_camera)
|
||||
_camera.make_current()
|
||||
_disable_collisions(_rig)
|
||||
call_deferred("_finish_rig_setup")
|
||||
|
||||
func _finish_rig_setup() -> void:
|
||||
var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if player != null:
|
||||
player.stop()
|
||||
_rig.stop_queue()
|
||||
_rig.clear_queue()
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null:
|
||||
_hand_target_rests_local[hand] = target.position
|
||||
_apply_motion_pose()
|
||||
_update_target_marker()
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void:
|
||||
_shapes = shapes.duplicate(true)
|
||||
_anchors = anchors.duplicate(true)
|
||||
_selected = selected_anchor_id
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_selected_anchor(anchor_id: String) -> void:
|
||||
_selected = anchor_id
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_hand(hand: int) -> void:
|
||||
_hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose()
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_motion(motion: int) -> void:
|
||||
_motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose(true)
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
# Compatibility facade for Phase 9a callers. The visible Facing control was
|
||||
# removed in 9c; callers now map to the equivalent preview motion.
|
||||
func set_facing_profile(profile: int) -> void:
|
||||
if profile == StickmanRig.FacingProfile.LEFT:
|
||||
set_preview_motion(PreviewMotion.WALK_LEFT)
|
||||
elif profile == StickmanRig.FacingProfile.RIGHT:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
else:
|
||||
set_preview_motion(PreviewMotion.FRONT_POSE)
|
||||
|
||||
func set_walk_paused(paused: bool) -> void:
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_walk_paused = true
|
||||
_update_walk_button()
|
||||
return
|
||||
var player := _animation_player()
|
||||
_walk_paused = paused
|
||||
if player != null:
|
||||
if paused:
|
||||
player.pause()
|
||||
else:
|
||||
player.play()
|
||||
_manual_arm_override = false if not paused else _manual_arm_override
|
||||
_update_walk_button()
|
||||
|
||||
func toggle_walk_playback() -> void:
|
||||
set_walk_paused(not _walk_paused)
|
||||
|
||||
func set_arm_animation_enabled(enabled: bool) -> void:
|
||||
_animate = enabled
|
||||
if enabled and _motion == PreviewMotion.FRONT_POSE:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
elif not enabled:
|
||||
set_walk_paused(true)
|
||||
|
||||
func reset_arm_pose() -> void:
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
var player := _animation_player()
|
||||
if player != null:
|
||||
player.seek(player.current_animation_position, true)
|
||||
else:
|
||||
_restore_standing_targets()
|
||||
_manual_arm_override = false
|
||||
_animate = false
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func is_preview_available() -> bool:
|
||||
return _rig != null and is_instance_valid(_rig) and _viewport != null
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_preview_available():
|
||||
return
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func _animation_player() -> AnimationPlayer:
|
||||
return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null
|
||||
|
||||
func _restore_standing_targets() -> void:
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null and _hand_target_rests_local.has(hand):
|
||||
target.position = _hand_target_rests_local[hand]
|
||||
|
||||
func _apply_motion_pose(restart: bool = false) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var player := _animation_player()
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
_rig_root.scale = Vector2.ONE
|
||||
if player != null: player.stop(true)
|
||||
_restore_standing_targets()
|
||||
_walk_paused = true
|
||||
else:
|
||||
var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT
|
||||
_rig.set_facing_profile(profile)
|
||||
_rig.scale = Vector2.ONE
|
||||
_rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE
|
||||
if player != null:
|
||||
if restart or player.current_animation != "walk_right":
|
||||
player.play("walk_right")
|
||||
player.seek(0.0, true)
|
||||
else:
|
||||
player.play()
|
||||
_walk_paused = false
|
||||
_enable_arm_ik()
|
||||
_update_walk_button()
|
||||
|
||||
func _update_walk_button() -> void:
|
||||
if _walk_playback_button == null:
|
||||
return
|
||||
var front: bool = _motion == PreviewMotion.FRONT_POSE
|
||||
_walk_playback_button.disabled = front
|
||||
_walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk"
|
||||
_walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else ""
|
||||
|
||||
func _hand_target() -> Marker2D:
|
||||
return _target_for(_hand)
|
||||
|
||||
func _target_for(hand: int) -> Marker2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D
|
||||
|
||||
func _lower_arm() -> Bone2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D
|
||||
|
||||
func _solved_wrist_transform() -> Transform2D:
|
||||
var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D
|
||||
var lower := _lower_arm()
|
||||
if lower == null or lower_visual == null:
|
||||
return Transform2D.IDENTITY
|
||||
var elbow := lower_visual.to_global(Vector2.ZERO)
|
||||
var wrist := lower_visual.to_global(Vector2(0.0, lower.length))
|
||||
var direction := (wrist - elbow).normalized()
|
||||
if direction.length_squared() < 0.0001:
|
||||
return Transform2D.IDENTITY
|
||||
return Transform2D(direction.angle(), wrist)
|
||||
|
||||
func _rebuild_prop() -> void:
|
||||
if _prop == null:
|
||||
return
|
||||
for child: Node in _prop.get_children():
|
||||
child.queue_free()
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor)
|
||||
_update_hand_choices(anchor)
|
||||
_prop.visible = grip_ok
|
||||
if _hint != null:
|
||||
_hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding."
|
||||
if not grip_ok:
|
||||
return
|
||||
for shape: Dictionary in _shapes:
|
||||
_add_shape_visual(shape)
|
||||
_update_prop_transform()
|
||||
|
||||
func _add_shape_visual(shape: Dictionary) -> void:
|
||||
# PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array
|
||||
# directly to PackedVector2Array silently turns every point into zero.
|
||||
var points := _coerce_points(shape.get("points", PackedVector2Array()))
|
||||
if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle":
|
||||
var radius := maxf(float(shape.get("radius", 24.0)), 0.001)
|
||||
var center := Vector2.ZERO
|
||||
for index: int in 24:
|
||||
points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius)
|
||||
if points.is_empty():
|
||||
return
|
||||
var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY)
|
||||
if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)):
|
||||
var line := Line2D.new()
|
||||
line.points = points
|
||||
line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0)
|
||||
line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE)
|
||||
_prop.add_child(line)
|
||||
else:
|
||||
var polygon := Polygon2D.new()
|
||||
polygon.polygon = points
|
||||
polygon.color = color
|
||||
_prop.add_child(polygon)
|
||||
|
||||
func _coerce_points(raw: Variant) -> PackedVector2Array:
|
||||
if raw is PackedVector2Array:
|
||||
return raw as PackedVector2Array
|
||||
var result := PackedVector2Array()
|
||||
if raw is Array:
|
||||
for item: Variant in raw:
|
||||
if item is Vector2:
|
||||
result.append(item as Vector2)
|
||||
elif item is Dictionary:
|
||||
var point: Dictionary = item as Dictionary
|
||||
result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0))))
|
||||
return result
|
||||
|
||||
func _update_prop_transform() -> void:
|
||||
if _prop == null or not _prop.visible:
|
||||
return
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var mirror := _motion == PreviewMotion.WALK_LEFT
|
||||
_prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror)
|
||||
# PreviewProp follows PreviewRigRoot in tree order, so zero remains visible
|
||||
# above the stickman; negative puts it behind and positive remains in front.
|
||||
_prop.z_index = int(anchor.get("z_index_offset", 0))
|
||||
|
||||
func _grip_allows_hand(anchor: Dictionary) -> bool:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT)
|
||||
|
||||
func _update_target_marker() -> void:
|
||||
var target := _hand_target()
|
||||
if target != null and _target_marker != null:
|
||||
_target_marker.global_position = target.global_position
|
||||
_target_marker.visible = _prop == null or _prop.visible
|
||||
|
||||
func _resize_viewport() -> void:
|
||||
if _viewport != null:
|
||||
# SubViewportContainer.stretch owns the viewport size. Resizing it here
|
||||
# fights that ownership and produces an engine warning.
|
||||
_reframe_camera()
|
||||
|
||||
func _on_viewport_gui_input(event: InputEvent) -> void:
|
||||
if _camera == null:
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
var mouse := event as InputEventMouseButton
|
||||
if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
var world_before := _screen_to_world(mouse.position)
|
||||
_camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0)
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
_camera.position += world_before - _screen_to_world(mouse.position)
|
||||
return
|
||||
var world_pos := _screen_to_world(mouse.position)
|
||||
if mouse.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom:
|
||||
_dragging_target = true
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
_manual_arm_override = true
|
||||
elif not mouse.pressed:
|
||||
_dragging_target = false
|
||||
if mouse.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
_camera_dragging = mouse.pressed
|
||||
_last_pointer = mouse.position
|
||||
elif event is InputEventMouseMotion:
|
||||
var motion := event as InputEventMouseMotion
|
||||
if _dragging_target:
|
||||
_set_target_clamped(_screen_to_world(motion.position))
|
||||
elif _camera_dragging:
|
||||
_camera.position -= (motion.position - _last_pointer) / _camera_zoom
|
||||
_last_pointer = motion.position
|
||||
|
||||
func _screen_to_world(screen: Vector2) -> Vector2:
|
||||
return _viewport.get_canvas_transform().affine_inverse() * screen
|
||||
|
||||
func _set_target_clamped(requested: Vector2) -> void:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
if target == null or lower == null:
|
||||
return
|
||||
target.global_position = _clamp_target(requested)
|
||||
|
||||
func _clamp_target(requested: Vector2) -> Vector2:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
var upper := lower.get_parent() as Bone2D if lower != null else null
|
||||
if target == null or lower == null or upper == null:
|
||||
return requested
|
||||
var origin := upper.global_position
|
||||
var offset := requested - origin
|
||||
var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin
|
||||
if offset.length_squared() < 0.0001:
|
||||
offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT
|
||||
var min_reach := absf(upper.length - lower.length) + 8.0
|
||||
var max_reach := maxf(min_reach, upper.length + lower.length - 8.0)
|
||||
return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach)
|
||||
|
||||
func _update_hand_choices(anchor: Dictionary) -> void:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
if allowed == "left_only" and _hand != PreviewHand.LEFT:
|
||||
_hand = PreviewHand.LEFT
|
||||
reset_arm_pose()
|
||||
if allowed == "right_only" and _hand != PreviewHand.RIGHT:
|
||||
_hand = PreviewHand.RIGHT
|
||||
reset_arm_pose()
|
||||
if _hand_option == null:
|
||||
return
|
||||
_hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only")
|
||||
_hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only")
|
||||
_hand_option.select(_hand)
|
||||
|
||||
func _enable_arm_ik() -> void:
|
||||
var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D
|
||||
if skeleton == null:
|
||||
return
|
||||
var stack := skeleton.get_modification_stack()
|
||||
if stack == null:
|
||||
return
|
||||
for index: int in 2:
|
||||
var modification := stack.get_modification(index)
|
||||
if modification is SkeletonModification2DTwoBoneIK:
|
||||
(modification as SkeletonModification2DTwoBoneIK).enabled = true
|
||||
|
||||
func _reframe_camera() -> void:
|
||||
if _camera == null or _viewport == null or _viewport.size.x < 1:
|
||||
return
|
||||
var bounds := _visual_bounds(true)
|
||||
if bounds.size.length_squared() < 0.001:
|
||||
return
|
||||
bounds = bounds.grow(32.0)
|
||||
_camera.position = bounds.get_center()
|
||||
_fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y)
|
||||
_fitted_zoom = maxf(_fitted_zoom, 0.001)
|
||||
_camera_zoom = _fitted_zoom
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
|
||||
func _visual_bounds(full_figure: bool) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure)
|
||||
_collect_visual_points(_prop, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0))
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func get_prop_global_bounds() -> Rect2:
|
||||
return _visual_bounds_for_node(_prop)
|
||||
|
||||
func get_preview_world_rect() -> Rect2:
|
||||
if _viewport == null:
|
||||
return Rect2()
|
||||
var transform := _viewport.get_canvas_transform().affine_inverse()
|
||||
var top_left := transform * Vector2.ZERO
|
||||
var bottom_right := transform * Vector2(_viewport.size)
|
||||
return Rect2(top_left, bottom_right - top_left).abs()
|
||||
|
||||
func get_solved_wrist_transform() -> Transform2D:
|
||||
return _solved_wrist_transform()
|
||||
|
||||
func _visual_bounds_for_node(node: Node) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(node, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2()
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void:
|
||||
if node == null or not (node as CanvasItem).visible:
|
||||
return
|
||||
var include := full_figure or not (node.name.contains("Leg"))
|
||||
if include and node is Polygon2D:
|
||||
for point: Vector2 in (node as Polygon2D).polygon:
|
||||
out.append((node as Polygon2D).to_global(point))
|
||||
if include and node is Line2D:
|
||||
for point: Vector2 in (node as Line2D).points:
|
||||
out.append((node as Line2D).to_global(point))
|
||||
for child: Node in node.get_children():
|
||||
_collect_visual_points(child, out, full_figure)
|
||||
|
||||
func _disable_collisions(node: Node) -> void:
|
||||
if node is CollisionObject2D:
|
||||
var body := node as CollisionObject2D
|
||||
body.collision_layer = 0
|
||||
body.collision_mask = 0
|
||||
for child: Node in node.get_children():
|
||||
_disable_collisions(child)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bthsk33yw5rr8
|
||||
+109
-1
@@ -10,6 +10,9 @@ extends RigidBody2D
|
||||
## properties update the children live. A material preset selector sets mass,
|
||||
## friction/bounce (PhysicsMaterial), and themed visuals.
|
||||
|
||||
const PropVisuals := preload("res://scripts/prop_visuals.gd")
|
||||
const PropAnchorData := preload("res://scripts/prop_anchor_data.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -20,6 +23,11 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||
|
||||
## Stable library provenance used by Sandbox Stage scene files.
|
||||
var spawn_id: String = ""
|
||||
## Runtime interaction metadata. Both values are additive so existing scenes
|
||||
## and built-in templates remain pickup-enabled.
|
||||
var can_be_picked_up: bool = true
|
||||
var anchors: Dictionary = PropAnchorData.default_anchors()
|
||||
var held_by_instance_id: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Child node names
|
||||
@@ -91,6 +99,11 @@ var _outline: Line2D
|
||||
var _collision_polygon: CollisionPolygon2D
|
||||
var _collision_shape: CollisionShape2D
|
||||
var _circle_shape: CircleShape2D
|
||||
var _custom_shapes: bool = false
|
||||
var _visual_local_bounds := Rect2()
|
||||
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
|
||||
var _play_instance_scale: Vector2 = Vector2.ONE
|
||||
var _play_scale_active: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -241,10 +254,105 @@ func _apply_style() -> void:
|
||||
func _apply_material_preset() -> void:
|
||||
mass = mass_for(material_preset)
|
||||
physics_material_override = physics_material(material_preset)
|
||||
if material_preset != MaterialPreset.NONE:
|
||||
if not _custom_shapes and material_preset != MaterialPreset.NONE:
|
||||
fill_color = tint_for(material_preset)
|
||||
outline_color = tint_for(material_preset).darkened(0.55)
|
||||
|
||||
|
||||
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
|
||||
_ensure_children()
|
||||
_custom_shapes = true
|
||||
_polygon.visible = false
|
||||
_outline.visible = false
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
_custom_collision_hull = collision_hull.duplicate()
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals == null:
|
||||
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
|
||||
for child: Node in visuals.get_children(): child.queue_free()
|
||||
_visual_local_bounds = PropVisuals.build(visuals, shapes)
|
||||
_apply_material_preset()
|
||||
|
||||
|
||||
func has_custom_shapes() -> bool:
|
||||
return _custom_shapes
|
||||
|
||||
|
||||
func get_visual_local_bounds() -> Rect2:
|
||||
if _custom_shapes:
|
||||
return _visual_local_bounds
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
return Rect2(Vector2.ONE * -radius, Vector2.ONE * radius * 2.0)
|
||||
if not polygon_points.is_empty():
|
||||
var bounds: Rect2 = Rect2(polygon_points[0], Vector2.ZERO)
|
||||
for point: Vector2 in polygon_points:
|
||||
bounds = bounds.expand(point)
|
||||
return bounds
|
||||
return Rect2()
|
||||
|
||||
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
||||
## the authored uniform scale into child visuals and collision geometry for
|
||||
## PLAY, then restore the root scale on return to EDIT for scene persistence.
|
||||
func begin_play_instance_scale() -> void:
|
||||
if _play_scale_active:
|
||||
return
|
||||
_play_instance_scale = scale
|
||||
if _play_instance_scale.is_equal_approx(Vector2.ONE):
|
||||
return
|
||||
_play_scale_active = true
|
||||
scale = Vector2.ONE
|
||||
_apply_play_scale()
|
||||
|
||||
func end_play_instance_scale() -> void:
|
||||
if not _play_scale_active:
|
||||
return
|
||||
_play_scale_active = false
|
||||
_set_visual_scale(Vector2.ONE)
|
||||
_apply_shape()
|
||||
if _custom_shapes and _collision_polygon != null:
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
scale = _play_instance_scale
|
||||
|
||||
func _apply_play_scale() -> void:
|
||||
_set_visual_scale(_play_instance_scale)
|
||||
if _custom_shapes:
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
|
||||
return
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
# A non-uniform legacy instance scale makes an ellipse. Use the generated
|
||||
# polygon hull while simulating so collision follows both axes exactly.
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
elif _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
|
||||
|
||||
func _set_visual_scale(value: Vector2) -> void:
|
||||
if _polygon != null:
|
||||
_polygon.scale = value
|
||||
if _outline != null:
|
||||
_outline.scale = value
|
||||
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
visuals.scale = value
|
||||
|
||||
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in points:
|
||||
result.append(point * value)
|
||||
return result
|
||||
|
||||
func _circle_loop() -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for index: int in CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
|
||||
result.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static preset factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+449
-14
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+66
-1
@@ -3,6 +3,8 @@ extends RefCounted
|
||||
|
||||
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_FILE := preload("res://scripts/prop_file.gd")
|
||||
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
|
||||
|
||||
static var _templates: Array[Dictionary] = []
|
||||
|
||||
@@ -24,7 +26,64 @@ static func get_entry(id: String) -> Dictionary:
|
||||
for t: Dictionary in get_entries():
|
||||
if String(t["id"]) == id:
|
||||
return t
|
||||
return {}
|
||||
return make_custom_entry(id) if is_custom_path(id) else {}
|
||||
|
||||
|
||||
static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = get_entries().duplicate(true)
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path)
|
||||
return entries
|
||||
var directory: DirAccess = DirAccess.open(dir_path)
|
||||
if directory == null:
|
||||
return entries
|
||||
var paths: Array[String] = []
|
||||
directory.list_dir_begin()
|
||||
var file_name: String = directory.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
|
||||
paths.append(dir_path.path_join(file_name))
|
||||
file_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
|
||||
for path: String in paths:
|
||||
var entry := make_custom_entry(path)
|
||||
if not entry.is_empty(): entries.append(entry)
|
||||
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false))
|
||||
var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower()
|
||||
return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0)
|
||||
return entries
|
||||
|
||||
|
||||
static func make_custom_entry(path: String) -> Dictionary:
|
||||
var normalized_path: String = canonical_path(path)
|
||||
if not is_custom_path(normalized_path): return {}
|
||||
var raw: Dictionary = PROP_FILE.load(normalized_path)
|
||||
if raw.is_empty():
|
||||
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
|
||||
return {}
|
||||
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
|
||||
if parsed.is_empty():
|
||||
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
|
||||
return {}
|
||||
var shapes: Array[Dictionary] = parsed.get("shapes", [])
|
||||
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
|
||||
if shapes.is_empty() or hull.size() < 3:
|
||||
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
|
||||
return {}
|
||||
var name := String(parsed.get("prop_name", "")).strip_edges()
|
||||
if name.is_empty(): name = normalized_path.get_file().get_basename()
|
||||
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
|
||||
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {}), "can_be_picked_up": bool(parsed.get("can_be_picked_up", true))}
|
||||
|
||||
|
||||
static func is_custom_path(path: String) -> bool:
|
||||
return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path())
|
||||
|
||||
|
||||
static func canonical_path(path: String) -> String:
|
||||
return path.replace("\\", "/").simplify_path()
|
||||
|
||||
|
||||
static func get_default_id() -> String:
|
||||
@@ -47,4 +106,10 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
|
||||
"material_preset": preset,
|
||||
"material_label": label,
|
||||
"payload": payload,
|
||||
"is_custom": false,
|
||||
"path": "",
|
||||
"shapes": [],
|
||||
"collision_hull": PackedVector2Array(),
|
||||
"anchors": {},
|
||||
"can_be_picked_up": true,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name PropTrajectory
|
||||
extends RefCounted
|
||||
## Shared deterministic ballistic solver for director feedback and playback.
|
||||
|
||||
const MIN_FLIGHT_TIME: float = 0.35
|
||||
const MAX_FLIGHT_TIME: float = 1.5
|
||||
const MAX_LAUNCH_SPEED: float = 900.0
|
||||
|
||||
static func solve_throw_velocity(release_point: Vector2, target_point: Vector2, flight_time: float, gravity_vector: Vector2) -> Dictionary:
|
||||
if not release_point.is_finite() or not target_point.is_finite() or not gravity_vector.is_finite():
|
||||
return {"ok": false, "reason": "non_finite"}
|
||||
if not is_finite(flight_time) or flight_time < MIN_FLIGHT_TIME or flight_time > MAX_FLIGHT_TIME:
|
||||
return {"ok": false, "reason": "flight_time"}
|
||||
var velocity: Vector2 = (target_point - release_point - gravity_vector * 0.5 * flight_time * flight_time) / flight_time
|
||||
if not velocity.is_finite() or velocity.length() > MAX_LAUNCH_SPEED:
|
||||
return {"ok": false, "reason": "speed"}
|
||||
return {"ok": true, "velocity": velocity, "flight_time": flight_time, "reason": ""}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddw3n0jjlduk
|
||||
@@ -0,0 +1,89 @@
|
||||
class_name PropVisuals
|
||||
extends RefCounted
|
||||
## Shared custom-prop drawing and collision approximation.
|
||||
|
||||
const CIRCLE_SEGMENTS: int = 24
|
||||
|
||||
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2:
|
||||
var bounds := Rect2()
|
||||
var has_bounds := false
|
||||
for index: int in shapes.size():
|
||||
var shape: Dictionary = shapes[index]
|
||||
var points := _shape_points(shape)
|
||||
if points.size() < 2:
|
||||
push_warning("PropVisuals: skipped shape with insufficient points.")
|
||||
continue
|
||||
var group := Node2D.new()
|
||||
group.name = "Shape_%d" % index
|
||||
parent.add_child(group)
|
||||
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
|
||||
var fill := Color.from_string(String(shape.get("fill_color", "")), Color.WHITE)
|
||||
var outline := Color.from_string(String(shape.get("outline_color", "")), Color.BLACK)
|
||||
var width: float = maxf(0.0, float(shape.get("outline_width", 2.0)))
|
||||
if closed and points.size() >= 3:
|
||||
var polygon := Polygon2D.new()
|
||||
polygon.name = "Polygon2D"
|
||||
polygon.polygon = points
|
||||
polygon.color = fill
|
||||
group.add_child(polygon)
|
||||
var line := Line2D.new()
|
||||
line.name = "Line2D"
|
||||
var loop: PackedVector2Array = points.duplicate()
|
||||
if closed:
|
||||
loop.append(points[0])
|
||||
line.points = loop
|
||||
line.default_color = outline
|
||||
line.width = width
|
||||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
group.add_child(line)
|
||||
for point: Vector2 in points:
|
||||
if not has_bounds:
|
||||
bounds = Rect2(point, Vector2.ZERO)
|
||||
has_bounds = true
|
||||
else:
|
||||
bounds = bounds.expand(point)
|
||||
return bounds
|
||||
|
||||
|
||||
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array:
|
||||
var points := PackedVector2Array()
|
||||
for shape: Dictionary in shapes:
|
||||
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
|
||||
if closed:
|
||||
points.append_array(_shape_points(shape))
|
||||
if points.size() < 3:
|
||||
return PackedVector2Array()
|
||||
var hull: PackedVector2Array = Geometry2D.convex_hull(points)
|
||||
if hull.size() < 3 or absf(_polygon_area(hull)) <= 0.001:
|
||||
return PackedVector2Array()
|
||||
return hull
|
||||
|
||||
|
||||
static func _shape_points(shape: Dictionary) -> PackedVector2Array:
|
||||
var raw: Variant = shape.get("points", PackedVector2Array())
|
||||
var points := PackedVector2Array()
|
||||
if raw is PackedVector2Array:
|
||||
points = raw as PackedVector2Array
|
||||
elif raw is Array:
|
||||
for item: Variant in raw as Array:
|
||||
if item is Vector2:
|
||||
points.append(item as Vector2)
|
||||
elif item is Dictionary:
|
||||
var data: Dictionary = item as Dictionary
|
||||
points.append(Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))))
|
||||
if points.is_empty() and String(shape.get("shape_type", "")) == "circle":
|
||||
var radius: float = maxf(0.0, float(shape.get("radius", 24.0)))
|
||||
for index: int in CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
|
||||
points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return points
|
||||
|
||||
|
||||
static func _polygon_area(points: PackedVector2Array) -> float:
|
||||
var area: float = 0.0
|
||||
for index: int in points.size():
|
||||
var next: Vector2 = points[(index + 1) % points.size()]
|
||||
area += points[index].cross(next)
|
||||
return area * 0.5
|
||||
@@ -0,0 +1 @@
|
||||
uid://7cehuu3v8tig
|
||||
+192
-27
@@ -39,6 +39,8 @@ const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
|
||||
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
|
||||
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
|
||||
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
|
||||
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
|
||||
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
@@ -100,7 +102,10 @@ const TRIG_ACTION_FINISHED := 1
|
||||
const TRIG_SPEECH_FINISHED := 2
|
||||
const TRIG_ENTERED_AREA := 3
|
||||
const TRIG_COLLIDED := 4
|
||||
const TRIG_BACK := 5
|
||||
const TRIG_PROP_PICKED_UP := 5
|
||||
const TRIG_PROP_DROPPED := 6
|
||||
const TRIG_PROP_THROWN := 7
|
||||
const TRIG_BACK := 8
|
||||
|
||||
## Phase 4 "add another / done" popup item ids.
|
||||
const RULE_MORE_ADD := 0
|
||||
@@ -391,6 +396,11 @@ var _action_editor_rig: StickmanRig = null
|
||||
var _action_editor_actor_id: int = -1
|
||||
var _action_editor_restore_queue: bool = false
|
||||
var _pending_rule_action_flat: Dictionary = {}
|
||||
## Active only while the Action Editor is capturing a throw landing point.
|
||||
## Kept separately from generic position capture so walk/rule capture remains
|
||||
## unchanged and preview/commit share one explicit contract.
|
||||
var _throw_capture_prop_id: int = -1
|
||||
var _throw_capture_flight_time: float = 0.9
|
||||
|
||||
## Rule editor came from the rule panel (restore it on close).
|
||||
var _rule_editor_from_panel: bool = false
|
||||
@@ -673,6 +683,7 @@ func _enter_edit_shared() -> void:
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.release_held_prop_for_lifecycle(&"play_exit")
|
||||
rig.stop_queue()
|
||||
rig.set_terrain_following_enabled(false)
|
||||
rig.snap_to_standing()
|
||||
@@ -686,6 +697,8 @@ func _enter_edit_shared() -> void:
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).end_play_instance_scale()
|
||||
_restore_authored_state()
|
||||
# The freeze/teleport can take a physics frame or two to settle in the engine,
|
||||
# so re-assert the authored transform on the next few physics frames.
|
||||
@@ -766,6 +779,8 @@ func _enter_play_mode() -> void:
|
||||
_collision_pairs.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).begin_play_instance_scale()
|
||||
(node as RigidBody2D).freeze = false
|
||||
var rig_count := 0
|
||||
for node: Node2D in _world_children_selectable():
|
||||
@@ -809,6 +824,8 @@ func _restore_authored_state() -> void:
|
||||
continue
|
||||
node.global_position = entry.get("position", node.global_position)
|
||||
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||||
if entry.has("scale"):
|
||||
node.scale = entry["scale"]
|
||||
if node is STICKMAN_RIG and entry.has("facing_profile"):
|
||||
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
|
||||
if node is RigidBody2D:
|
||||
@@ -864,6 +881,9 @@ func _finalize_placed_node(node: Node2D) -> void:
|
||||
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
|
||||
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
|
||||
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
|
||||
var interaction: StickmanPropInteraction = rig.get_prop_interaction()
|
||||
if interaction != null:
|
||||
interaction.prop_transition.connect(_on_rig_prop_transition)
|
||||
_director_visuals.mark_dirty()
|
||||
if node is PropBlock:
|
||||
var prop := node as PropBlock
|
||||
@@ -1227,6 +1247,16 @@ func delete_selected() -> void:
|
||||
var nav_changed := false
|
||||
for node: Node2D in selected:
|
||||
if is_instance_valid(node):
|
||||
# Disarm before either endpoint leaves World so a held body is never
|
||||
# left frozen or referenced by a surviving IK marker.
|
||||
if node is StickmanRig:
|
||||
(node as StickmanRig).release_held_prop_for_lifecycle(&"holder_deleted")
|
||||
elif node is PropBlock:
|
||||
for child: Node in _world.get_children():
|
||||
if child is StickmanRig:
|
||||
var interaction: StickmanPropInteraction = (child as StickmanRig).get_prop_interaction()
|
||||
if interaction != null and interaction.get_held_prop() == node:
|
||||
interaction.release_for_lifecycle(&"prop_deleted")
|
||||
if node is TerrainBlock:
|
||||
nav_changed = true
|
||||
_clear_object_state(node)
|
||||
@@ -1267,18 +1297,31 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
||||
var objects: Array[Dictionary] = []
|
||||
var runtime_to_file: Dictionary = {}
|
||||
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||
# Queue actions may point at a prop that appears later in World. Build the
|
||||
# complete runtime→file map before encoding any individual rig queue.
|
||||
for node: Node in _world.get_children():
|
||||
if not node is Node2D: continue
|
||||
var map_node: Node2D = node as Node2D
|
||||
var map_type: String = ""
|
||||
if map_node is TerrainBlock: map_type = "terrain"
|
||||
elif map_node is PropBlock or map_node.get_script() == MISSING_PROP_PLACEHOLDER: map_type = "prop"
|
||||
elif map_node is STICKMAN_RIG or map_node.get_script() == MISSING_STICKMAN_PLACEHOLDER: map_type = "stickman"
|
||||
elif map_node is TriggerArea: map_type = "area"
|
||||
if map_type.is_empty(): continue
|
||||
counters[map_type] = int(counters[map_type]) + 1
|
||||
runtime_to_file[map_node.get_instance_id()] = "%s_%d" % [map_type, int(counters[map_type])]
|
||||
counters = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||
for node: Node in _world.get_children():
|
||||
if not node is Node2D: continue
|
||||
var n := node as Node2D
|
||||
var type := ""
|
||||
if n is TerrainBlock: type = "terrain"
|
||||
elif n is PropBlock: type = "prop"
|
||||
elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop"
|
||||
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
|
||||
elif n is TriggerArea: type = "area"
|
||||
else: continue
|
||||
counters[type] = int(counters[type]) + 1
|
||||
var file_id := "%s_%d" % [type, int(counters[type])]
|
||||
runtime_to_file[n.get_instance_id()] = file_id
|
||||
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
|
||||
var pos: Vector2 = authored.get("position", n.global_position)
|
||||
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
|
||||
@@ -1286,10 +1329,14 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
||||
if type == "terrain":
|
||||
entry["asset_id"] = (n as TerrainBlock).spawn_id
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
|
||||
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
|
||||
entry = (n.get("original_entry") as Dictionary).duplicate(true)
|
||||
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
|
||||
elif type == "prop":
|
||||
var prop := n as PropBlock
|
||||
entry["asset_id"] = prop.spawn_id
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop.scale)
|
||||
var prop_scale: Vector2 = authored.get("scale", prop.scale)
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
|
||||
entry["material_preset"] = prop.material_preset
|
||||
elif type == "area":
|
||||
var area := n as TriggerArea
|
||||
@@ -1302,7 +1349,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
||||
var rig := n as StickmanRig
|
||||
entry["stk_path"] = rig.source_stk_path
|
||||
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
|
||||
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
||||
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue(), runtime_to_file)
|
||||
entry["action_queue"] = queue_result["actions"]
|
||||
objects.append(entry)
|
||||
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
|
||||
@@ -1344,7 +1391,7 @@ func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
|
||||
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
|
||||
var was_suppressed := _suppress_dirty; _suppress_dirty = true
|
||||
_reset_scene_content()
|
||||
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
|
||||
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []; var pending_queues: Array[Dictionary] = []
|
||||
for raw: Variant in data.get("objects", []):
|
||||
if not raw is Dictionary: warnings.append("Malformed object"); continue
|
||||
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
|
||||
@@ -1355,7 +1402,11 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
|
||||
var node: Node2D = null
|
||||
match type:
|
||||
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
|
||||
"prop": node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
|
||||
"prop":
|
||||
node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
|
||||
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
|
||||
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
|
||||
prop_placeholder.configure(entry); prop_placeholder.position = pos; _world.add_child(prop_placeholder); node = prop_placeholder
|
||||
"area": node = _spawner.spawn_area(pos)
|
||||
"stickman":
|
||||
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
|
||||
@@ -1401,14 +1452,16 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
|
||||
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
|
||||
var raw_queue: Variant = entry.get("action_queue", [])
|
||||
if raw_queue is Array:
|
||||
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
|
||||
for warning: String in queue.get("warnings", []):
|
||||
warnings.append(warning)
|
||||
for action: Dictionary in queue["actions"]:
|
||||
(node as StickmanRig).queue_action(action)
|
||||
pending_queues.append({"rig": node, "entries": raw_queue})
|
||||
else:
|
||||
warnings.append("Invalid stickman action queue")
|
||||
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
|
||||
for pending: Dictionary in pending_queues:
|
||||
var queued_rig: StickmanRig = pending.get("rig") as StickmanRig
|
||||
if queued_rig == null or not is_instance_valid(queued_rig): continue
|
||||
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(pending.get("entries", []) as Array, file_to_runtime, unavailable)
|
||||
for warning: String in queue.get("warnings", []): warnings.append(warning)
|
||||
for action: Dictionary in queue.get("actions", []): queued_rig.queue_action(action)
|
||||
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
|
||||
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
|
||||
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
|
||||
@@ -2015,6 +2068,9 @@ func _build_ui() -> void:
|
||||
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
|
||||
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
|
||||
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
|
||||
_trigger_popup.add_item("🖐️ Picks up a prop", TRIG_PROP_PICKED_UP)
|
||||
_trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED)
|
||||
_trigger_popup.add_item("🎯 Throws a prop", TRIG_PROP_THROWN)
|
||||
_trigger_popup.add_separator()
|
||||
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
|
||||
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
|
||||
@@ -2194,6 +2250,7 @@ func _build_ui() -> void:
|
||||
_action_editor.committed.connect(_on_action_editor_committed)
|
||||
_action_editor.cancelled.connect(_on_action_editor_cancelled)
|
||||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||||
_action_editor.prop_requested.connect(_on_action_editor_prop_requested)
|
||||
ui.add_child(_action_editor)
|
||||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
||||
@@ -2408,7 +2465,7 @@ func _open_selector(kind: String) -> void:
|
||||
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
|
||||
_spawner.selected_stickman_path = String(entries[0]["path"])
|
||||
else:
|
||||
entries = PROP_LIBRARY.get_entries()
|
||||
entries = PROP_LIBRARY.scan()
|
||||
if kind == "stickman" and entries.size() == 1:
|
||||
_on_asset_selected(entries[0])
|
||||
return
|
||||
@@ -2449,18 +2506,29 @@ func _on_selector_cancelled() -> void:
|
||||
|
||||
func _on_browse_requested() -> void:
|
||||
if _browse_dialog != null:
|
||||
if _selector_kind == "prop":
|
||||
_browse_dialog.title = "Open .prop"
|
||||
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
|
||||
else:
|
||||
_browse_dialog.title = "Open .stk"
|
||||
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||||
_browse_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_browse_file_selected(path: String) -> void:
|
||||
var entry := _stickman_library.make_entry(path)
|
||||
var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
|
||||
if entry.is_empty():
|
||||
_show_toast("Could not load stickman: " + path)
|
||||
_show_toast("Could not load %s: %s" % [_selector_kind, path])
|
||||
return
|
||||
_on_asset_selected(entry)
|
||||
|
||||
|
||||
func _on_refresh_requested() -> void:
|
||||
if _selector_kind == "prop":
|
||||
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
|
||||
_selector.set_entries(prop_entries)
|
||||
_load_or_enqueue_thumbnails("prop", prop_entries)
|
||||
return
|
||||
var entries: Array[Dictionary] = _stickman_library.scan()
|
||||
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
|
||||
_spawner.selected_stickman_path = String(entries[0]["path"])
|
||||
@@ -2485,7 +2553,7 @@ func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> vo
|
||||
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
|
||||
else:
|
||||
var id := String(entry.get("id", ""))
|
||||
var png := _thumbnail_cache.prop_png(id)
|
||||
var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
|
||||
var tex := _thumbnail_cache.get_texture(png)
|
||||
if tex != null:
|
||||
if tex != null and _selector != null:
|
||||
@@ -2509,11 +2577,15 @@ func _drain_thumbnail_queue() -> void:
|
||||
if tex != null:
|
||||
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
|
||||
else:
|
||||
var payload: Dictionary = entry["payload"]
|
||||
var preset: int = int(entry["material_preset"])
|
||||
tex = await _prop_thumb.render(payload, preset)
|
||||
if bool(entry.get("is_custom", false)):
|
||||
tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
|
||||
else:
|
||||
var payload: Dictionary = entry["payload"]
|
||||
var preset: int = int(entry["material_preset"])
|
||||
tex = await _prop_thumb.render(payload, preset)
|
||||
if tex != null:
|
||||
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
|
||||
var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
|
||||
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
|
||||
_thumbnail_busy = false
|
||||
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
|
||||
_selector.set_thumbnail(entry, tex)
|
||||
@@ -2841,11 +2913,17 @@ func _update_action_overlay() -> void:
|
||||
var awaiting := _is_awaiting_click()
|
||||
if _placement_overlay != null:
|
||||
if awaiting:
|
||||
var origin := _action_origin()
|
||||
var throw_capture: bool = _is_throw_capture_active()
|
||||
var origin: Vector2 = _throw_release_point() if throw_capture else _action_origin()
|
||||
var target := _camera.get_global_mouse_position()
|
||||
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
|
||||
if _snap_enabled and (_capture_kind == CaptureKind.POSITION or _rule_step == RuleStep.ACTION_POSITION):
|
||||
target = _snap_to_grid(target)
|
||||
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
||||
if throw_capture:
|
||||
var solution: Dictionary = _solve_throw_capture(target)
|
||||
var arc: PackedVector2Array = _sample_throw_arc(origin, solution) if bool(solution.get("ok", false)) else PackedVector2Array([origin, target])
|
||||
_placement_overlay.set_throw_trajectory(origin, target, arc, bool(solution.get("ok", false)))
|
||||
else:
|
||||
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
||||
else:
|
||||
_placement_overlay.clear_action()
|
||||
if _tooltip != null:
|
||||
@@ -2969,7 +3047,7 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
|
||||
var trigger: Dictionary = _rule_builder.get("trigger", {})
|
||||
trigger["type"] = _trigger_type_string(id)
|
||||
_rule_builder["trigger"] = trigger
|
||||
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
|
||||
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
|
||||
_rule_step = RuleStep.TRIGGER_TARGET
|
||||
match id:
|
||||
TRIG_ARRIVED:
|
||||
@@ -2978,6 +3056,12 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
|
||||
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
||||
TRIG_COLLIDED:
|
||||
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||
TRIG_PROP_PICKED_UP:
|
||||
_rule_hint = "Click the prop to watch being picked up (Esc or RMB to cancel)"
|
||||
TRIG_PROP_DROPPED:
|
||||
_rule_hint = "Click the prop to watch being dropped (Esc or RMB to cancel)"
|
||||
TRIG_PROP_THROWN:
|
||||
_rule_hint = "Click the prop to watch being thrown (Esc or RMB to cancel)"
|
||||
_refresh_status()
|
||||
else:
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
@@ -3018,6 +3102,12 @@ func _trigger_type_string(id: int) -> String:
|
||||
return "entered_area"
|
||||
TRIG_COLLIDED:
|
||||
return "collided"
|
||||
TRIG_PROP_PICKED_UP:
|
||||
return "prop_picked_up"
|
||||
TRIG_PROP_DROPPED:
|
||||
return "prop_dropped"
|
||||
TRIG_PROP_THROWN:
|
||||
return "prop_thrown"
|
||||
_:
|
||||
return ""
|
||||
|
||||
@@ -3088,7 +3178,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
||||
else:
|
||||
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
|
||||
_refresh_status()
|
||||
"collided":
|
||||
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
|
||||
var hit2 := _selection.hit_test(world_pos)
|
||||
if hit2 is PropBlock:
|
||||
trigger["target"] = hit2.get_instance_id()
|
||||
@@ -3096,7 +3186,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
_open_rule_action_popup()
|
||||
else:
|
||||
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||
_rule_hint = "Click the prop to use for this trigger (Esc or RMB to cancel)"
|
||||
_refresh_status()
|
||||
|
||||
|
||||
@@ -3266,6 +3356,11 @@ func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
|
||||
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
|
||||
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
|
||||
|
||||
func _on_rig_prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName) -> void:
|
||||
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
|
||||
return
|
||||
_handle_event({"type": String(action_type), "source": rig, "target": prop, "position": prop.global_position, "action": {"type": String(action_type), "reason": String(reason)}})
|
||||
|
||||
|
||||
func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||||
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
|
||||
@@ -3380,6 +3475,8 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
|
||||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||||
"collided":
|
||||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||||
"prop_picked_up", "prop_dropped", "prop_thrown":
|
||||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||||
_:
|
||||
return false
|
||||
|
||||
@@ -3594,6 +3691,8 @@ func _end_capture() -> void:
|
||||
_capture_hint = ""
|
||||
_capture_callback = Callable()
|
||||
_capture_cancel = Callable()
|
||||
_throw_capture_prop_id = -1
|
||||
_throw_capture_flight_time = 0.9
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
|
||||
@@ -3629,6 +3728,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
|
||||
if value == null:
|
||||
_refresh_status()
|
||||
return
|
||||
if _capture_kind == CaptureKind.POSITION and _is_throw_capture_active() and not bool(_solve_throw_capture(value as Vector2).get("ok", false)):
|
||||
_show_toast("That throw is too far for this flight time.")
|
||||
_refresh_status()
|
||||
return
|
||||
var cb := _capture_callback
|
||||
_end_capture()
|
||||
if cb.is_valid():
|
||||
@@ -4031,18 +4134,80 @@ func _on_action_editor_cancelled() -> void:
|
||||
|
||||
func _on_action_editor_target_requested() -> void:
|
||||
_action_editor.hide()
|
||||
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||
var is_throw: bool = _action_editor != null and _action_editor.get_action_type() == "throw_prop"
|
||||
if is_throw:
|
||||
_throw_capture_prop_id = _action_editor.get_target_prop_id()
|
||||
_throw_capture_flight_time = _action_editor.get_flight_time()
|
||||
_begin_capture(CaptureKind.POSITION, "🎯 Click where the prop should land (Esc or RMB to cancel)" if is_throw else "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_throw_target if is_throw else _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||
|
||||
|
||||
func _cb_editor_walk_target(value: Vector2) -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.set_walk_target(value)
|
||||
|
||||
func _cb_editor_throw_target(value: Vector2) -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.set_throw_target(value)
|
||||
|
||||
func _on_action_editor_prop_requested() -> void:
|
||||
_action_editor.hide()
|
||||
_begin_capture(CaptureKind.PROP, "🧱 Click the prop to use (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
|
||||
|
||||
func _cb_editor_prop_target(value: PropBlock) -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.set_prop_target(value, _prop_action_target_label(value))
|
||||
|
||||
func _prop_action_target_label(prop: PropBlock) -> String:
|
||||
if prop == null or not is_instance_valid(prop):
|
||||
return ""
|
||||
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
|
||||
var label: String = String(entry.get("name", ""))
|
||||
if label.is_empty():
|
||||
label = prop.spawn_id.get_file().get_basename() if prop.spawn_id.contains(".prop") else prop.spawn_id.capitalize()
|
||||
if label.is_empty():
|
||||
label = "Prop"
|
||||
var ordinal: int = 0
|
||||
for node: Node in _world.get_children():
|
||||
if node is PropBlock and (node as PropBlock).spawn_id == prop.spawn_id:
|
||||
ordinal += 1
|
||||
if node == prop:
|
||||
break
|
||||
return "%s #%d" % [label, maxi(1, ordinal)]
|
||||
|
||||
|
||||
func _cb_editor_walk_target_cancel() -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.popup_centered()
|
||||
|
||||
func _is_throw_capture_active() -> bool:
|
||||
return _capture_kind == CaptureKind.POSITION and _throw_capture_prop_id >= 0
|
||||
|
||||
func _throw_release_point() -> Vector2:
|
||||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||||
var marker: Marker2D = _action_editor_rig._get_ik_marker("Right_Hand")
|
||||
if marker != null and is_instance_valid(marker): return marker.global_position
|
||||
return _action_editor_rig.global_position
|
||||
return Vector2.ZERO
|
||||
|
||||
func _solve_throw_capture(target: Vector2) -> Dictionary:
|
||||
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||||
if prop == null or not is_instance_valid(prop): return {"ok": false, "reason": "missing_prop"}
|
||||
return PROP_TRAJECTORY.solve_throw_velocity(_throw_release_point(), target, _throw_capture_flight_time, StickmanPropInteraction.gravity_for(prop))
|
||||
|
||||
func _sample_throw_arc(origin: Vector2, solution: Dictionary) -> PackedVector2Array:
|
||||
var points := PackedVector2Array()
|
||||
if not bool(solution.get("ok", false)): return points
|
||||
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||||
if prop == null or not is_instance_valid(prop): return points
|
||||
var velocity: Vector2 = solution.get("velocity", Vector2.ZERO)
|
||||
var duration: float = float(solution.get("flight_time", 0.0))
|
||||
var gravity: Vector2 = StickmanPropInteraction.gravity_for(prop)
|
||||
const SEGMENTS: int = 24
|
||||
for index: int in range(SEGMENTS + 1):
|
||||
var time: float = duration * float(index) / float(SEGMENTS)
|
||||
points.append(origin + velocity * time + gravity * 0.5 * time * time)
|
||||
return points
|
||||
|
||||
|
||||
func _restore_queue_panel() -> void:
|
||||
if _action_editor_restore_queue and _queue_panel != null:
|
||||
|
||||
@@ -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
@@ -10,12 +10,13 @@ extends Node2D
|
||||
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum Handle { NONE, TRANSLATE, ROTATE }
|
||||
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -39,6 +40,10 @@ const ROTATE_WIDTH := 2.0
|
||||
|
||||
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
||||
const SCALE_CROSS_SIZE_PX := 5.0
|
||||
const SCALE_HIT_RADIUS_PX := 12.0
|
||||
const MIN_SCALE: float = 0.05
|
||||
const MAX_SCALE: float = 20.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
@@ -61,6 +66,10 @@ var _rotate_start_rotation: float = 0.0
|
||||
var _rotate_start_angle: float = 0.0
|
||||
var _rotate_center: Vector2 = Vector2.ZERO
|
||||
var _box_rect: Rect2 = Rect2()
|
||||
var _scale_start: Vector2 = Vector2.ONE
|
||||
var _scale_pivot: Vector2 = Vector2.ZERO
|
||||
var _scale_corner: Vector2 = Vector2.ZERO
|
||||
var _scale_changed: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -99,6 +108,10 @@ func hit_test(world_pos: Vector2) -> Handle:
|
||||
if target == null or not is_instance_valid(target):
|
||||
return Handle.NONE
|
||||
var rect := STAGE_SELECTION.get_world_aabb(target)
|
||||
if target is PropBlockScript:
|
||||
for corner: Vector2 in _corners(rect):
|
||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||
return Handle.SCALE
|
||||
var ring := _rotate_ring_radius(rect)
|
||||
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
||||
return Handle.ROTATE
|
||||
@@ -114,6 +127,14 @@ func begin_drag(world_pos: Vector2) -> void:
|
||||
_rotate_start_rotation = _targets[0].global_rotation
|
||||
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
||||
transform_started.emit(_targets.duplicate())
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var target: Node2D = _targets[0]
|
||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
|
||||
_scale_pivot = target.global_position
|
||||
_scale_corner = _nearest_corner(rect, world_pos)
|
||||
_scale_start = target.scale
|
||||
_scale_changed = false
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
## Begins a translate drag anchored on `node` (used when an object body is
|
||||
@@ -155,16 +176,30 @@ func drag_to(world_pos: Vector2) -> void:
|
||||
if Input.is_key_pressed(KEY_CTRL):
|
||||
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
|
||||
target.global_rotation = new_rotation
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var prop: Node2D = _targets[0]
|
||||
var start: Vector2 = _scale_corner - _scale_pivot
|
||||
var denominator: float = start.length_squared()
|
||||
if denominator > 0.0001:
|
||||
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
|
||||
if is_finite(requested):
|
||||
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
|
||||
if not next_scale.is_equal_approx(prop.scale):
|
||||
prop.scale = next_scale
|
||||
_scale_changed = true
|
||||
_drag_last = world_pos
|
||||
transform_updated.emit(_targets.duplicate())
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func end_drag() -> void:
|
||||
if _dragging != Handle.NONE and not _targets.is_empty():
|
||||
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
|
||||
transform_committed.emit(_targets.duplicate())
|
||||
_dragging = Handle.NONE
|
||||
_drag_anchor = null
|
||||
_scale_changed = false
|
||||
_translate_start_positions.clear()
|
||||
|
||||
|
||||
@@ -194,6 +229,13 @@ func _draw() -> void:
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
|
||||
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
||||
if _targets[0] is PropBlockScript:
|
||||
var half: float = SCALE_CROSS_SIZE_PX / zoom
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
|
||||
if _box_rect.size != Vector2.ZERO:
|
||||
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
|
||||
@@ -215,3 +257,16 @@ func _snap(v: Vector2) -> Vector2:
|
||||
if snap_size <= 0.0:
|
||||
return v
|
||||
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
|
||||
|
||||
func _corners(rect: Rect2) -> Array[Vector2]:
|
||||
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
|
||||
|
||||
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
|
||||
var nearest: Vector2 = rect.position
|
||||
var distance: float = INF
|
||||
for corner: Vector2 in _corners(rect):
|
||||
var candidate: float = corner.distance_squared_to(world_pos)
|
||||
if candidate < distance:
|
||||
distance = candidate
|
||||
nearest = corner
|
||||
return nearest
|
||||
|
||||
@@ -20,6 +20,8 @@ var action_visible: bool = false
|
||||
var action_origin: Vector2 = Vector2.ZERO
|
||||
var action_target: Vector2 = Vector2.ZERO
|
||||
var action_valid: bool = true
|
||||
var action_arc: PackedVector2Array = PackedVector2Array()
|
||||
var action_is_throw: bool = false
|
||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||
var line_width_px: float = 2.0
|
||||
@@ -63,10 +65,24 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
|
||||
## Displays a sampled ballistic arc. `points` must come from the same solver
|
||||
## that validates the eventual physics release.
|
||||
func set_throw_trajectory(origin: Vector2, target: Vector2, points: PackedVector2Array, valid: bool) -> void:
|
||||
action_visible = true
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = points.duplicate()
|
||||
action_is_throw = true
|
||||
|
||||
|
||||
func clear_action() -> void:
|
||||
action_visible = false
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -77,8 +93,24 @@ func _draw() -> void:
|
||||
_draw_cell_marker(terrain_target, guide_line_color)
|
||||
if action_visible:
|
||||
var color := valid_color if action_valid else invalid_color
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
if action_is_throw and action_arc.size() >= 2:
|
||||
_draw_arc_path(action_arc, color)
|
||||
else:
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
_draw_ghost_marker(action_target, color)
|
||||
if not action_valid:
|
||||
_draw_invalid_marker(action_target, color)
|
||||
|
||||
func _draw_arc_path(points: PackedVector2Array, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
for index: int in range(points.size() - 1):
|
||||
draw_line(points[index], points[index + 1], color, line_width_px / zoom, true)
|
||||
|
||||
func _draw_invalid_marker(pos: Vector2, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
var radius: float = MARKER_RADIUS_PX * 1.35 / zoom
|
||||
draw_line(pos - Vector2(radius, radius), pos + Vector2(radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
draw_line(pos + Vector2(radius, -radius), pos + Vector2(-radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||
|
||||
@@ -22,25 +22,25 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
||||
var data: Dictionary = value
|
||||
return Vector2(float(data["x"]), float(data["y"]))
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
||||
static func encode_queue(actions: Array[Dictionary], runtime_to_file: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for action: Dictionary in actions:
|
||||
var encoded := _encode_flat_action(action)
|
||||
var encoded := _encode_flat_action(action, runtime_to_file)
|
||||
if encoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
out.append(encoded)
|
||||
return {"actions": out, "warnings": warnings}
|
||||
|
||||
static func decode_queue(entries: Array) -> Dictionary:
|
||||
static func decode_queue(entries: Array, file_to_runtime: Dictionary = {}, unavailable_ids: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for entry: Variant in entries:
|
||||
if not entry is Dictionary:
|
||||
warnings.append("Skipped malformed queue action.")
|
||||
continue
|
||||
var decoded := _decode_flat_action(entry as Dictionary)
|
||||
var decoded := _decode_flat_action(entry as Dictionary, file_to_runtime, unavailable_ids)
|
||||
if decoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
@@ -171,7 +171,7 @@ static func _recover_backup(path: String) -> void:
|
||||
static func _finite_number(value: Variant) -> bool:
|
||||
return (value is float or value is int) and is_finite(float(value))
|
||||
|
||||
static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _encode_flat_action(action: Dictionary, refs: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -180,9 +180,20 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
return {"type": type, "target": vector_to_data(action["target"])}
|
||||
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var prop_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop_id": prop_id, "hand": hand} if prop_id != null and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var target: Variant = action.get("target_position")
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id == null or not target is Vector2 or not target.is_finite() or not _finite_number(time): return {}
|
||||
return {"type": type, "target_prop_id": throw_id, "target_position": vector_to_data(target as Vector2), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _decode_flat_action(action: Dictionary, refs: Dictionary = {}, unavailable: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -195,6 +206,17 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
if bool(action.get("stop_current", false)): out["stop_current"] = true
|
||||
return out
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var pickup_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop": pickup_id, "hand": hand} if pickup_id >= 0 and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id < 0 or not is_vector_data(action.get("target_position")) or not _finite_number(time): return {}
|
||||
if time < 0.35 or time > 1.5: return {}
|
||||
return {"type": type, "target_prop": throw_id, "target_position": data_to_vector(action["target_position"], Vector2.ZERO), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||
@@ -229,12 +251,12 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
|
||||
|
||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action), refs); if flat.is_empty(): return {}
|
||||
var params := flat.duplicate(); params.erase("type")
|
||||
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
||||
|
||||
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
||||
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat, refs, unavailable); if decoded.is_empty(): return {}
|
||||
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
||||
|
||||
@@ -141,6 +141,12 @@ static func get_world_aabb(node: Node2D) -> Rect2:
|
||||
# Duck-typed TriggerArea: expose its centered local rect through the transform.
|
||||
if node.has_method("get_area_rect"):
|
||||
return node.global_transform * (node.call("get_area_rect") as Rect2)
|
||||
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(visuals, visual_acc)
|
||||
if visual_acc["min_x"] <= visual_acc["max_x"]:
|
||||
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
|
||||
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||
if poly != null and not poly.polygon.is_empty():
|
||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
class_name StickmanPropInteraction
|
||||
extends Node
|
||||
## Owns the one-prop hand interaction lifecycle for a StickmanRig.
|
||||
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
|
||||
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||
|
||||
const REACH_DURATION: float = 0.18
|
||||
const ARM_REACH: float = 360.0
|
||||
const RELEASE_SPIN: float = 1.5
|
||||
enum State { IDLE, APPROACHING, REACHING, HOLDING, WINDUP }
|
||||
|
||||
signal interaction_finished(succeeded: bool)
|
||||
signal prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _state: State = State.IDLE
|
||||
var _held_prop: PropBlock = null
|
||||
var _reserved_prop: PropBlock = null
|
||||
var _hand: StringName = &"right"
|
||||
var _grip: Dictionary = {}
|
||||
var _saved_hand_target: Vector2 = Vector2.ZERO
|
||||
var _timer: float = 0.0
|
||||
var _pending_action: Dictionary = {}
|
||||
var _saved_physics: Dictionary = {}
|
||||
var _last_rig_rotation: float = 0.0
|
||||
var _angular_snapshot: float = 0.0
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
|
||||
func has_held_prop() -> bool:
|
||||
return _held_prop != null and is_instance_valid(_held_prop)
|
||||
|
||||
func get_held_prop() -> PropBlock:
|
||||
return _held_prop if has_held_prop() else null
|
||||
|
||||
func begin_action(action: Dictionary) -> bool:
|
||||
var type: String = String(action.get("type", ""))
|
||||
match type:
|
||||
"pick_up_prop":
|
||||
return _begin_pickup(action)
|
||||
"drop_prop":
|
||||
if not has_held_prop():
|
||||
return false
|
||||
release_held(&"drop", _rig_velocity(), 0.0, &"prop_dropped")
|
||||
interaction_finished.emit(true)
|
||||
return true
|
||||
"throw_prop":
|
||||
return _begin_throw(action)
|
||||
return false
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if has_held_prop():
|
||||
_update_attachment()
|
||||
if _state == State.APPROACHING:
|
||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
return
|
||||
if _state != State.REACHING and _state != State.WINDUP:
|
||||
return
|
||||
_timer -= delta
|
||||
if _state == State.REACHING:
|
||||
_update_reach_target()
|
||||
if _timer <= 0.0:
|
||||
_complete_pickup()
|
||||
elif _timer <= 0.0:
|
||||
_complete_throw()
|
||||
|
||||
func update_held_attachment(delta: float) -> void:
|
||||
if _rig != null and is_instance_valid(_rig) and delta > 0.0:
|
||||
_angular_snapshot = wrapf(_rig.global_rotation - _last_rig_rotation, -PI, PI) / delta
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
if has_held_prop(): _update_attachment()
|
||||
|
||||
func cancel() -> void:
|
||||
if _state == State.APPROACHING or _state == State.REACHING or _state == State.WINDUP:
|
||||
_restore_hand_target()
|
||||
if _reserved_prop != null and is_instance_valid(_reserved_prop):
|
||||
_reserved_prop.held_by_instance_id = 0
|
||||
_reserved_prop = null
|
||||
_pending_action.clear()
|
||||
_state = State.IDLE
|
||||
|
||||
func release_for_ragdoll() -> void:
|
||||
if has_held_prop():
|
||||
release_held(&"ragdoll", _rig_velocity(), _angular_snapshot if is_finite(_angular_snapshot) else 0.0, &"prop_dropped")
|
||||
|
||||
func release_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||
if has_held_prop():
|
||||
release_held(reason, _rig_velocity(), 0.0, &"prop_dropped")
|
||||
else:
|
||||
cancel()
|
||||
|
||||
func release_held(reason: StringName, linear_velocity: Vector2, angular_velocity: float, event_type: StringName) -> bool:
|
||||
if not has_held_prop():
|
||||
return false
|
||||
var prop: PropBlock = _held_prop
|
||||
_update_attachment()
|
||||
prop.freeze = bool(_saved_physics.get("freeze", false))
|
||||
prop.freeze_mode = int(_saved_physics.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
prop.collision_layer = int(_saved_physics.get("collision_layer", prop.collision_layer))
|
||||
prop.collision_mask = int(_saved_physics.get("collision_mask", prop.collision_mask))
|
||||
prop.z_index = int(_saved_physics.get("z_index", prop.z_index))
|
||||
prop.gravity_scale = float(_saved_physics.get("gravity_scale", prop.gravity_scale))
|
||||
prop.linear_damp = float(_saved_physics.get("linear_damp", prop.linear_damp))
|
||||
prop.angular_damp = float(_saved_physics.get("angular_damp", prop.angular_damp))
|
||||
prop.linear_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
||||
prop.angular_velocity = angular_velocity if is_finite(angular_velocity) else 0.0
|
||||
prop.held_by_instance_id = 0
|
||||
_held_prop = null
|
||||
_reserved_prop = null
|
||||
_saved_physics.clear()
|
||||
_restore_hand_target()
|
||||
_state = State.IDLE
|
||||
prop_transition.emit(event_type, _rig, prop, reason)
|
||||
return true
|
||||
|
||||
static func gravity_for(prop: PropBlock) -> Vector2:
|
||||
var direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
|
||||
var magnitude: float = float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
|
||||
return direction * magnitude * prop.gravity_scale
|
||||
|
||||
func _begin_pickup(action: Dictionary) -> bool:
|
||||
if has_held_prop() or _state != State.IDLE:
|
||||
return false
|
||||
var prop: PropBlock = instance_from_id(int(action.get("target_prop", 0))) as PropBlock
|
||||
if prop == null or not is_instance_valid(prop) or not prop.can_be_picked_up or prop.held_by_instance_id != 0:
|
||||
return false
|
||||
var choice: Dictionary = _choose_grip(prop, String(action.get("hand", "auto")))
|
||||
if choice.is_empty():
|
||||
return false
|
||||
_reserved_prop = prop
|
||||
_reserved_prop.held_by_instance_id = _rig.get_instance_id()
|
||||
_hand = StringName(choice["hand"])
|
||||
_grip = choice["grip"]
|
||||
_saved_hand_target = _hand_marker().global_position
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
_pending_action = action.duplicate(true)
|
||||
var grip_world: Vector2 = _grip_world_point(prop, _grip)
|
||||
if _can_reach(grip_world):
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
else:
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var left: float = prop.to_global(Vector2(bounds.position.x, bounds.get_center().y)).x
|
||||
var right: float = prop.to_global(Vector2(bounds.end.x, bounds.get_center().y)).x
|
||||
var side: float = -1.0 if _rig.global_position.x <= grip_world.x else 1.0
|
||||
var feet: Vector2 = _rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var stand_point: Vector2 = Vector2(left - ARM_REACH * 0.65 if side < 0.0 else right + ARM_REACH * 0.65, feet.y)
|
||||
if _locomotion == null or not _locomotion.walk_to(stand_point):
|
||||
cancel()
|
||||
return false
|
||||
_state = State.APPROACHING
|
||||
return true
|
||||
|
||||
func _complete_pickup() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop) or not _can_reach(_grip_world_point(_reserved_prop, _grip)):
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
_held_prop = _reserved_prop
|
||||
_saved_physics = {"freeze": _held_prop.freeze, "freeze_mode": _held_prop.freeze_mode, "collision_layer": _held_prop.collision_layer, "collision_mask": _held_prop.collision_mask, "z_index": _held_prop.z_index, "gravity_scale": _held_prop.gravity_scale, "linear_damp": _held_prop.linear_damp, "angular_damp": _held_prop.angular_damp}
|
||||
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
_held_prop.freeze = true
|
||||
_held_prop.collision_layer = 0
|
||||
_held_prop.collision_mask = 0
|
||||
_held_prop.linear_velocity = Vector2.ZERO
|
||||
_held_prop.angular_velocity = 0.0
|
||||
_held_prop.z_index += int(_grip.get("z_index_offset", 0))
|
||||
_state = State.HOLDING
|
||||
_update_attachment()
|
||||
prop_transition.emit(&"prop_picked_up", _rig, _held_prop, &"pickup")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _begin_throw(action: Dictionary) -> bool:
|
||||
if not has_held_prop() or _state != State.HOLDING:
|
||||
return false
|
||||
var requested: int = int(action.get("target_prop", _held_prop.get_instance_id()))
|
||||
if requested != _held_prop.get_instance_id() or not action.get("target_position") is Vector2:
|
||||
return false
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, action["target_position"] as Vector2, time, gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
return false
|
||||
_pending_action = action.duplicate(true)
|
||||
_timer = 0.10
|
||||
_state = State.WINDUP
|
||||
return true
|
||||
|
||||
func _complete_throw() -> void:
|
||||
if not has_held_prop() or not _pending_action.get("target_position") is Vector2:
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, _pending_action["target_position"] as Vector2, float(_pending_action.get("flight_time", 0.0)), gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
_restore_hand_target(); _state = State.HOLDING; interaction_finished.emit(false); return
|
||||
release_held(&"throw", solved["velocity"] as Vector2, RELEASE_SPIN, &"prop_thrown")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _choose_grip(prop: PropBlock, requested_hand: String) -> Dictionary:
|
||||
var options: Array[Dictionary] = []
|
||||
for hand_id: String in ["right", "left"]:
|
||||
if requested_hand != "auto" and requested_hand != hand_id:
|
||||
continue
|
||||
var enum_hand: int = PROP_ANCHOR_DATA.Hand.RIGHT if hand_id == "right" else PROP_ANCHOR_DATA.Hand.LEFT
|
||||
for id: String in PROP_ANCHOR_DATA.compatible_grip_ids(prop.anchors, enum_hand):
|
||||
options.append({"hand": hand_id, "grip": prop.anchors[id], "id": id})
|
||||
if options.is_empty():
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var fallback: Dictionary = {"position": bounds.get_center(), "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
for fallback_hand: String in ["right", "left"]:
|
||||
if requested_hand == "auto" or requested_hand == fallback_hand:
|
||||
options.append({"hand": fallback_hand, "grip": fallback, "id": "fallback"})
|
||||
options.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var ad: float = _marker_for(String(a["hand"])).global_position.distance_squared_to(_grip_world_point(prop, a["grip"] as Dictionary))
|
||||
var bd: float = _marker_for(String(b["hand"])).global_position.distance_squared_to(_grip_world_point(prop, b["grip"] as Dictionary))
|
||||
if not is_equal_approx(ad, bd): return ad < bd
|
||||
if String(a["hand"]) != String(b["hand"]): return String(a["hand"]) == "right"
|
||||
return String(a["id"]) < String(b["id"])
|
||||
)
|
||||
return options[0]
|
||||
|
||||
func _update_reach_target() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop):
|
||||
return
|
||||
var goal: Vector2 = _reserved_prop.global_transform * (_grip.get("position", Vector2.ZERO) as Vector2)
|
||||
_hand_marker().global_position = _saved_hand_target.lerp(goal, clampf(1.0 - _timer / REACH_DURATION, 0.0, 1.0))
|
||||
|
||||
func _update_attachment() -> void:
|
||||
if not has_held_prop(): return
|
||||
var wrist: Transform2D = _hand_marker().global_transform
|
||||
_held_prop.global_transform = PROP_ANCHOR_DATA.solve_attachment_transform(wrist, _grip, _held_prop.scale, _rig.facing_profile == StickmanRig.FacingProfile.LEFT)
|
||||
|
||||
func _can_reach(point: Vector2) -> bool:
|
||||
return _hand_marker().global_position.distance_to(point) <= ARM_REACH
|
||||
|
||||
func _grip_world_point(prop: PropBlock, grip: Dictionary) -> Vector2:
|
||||
return prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)
|
||||
|
||||
func _hand_marker() -> Marker2D:
|
||||
return _marker_for(String(_hand))
|
||||
|
||||
func _marker_for(hand: String) -> Marker2D:
|
||||
return _rig._get_ik_marker("Right_Hand" if hand == "right" else "Left_Hand")
|
||||
|
||||
func _restore_hand_target() -> void:
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker != null and is_instance_valid(marker): marker.global_position = _saved_hand_target
|
||||
|
||||
func _rig_velocity() -> Vector2:
|
||||
if _locomotion != null and _locomotion.terrain_air_velocity.is_finite(): return _locomotion.terrain_air_velocity
|
||||
return Vector2.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://p3owck52oug2
|
||||
+18
-1
@@ -225,6 +225,7 @@ var source_stk_path: String = ""
|
||||
var _locomotion_component: StickmanLocomotion = null
|
||||
var _ragdoll_component: StickmanRagdoll = null
|
||||
var _action_runner_component: StickmanActionRunner = null
|
||||
var _prop_interaction_component: StickmanPropInteraction = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -234,12 +235,15 @@ func _ready() -> void:
|
||||
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
|
||||
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
|
||||
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
|
||||
_prop_interaction_component = get_node_or_null(NodePath("RigComponents/PropInteraction")) as StickmanPropInteraction
|
||||
if _locomotion_component == null:
|
||||
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
|
||||
if _ragdoll_component == null:
|
||||
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
|
||||
if _action_runner_component == null:
|
||||
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
|
||||
if _prop_interaction_component == null:
|
||||
push_warning("StickmanRig: missing RigComponents/PropInteraction; prop interaction boundary unavailable.")
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.configure(self)
|
||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
||||
@@ -248,7 +252,7 @@ func _ready() -> void:
|
||||
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
_ragdoll_component.state_transition_requested.connect(_set_state)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.configure(self, _locomotion_component)
|
||||
_action_runner_component.configure(self, _locomotion_component, _prop_interaction_component)
|
||||
_action_runner_component.action_queue = _pending_action_queue
|
||||
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
|
||||
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
|
||||
@@ -257,6 +261,8 @@ func _ready() -> void:
|
||||
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
||||
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
||||
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.configure(self, _locomotion_component)
|
||||
# Resolve all runtime node references.
|
||||
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||
if _skeleton == null:
|
||||
@@ -313,6 +319,8 @@ func _physics_process(delta: float) -> void:
|
||||
_pin_mirrored_head_rotation()
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.tick_post_skeleton(delta, int(state))
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.update_held_attachment(delta)
|
||||
|
||||
|
||||
func get_locomotion() -> StickmanLocomotion:
|
||||
@@ -450,6 +458,8 @@ func _set_state(next_state: int) -> void:
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
if enabled:
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.release_for_ragdoll()
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.reset_for_ragdoll_handoff()
|
||||
match state:
|
||||
@@ -805,6 +815,13 @@ func stop_queue() -> void:
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.stop_queue()
|
||||
|
||||
func get_prop_interaction() -> StickmanPropInteraction:
|
||||
return _prop_interaction_component
|
||||
|
||||
func release_held_prop_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.release_for_lifecycle(reason)
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
return _action_runner_component != null and _action_runner_component.is_queue_running()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -32,6 +32,9 @@ const TRIGGER_TEMPLATES := {
|
||||
"icon": "💥",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_picked_up": { "label": "Picks up a prop", "icon": "🖐️", "target_type": "prop" },
|
||||
"prop_dropped": { "label": "Drops a prop", "icon": "⬇️", "target_type": "prop" },
|
||||
"prop_thrown": { "label": "Throws a prop", "icon": "🎯", "target_type": "prop" },
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Phase 10 — Custom Prop Library and Sandbox Integration
|
||||
|
||||
**Status:** Approved for implementation (2026-09-21). Implements the approved choices from `plans/PHASE_10_PROP_INTEGRATION.md`; this document specifies work and does not itself change runtime behavior.
|
||||
|
||||
## 1. Scope and existing seams
|
||||
|
||||
The Sandbox already has the required three-mode physics lifecycle. `StageSpawner.spawn_prop_asset()` assigns `PropBlock.spawn_id`; `SandboxStage._finalize_placed_node()` freezes props and captures authored state; PLAY unfreezes them; EDIT/DIRECT restore authored transforms and clear velocities. Scene files already save prop `asset_id`, transform, scale, and material preset. Reuse those paths for custom props.
|
||||
|
||||
The missing pieces are filesystem-backed prop entries, full layered visuals, a deliberate collision approximation, custom thumbnail keys and rendering, prop Browse/Refresh, and preservation of missing custom props during scene reload. `PropFile.flatten_to_payload()` currently chooses one shape; it remains a compatibility helper for existing single-shape consumers, not the custom prop visual representation.
|
||||
|
||||
Runtime attachment, grip/emitter roles, prop editing inside the Sandbox, and `.prop` schema changes are outside this phase.
|
||||
|
||||
## 2. Architecture and contracts
|
||||
|
||||
```text
|
||||
res://props/*.prop ──scan──> PropLibrary entry ──id/path──> AssetSelector
|
||||
│ │
|
||||
├── parsed shapes ──> thumbnail queue ──> PropThumbnail
|
||||
│ └── ThumbnailCache
|
||||
└── on-demand id lookup ──> StageSpawner ──> PropBlock
|
||||
├── Visuals/Shape_*
|
||||
└── one collision hull
|
||||
.scene asset_id ──> same on-demand lookup ──> prop or MissingPropPlaceholder
|
||||
```
|
||||
|
||||
### 2.1 `PropLibrary` (`class_name PropLibrary extends RefCounted`)
|
||||
|
||||
Preserve `static func get_entries() -> Array[Dictionary]` as the **four built-in templates only**, and preserve `get_ids()` and `get_default_id() == "crate"`. Add:
|
||||
|
||||
```gdscript
|
||||
static func scan(dir_path: String = PropFile.DEFAULT_DIR) -> Array[Dictionary]
|
||||
static func make_custom_entry(path: String) -> Dictionary
|
||||
static func get_entry(id: String) -> Dictionary
|
||||
```
|
||||
|
||||
`scan()` returns built-ins first, then valid `.prop` files in `dir_path`, sorted by case-insensitive display name and path. It does a fresh, non-recursive scan each call. Missing directory means built-ins only and one descriptive warning. `make_custom_entry()` accepts a `res://`, `user://`, or absolute filesystem `.prop` path, loads with `PropFile.load()` and `PropFile.from_file_dict()`, validates renderable geometry and a non-degenerate closed-shape collision hull, and returns `{}` with a path-specific warning on failure. It must not convert an `Array` of `{x,y}` dictionaries directly into `PackedVector2Array`; use the parsed `shapes` returned by `PropFile.from_file_dict()` or a dedicated coercion helper. `get_entry()` checks built-ins first; for a path ID it calls `make_custom_entry()` on demand, so a saved external prop resolves without first opening the selector.
|
||||
|
||||
All entries expose these keys. GDScript cannot statically type dictionary fields; every method signature and local variable around this boundary remains statically typed.
|
||||
|
||||
| Key | Type | Built-in | Custom |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `String` | `"crate"`, etc. | canonical source path |
|
||||
| `name` | `String` | template name | `prop_name`, falling back to filename stem |
|
||||
| `material_preset` | `int` | template preset | parsed file preset |
|
||||
| `material_label` | `String` | existing label | corresponding preset label |
|
||||
| `is_custom` | `bool` | `false` | `true` |
|
||||
| `path` | `String` | `""` | same canonical path as `id` |
|
||||
| `payload` | `Dictionary` | existing primitive payload | compatibility value from `flatten_to_payload()` |
|
||||
| `shapes` | `Array[Dictionary]` | empty | full parsed shape stack, back to front |
|
||||
| `collision_hull` | `PackedVector2Array` | empty | validated local-space hull |
|
||||
|
||||
The canonical custom ID is the normalized source path, including its scheme where applicable. Browse keeps an external absolute path; no file is copied. Scene portability therefore depends on that file remaining at the saved path. No last-selection setting is added.
|
||||
|
||||
### 2.2 Shared visual and collision conversion
|
||||
|
||||
Add `scripts/prop_visuals.gd` (`class_name PropVisuals extends RefCounted`) as a stateless helper used by both runtime and thumbnail rendering:
|
||||
|
||||
```gdscript
|
||||
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2
|
||||
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array
|
||||
```
|
||||
|
||||
`build()` creates one visual group per source shape in array order. Closed polygons and circles receive a `Polygon2D` fill plus a closed `Line2D` outline. Open lines receive only a `Line2D`, with caps/joints matching the editor. Preserve each shape's fill, outline, width, and authored point coordinates. A circle uses its parsed points when present, including hand-reshaped circles; only an absent point list is regenerated from `radius`. The returned local bounds cover every rendered shape; empty or invalid individual shapes are ignored with a warning, while `PropLibrary` rejects an asset with no usable geometry.
|
||||
|
||||
`collision_hull()` collects points from valid **closed** polygon/circle shapes and constructs one convex hull. Open lines never create collision. Reject a hull with fewer than three distinct, non-collinear points or zero area. The hull keeps authored local coordinates without terrain grid snapping. This is a deliberate approximate solid: cavities and concavities are filled, but the body remains stable with one collision node. Do not derive collision from the frontmost shape selected by `flatten_to_payload()`; for example, the briefcase's decorative details must not become its entire collision body.
|
||||
|
||||
`PropBlock` (`class_name PropBlock extends RigidBody2D`) gains:
|
||||
|
||||
```gdscript
|
||||
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void
|
||||
func has_custom_shapes() -> bool
|
||||
func get_visual_local_bounds() -> Rect2
|
||||
```
|
||||
|
||||
Custom nodes retain the existing `PropBlock` root, material mass/friction/bounce, contact signals, and one enabled `CollisionPolygon2D` populated from the hull. Their visual hierarchy is:
|
||||
|
||||
```text
|
||||
PropBlock (RigidBody2D; spawn_id = source path)
|
||||
├── Visuals (Node2D)
|
||||
│ ├── Shape_0 (Node2D) ── Polygon2D? + Line2D
|
||||
│ └── Shape_1 ...
|
||||
├── CollisionPolygon2D (one convex hull)
|
||||
└── existing primitive children (disabled/hidden for custom)
|
||||
```
|
||||
|
||||
For custom props, changing `material_preset` applies physics properties but **does not replace authored per-shape colors**. Existing built-ins keep their current preset tinting and primitive structure. Reapplying the preset during scene load must preserve this distinction. `StageSpawner.spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock` branches on `is_custom`, uses `PropBlock.set_custom_shapes()` for custom entries, sets `spawn_id = asset_id`, and returns `null` if validation fails. `PropUtils.spawn_prop()` remains the built-in primitive path.
|
||||
|
||||
`StageSelection.get_world_aabb()` and `StageSpawner.get_world_aabb()` must union all custom visual points (or transform `get_visual_local_bounds()`), rather than stop at the root `Polygon2D`. This keeps selection, gizmos, ghost placement, trigger overlap, and grid indexing aligned with the full visible prop. The current AABB hit-test remains approximate.
|
||||
|
||||
### 2.3 Thumbnail pipeline
|
||||
|
||||
`PropThumbnail` keeps `render(payload: Dictionary, material_preset: int) -> Texture2D` for built-ins and adds `render_shapes(shapes: Array[Dictionary]) -> Texture2D` for custom props. Both are async render paths driven by the existing `SandboxStage._thumbnail_queue`, at most one queued render at a time. The custom path calls `PropVisuals.build()` and frames the camera around the **entire** stack; it never flattens the stack or recolors it from the material preset. A null/blank headless capture leaves the selector placeholder and does not poison the disk cache.
|
||||
|
||||
`ThumbnailCache.prop_png(id)` remains built-in-only. `prop_file_key(path)`/`prop_file_png(path)` include a hash of the canonical **full path** and the file modification time, with a `file_` prefix distinct from built-in keys. Two `chair.prop` files in different directories cannot share a PNG key. On Refresh, the stage recomputes keys and queues only missing images. Cleanup may remove superseded custom PNGs for known canonical paths after successful replacement; it must not delete built-in PNGs or cache files for unrelated external paths. No global cache sweep is required.
|
||||
|
||||
### 2.4 Selector and stage signals
|
||||
|
||||
`AssetSelector` keeps the existing typed signals `item_selected(entry: Dictionary)`, `browse_requested()`, `refresh_requested()`, and `cancelled()`. In `open("prop", entries)`, show Browse and Refresh, and use a prop-specific empty label. The selector's `_entry_key()` already uses `id` for props, so path IDs work without an additional key field. `set_entries()` still resets pagination and clears displayed thumbnail references; the stage repopulates them from cache or queue.
|
||||
|
||||
`SandboxStage` calls `PropLibrary.scan()` whenever the prop selector opens or Refresh is pressed. Browse sets the existing `FileDialog` title/filter to `.prop` according to `_selector_kind`; stickman browsing retains `.stk`. A valid browsed prop selects its path ID and enters prop placement. An invalid file shows a concise error/toast and leaves the selector open. Refresh updates the open grid, invalidates stale thumbnail jobs through the existing generation counter, and preserves the selected prop ID when still resolvable. Custom entries choose `prop_file_png(path)`; built-ins choose `prop_png(id)`.
|
||||
|
||||
### 2.5 Scene reload and missing custom assets
|
||||
|
||||
Add `scripts/missing_prop_placeholder.gd` (`class_name MissingPropPlaceholder extends Node2D`) with typed `original_entry: Dictionary`, `missing_prop_path: String`, `configure(entry: Dictionary) -> void`, and `get_area_rect() -> Rect2`. It displays an inert missing-asset marker and retains a deep copy of the original prop scene entry. The loader creates it **only for a missing/invalid path-valued custom prop ID**; unknown legacy template IDs continue to warn and skip. It applies the saved transform and scale and adds the placeholder to the World in the original object order.
|
||||
|
||||
The placeholder is intentionally entered into `file_to_runtime` as an available object ID, so `StageSceneIO.decode_rules()` maps existing rule references to its instance ID. It does **not** belong in the `unavailable` set that currently makes rules disappear for missing stickmen. Its `Node2D` type prevents `PropBlock` physics/contact handling; stage trigger/action execution must treat it as inert. `_collect_scene_data()` recognizes `MissingPropPlaceholder` as a `prop`, begins from `original_entry.duplicate(true)`, updates the current file ID and transform/scale, and serializes its original `asset_id` and material. The normal runtime-to-file map includes its ID so `StageSceneIO.encode_rules()` writes the preserved references. Deleting a placeholder follows normal object/rule cleanup. Reopening the scene retries the path and replaces it with a real prop when available.
|
||||
|
||||
Example scene flow:
|
||||
|
||||
```text
|
||||
load prop_1 -> path missing -> placeholder instance 842 -> rule source = 842
|
||||
save -> object prop_1 still has original asset_id -> rule source_id = prop_1
|
||||
restore file, reload -> real PropBlock instance 913 -> rule source = 913
|
||||
```
|
||||
|
||||
This extends preservation specifically to missing custom props; the existing missing-stickman skip policy remains outside this phase.
|
||||
|
||||
## 3. Mode and data flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
EDIT --> DIRECT: select Direct
|
||||
EDIT --> PLAY: select Play
|
||||
DIRECT --> EDIT: select Edit
|
||||
DIRECT --> PLAY: select Play
|
||||
PLAY --> EDIT: freeze + restore authored pose
|
||||
PLAY --> DIRECT: freeze + restore authored pose
|
||||
```
|
||||
|
||||
The custom prop goes through existing `_finalize_placed_node()`, `_save_object_state()`, `_enter_play_mode()`, and `_enter_edit_shared()` methods. Do not add a second authored-position metadata system. Stage save uses `prop.spawn_id` (the custom path) as `asset_id`. EDIT and DIRECT keep the body kinematically frozen; PLAY unfreezes it; leaving PLAY restores position/rotation and zeros linear/angular velocity. Current scale remains a scene-level transform. Scene load's material override must update physics without tinting custom artwork.
|
||||
|
||||
## 4. Verification and acceptance
|
||||
|
||||
Add a focused `tests/test_phase10_prop_integration.gd` headless suite and update relevant existing suites. Assertions must test values and behavior, not only node counts.
|
||||
|
||||
1. **Library:** scan returns four built-ins plus the valid checked-in `.prop` entries in deterministic order; invalid version, corrupt JSON, missing file, empty/line-only, and degenerate closed geometry are skipped with warnings. `get_entries()` remains four templates, preserving `tests/test_phase3b_library.gd`; add an explicit scan test rather than changing that assertion. A custom ID resolves by path without prior scan.
|
||||
2. **Geometry:** a multi-shape sample such as `briefcase.prop` spawns all authored layers, including open outlines where present, with original per-shape colors and point values after JSON parse. It has exactly one enabled collision hull that covers the intended closed-shape extrema; lines do not expand collision. `get_world_aabb()` and selection bounds include all visual layers. Built-in crate and ball remain visually/physically unchanged.
|
||||
3. **Thumbnails:** the custom render path receives every layer and computes full-stack bounds; an interactive capture shows their colors and order. Two equal basenames from different folders have distinct cache paths, and changing mtime changes the key. A headless null capture is harmless. The stage queue still drains sequentially and ignores jobs from an older selector generation.
|
||||
4. **Selector:** prop Browse/Refresh are visible; stickman Browse retains the `.stk` filter. Browse accepts a valid external `.prop` as a path ID; invalid browse keeps the selector open. Refresh finds a new file, drops a removed file from the grid, and does not replace a still-valid selected prop. Pagination remains correct past 12 items.
|
||||
5. **Scene persistence:** save/reload retains a custom prop's path ID, material, position, rotation, scale, and full visuals. With its source temporarily absent, load creates a placeholder carrying the original entry; save/reload preserves that entry and a rule referring to it. Restoring the file and reopening resolves a real prop and the rule reference. Existing built-in prop and missing-stickman behavior stays covered by `tests/test_phase5_file_features.gd`.
|
||||
6. **Lifecycle:** in EDIT/DIRECT the custom `RigidBody2D` is frozen; in PLAY it simulates and reports contacts; after PLAY→EDIT and PLAY→DIRECT it returns to the authored transform with zero velocities. Use a physics-frame runtime test and a brief interactive Sandbox check for full-layer artwork, collision, selection, Browse, and reload.
|
||||
|
||||
Run the affected headless suites plus Godot 4.7.1 `--headless --check-only` on changed scripts. Test thumbnail pixels interactively because headless `SubViewport` capture can be blank. The target scene remains `res://scenes/sandbox_stage.tscn`; no new UI scene is required beyond the existing selector's code-controlled visibility/text.
|
||||
|
||||
## 5. Architectural trade-offs
|
||||
|
||||
- A single convex collision hull is cheap and stable, but fills gaps and concavities. Full layered visual fidelity is independent of this approximation.
|
||||
- External Browse paths support immediate use without copying, but moving the source file breaks reload until restored. The placeholder prevents scene and rule data loss during that interval.
|
||||
- Dictionary entries match the existing libraries and `.prop` JSON boundary; introducing custom `Resource` assets would add conversion and migration work without improving this phase's data flow.
|
||||
- Path-plus-mtime thumbnail keys prevent same-name cross-folder collisions. Filesystems with coarse timestamp resolution can still miss two edits within one timestamp tick; content hashing is a possible later upgrade.
|
||||
- Missing-prop rule references remain serialized through inert placeholders. Rules cannot fire from absent geometry; existing missing-stickman rules continue to follow their current skip behavior.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Phase 10a — Prop Scaling
|
||||
|
||||
Status: **implemented and user verified** (amended and verified 2026-09-22).
|
||||
|
||||
Source: `plans/PHASE_10a_PROP_SCALING.md`. Phase 10a adds two distinct operations:
|
||||
permanent size changes to a `.prop` document in the Prop Editor, and reversible
|
||||
uniform instance scaling of one `PropBlock` in Sandbox EDIT mode. The final UI uses
|
||||
origin-based Normalize Size and direct whole-prop Arrange gestures; it does not expose
|
||||
a numeric document-scale control. Sandbox keeps single-prop corner handles that scale
|
||||
around the prop's local origin.
|
||||
|
||||
## 1. Existing contracts and scope
|
||||
|
||||
- `PropCanvas` stores every shape's `PackedVector2Array` points and computes the
|
||||
union bounds. `PropEditor` owns the matching anchor dictionary, dirty/autosave
|
||||
flow, inspector, and the mode-specific top-bar controls. `PropAnchorData`
|
||||
always synthesizes a fixed `origin` at `(0, 0)`.
|
||||
- Circles carry both `points` and `radius`; both must remain consistent after a
|
||||
document-wide uniform scale. Keep `shape_type`, `closed`, `vertex_flags`,
|
||||
`reshaped`, fill, outline color, and material preset unchanged.
|
||||
- `StageGizmos` currently has translation and rotation gestures and emits
|
||||
`transform_started`, `transform_updated`, and `transform_committed`.
|
||||
`SandboxStage` already uses these signals to sync frozen bodies, save authored
|
||||
transforms, rebuild spatial cells, and mark the scene dirty.
|
||||
- `SandboxStage._collect_scene_data()` already writes prop `scale`; scene loading
|
||||
already reads it. `_restore_authored_state()` restores authored position and
|
||||
rotation and zeroes rigid-body velocities without changing scale. No `.prop`
|
||||
or `.scene` version change is required.
|
||||
|
||||
In scope: document scale/normalize controls, a passive 1:1 guide in the Prop
|
||||
Editor, prop-only Sandbox scale handles, and focused verification. Keep the
|
||||
existing built-in/custom prop rendering and one-hull custom collision policy.
|
||||
Do not add Sandbox numeric scale fields, presets, new authoring modes, or
|
||||
document-wide undo/redo. Debt item #37 continues to track undo.
|
||||
|
||||
The 2026-09-22 amendment replaces the visible, Arrange-only **Resize Whole
|
||||
Prop** toggle with **Transform Whole Prop**. One union gizmo moves, rotates,
|
||||
or uniformly resizes the complete document. It does not change Sandbox scaling.
|
||||
|
||||
## 2. Class and API contracts
|
||||
|
||||
| Class / file | Required contract |
|
||||
| --- | --- |
|
||||
| `class_name PropCanvas extends Control` (`scripts/prop_canvas.gd`) | Keep `can_scale_all_shapes()` / `scale_all_shapes()`, and add equivalent validated document helpers `can_translate_all_shapes(delta: Vector2) -> bool`, `translate_all_shapes(delta: Vector2) -> bool`, `can_rotate_all_shapes(angle_rad: float) -> bool`, and `rotate_all_shapes(angle_rad: float) -> bool`. Successful commit helpers mutate every shape and emit `shape_changed(shapes: Array[Dictionary])` exactly once; preflight/no-op/failure never mutates or emits. Add the transient whole-prop tool API in §3.4. `get_shapes_bounds() -> Rect2` remains authoritative for its union gizmo. |
|
||||
| `class_name PropAnchorData extends RefCounted` (`scripts/prop_anchor_data.gd`) | Keep pure `try_scale_non_origin()`, and add pure `try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary` and `try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary`, each returning `{ "ok": bool, "anchors": Dictionary }`. Translation changes editable positions only. Rotation rotates editable positions about `(0,0)` and adds the angle to their wrapped `rotation_deg`. Every helper regenerates canonical `origin` unchanged and rejects invalid results without partially modifying input. Existing `translated_non_origin()` may delegate to the validated helper. |
|
||||
| `class_name PropEditor extends Control` (`scripts/prop_editor.gd`) | Own three atomic document transactions: `_translate_document(delta: Vector2) -> bool`, `_rotate_document(angle_rad: float) -> bool`, and existing `_scale_document(factor: float) -> bool`. Each prevalidates shapes and anchors, applies the anchor copy, commits canvas geometry once, rolls anchors back on an unexpected canvas failure, and refreshes inspector/preview/buttons through the existing shape-change path. Connect the typed request signals in §3.4. Persist only guide visibility in editor settings. |
|
||||
| `class_name StageGizmos extends Node2D` (`scripts/stage_gizmos.gd`) | Extend `enum Handle` with `SCALE`. Draw and hit-test four corner crosses only when `_targets.size() == 1` and `_targets[0] is PropBlock`; keep hit radii and strokes in screen pixels via camera zoom. Capture start `scale: Vector2`, world origin, selected corner, and cursor at gesture start. Use one positive uniform multiplier through the drag; emit the existing transform signals. |
|
||||
| `class_name SandboxStage extends Node2D` (`scripts/sandbox_stage.gd`) | Keep the current EDIT pointer routing and existing transform-signal handlers. Route scale gestures through the same physics-sync and authored-state commit path as move/rotate. No save/load or PLAY→EDIT reset rewrite is needed unless verification exposes a defect. |
|
||||
| `class_name StageSelection extends RefCounted` (`scripts/stage_selection.gd`) | Its existing `get_world_aabb(node: Node2D) -> Rect2` transforms visual points to world space. Verify scaled/rotated built-in and custom props; change the method only if a specific failing case appears. |
|
||||
|
||||
No new persistent shape fields are required. The whole-prop tool adds three
|
||||
typed commit-request signals. Use static type hints for new fields, parameters,
|
||||
local values where inference is ambiguous, and return values. The signal
|
||||
interfaces are:
|
||||
|
||||
```gdscript
|
||||
signal shape_changed(shapes: Array[Dictionary])
|
||||
signal transform_started(nodes: Array[Node2D])
|
||||
signal transform_updated(nodes: Array[Node2D])
|
||||
signal transform_committed(nodes: Array[Node2D])
|
||||
signal whole_prop_move_requested(delta: Vector2)
|
||||
signal whole_prop_rotate_requested(angle_rad: float)
|
||||
signal whole_prop_resize_requested(factor: float)
|
||||
```
|
||||
|
||||
## 3. Prop Editor document transaction
|
||||
|
||||
### 3.1 Explicit scale and 96 × 96 normalization
|
||||
|
||||
Do not expose a numeric scale textbox or Apply Scale button. In Arrange, the
|
||||
mode top bar presents **Transform Whole Prop**, **📏 Normalize Size**, **🎯 Center
|
||||
Prop**, and **Show Stickman Guide**, in that order. Normalize Size is a
|
||||
document operation, not saved UI state: it uniformly fits valid shape-point
|
||||
bounds to a 96 px maximum dimension around `(0, 0)`, preserves proportional
|
||||
anchor offsets, and leaves outline width unchanged. Disable it when there is no
|
||||
valid shape geometry. Transform Whole Prop supplies direct move, rotate, and
|
||||
uniform resize gestures; its detailed snapshot and commit contract is in §3.4.
|
||||
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while
|
||||
Center Prop and Show Stickman Guide remain available.
|
||||
|
||||
Normalize uses the shape-point union already returned by `get_shapes_bounds()`:
|
||||
|
||||
```gdscript
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
var factor: float = 96.0 / extent
|
||||
```
|
||||
|
||||
For a nonempty finite bounds with `extent > 0`, apply this factor uniformly
|
||||
around `(0, 0)`. The resulting maximum geometry extent is 96 world pixels;
|
||||
the smaller dimension retains its aspect ratio. A line with one nonzero
|
||||
dimension is valid. Empty or point-only geometry, zero extent, nonfinite
|
||||
values, or an output coordinate/radius that cannot be represented safely make
|
||||
the action a no-op. Do not silently clamp the factor and miss the 96 px target.
|
||||
Use a reasonable coordinate safety cap documented in code rather than relying
|
||||
on floating-point overflow. Normalization does **not** call **Center Prop**:
|
||||
off-origin content stays off-origin at its new proportional offset.
|
||||
|
||||
### 3.2 Geometry and anchor invariants
|
||||
|
||||
The transaction computes its result from a deep copy before updating either
|
||||
owner. Transform every point of every polygon, line, and circle as `point *=
|
||||
factor`. For every circle, also set `radius *= factor`. Do not regenerate
|
||||
circle points: that would erase an intentionally reshaped circle or move its
|
||||
center. Preserve `reshaped` exactly as stored. Keep `vertex_flags` in their
|
||||
original index order. Outline width is a style value and stays unchanged;
|
||||
the 96 px target measures shape points, not stroke thickness. Preserve the
|
||||
canonical origin while multiplying all editable anchor positions by the same
|
||||
factor. Anchor rotations and metadata stay unchanged.
|
||||
|
||||
Call `_canvas.can_scale_all_shapes(factor)` and
|
||||
`PropAnchorData.try_scale_non_origin(_anchors, factor)` before changing either
|
||||
owner. A failed preflight leaves both unchanged. Apply the prevalidated anchor
|
||||
copy without emitting a separate document edit, then commit the canvas shapes
|
||||
with the same factor; this synchronous path must use the same canvas validation
|
||||
as preflight. Let its one `shape_changed` emission use the existing
|
||||
`PropEditor._on_canvas_shape_changed()` dirty/autosave/preview refresh path.
|
||||
Refresh selected anchor controls and any disabled button state afterward.
|
||||
The scale operation must not reset pan/zoom, selection, edit mode, clipboard,
|
||||
or guide visibility. Both successful actions mark the document dirty once;
|
||||
changing the guide toggle does not.
|
||||
|
||||
Example with factor `2`:
|
||||
|
||||
```text
|
||||
Before: circle points centered at (20, 10), radius 12;
|
||||
primary_grip.position = (8, -3), origin = (0, 0)
|
||||
After: circle points centered at (40, 20), radius 24;
|
||||
primary_grip.position = (16, -6), origin = (0, 0)
|
||||
```
|
||||
|
||||
Save, autosave, recovery, and load keep the existing `.prop` v1.0 shape and
|
||||
anchor representation. `PropFile._serialize_shape()` must continue to coerce
|
||||
export-format point dictionaries; never assign JSON arrays directly to a
|
||||
`PackedVector2Array`.
|
||||
|
||||
### 3.3 Stickman scale guide
|
||||
|
||||
Add a **Show Stickman Guide** `CheckBox` in the Prop Editor canvas controls.
|
||||
Default is OFF for a fresh settings file. Persist the Boolean as
|
||||
`show_stickman_guide` in `user://prop_editor_settings.json` alongside
|
||||
`recent_props`; a missing key means `false`, and old settings files remain
|
||||
valid. Apply the loaded value during `_ready()` after the controls exist.
|
||||
Toggling calls `PropCanvas.set_show_stickman_guide()`, saves settings, and
|
||||
redraws, without changing `_dirty`, autosave, `.prop` data, or scene data.
|
||||
|
||||
Draw the same 1:1 head circle and bone-line reference already described by
|
||||
`WholeStickmanPreview.GUIDE_JOINTS`, `GUIDE_BONES`, and
|
||||
`GUIDE_HEAD_RADIUS`. Prefer preloading that source's constants or extracting
|
||||
a shared constant-only guide source if compilation requires it; do not copy
|
||||
the numerical joint table into a second implementation. Draw raw master
|
||||
coordinates with `Hips = Vector2.ZERO` after `_draw_grid()` and before user
|
||||
shapes, under the canvas's existing pan/zoom transform. This keeps a 96 px
|
||||
prop directly comparable with the approximately 940 px standing figure in
|
||||
every view. Use muted translucent strokes with width divided by `_zoom` for
|
||||
legibility; keep the guide noninteractive and out of hit-testing, shape bounds,
|
||||
thumbnails, and `.prop` serialization. Existing origin marker and selection
|
||||
gizmos remain above it. New/load/reset do not forcibly change its saved
|
||||
visibility preference.
|
||||
|
||||
### 3.4 Arrange-only Transform Whole Prop tool (2026-09-22 decision)
|
||||
|
||||
**Interaction and scope.** Replace **↗ Resize Whole Prop** with one prominent
|
||||
toggle labeled **✥ Transform Whole Prop** in the Arrange mode top bar. It is available only in Arrange
|
||||
(`EditMode.TRANSFORM`) when valid shape geometry exists. It starts OFF, remains
|
||||
armed after each successful gesture, is session-only, and is excluded from
|
||||
settings and `.prop` data. This extends the already learned per-shape Arrange
|
||||
mapping to the full document:
|
||||
|
||||
| Target | Gesture | Whole-document result |
|
||||
| --- | --- | --- |
|
||||
| Any visible shape body | Drag | Move all shapes and editable anchors. Transparent gaps inside the union box do nothing. |
|
||||
| Dot below union bounds | Drag | Rotate all shapes and editable anchors about canonical `(0,0)`. Ctrl snaps to 15°. |
|
||||
| Any of four union corners | Drag | Uniformly resize all shapes and editable anchors about canonical `(0,0)`. |
|
||||
|
||||
Hit-test in the strict order **corner → rotation dot → visible shape body**.
|
||||
This keeps small handles usable when they overlap geometry. Suppress per-shape
|
||||
selection, translation/rotation/scale gizmos, and right-click menus while the
|
||||
tool is armed. Preserve `_selected_shape_idx` internally and restore its
|
||||
selection/inspector when the toggle turns OFF. Existing wheel/pinch zoom and
|
||||
middle-button/touchpad pan remain available during the tool.
|
||||
|
||||
Draw one two-tone union-bounds outline, four large constant-screen-size corner
|
||||
crosses (at least 12 px hit radius), and the familiar constant-screen-size
|
||||
rotation dot below the union AABB. Keep the canonical origin marker visible;
|
||||
if **Show Other Markers** hid it, restore that preference when the tool exits.
|
||||
Also draw small passive markers for every non-origin anchor, with grip markers
|
||||
clearly visible. During a gesture, the union outline and markers follow the
|
||||
temporary preview. These markers are noninteractive and excluded from bounds,
|
||||
hit-testing, thumbnails, and serialization. The tooltip must say that body,
|
||||
dot, and corners move, rotate, and resize the whole prop including grip markers.
|
||||
|
||||
**Typed Canvas API and signals.** Replace resize-specific tool methods with:
|
||||
|
||||
```gdscript
|
||||
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
|
||||
|
||||
func set_whole_prop_transform_enabled(enabled: bool) -> void
|
||||
func is_whole_prop_transform_enabled() -> bool
|
||||
func is_whole_prop_transform_dragging() -> bool
|
||||
func cancel_whole_prop_transform() -> void
|
||||
|
||||
signal whole_prop_move_requested(delta: Vector2)
|
||||
signal whole_prop_rotate_requested(angle_rad: float)
|
||||
signal whole_prop_resize_requested(factor: float)
|
||||
```
|
||||
|
||||
`set_whole_prop_transform_enabled(false)` cancels a live preview before
|
||||
disarming. `cancel_whole_prop_transform()` clears the active drag, snapshots,
|
||||
preview arrays, accumulated rotation, and pending payload without changing
|
||||
authoritative data or the armed state. `PropEditor` connects the three signals
|
||||
to `_translate_document`, `_rotate_document`, and `_scale_document`. Do not
|
||||
emit `anchors_changed` for these document gestures.
|
||||
|
||||
The implementation also retains resize-named compatibility aliases internally
|
||||
(`set_whole_prop_resize_enabled`, `is_whole_prop_resize_enabled`,
|
||||
`is_whole_prop_resize_dragging`, and `cancel_whole_prop_resize`) so existing
|
||||
controller paths continue to target the same Transform Whole Prop state. They
|
||||
are not separate user-facing tools.
|
||||
|
||||
**Snapshot and preview contract.** On pointer-down, capture deep starting
|
||||
copies of `shapes: Array[Dictionary]` and normalized anchors, starting union
|
||||
bounds, pointer, selected handle, and any gesture-specific state. Render all
|
||||
motion from those starting copies into temporary preview shape/anchor arrays.
|
||||
Do not replace authoritative `shapes` or editor `_anchors` before release;
|
||||
therefore `get_shape_data()`, save, autosave, and recovery always see the
|
||||
pre-drag document if their timer runs during a drag. Preview motion can redraw
|
||||
each input event but never emits edit signals or arms autosave.
|
||||
|
||||
All gestures preserve shape type, layer order, `closed`, `vertex_flags`,
|
||||
`reshaped`, fill/outline, and outline width, plus anchor role, z offset,
|
||||
allowed hands, and IDs. Canonical `origin` remains at `(0,0)` with rotation
|
||||
`0°`. Circle behavior is explicit: move and rotate transform its stored points
|
||||
but leave `radius` unchanged; resize transforms its points and multiplies
|
||||
`radius`. Never regenerate circle points.
|
||||
|
||||
**Move math.** Capture `start_pointer`; compute from the baseline every frame:
|
||||
|
||||
```gdscript
|
||||
var delta: Vector2 = current_pointer - start_pointer
|
||||
if snap_enabled:
|
||||
delta = Vector2(snappedf(delta.x, grid_size), snappedf(delta.y, grid_size))
|
||||
preview_point = start_point + delta
|
||||
preview_anchor.position = start_anchor.position + delta
|
||||
```
|
||||
|
||||
Snap the **delta**, so all layers and anchors retain their relative placement
|
||||
even when the starting document is off-grid. Anchor `rotation_deg` and circle
|
||||
radius remain unchanged. Moving the whole prop means moving authored geometry
|
||||
and editable anchor markers relative to the fixed document pivot; the canonical
|
||||
origin itself does not translate. A zero delta is a no-op.
|
||||
|
||||
**Rotate math.** The pivot is always canonical `Vector2.ZERO`, independent of
|
||||
the union-bounds center. Capture the pointer angle around origin and accumulate
|
||||
wrapped frame deltas so crossing `-PI/PI` cannot jump:
|
||||
|
||||
```gdscript
|
||||
var frame_delta: float = wrapf(current_angle - previous_angle, -PI, PI)
|
||||
total_angle += frame_delta
|
||||
previous_angle = current_angle
|
||||
var preview_angle: float = snappedf(total_angle, deg_to_rad(15.0)) if ctrl_pressed else total_angle
|
||||
preview_point = start_point.rotated(preview_angle)
|
||||
preview_anchor.position = start_anchor.position.rotated(preview_angle)
|
||||
preview_anchor.rotation_deg = wrapf(start_anchor.rotation_deg + rad_to_deg(preview_angle), -180.0, 180.0)
|
||||
```
|
||||
|
||||
Reject pointer-down or motion whose angle around origin is nonfinite or too
|
||||
close to origin for stable measurement. Always render the captured baseline
|
||||
rotated by `preview_angle`; never rotate the prior preview. The rotation dot's
|
||||
union-bounds location is only an input affordance and does not change the pivot.
|
||||
Circle radius and `reshaped` remain unchanged. An approximately zero angle is
|
||||
a no-op.
|
||||
|
||||
**Resize math.** Preserve the implemented uniform resize rule. Compute the
|
||||
gesture direction from the starting union center and selected starting corner,
|
||||
but measure pointer travel from the exact captured press position:
|
||||
|
||||
```gdscript
|
||||
var direction: Vector2 = start_corner - start_bounds.get_center()
|
||||
var pointer_delta: Vector2 = current_pointer - start_pointer
|
||||
var factor: float = 1.0 + pointer_delta.dot(direction) / direction.length_squared()
|
||||
```
|
||||
|
||||
The factor is exactly `1.0` on pointer-down even when the press is off the
|
||||
handle center. Apply it to points, circle radii, and editable anchor positions
|
||||
about `(0,0)`; anchor rotations stay unchanged. Clamp to positive finite
|
||||
`0.01..10.0`, satisfy canvas coordinate/radius and anchor preflight, and never
|
||||
cross zero or flip geometry. Reject a near-zero direction denominator. An
|
||||
approximately `1.0` factor is a no-op.
|
||||
|
||||
**Commit and cancellation.** On left release, capture the final payload, clear
|
||||
the temporary preview, and emit exactly one matching typed request signal.
|
||||
The controller transaction prevalidates geometry and anchors, applies both
|
||||
atomically, and lets the canvas's single `shape_changed` emission mark dirty,
|
||||
arm autosave once, refresh inspector/preview/buttons, and retain the armed
|
||||
Transform Whole Prop tool. An invalid/no-op release emits nothing. On an
|
||||
unexpected canvas commit failure, restore the original anchors and leave the
|
||||
document unchanged.
|
||||
|
||||
Esc during a drag cancels only that preview and keeps the tool armed. Esc while
|
||||
armed but idle turns the tool OFF. Toggling OFF, changing to Reshape/Attach,
|
||||
New, Load, or Clear cancels a live preview and disarms before changing mode or
|
||||
document. Route Esc through `PropEditor._unhandled_key_input()` while respecting
|
||||
dialogs and text fields, and consume it. A committed gesture is not reverted by
|
||||
Esc because document-wide UndoRedo remains deferred under debt #37. Each move,
|
||||
rotate, or resize gesture must become one future shapes-plus-anchors undo unit.
|
||||
|
||||
```text
|
||||
Arrange, shape scope Transform Whole Prop ON
|
||||
selected-shape bbox all-shape union bbox
|
||||
body = move one shape body = move entire document
|
||||
dot = rotate one shape dot = rotate entire document
|
||||
corners = resize one shape corners = resize entire document
|
||||
|
||||
pointer down → deep snapshots → temporary preview from baseline
|
||||
→ release → one typed controller transaction
|
||||
→ shape_changed once → dirty/autosave once → tool remains armed
|
||||
↘ Esc/mode/data change → discard preview, no dirty change
|
||||
```
|
||||
|
||||
## 4. Sandbox Stage instance gesture
|
||||
|
||||
### 4.1 Visual and input contract
|
||||
|
||||
When exactly one `PropBlock` is selected in EDIT, draw four crosses on the
|
||||
corners of `StageSelection.get_world_aabb(prop)`. Use a contrasting backing
|
||||
and bright core so they remain visible on light/dark props, and a 12 screen px
|
||||
hit radius. Handle hit-testing precedes the existing rotate ring, then normal
|
||||
body selection; an active placement mode keeps the current gizmo precedence.
|
||||
No scale handles appear for multi-selection, terrain, trigger areas,
|
||||
stickmen, missing-prop placeholders, DIRECT, or PLAY. The existing rotation
|
||||
ring and body translation remain available.
|
||||
|
||||
On pointer-down, record the selected corner's world point, the prop's
|
||||
`global_position` as pivot, and its starting `scale: Vector2`. For each drag
|
||||
update, project the cursor vector onto the start-corner vector around the
|
||||
fixed pivot:
|
||||
|
||||
```gdscript
|
||||
var start: Vector2 = start_corner_world - pivot_world
|
||||
var current: Vector2 = cursor_world - pivot_world
|
||||
var requested: float = current.dot(start) / start.length_squared()
|
||||
var new_scale: Vector2 = start_scale * positive_clamped_multiplier
|
||||
```
|
||||
|
||||
Guard near-zero denominators/nonfinite values. Clamp the multiplier so the
|
||||
absolute values of both final scale components stay within `0.05..20.0` and
|
||||
never cross zero; retain the signs of any previously saved scale. The gesture
|
||||
multiplies both axes by **the same positive factor**, preserving the instance's
|
||||
starting aspect ratio even if an older `.scene` contains nonuniform scale.
|
||||
It changes only `prop.scale`, never `global_position`, `global_rotation`,
|
||||
template vertices, radius, anchors, `material_preset`, or `mass`. Compute from
|
||||
the captured starting scale on every motion event rather than multiplying the
|
||||
previous frame, avoiding drift. Draw from the updated world AABB while moving.
|
||||
One release emits one `transform_committed` and enters the existing dirty/save
|
||||
pipeline. No motion or invalid gesture must not produce a scale edit; keep
|
||||
the existing move/rotate behavior unless a focused regression needs repair.
|
||||
|
||||
### 4.2 Physics and lifecycle
|
||||
|
||||
The `PropBlock` root is a `RigidBody2D`; its child visuals and collision
|
||||
geometry inherit its node transform. `material_preset` remains the sole mass
|
||||
source (`PropBlock.mass_for()`), so scale must not recalculate mass. During
|
||||
EDIT drag, use the existing `_on_gizmo_transform_updated()`/
|
||||
`_sync_editable_physics_transform()` path to keep the frozen physics body in
|
||||
sync. On commit, `_on_transform_committed()` saves authored state and rebuilds
|
||||
grid cells. PLAY may move/rotate the prop physically; returning to EDIT must
|
||||
restore authored position/rotation and zero velocities while retaining the
|
||||
authored scale. Saving during PLAY must serialize authored position/rotation
|
||||
and the selected scale as the current code already does.
|
||||
|
||||
Godot warns that scaling collision shapes can produce unexpected physics
|
||||
behavior. Verify polygon and circle contacts at several positive scales in
|
||||
the project engine, including a rotated prop and PLAY→EDIT→PLAY. If the
|
||||
`RigidBody2D` root transform does not give matching collisions, adapt the
|
||||
per-instance collision construction while keeping the scene's scale field,
|
||||
visual size, preset mass, and source `.prop` unchanged. A visually scaled prop
|
||||
with stale collision is not an acceptable implementation.
|
||||
|
||||
## 5. Data and signal flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Whole-prop body, dot, or corner drag] --> V[Snapshot-only live preview]
|
||||
V -->|one typed request on release| E[PropEditor validates transaction]
|
||||
N[Normalize Size] --> E
|
||||
E --> A[PropAnchorData transforms non-origin anchors]
|
||||
E --> C[PropCanvas transforms every shape]
|
||||
C -->|shape_changed once| D[Dirty + autosave + preview refresh]
|
||||
D --> P[Existing PropFile v1.0 save]
|
||||
P --> L[Sandbox PropBlock instance]
|
||||
G[StageGizmos prop corner drag] -->|uniform prop.scale| S[Stage transform signals]
|
||||
S --> F[Frozen-body sync + authored state + dirty]
|
||||
F --> I[Existing .scene scale field]
|
||||
```
|
||||
|
||||
```text
|
||||
Sandbox EDIT selection
|
||||
├─ one PropBlock → AABB + rotate ring + four scale crosses
|
||||
└─ any other selection → existing AABB/rotate behavior, no scale crosses
|
||||
|
||||
PLAY → EDIT → PLAY
|
||||
authored {position, rotation, scale} → simulate → restore position/rotation
|
||||
and zero velocities → retain authored scale → simulate at retained size
|
||||
```
|
||||
|
||||
Example persisted values remain additive to existing formats:
|
||||
|
||||
```json
|
||||
{"version":"1.0","shapes":[{"shape_type":"circle","points":[{"x":24,"y":0},{"x":0,"y":24},{"x":-24,"y":0},{"x":0,"y":-24}],"radius":24,"reshaped":false}],"anchors":{"origin":{"role":"pivot","x":0,"y":0},"primary_grip":{"role":"grip","x":12,"y":-4}}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"type":"prop","file_id":"prop_1","asset_id":"res://props/ball.prop","position":{"x":100,"y":200},"rotation":0,"scale":{"x":1.5,"y":1.5},"material_preset":2}
|
||||
```
|
||||
|
||||
The JSON fragments show only the fields relevant to scaling; ordinary
|
||||
serializer fields remain present in real files.
|
||||
|
||||
## 6. Implementation order and acceptance
|
||||
|
||||
1. Keep the existing scale transaction, guide, and whole-prop resize behavior;
|
||||
add validated geometry/anchor move and rotate helpers plus atomic editor
|
||||
transactions.
|
||||
2. Rename and extend the Arrange-only toggle/gizmo with body move and rotation
|
||||
dot gestures, retaining snapshot preview and resize behavior.
|
||||
3. Implement prop-only Stage scale crosses and pointer gesture through the
|
||||
existing transform signal flow. Leave persistence/lifecycle code alone
|
||||
unless an assertion fails.
|
||||
4. Run Godot headless syntax checks for every changed script and focused
|
||||
headless tests. Per project instructions, use
|
||||
`Godot_v4.7.1-stable_win64_console.exe --headless --check-only --script
|
||||
res://scripts/<changed>.gd --path C:\Godot4\stickman`; if class caches need
|
||||
refresh, run a headless editor scan first, then repeat checks. Fix failures
|
||||
before handoff.
|
||||
|
||||
Acceptance checks:
|
||||
|
||||
- A `200%` document-scale transaction doubles all point coordinates, circle radii, and
|
||||
editable anchor offsets, keeps origin `(0,0)`, and produces one dirty edit.
|
||||
A reshaped circle remains reshaped; style widths, flags, and order remain.
|
||||
- Normalize a 192 × 48 bounds to 96 × 24; a nonzero off-origin center scales
|
||||
proportionally and is not silently centered. Invalid/empty geometry is a
|
||||
no-op. Save/load and autosave/recovery retain exact point and anchor values.
|
||||
- The guide starts hidden with no settings file, can be enabled in all modes,
|
||||
stays aligned with world origin through pan/zoom, survives editor restart
|
||||
through settings, and never enters a `.prop` file or a thumbnail.
|
||||
- In Arrange, **Transform Whole Prop** shows one union outline, four large
|
||||
corners, and a rotation dot. Body drag moves all points and non-origin
|
||||
anchors; with Snap ON, its baseline-derived delta lands on the current grid.
|
||||
Circle radius and anchor angles do not change. Canonical origin stays fixed.
|
||||
- Rotation-dot drag rotates all points and anchor positions about `(0,0)` and
|
||||
adds the same wrapped angle to every editable anchor's `rotation_deg`.
|
||||
Circle radius/primitive state stay fixed, crossing ±180° is continuous, and
|
||||
Ctrl produces 15° increments.
|
||||
- Corner drag retains the verified uniform origin-based resize behavior,
|
||||
including circle radius and anchor positions, without changing anchor angle.
|
||||
- During every gesture, authoritative shape/anchor export stays at the baseline
|
||||
while the union outline, shapes, and visible markers show preview copies.
|
||||
Release performs one matching editor transaction, one `shape_changed`, and
|
||||
one dirty/autosave arm. No-op release and cancellation emit nothing.
|
||||
- Esc cancels an active drag but leaves the tool armed; idle Esc, toggle OFF,
|
||||
mode switch, New, Load, or Clear cancel and disarm without a dirty edit. The
|
||||
tool stays armed after a successful commit. Turning it OFF restores the
|
||||
former selected shape and per-shape Arrange behavior.
|
||||
- Focused headless tests assert numeric point/radius/anchor values, anchor-angle
|
||||
wrapping, snap deltas, signal counts, baseline immutability during preview,
|
||||
resize regression, no-op/cancel behavior, and selection/mode restoration.
|
||||
- Exactly one selected prop exposes four usable crosses. Dragging retains its
|
||||
aspect ratio and pivot, never flips it, changes collision bounds along with
|
||||
visuals, and leaves material mass unchanged. Other object types and multiple
|
||||
selection do not expose scale handles.
|
||||
- Save/reload retains prop scale. PLAY→EDIT restores position/rotation and
|
||||
zeroes velocities while retaining scale; a second PLAY collides at the
|
||||
retained size. Existing rotation, translation, selection, and custom-prop
|
||||
rendering still work.
|
||||
|
||||
## 7. Trade-offs and deferred work
|
||||
|
||||
- Origin-based sizing is predictable for anchor offsets and preserves the
|
||||
fixed `origin`, but an off-origin prop moves relative to its bbox center
|
||||
when resized. **Center Prop** remains the explicit remedy; no auto-centering.
|
||||
- Geometry bounds exclude outline stroke and physics center of mass, matching
|
||||
current Center Prop semantics and debt item #39.
|
||||
- Stage scaling uses one rigid-body root transform and the existing `.scene`
|
||||
scale field. Physics accuracy must be proved in the project engine; a
|
||||
per-instance collider adjustment is a contingency, not a separate user UI.
|
||||
- No document-wide undo is added; debt item #37 remains open.
|
||||
- One visible **Transform Whole Prop** toggle avoids three competing subtools,
|
||||
a hidden modifier key, or a fourth primary mode. Its body/dot/corner mapping
|
||||
mirrors per-shape Arrange. Each completed move, rotate, or resize is one
|
||||
future undo unit; Esc provides cancellation before commit, while existing
|
||||
save/recovery protects committed work until document-wide undo is designed.
|
||||
- Move translates authored geometry and non-origin anchors relative to the
|
||||
fixed canonical pivot. Rotate uses that same pivot and changes anchor
|
||||
orientation as well as position. This consistency benefits runtime mounting,
|
||||
though an off-origin prop may orbit widely; **Center Prop** remains explicit.
|
||||
|
||||
The focused Phase 10a suite and Prop Editor startup smoke test passed, and the
|
||||
user verified the final Prop Editor layout and controls in the Godot Editor.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Phase 11 — Interactive Props and Event Rules
|
||||
|
||||
Status: **approved — implementation complete, awaiting Editor verification** (2026-09-23).
|
||||
|
||||
Source: plans/PHASE_11_PROP_INTERACTION.md, refined through the spec-pipeline architecture round.
|
||||
|
||||
## 1. Accepted decisions
|
||||
|
||||
1. Deliver the work as gated milestones. Milestone 11A adds the interaction foundation, pickup/drop, and their triggers. Milestone 11B adds deterministic aimed throws and the trajectory preview. Milestone 11C adds ragdoll disarm and lifecycle hardening. Each milestone must work before the next begins.
|
||||
2. Valid props are pickup-enabled by default and use authored grip anchors when available. An explicit **can_be_picked_up** capability lets a prop such as a tree opt out. Props without a compatible authored grip use a deterministic bounds-center fallback.
|
||||
3. Throws target an exact point and save the selected arc/flight-time setting. The same solver drives the preview and PLAY release. A finite launch-speed cap and flight-time range reject targets that would require an implausible throw.
|
||||
|
||||
These decisions are requirements for implementation.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### 2.1 Milestone 11A — pickup, hold, drop, and triggers
|
||||
|
||||
- Add the can_be_picked_up asset capability to .prop data and the Prop Editor. Missing values default to true for old .prop files. Existing built-in props remain pickup-enabled; future built-ins can opt out in their library entry.
|
||||
- Carry normalized grip anchors and the pickup capability through PropLibrary, StageSpawner, and PropBlock. Custom props currently lose their anchors at the library boundary; this closes that gap.
|
||||
- Add a dedicated StickmanPropInteraction component under RigComponents. It owns one held prop, the selected hand and grip, saved IK target state, the pickup/approach lifecycle, and release.
|
||||
- Add pick_up_prop and drop_prop queue actions. The queue runner delegates these actions to the component and waits for completion. Auto-approach is a private subgoal that calls the existing horizontal walk_to behavior; it does not insert a visible or saved queue action.
|
||||
- Extend Director action editing/target capture so the actor and a specific prop instance can be selected. Prop identity uses a stage object file ID, not spawn_id, because several instances can share one asset.
|
||||
- Extend StageSceneIO queue serialization to map runtime prop instance IDs to and from scene file IDs. Preserve scene version 1.0 with additive action fields. A missing target drops only the unresolved action with a warning, following the existing unresolved-reference policy.
|
||||
- Add prop_picked_up and prop_dropped trigger types. Emit them from the actual successful state transition, with source = acting StickmanRig and target = affected PropBlock. Drop includes manual release; ragdoll disarm is added in 11C.
|
||||
- Add the trigger choices to TriggerRegistry and RuleEditor. Existing consequences remain the current stickman action set; prop-targeted consequence actions are out of scope.
|
||||
|
||||
### 2.2 Milestone 11B — aimed throw
|
||||
|
||||
- Add throw_prop with a specific prop instance, exact world target, and flight_time/arc setting.
|
||||
- In DIRECT, capture the landing point through the stage's existing target-capture flow and show a live parabolic preview. The action is saved in the actor's queue and runs in PLAY; there is no free-aim input during playback.
|
||||
- Use one pure trajectory solver for preview and release. It uses the release point, target, flight time, and the live 2D gravity vector scaled by the prop's gravity_scale.
|
||||
- Initial tuning envelope: minimum flight time 0.35 s, maximum flight time 1.5 s, and maximum launch speed 900 px/s. Treat these as named tunable constants. Validate finite inputs and reject a target whose required velocity exceeds the speed cap. Do not clamp the velocity, since that would miss the stored target.
|
||||
- The Director preview marks an invalid target clearly and does not commit the action until it is valid. Changing the arc setting re-solves both preview and stored velocity from the same function.
|
||||
- Release uses the computed initial velocity directly as PropBlock.linear_velocity, not as an unscaled impulse. This keeps a selected trajectory independent of material mass. Apply angular spin as a separate bounded setting or deterministic default; it must not alter the linear landing target.
|
||||
- Add prop_thrown, emitted once at the successful physics release. It uses the same source/target direction as the other prop triggers.
|
||||
|
||||
### 2.3 Milestone 11C — ragdoll disarm and lifecycle hardening
|
||||
|
||||
- Before StickmanRagdoll begins building bodies, StickmanRig asks StickmanPropInteraction to release any held prop. The release must happen before the animated body is hidden and before the rig pose is handed to physics.
|
||||
- Transfer a finite linear and angular momentum snapshot from the rig to the released PropBlock. Add a typed snapshot API at the rig/ragdoll boundary if needed; capture values before the ragdoll transition changes the source pose.
|
||||
- Emit exactly one prop_dropped event with a ragdoll release reason.
|
||||
- Release safely on PLAY exit and actor/prop deletion. PLAY exit restores the stage's authored transforms through the existing authored-state snapshot. Invalid references must never leave a prop frozen or an IK marker targeting a freed node.
|
||||
- Cover interruption during approach, reach, windup, or release without duplicate triggers or stale physics state.
|
||||
|
||||
### 2.4 Explicitly deferred
|
||||
|
||||
- Two-hand carries, inventory/equipment slots, multiple held props per rig, melee/use actions, prop-targeted rule consequences, and AI choice of which prop to use.
|
||||
- Slope-aware approach, full obstacle avoidance, or a general 2D path/reach planner. Approach uses the existing horizontal locomotion and reports a clean failure if it cannot reach a valid stand point.
|
||||
- Multiplayer, network replication, persistent physics state, and new scene-file versioning.
|
||||
- A dedicated stage event registry node. SandboxStage remains the event dispatcher and matcher.
|
||||
- Ragdolling a held prop together with a character; a held prop is disarmed into ordinary world physics before ragdoll construction.
|
||||
|
||||
## 3. Existing architecture constraints
|
||||
|
||||
- StickmanRig is the public facade. It owns RigComponents/Locomotion, Ragdoll, and ActionRunner; prop interaction belongs beside those components rather than in the facade or a second stage-only controller.
|
||||
- StickmanActionRunner uses action phases and emits action_started/action_finished. A prop phase must preserve queue order and action lifecycle signals. Internal approach steps must not appear as authored queue rows.
|
||||
- Locomotion is horizontal, feet-based, and exposes walk_to. The pickup stand point is beside the prop bounds on the ground plane. If navigation cannot reach it, the pickup action fails without attaching.
|
||||
- DIRECT authors queue actions while PLAY executes them. Props are frozen for authored editing and simulate in PLAY. Prop actions are authored in DIRECT and remain deterministic in PLAY.
|
||||
- PropAnchorData.compatible_grip_ids() and solve_attachment_transform() already provide hand compatibility and mirror-aware grip alignment. Reuse those helpers. PropAttachPreview is the reference for sampling solved wrist endpoints and converting between the wrist and authored grip.
|
||||
- There is no StageEventRegistry node. TriggerRegistry describes trigger metadata; SandboxStage owns _handle_event(), matching, and reactive action dispatch.
|
||||
- StageSceneIO currently serializes queues without an object-reference map. Rules already encode runtime object references to scene file IDs; queue serialization must gain the equivalent mapping for prop targets.
|
||||
- PropBlock is a RigidBody2D and remains parented under World during a hold. Do not use RemoteTransform2D reparenting for runtime socket attachment.
|
||||
|
||||
## 4. Data model and asset compatibility
|
||||
|
||||
### 4.1 Pickup capability
|
||||
|
||||
Add **can_be_picked_up: bool** to the PropFile in-memory model and additive top-level .prop field:
|
||||
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "Oak Tree",
|
||||
"can_be_picked_up": false,
|
||||
"anchors": {},
|
||||
"shapes": []
|
||||
}
|
||||
|
||||
PropFile.to_file_dict() always writes the boolean. PropFile.from_file_dict() reads only an actual boolean; absent or malformed values become true. Keep FILE_VERSION at 1.0, matching the project's existing additive-field policy. Loading an old file does not dirty it merely because the default was supplied.
|
||||
|
||||
Add a kid-facing “Can be picked up” checkbox to the Prop Editor's general prop settings. It is persisted through normal save, save-as, autosave, recovery, and New. This capability is asset metadata; it does not change the prop's collision, mass, or gravity.
|
||||
|
||||
PropLibrary.make_custom_entry() must preserve can_be_picked_up and normalized anchors. Built-in templates include an explicit value, initially true for crate, ball, plank, and triangle. StageSpawner transfers both fields to PropBlock. PropBlock exposes a typed runtime property with a safe default of true for scenes and callers that do not come through StageSpawner.
|
||||
|
||||
### 4.2 Grip resolution
|
||||
|
||||
For an eligible prop, pickup resolves a hand/grip pair in this order:
|
||||
|
||||
1. Enumerate authored grip anchors compatible with each candidate hand using PropAnchorData.compatible_grip_ids().
|
||||
2. For hand: "left" or "right", choose the compatible grip that requires the least reach for that hand. For hand: "auto", compare compatible hand/grip pairs and choose the shortest valid reach. Break ties deterministically in favor of the right hand, then lexicographic grip ID.
|
||||
3. If no authored grip is compatible, synthesize a runtime-only grip at the center of PropBlock's full visual local bounds, with zero rotation and both hands allowed. Do not serialize this fallback into the asset.
|
||||
4. Reject pickup immediately if can_be_picked_up is false, the prop is invalid/already held, the rig already holds a different prop, or no arm can reach after approach. Report a short Director status message and finish the action cleanly.
|
||||
|
||||
Only one prop per rig and one rig per prop are supported in this phase. The check and hold reservation occur synchronously before the reach animation begins, so two queued actors cannot both acquire one prop.
|
||||
|
||||
## 5. Runtime ownership and interaction lifecycle
|
||||
|
||||
### 5.1 Component boundary
|
||||
|
||||
Add scripts/stickman_prop_interaction.gd and a PropInteraction child to scenes/stickman_rig.tscn under RigComponents. StickmanRig resolves/configures it, exposes narrow public forwarding methods or typed signals where the stage needs access, and invokes release before ragdoll handoff.
|
||||
|
||||
The component owns:
|
||||
|
||||
- A small state machine for idle, approaching, reaching, holding, winding up, and recovery.
|
||||
- Held PropBlock, chosen hand/grip ID, saved IK target/rest pose, and any interpolation timers.
|
||||
- A single release kernel used by drop, throw, ragdoll, mode exit, and object cleanup.
|
||||
- The authoritative attachment transform update and its physics-state snapshot.
|
||||
|
||||
Suggested typed completion contract:
|
||||
|
||||
signal interaction_finished(action: Dictionary, succeeded: bool)
|
||||
signal prop_action_executed(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||
|
||||
Use the project's normal typed-signal style if names differ, but the contract must distinguish failed actions from successful transitions and must emit each transition once.
|
||||
|
||||
### 5.2 Pickup and hold
|
||||
|
||||
1. Resolve and reserve the target prop. If the rig is farther than its current arm reach, compute a stand point adjacent to the prop's left or right bounds, on the ground plane, and call the existing locomotion.walk_to(). Select the side that is reachable and requires less horizontal travel; use a deterministic tie-break.
|
||||
2. When locomotion reports arrival, recompute reach from current bone/wrist positions. If the target is still outside the solver's safe arm reach, fail cleanly rather than repeatedly walking or forcing a stretched IK limb.
|
||||
3. Interpolate the selected IK hand marker toward the authored grip's current world position for the configured reach duration. Preserve the opposite hand target. Cache the pre-interaction hand target once so release restores the actual locomotion/idle pose.
|
||||
4. At reach completion, solve the prop transform from the solved wrist transform and selected anchor with PropAnchorData.solve_attachment_transform(). Freeze the prop, keep it under World, suppress collisions against that rig's animated collision proxies, and latch the hand target.
|
||||
5. While holding, update the prop's global transform from the solved wrist and grip using the same helper. Apply the anchor's z_index_offset relative to the holder while preserving the prop's authored z state for restoration.
|
||||
6. Prop movement follows the hand during locomotion. The prop remains frozen and does not participate in collision contacts while held.
|
||||
|
||||
The exact process callback/order must be selected during implementation by matching the solved wrist update order. The attachment may never lag a frame behind a visible wrist or feed the target marker back into itself.
|
||||
|
||||
### 5.3 Drop and release
|
||||
|
||||
The central release operation accepts a reason and a linear/angular velocity payload. It is idempotent and:
|
||||
|
||||
- Validates and clears the held-prop reference and hand latch.
|
||||
- Restores the cached freeze mode, collision layer/mask/exceptions, z state, and saved gravity/damping values.
|
||||
- Sets final global transform, linear_velocity, and angular_velocity before enabling dynamic simulation.
|
||||
- Restores the selected hand marker smoothly to its cached pose.
|
||||
- Emits the successful release event exactly once after the prop is in a valid world-physics state.
|
||||
|
||||
Manual drop uses the rig's current finite root/locomotion velocity and a neutral angular velocity unless a deterministic small spin is approved during implementation. An interrupted pickup before the hold latch cancels the reach and restores the marker without emitting picked-up or dropped. Stopping a queue after a completed pickup does not silently drop the held prop; only a drop action or a lifecycle release does.
|
||||
|
||||
PLAY exit releases first, then the existing authored-state restore returns prop and rig transforms to the EDIT/DIRECT snapshot. Deleting the held prop clears the hold and hand target without attempting to restore a freed node. Deleting the holder drops the valid prop safely before the rig leaves World.
|
||||
|
||||
## 6. Action and scene-file contracts
|
||||
|
||||
### 6.1 Runtime payloads
|
||||
|
||||
Runtime actions use node instance IDs; an asset path or spawn_id is never used to choose among duplicate prop instances.
|
||||
|
||||
{ "type": "pick_up_prop", "target_prop": 123456, "hand": "auto" }
|
||||
{ "type": "drop_prop" }
|
||||
{ "type": "throw_prop", "target_prop": 123456, "target_position": Vector2(...), "flight_time": 0.9 }
|
||||
|
||||
hand accepts auto, left, or right. flight_time is stored in seconds and validated against the configured bounds. Drop acts on the rig's currently held prop and carries no prop ID.
|
||||
|
||||
### 6.2 Saved payloads
|
||||
|
||||
StageSceneIO maps target_prop through the same scene-object reference tables used by rules:
|
||||
|
||||
{ "type": "pick_up_prop", "target_prop_id": "prop_3", "hand": "auto" }
|
||||
{ "type": "drop_prop" }
|
||||
{ "type": "throw_prop", "target_prop_id": "prop_3", "target_position": { "x": 500, "y": 80 }, "flight_time": 0.9 }
|
||||
|
||||
Queue encode/decode receives the relevant runtime-to-file and file-to-runtime maps. Reject non-finite target coordinates, invalid hand values, non-finite/out-of-range flight times, missing prop references, and a throw target that fails the same solver validation. Invalid actions are skipped with the existing queue warning mechanism; unrelated actions and the rest of the queue remain loadable.
|
||||
|
||||
ActionRegistry, ActionEditor, QueuePanel, stage action capture, and ActionRegistry summaries expose the new actions. The queue editor's prop picker selects an object instance from World and displays its current asset name plus a stable per-scene ordinal so duplicate crates are distinguishable.
|
||||
|
||||
### 6.3 Trigger/event contract
|
||||
|
||||
Register:
|
||||
|
||||
| Type | Source | Target | Firing point |
|
||||
| --- | --- | --- | --- |
|
||||
| prop_picked_up | acting StickmanRig | acquired PropBlock | Hold latch succeeds and the prop is aligned to the wrist |
|
||||
| prop_dropped | acting StickmanRig | released PropBlock | Manual drop or lifecycle disarm completes |
|
||||
| prop_thrown | acting StickmanRig | released PropBlock | Throw velocity is assigned and physics is enabled |
|
||||
|
||||
The event dictionary includes type, source, target, event position, and reason/action data. Rule matching honors exact runtime source and target IDs and wildcard -1 values as it does for existing events. The trigger UI identifies the target kind as prop. prop_picked_up and prop_dropped are added in 11A; prop_thrown is added in 11B. A ragdoll release is prop_dropped with reason ragdoll, not a second new trigger type.
|
||||
|
||||
Emit transitions from StickmanPropInteraction through StickmanRig to SandboxStage. Do not infer pickup/drop/throw from action_finished: failed actions also finish, while lifecycle releases may not have a matching queue action.
|
||||
|
||||
## 7. Throw solver and Director preview
|
||||
|
||||
### 7.1 Solver
|
||||
|
||||
Create one pure helper, owned by StickmanPropInteraction or a small stateless PropTrajectory utility:
|
||||
|
||||
solve_throw_velocity(release_point, target_point, flight_time, gravity_vector) -> result
|
||||
|
||||
For time t, return:
|
||||
|
||||
velocity = (target_point - release_point - 0.5 * gravity_vector * t * t) / t
|
||||
|
||||
The result contains ok, velocity, flight_time, and an error reason. Reject zero/negative time, non-finite vectors, time outside 0.35–1.5 seconds, or velocity magnitude over 900 px/s. Resolve gravity using the current project 2D gravity direction and magnitude multiplied by PropBlock.gravity_scale. Preview and release call this exact helper.
|
||||
|
||||
The initial cap and range are intentionally tied to the project's pixel scale: with standard downward gravity near 980 px/s², a same-height throw at the speed cap has a theoretical range of about 826 px, approximately one current stickman height. The speed cap is the hard physical bound; the flight-time bound prevents long, slow, implausible arcs. Keep both values named and tunable, and show invalidity instead of silently modifying the authored target.
|
||||
|
||||
### 7.2 Playback release
|
||||
|
||||
The throw uses a brief IK windup, then at its release moment:
|
||||
|
||||
1. Sample the current solved wrist transform.
|
||||
2. Solve velocity again from the stored exact target, current release point, flight_time, and live gravity.
|
||||
3. If the result is no longer valid (for example, the actor moved after authoring), abort with a concise message and restore the arm. Do not emit prop_thrown.
|
||||
4. Release through the central kernel, set the computed linear velocity directly, apply the bounded spin, and emit prop_thrown.
|
||||
5. Blend the arm back to its cached rest target.
|
||||
|
||||
Windup/recovery are target-marker tweens rather than a new animation resource or body-rotation system. The plan's approximate 100 ms windup, 150 ms release, and 50 ms follow-through are initial tuning targets, not hard timing compatibility requirements.
|
||||
|
||||
### 7.3 Director capture
|
||||
|
||||
Throw target capture is available while authoring a queue in DIRECT. The reticle shows the solved arc from the current expected release point to the pointer. A compact arc control stores flight_time. Invalid targets use the existing stage feedback/status path, are visibly distinct, and cannot be committed. The saved queue stores target position and flight_time; playback never reads the mouse.
|
||||
|
||||
## 8. Ragdoll and lifecycle order
|
||||
|
||||
StickmanRig.set_ragdoll(true) performs this order:
|
||||
|
||||
1. Ask StickmanPropInteraction for a finite rig momentum snapshot while the animated pose and root movement data are still available.
|
||||
2. Release a held prop through the central release kernel with that snapshot and reason ragdoll.
|
||||
3. Emit prop_dropped once through SandboxStage so matching rules can enqueue their normal reactive actions.
|
||||
4. Continue the existing locomotion handoff and StickmanRagdoll.enter_ragdoll() path.
|
||||
|
||||
The release precedes body hiding and ragdoll construction. If ragdoll construction fails after a release, the prop remains safely released; it is never refrozen or rebound implicitly.
|
||||
|
||||
On PLAY exit, release any held prop before _restore_authored_state(). If queue/actor cleanup occurs while approaching or reaching, cancel the private subgoal and restore marker state. If the actor is removed while holding, drop into World using its last valid transform and finite velocity. All cleanup paths are idempotent.
|
||||
|
||||
## 9. Acceptance and user verification
|
||||
|
||||
### Milestone 11A
|
||||
|
||||
- Old v1.0 .prop assets load pickup-enabled and keep their existing anchors; a saved false capability survives Prop Editor save/load/autosave.
|
||||
- A custom grip anchor reaches the hand with authored rotation, scale, mirror, and allowed-hand rules respected. Props without compatible grips use the deterministic bounds-center fallback.
|
||||
- A non-pickup prop fails with feedback and never freezes. Existing built-ins remain eligible.
|
||||
- Picking a nearby prop reaches, aligns, freezes, and holds it through walking. Dropping restores physics and collision behavior, hand pose, z state, and movement velocity.
|
||||
- A distant target uses one private horizontal walk_to subgoal before reaching. An unreachable target ends cleanly with no queue mutation or stale hold.
|
||||
- Duplicate instances of one asset can be independently targeted, save/reload retains the target instance, and unresolved IDs do not corrupt other queue rows.
|
||||
- Pickup/drop triggers fire once only after successful transitions, identify the correct source and prop, and execute matching rules.
|
||||
- DIRECT only authors the actions; PLAY runs them. PLAY exit restores the authored scene transforms.
|
||||
|
||||
### Milestone 11B
|
||||
|
||||
- The live preview and playback compute the same velocity for the same release point, target, flight time, gravity, and prop scale.
|
||||
- Valid targets land at the stored point when unobstructed within normal physics tolerance. Gravity scale is respected.
|
||||
- Targets outside the speed or time envelope are visibly invalid and cannot be saved. A prop whose release point changes enough during playback to exceed the cap aborts without a throw event.
|
||||
- Throw velocity is mass-independent. prop_thrown fires once at release; no throw event fires on cancellation or invalid playback.
|
||||
|
||||
### Milestone 11C
|
||||
|
||||
- Entering ragdoll releases a held prop before kinematic body hiding and the physics handoff. The prop receives finite rig linear/angular motion and then responds to gravity/collisions.
|
||||
- Ragdoll disarm fires one prop_dropped event with reason ragdoll.
|
||||
- PLAY exit, deleted prop, deleted holder, queue cancellation, and invalid/stale references leave no frozen prop, dangling target, duplicate event, or lost authored state.
|
||||
|
||||
Manual verification takes place in the Godot editor and Sandbox Stage. Implementation must run the skill-prescribed headless per-script syntax check for changed scripts. Documentation/README updates are deferred until the user confirms: “Feature verified and working.”
|
||||
|
||||
## 10. Trade-offs and known limits
|
||||
|
||||
- The internal approach is horizontal because that is the existing locomotion contract. It will not find a route around an obstacle; it fails cleanly when the current navigation cannot reach the chosen stand point.
|
||||
- A bounds-center fallback keeps built-ins and older props usable without inventing saved anchor data. Authors can add explicit grip anchors for better hand placement.
|
||||
- One held prop per rig keeps state ownership simple. Two-hand grips are authored metadata but simultaneous two-hand solving remains deferred.
|
||||
- A speed cap plus maximum flight time makes aiming deterministic and prevents arbitrary-distance throws. The initial cap is a tuning value that should be adjusted only against the visible stage/rig scale.
|
||||
- Keeping the prop under World avoids fragile RemoteTransform2D paths and reparenting transforms, but requires a correctly ordered transform update from the solved wrist.
|
||||
- Trigger matching is edge-triggered at the semantic transition. Rules may enqueue reactive stickman actions; this phase does not add prop manipulation as a rule consequence.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0vrrhknni6hi
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4e5j6k0jm0hw
|
||||
@@ -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)))
|
||||
@@ -0,0 +1 @@
|
||||
uid://qpqqxm4acs2g
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ofaxq8iw1n1c
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.venv/
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user