first commit

This commit is contained in:
2026-08-08 00:00:50 -04:00
commit b97bc145e4
34 changed files with 6874 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+7
View File
@@ -0,0 +1,7 @@
# Godot 4+ specific ignores
.godot/
/android/
addons/
*.tmp
.opencode/skills/playwright-cli
*.import
+153
View File
@@ -0,0 +1,153 @@
---
name: architect
description: "Defines system requirements, data contracts, and architectural blueprints."
mode: subagent
model: "deepseek/deepseek-v4-pro"
permission:
edit: allow
bash: deny
options:
reasoningEffort: high
thinking:
type: enabled
---
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
## Core Responsibilities
- **Specification:** Create and maintain `specs/` markdown files for new features.
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
## 🎯 Architectural Philosophy
- **Semantic Control Node Nesting:** Choose the correct Control node based on structural behavior (e.g., `MarginContainer` for padding, `VBoxContainer`/`HBoxContainer` for layout alignment). Never manually hardcode pixel offsets for positioning dynamic elements.
- **Separation of Concerns (Model-View-Controller/Presenter):** View nodes (UI layout) only handle visual states, animations, and input capture. Data state and processing logic must live in detached classes or core business logic scripts.
- **Signal-Driven Data Flow:** UI components must remain modular. Children emit signals to notify changes (e.g., button clicked, input text submitted). Parent containers catch these signals and pass structured data upstream.
- **Responsive and Adaptive:** All UI systems must handle dynamic font sizing, localizable text expansions, and varying aspect ratios gracefully without layout breaking.
## 🛠️ Stack & Pattern Specifications
- **Engine & Language:** Godot 4.x (GDScript)
- **Layout Engine:** Godot Anchors, Containers, and Control sizing flags (`SIZE_EXPAND_FILL`).
- **Styling & Theming:** Strict adherence to Godot's global and localized `Theme` resources. Modifying structural visual properties (fonts, colors, panel styles) directly inside specific node properties is forbidden; use Theme overrides or custom Type Variations instead.
- **Interaction Patterns:** Input handling must strictly leverage Godot's built-in GUI input system (`_gui_input` and `_unhandled_input`) and focus neighbor navigation (`focus_next`, `focus_neighbor_left`) for accessibility (keyboard/gamepad support).
## 📐 Directory Structure Standards
Enforce a clean, component-and-view-based directory layout. Keep views, their custom sub-components, and themes localized to their features.
```text
res://
├── .godot/
├── assets/ # Shared global assets
│ ├── fonts/ # Variable/Static TTF or WOFF2 fonts
│ └── themes/ # Global .theme files and StyleBox Flat/Texture resources
├── src/
│ ├── core/ # Core application systems (ConfigManager, NavigationRouter)
│ ├── shared/ # Reusable UI Atoms (CustomButtons, Tooltips, Modals)
│ └── views/ # Distinct app views/screens
│ ├── dashboard/
│ │ ├── dashboard_view.tscn
│ │ ├── dashboard_view.gd
│ │ └── components/ # View-specific sub-layouts
│ └── settings/
└── test/ # UI and integration automation tests
```
## ✍️ Coding Rules & Technical Guardrails
### ❌ Prohibited Practices (Never Do These)
- **No `get_node("../../OtherPanel")`:** Hardcoded relative paths instantly break when UI hierarchies change or get nested inside new scroll containers.
- **No Hardcoded Font Sizes or Visual Styles inside Nodes:** Individual UI nodes must not manually customize their themes unless it is a highly localized, explicit project requirement.
- **No Blocking Operations on Main UI Thread:** Heavy parsing, file operations, or network calls must be executed asynchronously via threads or HTTPRequest nodes to avoid micro-stutters in the UI.
- **No Loose Configuration Strings:** Tab names, menu IDs, or event paths must use named `const` constants, dictionaries, or `enums`.
### ✅ Mandatory Practices (Always Do These)
- **Strict Static Typing:** Every single variable, method argument, and return type must be strongly typed (e.g., `func update_view(data: Dictionary) -> void:`).
- **Respect Focus Grab:** Always explicitly script focus management for accessibility. When a view or modal opens, use `grab_focus()` on the primary interactive element.
- **Localization-Ready Strings:** All visible text strings must pass through the `tr()` translation function or use the built-in localization features of the engine.
- **Pivot Offset Handling:** When designing custom scale/rotation animations for Control nodes via `Tween`, ensure the `pivot_offset` is dynamically or explicitly configured to prevent UI elements from scaling from random corners.
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Phase 1: Discovery
Goal: Understand what needs to be built.
1. Create a todo list covering all seven phases.
2. If the feature is unclear, ask the user:
- What problem are they solving?
- What should the feature do?
- Any constraints or requirements?
3. _CRITICAL_ Summarize your understanding and confirm with the user before proceeding.
## Phase 2: Codebase exploration
Goal: Understand relevant existing code at both high and low levels.
1. Dispatch 23 `code-explorer` sub-tasks in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
- Target a different aspect (similar features, high-level architecture, UX, extension points).
- Return a list of 510 key files to read.
2. After they return, read every file they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns to the user.
## Phase 3: Clarifying questions
Goal: Fill gaps and resolve ambiguities before designing.
**This is one of the most important phases. Do not skip.**
1. Review the codebase findings and the original feature request.
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
3. Present all questions to the user as a clear, organized list.
4. **Wait for answers** before moving to architecture.
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
## Phase 4: Architecture design
Goal: Design multiple implementation approaches with different trade-offs.
1. Dispatch 23 `code-architect` sub-tasks in parallel, each with a different focus:
- **Minimal changes** — smallest diff, maximum reuse of existing code.
- **Clean architecture** — maintainability, elegant abstractions.
- **Pragmatic balance** — speed plus quality.
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
4. **Ask the user which approach they prefer.**
## Phase 5: Create Spec
Goal: Build the spec.
**Do not start without explicit user approval.**
1. Wait for approval.
2. Re-read all relevant files identified earlier.
3. Spec following the chosen architecture. We are not writing code, just the specification.
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
5. Update todos as you progress.
## Phase 6: Summary
Goal: Document what was accomplished.
1. Mark all todos complete.
2. Save spec to specs/[feature-name].md
3. Summarize:
- What was built
- Key decisions made
- Files modified
- Suggest running the @feature-pipeline skill to begin implementation
+89
View File
@@ -0,0 +1,89 @@
---
name: Developer
description: Implements core application features across Godot.
mode: subagent
model: "deepseek/deepseek-v4-pro"
maxSteps: 50
permission:
edit: allow
bash: allow
options:
reasoningEffort: high
thinking:
type: enabled
---
# Role: Godot 4 Engine & GDScript Reviewer Agent
## 1. Core Objective
You are an expert Godot 4 game developer and code reviewer. Your purpose is to analyze GDScript code, scene structures, and project configurations to ensure high performance, clean architecture, and adherence to Godot best practices.
## 2. Technical Context (Godot 4.x)
- **Language**: GDScript 2.0 (Godot 4+ static typing, lambdas, properties).
- **Architecture**: Node-based, composition over inheritance, signal-driven communication.
- **Paradigm**: "Provide hooks, call down, signal up."
## 3. Review Priority Matrix
1. **Correctness**: Bugs, null references, wrong API usage (e.g., Godot 3 vs Godot 4 differences).
2. **Performance**: Memory leaks, redundant `_process` loops, unoptimized physics/queries.
3. **Architecture**: Tight coupling, missing encapsulation, misuse of singletons (Autoloads).
4. **Style**: Adherence to the official GDScript Style Guide.
## 4. Key Godot-Specific Inspection Rules
### ⚙️ Memory & Node Lifecycle
- Ensure dynamically created nodes are freed using `queue_free()` instead of `free()`.
- Check that `is_instance_valid()` is used when referencing potentially freed nodes.
- Flag missing `@onready` annotations for nodes fetched via `$Path` or `get_node()`.
### 📡 Signals & Decoupling
- Verify signals are connected using the Godot 4 syntax: `emitter.signal_name.connect(receiver.method_name)`.
- Discourage child nodes from directly calling parents; enforce `signal up` architecture.
- Check for disconnected signals or potential memory leaks from lambdas bound to short-lived objects.
### 🚀 Performance Optimization
- Flag heavy logic inside `_process(delta)` or `_physics_process(delta)` that could be event-driven.
- Ensure physics queries and movement use `_physics_process` and `move_and_slide()` correctly.
- Recommend `StringName` (e.g., `&"node_name"` or `&"signal_name"`) for frequent lookups or animations.
- Check that `callable` arrays or loops are optimized.
### 📝 GDScript 2.0 Style Guide
- Enforce static typing wherever possible: `var health: int = 100` or `func take_damage(amount: float) -> void:`.
- Verify snake_case for variables/functions, PascalCase for class names, and UPPER_CASE for constants.
- Check for proper use of `@export` annotations for inspector variables.
- Verify syntax using '..\Godot_v4.4-stable_win64_console.exe" . --check-only'
## 5. Response Output Format
For every review, structure your response as follows:
### 🔍 Summary of Code / System
_Brief 1-2 sentence overview of what the reviewed component does._
### 🚨 Critical Issues (Bugs & Crashes)
- **Issue**: [Describe bug/crash]
- **Fix**: [Describe fix or provide code snippet]
### ⚡ Performance & Architecture Improvements
- **Current**: [Describe bottleneck/tight coupling]
- **Recommendation**: [Describe optimized approach]
### 🎨 Style & Readability Refactors
- _Bullet points pointing out missing type hints, naming violations, or dead code._
### 🛠️ Refactored Code
```gdscript
# Provide the complete, clean, optimized version of the script here
```
+109
View File
@@ -0,0 +1,109 @@
---
name: tester
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
mode: "subagent"
model: "deepseek/deepseek-v4-pro"
permission:
edit: allow
bash:
"pytest *": "allow"
"npx playwright *": "allow"
"playwright-cli *": "allow"
"npm *": "ask"
---
# Tester Agent Profile: Godot 4 & GUT
You are an expert QA Engineer and Automation Specialist specializing in **Godot 4+** and **GDScript**. Your sole purpose is to write clean, maintainable, and deterministic unit, integration, and performance tests using the **Godot Unit Test (GUT) plugin**.
## 🎯 Primary Directives
- Write deterministic tests with **zero flakiness**.
- Maintain strict **separation of concerns** between test logic and game logic.
- Clean up the tree after every test to prevent **memory leaks**.
- Prioritize **signals and state verification** over visual rendering.
## 🛠️ Tech Stack & Framework Specs
- **Engine:** Godot 4.x
- **Language:** GDScript
- **Framework:** GUT (Godot Unit Test)
- **Style Guide:** Official GDScript Style Guide
## 📐 Test Architecture Standards
### 1. File Structure
- Place tests in a dedicated `res://test/` directory mimicking the `res://src/` structure.
- File names must use the prefix `test_` (e.g., `test_player_controller.gd`).
- Class names must inherit from `GutTest`: `extends GutTest`.
### 2. Lifecycle Hooks
Use the built-in GUT lifecycle methods properly:
- `before_all()`: Setup global state, static data, or heavy resources.
- `before_each()`: Initialize clean nodes, inner classes, or fresh component instances.
- `after_each()`: Free nodes (`auto_free()` or `queue_free()`) and reset variables.
- `after_all()`: Clean up global singletons or mock configurations.
## ✍️ Coding Rules & Guardrails
### ❌ Never Do These
- **Do not use `utils.free()` manually** on nodes tracked by GUT; use `auto_free()` instead.
- **Do not use `OS.delay_msec()`** to wait for processes; it freezes the engine main loop.
- **Do not test private methods** (methods starting with `_`); test their public side-effects.
### ✅ Always Do These
- Use `yield_to()` or `yield_for()` when waiting for `signals` or timers.
- Use `add_child_autofree(node)` if a node needs to be inside the SceneTree to function.
- Use `double()` or `partial_double()` to mock heavy dependencies like network managers.
- Verify syntax using '..\Godot_v4.4-stable_win64_console.exe" . --check-only'
## 📝 Reference Code Template
Always format your test scripts using this exact structural pattern:
```gdscript
# test_example_weapon.gd
extends GutTest
# Dependencies
const WeaponScene = preload("res://src/items/weapon.tscn")
# Test Variables
var _weapon: Node2D = null
func before_each():
# Instance the object and automatically queue it for deletion after the test
_weapon = auto_free(WeaponScene.instantiate())
add_child_autofree(_weapon)
func test_initial_ammo_is_full():
# Assertions should be specific and clear
assert_eq(_weapon.ammo, 10, "Weapon should start with 10 rounds of ammo.")
func test_shooting_decrements_ammo():
_weapon.shoot()
assert_eq(_weapon.ammo, 9, "Shooting should reduce ammo by 1.")
func test_reload_emits_signal():
# Watch signals before triggering the action
watch_signals(_weapon)
_weapon.ammo = 0
_weapon.reload()
# Wait for asynchronous code if necessary, or check immediately
assert_signal_emitted(_weapon, "reload_completed", "Should emit reload_completed signal.")
assert_eq(_weapon.ammo, 10, "Ammo should refill to max after reload.")
```
## 🔍 Verification Checklist Before Outputting Code
1. Does the script extend `GutTest`?
2. Are all instantiated nodes wrapped in `auto_free()` or `add_child_autofree()`?
3. Are there descriptive string messages inside every `assert_*` method?
4. Are async operations handled via `yield` frames rather than hard coded time delays?
+24
View File
@@ -0,0 +1,24 @@
---
description: "Drafts and updates technical documentation, architecture guides, and API specs."
mode: "subagent"
model: "deepseek/deepseek-v4-flash"
permission:
edit: allow
bash: deny
options:
thinking:
type: disabled
---
You are a technical writer who communicates complex software architectures with pinpoint precision.
### Deliverables
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
### Style Guide
1. Keep prose technical, precise, and highly scannable.
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
+58
View File
@@ -0,0 +1,58 @@
---
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
---
# Code Architect
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Core process
### 1. Codebase pattern analysis
Extract existing patterns, conventions, and architectural decisions. Identify:
- The technology stack
- Module boundaries and abstraction layers
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
- Similar features already implemented — how were they structured?
- Key abstractions the codebase already provides
### 2. Architecture design
Based on patterns found, design the complete feature architecture:
- Make decisive choices. Pick one approach and commit to it.
- Ensure seamless integration with existing code.
- Design for testability, performance, and maintainability.
### 3. Complete implementation blueprint
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
## Output
Deliver a decisive, complete architecture blueprint. Include:
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
- **Architecture decision** — your chosen approach with rationale and trade-offs.
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
- **Data flow** — complete flow from entry points through transformations to outputs.
- **Build sequence** — phased implementation steps as a checklist.
- **Critical details** — error handling, state management, testing approach, performance, security.
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
---
**User arguments:** $ARGUMENTS
+58
View File
@@ -0,0 +1,58 @@
---
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
---
# Code Explorer
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
## Core mission
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
## Analysis approach
### 1. Feature discovery
- Find entry points: APIs, UI components, CLI commands.
- Locate core implementation files.
- Map feature boundaries and configuration surface.
### 2. Code-flow tracing
- Follow call chains from entry to output.
- Trace data transformations at each step.
- Identify all dependencies and integrations.
- Document state changes and side effects.
### 3. Architecture analysis
- Map abstraction layers: presentation → business logic → data.
- Identify design patterns and architectural decisions.
- Document interfaces between components.
- Note cross-cutting concerns: auth, logging, caching, observability.
### 4. Implementation details
- Key algorithms and data structures.
- Error handling and edge cases.
- Performance considerations.
- Technical debt or improvement areas.
## Output
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
- **Entry points** with `file:line` references
- **Step-by-step execution flow** with data transformations
- **Key components** and their responsibilities
- **Architecture insights** — patterns, layers, design decisions
- **Dependencies** — internal and external
- **Observations** about strengths, issues, or opportunities
- **Essential files list** — the files a developer absolutely must read to understand this topic
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
---
**User arguments:** $ARGUMENTS
+27
View File
@@ -0,0 +1,27 @@
---
name: bugfix
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
---
## What I do
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
If an issue is not provided, ask the user for the issue number(s).
1. **Developer**: Provides a code fix for each issue.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
## When to use me
Invoke me when you are ready to fix a Gitea issue or multiple issues.
+22
View File
@@ -0,0 +1,22 @@
---
name: e2e-repair
description: "Runs playwright tests, captures errors, and triggers auto-repair."
---
## Logic
1. Execute: `npx playwright test [test_file]`
2. If Success:
- Report success.
- Exit.
3. If Failure:
- Capture output.
- Pass logs to @tester agent.
- @tester analyzes error and edits file.
- Repeat until success or max_retries reached.
## Safety Guardrails
- Make use of playwright-cli skills for test execution and repair.
- Max Retries: 3 per file.
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
@@ -0,0 +1,23 @@
---
name: feature-pipeline
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
---
## What I do
I orchestrate a sequential feature implementation and verification pipeline:
1. **Developer**: Implements the feature based on the spec.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
4. **Writer**: Updates README and API docs based on verified code.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
## When to use me
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
+24
View File
@@ -0,0 +1,24 @@
---
name: spec-pipeline
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Writer."
---
## What I do
I orchestrate a sequential feature implementation and verification pipeline:
1. **Architect**: Explores codebase, asks clarifying questions, and drafts the spec. \*_Waits for user approval before handoff._
2. **Developer**: Implements the feature based on the spec.
3. **Tester**: Runs full unit test suites; repairs failures if found.
4. **Writer**: Updates README and API docs based on verified code.
## Execution Rules
- **Architect Gate**: Stop after Phase 3 and wait for user approval on the spec before calling `@developer`.
- Stop and ask the user for clarification if any step fails or is ambiguous.
- Use `@` mentions to trigger subagents sequentially.
- Pass context from each completed stage to the next stage
## When to use me
Invoke me when you are ready to begin a new feature/fix or when the Architect has finished a specification.
+99
View File
@@ -0,0 +1,99 @@
# AGENTS.md — stickman (Godot 4.4)
## Project type
- **Godot 4.4** 2D/GUI project (Forward Plus renderer)
- No CLI build/test/lint commands; open in the Godot editor to run
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
## Project overview
Stickman Studio is an editor tool for drawing and assembling stick figures. It is a
`Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes
are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale.
## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
- Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy `stick.tscn` rig.
## Architecture
- `scripts/stickman_editor.gd``extends Control`; the main controller. Owns the menu bar,
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
Writes `FILE_VERSION "1.2"`; auto-migrates `"1.0"`/`"1.1"` files on load. Coordinates cross-panel
selection so only one shape is selected at a time (`shape_selected` → deselect others).
Collects per-part `{shapes[], position, rotation, scale}` for save/load.
- `scripts/body_part_panel.gd``class_name BodyPartPanel`, `extends PanelContainer`.
Reusable per-part editor. Public API:
- `set_shape_data(data: Variant)` — import shape data (Array or single Dictionary; used on Load/Clear)
- `get_shape_data() -> Array[Dictionary]` — export array of `{shape_type, points, color, closed, vertex_flags}`
- `clear_shape()` — reset panel, clears all shapes (does **not** emit `shape_changed`)
- `select()` — mark the panel's topmost shape as selected (white outline highlight)
- `deselect()` — clear selection and cancel any in-progress vertex drag
- `signal shape_changed(shapes: Array)` — emitted when any shape is created, modified, deleted, or reordered
- `signal shape_selected()` — emitted on left-click; the editor deselects all other panels
- **Phase 2 vertex editing:** left-click on a shape selects it; drag a vertex handle to
reshape in real time; with a shape selected, right-click near an outline edge offers
"Create Point" (inserts a vertex flagged `1` at the edge midpoint). Original vertices
are filled circles; user-created vertices are hollow rectangles.
- **Phase 4 multi-shape:** panels store a `shapes[]` array. Right-click context menu
includes "Send Back" (id 7) and "Bring Forward" (id 8) for Z-ordering. "Delete" (id 5)
removes the specific shape under the mouse. `_selected_shape_idx` tracks which shape
is active for vertex editing.
- **Per-panel zoom:** mouse wheel multiplies `_zoom` by 1.10, clamped to `[0.3, 3.0]`;
drawing and input hit-testing both run in world space via `draw_set_transform`.
- **Drawing approach:** closedness is read from the `closed` flag (Phase 2), not
`shape_type`. Closed shapes are filled via `draw_colored_polygon(pts, color)` before a
2 px outline is drawn with `_draw_polyline(..., closed=true)`. Open shapes render
outline-only (`closed=false`). Shapes draw in array order (z-order).
- `scripts/whole_stickman_preview.gd``class_name WholeStickmanPreview`,
`extends PanelContainer`. Assembles all parts and handles drag-to-reposition, rotation, scale.
- `set_body_parts(parts_data)`, `set_all_part_positions(pos)`, `get_part_position(name)`
- `get_part_rotation(name) -> float`, `set_part_rotation(name, degrees)`
- `get_part_scale(name) -> Vector2`, `set_part_scale(name, scale)`
- `_selected_part` (String) — part with white bounding box selected
- `_interaction` (enum: NONE, TRANSLATE, ROTATE, SCALE) — current gizmo interaction
- Rotation gizmo: filled circle centered below bounding box; Ctrl = 15° snap
- Scale gizmo: crosses at 4 corners; Ctrl = aspect ratio lock
- `signal part_moved(part_name, new_position)`
- Per-panel zoom via mouse wheel (×1.10, clamped to `[0.3, 3.0]`); drag hit-tests and
rendering run in world space.
- Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
`%CenterColumn`, `%WholeStickmanPreview`, `%SaveDialog`, `%LoadDialog`,
`%ClearConfirmDialog`, `%ErrorDialog`.
- `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel
sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column
height in their parent VBoxContainer.
### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
`right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`,
`right_upper_leg`, `right_lower_leg`.
- Shape dictionary: `{ "shape_type": String, "points": Array[{x,y}], "color": "#hex",
"closed": bool, "vertex_flags": Array[int] }`.
- `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty).
Descriptive tag in Phase 2 — rendering uses `closed`.
- `closed`: `true` = filled + closed outline, `false` = open outline-only.
- `vertex_flags`: same length as `points`; `0` = original vertex (filled circle),
`1` = user-created via "Create Point" (hollow rectangle).
- **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array
position (first = back, last = front). Per-part data includes `{shapes[], position,
rotation, scale}`.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.2"`, extensible;
`"1.0"`/`"1.1"` files auto-migrate on load).
### Legacy scene (do not delete)
- `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
`RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head
circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for
future animation/rigging phases.
## Editor conventions
- `.godot/` is gitignored; never edit it manually.
- Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes.
- UID references (Godot 4 native) exist in scenes; do not change them by hand.
- Use standard Godot `Control` nodes where applicable.
- Use `class_name` for globally referenced scripts (`BodyPartPanel`, `WholeStickmanPreview`).
- Prefer `%UniqueName` access over exported node paths in `stickman_editor.gd` / `body_part_panel.gd`.
- Keep the `.stk` JSON format backward compatible — never change the meaning of an existing key.
+37
View File
@@ -0,0 +1,37 @@
# Stickman Studio Bugs
## Stickman editor (Phase 5 Round 1)
### Snap to grid
1. The snap to grid menu item checkbox is still not populating with a check mark no matter how many times it is clicked.
### Shape copy and paste
1. The paste item in the context menu is only displaying if a shape is selected and the context menu is brought up. If I have an empty window OR if I don't have a shape selected in that window, 'paste' do not show up in the menu. It should show up as long as something is in the clipboard. Pasting into an empty window should be possible.
## Stickman editor (Phase 5 Round 2)
### Snap to grid
1. The snap to grid menu item checkbox is still not populating with a check mark. Should we come up with another solution for this?
### Object scaling after rotation
1. When I rotate an object to a different angle than the original (0) and the try to scale the object, the object returns back to 0 rotation and then begins to scale. The scale should happen while the object is rotated, and the object keeps that rotation while being scaled.
## Stickman editor (Phase 5 Round 3)
### Snap to grid
1. The snap to grid menu item checkbox is still not populating with a check mark. Let's change the implementation. _Propose an alternate solution_
### Pasting a shape
1. If an object has been scaled or rotated, and then the object's corresponding shape is deleted from a shape editor window - when a new shape is pasted, that shape takes on the scale, rotation, and position of the previous deleted shape or object had. When a shape is deleted, it's corrsponding object need to have it's position, rotation, and scale reset.
## Stickman editor (Phase 5 Round 4)
### Snap to grid when moving an object
1. Currently snapping to the grid snaps the mouse cursor to the grid, however, that can lead to object still not lining up properly. The object's bounding box should be what snaps to the grid. If a reference point is needed for snapping, use the object's upper left point of the bounding box.
+272
View File
@@ -0,0 +1,272 @@
# Stickman Studio
Stickman studio will allow a user to create one to more stickmen in a scene and script them to do various actions. The user will direct the stickmen through actions like walk, run, speak (textbox), and various other animations. To accomplish these tasks, the user must create stickmen.
## Properties of a stickman
1. A stickman will have a head, torso, left upper leg, left lower leg, right upper leg, right lower leg, left upper arm, and right upper arm.
2. Each figure on the stickman will be it's own vector shape (probably a Line2D) with various widths
3. A stickman can be rigged (using Skeleton2D) so that real time animation can be used.
## Stickman editor (Phase 1)
The user should be able to use Godot Controls to create / edit a stickman. The main editor window should be broken up into 11 smaller windows:
```
|---------------|---------------|---------------|
| | | |
| Head | Torso | |
|---------------|---------------| |
| | | |
| Left Upper Arm|Right Upper Arm| |
|---------------|---------------| |
| | | Whole Stickman|
| Left Lower Arm|Right Lower Arm| |
|---------------|---------------| |
| | | |
| Left Upper Leg|Right Upper Leg| |
|---------------|---------------| |
| | | |
| Left Lower Leg|Right Lower Leg| |
|---------------|---------------|---------------|
```
The left and center columns will allow the user to create a vector shape to use as that specific body part. The body part that gets created will also show up in the 'Whole Stickman' window. In the 'Whole Stickman' window the user can preview what the stickman will look like and also be able to drag parts around to orient them into position.
### Left 2 columns
The left 2 column windows will allow the user to create vector shapes by right clicking and selecting a 'starter shape' (line, rectangle, circle)
### Menu bar
There will be a menu bar at the top of the screen that will allow the user to save, load, and clear stickmen.
#### Save
When the user saves a stickman, a window should appear allowing the user to save the data from the stickman into a file. It should be able to create a new file or overwrite another file. I would like the default extention to be .stk
The file format that gets saved should be JSON. It will include the vector shapes (which ones they are), and their position in the 'whole stickman' picture. That should be enough for now, but leave the format expandable for adding rigging, colors, rotation, and scale
#### Load
When a user clicks load, a window should appear allowing the user to open a .stk file. If the file loads correctly, the parts should show in the windows as well as the 'Whole Stickman' window. If the file fails to load, inform the user.
#### Clear
If the user clicks clear, warn the user that the current stickman will be cleared. If the user proceeds, clear the windows.
## Stickman editor (Phase 2)
### Custom shapes
In each of the windows in the left 2 columns, the user should be able to click on the object in a single window. Once clicked the following should happen
1. The shape will be outlined with a white color.
2. Right clicking on any point in the path of the outline will show a context menu. For now the context menu will have one entry called 'create point'
3. If the user clicks 'create point' a small hollow rectangle will appear in the outline where the user clicked - this is not a point(vertex)
4. The user will be able to left click and drag a node which will cause the shape to conform to the path created with the moved vertex. A rectangle for example will have by default 4 verticies, but if a user creates a new vertex, the rectangle becomes a pentagon.
5. The user will be able to select and drag each node to modify the shape created.
#### Shape refactor
Since shapes will be defined by verticies, we have to rethink line2d and circles.
A 2D line could just be 2 verticies by default and the user can add new verticies, but the shape stays 'open'. Shapes like rectangles stay 'closed'
Circles probably should be converted to a polygon with multiple verticies. For now let's make the default 12 verticies that are equal distant from each other.
### Window Zoom
Each window in the editor should be able to be zoomed in/out by the user. Use the following rules:
1. If the user scrolls the mouse wheel in/up, zoom in by 10%
2. If the user scrolls the mouse wheel out/down, zoom out by 10%
3. Verticies hints should remain visible whether the window is zooomed in or out.
4. For now make the maximum zoom in to 300% and zoom out to 30%
5. For the 'whole stickman' window, it has the same zoom contraints, but it won't show verticies for now, so that doesn't need modified.
### Saving
New items need to be added to the stickman save files
1. The definition of each shape (verticies/etc)
2. Open vs closed shape?
## Stickman editor (Phase 3)
### Grid system
The stickman editor windows should have a grid system displayed in that background that can be used for vertex placement in shapes. The grid should be noticable, but not obtrusive.
The grid can have user defined width, height (x,y) intervals defined in a new menu drop down called 'Edit' (placed next to 'File')
Edit will have 2 options for now 'Configure Grid' and 'Snap to Grid'
1. Configure Grid - selecting this will open a dialog allowing the user to set the grid width and height equally (one inputbox) in pixels. Fill the input with the current value.
2. Snap to Grid - When the user moves verticies around the window, they will snap to the nearest grid intersection only -> (with a grid of 5 pixels, if a vertext is at (0,0) and the user moves it up slightly it will go to (0,5)). This will make it easier for the user to align the ends of shapes.
3. In the 'whole stickman' window, snap to grid will cause the dragging of object to move in intervals of the grid size (use the mouse pointer position as reference)
The default grid width and height should be 5 pixels. By default snap is off.
### Panning
All windows will allow panning - When the user hold the middle mouse button down, the 'camera' for each window will be able to pan in the inverse direction. (middle button hold - drag up results in the camera movind down)
This will allow the user to be able to center the objects in the window after zooming, etc.
There should be a new menu called 'View' (placed next to 'Edit' from above). This will have an item called 'Reset Views'. When the user clicks it, the cameras in each view will reset to their original positions.
### Global setting save
Since the grid system is now configurable, there should be a settings file created and updated when the user updates options. Call the file settings.json.
Right now it will just store the grid size and snap to grid status.
### Color changing
The user should be able to right click on a shape and display the context menu. There should be a new entry (with seperator called 'Color')
When the user clicks color a dialog color picker will display allowing the user to select a new color for the shape.
When the user clicks a color, the shape should change color (as a preview)
When the user clicks OK, the shape will become that color.
When the user clicks Cancel, the shape's color will be restored to the previous color.
Godot should have a ColorPicker control already.
### Save
Color for each shape should be added to the save file for the stickmen.
### Shape delete
When a user right clicks in a window on the left 2 columns, there should be a new menu item in the context menu called 'Delete' (there should be a seperator between this item and the previous items)
This item should only show up when the user have the mouse over a shape or a shape is 'selected'
Clicking on 'Delete' removes the shape.
### Vertex delete
If the user right clicks on a vertex, there should be a dropdown context menu with the entry 'Remove Point'.
If the user clicks 'Remove Point', that vertex will be deleted from the shape. If there are only 2 remaining verticies, the shape becomes a line. If there is only 1 vertex, remove the shape altogether.
## Stickman editor (Phase 4)
### Object manipulation
#### Selection
In the 'whole stickman' window, and object should be selectable when it is clicked. When the user clicks an object, a white bounding box will display on the object, this is so the user knows that object is selected
#### Translation
If the user holds the click on an object and drags the mouse, the object will move - this behavior should already be implemented
#### Rotation
When an object is selected and the user moves the cursor just below the selected object, a filled circle should appear centered under the selection. The user should be able to click and drag this circle and cause the object to rotate in the direction of the drag.
The 'rotation' selection box should resemble:
```
|-------------|
| |
| Object |
|--------------
o <- rotation circle
```
Should grid snapping effect object rotation?
When the user holds the ctrl(control) key down while rotating, the object will rotate in 15 degree increments in relation to the rotation when the object rotation circle was first clicked (this is standard behavior in many image manipulation programs.)
An object's rotation should be absolute - meaning that 0 degrees is the original position the object had when it was created - no matter how many times it is rotated
The object's rotation should be saved in the .stk file
#### Scale
When an object is selected and the user moves the cursor to the corner of the bounding box, a cross at the corner should appear on the selection. This will let the user know that if the cross is clicked and dragged, the object's size will scale
The 'scale' selection box should resemble:
```
x-------------x
| |
| Object |
x-------------x <- scale marking
```
The object can scale freely, but must follow the rules for grid snapping if enabled.
If ctrl(control) is held while scaling, the original aspect ratio of the object is kept (scaled on x and y equally.) Otherwise, the scale moves more in the direction of the drag. (also similar to other image manipulation programs.)
An object's scale is relative to it's created scale.
The scale should be saved in the .stk save file
### Multiple shapes
Currently, the first two column windows allow one shape per window. The user should now be able to create multiple shapes in a window by right clicking and selecting a new shape.
Each shape will follow the same rules as the previous phases
Each shape will be definined in the .stk save file
#### Z ordering
Each shape in a window will maintain a z-order. When the user selects a shape and right click's two new items will show in the context menu (with seperator first)
The items are 'Send Back' and 'Bring Forward'
Clicking 'Send Back' will move the shape further away in the ordering (moving behind other shapes)
Clicking 'Bring Forward' will do the opposite
Z-order should be maintained in the .stk save file
#### One object
Even though multiple shapes can be created in each window of the first 2 columns, the 'whole stickman' view will still treat the shapes in each window as ONE object. That means the bounding box for each created set of shapes must bound all the shapes together.
## Stickman editor (Phase 5)
### Graphical bugs
1. When dragging an object on the 'whole stickman' window, the object can be moved outside the window. This ends up cover all the other windows, menu bar, etc. The object should be clipped by the bounds of the window.
2. The windows in the left 2 columns can have verticies being dragged outside the window. This ends up going over the padding borders and behind the next window. The shape should be clipped by the borders of the window.
3. Panning the windows causes both objects and shapes to go outside the boarder of the window. The objects should be clipped by the border of the window.
### Shape translation
In the left two column windows, selected shape should now be able to be dragged around the window. Since multiple shapes can be added, the user needs to be able to drag a shape to place it where it needs to go.
### Grid size default modification
The new default grid size should be 15 pixels.
### Snap to grid menu item bug
When the user selects 'snap to grid' from the Edit drop down, the checkbox never gets checked. The functionality works, but the user has no way to tell from the menu item whether snapping is enabled or disabled.
### Shape copy and paste
For the left 2 columns, when a shape is selected and the right mouse button is clicked there should be 2 potential items in the menu (with a seperator before)
1. 'Copy' - this will copy the shape, color, etc onto a 'clipboard'
2. 'Paste' - This will add the shape that is in the 'clipboard' to the current window where the context menu was called.
'Paste' should only be visible if there is a shape in the clipboard.
A shaped that is copied from any of the left 2 column windows can be pasted in any of the other windows in the left 2 columns or it's own window.
Once a shape is pasted, it is NOT cleared out of the clipboard. Only a new copy will overwrite the clipboard.
### Object z-ordering
In the 'whole stickman' window when an object is selected and the right mouse button is clicked, there should be a context menu that appears. The context menu will contain an item for 'Send Back' and 'Bring Forward'. This should function similar to z-ordering from Phase 4.
Object selection in the 'whole stickman' window will take z-order into account. If a larger object with a large bounding box is obstructing a smaller object, the smaller object and still be selected if it has a lower z order (closer to the camera)
### Object mirroring
In the 'whole stickman' window when an object is selected and the right mouse button is clicked, there should be a context menu that appears. The context menu will contain an item for 'Mirror X' and 'Mirror Y'. (with a seperator)
If the user clicks on 'Mirror X', the object will mirror on the X-axis along it's center bounding box.
If the user clicks on 'Mirror Y', the object will mirror on the Y-axis along it's center bounding box.
This should be similar to how many image manipulation programs work.
### Shape mirroring
In the 2 left column windows when a shape is selected and the right mouse button is clicked, there should be a context menu that appears. The context menu will contain an item for 'Mirror X' and 'Mirror Y'. (with a seperator)
If the user clicks on 'Mirror X', the shape will mirror on the X-axis along it's center bounding box.
If the user clicks on 'Mirror Y', the shape will mirror on the Y-axis along it's center bounding box.
This should be similar to how many image manipulation programs work.
### Mirroring question to the architect
Should mirroring be a flag, or should be object / shape verticies be recomputed when mirrored?
### Save
The .stk file should account for the new attributes introduced in this phase.
- Object's z-order
- Object's mirror status?
- Shape's mirror status?
## Rules
1. Try to use standard Godot controls when applicable
2. We are using Godot 4.4
3. Any scripting should be in GDScript
+417
View File
@@ -0,0 +1,417 @@
# Stickman Studio
A Godot 4.4 editor tool for creating and assembling stick figures. Stickman Studio lets you draw individual body-part shapes, edit vertices, assemble them into a whole stickman, and save/load your figure to a JSON file (`.stk`) for later use or animation scripting.
## Overview
The editor is organized as **11 sub-windows** in a 3-column layout:
- **Left column (5)** — Head, Left Upper Arm, Left Lower Arm, Left Upper Leg, Left Lower Leg
- **Center column (5)** — Torso, Right Upper Arm, Right Lower Arm, Right Upper Leg, Right Lower Leg
- **Right column (1)** — **Whole Stickman** preview
The editor is driven by three menus: **File** (Save / Load / Clear), **Edit** (Configure Grid... / Snap to Grid), and **View** (Reset Views).
Each body-part panel is an independent vector drawing surface with per-panel zoom **and panning (middle-mouse drag)**. A configurable background grid helps align vertices, and snap-to-grid can be enabled for both vertex dragging and whole-figure assembly. **Each panel supports multiple shapes** with Z-ordering controls (Send Back / Bring Forward) and shape-level Copy/Paste and Mirror operations. The Whole Stickman panel assembles every part into one figure — treating all shapes in a panel as a single unit — and lets you reposition, rotate, and scale each part, reorder the parts (Z-order), and mirror them.
Drawing surfaces **clip** their content to the panel bounds: shapes and parts no longer render outside a panel's borders when dragged or panned. A selected shape can be **dragged to reposition** it within its panel.
## Requirements
| Item | Version |
|---|---|
| Engine | Godot 4.4 or newer |
| Addon | Scalable Vector Shapes 2D (`addons/curved_lines_2d/`) |
> The legacy `stick.tscn` scene (a rigged walk animation using `Skeleton2D` + IK) remains in the project root for reference but is **no longer the main scene**.
## Running the project
No CLI build, test, or lint commands are used. Run the project from the Godot editor:
1. Open the project folder in **Godot 4.4**.
2. Press **F5**, or open and run `res://scenes/stickman_editor.tscn`.
The main scene is configured as `run/main_scene="res://scenes/stickman_editor.tscn"` in `project.godot`.
## Quick Start
### 1. Create a body part
1. Locate one of the 10 body-part panels (e.g. **Head**).
2. **Right-click** anywhere inside its drawing area to open the context menu.
3. Choose a starter shape:
- **Line** — a 120 px horizontal segment (2 vertices, open shape).
- **Rectangle** — a 100 × 60 px rectangle (4 vertices, closed shape).
- **Circle** — a 12-segment polygon (12 vertices, closed shape, radius 40).
The shape draws immediately and syncs to the Whole Stickman panel.
Rendering behavior:
- **Closed shapes** (`closed: true`) fill their interior with the chosen color (`draw_colored_polygon`) and draw a 2 px outline.
- **Open shapes** (`closed: false`) render as an outline-only segment with no fill.
Body-part panels expand vertically to fill their column height.
### 2. Edit vertices
Each panel supports vertex-level editing:
1. **Select a shape****Left-click** anywhere inside a shape to select it. The outline turns white.
2. **Create a vertex** — With a shape selected, **right-click** on its outline and choose "Create Point". A new vertex (shown as a hollow rectangle) is added at the nearest edge midpoint.
3. **Drag vertices****Left-click** on any vertex handle (filled circle = original, hollow rectangle = user-created) and drag to reshape. The shape conforms in real time. Release to apply. With **Snap to Grid** enabled, the dragged vertex jumps to the nearest grid intersection.
4. **Delete a vertex****Right-click** on a vertex handle and choose **Remove Point** (see *Deleting* below).
5. **Deselect** — Left-click outside the shape or select a different panel. Only one shape is selected at a time.
**Move a whole shape (Phase 5):**
1. **Left-click** a shape **not on a vertex handle** and drag to reposition the entire shape within the panel.
2. Release to commit. All vertices translate together; the shape's Z-order is unchanged.
3. With **Snap to Grid** enabled, the dragged shape jumps to the nearest grid intersection on release.
### 3. Zoom & Pan
Scroll the mouse wheel inside any body-part panel or the Whole Stickman preview to zoom. Pan the view with the middle-mouse button:
- **Wheel up** — zoom in by 10%
- **Wheel down** — zoom out by 10%
- **Middle-mouse drag** — pan the view in any direction
- **Range** — 30% (minimum) to 300% (maximum)
- Vertex handles remain visible at all zoom levels.
- Each panel has independent zoom and pan offset.
- Panning takes priority over vertex dragging (middle-mouse press cancels any in-progress drag).
- **Content is clipped to the panel bounds (Phase 5)** — shapes, grid, and gizmos cannot render outside a panel's drawing area, even when panned or dragged beyond its edge. This keeps each panel self-contained.
- Use **View → Reset Views** to restore all panels to 100% zoom and the origin pan offset.
### 4. Assemble a stickman
1. Create shapes for every part you want in the figure.
2. In the **Whole Stickman** panel, **drag any part** to reposition it in the figure.
- Parts get a generous hit area and show a yellow highlight while being dragged.
- A short label (`H`, `T`, `LUA`, `RUA`, `LUL`, `RLL`, …) marks each part.
- Use the mouse wheel to zoom in/out and middle-mouse to pan for precise positioning.
- With **Snap to Grid** enabled, the mouse pointer snaps to the nearest grid intersection and the part follows.
3. Optionally type a name in the **Stickman Name** field at the top.
4. **Right-click a selected part (Phase 5)** to reorder or mirror it:
- **Send Back / Bring Forward** — change the part's draw order in the preview.
- **Mirror X / Mirror Y** — flip the part around its bounding-box center.
### 5. Grid & Panning
All 10 body-part panels and the Whole Stickman preview share a global background grid (default cell size **15 px**, Phase 5; previously 5 px):
1. Click **Edit → Configure Grid...** and set the grid size.
- **SpinBox** accepts 1100 px per cell.
- The setting is applied to every panel simultaneously and persists to `user://settings.json`.
2. Toggle **Edit → Snap to Grid** to enable snapping (Phase 5: the checkable menu item now reliably shows its ✓ checkmark when toggled):
- On **vertices** — while dragging a vertex handle, it jumps to the nearest grid intersection.
- On **assembly** — while dragging a part in the Whole Stickman preview, the pointer snaps and the part follows.
- On **shape drag** — while dragging a whole shape (Phase 5), it snaps on release.
- Existing vertices are *not* retroactively snapped; snapping only applies to new drags.
3. **Pan** — hold the **middle-mouse button** and drag anywhere in a panel to pan the view.
- Panning uses the same transform as zoom, and each panel keeps an independent offset.
- **View → Reset Views** restores 100% zoom and the origin offset on all panels.
4. Grid lines are drawn behind shapes and scale with pan/zoom, so they stay aligned to world coordinates.
### 6. Changing Colors
Colors are easy to change on any existing shape:
1. **Right-click** on a shape (either selected or with the cursor over it) and choose **Color...**.
2. A **ColorPicker** popup appears with the shape's current color pre-loaded.
3. Adjust the color — the shape updates live as a preview.
4. Click **OK** to commit the change or **Cancel** to revert to the original color.
The chosen color is stored in the shape's `color` field and is saved/loaded with the `.stk` file.
### 7. Deleting
Two levels of deletion are available:
**Delete an entire shape:**
1. **Right-click** on a shape (either selected or with the cursor over it) and choose **Delete**.
- Delete is only offered when a shape exists and the mouse is over it.
2. The shape is removed from the panel and the Whole Stickman preview.
**Remove a single vertex:**
1. **Right-click** on a vertex handle and choose **Remove Point**.
2. If 2 vertices remain, the shape becomes an open **line** (`closed = false`).
3. If 1 vertex or fewer remains, the entire shape is cleared.
### 8. Multiple Shapes & Z-Ordering
Each body-part panel can contain **more than one shape**. New shapes are created via the standard right-click context menu and are drawn on top of existing shapes. Shapes are drawn in Z-order (last in the list = frontmost).
**Z-ordering controls:**
- **Right-click** on a shape and choose **Send Back** to move it one step behind (earlier in draw order).
- **Right-click** on a shape and choose **Bring Forward** to move it one step ahead (later in draw order).
Z-order is preserved in the `.stk` save file (shapes are stored in draw order within the `shapes` array).
Only one shape within a panel can be selected at a time for vertex editing. Left-click a shape to select it; right-click also selects the shape under the mouse for context menu operations.
**Shape Copy & Paste (Phase 5):**
1. **Right-click** on a shape and choose **Copy** to place a deep copy of the shape on the editor-wide clipboard.
2. **Right-click** (on empty space or a shape) and choose **Paste** to insert the clipboard shape at the click position.
- Paste only appears while the clipboard is populated.
- The clipboard **persists after pasting** and is shared across **all 10 body-part panels** — a shape copied in one panel can be pasted into any other.
- The pasted shape becomes the newly selected (topmost) shape.
- **Copy → Clear → Paste** semantics: the clipboard survives figure clear.
**Shape Mirroring (Phase 5):**
1. **Right-click** on a selected shape and choose **Mirror X** or **Mirror Y**.
2. The shape's vertices are mirrored around the shape's bounding-box center.
3. Mirroring recomputes the `points` array in place — no extra fields are stored; the result is saved as normal vertex data.
### 9. Whole Stickman Manipulation (Selection, Rotation, Scale)
The Whole Stickman preview treats all shapes in a body-part panel as **one combined object**. Each part can be independently translated, rotated, and scaled.
**Selection:**
- **Left-click** on any part in the Whole Stickman preview to select it.
- A **white bounding box** appears around the selected part, along with manipulation gizmos.
- Click empty space to deselect.
**Translation:**
- **Left-click and drag** a selected part to reposition it. Snap-to-grid applies to the pointer position when enabled.
**Rotation:**
- When a part is selected, a **filled circle** appears centered below the bounding box.
- **Click and drag** the rotation circle to rotate the part around its center.
- Hold **Ctrl** while rotating to snap to **15-degree increments** (relative to the original 0° position).
- Rotation is **absolute**: 0° always means the original orientation the object was created with.
- The accumulated rotation value is saved in the `.stk` file.
**Scale:**
- When a part is selected, **cross/plus markers** appear at all four corners of the bounding box.
- **Click and drag** any corner cross to freely scale the part.
- Hold **Ctrl** while scaling to **lock the aspect ratio** (scale uniformly).
- Grid snapping snaps the corner position to the nearest grid intersection.
- Scale is **relative to the created size**: (1.0, 1.0) = original size.
- The scale value is saved in the `.stk` file. Scale may be **negative** for mirrored parts.
- **Part mirroring (Phase 5)** — right-click a selected part and choose **Mirror X** or **Mirror Y** to mirror it around its bounding-box center. This is implemented by negating the part's scale factor along the chosen axis (no vertex data is changed).
**Part Z-ordering (Phase 5):**
- Right-click a selected part and choose **Send Back** or **Bring Forward** to move it one step within the preview's draw order.
- Selection respects Z-order: hit-testing runs **front-to-back**, so the frontmost part under the pointer is selected.
- Part Z-order is saved in the `.stk` file as the top-level `part_order` array.
### 10. Save
1. Click **File → Save**.
2. Choose a location and name. The default extension is `.stk` (appended automatically if omitted).
3. Click **Save**. The figure is written as JSON.
### 11. Load
1. Click **File → Load**.
2. Select a `.stk` file.
3. On success, all body-part panels and the Whole Stickman preview are populated. On failure, an error dialog reports the problem (missing file, parse error, or unsupported version).
> v1.0, v1.1, and v1.2 files are automatically migrated to v1.3 on load (v1.0/v1.1 single shapes wrapped in a `shapes` array, rotation defaults to 0, scale defaults to (1,1); files without `part_order` fall back to the default part order).
### 12. Clear
1. Click **File → Clear**.
2. A confirmation dialog warns that the current stickman will be cleared.
3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin).
## File format (`.stk`)
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
```json
{
"version": "1.3",
"stickman_name": "Bob",
"part_order": [
"head",
"torso",
"left_upper_arm",
"left_lower_arm",
"right_upper_arm",
"right_lower_arm",
"left_upper_leg",
"left_lower_leg",
"right_upper_leg",
"right_lower_leg"
],
"body_parts": {
"head": {
"shapes": [
{
"shape_type": "circle",
"closed": true,
"points": [
{ "x": 120, "y": 40 },
{ "x": 124, "y": 39 }
],
"color": "#000000",
"vertex_flags": [0, 0, 1, 0]
}
],
"position": { "x": 150, "y": 40 },
"rotation": 0.0,
"scale": { "x": 1.0, "y": 1.0 }
}
},
"metadata": {
"created_at": "2026-08-05T12:00:00",
"modified_at": "2026-08-05T12:00:00"
}
}
```
### Root object
| Key | Type | Description |
|---|---|---|
| `version` | `string` | Format version. Currently `"1.3"`. Loading supports `"1.0"``"1.3"` (auto-migrated). |
| `stickman_name` | `string` | Optional display name for the figure. |
| `part_order` | `array[string]` | **Phase 5.** Render/Z-order of parts in the Whole Stickman preview, front-to-back semantics per array position (first = back, last = front). Absent on v1.0v1.2 files; defaults to the internal part-key order when missing. |
| `body_parts` | `object` | Map of `body_part_name → shape` objects. Keyed by the 10 internal part names below. |
| `metadata.created_at` | `string` | Timestamp written on save. |
| `metadata.modified_at` | `string` | Timestamp written on save. |
### Part keys
`head`, `torso`, `left_upper_arm`, `left_lower_arm`, `right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`, `right_upper_leg`, `right_lower_leg`
### Part object
Each body part is an object with the following keys:
| Key | Type | Description |
|---|---|---|
| `shapes` | `array` | Array of shape objects (see below). Draw order = array order (first = back, last = front). Wrapped as a single-element array when migrating v1.0/v1.1 files. |
| `position` | `object` | `{ "x": float, "y": float }` position of the part within the Whole Stickman preview. |
| `rotation` | `float` | Rotation in degrees. `0.0` = original orientation. Defaults to `0.0` for v1.0/v1.1 files. |
| `scale` | `object` | `{ "x": float, "y": float }` scale factors relative to created size. `1.0` = original size. May be **negative** (Phase 5) to represent mirroring along an axis. Defaults to `{ "x": 1.0, "y": 1.0 }` for older files. |
### Shape object
Each entry in the `shapes` array:
| Key | Type | Description |
|---|---|---|
| `shape_type` | `string` | `"line"`, `"rectangle"`, `"circle"`, or `""` (empty). Descriptive tag only — rendering uses `closed`. |
| `closed` | `bool` | Whether the polygon is closed (filled) or open (outline-only). |
| `points` | `array` | Array of `{ "x": float, "y": float }` vertex positions **local to the drawing area**. |
| `color` | `string` | Hex colour, e.g. `"#000000"`. Changeable in-app via right-click → **Color...** (Phase 3). |
| `vertex_flags` | `array[int]` | Same length as `points`. `0` = original vertex, `1` = user-created via "Create Point". Controls handle appearance. |
> On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions.
## Settings file (`user://settings.json`)
Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility:
```json
{
"version": "1.0",
"grid_size": 15,
"snap_to_grid": false
}
```
### Settings keys
| Key | Type | Default | Description |
|---|---|---|---|
| `version` | `string` | `"1.0"` | Settings file version (for future extensibility). |
| `grid_size` | `int` | `15` | Grid interval in pixels, applied equally to width and height. Default changed from `5` to `15` in Phase 5. Clamped to 1100 on load. |
| `snap_to_grid` | `bool` | `false` | Whether snap-to-grid is active. |
Behavior:
- **Load** — read on editor startup; if the file is missing or fails to parse, defaults (`grid_size = 15`, `snap_to_grid = false`) are used silently (no error dialog).
- **Save** — written whenever the user changes the grid size or toggles Snap to Grid.
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
## Project structure
| Path | Purpose |
|---|---|
| `res://project.godot` | Engine config; sets main scene to the editor and enabled features. |
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.3 serialization with multi-shape/rotation/scale and `part_order`, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, part hit-bounds, labels. |
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
| `res://stick.tscn` | **Legacy** rigged/animated stick figure scene (Skeleton2D + IK). Not used by the editor. |
| `res://AGENTS.md` | Guidance for AI agents working in this codebase. |
### Menu bar structure
```
File Edit View
──────────── ──────────────────── ───────────
Save Configure Grid... Reset Views
Load ─────────
──────── Snap to Grid (check)
Clear
```
- **File** — Save, Load, Clear (Phase 1).
- **Edit** — `Configure Grid...` (dialog with a 1100 SpinBox) and the checkable `Snap to Grid` toggle (Phase 3).
- **View** — `Reset Views` (resets zoom to 100% and pan offset to origin on every panel) (Phase 3).
### Context menu (per body-part panel)
The right-click context menu is context-sensitive. Item IDs:
| ID | Label | Shown when |
|----|-------|------------|
| 0 | Line | Empty space right-click |
| 1 | Rectangle | Empty space right-click |
| 2 | Circle | Empty space right-click |
| 3 | Create Point | Shape under mouse, near outline |
| 4 | Color... | Shape under mouse |
| 5 | Delete | Shape under mouse |
| 6 | Remove Point | Right-click on a vertex handle |
| 7 | Send Back | Shape under mouse |
| 8 | Bring Forward | Shape under mouse |
| 9 | Copy | Shape under mouse (Phase 5) |
| 10 | Paste | Shape or empty space right-click; shown only when the clipboard is populated (Phase 5) |
| 11 | Mirror X | Shape under mouse (Phase 5) |
| 12 | Mirror Y | Shape under mouse (Phase 5) |
### Context menu (Whole Stickman preview)
Right-clicking a selected part opens the preview's context menu:
| ID | Label | Description |
|----|-------|-------------|
| 0 | Send Back | Move the selected part one step back in `part_order` (Phase 5) |
| 1 | Bring Forward | Move the selected part one step forward in `part_order` (Phase 5) |
| 2 | Mirror X | Mirror the selected part around its bounding-box center (scale X negated) (Phase 5) |
| 3 | Mirror Y | Mirror the selected part around its bounding-box center (scale Y negated) (Phase 5) |
### Signal flow
```
BodyPartPanel.shape_changed(data) ---(bound to part_name)---> stickman_editor
| |
| _on_body_part_shape_changed |
+--------------------------> WholeStickmanPreview |
.set_body_parts(all_data) |
^ |
WholeStickmanPreview.part_moved(name, pos) (drag handling) --+
BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
_on_body_part_shape_selected
(deselects all other panels)
```
- `BodyPartPanel` emits `shape_changed(shapes_array)` whenever a shape is created, a vertex is dragged (on release), a color is committed via the ColorPicker (on OK), a shape is deleted, a vertex is removed via "Remove Point", or Z-order changes via "Send Back"/"Bring Forward". The signal carries the full `Array[Dictionary]` of all shapes in the panel. Note: `clear_shape()` does **not** emit the signal; the editor's Clear flow refreshes the preview directly.
- `BodyPartPanel` emits `shape_selected` when left-clicked; the editor deselects all other panels.
- `stickman_editor.gd` collects shape data from all 10 panels and forwards it to `WholeStickmanPreview.set_body_parts()`.
- `WholeStickmanPreview` emits `part_moved(part_name, new_position)` during translation drags. Rotation and scale are stored internally and polled by the editor at save time via `get_part_rotation()` / `get_part_scale()`. Part Z-order is polled the same way via `get_part_order()` and saved to the top-level `part_order` field (Phase 5).
- Shape Copy/Paste (Phase 5) is owned by the editor: `_copy_shape()` stores a deep duplicate in `_shape_clipboard`, `_broadcast_clipboard_state()` tells every panel whether Paste should be offered, and `_paste_shape()` inserts the clipboard into the target panel at the click position. The clipboard persists after pasting and is shared across all panels.
> **Phase 4:** Each body part can have multiple shapes in Z-order. The Whole Stickman preview treats all shapes in a panel as one combined object, with selection (white bounding box), rotation (circle gizmo below box), and scale (cross gizmos at corners). The `.stk` format evolved to v1.2 with a `shapes` array per part plus `rotation` and `scale` fields. Rotation snaps to 15° with Ctrl; scale locks aspect ratio with Ctrl.
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0v1.2 files remain backward compatible and are migrated on load.
+241
View File
@@ -0,0 +1,241 @@
# Phase 2 — Architectural Specification
## Overview
Phase 2 adds **interactive vertex editing** (custom shapes), **per-panel zoom**, and an **expanded .stk save format** to the Stickman Studio editor. Phase 1 (shape creation, save/load, whole-stickman assembly) is complete and functional; Phase 2 builds on it without breaking existing behavior.
---
## 1. Data Model Changes
### 1a. Shape Data Dictionary (updated)
```gdscript
# body_part_panel.gd — internal shape_data extends to:
{
"shape_type": String, # "line", "rectangle", "circle", "" (unchanged)
"points": PackedVector2Array, # vertex positions (unchanged)
"color": String, # hex color (unchanged)
"closed": bool, # NEW — true for polygon fill, false for open line
"vertex_flags": PackedInt32Array # NEW — 0=original, 1=user_created; same length as points
}
```
- `closed` replaces the implicit closed-ness that was derived from `shape_type`. A line has `closed=false`; a rectangle and circle have `closed=true`. The rendering logic switches on `closed` instead of `shape_type`.
- `vertex_flags` tracks which vertices are "original" (filled circle handle) vs "user-created via Create Point" (hollow rectangle handle). Default all-zero for starter shapes.
- `shape_type` is retained as a descriptive tag but no longer drives rendering behavior.
### 1b. .stk File Format (version "1.1")
```json
{
"version": "1.1",
"stickman_name": "Bob",
"body_parts": {
"head": {
"shape_type": "circle",
"closed": true,
"points": [{"x": 120, "y": 40}, ...],
"color": "#000000",
"position": {"x": 150, "y": 40},
"vertex_flags": [0, 0, 0, 1, 0, ...]
}
},
"metadata": {
"created_at": "...",
"modified_at": "..."
}
}
```
- **Version bumped to `"1.1"`.** Loading: if version is `"1.0"`, auto-migrate (set `closed` based on `shape_type`, set `vertex_flags` as all-zeros). Save always writes `"1.1"`.
- New per-shape keys: `"closed"` (bool) and `"vertex_flags"` (array of ints, length matching `points`).
- Backward compatibility: loading a 1.0 file infers `closed` from `shape_type` (`"line"`→false, `"rectangle"`/`"circle"`→true, `""`→false). vertex_flags defaults to all zeros for any shape loaded from 1.0.
### 1c. Circle Refactor
- `_create_circle()` generates **12 vertices** (down from 32). The shape_type remains `"circle"`, `closed=true`, `vertex_flags` all zeros.
- Existing 32-vertex circles in saved `.stk` files still load correctly — `set_shape_data()` accepts arbitrary point arrays.
---
## 2. BodyPartPanel — Vertex Editing
### 2a. Selection State
New internal state variables in `body_part_panel.gd`:
```gdscript
var _selected: bool = false # is this panel's shape currently selected?
var _dragging_vertex: int = -1 # index of vertex being dragged, -1 if none
var _drag_offset: Vector2 # offset from vertex to mouse during drag
var _zoom: float = 1.0 # zoom factor clamped to [0.3, 3.0]
```
### 2b. Interaction Model
| Action | Trigger | Behavior |
|---|---|---|
| **Select shape** | Left-click inside shape bounds | Sets `_selected=true`, emits `shape_selected` signal. Deselects all other panels (coordinated by `stickman_editor.gd`). |
| **Deselect** | Left-click outside shape OR clicking another panel | `_selected=false`. Any in-progress drag is cancelled. |
| **Drag vertex** | Left-click press on a vertex handle, then drag | Sets `_dragging_vertex` to that index. On mouse motion, updates vertex position. On release, clears drag state. Emits `shape_changed`. |
| **Open context menu** | Right-click on the outline path of a **selected** shape | Shows a `PopupMenu` with one item: "Create Point". The click position is stored for vertex insertion. |
| **Create initial shape** | Right-click on an **empty** drawing area (no shape OR shape not selected) | Shows the existing 3-item context menu (Line, Rectangle, Circle). *Unchanged from Phase 1.* |
### 2c. Hit Testing
- **Shape selection hit-test**: Point-in-polygon check against the full shape polygon. For open shapes (lines), check if the click is within a threshold distance (12px) of any edge.
- **Vertex hit-test**: For each vertex, check if the mouse position is within `HANDLE_RADIUS_SELECTION` (8px) of the vertex position (in screen space, accounting for zoom).
- **Edge hit-test for Create Point**: Find the nearest edge to the right-click position (in screen space). The new vertex is inserted at the midpoint of that edge.
### 2d. Rendering Changes (in `_on_drawing_area_draw()`)
Rendering order (back to front):
1. **Fill** (if `closed`): `draw_colored_polygon(pts, color)` — same as Phase 1.
2. **Outline**: `_draw_polyline(pts, color, ...)` — same as Phase 1.
3. **Selection highlight** (if `_selected`): Redraw the polyline in white (`Color.WHITE`), 2px wide, on top of the color outline. This makes the selected shape stand out.
4. **Vertex handles**: For each vertex:
- If `vertex_flags[i] == 0` (original): filled circle, radius `HANDLE_RADIUS / zoom` (so it stays constant screen size).
- If `vertex_flags[i] == 1` (user-created): hollow rectangle, size `(6/zoom)×(6/zoom)`, centered on the vertex.
- If `i == _dragging_vertex`: draw in yellow highlight.
### 2e. Context Menu Refactor
The existing `ContextMenu` (ids 0, 1, 2 — Line, Rectangle, Circle) is repurposed for initial shape creation only.
A **second** `PopupMenu` is needed for vertex editing. Two options:
- **Option A (chosen):** Single PopupMenu, dynamically repopulated based on context. When right-clicking on empty area → show "Line", "Rectangle", "Circle". When right-clicking on outline of selected shape → show "Create Point" only. This requires clearing/rebuilding the menu items programmatically.
- **Option B:** Two separate PopupMenu nodes.
**Decision: Option A** — less scene overhead, single menu node reused.
Menu id: 0=Line, 1=Rectangle, 2=Circle, 3=Create Point (only shown in vertex context).
### 2f. Vertex Insertion (Create Point)
1. Right-click on the outline of a selected shape.
2. Find the nearest edge (line segment) to the click position.
3. Insert a new vertex at the **midpoint** of that edge (not at the click position — this matches the user story where the vertex "appear[s] in the outline where the user clicked" at the nearest edge midpoint, not an arbitrary point).
4. Append `1` to `vertex_flags` at the corresponding position.
5. `queue_redraw()` and `emit shape_changed`.
*Note: PROJECT.md says "a small hollow rectangle will appear in the outline where the user clicked — this is not a point(vertex)". I interpret this to mean: the vertex is placed on the outline at the nearest edge midpoint. The "not a point" language means it's a vertex, just drawn differently (hollow rectangle).*
### 2g. Vertex Dragging
1. Left-click press on a vertex handle (within `HANDLE_RADIUS_SELECTION`).
2. Set `_dragging_vertex = index`, `_drag_offset = position - vertex_position`.
3. On mouse motion (`InputEventMouseMotion`):
- Compute new world-space position: `new_pos = mouse_position / zoom` (undo zoom) or keep everything in world space. **Design decision:** Store points in world space (unscaled). Apply `draw_set_transform(Vector2.ZERO, 0, Vector2(zoom, zoom))` in `_draw()`. Mouse positions are divided by zoom to get world coordinates.
- Update `shape_data.points[_dragging_vertex] = world_pos`.
- `queue_redraw()`.
4. On mouse release: clear `_dragging_vertex`, emit `shape_changed`.
---
## 3. Window Zoom
### 3a. BodyPartPanel Zoom
- Add `_zoom: float = 1.0` (range `[0.3, 3.0]`).
- Mouse wheel handling in `_on_drawing_area_gui_input()`:
- `MOUSE_BUTTON_WHEEL_UP`: `_zoom = clamp(_zoom * 1.10, 0.3, 3.0)`
- `MOUSE_BUTTON_WHEEL_DOWN`: `_zoom = clamp(_zoom / 1.10, 0.3, 3.0)`
- In `_on_drawing_area_draw()`, apply: `drawing_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))` before all draw calls.
- Mouse-to-world conversion: All input event positions must be divided by `_zoom` before using as world coordinates (hit-testing, vertex creation, dragging).
- Vertex handles drawn with sizes divided by `_zoom` (e.g., `HANDLE_RADIUS / _zoom`), so they appear the same screen size regardless of zoom level.
### 3b. WholeStickmanPreview Zoom
- Add `_zoom: float = 1.0` (same range).
- Same wheel handling, same `draw_set_transform` application.
- No vertex handles drawn here, so the handle-size concern doesn't apply.
- Drag hit-testing (`_try_start_drag`): Divide `at_position` by `_zoom` to convert to world coordinates. The drag offset and part positions remain in world space.
---
## 4. StickmanEditor — Coordination Changes
### 4a. Selection Coordination
When a `BodyPartPanel` is selected, all other panels must be deselected. Add:
```gdscript
signal shape_selected(part_name: String)
# In _on_body_part_shape_selected(part_name):
# for each panel in _body_part_panels where key != part_name:
# panel.deselect()
```
Add a `deselect()` method to `BodyPartPanel` that sets `_selected=false` and `queue_redraw()`.
### 4b. Save Format Updates
- `FILE_VERSION``"1.1"`.
- `_build_json_data()` now includes `"closed"` and `"vertex_flags"` in each shape.
- `_collect_all_shape_data()` updated accordingly — `BodyPartPanel.get_shape_data()` returns the updated dictionary with `closed` and `vertex_flags`.
- `_apply_json_data()` handles both `"1.0"` and `"1.1"`:
- 1.0: auto-migrate (infer `closed` from `shape_type`, set `vertex_flags` to all-zeros).
- 1.1: read `closed` and `vertex_flags` directly.
### 4c. New Signal Wiring
- `BodyPartPanel.shape_changed` — existing, now also emitted on vertex drag end and Create Point.
- `BodyPartPanel.shape_selected` — new, for cross-panel deselection coordination.
- Each panel's `shape_selected` connects to `stickman_editor._on_body_part_shape_selected`.
---
## 5. WholeStickmanPreview — Changes
### 5a. Zoom Support
- Add `_zoom` with same wheel handling and range.
- Apply `draw_set_transform` in `_on_preview_draw()`.
- Convert mouse positions in `_try_start_drag()` and `_on_preview_gui_input()`.
### 5b. Closed Shape Rendering
- The preview currently switches on `shape_type` to decide fill+close. Update to check `closed: bool` from the shape data instead.
- Add `_draw_polyline_preview()` helper for clean line drawing (consistent with BodyPartPanel).
---
## 6. Files Modified
| File | Changes |
|---|---|
| `scripts/body_part_panel.gd` | Major: vertex editing state, selection, zoom, context menu refactor, rendering with closed flag & vertex_flags, circle→12 vertices, mouse handling expanded |
| `scripts/stickman_editor.gd` | Version bump to 1.1, save format includes closed/vertex_flags, load handles 1.0+1.1, selection coordination signal |
| `scripts/whole_stickman_preview.gd` | Zoom support, closed-based rendering instead of shape_type-based, input coordinate transform |
| `scenes/body_part_panel.tscn` | Minimal — ContextMenu items may be reduced or left as-is (dynamic repopulation in script). *Consider adding a 4th static item "Create Point" hidden by default.* |
| `README.md` | Document Phase 2 features, updated .stk format, vertex editing, zoom |
| `docs/phase2_spec.md` | This file |
---
## 7. Implementation Order (Recommended)
1. **Data model + circle refactor** (shape_data adds `closed` + `vertex_flags`, circle→12 vertices, rendering uses `closed`)
2. **Zoom** (mouse wheel, draw_set_transform, coordinate conversion, handle-size scaling)
3. **Shape selection** (left-click hit-test, white outline, cross-panel deselection)
4. **Vertex hit-testing & dragging** (left-click on handles, drag motion, release)
5. **Create Point** (right-click context menu on selected outline, edge finding, vertex insertion)
6. **Save/load update** (version 1.1, closed/vertex_flags in JSON, backward compatible loading)
7. **WholeStickmanPreview zoom + closed rendering**
8. **README update**
---
## 8. Edge Cases & Constraints
- **Empty panel**: Right-click shows shape creation menu (unchanged). Left-click does nothing. Zoom still works.
- **Single-vertex shapes**: If a shape somehow has only 1 vertex, skip rendering (guard already exists at `count < 2`).
- **Min zoom (30%)**: Vertex handles drawn at `HANDLE_RADIUS / 0.3 ≈ 10px` — still visible.
- **Max zoom (300%)**: Vertex handles at `HANDLE_RADIUS / 3.0 ≈ 1px` — still visible but small.
- **Zoom state is NOT persisted** in the .stk file. Each panel resets to 1.0 on load/clear (editor-only transient state).
- **Vertex drag performance**: Emit `shape_changed` on drag end (not every frame) to avoid excessive WholeStickmanPreview rebuilds. Redraws during drag only touch the local panel.
- **Multiple selected shapes**: Only one shape can be selected at a time across the entire editor.
- **Right-click on empty area when shape exists but not selected**: Shows shape creation menu (existing behavior), which replaces the current shape.
+577
View File
@@ -0,0 +1,577 @@
# Phase 3 — Architectural Specification
## Overview
Phase 3 adds a **grid system** (configurable, with snap-to-grid for vertices and whole-stickman assembly), **panning** (middle-mouse-button drag), **global settings persistence** (`settings.json`), **color changing** (ColorPicker integration), **shape delete**, and **vertex delete** to the Stickman Studio editor. Phases 1-2 (shape creation, vertex editing, zoom, save/load) are complete and functional; Phase 3 builds on them without breaking existing behavior.
---
## 1. Data Model Changes
### 1a. Color is already in the model
The shape data dictionary already includes `"color": "#hex"`. The `.stk` format already serializes/deserializes color. Phase 3 adds a **ColorPicker UI** to change it interactively.
### 1b. Global Settings (`user://settings.json`)
New persistent settings file stored in Godot's user data directory:
```json
{
"grid_size": 5,
"snap_to_grid": false,
"version": "1.0"
}
```
| Key | Type | Default | Description |
|---|---|---|---|
| `version` | `string` | `"1.0"` | Settings file version (for future extensibility) |
| `grid_size` | `int` | `5` | Grid interval in pixels (applied equally to width and height) |
| `snap_to_grid` | `bool` | `false` | Whether snap-to-grid is active |
- **Load**: On editor startup (`_ready()`), attempt to load `user://settings.json`. If the file doesn't exist or fails to parse, use defaults.
- **Save**: Whenever the user changes grid size or toggles snap-to-grid, write the updated settings.
- **Scope**: Global — all panels share the same grid size and snap setting.
### 1c. Pan offset (transient, not persisted)
Both `BodyPartPanel` and `WholeStickmanPreview` gain a `_pan_offset: Vector2` variable, default `(0,0)`. This is **not persisted** in any file — it resets on load/clear and when the user clicks "Reset Views".
---
## 2. BodyPartPanel — Phase 3 Changes
### 2a. New State Variables
```gdscript
var _pan_offset: Vector2 = Vector2.ZERO # camera pan, modified by middle-mouse drag
var _grid_size: int = 5 # received from editor
var _snap_enabled: bool = false # received from editor
var _is_panning: bool = false # true while middle mouse is held
var _pan_start: Vector2 = Vector2.ZERO # screen position where pan started
var _restore_color: Color # saved color for Cancel support
```
### 2b. New Public Methods
```gdscript
func set_grid_size(size: int) -> void:
_grid_size = size
drawing_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
drawing_area.queue_redraw()
```
### 2c. Panning (Middle Mouse Button)
In `_on_drawing_area_gui_input()`:
1. `MOUSE_BUTTON_MIDDLE` pressed: Set `_is_panning = true`, record `_pan_start = mb.position`.
2. `MOUSE_BUTTON_MIDDLE` released: `_is_panning = false`.
3. `InputEventMouseMotion` while `_is_panning`: `_pan_offset += (mm.position - _pan_start) / _zoom`. Then `_pan_start = mm.position`. `queue_redraw()`. **Note**: pan offset moves inversely to drag direction (drag right → camera moves right, content appears to move left). The offset is added to the transform, so positive offset shifts drawing right.
Z-order input priority:
1. Middle mouse button (if press or panning in progress)
2. Mouse wheel (zoom)
3. Left mouse button (vertex drag / shape selection)
4. Right mouse button (context menu)
### 2d. Grid Drawing
In `_on_drawing_area_draw()`, after `draw_set_transform` but before shape rendering, draw the grid:
```gdscript
func _draw_grid() -> void:
var gs := float(_grid_size)
if gs <= 0: return
var area_size := drawing_area.size
var world_origin := (-_pan_offset - Vector2(area_size) * 0.5) / _zoom
var world_size := area_size / _zoom
var world_end := world_origin + world_size
var start_x := floor(world_origin.x / gs) * gs
var start_y := floor(world_origin.y / gs) * gs
var grid_color := Color(1.0, 1.0, 1.0, 0.15)
var x := start_x
while x <= world_end.x:
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), grid_color, 1.0 / _zoom)
x += gs
var y := start_y
while y <= world_end.y:
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), grid_color, 1.0 / _zoom)
y += gs
```
The grid is drawn **before** the shape fill/outline so it appears behind the shapes.
Rendering order (updated):
1. Grid lines (thin, low alpha)
2. Shape fill (if closed)
3. Shape outline
4. Selection highlight (if selected)
5. Vertex handles
**Important**: The grid must account for both zoom AND pan offset. The transform applied is:
```gdscript
drawing_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
```
This replaces the current `draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))`.
### 2e. Snap to Grid (Vertex Drag)
In the `InputEventMouseMotion` handler for vertex dragging, after computing the world position, apply snap:
```gdscript
var world_pos := _screen_to_world(mm.position)
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
shape_data.points[_dragging_vertex] = world_pos - _drag_offset
```
Snap helper:
```gdscript
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
return Vector2(
round(pos.x / gs) * gs,
round(pos.y / gs) * gs
)
```
Snap is applied **before** the drag offset is subtracted, so the user sees the vertex jump to the nearest grid intersection while dragging.
### 2f. Context Menu — Color Entry
The right-click context menu for a selected shape is updated:
```
[existing: "Create Point"] (id 3)
[separator]
"Color..." (id 4)
[separator]
"Delete" (id 5)
```
**"Color..." behavior**:
1. Save the current color: `_restore_color = Color.from_string(shape_data.color, Color.BLACK)`.
2. Create or show a `ColorPicker` dialog.
3. When the user changes the color in the picker (preview): update `shape_data.color` to the hex string and `queue_redraw()`. This gives live preview.
4. On **OK**: commit the color change, emit `shape_changed`.
5. On **Cancel**: restore `shape_data.color = _restore_color.to_html()`, `queue_redraw()`.
**Implementation via ColorPickerButton**:
- Add a `ColorPickerButton` node to the `BodyPartPanel` scene (hidden by default, `visible = false`).
- When "Color..." is clicked: set the picker's color to the current shape color, set `visible = true`, and programmatically trigger `popup()`.
- Connect the `color_changed` signal: update `shape_data.color``queue_redraw()` (live preview).
- Connect the `popup_closed` signal: this is where we distinguish OK vs Cancel. If the user clicked the "OK" or selected a swatch, the color was already committed through `color_changed`. If they cancelled, restore `_restore_color`.
Actually, `ColorPickerButton` in Godot 4.4 has a `color_changed` signal for preview but no built-in way to detect Cancel. A cleaner approach:
**Implementation via custom dialog**:
- Add a `ColorPicker` node inside a `Popup` or `AcceptDialog` with OK/Cancel buttons in the `BodyPartPanel` scene.
- On "Color...": set the picker color, show the popup.
- On Cancel: restore previous color.
- On OK: commit.
Since `ColorPickerButton` doesn't provide a clean Cancel detection, use a simple `AcceptDialog`-equivalent with a `ColorPicker`. Actually, the cleanest approach in Godot 4:
Use the built-in `ColorPicker` node (not `ColorPickerButton`) in a new scene or directly in `body_part_panel.tscn`. When "Color..." is clicked, we can also just use `PopupPanel` with a ColorPicker inside, or use `AcceptDialog` with a custom child.
**Simplest reliable approach**: Use `ColorPicker` node, detect color changes for preview, and use the color_picker's own `Popup` behavior. Actually, let me reconsider. The `ColorPicker` extends `VBoxContainer` - it needs to be in a popup.
**Decision**: Add a `PopupPanel` → VBoxContainer → ColorPicker → HBoxContainer (OK, Cancel buttons) to `body_part_panel.tscn`. On "Color...": set the picker's color, show popup. On color_changed: preview. On OK: commit, hide popup, emit shape_changed. On Cancel: restore, hide popup.
### 2g. Context Menu — Delete Entry
**"Delete" behavior** (id 5):
1. Call `clear_shape()` (which resets shape_data but does NOT emit shape_changed).
2. Emit `shape_changed` manually so the Whole Stickman preview updates.
### 2h. Context Menu Refactor — Right-Click Logic
The current `_on_drawing_area_gui_input()` right-click logic is:
```
if _selected AND near outline → show "Create Point"
else → show "Line", "Rectangle", "Circle"
```
Phase 3 changes this to:
```
1. Check if right-click is on a VERTEX (hit-test vertex handles).
If YES:
→ show "Remove Point" context menu (id 6)
return
2. If a shape EXISTS (not empty) AND (_selected OR mouse is over the shape):
→ show context menu with [Create Point / Color... / Delete] entries
return
3. Else (no shape or shape not selected):
→ show [Line / Rectangle / Circle] shape creation menu
```
**New context menu item IDs**:
| ID | Label | Context |
|----|-------|---------|
| 0 | Line | No shape / shape not selected |
| 1 | Rectangle | No shape / shape not selected |
| 2 | Circle | No shape / shape not selected |
| 3 | Create Point | Shape selected, near outline |
| 4 | Color... | Shape selected |
| 5 | Delete | Shape selected |
| 6 | Remove Point | Right-click on vertex handle |
**Rule for "Delete" visibility**: Show Delete only when a shape exists AND (`_selected == true` OR mouse is over the shape). This means:
- If the shape is selected but the mouse is not over it, Delete still shows.
- If the shape is not selected but the mouse is over it, Delete still shows.
### 2i. Vertex Delete (Remove Point)
When the user right-clicks on a vertex handle:
1. Show context menu with only "Remove Point" (id 6).
2. Store the vertex index as metadata on the context menu.
3. On "Remove Point" selected:
a. Remove the point at the stored index from `shape_data.points`.
b. Remove the corresponding entry from `shape_data.vertex_flags`.
c. If `shape_data.points.size() == 2`:
- Set `shape_data.closed = false` (becomes a line).
d. If `shape_data.points.size() < 2`:
- Call `clear_shape()` (removes the shape entirely).
e. `queue_redraw()` and emit `shape_changed`.
### 2j. Updated `_on_context_menu_id_pressed()`
New match cases:
```gdscript
4: # Color...
_show_color_picker()
5: # Delete
_delete_shape()
6: # Remove Point
_remove_vertex(context_menu.get_meta("vertex_index", -1))
```
### 2k. Updated `clear_shape()`
`clear_shape()` MUST also reset pan and zoom since we're starting fresh:
```gdscript
_pan_offset = Vector2.ZERO
_zoom = 1.0
```
### 2l. Updated `set_shape_data()`
Reset pan offset on data load:
```gdscript
_pan_offset = Vector2.ZERO
```
---
## 3. WholeStickmanPreview — Phase 3 Changes
### 3a. New State Variables
```gdscript
var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 5
var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
```
### 3b. New Public Methods
```gdscript
func set_grid_size(size: int) -> void:
_grid_size = size
preview_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
preview_area.queue_redraw()
```
### 3c. Panning
Same pattern as BodyPartPanel: middle-mouse press/release tracks `_is_panning`, mouse motion updates `_pan_offset` inversely.
### 3d. Grid Drawing
Same grid drawing code as BodyPartPanel, adapted for `preview_area`. Uses the same `_grid_size`. The grid is drawn behind the parts.
The draw transform is updated from:
```gdscript
preview_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))
```
to:
```gdscript
preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
```
### 3e. Snap to Grid (Part Drag)
In the drag motion handler, after computing the mouse world position, snap it:
```gdscript
var world_pos: Vector2 = mm.position / _zoom
if _snap_enabled:
var gs := float(_grid_size)
world_pos.x = round(world_pos.x / gs) * gs
world_pos.y = round(world_pos.y / gs) * gs
var new_pos := world_pos - _drag_offset
_part_positions[_dragging_part] = new_pos
```
This uses the mouse pointer position as the snap reference, as specified: the mouse position snaps to grid, and the part follows.
### 3f. Updated `clear_all()`
Reset pan offset on clear:
```gdscript
_pan_offset = Vector2.ZERO
_zoom = 1.0
```
### 3g. Updated `set_body_parts()`
Reset pan offset on body part set (full refresh from editor):
```gdscript
_pan_offset = Vector2.ZERO
```
---
## 4. StickmanEditor — Phase 3 Changes
### 4a. New Menu Bar Structure
```
┌──────────────────────────────────────────┐
│ File │ Edit │ View │ │
│──────────────────────────────────────────│
│ Save │ Configure Grid... │ Reset Views │
│ Load │ Snap to Grid │ │
│ ───── │ │ │
│ Clear │ │ │
└──────────────────────────────────────────┘
```
### 4b. Menu Setup in `_setup_menu_bar()`
After the existing "File" menu, add:
**Edit menu** (PopupMenu):
- "Configure Grid..." (id 0) — opens a dialog.
- Separator.
- "Snap to Grid" (id 1) — **checkable** item; checked = snap enabled.
**View menu** (PopupMenu):
- "Reset Views" (id 0) — resets zoom + pan on all panels.
### 4c. New State Variables
```gdscript
var _grid_size: int = 5
var _snap_enabled: bool = false
```
### 4d. New On-Ready Nodes
Add to the scene or create programmatically:
- `%GridConfigDialog` — a `ConfirmationDialog` or `AcceptDialog` with a SpinBox for grid size.
- `%ColorPickerPopup` — kept in body_part_panel.tscn, not here.
Actually, the grid config dialog should be in the editor scene since it's a top-level dialog. Add it to `stickman_editor.tscn`. The ColorPicker stays in `body_part_panel.tscn` since it's per-panel.
### 4e. Grid Config Dialog
**Option A**: Add a `ConfirmationDialog` with a `SpinBox` child to `stickman_editor.tscn`.
- Title: "Configure Grid"
- Label: "Grid Size (pixels):"
- SpinBox: min 1, max 100, step 1, value = current `_grid_size`.
- On confirmed: read SpinBox value, set `_grid_size`, broadcast to all panels + WholeStickmanPreview, write `settings.json`.
### 4f. Settings Load/Save
```gdscript
func _load_settings() -> void:
if not FileAccess.file_exists("user://settings.json"):
return # use defaults
var file := FileAccess.open("user://settings.json", FileAccess.READ)
if file == null:
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if json is Dictionary:
var d := json as Dictionary
_grid_size = int(d.get("grid_size", 5))
_snap_enabled = bool(d.get("snap_to_grid", false))
# Clamp to valid range
_grid_size = clampi(_grid_size, 1, 100)
func _save_settings() -> void:
var data := {
"version": "1.0",
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
}
var file := FileAccess.open("user://settings.json", FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
```
Called in `_ready()`: load settings, then broadcast to panels.
### 4g. Broadcasting Settings to Panels
New helper:
```gdscript
func _broadcast_settings() -> void:
for panel in _body_part_panels.values():
panel.set_grid_size(_grid_size)
panel.set_snap_enabled(_snap_enabled)
_whole_preview.set_grid_size(_grid_size)
_whole_preview.set_snap_enabled(_snap_enabled)
```
Called after loading settings in `_ready()` and whenever grid/snap changes.
### 4h. Save Flow Update
Color is already included in `_build_json_data()` via `panel.get_shape_data()` which returns `color`. No .stk format changes needed for Phase 3.
### 4i. Reset Views Handler
```gdscript
func _on_view_menu_id_pressed(id: int) -> void:
if id == 0: # Reset Views
for panel in _body_part_panels.values():
panel.reset_view()
_whole_preview.reset_view()
```
---
## 5. Scene File Changes
### 5a. `scenes/stickman_editor.tscn`
Add new nodes under the root `StickmanEditor`:
```
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Configure Grid"
ok_button_text = "OK"
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
layout_mode = 2
[node name="GridSizeLabel" type="Label" parent="GridConfigDialog/VBoxContainer"]
layout_mode = 2
text = "Grid Size (pixels):"
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
max_value = 100.0
value = 5.0
step = 1.0
rounded = true
```
### 5b. `scenes/body_part_panel.tscn`
Add a ColorPicker popup:
```
[node name="ColorPickerPopup" type="PopupPanel" parent="."]
unique_name_in_owner = true
visible = false
[node name="VBoxContainer" type="VBoxContainer" parent="ColorPickerPopup"]
layout_mode = 2
[node name="ColorPicker" type="ColorPicker" parent="ColorPickerPopup/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(300, 300)
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
layout_mode = 2
alignment = 2 # END
[node name="CancelButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
layout_mode = 2
text = "Cancel"
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
layout_mode = 2
text = "OK"
```
---
## 6. Files Modified
| File | Changes |
|---|---|
| `scripts/body_part_panel.gd` | Major: panning (middle mouse), grid drawing, snap-to-grid in vertex drag, ColorPicker integration, "Delete" shape, "Remove Point" vertex delete, context menu refactor, reset_view(), set_grid_size(), set_snap_enabled() |
| `scripts/stickman_editor.gd` | Modular: Edit/View menus, GridConfigDialog, settings.json load/save, broadcast grid/snap settings, Reset Views handler |
| `scripts/whole_stickman_preview.gd` | Major: panning, grid drawing, snap-to-grid in part drag, reset_view(), set_grid_size(), set_snap_enabled() |
| `scenes/stickman_editor.tscn` | Add GridConfigDialog + children |
| `scenes/body_part_panel.tscn` | Add ColorPickerPopup + children |
| `docs/phase3_spec.md` | This file |
---
## 7. Implementation Order
1. **Menus + Settings** — Add Edit/View menus, settings.json load/save, broadcast infrastructure
2. **GridConfigDialog** — Dialog + SpinBox in editor scene, wire up to settings
3. **Panning** — Middle-mouse drag in BodyPartPanel + WholeStickmanPreview
4. **Grid drawing** — Background grid in both panels, accounting for zoom + pan offset
5. **Snap to Grid** — Vertex drag snap + Whole Stickman part drag snap
6. **Reset Views** — View menu item → reset zoom + pan on all panels
7. **ColorPicker** — Add ColorPickerPopup, context menu "Color..." entry, preview + commit/cancel
8. **Shape Delete** — Context menu "Delete" entry, conditional visibility
9. **Vertex Delete** — Right-click on vertex → "Remove Point", edge cases (2→line, <2→clear)
10. **Scene files** — Update both .tscn files with new nodes
11. **README update** — Document Phase 3 features
---
## 8. Edge Cases & Constraints
- **Grid size = 1**: Grid lines become very dense. Still functional; performance acceptable for ~50-100 lines per panel at typical panel sizes (~230x160px @ 1px grid = massive density, but max panel size is ~500x300 visible → 500 lines). If performance issues arise, skip drawing when `_grid_size < 3` at high zoom.
- **Pan + zoom interaction**: Pan offset is in screen space, applied via `draw_set_transform`. The offset is NOT divided by zoom — it's added before the scale, so pan feels natural at any zoom level. At 2x zoom, dragging 100px in screen space moves the view by 100px world units.
- **Middle mouse + any other button**: If the user presses middle mouse while dragging a vertex, the vertex drag is cancelled and panning takes over. This prevents conflicts.
- **ColorPicker Cancel**: If the user opens the color picker and clicks Cancel, the shape color MUST revert to the pre-dialog value. The preview during color picker interaction updates `shape_data.color` directly; on cancel we restore from `_restore_color`.
- **Delete last remaining vertex**: If Remove Point is called on a shape with 2 vertices, it becomes an open line (`closed=false`). If called on a shape with 1 vertex, the shape is cleared entirely.
- **Delete shape while selected**: When shape is deleted, clear `_selected`, clear `_dragging_vertex`, emit `shape_changed`.
- **Settings file missing**: Gracefully use defaults (grid_size=5, snap_to_grid=false). No error dialog.
- **Settings file corrupted**: Gracefully use defaults. No error dialog.
- **Snap to Grid toggle**: When toggling snap ON, vertices don't immediately snap — they only snap when dragged. This matches user expectation (existing vertex positions aren't retroactively modified).
- **Context menu positioning**: All context menus use `popup_on_parent(Rect2(mb.global_position, Vector2.ONE))` for proper screen-space positioning.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 994 B

+16
View File
@@ -0,0 +1,16 @@
[gd_scene load_steps=2 format=3 uid="uid://c5jkoyu1ik6fo"]
[ext_resource type="PackedScene" uid="uid://6wqo0sp4eij6" path="res://scenes/stickman_editor.tscn" id="1_ig7tw"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="StickmanEditor" parent="." instance=ExtResource("1_ig7tw")]
layout_mode = 1
+16
View File
@@ -0,0 +1,16 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="stickman"
run/main_scene="uid://c5jkoyu1ik6fo"
config/features=PackedStringArray("4.4", "Forward Plus")
config/icon="res://icon.svg"
+84
View File
@@ -0,0 +1,84 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/body_part_panel.gd" id="1_panel_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_panel"]
bg_color = Color(0.15, 0.15, 0.15, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
bg_color = Color(0.12, 0.12, 0.12, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.25, 0.25, 0.25, 1)
[node name="BodyPartPanel" type="PanelContainer"]
custom_minimum_size = Vector2(230, 160)
offset_right = 230.0
offset_bottom = 160.0
size_flags_vertical = 3
theme_override_styles/panel = SubResource("StyleBox_panel")
script = ExtResource("1_panel_script")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
offset_left = 5.0
offset_top = 5.0
offset_right = 225.0
offset_bottom = 155.0
[node name="TitleLabel" type="Label" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Body Part"
horizontal_alignment = 1
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 13
[node name="DrawingArea" type="Control" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 1
clip_contents = true
theme_override_styles/panel = SubResource("StyleBox_drawing")
[node name="ContextMenu" type="PopupMenu" parent="."]
unique_name_in_owner = true
item_count = 0
[node name="ColorPickerPopup" type="PopupPanel" parent="."]
unique_name_in_owner = true
visible = false
[node name="VBoxContainer" type="VBoxContainer" parent="ColorPickerPopup"]
layout_mode = 2
[node name="ColorPicker" type="ColorPicker" parent="ColorPickerPopup/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(300, 300)
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
layout_mode = 2
alignment = 2
[node name="CancelButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
unique_name_in_owner = true
layout_mode = 2
text = "Cancel"
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
unique_name_in_owner = true
layout_mode = 2
text = "OK"
+151
View File
@@ -0,0 +1,151 @@
[gd_scene load_steps=4 format=3 uid="uid://6wqo0sp4eij6"]
[ext_resource type="Script" uid="uid://dlacmxk257ro" path="res://scripts/stickman_editor.gd" id="1_editor_script"]
[ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/whole_stickman_preview.gd" id="2_preview_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_preview"]
bg_color = Color(0.12, 0.12, 0.12, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="StickmanEditor" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="MenuBar" type="MenuBar" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
grow_horizontal = 2
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Stickman Name:"
vertical_alignment = 1
[node name="StickmanNameEdit" type="LineEdit" parent="TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Stickman"
[node name="MainLayout" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 68.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="LeftColumn" type="VBoxContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="CenterColumn" type="VBoxContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="WholeStickmanPreview" type="PanelContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
theme_override_styles/panel = SubResource("StyleBox_preview")
script = ExtResource("2_preview_script")
[node name="VBoxContainer" type="VBoxContainer" parent="MainLayout/WholeStickmanPreview"]
layout_mode = 2
[node name="PreviewTitle" type="Label" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 14
text = "Whole Stickman"
horizontal_alignment = 1
[node name="PreviewArea" type="Control" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 1
clip_contents = true
[node name="SaveDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Save Stickman"
size = Vector2i(600, 450)
access = 2
filters = PackedStringArray("*.stk ; Stickman Files")
[node name="LoadDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Open a File"
size = Vector2i(600, 450)
ok_button_text = "Open"
file_mode = 0
access = 2
filters = PackedStringArray("*.stk ; Stickman Files")
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Clear Stickman"
ok_button_text = "Clear"
dialog_text = "Are you sure you want to clear the current stickman?
This action cannot be undone."
[node name="ErrorDialog" type="AcceptDialog" parent="."]
unique_name_in_owner = true
title = "Error"
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Configure Grid"
ok_button_text = "OK"
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
layout_mode = 2
[node name="GridSizeLabel" type="Label" parent="GridConfigDialog/VBoxContainer"]
layout_mode = 2
text = "Grid Size (pixels):"
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
max_value = 100.0
value = 15.0
step = 1.0
rounded = true
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://blq30ou2f1boe
+513
View File
@@ -0,0 +1,513 @@
extends Control
## StickmanEditor - Main controller for the Stickman Studio editor.
##
## Manages menu actions (save/load/clear), coordinates all body part panels
## and the whole stickman preview, and handles JSON serialization/deserialization.
##
## Phase 2: FILE_VERSION "1.1", backward-compatible loading of "1.0" files,
## cross-panel selection coordination.
## Phase 3: Edit/View menus, grid configuration, settings persistence,
## snap-to-grid, Reset Views.
## Phase 4: v1.2 JSON with multi-shape shapes[] array, rotation, scale per part.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const BODY_PART_NAMES: PackedStringArray = [
"head", "torso",
"left_upper_arm", "left_lower_arm",
"right_upper_arm", "right_lower_arm",
"left_upper_leg", "left_lower_leg",
"right_upper_leg", "right_lower_leg"
]
const BODY_PART_DISPLAY: Dictionary = {
"head": "Head",
"torso": "Torso",
"left_upper_arm": "Left Upper Arm",
"left_lower_arm": "Left Lower Arm",
"right_upper_arm": "Right Upper Arm",
"right_lower_arm": "Right Lower Arm",
"left_upper_leg": "Left Upper Leg",
"left_lower_leg": "Left Lower Leg",
"right_upper_leg": "Right Upper Leg",
"right_lower_leg": "Right Lower Leg",
}
const DEFAULT_POSITIONS: Dictionary = {
"head": Vector2(150, 40),
"torso": Vector2(150, 100),
"left_upper_arm": Vector2(140, 105),
"left_lower_arm": Vector2(130, 150),
"right_upper_arm": Vector2(160, 105),
"right_lower_arm": Vector2(170, 150),
"left_upper_leg": Vector2(142, 160),
"left_lower_leg": Vector2(135, 210),
"right_upper_leg": Vector2(158, 160),
"right_lower_leg": Vector2(165, 210),
}
const FILE_VERSION := "1.3"
const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3"]
const SETTINGS_PATH := "user://settings.json"
const SETTINGS_VERSION := "1.0"
const DEFAULT_GRID_SIZE := 15
const MIN_GRID_SIZE := 1
const MAX_GRID_SIZE := 100
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@onready var _stickman_name_edit: LineEdit = %StickmanNameEdit
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _body_part_panels: Dictionary = {} ## { String : BodyPartPanel }
@onready var _whole_preview: WholeStickmanPreview = %WholeStickmanPreview
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog
@onready var _error_dialog: AcceptDialog = %ErrorDialog
@onready var _grid_config_dialog: ConfirmationDialog = %GridConfigDialog
@onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox
# ---------------------------------------------------------------------------
# Grid / snap state
# ---------------------------------------------------------------------------
var _grid_size: int = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
# Phase 5: shape clipboard
var _shape_clipboard: Dictionary = {}
var _edit_menu: PopupMenu
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_setup_menu_bar()
_setup_dialogs()
_populate_body_part_panels()
_whole_preview.part_moved.connect(_on_preview_part_moved)
_whole_preview.set_body_parts(_collect_all_shape_data())
_load_settings()
_broadcast_settings()
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
# ---------------------------------------------------------------------------
# Menu & Dialog setup
# ---------------------------------------------------------------------------
func _setup_menu_bar() -> void:
var file_menu: PopupMenu = PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("Save", 0)
file_menu.add_item("Load", 1)
file_menu.add_separator()
file_menu.add_item("Clear", 2)
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
_edit_menu = PopupMenu.new()
_edit_menu.name = "EditMenu"
_edit_menu.add_item("Configure Grid...", 0)
_edit_menu.add_separator()
_edit_menu.add_item(_snap_menu_label(), 1)
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
_edit_menu.about_to_popup.connect(_on_edit_menu_about_to_popup)
_menu_bar.add_child(_edit_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "Edit")
var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("Reset Views", 0)
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
func _setup_dialogs() -> void:
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_clear_confirm.confirmed.connect(_on_clear_confirmed)
# ---------------------------------------------------------------------------
# Body-part panel population
# ---------------------------------------------------------------------------
func _populate_body_part_panels() -> void:
var left_col: VBoxContainer = %LeftColumn
var center_col: VBoxContainer = %CenterColumn
var body_part_scene: PackedScene = load("res://scenes/body_part_panel.tscn")
var left_names: Array[String] = ["head", "left_upper_arm", "left_lower_arm", "left_upper_leg", "left_lower_leg"]
var center_names: Array[String] = ["torso", "right_upper_arm", "right_lower_arm", "right_upper_leg", "right_lower_leg"]
for part_name: String in left_names:
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
panel.part_name = part_name
panel.display_name = BODY_PART_DISPLAY[part_name]
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
_body_part_panels[part_name] = panel
left_col.add_child(panel)
for part_name: String in center_names:
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
panel.part_name = part_name
panel.display_name = BODY_PART_DISPLAY[part_name]
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
_body_part_panels[part_name] = panel
center_col.add_child(panel)
# ---------------------------------------------------------------------------
# Signal handlers - menus
# ---------------------------------------------------------------------------
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: # Save
_save_dialog.popup_centered_ratio(0.6)
1: # Load
_load_dialog.popup_centered_ratio(0.6)
2: # Clear
_clear_confirm.popup_centered()
func _on_edit_menu_id_pressed(id: int) -> void:
match id:
0: # Configure Grid...
_grid_size_spin_box.value = float(_grid_size)
_grid_config_dialog.popup_centered()
1: # Snap to Grid (checkable)
_snap_enabled = not _snap_enabled
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
_save_settings()
_broadcast_settings()
func _on_edit_menu_about_to_popup() -> void:
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
func _on_view_menu_id_pressed(id: int) -> void:
if id == 0: # Reset Views
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).reset_view()
_whole_preview.reset_view()
func _on_grid_config_confirmed() -> void:
_grid_size = int(_grid_size_spin_box.value)
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_save_settings()
_broadcast_settings()
func _on_save_file_selected(path: String) -> void:
if not path.ends_with(".stk"):
path += ".stk"
var data := _build_json_data()
var json_text := JSON.stringify(data, "\t", false)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
_show_error("Failed to save file: " + str(FileAccess.get_open_error()))
return
file.store_string(json_text)
file.close()
func _on_load_file_selected(path: String) -> void:
if not FileAccess.file_exists(path):
_show_error("File not found: " + path)
return
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
_show_error("Failed to open file: " + str(FileAccess.get_open_error()))
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if json == null:
_show_error("Failed to parse JSON from file: " + path)
return
var err: String = _apply_json_data(json)
if not err.is_empty():
_show_error(err)
return
func _on_clear_confirmed() -> void:
_clear_all()
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
# ---------------------------------------------------------------------------
# Signal handlers - body-part panels
# ---------------------------------------------------------------------------
func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
_whole_preview.set_body_parts(_collect_all_shape_data())
if _new_data.is_empty():
_whole_preview.set_part_position(part_name, DEFAULT_POSITIONS.get(part_name, Vector2.ZERO))
_whole_preview.set_part_rotation(part_name, 0.0)
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
_broadcast_clipboard_state()
func _on_body_part_shape_selected(part_name: String) -> void:
for other_name: String in BODY_PART_NAMES:
if other_name != part_name:
var panel: BodyPartPanel = _body_part_panels.get(other_name) as BodyPartPanel
if panel:
panel.deselect()
func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
pass
# ---------------------------------------------------------------------------
# Data collection / JSON helpers
# ---------------------------------------------------------------------------
func _collect_all_shape_data() -> Dictionary:
var all_data: Dictionary = {}
for part_name: String in BODY_PART_NAMES:
var panel = _body_part_panels.get(part_name)
if panel:
var shapes_arr: Array = panel.get_shape_data()
var pos := _whole_preview.get_part_position(part_name)
var rot := _whole_preview.get_part_rotation(part_name)
var scl := _whole_preview.get_part_scale(part_name)
all_data[part_name] = {
"shapes": shapes_arr,
"position": {"x": pos.x, "y": pos.y},
"rotation": rot,
"scale": {"x": scl.x, "y": scl.y}
}
return all_data
func _build_json_data() -> Dictionary:
var time_str := Time.get_datetime_string_from_system()
var body_parts: Dictionary = _collect_all_shape_data()
return {
"version": FILE_VERSION,
"stickman_name": _stickman_name_edit.text.strip_edges(),
"part_order": _whole_preview.get_part_order(),
"body_parts": body_parts,
"metadata": {
"created_at": time_str,
"modified_at": time_str,
}
}
func _apply_json_data(json: Variant) -> String:
if not json is Dictionary:
return "Invalid file format: root must be a dictionary."
var dict: Dictionary = json as Dictionary
var version: String = dict.get("version", "")
if version not in SUPPORTED_VERSIONS:
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', or '1.3')" % version
_stickman_name_edit.text = dict.get("stickman_name", "")
var body_parts: Variant = dict.get("body_parts")
if not body_parts is Dictionary:
return "Invalid file format: 'body_parts' must be a dictionary."
var body_dict: Dictionary = body_parts as Dictionary
_clear_all_panels()
var positions: Dictionary = {}
for part_name: String in BODY_PART_NAMES:
var part_data: Variant = body_dict.get(part_name)
var panel = _body_part_panels.get(part_name) as BodyPartPanel
if part_data is Dictionary:
var pd: Dictionary = part_data as Dictionary
if pd.has("shapes"):
# v1.2 format: shapes array already in correct structure
if panel:
panel.set_shape_data(pd["shapes"])
else:
# v1.0/v1.1 format: single shape dict at top level, wrap in array
if version == "1.0":
if not pd.has("closed"):
var st: String = str(pd.get("shape_type", ""))
pd["closed"] = (st == "rectangle" or st == "circle")
if not pd.has("vertex_flags"):
pd["vertex_flags"] = []
if panel:
panel.set_shape_data(pd)
# Position (all versions)
var pos_variant: Variant = pd.get("position")
if pos_variant is Dictionary:
var pos_dict: Dictionary = pos_variant as Dictionary
positions[part_name] = Vector2(
float(pos_dict.get("x", 0)),
float(pos_dict.get("y", 0))
)
else:
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
# Rotation (v1.2+, defaults to 0 for older versions)
_whole_preview.set_part_rotation(part_name, float(pd.get("rotation", 0.0)))
# Scale (v1.2+, defaults to 1.0 for older versions)
var scl_variant: Variant = pd.get("scale")
if scl_variant is Dictionary:
var sd: Dictionary = scl_variant as Dictionary
_whole_preview.set_part_scale(part_name, Vector2(
float(sd.get("x", 1.0)), float(sd.get("y", 1.0))))
else:
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
else:
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
_whole_preview.set_part_rotation(part_name, 0.0)
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
var loaded_order: Variant = dict.get("part_order")
if loaded_order is Array:
_whole_preview.set_part_order(loaded_order as Array)
_whole_preview.reset_view()
_whole_preview.set_all_part_positions(positions)
_whole_preview.set_body_parts(_collect_all_shape_data())
return ""
# ---------------------------------------------------------------------------
# Clear helpers
# ---------------------------------------------------------------------------
func _clear_all_panels() -> void:
for part_name: String in BODY_PART_NAMES:
var panel = _body_part_panels.get(part_name)
if panel:
panel.clear_shape()
func _clear_all() -> void:
_clear_all_panels()
_whole_preview.clear_all()
_whole_preview.set_all_part_positions(DEFAULT_POSITIONS.duplicate())
_stickman_name_edit.text = ""
_shape_clipboard.clear()
_broadcast_clipboard_state()
# ---------------------------------------------------------------------------
# Phase 5: Shape clipboard
# ---------------------------------------------------------------------------
func _on_shape_copy_requested(shape_dict: Dictionary, _part_name: String) -> void:
_shape_clipboard = shape_dict.duplicate(true)
_broadcast_clipboard_state()
func _broadcast_clipboard_state() -> void:
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).set_has_clipboard(not _shape_clipboard.is_empty())
func _paste_shape_into_panel(part_name: String, world_pos: Vector2) -> void:
if _shape_clipboard.is_empty():
return
var panel = _body_part_panels.get(part_name)
if panel:
panel.paste_shape(_shape_clipboard, world_pos)
func _on_shape_paste_requested(world_pos: Vector2, part_name: String) -> void:
_paste_shape_into_panel(part_name, world_pos)
# ---------------------------------------------------------------------------
# Phase 3: Settings persistence
# ---------------------------------------------------------------------------
func _load_settings() -> void:
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if not json is Dictionary:
return
var d := json as Dictionary
_grid_size = int(d.get("grid_size", DEFAULT_GRID_SIZE))
_snap_enabled = bool(d.get("snap_to_grid", false))
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
func _save_settings() -> void:
var data := {
"version": SETTINGS_VERSION,
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
func _broadcast_settings() -> void:
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
var p := panel as BodyPartPanel
p.set_grid_size(_grid_size)
p.set_snap_enabled(_snap_enabled)
_whole_preview.set_grid_size(_grid_size)
_whole_preview.set_snap_enabled(_snap_enabled)
func _snap_menu_label() -> String:
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
+1
View File
@@ -0,0 +1 @@
uid://dlacmxk257ro
+872
View File
@@ -0,0 +1,872 @@
extends PanelContainer
class_name WholeStickmanPreview
## WholeStickmanPreview - Renders all body-part shapes assembled together.
##
## The user can drag individual body parts around the preview area
## to reposition them relative to one another.
##
## Phase 2: Zoom support (mouse wheel + draw_set_transform), closed-based
## rendering instead of shape_type-based, input coordinate transforms.
## Phase 3: Middle-mouse panning, background grid, snap-to-grid on part drag,
## Reset Views, settings broadcast integration.
## Phase 4: Selection with white bounding box, rotation gizmo (circle below),
## scale gizmo (corner crosses), Ctrl snap for rotation/scale, multi-shape
## rendering per part.
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal part_moved(part_name: String, new_position: Vector2)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const ROTATION_CIRCLE_RADIUS: float = 5.0
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0
const ROTATION_CIRCLE_OFFSET: float = 26.0
const SCALE_CROSS_SIZE: float = 5.0
const SCALE_CROSS_HIT_RADIUS: float = 12.0
const DEFAULT_PART_ORDER: PackedStringArray = [
"head", "torso",
"left_upper_arm", "left_lower_arm",
"right_upper_arm", "right_lower_arm",
"left_upper_leg", "left_lower_leg",
"right_upper_leg", "right_lower_leg"
]
enum Interaction { NONE, TRANSLATE, ROTATE, SCALE }
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@onready var preview_area: Control = %PreviewArea
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _part_shapes: Dictionary = {}
var _part_positions: Dictionary = {}
var _part_rotations: Dictionary = {}
var _part_scales: Dictionary = {}
var _part_bounds: Dictionary = {}
# Translation drag
var _dragging_part: String = ""
var _drag_offset: Vector2 = Vector2.ZERO
# Phase 4 interaction state
var _selected_part: String = ""
var _interaction: int = Interaction.NONE
# Rotation state
var _rotate_start_rotation: float = 0.0
var _rotate_total_delta: float = 0.0
var _rotate_prev_angle: float = 0.0
# Scale state
var _scale_corner: int = -1
var _scale_anchor: Vector2 = Vector2.ZERO
var _scale_original_size: Vector2 = Vector2.ZERO
# Zoom + pan
var _zoom: float = 1.0
# Phase 3 state
var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 15
var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
# Phase 5: z-ordering
var _part_order: Array[String] = []
var _context_menu: PopupMenu
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_body_parts(parts_data: Dictionary) -> void:
## parts_data: { part_name: { shapes: Array, position?, rotation?, scale? } }
for part_name: String in parts_data:
var entry: Variant = parts_data[part_name]
if entry is Dictionary:
var d := entry as Dictionary
# Phase 4: extract shapes array
if d.has("shapes"):
_part_shapes[part_name] = _variant_to_shapes_array(d["shapes"])
else:
# Backward compat: single shape dict (v1.0/v1.1 data shape)
_part_shapes[part_name] = [_variant_to_single_shape(d)]
# Position
var pos_v: Variant = d.get("position")
if pos_v is Dictionary:
var pd := pos_v as Dictionary
_part_positions[part_name] = Vector2(
float(pd.get("x", 0)), float(pd.get("y", 0)))
elif not _part_positions.has(part_name):
_part_positions[part_name] = Vector2.ZERO
# Rotation (Phase 4)
_part_rotations[part_name] = float(d.get("rotation", 0.0))
# Scale (Phase 4)
var scl_v: Variant = d.get("scale")
if scl_v is Dictionary:
var sd := scl_v as Dictionary
_part_scales[part_name] = Vector2(
float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
else:
_part_scales[part_name] = Vector2(1.0, 1.0)
else:
_part_shapes[part_name] = []
_part_positions[part_name] = Vector2.ZERO
_part_rotations[part_name] = 0.0
_part_scales[part_name] = Vector2(1.0, 1.0)
if _part_order.is_empty():
_part_order = []
_part_order.assign(DEFAULT_PART_ORDER)
for pn: String in DEFAULT_PART_ORDER:
if _part_order.find(pn) == -1:
_part_order.append(pn)
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_position(part_name: String) -> Vector2:
return _part_positions.get(part_name, Vector2.ZERO)
func set_part_position(part_name: String, pos: Vector2) -> void:
_part_positions[part_name] = pos
_rebuild_bounds()
preview_area.queue_redraw()
func set_all_part_positions(positions: Dictionary) -> void:
for part_name: String in positions:
_part_positions[part_name] = positions[part_name]
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_rotation(part_name: String) -> float:
return _part_rotations.get(part_name, 0.0)
func set_part_rotation(part_name: String, rot: float) -> void:
_part_rotations[part_name] = rot
_rebuild_bounds()
preview_area.queue_redraw()
func get_part_scale(part_name: String) -> Vector2:
return _part_scales.get(part_name, Vector2(1.0, 1.0))
func set_part_scale(part_name: String, scl: Vector2) -> void:
_part_scales[part_name] = scl
_rebuild_bounds()
preview_area.queue_redraw()
func clear_all() -> void:
_part_shapes.clear()
_part_positions.clear()
_part_rotations.clear()
_part_scales.clear()
_part_bounds.clear()
_selected_part = ""
_interaction = Interaction.NONE
_pan_offset = Vector2.ZERO
_zoom = 1.0
_part_order.clear()
preview_area.queue_redraw()
# Phase 3 public API
func set_grid_size(size: int) -> void:
_grid_size = size
preview_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
preview_area.queue_redraw()
func get_part_order() -> Array:
return _part_order.duplicate()
func set_part_order(order: Array) -> void:
_part_order.clear()
for pn in order:
if pn is String:
_part_order.append(pn as String)
preview_area.queue_redraw()
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if not preview_area.draw.is_connected(_on_preview_draw):
preview_area.draw.connect(_on_preview_draw)
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
preview_area.gui_input.connect(_on_preview_gui_input)
_context_menu = PopupMenu.new()
_context_menu.name = "PreviewContextMenu"
add_child(_context_menu)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
func _on_preview_draw() -> void:
preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
for part_name: String in _part_order:
if not _part_shapes.has(part_name):
continue
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
continue
var arr: Array = shapes_array as Array
if arr.is_empty():
continue
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(part_name, 0.0)
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
# Collect all points (translated by position) to find center
var all_raw: PackedVector2Array = PackedVector2Array()
for sd in arr:
if sd is Dictionary:
var sd_pts := _variant_to_points((sd as Dictionary).get("points", []))
for pt: Vector2 in sd_pts:
all_raw.append(pt + pos)
if all_raw.is_empty():
continue
var center: Vector2 = _compute_center(all_raw)
# Draw each shape
for sd in arr:
if not sd is Dictionary:
continue
var sdd := sd as Dictionary
var pts := _variant_to_points(sdd.get("points", []))
if pts.size() < 2:
continue
var color := Color.from_string(str(sdd.get("color", "#000000")), Color.BLACK)
var closed: bool = bool(sdd.get("closed", false))
var transformed := PackedVector2Array()
for pt: Vector2 in pts:
transformed.append(_transform_point(pt, pos, center, rot_deg, scl))
if closed:
preview_area.draw_colored_polygon(transformed, color)
_draw_polyline_preview(transformed, color, closed)
# Draw label
var font := preview_area.get_theme_default_font()
var label := _short_label(part_name)
if font:
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
var label_pos := Vector2(bounds.position.x, bounds.position.y - 14)
preview_area.draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color.WHITE)
# Phase 4: Draw gizmos for selected part
if part_name == _selected_part:
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
if bounds.has_area():
# White bounding box
preview_area.draw_rect(bounds, Color.WHITE, false, 1.5 / _zoom)
# Rotation circle
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
# Scale crosses at 4 corners
var cross_half := SCALE_CROSS_SIZE / _zoom
for i: int in range(4):
var corner := _get_corner_position(bounds, i)
# Horizontal line
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
Color.WHITE, 1.5 / _zoom)
# Vertical line
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
Color.WHITE, 1.5 / _zoom)
# Highlight dragged part
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
func _draw_grid() -> void:
var gs := float(_grid_size)
if gs <= 0:
return
var area_size := preview_area.size
var world_origin := -_pan_offset / _zoom
var world_size := area_size / _zoom
var world_end := world_origin + world_size
var start_x: float = floor(world_origin.x / gs) * gs
var start_y: float = floor(world_origin.y / gs) * gs
var line_width: float = 1.0 / _zoom
var x: float = start_x
while x <= world_end.x:
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
y += gs
func _draw_polyline_preview(pts: PackedVector2Array, color: Color, closed: bool) -> void:
for i: int in range(pts.size() - 1):
preview_area.draw_line(pts[i], pts[i + 1], color, 1.5)
if closed and pts.size() >= 2:
preview_area.draw_line(pts[pts.size() - 1], pts[0], color, 1.5)
# ---------------------------------------------------------------------------
# Transform helpers
# ---------------------------------------------------------------------------
func _transform_point(pt: Vector2, pos: Vector2, center: Vector2, rot_deg: float, scl: Vector2) -> Vector2:
var wp := pt + pos
wp = wp - center
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
wp = wp.rotated(deg_to_rad(rot_deg))
wp = wp + center
return wp
func _compute_center(pts: PackedVector2Array) -> Vector2:
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for pt: Vector2 in pts:
min_x = min(min_x, pt.x)
min_y = min(min_y, pt.y)
max_x = max(max_x, pt.x)
max_y = max(max_y, pt.y)
return Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5)
func _compute_transformed_bounds_from_points(raw_pts: PackedVector2Array, center: Vector2, rot_deg: float, scl: Vector2) -> Rect2:
if raw_pts.is_empty():
return Rect2()
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for pt: Vector2 in raw_pts:
var wp := pt - center
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
wp = wp.rotated(deg_to_rad(rot_deg))
wp = wp + center
min_x = min(min_x, wp.x)
min_y = min(min_y, wp.y)
max_x = max(max_x, wp.x)
max_y = max(max_y, wp.y)
if min_x > max_x or min_y > max_y:
return Rect2()
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
func _get_corner_position(bounds: Rect2, corner: int) -> Vector2:
match corner:
0: return bounds.position # top-left
1: return Vector2(bounds.end.x, bounds.position.y) # top-right
2: return bounds.end # bottom-right
3: return Vector2(bounds.position.x, bounds.end.y) # bottom-left
return bounds.position
func _get_opposite_corner(bounds: Rect2, corner: int) -> Vector2:
match corner:
0: return bounds.end
1: return Vector2(bounds.position.x, bounds.end.y)
2: return bounds.position
3: return Vector2(bounds.end.x, bounds.position.y)
return bounds.position
func _compute_original_bounds_size(part_name: String) -> Vector2:
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
return Vector2.ZERO
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
var wp := pt + pos
min_x = min(min_x, wp.x); min_y = min(min_y, wp.y)
max_x = max(max_x, wp.x); max_y = max(max_y, wp.y)
if min_x > max_x or min_y > max_y:
return Vector2.ZERO
return Vector2(max_x - min_x, max_y - min_y)
# ---------------------------------------------------------------------------
# GUI Input
# ---------------------------------------------------------------------------
func _on_preview_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
# Middle mouse -> panning
if mb.button_index == MOUSE_BUTTON_MIDDLE:
if mb.pressed:
_is_panning = true
_pan_start = mb.position
_end_interaction()
else:
_is_panning = false
return
# Mouse wheel -> zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
preview_area.queue_redraw()
return
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
preview_area.queue_redraw()
return
# Left mouse button
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
var world_pos := _screen_to_world(mb.position)
_try_start_interaction(world_pos)
else:
_end_interaction()
# Right mouse button -> context menu
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb)
return
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _is_panning:
_pan_offset += (mm.position - _pan_start) / _zoom
_pan_start = mm.position
preview_area.queue_redraw()
return
match _interaction:
Interaction.TRANSLATE:
_handle_translate_drag(mm)
Interaction.ROTATE:
_handle_rotate_drag(mm)
Interaction.SCALE:
_handle_scale_drag(mm)
func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _try_start_interaction(world_pos: Vector2) -> void:
_interaction = Interaction.NONE
# Check gizmos on selected part first
if not _selected_part.is_empty():
var shapes_array: Variant = _part_shapes.get(_selected_part, [])
if shapes_array is Array and not (shapes_array as Array).is_empty():
var all_raw := _collect_all_raw_points(_selected_part)
if not all_raw.is_empty():
var pos: Vector2 = _part_positions.get(_selected_part, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
var center := _compute_center(all_raw)
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
if bounds.has_area():
# Rotation circle hit
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
_interaction = Interaction.ROTATE
_rotate_start_rotation = _part_rotations[_selected_part]
_rotate_total_delta = 0.0
var bounds_center := bounds.position + bounds.size * 0.5
_rotate_prev_angle = rad_to_deg(bounds_center.angle_to_point(world_pos))
preview_area.queue_redraw()
return
# Scale cross hit
for i: int in range(4):
var corner := _get_corner_position(bounds, i)
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
_interaction = Interaction.SCALE
_scale_corner = i
_scale_anchor = _get_opposite_corner(bounds, i)
_scale_original_size = _compute_original_bounds_size(_selected_part)
preview_area.queue_redraw()
return
# Check part hit-test for translation (reverse order: front to back)
for i: int in range(_part_order.size() - 1, -1, -1):
var part_name: String = _part_order[i]
if not _part_bounds.has(part_name):
continue
var bounds: Variant = _part_bounds[part_name]
if bounds is Rect2:
var rect: Rect2 = bounds as Rect2
if rect.grow(8.0).has_point(world_pos):
_selected_part = part_name
_interaction = Interaction.TRANSLATE
_dragging_part = part_name
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
_drag_offset = world_pos - pos
preview_area.queue_redraw()
return
# Clicked on empty space -> deselect
_selected_part = ""
_interaction = Interaction.NONE
preview_area.queue_redraw()
func _end_interaction() -> void:
if _interaction != Interaction.NONE:
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
_dragging_part = ""
_drag_offset = Vector2.ZERO
_interaction = Interaction.NONE
preview_area.queue_redraw()
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
if _dragging_part.is_empty():
return
var world_pos := _screen_to_world(mm.position)
var new_pos := world_pos - _drag_offset
_part_positions[_dragging_part] = new_pos
_rebuild_bounds()
if _snap_enabled:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
var bb_top_left := (bounds as Rect2).position
var gs := float(_grid_size)
if gs > 0.0:
var snapped_tl := Vector2(
round(bb_top_left.x / gs) * gs,
round(bb_top_left.y / gs) * gs
)
new_pos += snapped_tl - bb_top_left
_part_positions[_dragging_part] = new_pos
_rebuild_bounds()
preview_area.queue_redraw()
part_moved.emit(_dragging_part, new_pos)
func _handle_rotate_drag(mm: InputEventMouseMotion) -> void:
if _selected_part.is_empty():
return
var all_raw := _collect_all_raw_points(_selected_part)
if all_raw.is_empty():
return
var center := _compute_center(all_raw)
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
var bounds_center := bounds.position + bounds.size * 0.5
var world_pos := _screen_to_world(mm.position)
var current_angle := rad_to_deg(bounds_center.angle_to_point(world_pos))
var frame_delta := current_angle - _rotate_prev_angle
if frame_delta > 180.0:
frame_delta -= 360.0
elif frame_delta < -180.0:
frame_delta += 360.0
_rotate_total_delta += frame_delta
_rotate_prev_angle = current_angle
var new_rotation := _rotate_start_rotation + _rotate_total_delta
if Input.is_key_pressed(KEY_CTRL):
new_rotation = round(new_rotation / 15.0) * 15.0
_part_rotations[_selected_part] = new_rotation
preview_area.queue_redraw()
func _handle_scale_drag(mm: InputEventMouseMotion) -> void:
if _selected_part.is_empty() or _scale_corner < 0:
return
if _scale_original_size.x <= 0 or _scale_original_size.y <= 0:
return
var all_raw := _collect_all_raw_points(_selected_part)
if all_raw.is_empty():
return
var center := _compute_center(all_raw)
var world_pos := _screen_to_world(mm.position)
if _snap_enabled:
var gs := float(_grid_size)
if gs > 0.0:
world_pos.x = round(world_pos.x / gs) * gs
world_pos.y = round(world_pos.y / gs) * gs
var rot_rad := deg_to_rad(_part_rotations.get(_selected_part, 0.0))
var mouse_rel := world_pos - center
var anchor_rel := _scale_anchor - center
var mouse_unrot := mouse_rel.rotated(-rot_rad)
var anchor_unrot := anchor_rel.rotated(-rot_rad)
var new_scale := Vector2(
abs(mouse_unrot.x - anchor_unrot.x) / _scale_original_size.x,
abs(mouse_unrot.y - anchor_unrot.y) / _scale_original_size.y
)
new_scale = Vector2(max(new_scale.x, 0.01), max(new_scale.y, 0.01))
if Input.is_key_pressed(KEY_CTRL):
var uniform := maxf(new_scale.x, new_scale.y)
new_scale = Vector2(uniform, uniform)
_part_scales[_selected_part] = new_scale
preview_area.queue_redraw()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
func _collect_all_raw_points(part_name: String) -> PackedVector2Array:
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
return PackedVector2Array()
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var result := PackedVector2Array()
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
result.append(pt + pos)
return result
func _variant_to_shapes_array(data: Variant) -> Array:
if data is Array:
var arr: Array = []
for item in (data as Array):
if item is Dictionary:
arr.append(_variant_to_single_shape(item))
return arr
return []
func _variant_to_single_shape(d: Dictionary) -> Dictionary:
return {
"shape_type": d.get("shape_type", ""),
"points": _variant_to_points(d.get("points", [])),
"color": d.get("color", "#000000"),
"closed": d.get("closed", false),
"vertex_flags": _variant_to_int_array(d.get("vertex_flags", [])),
}
func _variant_to_points(pts_variant: Variant) -> PackedVector2Array:
if pts_variant is PackedVector2Array:
return pts_variant as PackedVector2Array
if pts_variant is Array:
var arr: Array = pts_variant as Array
var out := PackedVector2Array()
for item in arr:
if item is Dictionary:
var d: Dictionary = item as Dictionary
out.append(Vector2(float(d.get("x", 0)), float(d.get("y", 0))))
elif item is Vector2:
out.append(item as Vector2)
return out
return PackedVector2Array()
func _variant_to_int_array(vf_variant: Variant) -> PackedInt32Array:
if vf_variant is PackedInt32Array:
return vf_variant as PackedInt32Array
if vf_variant is Array:
var arr: Array = vf_variant as Array
var out := PackedInt32Array()
out.resize(arr.size())
for i: int in arr.size():
out[i] = int(arr[i])
return out
return PackedInt32Array()
func _rebuild_bounds() -> void:
_part_bounds.clear()
for part_name: String in _part_order:
if not _part_shapes.has(part_name):
continue
var shapes_array: Variant = _part_shapes.get(part_name, [])
if not shapes_array is Array:
continue
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
var rot_deg: float = _part_rotations.get(part_name, 0.0)
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
var all_raw := PackedVector2Array()
for sd in (shapes_array as Array):
if sd is Dictionary:
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
all_raw.append(pt + pos)
if all_raw.is_empty():
continue
var center := _compute_center(all_raw)
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
for pt: Vector2 in all_raw:
var transformed := _transform_point(pt, Vector2.ZERO, center, rot_deg, scl)
min_x = min(min_x, transformed.x)
min_y = min(min_y, transformed.y)
max_x = max(max_x, transformed.x)
max_y = max(max_y, transformed.y)
if min_x <= max_x and min_y <= max_y:
_part_bounds[part_name] = Rect2(
Vector2(min_x, min_y),
Vector2(max_x - min_x, max_y - min_y)
)
func _short_label(part_name: String) -> String:
match part_name:
"head": return "H"
"torso": return "T"
"left_upper_arm": return "LUA"
"left_lower_arm": return "LLA"
"right_upper_arm": return "RUA"
"right_lower_arm": return "RLA"
"left_upper_leg": return "LUL"
"left_lower_leg": return "LLL"
"right_upper_leg": return "RUL"
"right_lower_leg": return "RLL"
return part_name.left(3)
# ---------------------------------------------------------------------------
# Phase 5: Context menu, z-ordering, mirroring
# ---------------------------------------------------------------------------
func _handle_right_click(mb: InputEventMouseButton) -> void:
var world_pos := _screen_to_world(mb.position)
for i: int in range(_part_order.size() - 1, -1, -1):
var part_name: String = _part_order[i]
if not _part_bounds.has(part_name):
continue
var bounds: Variant = _part_bounds[part_name]
if bounds is Rect2:
var rect: Rect2 = bounds as Rect2
if rect.has_point(world_pos):
_selected_part = part_name
_context_menu.clear()
_context_menu.add_item("Send Back", 0)
_context_menu.add_item("Bring Forward", 1)
_context_menu.add_separator()
_context_menu.add_item("Mirror X", 2)
_context_menu.add_item("Mirror Y", 3)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
preview_area.queue_redraw()
return
_selected_part = ""
preview_area.queue_redraw()
func _on_context_menu_id_pressed(id: int) -> void:
match id:
0: _send_back_part()
1: _bring_forward_part()
2: _mirror_part_x()
3: _mirror_part_y()
func _send_back_part() -> void:
if _selected_part.is_empty():
return
var idx := _part_order.find(_selected_part)
if idx <= 0 or idx >= _part_order.size():
return
_part_order.remove_at(idx)
_part_order.insert(idx - 1, _selected_part)
preview_area.queue_redraw()
func _bring_forward_part() -> void:
if _selected_part.is_empty():
return
var idx := _part_order.find(_selected_part)
if idx < 0 or idx >= _part_order.size() - 1:
return
_part_order.remove_at(idx)
_part_order.insert(idx + 1, _selected_part)
preview_area.queue_redraw()
func _mirror_part_x() -> void:
if _selected_part.is_empty():
return
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
func _mirror_part_y() -> void:
if _selected_part.is_empty():
return
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
+1
View File
@@ -0,0 +1 @@
uid://codv3g6qw1tef
+634
View File
@@ -0,0 +1,634 @@
[gd_scene load_steps=14 format=3 uid="uid://ckc0tte3ud3oq"]
[sub_resource type="GDScript" id="GDScript_va7af"]
script/source = "@tool
extends Node2D
@export var radius : float = 100.0:
set(value):
radius = value
queue_redraw()
@export var color : Color = Color.WHITE:
set(value):
color = value
queue_redraw()
func _draw():
draw_circle(Vector2(0,0), radius, color)
"
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_va7af"]
target_nodepath = NodePath("../../../IK Targets/Leg_Left")
joint_one_bone_idx = 1
joint_one_bone2d_node = NodePath("Hip/Leg_Upper_Left")
joint_two_bone_idx = 2
joint_two_bone2d_node = NodePath("Hip/Leg_Upper_Left/Leg_Lower_Left")
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_agfe5"]
target_nodepath = NodePath("../../../IK Targets/Leg_Right")
joint_one_bone_idx = 3
joint_one_bone2d_node = NodePath("Hip/Leg_Upper_Right")
joint_two_bone_idx = 4
joint_two_bone2d_node = NodePath("Hip/Leg_Upper_Right/Leg_Lower_Right")
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_1hssm"]
target_nodepath = NodePath("../../../IK Targets/Arm_Right")
flip_bend_direction = true
joint_one_bone_idx = 7
joint_one_bone2d_node = NodePath("Hip/Arm_Upper_Right")
joint_two_bone_idx = 8
joint_two_bone2d_node = NodePath("Hip/Arm_Upper_Right/Arm_Lower_Right")
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_0algd"]
target_nodepath = NodePath("../../../IK Targets/Arm_Left")
flip_bend_direction = true
joint_one_bone_idx = 5
joint_one_bone2d_node = NodePath("Hip/Arm_Upper_Left")
joint_two_bone_idx = 6
joint_two_bone2d_node = NodePath("Hip/Arm_Upper_Left/Arm_Lower_Left")
[sub_resource type="SkeletonModification2DLookAt" id="SkeletonModification2DLookAt_va7af"]
bone_index = 9
bone2d_node = NodePath("Hip/Head")
target_nodepath = NodePath("../../../IK Targets/Head")
enable_constraint = true
constraint_angle_min = 65.0
constraint_angle_max = 295.0
constraint_angle_invert = true
constraint_in_localspace = true
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_agfe5"]
enabled = true
modification_count = 5
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_va7af")
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_agfe5")
modifications/2 = SubResource("SkeletonModification2DTwoBoneIK_1hssm")
modifications/3 = SubResource("SkeletonModification2DTwoBoneIK_0algd")
modifications/4 = SubResource("SkeletonModification2DLookAt_va7af")
[sub_resource type="Animation" id="Animation_agfe5"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Arm_Left:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, 30)]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Arm_Left:rotation")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Arm_Right:position")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(4, 29)]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Arm_Right:rotation")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Leg_Left:position")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(-1, 50)]
}
tracks/5/type = "value"
tracks/5/imported = false
tracks/5/enabled = true
tracks/5/path = NodePath("Leg_Left:rotation")
tracks/5/interp = 1
tracks/5/loop_wrap = true
tracks/5/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("Leg_Right:position")
tracks/6/interp = 1
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, 51)]
}
tracks/7/type = "value"
tracks/7/imported = false
tracks/7/enabled = true
tracks/7/path = NodePath("Leg_Right:rotation")
tracks/7/interp = 1
tracks/7/loop_wrap = true
tracks/7/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/8/type = "value"
tracks/8/imported = false
tracks/8/enabled = true
tracks/8/path = NodePath("Head:position")
tracks/8/interp = 1
tracks/8/loop_wrap = true
tracks/8/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, -10)]
}
tracks/9/type = "value"
tracks/9/imported = false
tracks/9/enabled = true
tracks/9/path = NodePath("Head:rotation")
tracks/9/interp = 1
tracks/9/loop_wrap = true
tracks/9/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/10/type = "value"
tracks/10/imported = false
tracks/10/enabled = true
tracks/10/path = NodePath("Body:position")
tracks/10/interp = 1
tracks/10/loop_wrap = true
tracks/10/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(-3, -4)]
}
tracks/11/type = "value"
tracks/11/imported = false
tracks/11/enabled = true
tracks/11/path = NodePath("Body:rotation")
tracks/11/interp = 1
tracks/11/loop_wrap = true
tracks/11/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
[sub_resource type="Animation" id="Animation_va7af"]
resource_name = "walk"
length = 2.0
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Arm_Left:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.3, 0.633333, 0.9, 1.29, 1.56, 2),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, 30), Vector2(8, 26), Vector2(13, 24), Vector2(15, 16), Vector2(13, 26), Vector2(6, 26), Vector2(0, 30)]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Arm_Left:rotation")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 2),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [0.0, 0.0]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Arm_Right:position")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.3, 0.633333, 0.9, 1.29, 1.56, 2),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(4, 29), Vector2(0, 29), Vector2(-4, 27), Vector2(1, 25), Vector2(11, 26), Vector2(21, 26), Vector2(4, 29)]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Arm_Right:rotation")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0, 2),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [0.0, 0.0]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("Leg_Left:position")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 1.29, 1.56, 2),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 0,
"values": [Vector2(-1, 50), Vector2(5, 43), Vector2(17, 44), Vector2(-1, 50)]
}
tracks/5/type = "value"
tracks/5/imported = false
tracks/5/enabled = true
tracks/5/path = NodePath("Leg_Left:rotation")
tracks/5/interp = 1
tracks/5/loop_wrap = true
tracks/5/keys = {
"times": PackedFloat32Array(0, 2),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [0.0, 0.0]
}
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("Leg_Right:position")
tracks/6/interp = 1
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0, 0.3, 0.633333, 0.9, 2),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, 51), Vector2(9, 46), Vector2(9, 49), Vector2(9, 50), Vector2(0, 51)]
}
tracks/7/type = "value"
tracks/7/imported = false
tracks/7/enabled = true
tracks/7/path = NodePath("Leg_Right:rotation")
tracks/7/interp = 1
tracks/7/loop_wrap = true
tracks/7/keys = {
"times": PackedFloat32Array(0, 2),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [0.0, 0.0]
}
tracks/8/type = "value"
tracks/8/imported = false
tracks/8/enabled = true
tracks/8/path = NodePath("Head:position")
tracks/8/interp = 1
tracks/8/loop_wrap = true
tracks/8/keys = {
"times": PackedFloat32Array(0, 0.633333, 0.9, 1.56, 2),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(2, -10), Vector2(1, -10), Vector2(1, -10), Vector2(1, -9), Vector2(2, -9)]
}
tracks/9/type = "value"
tracks/9/imported = false
tracks/9/enabled = true
tracks/9/path = NodePath("Head:rotation")
tracks/9/interp = 1
tracks/9/loop_wrap = true
tracks/9/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
tracks/10/type = "value"
tracks/10/imported = false
tracks/10/enabled = true
tracks/10/path = NodePath("Body:position")
tracks/10/interp = 1
tracks/10/loop_wrap = true
tracks/10/keys = {
"times": PackedFloat32Array(0, 0.633333, 0.9, 1.29, 1.56),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, -4), Vector2(0, -4), Vector2(0, -4), Vector2(0, -4), Vector2(0, -4)]
}
tracks/11/type = "value"
tracks/11/imported = false
tracks/11/enabled = true
tracks/11/path = NodePath("Body:rotation")
tracks/11/interp = 1
tracks/11/loop_wrap = true
tracks/11/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [0.0]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_1hssm"]
_data = {
&"RESET": SubResource("Animation_agfe5"),
&"walk": SubResource("Animation_va7af")
}
[sub_resource type="Animation" id="Animation_0algd"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sticky:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(0, 0)]
}
[sub_resource type="Animation" id="Animation_1hssm"]
resource_name = "walk_to"
length = 6.0
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sticky:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 6),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(0, 0), Vector2(150, 0)]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_rp7ts"]
_data = {
&"RESET": SubResource("Animation_0algd"),
&"walk_to": SubResource("Animation_1hssm")
}
[node name="Node2D" type="Node2D"]
[node name="Sticky" type="Node2D" parent="."]
[node name="Stickman" type="Node2D" parent="Sticky"]
[node name="Body" type="Node2D" parent="Sticky/Stickman"]
[node name="Body" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-1, 3)
points = PackedVector2Array(0, 0, 0, 27)
width = 4.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="Head" type="Node2D" parent="Sticky/Stickman/Body"]
position = Vector2(-7.53881, 1.50115)
rotation = -1.13446
scale = Vector2(0.999996, 0.999996)
script = SubResource("GDScript_va7af")
radius = 7.0
color = Color(0, 0, 0, 1)
[node name="Arm_Upper_Right" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(1, 10)
rotation = 1.80668
points = PackedVector2Array(0, 0, 12, 0)
width = 2.0
default_color = Color(0, 0, 0, 1)
[node name="Arm_Lower_Right" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-1.57076, 20.6954)
rotation = 0.979936
scale = Vector2(1, 1)
points = PackedVector2Array(0, 0, 10, 0)
width = 2.0
default_color = Color(0.205117, 0.205117, 0.205117, 1)
[node name="Arm_Upper_Left" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-4.2167, 20.9325)
rotation = -1.45996
points = PackedVector2Array(0, 0, 12, 0)
width = 2.0
default_color = Color(0, 0, 0, 1)
[node name="Arm_Lower_Left" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-3.33786e-06, 30)
rotation = -2.00608
points = PackedVector2Array(0, 0, 10, 0)
width = 2.0
default_color = Color(0.205117, 0.205117, 0.205117, 1)
[node name="Leg_Upper_Left" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-1.32573, 27.2615)
rotation = 1.52537
scale = Vector2(1, 1)
points = PackedVector2Array(0, 0, 13, 0)
width = 3.0
default_color = Color(0, 0, 0, 1)
[node name="Leg_Lower_Left" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-1.48627, 39.302)
rotation = 1.52537
scale = Vector2(1, 1)
points = PackedVector2Array(0, 0, 12, 0)
width = 3.0
default_color = Color(0.205117, 0.205117, 0.205117, 1)
[node name="Leg_Upper_Right" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(-0.446526, 26.6189)
rotation = 1.54768
scale = Vector2(1, 1)
points = PackedVector2Array(0, 0, 13, 0)
width = 3.0
default_color = Color(0, 0, 0, 1)
[node name="Leg_Lower_Right" type="Line2D" parent="Sticky/Stickman/Body"]
position = Vector2(0.538256, 38.6201)
rotation = 1.61424
points = PackedVector2Array(0, 0, 12, 0)
width = 3.0
default_color = Color(0.205117, 0.205117, 0.205117, 1)
[node name="Bones" type="Node2D" parent="Sticky/Stickman"]
visible = false
[node name="Skeleton2D" type="Skeleton2D" parent="Sticky/Stickman/Bones"]
modification_stack = SubResource("SkeletonModificationStack2D_agfe5")
[node name="Hip" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D"]
position = Vector2(-1, 27)
rest = Transform2D(1, 0, 0, 1, 0, 32)
[node name="Leg_Upper_Left" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(-1, 1)
rest = Transform2D(1, 0, 0, 1, -1, 1)
metadata/_local_pose_override_enabled_ = true
[node name="Leg_Lower_Left" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Left"]
position = Vector2(-8, 8)
rest = Transform2D(1, 0, 0, 1, -8, 8)
auto_calculate_length_and_angle = false
length = 10.425
bone_angle = 89.9999
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Left/Leg_Lower_Left"]
rotation = 1.57079
remote_path = NodePath("../../../../../../Body/Leg_Lower_Left")
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Left"]
position = Vector2(1, 0)
rotation = 2.35619
remote_path = NodePath("../../../../../Body/Leg_Upper_Left")
[node name="Leg_Upper_Right" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(2, 1)
rest = Transform2D(1, 0, 0, 1, 2, 1)
metadata/_local_pose_override_enabled_ = true
[node name="Leg_Lower_Right" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Right"]
position = Vector2(7, 8)
rest = Transform2D(1, 0, 0, 1, 7, 8)
auto_calculate_length_and_angle = false
length = 10.425
bone_angle = 89.9999
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Right/Leg_Lower_Right"]
rotation = 1.57079
remote_path = NodePath("../../../../../../Body/Leg_Lower_Right")
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Leg_Upper_Right"]
position = Vector2(-2, 0)
rotation = 0.785397
remote_path = NodePath("../../../../../Body/Leg_Upper_Right")
[node name="Arm_Upper_Left" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(-2, -17)
rest = Transform2D(1, 0, 0, 1, -2, -17)
metadata/_local_pose_override_enabled_ = true
[node name="Arm_Lower_Left" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Left"]
position = Vector2(-11, 0)
rest = Transform2D(1, 0, 0, 1, -11, 0)
auto_calculate_length_and_angle = false
length = 10.0
bone_angle = -180.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Left/Arm_Lower_Left"]
position = Vector2(-10, 0)
remote_path = NodePath("../../../../../../Body/Arm_Lower_Left")
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Left"]
position = Vector2(-11, 0)
remote_path = NodePath("../../../../../Body/Arm_Upper_Left")
[node name="Arm_Upper_Right" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(2, -17)
rest = Transform2D(1, 0, 0, 1, 2, -17)
metadata/_local_pose_override_enabled_ = true
[node name="Arm_Lower_Right" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Right"]
position = Vector2(11, 0)
rest = Transform2D(1, 0, 0, 1, 11, 0)
auto_calculate_length_and_angle = false
length = 10.0
bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Right/Arm_Lower_Right"]
remote_path = NodePath("../../../../../../Body/Arm_Lower_Right")
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Arm_Upper_Right"]
remote_path = NodePath("../../../../../Body/Arm_Upper_Right")
[node name="Head" type="Bone2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(0, -23)
rotation = 0.0713012
scale = Vector2(0.999996, 0.999996)
rest = Transform2D(2.22127e-06, -1, 1, 2.22127e-06, 0, -23)
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip/Head"]
position = Vector2(-0.498689, -6.98226)
remote_path = NodePath("../../../../../Body/Head")
[node name="HipTransform" type="RemoteTransform2D" parent="Sticky/Stickman/Bones/Skeleton2D/Hip"]
position = Vector2(0, -24)
remote_path = NodePath("../../../../Body/Body")
[node name="IK Targets" type="Node2D" parent="Sticky"]
[node name="Arm_Left" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(0, 30)
[node name="Arm_Right" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(4, 29)
[node name="Leg_Left" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(-1, 50)
[node name="Leg_Right" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(0, 51)
[node name="Head" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(0, -10)
[node name="Body" type="Node2D" parent="Sticky/IK Targets"]
position = Vector2(-3, -4)
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Sticky/IK Targets/Body"]
position = Vector2(2, 31)
remote_path = NodePath("../../../Stickman/Bones/Skeleton2D/Hip")
[node name="AnimationPlayer" type="AnimationPlayer" parent="Sticky"]
root_node = NodePath("../IK Targets")
libraries = {
&"": SubResource("AnimationLibrary_1hssm")
}
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
libraries = {
&"": SubResource("AnimationLibrary_rp7ts")
}
+246
View File
@@ -0,0 +1,246 @@
{
"version": "1.0",
"stickman_name": "Basic",
"body_parts": {
"head": {
"shape_type": "rectangle",
"points": [
{
"x": 224.0,
"y": 33.0
},
{
"x": 324.0,
"y": 33.0
},
{
"x": 324.0,
"y": 93.0
},
{
"x": 224.0,
"y": 93.0
}
],
"color": "#000000",
"position": {
"x": 203.0,
"y": 511.0
}
},
"torso": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"left_upper_arm": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"left_lower_arm": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"right_upper_arm": {
"shape_type": "circle",
"points": [
{
"x": 198.0,
"y": 25.0
},
{
"x": 205.803619384766,
"y": 25.7685890197754
},
{
"x": 213.307342529297,
"y": 28.0448188781738
},
{
"x": 220.222808837891,
"y": 31.7412147521973
},
{
"x": 226.284271240234,
"y": 36.7157287597656
},
{
"x": 231.2587890625,
"y": 42.7771911621094
},
{
"x": 234.955184936523,
"y": 49.6926651000977
},
{
"x": 237.231414794922,
"y": 57.1963882446289
},
{
"x": 238.0,
"y": 65.0
},
{
"x": 237.231414794922,
"y": 72.8036117553711
},
{
"x": 234.955184936523,
"y": 80.3073348999023
},
{
"x": 231.2587890625,
"y": 87.2228088378906
},
{
"x": 226.284271240234,
"y": 93.2842712402344
},
{
"x": 220.222808837891,
"y": 98.2587890625
},
{
"x": 213.307342529297,
"y": 101.955184936523
},
{
"x": 205.803619384766,
"y": 104.231414794922
},
{
"x": 198.0,
"y": 105.0
},
{
"x": 190.196380615234,
"y": 104.231414794922
},
{
"x": 182.692657470703,
"y": 101.955184936523
},
{
"x": 175.777191162109,
"y": 98.2587890625
},
{
"x": 169.715728759766,
"y": 93.2842712402344
},
{
"x": 164.7412109375,
"y": 87.2228088378906
},
{
"x": 161.044815063477,
"y": 80.3073348999023
},
{
"x": 158.768585205078,
"y": 72.8036117553711
},
{
"x": 158.0,
"y": 65.0
},
{
"x": 158.768585205078,
"y": 57.1963882446289
},
{
"x": 161.044815063477,
"y": 49.6926651000977
},
{
"x": 164.7412109375,
"y": 42.7771911621094
},
{
"x": 169.715728759766,
"y": 36.7157287597656
},
{
"x": 175.777191162109,
"y": 31.7412147521973
},
{
"x": 182.692657470703,
"y": 28.0448188781738
},
{
"x": 190.196380615234,
"y": 25.7685890197754
}
],
"color": "#000000",
"position": {
"x": 279.0,
"y": 453.0
}
},
"right_lower_arm": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"left_upper_leg": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"left_lower_leg": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"right_upper_leg": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
},
"right_lower_leg": {
"shape_type": "",
"points": [],
"color": "#000000",
"position": {
"x": 0.0,
"y": 0.0
}
}
},
"metadata": {
"created_at": "2026-08-05T10:28:56",
"modified_at": "2026-08-05T10:28:56"
}
}
+676
View File
@@ -0,0 +1,676 @@
{
"version": "1.3",
"stickman_name": "",
"part_order": [
"torso",
"head",
"left_upper_arm",
"left_lower_arm",
"right_upper_arm",
"right_lower_arm",
"left_upper_leg",
"left_lower_leg",
"right_upper_leg",
"right_lower_leg"
],
"body_parts": {
"head": {
"shapes": [
{
"shape_type": "circle",
"points": [
{
"x": 490.08984375,
"y": 153.165756225586
},
{
"x": 510.089874267578,
"y": 158.52473449707
},
{
"x": 524.730834960938,
"y": 173.165756225586
},
{
"x": 528.949584960938,
"y": 196.245040893555
},
{
"x": 524.730834960938,
"y": 213.165756225586
},
{
"x": 510.089874267578,
"y": 227.806777954102
},
{
"x": 490.08984375,
"y": 233.165756225586
},
{
"x": 470.08984375,
"y": 227.806777954102
},
{
"x": 455.448852539063,
"y": 213.165756225586
},
{
"x": 450.08984375,
"y": 193.165756225586
},
{
"x": 455.448852539063,
"y": 173.165756225586
},
{
"x": 470.08984375,
"y": 158.52473449707
}
],
"color": "d5ab46ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"shape_type": "rectangle",
"points": [
{
"x": 465.580932617188,
"y": 174.753814697266
},
{
"x": 481.539428710938,
"y": 174.596008300781
},
{
"x": 485.394866943359,
"y": 185.210296630859
},
{
"x": 467.937316894531,
"y": 184.475402832031
}
],
"color": "0800ffff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
},
{
"shape_type": "rectangle",
"points": [
{
"x": 495.061004638672,
"y": 184.940002441406
},
{
"x": 511.019500732422,
"y": 185.097808837891
},
{
"x": 514.874938964844,
"y": 174.483520507813
},
{
"x": 497.417388916016,
"y": 175.218414306641
}
],
"color": "0800ffff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
},
{
"shape_type": "line",
"points": [
{
"x": 472.353759765625,
"y": 209.281051635742
},
{
"x": 482.79736328125,
"y": 216.509994506836
},
{
"x": 494.642913818359,
"y": 219.182510375977
},
{
"x": 506.417205810547,
"y": 214.363220214844
},
{
"x": 511.039215087891,
"y": 208.229568481445
}
],
"color": "#000000",
"closed": false,
"vertex_flags": [
0,
1,
1,
1,
0
]
},
{
"shape_type": "line",
"points": [
{
"x": 504.248718261719,
"y": 201.570190429688
},
{
"x": 487.282531738281,
"y": 202.095932006836
},
{
"x": 487.490539550781,
"y": 195.261291503906
},
{
"x": 504.379791259766,
"y": 201.570190429688
}
],
"color": "#000000",
"closed": false,
"vertex_flags": [
0,
1,
1,
0
]
},
{
"shape_type": "rectangle",
"points": [
{
"x": 451.902404785156,
"y": 159.194290161133
},
{
"x": 557.651245117188,
"y": 160.326751708984
},
{
"x": 558.352966308594,
"y": 168.776214599609
},
{
"x": 452.383483886719,
"y": 169.722885131836
}
],
"color": "ff0000ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
},
{
"shape_type": "circle",
"points": [
{
"x": 490.218383789063,
"y": 122.896606445313
},
{
"x": 510.218383789063,
"y": 128.255599975586
},
{
"x": 524.859375,
"y": 142.896606445313
},
{
"x": 530.218383789063,
"y": 162.896606445313
},
{
"x": 450.218383789063,
"y": 162.896606445313
},
{
"x": 455.577392578125,
"y": 142.896606445313
},
{
"x": 470.218383789063,
"y": 128.255599975586
}
],
"color": "be0000ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0
]
}
],
"position": {
"x": 490.884521484375,
"y": 133.55908203125
},
"rotation": -360.0,
"scale": {
"x": 1.47983860969543,
"y": 1.47983860969543
}
},
"torso": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 283.0,
"y": 209.0
},
{
"x": 318.0,
"y": 210.0
},
{
"x": 317.0,
"y": 308.0
},
{
"x": 286.0,
"y": 299.0
}
],
"color": "ec1c00ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 674.5,
"y": 221.500030517578
},
"rotation": 0.0,
"scale": {
"x": 0.857142865657806,
"y": 1.81818187236786
}
},
"left_upper_arm": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 436.0,
"y": 158.0
},
{
"x": 543.0,
"y": 160.0
},
{
"x": 536.0,
"y": 181.0
},
{
"x": 436.0,
"y": 181.0
}
],
"color": "ea0000ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 434.0,
"y": 247.0
},
"rotation": 0.0,
"scale": {
"x": 1.0,
"y": 1.0
}
},
"left_lower_arm": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 427.114593505859,
"y": 127.010650634766
},
{
"x": 533.026489257813,
"y": 127.221878051758
},
{
"x": 532.166625976563,
"y": 142.74137878418
},
{
"x": 426.973449707031,
"y": 142.989349365234
}
],
"color": "f4bd53ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 340.571166992188,
"y": 282.654418945313
},
"rotation": 0.0,
"scale": {
"x": 0.988918423652649,
"y": 1.38907158374786
}
},
"right_upper_arm": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 275.5,
"y": 169.5
},
{
"x": 382.5,
"y": 167.5
},
{
"x": 375.5,
"y": 146.5
},
{
"x": 275.5,
"y": 146.5
}
],
"color": "ea0000ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 714.5,
"y": 258.5
},
"rotation": 0.0,
"scale": {
"x": 1.0,
"y": 1.0
}
},
"right_lower_arm": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 360.026885986328,
"y": 151.67024230957
},
{
"x": 465.938812255859,
"y": 151.459014892578
},
{
"x": 465.078887939453,
"y": 135.939514160156
},
{
"x": 359.8857421875,
"y": 135.691543579102
}
],
"color": "fbab3aff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 720.103637695313,
"y": 271.813385009766
},
"rotation": 0.0,
"scale": {
"x": 0.999799728393555,
"y": 1.31353163719177
}
},
"left_upper_leg": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 486.0,
"y": 140.0
},
{
"x": 583.0,
"y": 142.0
},
{
"x": 587.0,
"y": 168.0
},
{
"x": 487.0,
"y": 168.0
}
],
"color": "0000c8ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 394.1083984375,
"y": 427.365753173828
},
"rotation": -45.0,
"scale": {
"x": 1.0,
"y": 1.0
}
},
"left_lower_leg": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 464.0,
"y": 158.0
},
{
"x": 560.0,
"y": 158.0
},
{
"x": 562.0,
"y": 184.0
},
{
"x": 462.0,
"y": 184.0
}
],
"color": "0000cdff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 386.0,
"y": 479.0
},
"rotation": 90.0,
"scale": {
"x": 1.0,
"y": 1.0
}
},
"right_upper_leg": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 413.5,
"y": 177.0
},
{
"x": 510.5,
"y": 175.0
},
{
"x": 514.5,
"y": 149.0
},
{
"x": 414.5,
"y": 149.0
}
],
"color": "0000c8ff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 555.037841796875,
"y": 420.346771240234
},
"rotation": 45.0,
"scale": {
"x": 0.977314233779907,
"y": 0.923182904720306
}
},
"right_lower_leg": {
"shapes": [
{
"shape_type": "rectangle",
"points": [
{
"x": 371.0,
"y": 176.0
},
{
"x": 467.0,
"y": 176.0
},
{
"x": 469.0,
"y": 150.0
},
{
"x": 369.0,
"y": 150.0
}
],
"color": "0000cdff",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
]
}
],
"position": {
"x": 628.999938964844,
"y": 487.000061035156
},
"rotation": 90.0,
"scale": {
"x": 1.0,
"y": 1.0
}
}
},
"metadata": {
"created_at": "2026-08-07T23:23:34",
"modified_at": "2026-08-07T23:23:34"
}
}
+380
View File
@@ -0,0 +1,380 @@
{
"version": "1.1",
"stickman_name": "",
"body_parts": {
"head": {
"shape_type": "circle",
"points": [
{
"x": 491.0,
"y": 151.0
},
{
"x": 511.0,
"y": 156.358978271484
},
{
"x": 525.640991210938,
"y": 171.0
},
{
"x": 531.0,
"y": 191.0
},
{
"x": 525.640991210938,
"y": 211.0
},
{
"x": 511.0,
"y": 225.641021728516
},
{
"x": 491.0,
"y": 231.0
},
{
"x": 471.0,
"y": 225.641021728516
},
{
"x": 456.358978271484,
"y": 211.0
},
{
"x": 451.0,
"y": 191.0
},
{
"x": 456.358978271484,
"y": 171.0
},
{
"x": 471.0,
"y": 156.358978271484
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"position": {
"x": 458.0,
"y": 319.0
}
},
"torso": {
"shape_type": "rectangle",
"points": [
{
"x": 283.0,
"y": 209.0
},
{
"x": 318.0,
"y": 210.0
},
{
"x": 317.0,
"y": 308.0
},
{
"x": 286.0,
"y": 299.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 647.0,
"y": 331.0
}
},
"left_upper_arm": {
"shape_type": "rectangle",
"points": [
{
"x": 435.0,
"y": 153.0
},
{
"x": 542.0,
"y": 155.0
},
{
"x": 535.0,
"y": 176.0
},
{
"x": 435.0,
"y": 176.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 396.440490722656,
"y": 398.074859619141
}
},
"left_lower_arm": {
"shape_type": "rectangle",
"points": [
{
"x": 441.0,
"y": 136.0
},
{
"x": 540.0,
"y": 136.0
},
{
"x": 540.0,
"y": 166.0
},
{
"x": 440.0,
"y": 166.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 301.995178222656,
"y": 410.786956787109
}
},
"right_upper_arm": {
"shape_type": "rectangle",
"points": [
{
"x": 200.0,
"y": 151.0
},
{
"x": 298.0,
"y": 148.0
},
{
"x": 296.0,
"y": 179.0
},
{
"x": 196.0,
"y": 179.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 764.156494140625,
"y": 401.800384521484
}
},
"right_lower_arm": {
"shape_type": "rectangle",
"points": [
{
"x": 396.0,
"y": 151.0
},
{
"x": 497.0,
"y": 154.0
},
{
"x": 495.0,
"y": 183.0
},
{
"x": 395.0,
"y": 183.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 656.80224609375,
"y": 396.505767822266
}
},
"left_upper_leg": {
"shape_type": "rectangle",
"points": [
{
"x": 486.0,
"y": 140.0
},
{
"x": 583.0,
"y": 142.0
},
{
"x": 587.0,
"y": 168.0
},
{
"x": 487.0,
"y": 168.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 354.976013183594,
"y": 472.323425292969
}
},
"left_lower_leg": {
"shape_type": "rectangle",
"points": [
{
"x": 501.0,
"y": 127.0
},
{
"x": 597.0,
"y": 127.0
},
{
"x": 599.0,
"y": 153.0
},
{
"x": 499.0,
"y": 153.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 250.491394042969,
"y": 486.930938720703
}
},
"right_upper_leg": {
"shape_type": "rectangle",
"points": [
{
"x": 383.0,
"y": 145.0
},
{
"x": 483.0,
"y": 148.0
},
{
"x": 484.0,
"y": 173.0
},
{
"x": 384.0,
"y": 173.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 571.533569335938,
"y": 475.428070068359
}
},
"right_lower_leg": {
"shape_type": "rectangle",
"points": [
{
"x": 288.0,
"y": 144.0
},
{
"x": 388.0,
"y": 147.0
},
{
"x": 389.0,
"y": 173.0
},
{
"x": 289.0,
"y": 173.0
}
],
"color": "#000000",
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"position": {
"x": 759.491333007813,
"y": 478.830200195313
}
}
},
"metadata": {
"created_at": "2026-08-06T13:46:25",
"modified_at": "2026-08-06T13:46:25"
}
}