From b97bc145e416717805e84ff6fb9b235f2fe3500f Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sat, 8 Aug 2026 00:00:50 -0400 Subject: [PATCH] first commit --- .editorconfig | 4 + .gitattributes | 2 + .gitignore | 7 + .opencode/agents/architect.md | 153 +++ .opencode/agents/developer.md | 89 ++ .opencode/agents/tester.md | 109 ++ .opencode/agents/writer.md | 24 + .opencode/commands/code-architect.md | 58 ++ .opencode/commands/code-explorer.md | 58 ++ .opencode/skills/bugfix/SKILL.md | 27 + .opencode/skills/e2e-repair/SKILL.md | 22 + .opencode/skills/feature-pipeline/SKILL.md | 23 + .opencode/skills/spec-pipeline/SKILL.md | 24 + AGENTS.md | 99 ++ BUGS.md | 37 + PROJECT.md | 272 +++++ README.md | 417 ++++++++ docs/phase2_spec.md | 241 +++++ docs/phase3_spec.md | 577 +++++++++++ icon.svg | 1 + main.tscn | 16 + project.godot | 16 + scenes/body_part_panel.tscn | 84 ++ scenes/stickman_editor.tscn | 151 +++ scripts/body_part_panel.gd | 1039 ++++++++++++++++++++ scripts/body_part_panel.gd.uid | 1 + scripts/stickman_editor.gd | 513 ++++++++++ scripts/stickman_editor.gd.uid | 1 + scripts/whole_stickman_preview.gd | 872 ++++++++++++++++ scripts/whole_stickman_preview.gd.uid | 1 + stick.tscn | 634 ++++++++++++ stickmen/basic.stk | 246 +++++ stickmen/break.stk | 676 +++++++++++++ stickmen/test.stk | 380 +++++++ 34 files changed, 6874 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .opencode/agents/architect.md create mode 100644 .opencode/agents/developer.md create mode 100644 .opencode/agents/tester.md create mode 100644 .opencode/agents/writer.md create mode 100644 .opencode/commands/code-architect.md create mode 100644 .opencode/commands/code-explorer.md create mode 100644 .opencode/skills/bugfix/SKILL.md create mode 100644 .opencode/skills/e2e-repair/SKILL.md create mode 100644 .opencode/skills/feature-pipeline/SKILL.md create mode 100644 .opencode/skills/spec-pipeline/SKILL.md create mode 100644 AGENTS.md create mode 100644 BUGS.md create mode 100644 PROJECT.md create mode 100644 README.md create mode 100644 docs/phase2_spec.md create mode 100644 docs/phase3_spec.md create mode 100644 icon.svg create mode 100644 main.tscn create mode 100644 project.godot create mode 100644 scenes/body_part_panel.tscn create mode 100644 scenes/stickman_editor.tscn create mode 100644 scripts/body_part_panel.gd create mode 100644 scripts/body_part_panel.gd.uid create mode 100644 scripts/stickman_editor.gd create mode 100644 scripts/stickman_editor.gd.uid create mode 100644 scripts/whole_stickman_preview.gd create mode 100644 scripts/whole_stickman_preview.gd.uid create mode 100644 stick.tscn create mode 100644 stickmen/basic.stk create mode 100644 stickmen/break.stk create mode 100644 stickmen/test.stk diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c364c9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Godot 4+ specific ignores +.godot/ +/android/ +addons/ +*.tmp +.opencode/skills/playwright-cli +*.import diff --git a/.opencode/agents/architect.md b/.opencode/agents/architect.md new file mode 100644 index 0000000..3a8559f --- /dev/null +++ b/.opencode/agents/architect.md @@ -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 2–3 `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 5–10 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 2–3 `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 diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md new file mode 100644 index 0000000..006137f --- /dev/null +++ b/.opencode/agents/developer.md @@ -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 +``` diff --git a/.opencode/agents/tester.md b/.opencode/agents/tester.md new file mode 100644 index 0000000..813bb09 --- /dev/null +++ b/.opencode/agents/tester.md @@ -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? diff --git a/.opencode/agents/writer.md b/.opencode/agents/writer.md new file mode 100644 index 0000000..e0dae2c --- /dev/null +++ b/.opencode/agents/writer.md @@ -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. diff --git a/.opencode/commands/code-architect.md b/.opencode/commands/code-architect.md new file mode 100644 index 0000000..04241ef --- /dev/null +++ b/.opencode/commands/code-architect.md @@ -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 diff --git a/.opencode/commands/code-explorer.md b/.opencode/commands/code-explorer.md new file mode 100644 index 0000000..dcd27e8 --- /dev/null +++ b/.opencode/commands/code-explorer.md @@ -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 diff --git a/.opencode/skills/bugfix/SKILL.md b/.opencode/skills/bugfix/SKILL.md new file mode 100644 index 0000000..9272c9b --- /dev/null +++ b/.opencode/skills/bugfix/SKILL.md @@ -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. diff --git a/.opencode/skills/e2e-repair/SKILL.md b/.opencode/skills/e2e-repair/SKILL.md new file mode 100644 index 0000000..4ae7702 --- /dev/null +++ b/.opencode/skills/e2e-repair/SKILL.md @@ -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." diff --git a/.opencode/skills/feature-pipeline/SKILL.md b/.opencode/skills/feature-pipeline/SKILL.md new file mode 100644 index 0000000..2fbccb3 --- /dev/null +++ b/.opencode/skills/feature-pipeline/SKILL.md @@ -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. diff --git a/.opencode/skills/spec-pipeline/SKILL.md b/.opencode/skills/spec-pipeline/SKILL.md new file mode 100644 index 0000000..bbbb331 --- /dev/null +++ b/.opencode/skills/spec-pipeline/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bd96c27 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 0000000..9f7f558 --- /dev/null +++ b/BUGS.md @@ -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. diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..54b5f0a --- /dev/null +++ b/PROJECT.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4f48a4 --- /dev/null +++ b/README.md @@ -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 1–100 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.0–v1.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.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions. + +## 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 1–100 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 1–100 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.0–v1.2 files remain backward compatible and are migrated on load. diff --git a/docs/phase2_spec.md b/docs/phase2_spec.md new file mode 100644 index 0000000..4829f75 --- /dev/null +++ b/docs/phase2_spec.md @@ -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. diff --git a/docs/phase3_spec.md b/docs/phase3_spec.md new file mode 100644 index 0000000..613d16c --- /dev/null +++ b/docs/phase3_spec.md @@ -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. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..9d8b7fa --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main.tscn b/main.tscn new file mode 100644 index 0000000..ae61910 --- /dev/null +++ b/main.tscn @@ -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 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..8cc656f --- /dev/null +++ b/project.godot @@ -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" diff --git a/scenes/body_part_panel.tscn b/scenes/body_part_panel.tscn new file mode 100644 index 0000000..0345596 --- /dev/null +++ b/scenes/body_part_panel.tscn @@ -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" diff --git a/scenes/stickman_editor.tscn b/scenes/stickman_editor.tscn new file mode 100644 index 0000000..99231e4 --- /dev/null +++ b/scenes/stickman_editor.tscn @@ -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 diff --git a/scripts/body_part_panel.gd b/scripts/body_part_panel.gd new file mode 100644 index 0000000..04dd617 --- /dev/null +++ b/scripts/body_part_panel.gd @@ -0,0 +1,1039 @@ +extends PanelContainer +class_name BodyPartPanel +## BodyPartPanel - Individual editor panel for a stickman body part. +## +## Contains a title label and a drawing area where the user can place +## vector shapes via a right-click context menu (line, rectangle, circle). +## +## Phase 2: interactive vertex editing, per-panel zoom, shape selection, +## Create Point on edges, expanded shape data model with closed/vertex_flags. +## Phase 3: middle-mouse panning, background grid, snap-to-grid, +## ColorPicker integration, Delete shape, Remove Point (vertex delete). +## Phase 4: multiple shapes per panel, z-ordering (Send Back / Bring Forward). + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +signal shape_changed(shape_data: Array) +signal shape_selected() +signal shape_copy_requested(shape_dict: Dictionary) +signal shape_paste_requested(world_pos: Vector2) + +# --------------------------------------------------------------------------- +# Exported / public properties +# --------------------------------------------------------------------------- + +@export var part_name: String = "": + set(v): + part_name = v + if is_inside_tree() and title_label: + _update_title() +@export var display_name: String = "": + set(v): + display_name = v + if is_inside_tree() and title_label: + _update_title() + +## Array of shape dictionaries: +## shape_type : String - "line", "rectangle", "circle", or "" (empty) +## points : PackedVector2Array - local positions in the drawing area +## color : String - hex colour e.g. "#000000" +## closed : bool - true for fill+closed outline, false for open outline +## vertex_flags : PackedInt32Array - 0=original, 1=user_created; same length as points +var shapes: Array[Dictionary] = [] + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +const HANDLE_RADIUS: float = 3.0 +const HANDLE_RADIUS_SELECTION: float = 8.0 +const HANDLE_HOLLOW_SIZE: float = 6.0 +const EDGE_HIT_THRESHOLD: float = 12.0 +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 + +# --------------------------------------------------------------------------- +# On-ready node references +# --------------------------------------------------------------------------- + +@onready var title_label: Label = %TitleLabel +@onready var drawing_area: Control = %DrawingArea +@onready var context_menu: PopupMenu = %ContextMenu +@onready var color_picker_popup: PopupPanel = %ColorPickerPopup +@onready var color_picker: ColorPicker = %ColorPicker +@onready var cancel_button: Button = %CancelButton +@onready var ok_button: Button = %OKButton + +# --------------------------------------------------------------------------- +# Internal state +# --------------------------------------------------------------------------- + +var _drawing_area_initialized := false +var _selected_shape_idx: int = -1 +var _dragging_vertex: int = -1 +var _drag_offset: Vector2 = Vector2.ZERO +var _is_dragging_shape: bool = false +var _shape_drag_start: Vector2 = Vector2.ZERO +var _shape_drag_original_points: PackedVector2Array = PackedVector2Array() +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 +var _restore_color: Color = Color.BLACK + +# Phase 4: tracks which shape was right-clicked for context menu ops. +var _context_shape_idx: int = -1 + +# Phase 5: clipboard state +var _has_clipboard: bool = false + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _update_title() + + if not drawing_area.draw.is_connected(_on_drawing_area_draw): + drawing_area.draw.connect(_on_drawing_area_draw) + if not drawing_area.gui_input.is_connected(_on_drawing_area_gui_input): + drawing_area.gui_input.connect(_on_drawing_area_gui_input) + if not context_menu.id_pressed.is_connected(_on_context_menu_id_pressed): + context_menu.id_pressed.connect(_on_context_menu_id_pressed) + + # Phase 3: Wire ColorPicker signals. + if not color_picker.color_changed.is_connected(_on_color_picker_changed): + color_picker.color_changed.connect(_on_color_picker_changed) + if not ok_button.pressed.is_connected(_on_color_picker_ok): + ok_button.pressed.connect(_on_color_picker_ok) + if not cancel_button.pressed.is_connected(_on_color_picker_cancel): + cancel_button.pressed.connect(_on_color_picker_cancel) + + _drawing_area_initialized = true + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +func set_shape_data(data: Variant) -> void: + ## Import shape data. Accepts an Array[Dictionary] (v1.2) or a single + ## Dictionary (v1.0/v1.1 backward compat, wrapped in a 1-element array). + shapes.clear() + + var source: Array = _coerce_to_array(data) + for item in source: + if item is Dictionary: + var sd: Dictionary = _parse_single_shape(item as Dictionary) + shapes.append(sd) + + _selected_shape_idx = -1 + _dragging_vertex = -1 + _zoom = 1.0 + _pan_offset = Vector2.ZERO + drawing_area.queue_redraw() + + +func _coerce_to_array(data: Variant) -> Array: + if data is Array: + return data + if data is Dictionary: + return [data] + return [] + + +func _parse_single_shape(item: Dictionary) -> Dictionary: + var sd := { + "shape_type": item.get("shape_type", ""), + "color": item.get("color", "#000000"), + } + + var pts_variant: Variant = item.get("points", []) + var pts := PackedVector2Array() + if pts_variant is Array: + for p in (pts_variant as Array): + if p is Dictionary: + var d := p as Dictionary + pts.append(Vector2(float(d.get("x", 0)), float(d.get("y", 0)))) + elif p is Vector2: + pts.append(p as Vector2) + elif pts_variant is PackedVector2Array: + pts = pts_variant + sd["points"] = pts + + if item.has("closed"): + sd["closed"] = bool(item.get("closed", false)) + else: + var st: String = item.get("shape_type", "") + sd["closed"] = (st == "rectangle" or st == "circle") + + var vf := PackedInt32Array() + if item.has("vertex_flags"): + var vf_variant: Variant = item.get("vertex_flags", []) + if vf_variant is Array: + var vf_arr: Array = vf_variant + vf.resize(vf_arr.size()) + for i: int in vf_arr.size(): + vf[i] = int(vf_arr[i]) + elif vf_variant is PackedInt32Array: + vf = vf_variant + else: + vf.resize(pts.size()) + sd["vertex_flags"] = vf + + return sd + + +func get_shape_data() -> Array: + ## Export all shapes as an Array of plain Dictionary. + var result: Array = [] + for sd in shapes: + if sd is Dictionary: + var d := sd as Dictionary + var pts_arr: Array[Dictionary] = [] + var d_pts: PackedVector2Array = d.get("points", PackedVector2Array()) + for pt: Vector2 in d_pts: + pts_arr.append({"x": pt.x, "y": pt.y}) + + var vf_arr: Array = [] + var d_vf: PackedInt32Array = d.get("vertex_flags", PackedInt32Array()) + for i: int in d_vf.size(): + vf_arr.append(d_vf[i]) + + result.append({ + "shape_type": d.get("shape_type", ""), + "points": pts_arr, + "color": d.get("color", "#000000"), + "closed": d.get("closed", false), + "vertex_flags": vf_arr, + }) + return result + + +func clear_shape() -> void: + ## Reset the panel to an empty state (all shapes cleared). + shapes.clear() + _selected_shape_idx = -1 + _dragging_vertex = -1 + _context_shape_idx = -1 + _zoom = 1.0 + _pan_offset = Vector2.ZERO + drawing_area.queue_redraw() + + +func select() -> void: + ## Mark this panel as selected. Selects the topmost shape if any exist. + if shapes.size() > 0: + _selected_shape_idx = shapes.size() - 1 + else: + _selected_shape_idx = -1 + drawing_area.queue_redraw() + + +func deselect() -> void: + ## Clear selection and cancel any in-progress drag. + _selected_shape_idx = -1 + _dragging_vertex = -1 + _context_shape_idx = -1 + drawing_area.queue_redraw() + + +# Phase 3 public API + +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() + +# --------------------------------------------------------------------------- +# Phase 5: Clipboard +# --------------------------------------------------------------------------- + +func set_has_clipboard(state: bool) -> void: + _has_clipboard = state + + +func paste_shape(source: Dictionary, target_pos: Vector2) -> void: + ## Insert a deep copy of the clipboard shape at the given world position. + ## The shape's points are translated so its center is at target_pos. + var new_sd: Dictionary = source.duplicate(true) + var src_pts: PackedVector2Array = new_sd.get("points", PackedVector2Array()) + if src_pts.is_empty(): + return + var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF + for pt: Vector2 in src_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) + var src_center := Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5) + var offset := target_pos - src_center + + var new_pts := PackedVector2Array() + for pt: Vector2 in src_pts: + new_pts.append(pt + offset) + new_sd["points"] = new_pts + + shapes.append(new_sd) + _selected_shape_idx = shapes.size() - 1 + drawing_area.queue_redraw() + _emit_changed() + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +func _selected_shape() -> Dictionary: + if _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size(): + return shapes[_selected_shape_idx] + return {} + + +func _update_title() -> void: + if title_label: + title_label.text = display_name if not display_name.is_empty() else part_name + + +func _emit_changed() -> void: + shape_changed.emit(get_shape_data()) + + +func _screen_to_world(pos: Vector2) -> Vector2: + return (pos - _pan_offset) / _zoom + + +func _snap_to_grid(pos: Vector2) -> Vector2: + var gs := float(_grid_size) + if gs <= 0.0: + return pos + return Vector2( + round(pos.x / gs) * gs, + round(pos.y / gs) * gs + ) + +# --------------------------------------------------------------------------- +# Drawing +# --------------------------------------------------------------------------- + +func _on_drawing_area_draw() -> void: + drawing_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) + + _draw_grid() + + if shapes.is_empty(): + var rect := drawing_area.get_rect() + var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 80, rect.size.y * 0.5)) + var font := drawing_area.get_theme_default_font() + if font: + drawing_area.draw_string( + font, + hint_pos, + "Right-click to add shape", + HORIZONTAL_ALIGNMENT_CENTER, + -1, + 14, + Color(0.5, 0.5, 0.5, 0.6) + ) + return + + # Draw shapes in z-order (array index = z-order, first = back, last = front) + for i: int in range(shapes.size()): + var sd: Dictionary = shapes[i] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + var count: int = pts.size() + if count < 2: + continue + + var color: Color = Color.from_string(str(sd.get("color", "#000000")), Color.BLACK) + var closed: bool = bool(sd.get("closed", false)) + + # 1. Fill (if closed) + if closed: + drawing_area.draw_colored_polygon(pts, color) + + # 2. Outline + _draw_polyline(pts, color, closed) + + # 3. Selection highlight (only on selected shape) + if i == _selected_shape_idx: + _draw_polyline(pts, Color.WHITE, closed, 2.5) + + # 4. Vertex handles (only on selected shape) + for j: int in range(count): + var pt: Vector2 = pts[j] + var flag: int = 0 + var sd_vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + if sd_vf.size() > j: + flag = sd_vf[j] + + var handle_color := color + if j == _dragging_vertex: + handle_color = Color.YELLOW + + if flag == 0: + drawing_area.draw_circle(pt, HANDLE_RADIUS / _zoom, handle_color) + else: + var hs: float = HANDLE_HOLLOW_SIZE / _zoom * 0.5 + drawing_area.draw_rect(Rect2(pt - Vector2(hs, hs), Vector2(hs * 2, hs * 2)), handle_color, false) + + +func _draw_grid() -> void: + var gs := float(_grid_size) + if gs <= 0: + return + + var area_size := drawing_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: + drawing_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: + drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width) + y += gs + + +func _draw_polyline(pts: PackedVector2Array, color: Color, closed: bool, width: float = 2.0) -> void: + for i: int in range(pts.size() - 1): + drawing_area.draw_line(pts[i], pts[i + 1], color, width) + if closed and pts.size() >= 2: + drawing_area.draw_line(pts[pts.size() - 1], pts[0], color, width) + +# --------------------------------------------------------------------------- +# Input / context menu +# --------------------------------------------------------------------------- + +func _on_drawing_area_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + + # 1. Middle mouse button -> panning + if mb.button_index == MOUSE_BUTTON_MIDDLE: + if mb.pressed: + _is_panning = true + _pan_start = mb.position + _dragging_vertex = -1 + else: + _is_panning = false + return + + # 2. Mouse wheel -> zoom + if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + _zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + drawing_area.queue_redraw() + return + if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + _zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + drawing_area.queue_redraw() + return + + var world_pos := _screen_to_world(mb.position) + + # 3. Right mouse button -> context menu + if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: + _handle_right_click(mb, world_pos) + return + + # 4. Left mouse button -> vertex drag / shape selection + if mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed: + _handle_left_press(world_pos) + else: + _handle_left_release() + + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + + if _is_panning: + _pan_offset += (mm.position - _pan_start) / _zoom + _pan_start = mm.position + drawing_area.queue_redraw() + return + + if _dragging_vertex >= 0 and _selected_shape_idx >= 0: + var sd := _selected_shape() + var sd_pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if _dragging_vertex < sd_pts.size(): + var world_pos := _screen_to_world(mm.position) + if _snap_enabled: + world_pos = _snap_to_grid(world_pos) + sd_pts[_dragging_vertex] = world_pos - _drag_offset + sd["points"] = sd_pts + drawing_area.queue_redraw() + + if _is_dragging_shape and _selected_shape_idx >= 0: + var world_pos := _screen_to_world(mm.position) + if _snap_enabled: + world_pos = _snap_to_grid(world_pos) + var delta := world_pos - _shape_drag_start + var sd: Dictionary = shapes[_selected_shape_idx] + var new_pts := PackedVector2Array() + for pt: Vector2 in _shape_drag_original_points: + new_pts.append(pt + delta) + sd["points"] = new_pts + drawing_area.queue_redraw() + + +func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: + _context_shape_idx = -1 + + # Step 1: Check if right-click is on a vertex of any shape (selected first). + # Check selected shape first, then others in reverse z-order (front to back). + if _selected_shape_idx >= 0: + var sd := _selected_shape() + var hit_idx := _hit_test_vertex_in_shape(sd, world_pos) + if hit_idx >= 0: + _context_shape_idx = _selected_shape_idx + context_menu.clear() + context_menu.add_item("Remove Point", 6) + context_menu.set_meta("vertex_index", hit_idx) + context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + return + + for i: int in range(shapes.size() - 1, -1, -1): + if i == _selected_shape_idx: + continue + var sd: Dictionary = shapes[i] + var hit_idx := _hit_test_vertex_in_shape(sd, world_pos) + if hit_idx >= 0: + _context_shape_idx = i + context_menu.clear() + context_menu.add_item("Remove Point", 6) + context_menu.set_meta("vertex_index", hit_idx) + context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + return + + # Step 2: Check if right-click is over any shape (reverse order = front first). + var found_shape_idx := -1 + for i: int in range(shapes.size() - 1, -1, -1): + var sd: Dictionary = shapes[i] + if _is_point_in_shape(sd, world_pos): + found_shape_idx = i + break + + if found_shape_idx >= 0: + # Select the shape that was right-clicked + _selected_shape_idx = found_shape_idx + _context_shape_idx = found_shape_idx + drawing_area.queue_redraw() + + context_menu.clear() + + var found_sd: Dictionary = shapes[found_shape_idx] + if _is_point_near_outline(found_sd, world_pos): + context_menu.add_item("Create Point", 3) + context_menu.add_separator() + + context_menu.add_item("Color...", 4) + context_menu.add_separator() + context_menu.add_item("Send Back", 7) + context_menu.add_item("Bring Forward", 8) + context_menu.add_separator() + context_menu.add_item("Copy", 9) + if _has_clipboard: + context_menu.add_item("Paste", 10) + context_menu.add_separator() + context_menu.add_item("Mirror X", 11) + context_menu.add_item("Mirror Y", 12) + context_menu.add_separator() + context_menu.add_item("Delete", 5) + + context_menu.set_meta("click_position", world_pos) + context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + return + + # Step 3: No shape under mouse -> shape creation menu. + context_menu.clear() + context_menu.add_item("Line", 0) + context_menu.add_item("Rectangle", 1) + context_menu.add_item("Circle", 2) + if _has_clipboard: + context_menu.add_separator() + context_menu.add_item("Paste", 10) + context_menu.set_meta("click_position", world_pos) + context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + + +func _handle_left_press(world_pos: Vector2) -> void: + _context_shape_idx = -1 + + # Check vertex hit on selected shape first. + if _selected_shape_idx >= 0: + var sd := _selected_shape() + var hit_idx := _hit_test_vertex_in_shape(sd, world_pos) + if hit_idx >= 0: + _dragging_vertex = hit_idx + var sd_pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + _drag_offset = world_pos - sd_pts[hit_idx] + drawing_area.queue_redraw() + return + + # Check shape hit (reverse order = front first) + var hit_shape_idx := -1 + for i: int in range(shapes.size() - 1, -1, -1): + var sd: Dictionary = shapes[i] + if _is_point_in_shape(sd, world_pos): + hit_shape_idx = i + break + + if hit_shape_idx >= 0: + _selected_shape_idx = hit_shape_idx + # Start shape translation drag + _is_dragging_shape = true + _shape_drag_start = world_pos + var sd: Dictionary = shapes[hit_shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + _shape_drag_original_points = PackedVector2Array(pts) + drawing_area.queue_redraw() + shape_selected.emit() + else: + _selected_shape_idx = -1 + _dragging_vertex = -1 + drawing_area.queue_redraw() + shape_selected.emit() + + +func _handle_left_release() -> void: + if _is_dragging_shape: + _is_dragging_shape = false + _shape_drag_original_points = PackedVector2Array() + drawing_area.queue_redraw() + _emit_changed() + return + + if _dragging_vertex >= 0: + _dragging_vertex = -1 + _drag_offset = Vector2.ZERO + drawing_area.queue_redraw() + _emit_changed() + + +func _on_context_menu_id_pressed(id: int) -> void: + match id: + 0: # Line + _create_line_from_context() + 1: # Rectangle + _create_rectangle_from_context() + 2: # Circle + _create_circle_from_context() + 3: # Create Point + _create_point_from_context() + 4: # Color... + _show_color_picker() + 5: # Delete shape + _delete_selected_shape() + 6: # Remove Point + _remove_vertex_from_context() + 7: # Send Back + _send_back() + 8: # Bring Forward + _bring_forward() + 9: # Copy + _copy_selected_shape() + 10: # Paste + _paste_from_context() + 11: # Mirror X + _mirror_shape_x() + 12: # Mirror Y + _mirror_shape_y() + + +func _create_line_from_context() -> void: + var world_pos := _get_context_world_pos() + _create_line(world_pos) + drawing_area.queue_redraw() + _emit_changed() + + +func _create_rectangle_from_context() -> void: + var world_pos := _get_context_world_pos() + _create_rectangle(world_pos) + drawing_area.queue_redraw() + _emit_changed() + + +func _create_circle_from_context() -> void: + var world_pos := _get_context_world_pos() + _create_circle(world_pos) + drawing_area.queue_redraw() + _emit_changed() + + +func _create_point_from_context() -> void: + var world_pos := _get_context_world_pos() + _create_point_on_edge(world_pos) + drawing_area.queue_redraw() + _emit_changed() + + +func _get_context_world_pos() -> Vector2: + var click_pos: Variant = context_menu.get_meta("click_position", Vector2(100, 60)) + var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100, 60) + + var world_size := drawing_area.size / _zoom + if world_pos.x < 0 or world_pos.y < 0 or world_pos.x > world_size.x or world_pos.y > world_size.y: + return _screen_to_world(drawing_area.size * 0.5) + return world_pos + +# --------------------------------------------------------------------------- +# Shape creation helpers +# --------------------------------------------------------------------------- + +func _make_shape_dict() -> Dictionary: + return { + "shape_type": "", + "points": PackedVector2Array(), + "color": "#000000", + "closed": false, + "vertex_flags": PackedInt32Array(), + } + + +func _create_line(center: Vector2) -> void: + var sd := _make_shape_dict() + sd["shape_type"] = "line" + sd["points"] = PackedVector2Array([ + center + Vector2(-60, 0), + center + Vector2(60, 0), + ]) + sd["closed"] = false + sd["vertex_flags"] = PackedInt32Array() + sd["vertex_flags"].resize(2) + shapes.append(sd) + _selected_shape_idx = shapes.size() - 1 + + +func _create_rectangle(center: Vector2) -> void: + var hw := 50.0 + var hh := 30.0 + var sd := _make_shape_dict() + sd["shape_type"] = "rectangle" + sd["points"] = PackedVector2Array([ + center + Vector2(-hw, -hh), + center + Vector2(hw, -hh), + center + Vector2(hw, hh), + center + Vector2(-hw, hh), + ]) + sd["closed"] = true + sd["vertex_flags"] = PackedInt32Array() + sd["vertex_flags"].resize(4) + shapes.append(sd) + _selected_shape_idx = shapes.size() - 1 + + +func _create_circle(center: Vector2) -> void: + var radius := 40.0 + var segments := 12 + var pts := PackedVector2Array() + for i: int in range(segments): + var angle: float = TAU * float(i) / float(segments) - (PI / 2.0) + pts.append(center + Vector2(cos(angle), sin(angle)) * radius) + var sd := _make_shape_dict() + sd["shape_type"] = "circle" + sd["points"] = pts + sd["closed"] = true + sd["vertex_flags"] = PackedInt32Array() + sd["vertex_flags"].resize(segments) + shapes.append(sd) + _selected_shape_idx = shapes.size() - 1 + +# --------------------------------------------------------------------------- +# Shape hit-test helpers (operate on a single shape dict) +# --------------------------------------------------------------------------- + +func _hit_test_vertex_in_shape(sd: Dictionary, world_pos: Vector2) -> int: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + var threshold: float = HANDLE_RADIUS_SELECTION / _zoom + for i: int in range(pts.size()): + if world_pos.distance_to(pts[i]) <= threshold: + return i + return -1 + + +func _is_point_in_shape(sd: Dictionary, world_pos: Vector2) -> bool: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return false + var closed: bool = bool(sd.get("closed", false)) + if closed: + return Geometry2D.is_point_in_polygon(world_pos, pts) + else: + return _distance_to_shape_pts(pts, closed, world_pos) <= EDGE_HIT_THRESHOLD / _zoom + + +func _is_point_near_outline(sd: Dictionary, world_pos: Vector2) -> bool: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return false + return _distance_to_shape_pts(pts, bool(sd.get("closed", false)), world_pos) <= EDGE_HIT_THRESHOLD / _zoom + + +func _distance_to_shape_pts(pts: PackedVector2Array, closed: bool, world_pos: Vector2) -> float: + var min_dist: float = INF + for i: int in range(pts.size() - 1): + var d: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1]) + min_dist = minf(min_dist, d) + if closed and pts.size() >= 2: + var d: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0]) + min_dist = minf(min_dist, d) + return min_dist + + +func _point_to_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float: + var ab: Vector2 = b - a + var ap: Vector2 = point - a + var ab_len_sq: float = ab.length_squared() + if ab_len_sq < 0.0001: + return ap.length() + var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0) + var closest: Vector2 = a + ab * t + return point.distance_to(closest) + + +func _create_point_on_edge(world_pos: Vector2) -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var sd: Dictionary = shapes[idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return + + var closed: bool = bool(sd.get("closed", false)) + var best_edge: int = 0 + var best_dist: float = INF + + for i: int in range(pts.size() - 1): + var seg_dist: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1]) + if seg_dist < best_dist: + best_dist = seg_dist + best_edge = i + + if closed and pts.size() >= 2: + var seg_dist: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0]) + if seg_dist < best_dist: + best_dist = seg_dist + best_edge = pts.size() - 1 + + var a: Vector2 = pts[best_edge] + var b: Vector2 = pts[(best_edge + 1) % pts.size()] + var midpoint: Vector2 = (a + b) * 0.5 + + var insert_idx: int = best_edge + 1 + if closed and best_edge == pts.size() - 1: + insert_idx = pts.size() + + pts.insert(insert_idx, midpoint) + sd["points"] = pts + + var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + vf.insert(insert_idx, 1) + sd["vertex_flags"] = vf + +# --------------------------------------------------------------------------- +# Phase 3: Color picker +# --------------------------------------------------------------------------- + +func _show_color_picker() -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var sd: Dictionary = shapes[idx] + _restore_color = Color.from_string(str(sd.get("color", "#000000")), Color.BLACK) + color_picker.color = _restore_color + color_picker_popup.popup_centered() + + +func _on_color_picker_changed(new_color: Color) -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var sd: Dictionary = shapes[idx] + sd["color"] = new_color.to_html() + drawing_area.queue_redraw() + + +func _on_color_picker_ok() -> void: + color_picker_popup.hide() + _emit_changed() + + +func _on_color_picker_cancel() -> void: + var idx := _get_context_shape_idx() + if idx < 0: + color_picker_popup.hide() + return + var sd: Dictionary = shapes[idx] + sd["color"] = _restore_color.to_html() + color_picker_popup.hide() + drawing_area.queue_redraw() + +# --------------------------------------------------------------------------- +# Phase 3: Delete shape +# --------------------------------------------------------------------------- + +func _delete_selected_shape() -> void: + var idx := _get_context_shape_idx() + if idx < 0 or idx >= shapes.size(): + return + shapes.remove_at(idx) + if shapes.is_empty(): + _selected_shape_idx = -1 + _dragging_vertex = -1 + else: + _selected_shape_idx = clampi(idx - 1, 0, shapes.size() - 1) + drawing_area.queue_redraw() + _emit_changed() + +# --------------------------------------------------------------------------- +# Phase 3: Remove vertex +# --------------------------------------------------------------------------- + +func _remove_vertex_from_context() -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var vertex_index: Variant = context_menu.get_meta("vertex_index", -1) + _remove_vertex_at(idx, int(vertex_index) if vertex_index is int else -1) + + +func _remove_vertex_at(shape_idx: int, vertex_idx: int) -> void: + if shape_idx < 0 or shape_idx >= shapes.size(): + return + var sd: Dictionary = shapes[shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if vertex_idx < 0 or vertex_idx >= pts.size(): + return + + pts.remove_at(vertex_idx) + sd["points"] = pts + + var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + vf.remove_at(vertex_idx) + sd["vertex_flags"] = vf + + if pts.size() == 2: + sd["closed"] = false + + if pts.size() < 2: + shapes.remove_at(shape_idx) + if shapes.is_empty(): + _selected_shape_idx = -1 + else: + _selected_shape_idx = clampi(shape_idx - 1, 0, shapes.size() - 1) + _dragging_vertex = -1 + else: + drawing_area.queue_redraw() + + _emit_changed() + +# --------------------------------------------------------------------------- +# Phase 4: Z-ordering +# --------------------------------------------------------------------------- + +func _get_context_shape_idx() -> int: + return _context_shape_idx if _context_shape_idx >= 0 else _selected_shape_idx + + +func _send_back() -> void: + var idx := _get_context_shape_idx() + if idx <= 0 or idx >= shapes.size(): + return + var sd := shapes[idx] + shapes.remove_at(idx) + shapes.insert(idx - 1, sd) + _selected_shape_idx = idx - 1 + _context_shape_idx = idx - 1 + drawing_area.queue_redraw() + _emit_changed() + + +func _bring_forward() -> void: + var idx := _get_context_shape_idx() + if idx < 0 or idx >= shapes.size() - 1: + return + var sd := shapes[idx] + shapes.remove_at(idx) + shapes.insert(idx + 1, sd) + _selected_shape_idx = idx + 1 + _context_shape_idx = idx + 1 + drawing_area.queue_redraw() + _emit_changed() + +# --------------------------------------------------------------------------- +# Phase 5: Copy, paste, and mirror +# --------------------------------------------------------------------------- + +func _copy_selected_shape() -> void: + var idx := _get_context_shape_idx() + if idx < 0 or idx >= shapes.size(): + return + shape_copy_requested.emit(shapes[idx]) + + +func _paste_from_context() -> void: + var world_pos := _get_context_world_pos() + shape_paste_requested.emit(world_pos) + + +func _mirror_shape_x() -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var sd: Dictionary = shapes[idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + var min_x := INF; var max_x := -INF + for pt: Vector2 in pts: + min_x = min(min_x, pt.x) + max_x = max(max_x, pt.x) + var center_x: float = (min_x + max_x) * 0.5 + for i: int in range(pts.size()): + pts[i] = Vector2(center_x * 2.0 - pts[i].x, pts[i].y) + sd["points"] = pts + drawing_area.queue_redraw() + _emit_changed() + + +func _mirror_shape_y() -> void: + var idx := _get_context_shape_idx() + if idx < 0: + return + var sd: Dictionary = shapes[idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + var min_y := INF; var max_y := -INF + for pt: Vector2 in pts: + min_y = min(min_y, pt.y) + max_y = max(max_y, pt.y) + var center_y: float = (min_y + max_y) * 0.5 + for i: int in range(pts.size()): + pts[i] = Vector2(pts[i].x, center_y * 2.0 - pts[i].y) + sd["points"] = pts + drawing_area.queue_redraw() + _emit_changed() diff --git a/scripts/body_part_panel.gd.uid b/scripts/body_part_panel.gd.uid new file mode 100644 index 0000000..ad44cea --- /dev/null +++ b/scripts/body_part_panel.gd.uid @@ -0,0 +1 @@ +uid://blq30ou2f1boe diff --git a/scripts/stickman_editor.gd b/scripts/stickman_editor.gd new file mode 100644 index 0000000..4224210 --- /dev/null +++ b/scripts/stickman_editor.gd @@ -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" diff --git a/scripts/stickman_editor.gd.uid b/scripts/stickman_editor.gd.uid new file mode 100644 index 0000000..08a1ec1 --- /dev/null +++ b/scripts/stickman_editor.gd.uid @@ -0,0 +1 @@ +uid://dlacmxk257ro diff --git a/scripts/whole_stickman_preview.gd b/scripts/whole_stickman_preview.gd new file mode 100644 index 0000000..83ed204 --- /dev/null +++ b/scripts/whole_stickman_preview.gd @@ -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() diff --git a/scripts/whole_stickman_preview.gd.uid b/scripts/whole_stickman_preview.gd.uid new file mode 100644 index 0000000..bcc925a --- /dev/null +++ b/scripts/whole_stickman_preview.gd.uid @@ -0,0 +1 @@ +uid://codv3g6qw1tef diff --git a/stick.tscn b/stick.tscn new file mode 100644 index 0000000..af39900 --- /dev/null +++ b/stick.tscn @@ -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") +} diff --git a/stickmen/basic.stk b/stickmen/basic.stk new file mode 100644 index 0000000..1d525da --- /dev/null +++ b/stickmen/basic.stk @@ -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" + } +} \ No newline at end of file diff --git a/stickmen/break.stk b/stickmen/break.stk new file mode 100644 index 0000000..c77373a --- /dev/null +++ b/stickmen/break.stk @@ -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" + } +} \ No newline at end of file diff --git a/stickmen/test.stk b/stickmen/test.stk new file mode 100644 index 0000000..3e8ee22 --- /dev/null +++ b/stickmen/test.stk @@ -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" + } +} \ No newline at end of file