Add unique identifier for test_phase3c_walk_recovery.gd
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
You must never combine phases 3–5 in a single response. Always stop after presenting questions or choices and wait for the user’s next message.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**This is a mandatory stop point.**
|
||||||
|
|
||||||
|
- Output a numbered list of questions.
|
||||||
|
- **Do NOT include any architecture, code, or spec content in this response.**
|
||||||
|
- End your response with: “Please reply with answers to these questions before I proceed.”
|
||||||
|
|
||||||
|
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
||||||
|
|
||||||
|
## Phase 4: Architecture design
|
||||||
|
|
||||||
|
**This is a mandatory stop point.**
|
||||||
|
|
||||||
|
- Present 2–3 approaches with trade‑offs.
|
||||||
|
- State your recommendation.
|
||||||
|
- **Do NOT choose or implement anything.**
|
||||||
|
- End with: “Which approach do you prefer? Reply with your choice.”
|
||||||
|
|
||||||
|
### 📝 Tech-Debt & Future-Optimization Logging
|
||||||
|
|
||||||
|
During architecture design, if you identify:
|
||||||
|
|
||||||
|
- Trade-offs that will cause friction later (e.g., "we're using a quick O(n²) loop here because the list is small now, but it will scale poorly").
|
||||||
|
- Obvious refactoring opportunities that are out of scope (e.g., "this legacy singleton should be replaced with an event bus").
|
||||||
|
- Missing tests or error handling that are not critical for the current feature.
|
||||||
|
|
||||||
|
**Append** a new entry to `docs/tech_debt_and_optimizations.md` using this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [YYYY-MM-DD] - [Feature Name]
|
||||||
|
|
||||||
|
- **Debt**: [Clear description]
|
||||||
|
- **Impact**: [What breaks/degrades if ignored]
|
||||||
|
- **Suggested Fix**: [Actionable improvement]
|
||||||
|
- **Context**: [Link to spec file or relevant code path]
|
||||||
|
|
||||||
|
## Phase 5: Create Spec
|
||||||
|
|
||||||
|
**Do not proceed until the user explicitly approves the chosen approach.**
|
||||||
|
|
||||||
|
- Once they approve, you may write the spec in the next turn.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: Developer
|
||||||
|
description: Implements core application features across Godot.
|
||||||
|
mode: subagent
|
||||||
|
model: "deepseek/deepseek-v4-pro"
|
||||||
|
steps: 60
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash: allow
|
||||||
|
options:
|
||||||
|
reasoningEffort: medium
|
||||||
|
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 the project's Godot 4.7 console binary:
|
||||||
|
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --quit`
|
||||||
|
(project-wide parse/import check). For a single script:
|
||||||
|
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"`
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
|
||||||
|
### 🔧 Runtime Tech-Debt Discovery
|
||||||
|
|
||||||
|
While writing code, if you encounter:
|
||||||
|
|
||||||
|
- Ugly workarounds forced by existing code.
|
||||||
|
- Performance pitfalls you have to code around.
|
||||||
|
- Unused imports, dead code, or outdated comments that are confusing.
|
||||||
|
|
||||||
|
**Immediately** append to `docs/tech_debt_and_optimizations.md` with the same format.
|
||||||
|
|
||||||
|
### 🎨 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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
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**: After updating README and API docs, review `docs/tech_debt_and_optimizations.md` for formatting consistency and ensure no duplicate entries exist.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- **Architect Gate**: Stop after Phase 3 and wait for user approval on the spec before calling `@developer`.
|
||||||
|
- **Tester Repair Limit**: Allow `@tester` a maximum of 2 auto-repair attempts for failing test suites. If tests still fail after 2 attempts, hand the error context back to `@developer` to fix the underlying implementation.
|
||||||
|
- 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.
|
||||||
+13
-148
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: architect
|
name: architect
|
||||||
description: "Defines system requirements, data contracts, and architectural blueprints."
|
description: "Defines system requirements and technical specifications for Godot features."
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: "deepseek/deepseek-v4-pro"
|
model: "deepseek/deepseek-v4-pro"
|
||||||
permission:
|
permission:
|
||||||
@@ -8,156 +8,21 @@ permission:
|
|||||||
bash: deny
|
bash: deny
|
||||||
options:
|
options:
|
||||||
reasoningEffort: high
|
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
|
You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory standards and coding rules.
|
||||||
|
|
||||||
## Core Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- **Specification:** Create and maintain `specs/` markdown files for new features.
|
1. **Analyze:** Read existing code patterns before designing.
|
||||||
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
|
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
|
||||||
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
|
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||||
- **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
|
## Workflow
|
||||||
|
|
||||||
- **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.
|
1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous.
|
||||||
- **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.
|
2. **Spec Creation:** Output a minimal specification containing:
|
||||||
- **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.
|
- Target Nodes / Classes modified.
|
||||||
- **Responsive and Adaptive:** All UI systems must handle dynamic font sizing, localizable text expansions, and varying aspect ratios gracefully without layout breaking.
|
- Signal interfaces & static type contracts.
|
||||||
|
- Layout/Theme updates required.
|
||||||
## 🛠️ Stack & Pattern Specifications
|
3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval.
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
You must never combine phases 3–5 in a single response. Always stop after presenting questions or choices and wait for the user’s next message.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
**This is a mandatory stop point.**
|
|
||||||
|
|
||||||
- Output a numbered list of questions.
|
|
||||||
- **Do NOT include any architecture, code, or spec content in this response.**
|
|
||||||
- End your response with: “Please reply with answers to these questions before I proceed.”
|
|
||||||
|
|
||||||
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
|
||||||
|
|
||||||
## Phase 4: Architecture design
|
|
||||||
|
|
||||||
**This is a mandatory stop point.**
|
|
||||||
|
|
||||||
- Present 2–3 approaches with trade‑offs.
|
|
||||||
- State your recommendation.
|
|
||||||
- **Do NOT choose or implement anything.**
|
|
||||||
- End with: “Which approach do you prefer? Reply with your choice.”
|
|
||||||
|
|
||||||
### 📝 Tech-Debt & Future-Optimization Logging
|
|
||||||
|
|
||||||
During architecture design, if you identify:
|
|
||||||
|
|
||||||
- Trade-offs that will cause friction later (e.g., "we're using a quick O(n²) loop here because the list is small now, but it will scale poorly").
|
|
||||||
- Obvious refactoring opportunities that are out of scope (e.g., "this legacy singleton should be replaced with an event bus").
|
|
||||||
- Missing tests or error handling that are not critical for the current feature.
|
|
||||||
|
|
||||||
**Append** a new entry to `docs/tech_debt_and_optimizations.md` using this format:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [YYYY-MM-DD] - [Feature Name]
|
|
||||||
|
|
||||||
- **Debt**: [Clear description]
|
|
||||||
- **Impact**: [What breaks/degrades if ignored]
|
|
||||||
- **Suggested Fix**: [Actionable improvement]
|
|
||||||
- **Context**: [Link to spec file or relevant code path]
|
|
||||||
|
|
||||||
## Phase 5: Create Spec
|
|
||||||
|
|
||||||
**Do not proceed until the user explicitly approves the chosen approach.**
|
|
||||||
|
|
||||||
- Once they approve, you may write the spec in the next turn.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,102 +1,26 @@
|
|||||||
---
|
---
|
||||||
name: Developer
|
name: developer
|
||||||
description: Implements core application features across Godot.
|
description: Implements GDScript logic and scene adjustments.
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: "deepseek/deepseek-v4-pro"
|
model: "deepseek/deepseek-v4-pro"
|
||||||
steps: 60
|
|
||||||
permission:
|
permission:
|
||||||
edit: allow
|
edit: allow
|
||||||
bash: allow
|
bash: allow
|
||||||
options:
|
options:
|
||||||
reasoningEffort: medium
|
reasoningEffort: medium
|
||||||
thinking:
|
|
||||||
type: enabled
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Role: Godot 4 Engine & GDScript Reviewer Agent
|
You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding standards in `AGENTS.md`.
|
||||||
|
|
||||||
## 1. Core Objective
|
## Rules
|
||||||
|
|
||||||
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.
|
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
|
||||||
|
- **Static Typing:** Enforce explicit type hints for all signatures and variables.
|
||||||
## 2. Technical Context (Godot 4.x)
|
- **Decoupling:** Follow "call down, signal up".
|
||||||
|
- **Validation:** Run syntax verification command when finished:
|
||||||
- **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 the project's Godot 4.7 console binary:
|
|
||||||
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --quit`
|
|
||||||
(project-wide parse/import check). For a single script:
|
|
||||||
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"`
|
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"`
|
||||||
|
|
||||||
## 5. Response Output Format
|
## Output Format
|
||||||
|
|
||||||
For every review, structure your response as follows:
|
- **Summary:** 1-2 lines on changes made.
|
||||||
|
- **Code Edits:** Show only modified snippet/function blocks with surrounding context lines.
|
||||||
### 🔍 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]
|
|
||||||
|
|
||||||
### 🔧 Runtime Tech-Debt Discovery
|
|
||||||
|
|
||||||
While writing code, if you encounter:
|
|
||||||
|
|
||||||
- Ugly workarounds forced by existing code.
|
|
||||||
- Performance pitfalls you have to code around.
|
|
||||||
- Unused imports, dead code, or outdated comments that are confusing.
|
|
||||||
|
|
||||||
**Immediately** append to `docs/tech_debt_and_optimizations.md` with the same format.
|
|
||||||
|
|
||||||
### 🎨 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: "Drafts and updates technical documentation, architecture guides, and API specs."
|
description: "Drafts and updates technical documentation, GDScript APIs, and architecture specs."
|
||||||
mode: "subagent"
|
mode: "subagent"
|
||||||
model: "deepseek/deepseek-v4-flash"
|
model: "deepseek/deepseek-v4-flash"
|
||||||
permission:
|
permission:
|
||||||
@@ -10,15 +10,15 @@ options:
|
|||||||
type: disabled
|
type: disabled
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a technical writer who communicates complex software architectures with pinpoint precision.
|
You are a technical writer for Godot 4 / GDScript projects. You document game architectures, class APIs, and node systems with pinpoint precision.
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
|
- **Architecture & System Guides:** Maintain concise READMEs, scene hierarchy overviews, and system runbooks.
|
||||||
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
|
- **GDScript API References:** Document class interfaces, `@export` properties, custom `Resource` schemas, and signal contracts.
|
||||||
|
- **Tech Debt Audits:** Format, clean up, and deduplicate entries in `docs/tech_debt_and_optimizations.md`.
|
||||||
|
|
||||||
### Style Guide
|
### Execution Rules
|
||||||
|
|
||||||
1. Keep prose technical, precise, and highly scannable.
|
1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files.
|
||||||
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
|
2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions.
|
||||||
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
|
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
---
|
---
|
||||||
name: spec-pipeline
|
name: spec-pipeline
|
||||||
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Writer."
|
description: "Lean implementation loop: Architect -> Developer -> User Verification."
|
||||||
---
|
---
|
||||||
|
|
||||||
## What I do
|
## Execution Logic
|
||||||
|
|
||||||
I orchestrate a sequential feature implementation and verification pipeline:
|
1. **Phase 1: Architecture (Optional for small edits)**
|
||||||
|
- `@architect` defines signatures, node structures, or spec updates in `specs/[feature-name].md`.
|
||||||
|
- **STOP:** Wait for user approval on design choices before invoking developer.
|
||||||
|
|
||||||
1. **Architect**: Explores codebase, asks clarifying questions, and drafts the spec. *Waits for user approval before handoff.*
|
2. **Phase 2: Implementation & Engine Validation**
|
||||||
2. **Developer**: Implements the feature based on the spec.
|
- `@developer` updates code and scenes using targeted diffs/snippets.
|
||||||
3. **Tester**: Runs full unit test suites; repairs failures if found.
|
- `@developer` runs headless syntax verification:
|
||||||
4. **Writer**: After updating README and API docs, review `docs/tech_debt_and_optimizations.md` for formatting consistency and ensure no duplicate entries exist.
|
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"`
|
||||||
|
- If static check fails, `@developer` fixes the code immediately.
|
||||||
|
|
||||||
## Execution Rules
|
3. **Phase 3: User Hand-off (Stop)**
|
||||||
|
- Present the changes to the user to test inside the Godot Editor.
|
||||||
|
- Do NOT update READMEs or documentation yet.
|
||||||
|
|
||||||
- **Architect Gate**: Stop after Phase 3 and wait for user approval on the spec before calling `@developer`.
|
4. **Phase 4: Documentation (Triggered manually by user)**
|
||||||
- **Tester Repair Limit**: Allow `@tester` a maximum of 2 auto-repair attempts for failing test suites. If tests still fail after 2 attempts, hand the error context back to `@developer` to fix the underlying implementation.
|
- Only call `@writer` after the user confirms: _"Feature verified and working."_
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
|
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
|
||||||
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
|
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
|
||||||
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
|
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
|
||||||
`FORWARD`, a preset whose setter writes the four per-joint vars + reorders `Body/*`) and an
|
`FORWARD`, a preset whose setter writes the four per-joint vars, reorders `Body/*`, and
|
||||||
|
applies a whole-rig Y-axis mirror for LEFT — `Master.scale.x = -1`; RIGHT/FORWARD `(1,1)`) and an
|
||||||
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
|
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
|
||||||
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
|
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
|
||||||
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
|
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
|
||||||
@@ -245,6 +246,25 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
||||||
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
||||||
`"StickmanRig: "` throughout; never crashes.
|
`"StickmanRig: "` throughout; never crashes.
|
||||||
|
- **Whole-rig Y-axis mirror (replaces `_apply_head_flip()`, 2026 design change):** Facing LEFT
|
||||||
|
is applied as a **whole-rig Y-axis mirror** — `Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) —
|
||||||
|
so the head AND body mirror together and face the correct direction. The old `_apply_head_flip()`
|
||||||
|
and the `Body/Head.scale.x` mirror are **removed** (the Head Pivot node's driver transform is
|
||||||
|
untouched). Per-joint `flip_bend_direction` flags (`PROFILE_FLAGS`) and `Z_ORDER_BY_PROFILE` are
|
||||||
|
**kept provisionally** (unchanged): the mirror reflects the whole skeleton + `IK_Targets` +
|
||||||
|
mounted `Body/*` geometry, while z-order (depth) is unaffected by an X-mirror. `walk_to()`
|
||||||
|
always plays the canonical `walk_right` clip (X-mirrored by the root for LEFT; `walk_left` is
|
||||||
|
no longer used at runtime) and sets the facing profile explicitly. Two **head-related fixes**
|
||||||
|
under the mirror: the master_rig.tscn head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`)
|
||||||
|
no longer sets `update_scale = false` — it pushes the **full transform** like every other `Body`
|
||||||
|
driver, so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel
|
||||||
|
push re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). And
|
||||||
|
`_apply_head_lookat_mirror_mode()` (called from `_apply_profile()`) **disables** the head
|
||||||
|
`SkeletonModification2DLookAt` when facing LEFT and pins the head bone rotation to the FORWARD
|
||||||
|
canonical aim (π); `_pin_mirrored_head_rotation()` re-asserts that pin each `_physics_process`
|
||||||
|
frame while ANIMATED/RECOVERING so recovery's stack re-arm can't let LookAt flip the bone.
|
||||||
|
RIGHT/FORWARD re-enable the LookAt. Consequence: interactive head-aiming while facing LEFT is
|
||||||
|
intentionally static.
|
||||||
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
|
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
|
||||||
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
|
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
|
||||||
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
||||||
@@ -280,18 +300,31 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
`rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` on it calls
|
`rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` on it calls
|
||||||
`_start_recovery()`. `_start_recovery()` (also `request_recovery()`, no-op unless in
|
`_start_recovery()`. `_start_recovery()` (also `request_recovery()`, no-op unless in
|
||||||
RAGDOLL) captures the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` =
|
RAGDOLL) captures the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` =
|
||||||
each capsule's half-length from build-time metadata), `_destroy_ragdoll()`s,
|
each capsule's half-length from build-time metadata) plus a landing anchor
|
||||||
|
(`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` =
|
||||||
|
`torso.center.y + RAGDOLL_TORSO_RADIUS`), `_reanchor_root_to_landing()`s (translates the rig
|
||||||
|
root so the standing figure's **feet** sit on the ground at the landing X — `feet =
|
||||||
|
(_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and
|
||||||
|
re-bases the captured rig-local positions by the root shift; the figure stands up **in place,
|
||||||
|
on the ground**, where the ragdoll landed, since the ragdoll bodies live under a world sibling
|
||||||
|
and the root never moved while it fell), `_destroy_ragdoll()`s,
|
||||||
sets `state = RECOVERING` + emits, then `_snap_skeleton_to_pose()` — a **marker-driven** snap
|
sets `state = RECOVERING` + emits, then `_snap_skeleton_to_pose()` — a **marker-driven** snap
|
||||||
writing `IK_Targets/Torso.position`/`.rotation`, `IK_Targets/Head.position`, and the 4 limb
|
writing `IK_Targets/Torso.position`/`.rotation`, `IK_Targets/Head.position`, and the 4 limb
|
||||||
markers (never the slaved `Torso` Bone2D), re-showing
|
markers (never the slaved `Torso` Bone2D), re-showing
|
||||||
`Body/*` before re-enabling the IK stack so TwoBoneIK solves toward the end-effectors. Snap
|
`Body/*`, then calling `_rearm_ik_stack()` — a defensive re-setup (re-sets up the
|
||||||
|
`SkeletonModificationStack2D` when it reports `!get_is_setup()`, re-asserts `enabled = true`
|
||||||
|
and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
|
||||||
|
`RemoteTransform2D` update-position/rotation/scale flags) so the stack reliably resumes solving
|
||||||
|
toward the end-effectors after a disable→enable toggle. Runtime diagnosis of the re-arm is
|
||||||
|
gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces stack
|
||||||
|
enabled/setup/internal + bone-following state). Snap
|
||||||
geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived
|
geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived
|
||||||
as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`;
|
as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`;
|
||||||
the Torso marker rotation subtracts the Torso Bone2D's `bone_angle` (bone world angle =
|
the Torso marker rotation subtracts the Torso Bone2D's `bone_angle` (bone world angle =
|
||||||
marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90°
|
marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90°
|
||||||
and lay it flat).
|
and lay it flat).
|
||||||
`_play_stand_up()` then tweens the 6 markers **directly** from their captured values to
|
`_play_stand_up()` then tweens the 6 markers **directly** from their captured values to
|
||||||
`STAND_POSE` over `STAND_UP_DURATION` (sine ease-in-out, `_tween_markers_to()`); the baked
|
`STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out, `_tween_markers_to()`); the baked
|
||||||
`"stand_up"` animation is **not** played (a fixed first keyframe can never match an arbitrary
|
`"stand_up"` animation is **not** played (a fixed first keyframe can never match an arbitrary
|
||||||
ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween
|
ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween
|
||||||
finish `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`,
|
finish `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`,
|
||||||
@@ -335,8 +368,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default
|
unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default
|
||||||
diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage).
|
diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage).
|
||||||
Walking is kinematic
|
Walking is kinematic
|
||||||
(`global_position.move_toward`); movement composes with the `walk_left`/`walk_right` in-place limb
|
(`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb
|
||||||
animations (each has a discrete `.:facing_profile` track).
|
animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set
|
||||||
|
explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and
|
||||||
|
the animation `.:facing_profile` tracks are neutralized/removed).
|
||||||
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
|
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
|
||||||
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
|
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
|
||||||
`enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the action
|
`enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the action
|
||||||
@@ -356,7 +391,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
(run manually with `master_rig.tscn` open, **not** auto-loaded or referenced at runtime).
|
(run manually with `master_rig.tscn` open, **not** auto-loaded or referenced at runtime).
|
||||||
Supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right`
|
Supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right`
|
||||||
(same keyframes as the old script, via `_generate_walk_animation()`) and a one-shot `stand_up`
|
(same keyframes as the old script, via `_generate_walk_animation()`) and a one-shot `stand_up`
|
||||||
(via `_generate_pose_animation()`, `STAND_UP_DURATION` = 0.8, `loop_mode = LOOP_NONE`) into the
|
(via `_generate_pose_animation()`, `STAND_UP_DURATION` = 2.0, `loop_mode = LOOP_NONE`) into the
|
||||||
open scene's default `AnimationLibrary`. `stand_up` keys the 6 `IK_Targets/*:position` tracks
|
open scene's default `AnimationLibrary`. `stand_up` keys the 6 `IK_Targets/*:position` tracks
|
||||||
plus a `IK_Targets/Torso:rotation` track from `POSE_DOWN` (a generic "lying on back" pose) to
|
plus a `IK_Targets/Torso:rotation` track from `POSE_DOWN` (a generic "lying on back" pose) to
|
||||||
`POSE_STANDING` (matching `master_rig.tscn` defaults), with `POSE_PATHS`/`POSE_MARKERS` consts.
|
`POSE_STANDING` (matching `master_rig.tscn` defaults), with `POSE_PATHS`/`POSE_MARKERS` consts.
|
||||||
@@ -476,8 +511,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
|
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
|
||||||
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
|
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
|
||||||
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
|
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
|
||||||
and state. Playing `walk_right` also sets the rig's `facing_profile` via its animation track →
|
and state. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now
|
||||||
export setter → the existing `_on_facing_profile_changed` handling (menu `[√] ` + redraw). No
|
set explicitly via `set_facing_profile()` / `walk_to()` (which sets LEFT/RIGHT from the walk
|
||||||
|
direction and root-mirrors for LEFT), so playing `walk_right` no longer flips the rig's profile
|
||||||
|
through an animation track. No
|
||||||
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
|
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
|
||||||
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
|
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
|
||||||
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
|
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
|
||||||
|
|||||||
@@ -123,3 +123,34 @@ When applying .stk v1.4 data to master*rig.tscn, shapes are severely distorted:G
|
|||||||
- **Symptom:** in Play, the stickman advances one `move_toward` step then stops; the walk never reaches its target.
|
- **Symptom:** in Play, the stickman advances one `move_toward` step then stops; the walk never reaches its target.
|
||||||
- **Root cause:** `_update_walking` checked `is_navigation_finished()` before the map had synchronized. An unsynced `NavigationAgent2D` (map iteration id `0`) reports an empty, already-finished path, so the walk was ended after a single step. Additionally, `get_current_navigation_path()` alone never triggers a path computation — only `get_next_path_position()` forces the agent's internal `_update_navigation()` to re-query the map for a fresh target, so the empty-path guard would misjudge an off-mesh target as "unreachable" immediately.
|
- **Root cause:** `_update_walking` checked `is_navigation_finished()` before the map had synchronized. An unsynced `NavigationAgent2D` (map iteration id `0`) reports an empty, already-finished path, so the walk was ended after a single step. Additionally, `get_current_navigation_path()` alone never triggers a path computation — only `get_next_path_position()` forces the agent's internal `_update_navigation()` to re-query the map for a fresh target, so the empty-path guard would misjudge an off-mesh target as "unreachable" immediately.
|
||||||
- **Fix:** (1) defer all nav reads until `map_get_iteration_id(...) != 0`; (2) call `get_next_path_position()` **before** the empty-path / finished checks so the path is actually computed; (3) make walking **hybrid** — an on-mesh target follows the nav path (`_walk_mode = "nav"`), while an off-mesh/unreachable target (path empty **or** `is_target_reachable()` false) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`), so the stickman always reaches the waypoint the user clicked; (4) **no** `push_warning` for an off-mesh waypoint (a supported case — logged only via `_walk_dbg`); the original "warn + finish in place" policy was itself reported as "stickman stands still with a waypoint" and was superseded by this DIRECT branch; (5) `_finish_walk(reason: String)` internal param for the debug trace. The `_walk_path_grace` counter is **removed** — the map-sync guard + forced path query replace it.
|
- **Fix:** (1) defer all nav reads until `map_get_iteration_id(...) != 0`; (2) call `get_next_path_position()` **before** the empty-path / finished checks so the path is actually computed; (3) make walking **hybrid** — an on-mesh target follows the nav path (`_walk_mode = "nav"`), while an off-mesh/unreachable target (path empty **or** `is_target_reachable()` false) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`), so the stickman always reaches the waypoint the user clicked; (4) **no** `push_warning` for an off-mesh waypoint (a supported case — logged only via `_walk_dbg`); the original "warn + finish in place" policy was itself reported as "stickman stands still with a waypoint" and was superseded by this DIRECT branch; (5) `_finish_walk(reason: String)` internal param for the debug trace. The `_walk_path_grace` counter is **removed** — the map-sync guard + forced path query replace it.
|
||||||
|
|
||||||
|
## Sandbox Stage (Phase 3c)
|
||||||
|
|
||||||
|
> FIXED — 2026-09-04: all five bugs fixed (developer stage). Bugs 1 and 4 (implementation defects — panel rows never parented into the list container) fixed by adding the row to the list container; bugs 2 and 3 (spec/design defects — unconditional entry-point items) fixed by gating popup items behind an `about_to_popup` refresh; bug 5 (spec/design defect — theme schema) fixed by extending `sandbox_theme.json`'s `fonts` block + adding `apply_font(...)` to the Phase 3c panels/editors. See per-bug notes below. Spec amendments: `docs/phase_3c_editor_spec.md` §2 decision 8, §5.3, §11, and §14.
|
||||||
|
|
||||||
|
1. **"Edit Queue" shows no actions (implementation defect).** `queue_panel.gd` `refresh()` builds each row via `_make_row()` into `_rows` but never `_list.add_child(...)`s it, so rows are tracked for reorder but never rendered. Fix: add `_list.add_child(panel)` in `_make_row()` (or in `refresh()`).
|
||||||
|
- **FIXED (2026-09-04):** rows are now parented into the list container (`_list.add_child(panel)` in `_make_row()`), so the queue rows render.
|
||||||
|
2. **"Edit Queue…" should not appear when the stickman has no actions (spec/design).** The Direct action popup (`sandbox_stage.gd:1389`) and the stickman right-click menu (`:1475`) add "📋 Edit Queue…" unconditionally; no gating is specified in the plan/spec. Fix: hide/disable the item when `rig.get_queue().is_empty()`, refreshed on `about_to_popup`.
|
||||||
|
- **FIXED (2026-09-04):** the popup items are now gated via an `about_to_popup` refresh — "📋 Edit Queue…" is hidden/disabled when `rig.get_queue().is_empty()`.
|
||||||
|
3. **"Edit Rules…" should not appear when an object has no rules (spec/design).** Same unconditional add (`sandbox_stage.gd:1390`, `:1476`); no gating specified. Fix: hide/disable the item when no rule has `trigger.source == rig.get_instance_id()`.
|
||||||
|
- **FIXED (2026-09-04):** "⚡ Edit Rules…" is now gated via the same `about_to_popup` refresh — hidden/disabled when no rule has `trigger.source == rig.get_instance_id()`.
|
||||||
|
4. **"Edit Rules" shows no rules (implementation defect).** `rule_panel.gd` `refresh()` has the identical missing `_list.add_child(panel)` as bug 1. Fix: add `_list.add_child(panel)` in `_make_row()`.
|
||||||
|
- **FIXED (2026-09-04):** rows are now parented into the list container (`_list.add_child(panel)` in `_make_row()`), so the rule rows render.
|
||||||
|
5. **Font styles/sizes not configurable in `sandbox_theme.json` (spec/design).** The `fonts` block only carries sizes + font paths; no style (bold/italic) and no keys for the Phase 3c panels/editors (which currently get no font override at all). `action_popup_emoji_size` is a dead key. Fix: extend the schema (per-widget `{size, bold, italic}` + Phase 3c sizes/style flags) and add `apply_font(...)` to `QueuePanel`/`RulePanel`/`ActionEditor`/`RuleEditor`; consume `action_popup_emoji_size`.
|
||||||
|
- **FIXED (2026-09-04):** the `fonts` schema is extended (per-widget `{size, bold, italic}` object form + Phase 3c size/style flags); the Phase 3c panels/editors now accept `apply_font(...)`; `set_item_font_size` is replaced with `add_theme_font_size_override`; and stale bold/italic are reset on theme reload.
|
||||||
|
|
||||||
|
## Stickman rig (head mirror + recovery re-anchor)
|
||||||
|
|
||||||
|
> FIXED — 2026-09-04: two runtime bugs in `scripts/stickman_rig.gd` fixed (developer stage), covered by a new 76-assertion headless regression suite `tests/test_phase3c_walk_recovery.gd`. See per-bug notes below. No spec changes; no `.stk` / scene / `.tscn` file touched.
|
||||||
|
|
||||||
|
1. **Head does not mirror when the rig faces LEFT (implementation defect).** `_apply_head_flip()` reflected the mirror through the Head **Pivot** node's negative-determinant scale, which Godot re-decomposed into a per-frame Y-scale flip as the Head bone rotated under `LookAt` (the driver only forwards `update_rotation = true`, so the negative scale leaked into the rotation channel). Root cause: folding the reflection into a driver whose transform is applied through the rotation channel. Fix: keep the Pivot's transform identity for **all** profiles (scale `(1,1)`, rotation `0`) and mirror the mounted geometry directly on `Body/Head.scale.x` (`-1` for LEFT, `(1,1)` for RIGHT/FORWARD); the driver's `update_scale = false` never clobbers this manual scale. Note: the driver may canonicalize the mirror to `(1,-1)` — the X-mirror semantics are unchanged.
|
||||||
|
- **FIXED (2026-09-04):** `_apply_head_flip()` now resets the Pivot transform to identity for every profile and applies the LEFT mirror as `Body/Head.scale.x = -1` (RIGHT/FORWARD → `(1,1)`).
|
||||||
|
2. **Figure slides back to its pre-ragdoll position when it stands up after a fall (implementation defect).** The ragdoll bodies spawn under a world sibling (the rig's parent), so the rig root never moves while the figure falls; recovery captured only rig-local body poses and re-solved them against the root's original world position, so the stand-up tween dragged the standing figure back to where it was **before** the ragdoll instead of where it **landed**. Root cause: recovery never translated the rig root to the ragdoll's landing spot. Fix: `_capture_ragdoll_pose()` additionally records `_captured_hip_world` (the ragdoll torso's world-space hip — `torso.global_position − spine_dir·half`); `_start_recovery()` calls a new `_reanchor_root_to_landing()` which translates the rig root so `STAND_POSE`'s hip lands on that captured world hip, then re-bases the captured rig-local positions by the root shift — the figure stands up **in place** where the ragdoll landed.
|
||||||
|
- **FIXED (2026-09-04):** `_capture_ragdoll_pose()` records `_captured_hip_world`; `_start_recovery()` calls `_reanchor_root_to_landing()` before the snap/tween.
|
||||||
|
3. **Recovery buries the standing figure when the ragdoll lands lying flat (implementation defect).** The 2026-09-04 re-anchor fix anchored `STAND_POSE`'s hip onto a **spine-direction hip** (`torso.global_position − spine_dir·half`), which is only correct while the torso is upright. When the ragdoll lies flat, `spine_dir` is horizontal, so the derived hip sits at ground level (torso center.y + capsule radius ≈ ground), and `_reanchor_root_to_landing()` places the standing hip at ground level — burying the standing feet ~363 px into the ground and making the stand-up tween read as a ground-level pivot instead of a lying→standing rise. Root cause: the landing anchor assumed an upright torso. Fix: replace `_captured_hip_world` with a **landing-center + ground-contact anchor** — `_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = `torso.center.y + RAGDOLL_TORSO_RADIUS`; `_reanchor_root_to_landing()` then sets `new_root = (landing_center.x, ground_y) + FOOT_OFFSET` so the standing figure's **feet** sit on the ground at the landing X.
|
||||||
|
- Also per user request, `STAND_UP_DURATION` is bumped **0.8 s → 2.0 s** so the lying→standing stand-up tween is clearly visible before returning to `ANIMATED`. (Docs updated: README §17, AGENTS.md Phase 11.)
|
||||||
|
|
||||||
|
## Stickman rig (whole-rig Y-axis mirror — design change)
|
||||||
|
|
||||||
|
> **DESIGN CHANGE (proposed by user, 2026-09-05):** replace the per-part/head mirroring for `FacingProfile` LEFT/RIGHT with mirroring the **entire stickman** over the Y-axis (`Master.scale.x = -1` for LEFT, `(1,1)` otherwise), so the head AND body mirror together and face the correct direction. `_apply_head_flip()` and the `Body/Head.scale.x` mirror are **removed**; per-joint `flip_bend_direction` flags (`PROFILE_FLAGS`) and `Z_ORDER_BY_PROFILE` are **kept provisionally** (unchanged). **Walk-clip mapping — Option A (single canonical clip):** `walk_right` is the canonical walk; for `FacingProfile.LEFT` the rig root is X-mirrored and the **same `walk_right`** clip plays mirrored (`walk_left` becomes unused at runtime; the animation `.:facing_profile` tracks are neutralized/removed and facing is set explicitly by `set_facing_profile()`/`walk_to()`). See `docs/phase9_task4_refactor_spec.md` §9a and the `AGENTS.md` stickman_rig section.
|
||||||
|
> **Follow-up (2026-09-05):** under the LEFT root mirror the head was displaced/flipped — (a) the head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`) set `update_scale = false`, a partial-channel push that re-canonicalized `Body/Head.scale` under the mirrored root (per-frame Y-flips/wrap-jumps), and (b) the head `SkeletonModification2DLookAt` is **not mirror-invariant**, writing a bone rotation 180° off the FORWARD aim that flips the head to hang below the neck. Fixed: the head driver now pushes the **full transform** like every other `Body` driver (`update_scale` no longer `false`), and `_apply_head_lookat_mirror_mode()` **disables** the LookAt when facing LEFT, pinning the head bone to the FORWARD canonical aim (π) with `_pin_mirrored_head_rotation()` re-asserting the pin each `_physics_process` frame while ANIMATED/RECOVERING (RIGHT/FORWARD re-enable the LookAt). Interactive head-aiming is intentionally static while facing LEFT.
|
||||||
|
|||||||
@@ -260,10 +260,10 @@ The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/
|
|||||||
- **Loaded filename** — status label showing the currently loaded file.
|
- **Loaded filename** — status label showing the currently loaded file.
|
||||||
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
||||||
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The rig self-enables its modification stack in `_ready()`.
|
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The rig self-enables its modification stack in `_ready()`.
|
||||||
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's **`StickmanRig`** exported `facing_profile`, which sets the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (RightArm / LeftLeg inverted — the rig's default). The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1, Task 4).
|
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's **`StickmanRig`** exported `facing_profile`, which sets the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (RightArm / LeftLeg inverted — the rig's default). Facing **Left** now applies a **whole-rig Y-axis mirror** — `Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) — so the head **and** body mirror together and face the correct direction (this replaces the old head-only `Body/Head.scale.x = -1` mirror; `_apply_head_flip()` is removed). Two head-related fixes make the mirror stable: the head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`) pushes the **full transform** (no `update_scale = false`), so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel push re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps); and when facing LEFT `_apply_head_lookat_mirror_mode()` **disables** the head `SkeletonModification2DLookAt` and pins the head bone to the FORWARD canonical aim (π), re-asserted each physics frame by `_pin_mirrored_head_rotation()` while ANIMATED/RECOVERING (RIGHT/FORWARD re-enable the LookAt) — so interactive head-aiming is intentionally static while facing LEFT. The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1, Task 4).
|
||||||
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
|
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
|
||||||
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order), now owned by the rig's `StickmanRig._apply_body_z_order()`: **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2, Task 4).
|
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order), now owned by the rig's `StickmanRig._apply_body_z_order()`: **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2, Task 4). The per-profile z-order tables are **unchanged** and kept provisionally — an X-mirror does not affect depth (draw order).
|
||||||
- **Animation** — a dropdown (populated per spawn from the rig's `AnimationPlayer.get_animation_list()`, `walk_right` pre-selected) plus **Play/Pause/Resume** (label swaps with playback state), **Stop**, and **Loop** (default ON, persists across loads) controls. The harness drives the rig's `AnimationPlayer` directly by node path (`ANIMATION_PLAYER_PATH`); loop writes `Animation.loop_mode` before play, and playback state is tracked via the button handlers + the `animation_finished` signal (no polling). The `AnimationTree` node remains an untouched placeholder. Playing `walk_right` also flips the rig's facing profile to Right via the animation's `facing_profile` track (Phase 9 Task 5).
|
- **Animation** — a dropdown (populated per spawn from the rig's `AnimationPlayer.get_animation_list()`, `walk_right` pre-selected) plus **Play/Pause/Resume** (label swaps with playback state), **Stop**, and **Loop** (default ON, persists across loads) controls. The harness drives the rig's `AnimationPlayer` directly by node path (`ANIMATION_PLAYER_PATH`); loop writes `Animation.loop_mode` before play, and playback state is tracked via the button handlers + the `animation_finished` signal (no polling). The `AnimationTree` node remains an untouched placeholder. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now set explicitly by `walk_to()`/`set_facing_profile()` (LEFT root-mirrors the rig and plays the same canonical `walk_right` clip mirrored; `walk_left` is no longer used at runtime), so playing `walk_right` no longer flips the profile through an animation track (Phase 9 Task 5).
|
||||||
|
|
||||||
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
||||||
|
|
||||||
@@ -387,9 +387,9 @@ Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll
|
|||||||
|
|
||||||
**Rest detection (auto-recovery):** while `state == RAGDOLL`, `_update_rest_detection()` reads the **Torso** `RigidBody2D`. When it is sleeping **or** its linear velocity ≤ `REST_LINEAR_THRESHOLD` (**5.0 px/s** — tuned up from the original 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s even when settled) and angular velocity ≤ `REST_ANGULAR_THRESHOLD` (0.1 rad/s), a `_rest_timer` accumulates; after `rest_timeout` (exported, default **2.0 s**) plus a `STABILIZATION_DELAY` (0.1 s) hold, and with `auto_recover` (exported, default **true**) enabled, recovery is triggered. `rest_timeout` and `auto_recover` are runtime-adjustable exports.
|
**Rest detection (auto-recovery):** while `state == RAGDOLL`, `_update_rest_detection()` reads the **Torso** `RigidBody2D`. When it is sleeping **or** its linear velocity ≤ `REST_LINEAR_THRESHOLD` (**5.0 px/s** — tuned up from the original 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s even when settled) and angular velocity ≤ `REST_ANGULAR_THRESHOLD` (0.1 rad/s), a `_rest_timer` accumulates; after `rest_timeout` (exported, default **2.0 s**) plus a `STABILIZATION_DELAY` (0.1 s) hold, and with `auto_recover` (exported, default **true**) enabled, recovery is triggered. `rest_timeout` and `auto_recover` are runtime-adjustable exports.
|
||||||
|
|
||||||
**Recovery (`_start_recovery()`, also `request_recovery()`):** the 10 bodies' rig-local `{pos, rot, half}` are captured into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata), the ragdoll is destroyed, and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the `IK_Targets/Torso` position + rotation, `IK_Targets/Head`, and the 4 limb markers (never the slaved Torso `Bone2D`), re-shows `Body/*`, then re-enables the IK stack so TwoBoneIK solves toward the end-effectors. Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the Torso marker rotation subtracts the Torso `Bone2D`'s `bone_angle` (bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° and lay it flat). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (**0.8 s**, sine ease-in-out, `_tween_markers_to()`). The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump from the captured pose to the animation's first frame), so the tween starts from wherever the snap left the markers. On tween finish, `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, and emits.
|
**Recovery (`_start_recovery()`, also `request_recovery()`):** `_capture_ragdoll_pose()` records the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata) **plus** a landing anchor — `_captured_landing_center` (the ragdoll torso's world center) and `_captured_ground_y` (the torso's ground-contact line, `center.y + RAGDOLL_TORSO_RADIUS`). Because the ragdoll bodies spawn under a world sibling, the rig root never moves while the figure falls, so before destroying the ragdoll `_start_recovery()` calls `_reanchor_root_to_landing()`, which translates the rig root so the standing figure's **feet** sit on the ground at the landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and re-bases the captured rig-local positions by that root shift. The figure stands up **in place, on the ground**, where the ragdoll landed — not anchoring its hip to the lying hip (a spine-direction hip sits at ground level when the torso lies flat, which would bury the standing feet ~363 px into the ground), and not back at its pre-ragdoll position. The ragdoll is then destroyed and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the `IK_Targets/Torso` position + rotation, `IK_Targets/Head`, and the 4 limb markers (never the slaved Torso `Bone2D`), re-shows `Body/*`, then calls `_rearm_ik_stack()` — a defensive re-setup (re-runs `setup()` when the stack reports `!get_is_setup()`, re-asserts `enabled = true` and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker `RemoteTransform2D`'s update-position/rotation/scale flags) so TwoBoneIK reliably resumes solving toward the end-effectors after a disable→enable toggle; its runtime diagnosis is gated behind `const DEBUG_RECOVERY := false` (`_recovery_dbg()`, off by default). Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the Torso marker rotation subtracts the Torso `Bone2D`'s `bone_angle` (bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° and lay it flat). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (**2.0 s**, sine ease-in-out, `_tween_markers_to()`). The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump from the captured pose to the animation's first frame), so the tween starts from wherever the snap left the markers. On tween finish, `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, and emits.
|
||||||
|
|
||||||
**Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it).
|
**Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it). The ragdoll bodies spawn under a **world sibling** (not the rig root), so the whole-rig Y-axis mirror does not affect them; `walk_left` remains in the library but is **not used at runtime** — `walk_to()` plays the canonical `walk_right` clip for every direction (root-mirrored for LEFT).
|
||||||
|
|
||||||
### 18. Sandbox Stage Builder
|
### 18. Sandbox Stage Builder
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ The stage is intentionally **extendable**: the spawn palette is registry-driven
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. |
|
| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. |
|
||||||
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (3-mode state machine, placement + drag-painting, camera, deletion, bottom status bar, mode badge/frame/cursors, signals); **Phase 3b** instantiates the `AssetSelector` grid popup + the two thumbnail renderers, owns the selector open/close flow and the lazy per-frame thumbnail drain, and wires the Stickman/Prop palette buttons to the selector (§22). |
|
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (3-mode state machine, placement + drag-painting, camera, deletion, bottom status bar, mode badge/frame/cursors, signals); **Phase 3b** instantiates the `AssetSelector` grid popup + the two thumbnail renderers, owns the selector open/close flow and the lazy per-frame thumbnail drain, and wires the Stickman/Prop palette buttons to the selector (§22). **Phase 3c** adds the editor-tool wiring: the `QueuePanel` / `RulePanel` / `ActionEditor` / `RuleEditor` / `WaypointContext` instantiation, the unified `CaptureKind` target-capture system, "Edit Queue…"/"Edit Rules…" entry points + right-click stickman/waypoint context menus, the shared confirmation dialog, and consequence-only rule editing (§23). |
|
||||||
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`; exposes `is_terrain_id()` / `get_template_aabb()` / `spawn_id` tagging. `get_template_aabb()` returns the **sanitized** template AABB (mirrors `_spawn_terrain()`'s 16-px grid pass), so it doubles as the block-unit paint stride. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` entries removed); holds the session state `selected_stickman_path` / `selected_prop_id` and a per-path `_stickman_cache`; `prop` and `stickman` spawn the **selected** asset. |
|
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`; exposes `is_terrain_id()` / `get_template_aabb()` / `spawn_id` tagging. `get_template_aabb()` returns the **sanitized** template AABB (mirrors `_spawn_terrain()`'s 16-px grid pass), so it doubles as the block-unit paint stride. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` entries removed); holds the session state `selected_stickman_path` / `selected_prop_id` and a per-path `_stickman_cache`; `prop` and `stickman` spawn the **selected** asset. |
|
||||||
| `res://scripts/stickman_library.gd` | **Phase 3b** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entry models (corrupt/missing-`body_parts` files skipped, empty `stickman_name` → filename basename), with `make_entry(path)` for arbitrary Browse-chosen paths (§22). |
|
| `res://scripts/stickman_library.gd` | **Phase 3b** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entry models (corrupt/missing-`body_parts` files skipped, empty `stickman_name` → filename basename), with `make_entry(path)` for arbitrary Browse-chosen paths (§22). |
|
||||||
| `res://scripts/prop_library.gd` | **Phase 3b** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard) with their `PropUtils.create_*()` payloads + material presets; `get_default_id()` = `"crate"` (§22). |
|
| `res://scripts/prop_library.gd` | **Phase 3b** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard) with their `PropUtils.create_*()` payloads + material presets; `get_default_id()` = `"crate"` (§22). |
|
||||||
@@ -413,7 +413,7 @@ The stage is intentionally **extendable**: the spawn palette is registry-driven
|
|||||||
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Phase 3b** `class_name PropThumbnail`, `extends Node` — renders a prop template into a 200×200 `Texture2D` via a lightweight `Polygon2D` + `Line2D` visual (no `RigidBody2D`, so no gravity), tinted by the material preset (§22). |
|
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Phase 3b** `class_name PropThumbnail`, `extends Node` — renders a prop template into a 200×200 `Texture2D` via a lightweight `Polygon2D` + `Line2D` visual (no `RigidBody2D`, so no gravity), tinted by the material preset (§22). |
|
||||||
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Phase 3b** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache under `user://thumbnails/`: stickman key = `basename_mtime`, prop key = `id_v<PROP_VERSION>`; `load_png`/`save_png`/`clean_stale_stickmen` (§22). |
|
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Phase 3b** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache under `user://thumbnails/`: stickman key = `basename_mtime`, prop key = `id_v<PROP_VERSION>`; `load_png`/`save_png`/`clean_stale_stickmen` (§22). |
|
||||||
| `res://scenes/asset_selector.tscn` | **Phase 3b** `PopupPanel` root + `asset_selector.gd` — minimal shell/layout skeleton (title bar, empty `GridContainer`, footer Prev/Next/Browse/Refresh/Close); all dynamic per-cell content is built in code at runtime (§22). |
|
| `res://scenes/asset_selector.tscn` | **Phase 3b** `PopupPanel` root + `asset_selector.gd` — minimal shell/layout skeleton (title bar, empty `GridContainer`, footer Prev/Next/Browse/Refresh/Close); all dynamic per-cell content is built in code at runtime (§22). |
|
||||||
| `res://sandbox_theme.json` | **Phase 4b** hand-editable styling defaults (font paths/sizes, grid snap default, mode accent colors). Loaded at `_ready()`; missing/malformed falls back to built-in constants. |
|
| `res://sandbox_theme.json` | **Phase 4b** hand-editable styling defaults (font paths/sizes, grid snap default, mode accent colors). **Phase 3c** extends the `fonts` block with style variants (`ui_font_bold`/`ui_font_italic`), per-widget size/style flags, and an optional per-widget `{size, bold, italic}` object form (§21.3 / spec §14). Loaded at `_ready()`; missing/malformed falls back to built-in constants. |
|
||||||
|
|
||||||
**Mode management** — a single **3-segment switcher** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` sits at the far left of the top bar (`enum StageMode { EDIT, DIRECT, PLAY }`). Each mode shows a **contextual toolbar** and a **mode badge pill** in the viewport's top-left corner (`✏️ EDIT` cyan, `🎬 DIRECTING` amber, `▶️ SIMULATING` green), sourced from `sandbox_theme.json` `mode_colors`:
|
**Mode management** — a single **3-segment switcher** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` sits at the far left of the top bar (`enum StageMode { EDIT, DIRECT, PLAY }`). Each mode shows a **contextual toolbar** and a **mode badge pill** in the viewport's top-left corner (`✏️ EDIT` cyan, `🎬 DIRECTING` amber, `▶️ SIMULATING` green), sourced from `sandbox_theme.json` `mode_colors`:
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `res://scripts/stickman_rig.gd` | Extended with navigation/walking, speech, an action queue, and the queue runner state machine (see API below). |
|
| `res://scripts/stickman_rig.gd` | Extended with navigation/walking, speech, an action queue, and the queue runner state machine (see API below). |
|
||||||
| `res://scripts/stickman_speech_bubble.gd` | `class_name SpeechBubble`, `extends Node2D` — a world-space speech bubble drawn via `_draw()` (`ThemeDB.fallback_font`), a child of the rig above the head. |
|
| `res://scripts/stickman_speech_bubble.gd` | `class_name SpeechBubble`, `extends Node2D` — a world-space speech bubble drawn via `_draw()` (`ThemeDB.fallback_font`), a child of the rig above the head. |
|
||||||
| `res://scripts/stage_director_visuals.gd` | `class_name StageDirectorVisuals`, `extends Node2D` — Edit-mode director overlay (waypoint dots, dashed connectors, action badges, order numbers); hidden in Play. |
|
| `res://scripts/stage_director_visuals.gd` | `class_name StageDirectorVisuals`, `extends Node2D` — Edit-mode director overlay (waypoint dots, dashed connectors, action badges, order numbers); hidden in Play. **Phase 3c** adds `hit_test_waypoint_action()` (rig/index/pos), `set_edit_waypoint()`/`clear_edit_waypoint()` with the pulsing edit highlight for visual walk re-placement, and rule-label → editor routing (§23). |
|
||||||
| `res://scripts/sandbox_stage.gd` | Extended with the **Direct** palette button, the action popup + speak/wait dialogs, a code-built `NavigationRegion2D` re-baked on terrain edits, and Play mode now starting each stickman's queue. |
|
| `res://scripts/sandbox_stage.gd` | Extended with the **Direct** palette button, the action popup + speak/wait dialogs, a code-built `NavigationRegion2D` re-baked on terrain edits, and Play mode now starting each stickman's queue. |
|
||||||
|
|
||||||
**Direct tool workflow (Edit):**
|
**Direct tool workflow (Edit):**
|
||||||
@@ -638,13 +638,25 @@ A single committed, hand-editable JSON config drives sandbox font/size/color/gri
|
|||||||
"fonts": {
|
"fonts": {
|
||||||
"ui_font": "",
|
"ui_font": "",
|
||||||
"emoji_font": "",
|
"emoji_font": "",
|
||||||
|
"ui_font_bold": "",
|
||||||
|
"ui_font_italic": "",
|
||||||
"action_popup_font_size": 24,
|
"action_popup_font_size": 24,
|
||||||
"action_popup_emoji_size": 22,
|
"action_popup_emoji_size": 22,
|
||||||
"assignment_badge_font_size": 20,
|
"assignment_badge_font_size": 20,
|
||||||
"assignment_badge_radius": 9,
|
"assignment_badge_radius": 9,
|
||||||
"rule_label_font_size": 16,
|
"rule_label_font_size": 16,
|
||||||
"status_pill_font_size": 16,
|
"status_pill_font_size": 16,
|
||||||
"tooltip_font_size": 18
|
"tooltip_font_size": 18,
|
||||||
|
"queue_panel_font_size": 18,
|
||||||
|
"rule_panel_font_size": 18,
|
||||||
|
"action_editor_font_size": 18,
|
||||||
|
"rule_editor_font_size": 18,
|
||||||
|
"panel_row_font_size": 16,
|
||||||
|
"panel_title_font_size": 18,
|
||||||
|
"panel_title_bold": true,
|
||||||
|
"rule_label_bold": false,
|
||||||
|
"badge_bold": true,
|
||||||
|
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||||||
},
|
},
|
||||||
"grid": {
|
"grid": {
|
||||||
"snap_size": 15.0
|
"snap_size": 15.0
|
||||||
@@ -661,16 +673,23 @@ A single committed, hand-editable JSON config drives sandbox font/size/color/gri
|
|||||||
| Key | Default | Consumed by |
|
| Key | Default | Consumed by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
|
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
|
||||||
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. |
|
| `fonts.ui_font_bold` / `fonts.ui_font_italic` | `""` (fallback to `ui_font`) | **Phase 3c** style-variant font paths for bold/italic; empty → `ui_font`. Bold/italic are realised via a dedicated `FontVariation` (e.g. `variation_embolden`, OpenType slant) when no separate file is configured. |
|
||||||
|
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. **Phase 3c:** `action_popup_emoji_size` is now **consumed** — applied as the popup menu's emoji-glyph font size when a popup font is configured (`_apply_popup_theme()`). |
|
||||||
|
| `fonts.action_popup` (optional object) | `{ "size": 24, "bold": false, "italic": false }` | **Phase 3c** per-widget object form for the popups; when present, overrides the flat `action_popup_font_size` / bold keys for that widget. |
|
||||||
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
|
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
|
||||||
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. |
|
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. **Phase 3c** `fonts.rule_label_bold` (default `false`) adds the bold flag; `fonts.badge_bold` (default `true`) bolds the action/trigger badges. |
|
||||||
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
|
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
|
||||||
|
| `fonts.queue_panel_font_size` / `rule_panel_font_size` | 18 / 18 | **Phase 3c** font sizes for the Queue / Rule panel bodies; fall back to `action_popup_font_size`. |
|
||||||
|
| `fonts.action_editor_font_size` / `rule_editor_font_size` | 18 / 18 | **Phase 3c** font sizes for the Action / Rule editors; fall back to `action_popup_font_size`. |
|
||||||
|
| `fonts.panel_row_font_size` / `panel_title_font_size` | 16 / 18 | **Phase 3c** per-row summary/number label and panel title label sizes; `fonts.panel_title_bold` (default `true`) bolds panel titles. |
|
||||||
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
|
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
|
||||||
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
|
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
|
||||||
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
|
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
|
||||||
|
|
||||||
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
|
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
|
||||||
|
|
||||||
|
**Phase 3c theming (font styles/sizes):** the Phase 3c widgets now accept an `apply_font(ui_font, emoji_font, sizes)` call — `QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` (see §23) each expose it (mirroring `AssetSelector.apply_font`), and `SandboxStage._build_ui()` invokes it **after** `add_child(...)`. Per-widget sizes come from the `*_panel_font_size` / `*_editor_font_size` / `panel_row_font_size` / `panel_title_font_size` keys; bold/italic style flags (`panel_title_bold`, `rule_label_bold`, `badge_bold`) and a widget's optional `{size, bold, italic}` object form are applied via the `ui_font_bold` / `ui_font_italic` `FontVariation`-derived font. The `PopupMenu`s (rig/waypoint context) keep `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the `action_popup` object form. Authoritative schema: `docs/phase_3c_editor_spec.md` §14.
|
||||||
|
|
||||||
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
||||||
|
|
||||||
#### 21.4 Walk-waypoint arrival jitter fix (`StickmanRig`)
|
#### 21.4 Walk-waypoint arrival jitter fix (`StickmanRig`)
|
||||||
@@ -726,6 +745,54 @@ The result: `mode` stays constant for the whole walk, exactly one `arrived` fire
|
|||||||
|
|
||||||
**Verification:** new headless suite `tests/test_phase3b_library.gd` (`extends SceneTree`, no pixel assertions) covering `StickmanLibrary.scan()`/corrupt-skip/`make_entry`, `PropLibrary.get_entries()`/`get_default_id()`, the `StageSpawner` registry ids (`ground/ramp/step/prop/stickman/area`), `_spawn_prop`/`_spawn_stickman` honoring `selected_prop_id`/`selected_stickman_path`, `ThumbnailCache` key/path formatting, `AssetSelector` pagination math (`PAGE_SIZE == 12`), and scene-load checks. Spec: `docs/phase_3b_asset_grid_spec.md`.
|
**Verification:** new headless suite `tests/test_phase3b_library.gd` (`extends SceneTree`, no pixel assertions) covering `StickmanLibrary.scan()`/corrupt-skip/`make_entry`, `PropLibrary.get_entries()`/`get_default_id()`, the `StageSpawner` registry ids (`ground/ramp/step/prop/stickman/area`), `_spawn_prop`/`_spawn_stickman` honoring `selected_prop_id`/`selected_stickman_path`, `ThumbnailCache` key/path formatting, `AssetSelector` pagination math (`PAGE_SIZE == 12`), and scene-load checks. Spec: `docs/phase_3b_asset_grid_spec.md`.
|
||||||
|
|
||||||
|
### 23. Editor Tools — Action & Rule Editing (Phase 3c)
|
||||||
|
|
||||||
|
**Phase 3c** makes the Sandbox Stage's **action queues and event rules fully editable**. Where Phase 3a let directors only *append* actions and Phase 4 only *create/delete* rules, Phase 3c adds edit / delete / drag-reorder for both, a **waypoint right-click context menu** with visual walk re-placement and insert-before/after, and both a **full** and a **consequence-only** rule editor. The entire system is **registry-driven** — the action and trigger templates live in two const registries, and the panels/editors generate their UI from them, so a future action/trigger type is a one-entry append. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. **No `.stk` format change.**
|
||||||
|
|
||||||
|
**New scripts:**
|
||||||
|
|
||||||
|
| File | `class_name` / extends | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Registry of the 5 action templates (`walk_to`/`speak`/`wait`/`ragdoll`/`recover`: label, icon, param spec). Static accessors `types()` / `has_type` / `label` / `icon`, plus `to_rule_action()` / `from_rule_action()` (flat queue-action ⇄ rule-action shape conversions) and `summarize()`. |
|
||||||
|
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Registry of the 5 trigger templates (label, icon, `target_type`: `waypoint`/`action_type`/`none`/`area`/`prop`). Static `types()` / `has_type` / `label` / `icon` / `target_type` / `summarize()`. |
|
||||||
|
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Action Queue panel (scrollable list of one stickman's actions, each with ✎ / ✕ / drag-to-reorder ≡). Mutations delegate to the stage via signals; reorders call the rig's `remove_action`/`insert_action`. Root of `scenes/queue_panel.tscn`. |
|
||||||
|
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Rule list panel filtered by the stage (source stickman **or** a waypoint), with ✎ / ✕ / drag-to-reorder and **Add Rule** / **Clear All**. Reordering emits the new order of the *displayed* rule ids; the stage maps them back onto its full `_event_rules`, preserving un-filtered rules' positions. Root of `scenes/rule_panel.tscn`. |
|
||||||
|
| `res://scripts/action_editor.gd` | `ActionEditor` / `PopupPanel` | Single-action property editor (add & edit). Type dropdown + param fields are generated from `ActionRegistry`. A `walk_to` target is captured on the stage (`target_requested()` → the stage hides the editor, captures a click, calls `set_walk_target()`). |
|
||||||
|
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Rule editor in two modes — **`full`** (trigger type + target + actions all editable) and **`consequence`** (trigger read-only; only the actions editable). Trigger targets and action actors are captured on the stage via signals. |
|
||||||
|
| `res://scripts/waypoint_context.gd` | `WaypointContext` / `PopupMenu` | Right-click menu for a `walk_to` waypoint: **✎ Edit this Walk**, **✕ Delete this Walk**, **⬆ Insert action before**, **⬇ Insert action after**, and **⚡ Edit Trigger Rules** (enabled + shows a count when rules target this waypoint). Item ids: `EDIT_WALK`/`DELETE_WALK`/`INSERT_BEFORE`/`INSERT_AFTER`/`EDIT_TRIGGER_RULES`. |
|
||||||
|
| `res://scenes/queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn` | minimal shells | Each `.tscn` is a bare `PopupPanel` + root script; **all UI is built in code** at `_ready()` (consistent with the `asset_selector.tscn` pattern). |
|
||||||
|
|
||||||
|
**Modified scripts:** `sandbox_stage.gd` (Phase 3c wiring) and `stage_director_visuals.gd` (waypoint action hit-testing + pulsing edit highlight + rule-label click → editor).
|
||||||
|
|
||||||
|
**Theming:** the four Phase 3c widgets above are themeable — each accepts an `apply_font(ui_font, emoji_font, sizes)` call driven by the extended `sandbox_theme.json` `fonts` block (style variants `ui_font_bold`/`ui_font_italic`, per-widget `queue/rule_panel_font_size`, `action/rule_editor_font_size`, `panel_row_font_size`/`panel_title_font_size`, style flags `panel_title_bold`/`rule_label_bold`/`badge_bold`, and the optional `action_popup` `{size, bold, italic}` object form; `action_popup_emoji_size` is now consumed). See §21.3 and `docs/phase_3c_editor_spec.md` §14.
|
||||||
|
|
||||||
|
**Entry points:**
|
||||||
|
|
||||||
|
| Entry point | Gesture | Opens |
|
||||||
|
|---|---|---|
|
||||||
|
| Edit Queue | **Direct** action popup → "📋 Edit Queue…" **or** right-click a stickman → "📋 Edit Queue…" | `QueuePanel` for that rig |
|
||||||
|
| Edit Rules | **Direct** action popup → "⚡ Edit Rules…" **or** right-click a stickman → "⚡ Edit Rules…" | `RulePanel` filtered to that stickman as trigger source |
|
||||||
|
| Edit Trigger Rules | Right-click a waypoint → "⚡ Edit Trigger Rules" | `RulePanel` filtered to rules whose `arrived_at_waypoint` trigger targets that waypoint |
|
||||||
|
| Edit a walk | Right-click a waypoint → "✎ Edit this Walk", or a walk action's ✎ in the Queue panel | **Visual walk edit** — enters a `POSITION` target capture with the waypoint highlighted by a pulsing amber ring; the next stage click moves the target |
|
||||||
|
| Edit a rule | Click a rule's dashed **label** on stage → consequence-only editor; or a rule's ✎ in the Rule panel → **full** editor | `RuleEditor` (consequence / full) |
|
||||||
|
| Delete / reorder / add | ✕ / drag ≡ / "➕ Add Action|Rule" in the panels; "Add Rule" only when a source stickman panel is open | Confirmation dialog (delete / clear) then mutation |
|
||||||
|
|
||||||
|
**Unified target capture (`CaptureKind`):** the ad-hoc "pending target" flows from Phase 3a/4 (walk target, rule trigger target, rule action actor) are unified into a single stage-click capture system on `SandboxStage`: `enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }`. `_begin_capture(kind, hint, on_resolve, on_cancel)` sets the current capture kind + status hint + cursor, routes the next left-click through `_resolve_capture()` (which hit-tests against the kind's expected target — a waypoint dot, `TriggerArea`, `PropBlock`, `StickmanRig`, or a snapped free position), then invokes the resolve callback; `Esc` runs the cancel callback (which re-pops the calling editor/panel). This replaces the previously scattered per-flow pending states.
|
||||||
|
|
||||||
|
**Data flow (typical edit-a-queue-action):** Queue panel row ✎ → stage `_on_queue_panel_edit_requested(index)` → for a non-walk action opens `ActionEditor.open_edit(action)` (pre-filled) with the `QueuePanel` hidden; on OK `committed(action)` → stage rewrites the rig queue via `remove_action`+`insert_action` → `queue_changed` → director visuals `mark_dirty()` → waypoints/badges redraw; `_restore_queue_panel()` re-pops the (refreshed) panel. For a `walk_to`, editing routes through the visual **walk-edit** capture instead. Rule editing follows the same hide-editor → capture (trigger target / actor) → re-pop pattern.
|
||||||
|
|
||||||
|
**Registry-driven extensibility (per plan §4/§10):** adding a new action type = appending one entry to `ActionRegistry.ACTION_TEMPLATES` (with a `params` spec); the `ActionEditor` type dropdown, param fields, and the panels' summaries all appear automatically. Adding a new trigger type = one entry to `TriggerRegistry.TRIGGER_TEMPLATES` (with a `target_type`); the `RuleEditor` trigger dropdown and the `RulePanel` trigger summaries pick it up. The action data model stays a `Dictionary`, so new keys are free (the editor shows editable fields for known `params` keys and ignores unknown ones gracefully).
|
||||||
|
|
||||||
|
**Backward compatibility:** editing preserves each action's `type`/params and each rule's `id`; pre-existing (Phase 3a/4-authored) queues and rules load and display through the new panels unchanged. No queue/rule persistence changes — both remain in-memory across `EDIT ⇄ DIRECT ⇄ PLAY` toggles and reset on scene reload.
|
||||||
|
|
||||||
|
**Verification:** new headless suite `tests/test_phase3c_editor.gd` (`extends SceneTree`, **253 assertions**) covering both registries (lookups, summaries, unknown-type handling), scene-shell instantiation, `ActionEditor` open_new/open_edit pre-fill + signal flow, `RuleEditor` full vs consequence modes + `get_action()` + id preservation, `QueuePanel`/`RulePanel` render + add/edit/delete/clear/reorder flows (mutating the rig queue via its API), `WaypointContext` item ids + trigger-rules enable/count, `StageDirectorVisuals.hit_test_waypoint_action()`, the `CaptureKind` begin/cancel/resolve + Esc priority, and backward compatibility of pre-existing queues/rules. Run:
|
||||||
|
|
||||||
|
```
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
Spec: `docs/phase_3c_editor_spec.md`.
|
||||||
|
|
||||||
## File format (`.stk`)
|
## 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.
|
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.
|
||||||
@@ -865,7 +932,7 @@ Behavior:
|
|||||||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
||||||
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||||||
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||||
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 0.8, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). |
|
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
|
||||||
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
||||||
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||||
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. Has a `spawn_id: String` property (set by `StageSpawner`) so same-template terrain overlaps are detectable during drag-painting. |
|
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. Has a `spawn_id: String` property (set by `StageSpawner`) so same-template terrain overlaps are detectable during drag-painting. |
|
||||||
@@ -874,7 +941,7 @@ Behavior:
|
|||||||
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. |
|
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. |
|
||||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
||||||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). Standalone staging scene run via **F6**; not wired into the editor. |
|
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). Standalone staging scene run via **F6**; not wired into the editor. |
|
||||||
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
||||||
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
||||||
| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. |
|
| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. |
|
||||||
@@ -883,11 +950,19 @@ Behavior:
|
|||||||
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name PropThumbnail`, `extends Node` — renders a prop template to a `Texture2D` (lightweight non-physics visual). |
|
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name PropThumbnail`, `extends Node` — renders a prop template to a `Texture2D` (lightweight non-physics visual). |
|
||||||
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Asset Library (Phase 3b).** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache (`user://thumbnails/`) keyed by basename+mtime (stickmen) / `id_v<PROP_VERSION>` (props); load/save/stale cleanup. |
|
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Asset Library (Phase 3b).** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache (`user://thumbnails/`) keyed by basename+mtime (stickmen) / `id_v<PROP_VERSION>` (props); load/save/stale cleanup. |
|
||||||
| `res://scenes/asset_selector.tscn` | **Asset Library (Phase 3b).** `PopupPanel` root + `asset_selector.gd` — minimal shell (title bar, empty grid, footer); dynamic cells built in code. |
|
| `res://scenes/asset_selector.tscn` | **Asset Library (Phase 3b).** `PopupPanel` root + `asset_selector.gd` — minimal shell (title bar, empty grid, footer); dynamic cells built in code. |
|
||||||
|
| `res://scripts/action_registry.gd` | **Editor Tools (Phase 3c).** `class_name ActionRegistry`, `extends RefCounted` — const registry of the 5 action templates (`walk_to`/`speak`/`wait`/`ragdoll`/`recover`: label/icon/params); static `types()`/`has_type`/`label`/`icon`, `to_rule_action()`/`from_rule_action()`, `summarize()`. |
|
||||||
|
| `res://scripts/trigger_registry.gd` | **Editor Tools (Phase 3c).** `class_name TriggerRegistry`, `extends RefCounted` — const registry of the 5 trigger templates (label/icon/`target_type`); static `types()`/`has_type`/`label`/`icon`/`target_type`/`summarize()`. |
|
||||||
|
| `res://scripts/queue_panel.gd` | **Editor Tools (Phase 3c).** `class_name QueuePanel`, `extends PopupPanel` — Action Queue panel (one stickman's actions with ✎/✕/drag-reorder); mutates the rig queue via its API. |
|
||||||
|
| `res://scripts/rule_panel.gd` | **Editor Tools (Phase 3c).** `class_name RulePanel`, `extends PopupPanel` — rule list panel filtered by source stickman or waypoint, with ✎/✕/drag-reorder + Add Rule/Clear All. |
|
||||||
|
| `res://scripts/action_editor.gd` | **Editor Tools (Phase 3c).** `class_name ActionEditor`, `extends PopupPanel` — add/edit single-action editor; type dropdown + params generated from `ActionRegistry`; `walk_to` target captured on stage. |
|
||||||
|
| `res://scripts/rule_editor.gd` | **Editor Tools (Phase 3c).** `class_name RuleEditor`, `extends PopupPanel` — rule editor in **full** or **consequence-only** modes (trigger read-only); actions add/edit/remove. |
|
||||||
|
| `res://scripts/waypoint_context.gd` | **Editor Tools (Phase 3c).** `class_name WaypointContext`, `extends PopupMenu` — right-click waypoint menu: Edit/Delete Walk, Insert action before/after, Edit Trigger Rules. |
|
||||||
|
| `res://scenes/queue_panel.tscn` / `rule_panel.tscn` / `action_editor.tscn` / `rule_editor.tscn` | **Editor Tools (Phase 3c).** Minimal `PopupPanel` shells (bare root + root script); all UI is built in code at `_ready()`. |
|
||||||
| `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. |
|
| `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. |
|
||||||
| `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. |
|
| `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. |
|
||||||
| `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. |
|
| `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. |
|
||||||
| `res://scripts/stage_placement_overlay.gd` | **Sandbox Stage Builder (Phase 4b).** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting dashed guide line (`set_terrain_guide` / `clear_terrain_guide`) and the director action rubber-band trajectory + ghost marker (`set_action_trajectory` / `clear_action`); pure drawing, no hit-testing. |
|
| `res://scripts/stage_placement_overlay.gd` | **Sandbox Stage Builder (Phase 4b).** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting dashed guide line (`set_terrain_guide` / `clear_terrain_guide`) and the director action rubber-band trajectory + ghost marker (`set_action_trajectory` / `clear_action`); pure drawing, no hit-testing. |
|
||||||
| `res://sandbox_theme.json` | **Sandbox Stage Builder (Phase 4b).** Hand-editable styling defaults for the sandbox (font paths/sizes, grid snap default, mode accent + guide-line colors); loaded by `SandboxStage._load_theme()` with defaults on missing/malformed file. |
|
| `res://sandbox_theme.json` | **Sandbox Stage Builder (Phase 4b).** Hand-editable styling defaults for the sandbox (font paths/sizes, grid snap default, mode accent + guide-line colors); loaded by `SandboxStage._load_theme()` with defaults on missing/malformed file. **Phase 3c** extends the `fonts` block (bold/italic variant paths, per-widget sizes/style flags, optional `action_popup` object form) and drives the panels'/editors' `apply_font(...)` (§21.3 / spec §14). |
|
||||||
| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer, placement overlay, and CanvasLayer UI (mode switcher, toolbars, bottom status bar, badge, tooltip) are built in code. |
|
| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer, placement overlay, and CanvasLayer UI (mode switcher, toolbars, bottom status bar, badge, tooltip) are built in code. |
|
||||||
| `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/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, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
|
| `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, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
|
||||||
@@ -988,4 +1063,4 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
|||||||
|
|
||||||
> **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
|
> **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
|
||||||
|
|
||||||
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery captures the 10 bodies' rig-local pose, destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves the skeleton via the **6** IK targets (`IK_Targets/Torso` pos+rot, `IK_Targets/Head`, 4 limb markers — never the slaved Torso `Bone2D`), deriving the **hip** from the torso capsule's bottom end (`pos − dir·half`) and the wrist/ankle targets from the lower-limb capsules' far ends (`pos + dir·half`), with the Torso marker rotation subtracting the Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat), then `_play_stand_up()` tweens the markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (0.8 s, sine ease-in-out) — the baked `stand_up` animation is **not** played, because a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump); `_on_stand_up_finished()` then returns the rig to `ANIMATED`. `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery captures the 10 bodies' rig-local pose **plus a landing anchor** (`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = torso `center.y + RAGDOLL_TORSO_RADIUS`), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`) so the figure stands up **in place, on the ground**, where the ragdoll landed rather than sliding back to its pre-ragdoll root position (the earlier spine-direction-hip anchor sat at ground level for a lying torso and buried the standing feet), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves the skeleton via the **6** IK targets (`IK_Targets/Torso` pos+rot, `IK_Targets/Head`, 4 limb markers — never the slaved Torso `Bone2D`), deriving the **hip** from the torso capsule's bottom end (`pos − dir·half`) and the wrist/ankle targets from the lower-limb capsules' far ends (`pos + dir·half`), with the Torso marker rotation subtracting the Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat), then `_play_stand_up()` tweens the markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out) — the baked `stand_up` animation is **not** played, because a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump); `_on_stand_up_finished()` then returns the rig to `ANIMATED`. `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|
||||||
|
|||||||
@@ -419,6 +419,37 @@ variant not referenced by the factory. The new `StickmanRig` script targets `mas
|
|||||||
| D8 | Rig enables its own mod stack in `_ready()` | Facing/bend are meaningless until the stack is live; the rig should self-enable at runtime (single consumer today always enables it). |
|
| D8 | Rig enables its own mod stack in `_ready()` | Facing/bend are meaningless until the stack is live; the rig should self-enable at runtime (single consumer today always enables it). |
|
||||||
| D9 | Rig exposes `get_bend_joint_global_position()` | Lets the harness drop `BEND_JOINT_BONE_PATHS`/`_bend_joint_bones` entirely; the rig owns the whole bend domain. |
|
| D9 | Rig exposes `get_bend_joint_global_position()` | Lets the harness drop `BEND_JOINT_BONE_PATHS`/`_bend_joint_bones` entirely; the rig owns the whole bend domain. |
|
||||||
|
|
||||||
|
## 9a. Round N — whole-rig Y-axis mirror (2026-09-05 design change)
|
||||||
|
|
||||||
|
**Decision (user-approved):** facing LEFT is now rendered as a **whole-rig Y-axis mirror** —
|
||||||
|
`Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) — replacing the per-part/head mirroring. This flips
|
||||||
|
the head **and** body together so the figure faces the correct direction.
|
||||||
|
|
||||||
|
- `_apply_head_flip()` and the `Body/Head.scale.x` mirror are **removed** (the Head Pivot node's
|
||||||
|
driver transform is untouched).
|
||||||
|
- `PROFILE_FLAGS` (per-joint `flip_bend_direction`) and `Z_ORDER_BY_PROFILE` are **kept
|
||||||
|
provisionally** (unchanged). The mirror reflects the whole skeleton + `IK_Targets` + mounted
|
||||||
|
`Body/*` geometry, but does **not** affect depth (draw order); whether the bend flags can be
|
||||||
|
collapsed to a single canonical set must still be verified empirically (a root mirror is not
|
||||||
|
provably reflection-invariant for TwoBoneIK's `flip_bend_direction` sign).
|
||||||
|
- **Walk-clip mapping — Option A (single canonical clip):** `walk_right` is the canonical walk.
|
||||||
|
For `FacingProfile.LEFT` the rig root is X-mirrored and the **same `walk_right`** clip plays
|
||||||
|
mirrored; `walk_left` is no longer used at runtime. The animation `.:facing_profile` tracks are
|
||||||
|
neutralized/removed — facing is set explicitly by `set_facing_profile()` / `walk_to()`.
|
||||||
|
|
||||||
|
**Follow-up (implemented, tested):** two head-related fixes were required to make the LEFT root
|
||||||
|
mirror render the head correctly. (1) The head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`)
|
||||||
|
no longer sets `update_scale = false` — it pushes the **full transform** like every other `Body`
|
||||||
|
driver, so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel push
|
||||||
|
re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). (2) The `SkeletonModification2DLookAt`
|
||||||
|
that aims the Head bone is **not mirror-invariant**: under the LEFT root mirror it writes a bone
|
||||||
|
rotation 180° off the FORWARD aim, flipping the head to hang below the neck. New
|
||||||
|
`_apply_head_lookat_mirror_mode()` (called from `_apply_profile()`) disables the LookAt and pins the
|
||||||
|
head bone to the FORWARD canonical aim (π) when facing LEFT; `_pin_mirrored_head_rotation()`
|
||||||
|
re-asserts the pin each `_physics_process` frame while ANIMATED/RECOVERING so recovery's stack
|
||||||
|
re-arm can't let LookAt flip the bone. RIGHT/FORWARD re-enable the LookAt. Consequence: interactive
|
||||||
|
head-aiming while facing LEFT is intentionally static.
|
||||||
|
|
||||||
## 10. Test plan
|
## 10. Test plan
|
||||||
|
|
||||||
1. **Parse check** (same as prior tasks):
|
1. **Parse check** (same as prior tasks):
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ func is_walking() -> bool
|
|||||||
1. Guard `state == RigState.ANIMATED`.
|
1. Guard `state == RigState.ANIMATED`.
|
||||||
2. `_walk_target_feet = target`.
|
2. `_walk_target_feet = target`.
|
||||||
3. `_nav_agent.target_position = target` (global ground point — see D1).
|
3. `_nav_agent.target_position = target` (global ground point — see D1).
|
||||||
4. Set facing from horizontal delta (`dx < -0.5` → `FacingProfile.LEFT` + play
|
4. Set facing from horizontal delta (`dx < -0.5` → `FacingProfile.LEFT`; `dx > 0.5` →
|
||||||
`walk_left`; `dx > 0.5` → `FacingProfile.RIGHT` + play `walk_right`; vertical-only →
|
`FacingProfile.RIGHT`; vertical-only → keep facing), then play the canonical `walk_right`
|
||||||
keep facing, play `walk_right`).
|
clip (root-mirrored via `Master.scale.x = -1` for LEFT; `walk_left` is not used at runtime).
|
||||||
5. `_anim_player.play(name)` (walk anims are authored `LOOP_LINEAR`, so they loop).
|
5. `_anim_player.play(name)` (walk anims are authored `LOOP_LINEAR`, so they loop).
|
||||||
6. `_walking = true`, `_walk_done = false`.
|
6. `_walking = true`, `_walk_done = false`.
|
||||||
|
|
||||||
@@ -301,9 +301,10 @@ ragdolled rig has no stale walk/path state. The agent is a passive helper node (
|
|||||||
body) — it does not interfere with the ragdoll network, and `_update_walking`'s
|
body) — it does not interfere with the ragdoll network, and `_update_walking`'s
|
||||||
`state != ANIMATED` guard prevents it being read while ragdolled.
|
`state != ANIMATED` guard prevents it being read while ragdolled.
|
||||||
|
|
||||||
> The `walk_left`/`walk_right` animations key the IK targets in-place and carry a discrete
|
> The canonical `walk_right` animation keys the IK targets in-place; `walk_to()` sets the facing
|
||||||
> `.:facing_profile` track, so playing the matching clip both swings limbs and (re)sets the
|
> profile explicitly (`LEFT` root-mirrors the rig via `Master.scale.x = -1` and plays the same
|
||||||
> facing profile/z-order/head-flip. Root translation composes with the in-place limb
|
> `walk_right` clip mirrored; `RIGHT`/`FORWARD` play it unmirrored — `walk_left` is no longer used
|
||||||
|
> at runtime). Root translation composes with the in-place limb
|
||||||
> animation. Movement is **kinematic** (`global_position.move_toward` in `_physics_process`;
|
> animation. Movement is **kinematic** (`global_position.move_toward` in `_physics_process`;
|
||||||
> the rig is a plain `Node2D`, no `CharacterBody2D`), so no `NavigationAgent2D.velocity` /
|
> the rig is a plain `Node2D`, no `CharacterBody2D`), so no `NavigationAgent2D.velocity` /
|
||||||
> `velocity_computed` RVO handling is used (avoidance is disabled).
|
> `velocity_computed` RVO handling is used (avoidance is disabled).
|
||||||
|
|||||||
@@ -0,0 +1,700 @@
|
|||||||
|
# Phase 3c — Editor Tools: Action & Rule Editing (Implementation Spec)
|
||||||
|
|
||||||
|
Status: IMPLEMENTED + TESTED (12 headless suites, 667 assertions; `tests/test_phase3c_editor.gd` alone: 253 assertions)
|
||||||
|
Related plan: `plans/PHASE_3c_EDITOR.md`
|
||||||
|
Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", ...)`; the Phase 3c test header names `Godot_v4.7.1-stable_win64_console.exe`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Scope
|
||||||
|
|
||||||
|
Phase 3c adds **full editing** for the Sandbox Stage's two director-facing authoring
|
||||||
|
artifacts that previously had no in-place editing:
|
||||||
|
|
||||||
|
- **Action queues** (per-`StickmanRig`, Phase 3a): actions could only be *appended*. Phase 3c
|
||||||
|
adds a **Queue Panel** (view / edit / delete / drag-reorder / add / clear-all) and a
|
||||||
|
**waypoint context menu** with visual walk re-placement and insert-before/after.
|
||||||
|
- **Event rules** (`_event_rules` on `SandboxStage`, Phase 4): rules could only be
|
||||||
|
*created or deleted*. Phase 3c adds a **Rule Panel** (view / edit / delete / drag-reorder /
|
||||||
|
add / clear-all), a **full rule editor** (trigger + target + actions) and a
|
||||||
|
**consequence-only rule editor** (trigger read-only; actions editable).
|
||||||
|
|
||||||
|
Everything is **registry-driven**: two const registries (`ActionRegistry`,
|
||||||
|
`TriggerRegistry`) are the single source of truth for the action/trigger templates, and the
|
||||||
|
new editors + panels generate their UI from them. Adding a new action or trigger type is a
|
||||||
|
one-entry registry append (plan §4/§10) — no other code changes.
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- `scripts/action_registry.gd` / `trigger_registry.gd` — extensible template registries.
|
||||||
|
- `scripts/queue_panel.gd` + `scenes/queue_panel.tscn` — Action Queue panel.
|
||||||
|
- `scripts/rule_panel.gd` + `scenes/rule_panel.tscn` — Rule list panel.
|
||||||
|
- `scripts/action_editor.gd` + `scenes/action_editor.tscn` — single-action property editor.
|
||||||
|
- `scripts/rule_editor.gd` + `scenes/rule_editor.tscn` — full / consequence-only rule editor.
|
||||||
|
- `scripts/waypoint_context.gd` — waypoint right-click menu.
|
||||||
|
- `scripts/sandbox_stage.gd` — Phase 3c wiring: "Edit Queue…"/"Edit Rules…" entry points,
|
||||||
|
right-click context menus, the **unified target-capture system** (`CaptureKind`), the shared
|
||||||
|
confirmation dialog, and consequence-only rule editing on rule-label click.
|
||||||
|
- `scripts/stage_director_visuals.gd` — `hit_test_waypoint_action()`, `set/clear_edit_waypoint`
|
||||||
|
with the pulsing edit highlight, and rule-label → editor routing.
|
||||||
|
- `tests/test_phase3c_editor.gd` — 253-assertion headless suite.
|
||||||
|
|
||||||
|
### Out of scope / untouched (must not regress)
|
||||||
|
|
||||||
|
- **`.stk` format / the editor** — no changes.
|
||||||
|
- **Queue / rule disk persistence** — still in-memory across `EDIT ⇄ DIRECT ⇄ PLAY` toggles,
|
||||||
|
reset on scene reload (matches Phase 3a/4 scope).
|
||||||
|
- **The queue runner** (`StickmanRig._process_queue`, Phase 3a) and the **event engine**
|
||||||
|
(Phase 4) — read only; they already consume the same action/rule dict shapes.
|
||||||
|
- **Rule-builder flows** (Phase 4 `RuleStep` state machine) — unchanged; Phase 3c's editors are
|
||||||
|
additive alongside them. The panel "Add Rule" reuses `_begin_rule_build()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Recorded User / Implementation Decisions
|
||||||
|
|
||||||
|
1. **Registries are const dictionaries with static accessors.** Each action/trigger template is
|
||||||
|
one const dict entry. Because `Dictionary.keys()` is untyped at runtime, the registries expose
|
||||||
|
`static func types() -> Array[String]` (a genuinely typed array) rather than exposing `.keys()`
|
||||||
|
directly, so callers can store the type list in typed locals (`ActionEditor._current_type`,
|
||||||
|
`RuleEditor._select_action_type`).
|
||||||
|
2. **Panels/editors are `PopupPanel`s with `exclusive = true` + `popup_window = true`** built in
|
||||||
|
code. The four `.tscn` files are **minimal shells** (bare `PopupPanel` + root script); all UI
|
||||||
|
is constructed in `_ready()` (consistent with the Phase 3b `asset_selector.tscn` pattern).
|
||||||
|
Because they are exclusive, the stage must hide a host popup before entering a stage-click
|
||||||
|
capture or opening a nested editor, and re-show it on resolve/cancel (see Known Limitations,
|
||||||
|
§10 #19).
|
||||||
|
3. **Rule-action shape ↔ flat queue-action conversions live on the registry.** A rule action
|
||||||
|
nests params under `params` and adds `target` (the actor's instance id); a flat queue action
|
||||||
|
(Phase 3a) inlines them. `ActionRegistry.to_rule_action()` / `from_rule_action()` convert, and
|
||||||
|
`ActionRegistry.summarize()` / `TriggerRegistry.summarize()` produce the one-line labels used
|
||||||
|
by both the panels and the editors (they `get()` with fallbacks tolerant of either key layout).
|
||||||
|
4. **Waypoint "Edit this Walk" is a visual stage edit** (a `POSITION` capture), not a numeric
|
||||||
|
dialog. The target waypoint is highlighted with a **pulsing amber ring** drawn by
|
||||||
|
`StageDirectorVisuals` while the capture is pending.
|
||||||
|
5. **`ragdoll` / `recover` "Edit" = a paramless `ActionEditor` pre-fill.** Plan §11.2 originally
|
||||||
|
specified a separate *confirmation dialog* for editing these no-parameter actions; the
|
||||||
|
implementation instead reuses the generic `ActionEditor` (open, then OK) — fewer special
|
||||||
|
cases, and the type is unchanged unless the user changes it. This is a deliberate deviation
|
||||||
|
from the plan, not a defect (recorded in the tech-debt Change Log only; no debt row).
|
||||||
|
6. **Confirmations use one shared `ConfirmationDialog`** (`_ask_confirm(title, message, cb)`).
|
||||||
|
Queue delete / clear-all and Rule delete / clear-all confirm before mutating.
|
||||||
|
7. **Reordering is nearest-row-center drop semantics** (see Known Limitations, §10 #21): a drag
|
||||||
|
targets the row whose vertical center is nearest the pointer.
|
||||||
|
8. **"Edit Queue…" / "Edit Rules…" entry points are content-gated.** In the Direct action popup
|
||||||
|
and the stickman right-click menu, "📋 Edit Queue…" is hidden (or disabled) when the stickman's
|
||||||
|
queue is empty, and "⚡ Edit Rules…" is hidden (or disabled) when the stickman is the source of
|
||||||
|
no rule in `_event_rules`. The waypoint menu's "⚡ Edit Trigger Rules" entry already follows this
|
||||||
|
pattern — `WaypointContext.popup_for(rect, count)` enables it (and shows the count) only when
|
||||||
|
`count > 0`. Rationale: a menu item that opens an empty panel is noise; gating it signals that
|
||||||
|
there is nothing to edit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. New Files
|
||||||
|
|
||||||
|
| File | `class_name` / extends | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | `ACTION_TEMPLATES` (5 actions), static accessors + queue⇄rule conversions + summaries |
|
||||||
|
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | `TRIGGER_TEMPLATES` (5 triggers), static accessors + summaries |
|
||||||
|
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Action Queue editor popup (root of `queue_panel.tscn`) |
|
||||||
|
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Rule list editor popup (root of `rule_panel.tscn`) |
|
||||||
|
| `res://scripts/action_editor.gd` | `ActionEditor` / `PopupPanel` | Single-action add/edit editor (root of `action_editor.tscn`) |
|
||||||
|
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Full + consequence-only rule editor (root of `rule_editor.tscn`) |
|
||||||
|
| `res://scripts/waypoint_context.gd` | `WaypointContext` / `PopupMenu` | Waypoint right-click menu |
|
||||||
|
| `res://scenes/queue_panel.tscn` / `rule_panel.tscn` / `action_editor.tscn` / `rule_editor.tscn` | `PopupPanel` roots | Minimal shells; UI built in code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Public API Signatures (GDScript)
|
||||||
|
|
||||||
|
### 4.1 `ActionRegistry` (`res://scripts/action_registry.gd`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ActionRegistry
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const ACTION_TEMPLATES := {
|
||||||
|
"walk_to": { "label": "Walk To", "icon": "🚶",
|
||||||
|
"params": [ { "key": "target", "type": "position", "required": true } ] },
|
||||||
|
"speak": { "label": "Speak", "icon": "💬",
|
||||||
|
"params": [ { "key": "text", "type": "text", "required": true },
|
||||||
|
{ "key": "duration", "type": "float", "default": 2.0 } ] },
|
||||||
|
"wait": { "label": "Wait", "icon": "⏳",
|
||||||
|
"params": [ { "key": "duration", "type": "float", "required": true } ] },
|
||||||
|
"ragdoll": { "label": "Ragdoll", "icon": "💥", "params": [] },
|
||||||
|
"recover": { "label": "Recover", "icon": "🔄", "params": [] },
|
||||||
|
}
|
||||||
|
|
||||||
|
static func types() -> Array[String] # genuinely typed (Dictionary.keys() is untyped)
|
||||||
|
static func has_type(type: String) -> bool
|
||||||
|
static func label(type: String) -> String # template label, else the type itself
|
||||||
|
static func icon(type: String) -> String # template icon, else ""
|
||||||
|
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary
|
||||||
|
# flat queue action -> rule-action shape: { "type", "target": target_id,
|
||||||
|
# "params": { target | text+duration | duration } } (walk_to/speak/wait only; others -> no params)
|
||||||
|
static func from_rule_action(rule_action: Dictionary) -> Dictionary
|
||||||
|
# rule-action shape -> flat queue action (drops target, inlines params)
|
||||||
|
static func summarize(action: Dictionary) -> String
|
||||||
|
# one-line label, e.g. 'Walk To (123, 456)', 'Speak "Hello" (2s)', 'Wait 1s'; tolerates
|
||||||
|
# both the flat and rule-action key layouts via get() fallbacks.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `TriggerRegistry` (`res://scripts/trigger_registry.gd`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name TriggerRegistry
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const TRIGGER_TEMPLATES := {
|
||||||
|
"arrived_at_waypoint": { "label": "Arrives at waypoint", "icon": "📍", "target_type": "waypoint" },
|
||||||
|
"action_finished": { "label": "Completes any action", "icon": "✅", "target_type": "action_type" },
|
||||||
|
"speech_finished": { "label": "Finishes speaking", "icon": "💬", "target_type": "none" },
|
||||||
|
"entered_area": { "label": "Enters trigger area", "icon": "🎯", "target_type": "area" },
|
||||||
|
"collided": { "label": "Collides with something", "icon": "💥", "target_type": "prop" },
|
||||||
|
}
|
||||||
|
|
||||||
|
static func types() -> Array[String]
|
||||||
|
static func has_type(type: String) -> bool
|
||||||
|
static func label(type: String) -> String
|
||||||
|
static func icon(type: String) -> String
|
||||||
|
static func target_type(type: String) -> String # "waypoint" | "action_type" | "none" | "area" | "prop"
|
||||||
|
static func summarize(trigger: Dictionary) -> String # "<icon> <label>", e.g. "📍 Arrives at waypoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `QueuePanel` (`res://scripts/queue_panel.gd`, root of `queue_panel.tscn`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name QueuePanel
|
||||||
|
extends PopupPanel
|
||||||
|
|
||||||
|
signal edit_requested(index: int) # ✎ on a row
|
||||||
|
signal delete_requested(index: int) # ✕ on a row
|
||||||
|
signal add_requested() # "➕ Add Action"
|
||||||
|
signal clear_requested() # "🗑 Clear All"
|
||||||
|
|
||||||
|
var rig: StickmanRig = null # attached via setup()
|
||||||
|
|
||||||
|
func setup(r: StickmanRig) -> void # attach a rig + refresh()
|
||||||
|
func refresh() -> void # rebuild rows from rig.get_queue()
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: built in `_ready()` (`exclusive = true`, `popup_window = true`, `min_size` 460×360).
|
||||||
|
Drag-reorder uses `_on_drag_handle_gui_input` (press = begin, motion = nearest-row-center
|
||||||
|
target, release = commit). On commit it moves the action via the rig's existing
|
||||||
|
`remove_action(from)` + `insert_action(to[, to>from ? to-1 : to], action)` API, so `queue_changed`
|
||||||
|
fires and the director overlay redraws. The panel stays decoupled — it never mutates directly
|
||||||
|
beyond the reorder helper, and add/edit/delete/clear are delegated to the stage via signals.
|
||||||
|
|
||||||
|
### 4.4 `RulePanel` (`res://scripts/rule_panel.gd`, root of `rule_panel.tscn`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name RulePanel
|
||||||
|
extends PopupPanel
|
||||||
|
|
||||||
|
signal edit_requested(rule_id: int) # ✎ on a row
|
||||||
|
signal delete_requested(rule_id: int) # ✕ on a row
|
||||||
|
signal add_requested() # "➕ Add Rule"
|
||||||
|
signal clear_requested() # "🗑 Clear All"
|
||||||
|
signal reorder_requested(ordered_ids: Array[int]) # new order of the DISPLAYED rules' ids
|
||||||
|
|
||||||
|
func set_rules(rules: Array[Dictionary], title_hint: String) -> void
|
||||||
|
# update the list + title WITHOUT popping up (used by restore flows that re-pop afterwards)
|
||||||
|
func show_rules(rules: Array[Dictionary], title_hint: String) -> void
|
||||||
|
# set_rules() then popup_centered()
|
||||||
|
func refresh() -> void # rebuild rows from the stored _rules
|
||||||
|
```
|
||||||
|
|
||||||
|
The stage pre-filters `rules` (by source stickman **or** by waypoint) before calling
|
||||||
|
`show_rules`/`set_rules`. Rows show trigger summary (`<icon> <actor?> <label>`) plus one
|
||||||
|
indented `→ ...` line per action (`<icon> <actor?> <summary>`), an order number, and ✎ / ✕ /
|
||||||
|
drag-reorder ≡. Reorder emits the new order of the *displayed* rule ids (see
|
||||||
|
`SandboxStage._reorder_filtered_rules`, §6) so un-filtered rules keep their slots.
|
||||||
|
|
||||||
|
### 4.5 `ActionEditor` (`res://scripts/action_editor.gd`, root of `action_editor.tscn`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ActionEditor
|
||||||
|
extends PopupPanel
|
||||||
|
|
||||||
|
signal committed(action: Dictionary) # flat queue action (type + inline params)
|
||||||
|
signal cancelled()
|
||||||
|
signal target_requested() # walk_to needs a stage target -> stage captures it
|
||||||
|
|
||||||
|
func open_new() -> void # "add" mode; empty, default type walk_to
|
||||||
|
func open_edit(action: Dictionary) -> void # "edit" mode; pre-filled from a flat queue action
|
||||||
|
func set_walk_target(pos: Vector2) -> void # stage calls after a POSITION capture; re-pops
|
||||||
|
```
|
||||||
|
|
||||||
|
Type dropdown + param fields are generated from `ActionRegistry`. Param fields: `walk_to` →
|
||||||
|
a "🎯 Click target…" button + target label; `speak` → `LineEdit` (text) + duration `SpinBox`
|
||||||
|
(0.1–3600 s, step 0.1); `wait` → a duration `SpinBox`; `ragdoll`/`recover` (and any future
|
||||||
|
no-param action) → no fields. Pressing **OK** on a `walk_to` with no target set emits
|
||||||
|
`target_requested()` instead of committing; once a target is captured (via
|
||||||
|
`set_walk_target`) OK emits `committed(action)`. Esc emits `cancelled()`.
|
||||||
|
|
||||||
|
### 4.6 `RuleEditor` (`res://scripts/rule_editor.gd`, root of `rule_editor.tscn`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name RuleEditor
|
||||||
|
extends PopupPanel
|
||||||
|
|
||||||
|
signal committed(rule: Dictionary) # { id, trigger, actions }
|
||||||
|
signal cancelled()
|
||||||
|
signal trigger_target_requested(trigger_type: String) # stage captures the target by trigger type
|
||||||
|
signal action_add_requested() # stage opens ActionEditor to collect a new action
|
||||||
|
signal action_edit_requested(index: int) # stage opens ActionEditor pre-filled for index
|
||||||
|
|
||||||
|
func open_full(rule: Dictionary) -> void # trigger type + target + actions editable
|
||||||
|
func open_consequence(rule: Dictionary) -> void # trigger READ-ONLY; actions editable
|
||||||
|
func set_trigger_target(target_id: int, params: Dictionary) -> void # after a stage capture; re-pops
|
||||||
|
func set_action(index: int, action: Dictionary) -> void # index < 0 appends; re-pops
|
||||||
|
func get_action(index: int) -> Dictionary # copy of a rule-action ({} out of range); used to
|
||||||
|
# pre-fill the ActionEditor when editing an action
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger controls are mode-sensitive: in `consequence` mode the trigger is shown via a read-only
|
||||||
|
label (`"When: <actor> <summary>"`) and the type dropdown / target row are hidden. In `full` mode
|
||||||
|
the target control shown depends on `TriggerRegistry.target_type(type)`: `waypoint` → a "🎯 Click
|
||||||
|
target…" button (target label reads `waypoint (x, y)` / `(not set)`); `action_type` → a dropdown
|
||||||
|
("Any action" or a specific action type, synced into `trigger.params.action_type` only for
|
||||||
|
`action_finished`); `area` / `prop` → a "🎯 Click target…" button + node name label; `none` /
|
||||||
|
`speech_finished` → no target control. The **Actions** section lists editable rows with ✎ / ✕ and
|
||||||
|
an "➕ Add Action" button; ✕ removes locally (`_remove_action`). **OK** emits `committed({id,
|
||||||
|
trigger, actions})` preserving `_rule_id`. Esc emits `cancelled()`.
|
||||||
|
|
||||||
|
### 4.7 `WaypointContext` (`res://scripts/waypoint_context.gd`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name WaypointContext
|
||||||
|
extends PopupMenu
|
||||||
|
|
||||||
|
const EDIT_WALK := 0
|
||||||
|
const DELETE_WALK := 1
|
||||||
|
const INSERT_BEFORE := 2
|
||||||
|
const INSERT_AFTER := 3
|
||||||
|
const EDIT_TRIGGER_RULES := 4
|
||||||
|
|
||||||
|
func popup_for(rect: Rect2i, trigger_rule_count: int) -> void
|
||||||
|
# sets the "⚡ Edit Trigger Rules" entry's text to include the count and enables it when
|
||||||
|
# trigger_rule_count > 0 (else disables it), then popup(rect).
|
||||||
|
```
|
||||||
|
|
||||||
|
Item order is fixed in `_init()`: Edit this Walk / Delete this Walk / ⬆ Insert action before /
|
||||||
|
⬇ Insert action after / separator / ⚡ Edit Trigger Rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `SandboxStage` Phase 3c State & Entry Points (`scripts/sandbox_stage.gd`)
|
||||||
|
|
||||||
|
### 5.1 New state
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }
|
||||||
|
|
||||||
|
# Editor popups/panels (instantiated from their scenes in _build_ui()).
|
||||||
|
var _queue_panel: QueuePanel
|
||||||
|
var _rule_panel: RulePanel
|
||||||
|
var _action_editor: ActionEditor
|
||||||
|
var _rule_editor: RuleEditor
|
||||||
|
var _waypoint_context: WaypointContext
|
||||||
|
var _confirm_dialog: ConfirmationDialog
|
||||||
|
var _confirm_callback: Callable = Callable()
|
||||||
|
|
||||||
|
# Context for the currently open panel.
|
||||||
|
var _panel_rig: StickmanRig = null # rig under the queue/rule panel
|
||||||
|
var _rule_panel_source_id: int = -1 # >= 0 => filtered by this source stickman's id
|
||||||
|
var _rule_panel_waypoint: Vector2 = Vector2.INF # finite => filtered by this waypoint
|
||||||
|
var _rule_panel_title: String = ""
|
||||||
|
var _rule_panel_filter_ids: Array[int] = [] # ids of the rules currently shown (for clear-all)
|
||||||
|
|
||||||
|
# Unified stage-click target capture.
|
||||||
|
var _capture_kind: CaptureKind = CaptureKind.NONE
|
||||||
|
var _capture_hint: String = ""
|
||||||
|
var _capture_callback: Callable = Callable()
|
||||||
|
var _capture_cancel: Callable = Callable()
|
||||||
|
|
||||||
|
# ActionEditor routing.
|
||||||
|
var _action_editor_kind: String = "" # "queue_add"|"queue_edit"|"queue_insert"|"rule_add"|"rule_edit"
|
||||||
|
var _action_editor_index: int = -1
|
||||||
|
var _action_editor_rig: StickmanRig = null
|
||||||
|
var _action_editor_actor_id: int = -1
|
||||||
|
var _action_editor_restore_queue: bool = false # re-pop the queue panel on resolve/cancel
|
||||||
|
|
||||||
|
# RuleEditor routing.
|
||||||
|
var _rule_editor_from_panel: bool = false # re-pop the rule panel on commit/cancel
|
||||||
|
|
||||||
|
# Visual walk edit.
|
||||||
|
var _walk_edit_rig: StickmanRig = null
|
||||||
|
var _walk_edit_index: int = -1
|
||||||
|
var _walk_edit_from_panel: bool = false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Entry-point ids
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
const ACT_EDIT_QUEUE := 7 # appended to the Direct action popup (after Phase 4's ACT_WHEN)
|
||||||
|
const ACT_EDIT_RULES := 8
|
||||||
|
const RIG_CTX_EDIT_QUEUE := 0 # stickman right-click context menu
|
||||||
|
const RIG_CTX_EDIT_RULES := 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Entry-point routing (all EDIT/DIRECT, never PLAY)
|
||||||
|
|
||||||
|
| Handler | Trigger | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `_on_action_popup_id_pressed(ACT_EDIT_QUEUE)` / `_on_rig_context_id_pressed(RIG_CTX_EDIT_QUEUE)` | "📋 Edit Queue…" (hidden/disabled when `rig.get_queue().is_empty()`) | `_open_queue_panel(rig)` |
|
||||||
|
| `... ACT_EDIT_RULES` / `RIG_CTX_EDIT_RULES` | "⚡ Edit Rules…" (hidden/disabled when no rule has `trigger.source == rig.get_instance_id()`) | `_open_rules_panel_for_rig(rig)` |
|
||||||
|
| `_open_waypoint_context()` (RMB hit via `_director_visuals.hit_test_waypoint_action`) | waypoint menu | `_waypoint_context.popup_for(rect, _count_rules_for_waypoint(pos))` |
|
||||||
|
| `_on_waypoint_context_id_pressed(EDIT_WALK)` | "✎ Edit this Walk" | `_begin_walk_edit(rig, index, false)` |
|
||||||
|
| `... DELETE_WALK` | "✕ Delete this Walk" | `rig.remove_action(index)` |
|
||||||
|
| `... INSERT_BEFORE/AFTER` | "⬆/⬇ Insert …" | `_open_action_editor("queue_insert", {}, index(+1), rig, -1, false)` |
|
||||||
|
| `... EDIT_TRIGGER_RULES` | "⚡ Edit Trigger Rules" | `_open_trigger_rules_panel(pos)` |
|
||||||
|
| `_begin_edit_rule(id)` (rule-label click → consequence) | click a rule dashed label | `_open_rule_editor_consequence(rule)` |
|
||||||
|
| `_on_rule_panel_edit_requested(rule_id)` | rule ✎ in panel | `_open_rule_editor_full(rule, true)` |
|
||||||
|
|
||||||
|
**Entry-point gating:** both menus refresh their "📋 Edit Queue…" / "⚡ Edit Rules…" item
|
||||||
|
visibility on `about_to_popup` via `_refresh_action_popup_items()` (the Direct action popup) and
|
||||||
|
`_refresh_rig_context_items()` (the stickman right-click menu). "Edit Queue…" is hidden/disabled
|
||||||
|
when the rig's queue is empty; "Edit Rules…" is hidden/disabled when the rig is the source of no
|
||||||
|
rule in `_event_rules` (the same test as `_rule_matches_panel_filter`, §5.4). The waypoint menu's
|
||||||
|
"⚡ Edit Trigger Rules" entry is already count-gated by `WaypointContext.popup_for(rect, count)`.
|
||||||
|
|
||||||
|
`_handle_right_click()` precedence (EDIT/DIRECT): an active placement/drag RMB keeps its Phase 4b
|
||||||
|
"cancel build" role; otherwise a **waypoint** hit (nearest within `WAYPOINT_HIT_RADIUS_PX` /
|
||||||
|
zoom) opens the waypoint context menu; otherwise a **stickman** hit opens the rig context menu.
|
||||||
|
|
||||||
|
### 5.4 Rule-panel filtering
|
||||||
|
|
||||||
|
`_rule_matches_panel_filter(rule)`:
|
||||||
|
- `_rule_panel_source_id >= 0` → `trigger.source == _rule_panel_source_id`.
|
||||||
|
- else if `_rule_panel_waypoint` is finite → the rule's trigger is `arrived_at_waypoint` and its
|
||||||
|
`trigger.params.waypoint_pos` is within `WAYPOINT_MATCH_EPSILON` of the panel waypoint.
|
||||||
|
- else → all rules (unused fallback).
|
||||||
|
|
||||||
|
`_show_rule_panel()` / `_refresh_rule_panel()` compute the filtered list + `_rule_panel_filter_ids`
|
||||||
|
then call `RulePanel.show_rules`/`set_rules`. The **Add Rule** button only works from a
|
||||||
|
source-stickman panel (`_rule_panel_source_id >= 0`); from a waypoint-filtered panel it toasts
|
||||||
|
"Select a stickman first" (see Known Limitations §10 #20). "Add Rule" hides the panel and reuses
|
||||||
|
the Phase 4 `_begin_rule_build(source_id, from_panel=true)`; on finish `_restore_rule_build_panel()`
|
||||||
|
re-pops it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data Flow (representative paths)
|
||||||
|
|
||||||
|
**Edit a non-walk queue action** (`queue_edit`):
|
||||||
|
1. Queue panel row ✎ → `_on_queue_panel_edit_requested(index)`.
|
||||||
|
2. `_open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, hide_queue=true)`
|
||||||
|
→ stores routing state, hides the queue panel, `ActionEditor.open_edit(action)`.
|
||||||
|
3. OK → `_on_action_editor_committed(flat)`: `remove_action(idx)` + `insert_action(idx, flat)`
|
||||||
|
(`queue_changed` → visuals redraw), then `_restore_queue_panel()` re-pops the refreshed panel.
|
||||||
|
Cancel → `_restore_queue_panel()` with no mutation.
|
||||||
|
|
||||||
|
**Edit a `walk_to` (visual)**: `_on_queue_panel_edit_requested` detects `walk_to` and routes to
|
||||||
|
`_begin_walk_edit(rig, index, from_panel=true)` instead of the generic editor.
|
||||||
|
`_begin_walk_edit` highlights the waypoint (`_director_visuals.set_edit_waypoint(pos)`) and begins
|
||||||
|
a `POSITION` capture. On capture, `_on_walk_edit_captured` rewrites the walk action's `target`
|
||||||
|
via `remove_action`+`insert_action`, clears the highlight, and (from a panel) re-pops it; cancel
|
||||||
|
(`_cb_walk_edit_cancel`) just clears the highlight + re-pops.
|
||||||
|
|
||||||
|
**Consequence-only rule edit** (rule label click): `_begin_edit_rule(id)` → `_open_rule_editor_
|
||||||
|
consequence(rule)` → `RuleEditor.open_consequence(rule)`. OK → `_on_rule_editor_committed(rule)`
|
||||||
|
writes the rule back into `_event_rules` by `id` (or, for a missing id, appends as new),
|
||||||
|
`_director_visuals.set_rules(...)`, and calls `_restore_rule_panel()` (a no-op here since
|
||||||
|
`_rule_editor_from_panel == false`).
|
||||||
|
|
||||||
|
**Reordering rules in a filtered panel**: `RulePanel.reorder_requested(ordered_ids)` →
|
||||||
|
`_on_rule_panel_reorder_requested` → `_reorder_filtered_rules(ordered_ids)`. This walks the full
|
||||||
|
`_event_rules`, rewrites only the slots whose ids are in `ordered_ids` into the new order, and
|
||||||
|
leaves every un-filtered rule's slot untouched; then `_director_visuals.set_rules(_event_rules)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Unified Target-Capture System (`CaptureKind`)
|
||||||
|
|
||||||
|
Phase 3a/4 scattered several "pending target" flows (walk target, rule trigger target, rule
|
||||||
|
action actor). Phase 3c consolidates them:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _begin_capture(kind: CaptureKind, hint: String,
|
||||||
|
on_resolve: Callable, on_cancel: Callable = Callable()) -> void:
|
||||||
|
# sets _capture_kind/_capture_hint/_capture_callback/_capture_cancel; applies the flag cursor
|
||||||
|
# and refreshes the status bar.
|
||||||
|
|
||||||
|
func _resolve_capture(world_pos: Vector2) -> void:
|
||||||
|
# match kind:
|
||||||
|
# WAYPOINT -> _director_visuals.hit_test_waypoint(world_pos) (Vector2 or INF-miss)
|
||||||
|
# AREA -> _selection.hit_test(world_pos) is TriggerArea
|
||||||
|
# PROP -> ... is PropBlock
|
||||||
|
# STICKMAN -> ... is StickmanRig
|
||||||
|
# POSITION -> _snap_to_grid(world_pos) when snap is on, else world_pos
|
||||||
|
# On a valid hit: _end_capture() then on_resolve.call(value). On a miss: keep capturing.
|
||||||
|
|
||||||
|
func _cancel_capture() -> void: # _end_capture() then on_cancel.call() (Esc path)
|
||||||
|
func _end_capture() -> void: # clears kind/hint/callbacks; restores cursor + status
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage-click and Esc handling check `_capture_kind != CaptureKind.NONE` first, giving capture
|
||||||
|
priority over placement/selection (matching the existing Esc chain). Each editor's capture
|
||||||
|
cancel callback re-pops the editor that requested the capture (`_cb_rule_trigger_cancel`,
|
||||||
|
`_cb_rule_actor_cancel`, `_cb_editor_walk_target_cancel`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `StageDirectorVisuals` Extensions (`scripts/stage_director_visuals.gd`)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# Phase 3c: the walk_to waypoint currently being visually edited (blinking highlight).
|
||||||
|
var _edit_waypoint: Vector2 = Vector2.INF
|
||||||
|
|
||||||
|
func set_edit_waypoint(pos: Vector2) -> void # highlight on + mark_dirty()
|
||||||
|
func clear_edit_waypoint() -> void # highlight off + mark_dirty()
|
||||||
|
|
||||||
|
func hit_test_waypoint(world_pos: Vector2) -> Vector2
|
||||||
|
# nearest waypoint dot within WAYPOINT_HIT_RADIUS_PX / zoom; Vector2.INF on miss
|
||||||
|
# (thin wrapper over hit_test_waypoint_action)
|
||||||
|
func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary
|
||||||
|
# {"rig": StickmanRig, "index": int, "pos": Vector2} for the nearest walk_to, or {}
|
||||||
|
# on miss. Reuses the same anchor math as _draw_rig_queue (rig feet as the current point).
|
||||||
|
```
|
||||||
|
|
||||||
|
- `_process()` redraws every frame while `_edit_waypoint.is_finite()` (the blink is
|
||||||
|
time-animated) and otherwise only on the dirty flag, so an active visual walk edit keeps
|
||||||
|
pulsing without a `mark_dirty` storm.
|
||||||
|
- `_draw_waypoint()` renders a **pulsing amber ring** (`radius + 6/zoom`,
|
||||||
|
`Color(1.0, 0.8, 0.0, 0.5 + 0.5·sin(ticks/150))`) around the waypoint when it is within 0.5 px
|
||||||
|
of `_edit_waypoint`.
|
||||||
|
- Rule-label click routing already existed via `hit_test_rule()` (Phase 4); Phase 3c connects the
|
||||||
|
label part to `SandboxStage._begin_edit_rule(id)` (consequence editor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Extensibility Guide
|
||||||
|
|
||||||
|
### 9.1 Add a new action type
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 1. action_registry.gd — append an entry (params drive the ActionEditor + summaries).
|
||||||
|
const ACTION_TEMPLATES := {
|
||||||
|
# ... existing ...
|
||||||
|
"jump": { "label": "Jump", "icon": "🦘",
|
||||||
|
"params": [ { "key": "height", "type": "float", "default": 100.0 },
|
||||||
|
{ "key": "duration", "type": "float", "default": 0.5 } ] },
|
||||||
|
}
|
||||||
|
# 2. Implement execution in StickmanRig._process_queue() (the queue runner).
|
||||||
|
# 3. (For rule actions) extend ActionRegistry.to_rule_action()/from_rule_action()/summarize()
|
||||||
|
# with the new type's params. The ActionEditor dropdown + param fields + the panels' summaries
|
||||||
|
# appear automatically from the registry + summarize().
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Add a new rule trigger type
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 1. trigger_registry.gd — append an entry with the correct target_type.
|
||||||
|
const TRIGGER_TEMPLATES := {
|
||||||
|
# ... existing ...
|
||||||
|
"variable_changed": { "label": "Variable changes", "icon": "📊", "target_type": "variable" },
|
||||||
|
}
|
||||||
|
# 2. Emit the trigger from SandboxStage's event engine when it fires.
|
||||||
|
# 3. Add the trigger to the rule builder (auto from the registry); if it needs a *new* target
|
||||||
|
# kind, add a CaptureKind + a _resolve_capture arm and a _refresh_trigger() target control.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Add a new action / rule property
|
||||||
|
|
||||||
|
Actions and rules remain `Dictionary`s. Add new keys freely; the `ActionEditor`/`RuleEditor`
|
||||||
|
display editable fields for the registry `params` and gracefully ignore unknown keys, and the
|
||||||
|
summaries use `get()` fallbacks so un-summarized keys do not crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Known Limitations
|
||||||
|
|
||||||
|
| # | Limitation | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Confirmation dialog over an exclusive panel.** Queue delete / clear-all (`_on_queue_panel_delete_requested`, `_on_queue_panel_clear_requested`) and Rule delete / clear-all (`_on_rule_panel_delete_requested`, `_on_rule_panel_clear_requested`) call `_ask_confirm(...)` **without hiding the exclusive `QueuePanel`/`RulePanel` first**. Popping the shared `ConfirmationDialog` while an `exclusive = true` panel is visible produces a **non-fatal engine warning** and the confirmation may render **non-modal** over the panel. It still works (the panel is re-shown after the callback); it is cosmetic. (Related to #19's host-visibility discipline — this is the confirmation-dialog facet of it.) |
|
||||||
|
| 2 | **`ragdoll`/`recover` "Edit" opens a paramless `ActionEditor`** instead of a dedicated confirmation dialog (plan §11.2). Deliberate design decision (§2 decision 5); the type is changeable via the editor. Not a defect — recorded here + Change Log only. |
|
||||||
|
| 3 | **Rule Panel "Add Rule" is unavailable in waypoint-filtered panels.** `_on_rule_panel_add_requested` toasts "Select a stickman first" when `_rule_panel_source_id < 0` (the waypoint-filtered "Edit Trigger Rules" state). A waypoint can be targeted by rules authored by several stickmen, so the source is ambiguous; a future pass could default the source to the waypoint's owning rig or open the builder in "any stickman" mode. (Tracked as tech-debt #20.) |
|
||||||
|
| 4 | **Drag-reorder uses nearest-row-center, not an insertion point.** Drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge). (Tracked as tech-debt #21.) |
|
||||||
|
| 5 | **Panels/editors must hide their host before a capture or nested editor.** Host-visibility discipline is spread across `_open_action_editor`, `_on_action_editor_committed`/`_cancelled`, `_begin_walk_edit`, and `_on_rule_editor_trigger_target_requested`, with one-off `_restore_queue_panel`/`_restore_rule_panel`/`_restore_rule_build_panel` helpers. Works but fragile — a popup-stack abstraction would make it impossible to forget. (Tracked as tech-debt #19.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Acceptance Criteria
|
||||||
|
|
||||||
|
### 11.1 Action Queue Panel
|
||||||
|
- [x] "Edit Queue" opens the panel from the Direct action popup and the stickman right-click menu.
|
||||||
|
- [x] Panel shows all actions in order (number + icon + summary).
|
||||||
|
- [x] ✕ deletes an action behind a confirmation dialog.
|
||||||
|
- [x] ✎ opens the edit popup pre-filled (`walk_to` → visual edit, speak text/duration, wait duration).
|
||||||
|
- [x] ≡ drag-reorders actions (nearest-row-center).
|
||||||
|
- [x] "Clear All" confirms then clears; "Add Action" appends.
|
||||||
|
- [x] All mutations go through the rig queue API → visuals update.
|
||||||
|
- [ ] "Edit Queue…" is hidden (or disabled) when the stickman's queue is empty (entry-point gating).
|
||||||
|
|
||||||
|
### 11.2 Edit Action
|
||||||
|
- [x] Walk To: edit enters visual target placement (waypoint pulsing highlight); a click moves it.
|
||||||
|
- [x] Speak: text + duration pre-filled. Wait: duration pre-filled.
|
||||||
|
- [x] Ragdoll/Recover: paramless `ActionEditor` pre-fill (deviation from plan's confirmation dialog).
|
||||||
|
|
||||||
|
### 11.3 Waypoint Context Menu
|
||||||
|
- [x] Right-click a waypoint opens the menu.
|
||||||
|
- [x] "Edit this Walk" enters visual placement; "Delete this Walk" removes the action;
|
||||||
|
"Insert action before/after" opens the ActionEditor in `queue_insert` at the right index.
|
||||||
|
- [x] "Edit Trigger Rules" (enabled + count when rules target the waypoint) opens a filtered Rule Panel.
|
||||||
|
|
||||||
|
### 11.4 Rule Panel
|
||||||
|
- [x] "Edit Rules" opens the panel from the stickman context menu; "Edit Trigger Rules" opens it
|
||||||
|
from the waypoint menu, filtered to that waypoint's `arrived_at_waypoint` rules.
|
||||||
|
- [x] Panel lists each rule's trigger + action(s); ✎ full editor; ✕ confirm-delete;
|
||||||
|
≡ drag-reorder (reorder preserved through `_reorder_filtered_rules`).
|
||||||
|
- [x] "Add Rule" (source-stickman panels only) reuses the rule builder; "Clear All" confirms.
|
||||||
|
- [ ] "Edit Rules…" is hidden (or disabled) when the stickman is the source of no rule (entry-point gating).
|
||||||
|
|
||||||
|
### 11.5 Rule Editor
|
||||||
|
- [x] Full editor: trigger type dropdown + target capture per type (`waypoint`/`action_type`/`area`/`prop`).
|
||||||
|
- [x] Consequence-only editor: trigger read-only.
|
||||||
|
- [x] Multi-action rules: add / edit / remove actions; OK preserves the rule id; editing updates
|
||||||
|
`_event_rules` and the visuals.
|
||||||
|
|
||||||
|
### 11.6 Visual Updates
|
||||||
|
- [x] Waypoint dots move when walk actions are edited; speech/action text updates; order numbers
|
||||||
|
update after insert/delete/reorder; rule labels and dashed connectors update (`mark_dirty`).
|
||||||
|
|
||||||
|
### 11.7 Backward Compatibility
|
||||||
|
- [x] Existing queues and rules load + display through the new panels; editing preserves action
|
||||||
|
types/params and rule ids; delete cleans up references. No queue/rule persistence change.
|
||||||
|
|
||||||
|
### 11.8 Extensibility
|
||||||
|
- [x] New action/trigger types = registry append; new properties = new dict keys; unknown keys ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Verification Plan
|
||||||
|
|
||||||
|
### 12.1 Runner command (from `tests/test_phase3c_editor.gd:28`)
|
||||||
|
|
||||||
|
```
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 New headless suite — `tests/test_phase3c_editor.gd` (`extends SceneTree`, 253 assertions)
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. Registries (`ActionRegistry` / `TriggerRegistry`): `types()`, `label`, `icon`, `target_type`,
|
||||||
|
`summarize()` for all 5 actions + 5 triggers, and graceful unknown-type handling.
|
||||||
|
2. Scene shells instantiate and build their UI (`queue_panel`/`rule_panel`/`action_editor`/
|
||||||
|
`rule_editor`/`waypoint_context`).
|
||||||
|
3. `ActionEditor`: `open_new`/`open_edit` pre-fill (walk target, speak text/duration, wait
|
||||||
|
duration), `committed`/`cancelled`/`target_requested`, and OK-without-target requests a stage
|
||||||
|
target capture.
|
||||||
|
4. `RuleEditor`: full vs consequence modes; trigger read-only in consequence; action
|
||||||
|
add/edit/remove; `get_action()`; rule-id preservation on commit; `action_finished` dropdown sync.
|
||||||
|
5. `QueuePanel`: setup/refresh shows the queue in order; stage add/edit/delete/clear/reorder
|
||||||
|
flows mutate the rig queue via its API.
|
||||||
|
6. `RulePanel`: filtered list by source stickman and by waypoint; edit/delete/add/clear/reorder.
|
||||||
|
7. `WaypointContext`: item ids; trigger-rules entry enabled/disabled by rule count.
|
||||||
|
8. `StageDirectorVisuals.hit_test_waypoint_action()` returns `rig`/`index`/`pos`.
|
||||||
|
9. `SandboxStage` Phase 3c capture: `CaptureKind` begin/cancel/resolve + Esc priority;
|
||||||
|
right-click waypoint/rig context entry points.
|
||||||
|
10. Backward compatibility: pre-existing queues/rules display correctly; editing preserves action
|
||||||
|
types/params and rule ids.
|
||||||
|
|
||||||
|
### 12.3 Static verification
|
||||||
|
|
||||||
|
Headless `--editor --quit` rescan (to register the new `class_name`s), then per-script
|
||||||
|
`--check-only` on every new/modified script.
|
||||||
|
|
||||||
|
### 12.4 Manual (F6)
|
||||||
|
|
||||||
|
`res://scenes/sandbox_stage.tscn`: queue/rule panel edit-delete-reorder, waypoint context menu,
|
||||||
|
visual walk edit, full vs consequence-only rule editor, rule-label click → consequence editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Summary
|
||||||
|
|
||||||
|
| Before (Phase 3a/4) | After (Phase 3c) |
|
||||||
|
|---|---|
|
||||||
|
| Actions can only be appended | Actions can be edited, deleted, reordered, inserted before/after a waypoint |
|
||||||
|
| Rules can only be created/deleted | Rules can be edited (full + consequence-only), deleted, reordered |
|
||||||
|
| No way to fix mistakes | Edit any parameter (text, duration, walk target) |
|
||||||
|
| No visual editing | Waypoint context menu + visual walk re-placement (pulsing highlight) |
|
||||||
|
| Tightly coupled per-flow pending states | Unified `CaptureKind` stage-click capture system |
|
||||||
|
| Fixed evaluation order | Drag-reorder rules (preserving un-filtered slots) |
|
||||||
|
| Hard-coded type lists | Registry-driven, extensible (`ActionRegistry` / `TriggerRegistry`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Theme & Font Configuration (`sandbox_theme.json`)
|
||||||
|
|
||||||
|
Phase 4b introduced `res://sandbox_theme.json` and its loader (`SandboxStage._load_theme()`).
|
||||||
|
Phase 3c extends the `fonts` block so **font styles (bold/italic) and per-widget sizes** are
|
||||||
|
configurable — not just the handful of sizes + font paths shipped originally.
|
||||||
|
|
||||||
|
### 14.1 Extended `fonts` schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"fonts": {
|
||||||
|
// Existing keys (unchanged, backward-compatible):
|
||||||
|
"ui_font": "", // base UI font resource path ("" = engine default)
|
||||||
|
"emoji_font": "", // emoji-capable font resource path
|
||||||
|
"action_popup_font_size": 24, // PopupMenu font size (action/trigger/rig/waypoint menus)
|
||||||
|
"tooltip_font_size": 18,
|
||||||
|
"status_pill_font_size": 16,
|
||||||
|
"assignment_badge_font_size": 20, // drawn by StageDirectorVisuals
|
||||||
|
"assignment_badge_radius": 9,
|
||||||
|
"rule_label_font_size": 16, // drawn by StageDirectorVisuals
|
||||||
|
|
||||||
|
// NEW — style variant resource paths (bold/italic realised as distinct Font
|
||||||
|
// resources, or via FontVariation when a separate file is unavailable):
|
||||||
|
"ui_font_bold": "", // fallback to ui_font when empty
|
||||||
|
"ui_font_italic": "", // fallback to ui_font when empty
|
||||||
|
|
||||||
|
// NEW — Phase 3c widget font sizes (fall back to action_popup_font_size):
|
||||||
|
"queue_panel_font_size": 18,
|
||||||
|
"rule_panel_font_size": 18,
|
||||||
|
"action_editor_font_size": 18,
|
||||||
|
"rule_editor_font_size": 18,
|
||||||
|
"panel_row_font_size": 16, // per-row summary/number labels
|
||||||
|
"panel_title_font_size": 18, // panel title labels
|
||||||
|
|
||||||
|
// NEW — style flags (bold via ui_font_bold / FontVariation.embolden):
|
||||||
|
"panel_title_bold": true,
|
||||||
|
"rule_label_bold": false,
|
||||||
|
"badge_bold": true,
|
||||||
|
|
||||||
|
// NEW — optional per-widget object form; overrides the flat size/style keys
|
||||||
|
// for that widget when present:
|
||||||
|
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A widget that has an object-form entry (e.g. `action_popup`) reads `{size, bold, italic}` from it,
|
||||||
|
falling back to the flat `*_font_size` / `*_bold` keys, then to the engine default.
|
||||||
|
- Bold/italic are realised via `FontVariation` (e.g. `variation_embolden`, or an OpenType slant)
|
||||||
|
applied to `ui_font`; a dedicated `ui_font_bold` / `ui_font_italic` path is honoured first.
|
||||||
|
- **`fonts.action_popup_emoji_size` is a currently-dead key** (shipped in `sandbox_theme.json` but
|
||||||
|
never read). **Decision: consume it** — apply it as the popup menu's emoji-glyph font size
|
||||||
|
alongside `_apply_popup_theme()` — rather than removing it: it is already shipped and removing it
|
||||||
|
would invalidate any user theme that sets it.
|
||||||
|
|
||||||
|
### 14.2 `apply_font` contract for the Phase 3c widgets
|
||||||
|
|
||||||
|
`QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` are currently added to the UI canvas
|
||||||
|
with **no** font override (`SandboxStage._build_ui()`), so they render in the engine default font
|
||||||
|
and ignore the configured `ui_font` / `emoji_font`. Each gains an
|
||||||
|
`apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary)` method mirroring
|
||||||
|
`AssetSelector.apply_font()` (`asset_selector.gd`), which:
|
||||||
|
|
||||||
|
1. walks every `Control` it built in `_ready()` and applies the `ui_font` override (and `emoji_font`
|
||||||
|
for glyph/icon labels), and
|
||||||
|
2. applies the per-widget `*_font_size` / title / row size overrides and the `*_bold` style flags
|
||||||
|
(via the `FontVariation`-derived font).
|
||||||
|
|
||||||
|
`SandboxStage._build_ui()` calls each `apply_font(...)` **after** `add_child(...)` (the same
|
||||||
|
after-add ordering used for `_selector.apply_font(...)`, `sandbox_stage.gd:1462`), passing the
|
||||||
|
parsed `sizes` from `_load_theme()`. The `PopupMenu`s (`_rig_context_popup`, `_waypoint_context`)
|
||||||
|
keep using `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the
|
||||||
|
`action_popup` object form.
|
||||||
@@ -28,6 +28,11 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) |
|
| 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) |
|
||||||
| 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v<PROP_VERSION>`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) |
|
| 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v<PROP_VERSION>`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) |
|
||||||
| 18 | **Asset selection is session-only, not saved** — `StageSpawner.selected_stickman_path` / `selected_prop_id` reset on scene reload (Phase 3b decision, per spec). | Low | Open | Deliberate for Phase 3b (spec §2 decision 1: no disk save, do not extend `_save_settings`). A future persistence phase could persist the last-chosen stickman/prop to `user://sandbox_settings.json` for convenience. (2026-09-03) |
|
| 18 | **Asset selection is session-only, not saved** — `StageSpawner.selected_stickman_path` / `selected_prop_id` reset on scene reload (Phase 3b decision, per spec). | Low | Open | Deliberate for Phase 3b (spec §2 decision 1: no disk save, do not extend `_save_settings`). A future persistence phase could persist the last-chosen stickman/prop to `user://sandbox_settings.json` for convenience. (2026-09-03) |
|
||||||
|
| 19 | **Phase 3c editor-popup host-visibility discipline is ad-hoc** — the new `QueuePanel` / `RulePanel` / `ActionEditor` / `RuleEditor` are `exclusive = true` `PopupPanel`s, so the stage must hide a host popup before entering a stage-click capture (or opening a nested editor) and re-show it on resolve/cancel. That logic is spread across `_open_action_editor`, `_on_action_editor_committed` / `_cancelled`, `_begin_walk_edit`, and `_on_rule_editor_trigger_target_requested`, with one-off `_restore_queue_panel` / `_restore_rule_panel` / `_restore_rule_build_panel` helpers. | Medium | Open | Works, but fragile: any future editor popup must remember to hide its host before every capture or the exclusive window swallows the stage click. A small popup-stack abstraction (push host → capture → pop) would make that impossible to forget. (2026-09-04) |
|
||||||
|
| 20 | **Rule Panel "Add Rule" has no source in waypoint-filtered panels** — `_on_rule_panel_add_requested` bails with a toast when `_rule_panel_source_id < 0`, which is exactly the state of the waypoint-filtered "Edit Trigger Rules" panel (opened from the waypoint context menu). | Low | Open | The trigger source is genuinely ambiguous there (a waypoint can be targeted by rules authored by several stickmen). Acceptable for now; a future pass could default the source to `_ctx_waypoint_rig` (the rig that owns the waypoint) or open the builder in "any stickman" (source −1) mode. (2026-09-04) |
|
||||||
|
| 21 | **Phase 3c drag-reorder uses nearest-row-center, not an insertion point** — `QueuePanel` / `RulePanel` reorder computes the drop target as the row whose vertical center is nearest the mouse, then moves the dragged item to that index. | Low | Open | Functional, but the drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge between rows). A true insertion-point indicator (a line drawn between rows) would be clearer; not worth the complexity until reordering long queues/rules is common. (2026-09-04) |
|
||||||
|
| 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) |
|
||||||
|
| 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 2026-09-03 | Phase 3b (Asset Library) implemented (`docs/phase_3b_asset_grid_spec.md`): `scripts/stickman_library.gd` / `prop_library.gd`, `scripts/thumbnails/thumbnail_cache.gd` / `stickman_thumbnail.gd` / `prop_thumbnail.gd`, `scripts/asset_selector.gd` + `scenes/asset_selector.tscn`. The Stickman/Prop palette buttons now open visual selector grids (pagination 12/page, session-only selection, Browse/Refresh); `StageSpawner` registry becomes `ground/ramp/step/prop/stickman/area` (`crate`/`ball` removed) with `selected_*` session state + per-path stickman cache; `SandboxStage` owns the selector open/close flow, Esc priority, and the one-per-frame lazy thumbnail drain. Logged #17 (thumbnail cache growth / headless placeholder) and #18 (session-only selection). Verified with the new headless suite `tests/test_phase3b_library.gd` plus the updated `tests/test_phase4b1_fixes.gd`. |
|
| 2026-09-03 | Phase 3b (Asset Library) implemented (`docs/phase_3b_asset_grid_spec.md`): `scripts/stickman_library.gd` / `prop_library.gd`, `scripts/thumbnails/thumbnail_cache.gd` / `stickman_thumbnail.gd` / `prop_thumbnail.gd`, `scripts/asset_selector.gd` + `scenes/asset_selector.tscn`. The Stickman/Prop palette buttons now open visual selector grids (pagination 12/page, session-only selection, Browse/Refresh); `StageSpawner` registry becomes `ground/ramp/step/prop/stickman/area` (`crate`/`ball` removed) with `selected_*` session state + per-path stickman cache; `SandboxStage` owns the selector open/close flow, Esc priority, and the one-per-frame lazy thumbnail drain. Logged #17 (thumbnail cache growth / headless placeholder) and #18 (session-only selection). Verified with the new headless suite `tests/test_phase3b_library.gd` plus the updated `tests/test_phase4b1_fixes.gd`. |
|
||||||
| 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. |
|
| 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. |
|
||||||
| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. |
|
| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. |
|
||||||
|
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+62
-86
@@ -55,12 +55,14 @@ bone_index = 1
|
|||||||
bone2d_node = NodePath("Torso/Head")
|
bone2d_node = NodePath("Torso/Head")
|
||||||
target_nodepath = NodePath("../IK_Targets/Head")
|
target_nodepath = NodePath("../IK_Targets/Head")
|
||||||
enable_constraint = true
|
enable_constraint = true
|
||||||
constraint_angle_min = -180
|
constraint_angle_min = -180.0
|
||||||
constraint_angle_max = 180
|
constraint_angle_max = 180.0
|
||||||
constraint_angle_invert = false
|
constraint_angle_invert = false
|
||||||
constraint_in_localspace = false
|
constraint_in_localspace = false
|
||||||
|
|
||||||
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
||||||
|
resource_local_to_scene = true
|
||||||
|
enabled = true
|
||||||
modification_count = 5
|
modification_count = 5
|
||||||
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
|
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
|
||||||
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
|
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
|
||||||
@@ -70,18 +72,6 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao")
|
|||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_2leu7"]
|
[sub_resource type="Animation" id="Animation_2leu7"]
|
||||||
length = 0.001
|
length = 0.001
|
||||||
tracks/0/type = "value"
|
|
||||||
tracks/0/imported = false
|
|
||||||
tracks/0/enabled = true
|
|
||||||
tracks/0/path = NodePath(".:facing_profile")
|
|
||||||
tracks/0/interp = 1
|
|
||||||
tracks/0/loop_wrap = true
|
|
||||||
tracks/0/keys = {
|
|
||||||
"times": PackedFloat32Array(0),
|
|
||||||
"transitions": PackedFloat32Array(1),
|
|
||||||
"update": 1,
|
|
||||||
"values": [2]
|
|
||||||
}
|
|
||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_ylko5"]
|
[sub_resource type="Animation" id="Animation_ylko5"]
|
||||||
length = 0.8
|
length = 0.8
|
||||||
@@ -176,85 +166,73 @@ loop_mode = 1
|
|||||||
tracks/0/type = "value"
|
tracks/0/type = "value"
|
||||||
tracks/0/imported = false
|
tracks/0/imported = false
|
||||||
tracks/0/enabled = true
|
tracks/0/enabled = true
|
||||||
tracks/0/path = NodePath(".:facing_profile")
|
tracks/0/path = NodePath("IK_Targets/Torso:position")
|
||||||
tracks/0/interp = 1
|
tracks/0/interp = 2
|
||||||
tracks/0/loop_wrap = true
|
tracks/0/loop_wrap = true
|
||||||
tracks/0/keys = {
|
tracks/0/keys = {
|
||||||
"times": PackedFloat32Array(0),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 1,
|
"update": 0,
|
||||||
"values": [0]
|
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
||||||
}
|
}
|
||||||
tracks/1/type = "value"
|
tracks/1/type = "value"
|
||||||
tracks/1/imported = false
|
tracks/1/imported = false
|
||||||
tracks/1/enabled = true
|
tracks/1/enabled = true
|
||||||
tracks/1/path = NodePath("IK_Targets/Torso:position")
|
tracks/1/path = NodePath("IK_Targets/Head:position")
|
||||||
tracks/1/interp = 2
|
tracks/1/interp = 2
|
||||||
tracks/1/loop_wrap = true
|
tracks/1/loop_wrap = true
|
||||||
tracks/1/keys = {
|
tracks/1/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
"values": [Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614)]
|
||||||
}
|
}
|
||||||
tracks/2/type = "value"
|
tracks/2/type = "value"
|
||||||
tracks/2/imported = false
|
tracks/2/imported = false
|
||||||
tracks/2/enabled = true
|
tracks/2/enabled = true
|
||||||
tracks/2/path = NodePath("IK_Targets/Head:position")
|
tracks/2/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
tracks/2/interp = 2
|
tracks/2/interp = 2
|
||||||
tracks/2/loop_wrap = true
|
tracks/2/loop_wrap = true
|
||||||
tracks/2/keys = {
|
tracks/2/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614)]
|
"values": [Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390)]
|
||||||
}
|
}
|
||||||
tracks/3/type = "value"
|
tracks/3/type = "value"
|
||||||
tracks/3/imported = false
|
tracks/3/imported = false
|
||||||
tracks/3/enabled = true
|
tracks/3/enabled = true
|
||||||
tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
|
tracks/3/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
tracks/3/interp = 2
|
tracks/3/interp = 2
|
||||||
tracks/3/loop_wrap = true
|
tracks/3/loop_wrap = true
|
||||||
tracks/3/keys = {
|
tracks/3/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390)]
|
"values": [Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380)]
|
||||||
}
|
}
|
||||||
tracks/4/type = "value"
|
tracks/4/type = "value"
|
||||||
tracks/4/imported = false
|
tracks/4/imported = false
|
||||||
tracks/4/enabled = true
|
tracks/4/enabled = true
|
||||||
tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
|
tracks/4/path = NodePath("IK_Targets/Right_Hand:position")
|
||||||
tracks/4/interp = 2
|
tracks/4/interp = 2
|
||||||
tracks/4/loop_wrap = true
|
tracks/4/loop_wrap = true
|
||||||
tracks/4/keys = {
|
tracks/4/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380)]
|
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||||
}
|
}
|
||||||
tracks/5/type = "value"
|
tracks/5/type = "value"
|
||||||
tracks/5/imported = false
|
tracks/5/imported = false
|
||||||
tracks/5/enabled = true
|
tracks/5/enabled = true
|
||||||
tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
|
tracks/5/path = NodePath("IK_Targets/Left_Hand:position")
|
||||||
tracks/5/interp = 2
|
tracks/5/interp = 2
|
||||||
tracks/5/loop_wrap = true
|
tracks/5/loop_wrap = true
|
||||||
tracks/5/keys = {
|
tracks/5/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
|
||||||
}
|
|
||||||
tracks/6/type = "value"
|
|
||||||
tracks/6/imported = false
|
|
||||||
tracks/6/enabled = true
|
|
||||||
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
|
|
||||||
tracks/6/interp = 2
|
|
||||||
tracks/6/loop_wrap = true
|
|
||||||
tracks/6/keys = {
|
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
|
||||||
"update": 0,
|
|
||||||
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,85 +242,73 @@ loop_mode = 1
|
|||||||
tracks/0/type = "value"
|
tracks/0/type = "value"
|
||||||
tracks/0/imported = false
|
tracks/0/imported = false
|
||||||
tracks/0/enabled = true
|
tracks/0/enabled = true
|
||||||
tracks/0/path = NodePath(".:facing_profile")
|
tracks/0/path = NodePath("IK_Targets/Torso:position")
|
||||||
tracks/0/interp = 1
|
tracks/0/interp = 2
|
||||||
tracks/0/loop_wrap = true
|
tracks/0/loop_wrap = true
|
||||||
tracks/0/keys = {
|
tracks/0/keys = {
|
||||||
"times": PackedFloat32Array(0),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 1,
|
"update": 0,
|
||||||
"values": [1]
|
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
||||||
}
|
}
|
||||||
tracks/1/type = "value"
|
tracks/1/type = "value"
|
||||||
tracks/1/imported = false
|
tracks/1/imported = false
|
||||||
tracks/1/enabled = true
|
tracks/1/enabled = true
|
||||||
tracks/1/path = NodePath("IK_Targets/Torso:position")
|
tracks/1/path = NodePath("IK_Targets/Head:position")
|
||||||
tracks/1/interp = 2
|
tracks/1/interp = 2
|
||||||
tracks/1/loop_wrap = true
|
tracks/1/loop_wrap = true
|
||||||
tracks/1/keys = {
|
tracks/1/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)]
|
||||||
}
|
}
|
||||||
tracks/2/type = "value"
|
tracks/2/type = "value"
|
||||||
tracks/2/imported = false
|
tracks/2/imported = false
|
||||||
tracks/2/enabled = true
|
tracks/2/enabled = true
|
||||||
tracks/2/path = NodePath("IK_Targets/Head:position")
|
tracks/2/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
tracks/2/interp = 2
|
tracks/2/interp = 2
|
||||||
tracks/2/loop_wrap = true
|
tracks/2/loop_wrap = true
|
||||||
tracks/2/keys = {
|
tracks/2/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)]
|
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)]
|
||||||
}
|
}
|
||||||
tracks/3/type = "value"
|
tracks/3/type = "value"
|
||||||
tracks/3/imported = false
|
tracks/3/imported = false
|
||||||
tracks/3/enabled = true
|
tracks/3/enabled = true
|
||||||
tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
|
tracks/3/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
tracks/3/interp = 2
|
tracks/3/interp = 2
|
||||||
tracks/3/loop_wrap = true
|
tracks/3/loop_wrap = true
|
||||||
tracks/3/keys = {
|
tracks/3/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)]
|
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)]
|
||||||
}
|
}
|
||||||
tracks/4/type = "value"
|
tracks/4/type = "value"
|
||||||
tracks/4/imported = false
|
tracks/4/imported = false
|
||||||
tracks/4/enabled = true
|
tracks/4/enabled = true
|
||||||
tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
|
tracks/4/path = NodePath("IK_Targets/Right_Hand:position")
|
||||||
tracks/4/interp = 2
|
tracks/4/interp = 2
|
||||||
tracks/4/loop_wrap = true
|
tracks/4/loop_wrap = true
|
||||||
tracks/4/keys = {
|
tracks/4/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)]
|
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
||||||
}
|
}
|
||||||
tracks/5/type = "value"
|
tracks/5/type = "value"
|
||||||
tracks/5/imported = false
|
tracks/5/imported = false
|
||||||
tracks/5/enabled = true
|
tracks/5/enabled = true
|
||||||
tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
|
tracks/5/path = NodePath("IK_Targets/Left_Hand:position")
|
||||||
tracks/5/interp = 2
|
tracks/5/interp = 2
|
||||||
tracks/5/loop_wrap = true
|
tracks/5/loop_wrap = true
|
||||||
tracks/5/keys = {
|
tracks/5/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
|
||||||
}
|
|
||||||
tracks/6/type = "value"
|
|
||||||
tracks/6/imported = false
|
|
||||||
tracks/6/enabled = true
|
|
||||||
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
|
|
||||||
tracks/6/interp = 2
|
|
||||||
tracks/6/loop_wrap = true
|
|
||||||
tracks/6/keys = {
|
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
|
||||||
"update": 0,
|
|
||||||
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,67 +335,69 @@ width = 16.0
|
|||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
|
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
|
||||||
position = Vector2(1.6098846e-05, 10.000002)
|
position = Vector2(1.6037084e-05, 10.000003)
|
||||||
rotation = 0.4947604
|
rotation = 0.55427897
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
|
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
|
||||||
position = Vector2(-2.3252098e-05, 10.000022)
|
position = Vector2(-2.4553774e-05, 10.000021)
|
||||||
rotation = -0.49392816
|
rotation = -0.43021643
|
||||||
scale = Vector2(0.99999994, 0.99999994)
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
|
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
|
||||||
position = Vector2(-94.96416, 186.0165)
|
position = Vector2(-105.26607, 180.05603)
|
||||||
rotation = 0.0051915743
|
rotation = 0.023672067
|
||||||
scale = Vector2(0.99999994, 0.99999994)
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
|
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
|
||||||
position = Vector2(94.817635, 186.09546)
|
position = Vector2(83.4135, 191.77509)
|
||||||
rotation = -0.0034907677
|
rotation = -0.13332568
|
||||||
scale = Vector2(0.9999998, 0.9999998)
|
scale = Vector2(0.9999997, 0.9999997)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
|
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
|
||||||
position = Vector2(9.313226e-10, -238)
|
position = Vector2(9.313226e-10, -238)
|
||||||
rotation = 1.6184965
|
rotation = -0.48905078
|
||||||
points = PackedVector2Array(0, 0, 0, 175)
|
points = PackedVector2Array(0, 0, 0, 175)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
|
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
|
||||||
position = Vector2(9.313226e-10, -238)
|
position = Vector2(9.313226e-10, -238)
|
||||||
rotation = -1.6184964
|
rotation = 0.48905125
|
||||||
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 175)
|
points = PackedVector2Array(0, 0, 0, 175)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
|
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
|
||||||
position = Vector2(-167.80888, -246.01059)
|
position = Vector2(78.92438, -89.6931)
|
||||||
rotation = 3.1406152
|
rotation = -0.055406693
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
|
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
|
||||||
position = Vector2(167.80891, -246.01057)
|
position = Vector2(-78.924484, -89.69313)
|
||||||
rotation = -3.1406155
|
rotation = 0.055405635
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
|
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
|
||||||
position = Vector2(-0.07846909, -453.50793)
|
position = Vector2(-0.17916618, -309.50787)
|
||||||
scale = Vector2(0.99999887, 0.99999887)
|
rotation = 3.1415925
|
||||||
|
scale = Vector2(0.9999996, 0.9999996)
|
||||||
script = SubResource("GDScript_f0s26")
|
script = SubResource("GDScript_f0s26")
|
||||||
|
|
||||||
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
|
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
|
||||||
@@ -448,6 +416,7 @@ rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.1288230
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 90.0
|
length = 90.0
|
||||||
bone_angle = -90.0
|
bone_angle = -90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
|
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
|
||||||
position = Vector2(0, -90)
|
position = Vector2(0, -90)
|
||||||
@@ -458,7 +427,6 @@ target_position = Vector2(500, 0)
|
|||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head/Pivot" unique_id=396112729]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head/Pivot" unique_id=396112729]
|
||||||
position = Vector2(0.050354004, -72.00006)
|
position = Vector2(0.050354004, -72.00006)
|
||||||
remote_path = NodePath("../../../../../Body/Head")
|
remote_path = NodePath("../../../../../Body/Head")
|
||||||
update_scale = false
|
|
||||||
|
|
||||||
[node name="LeftUpperArm" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1840957808]
|
[node name="LeftUpperArm" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1840957808]
|
||||||
position = Vector2(0, -248)
|
position = Vector2(0, -248)
|
||||||
@@ -467,6 +435,7 @@ rest = Transform2D(0.9988364, 0.04768082, -0.04768082, 0.9988364, 0, -248)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 168.0
|
length = 168.0
|
||||||
bone_angle = -180.0
|
bone_angle = -180.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
|
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
|
||||||
position = Vector2(-168, 0)
|
position = Vector2(-168, 0)
|
||||||
@@ -475,6 +444,7 @@ rest = Transform2D(0.9987903, -0.04865717, 0.04865717, 0.9987903, -168, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = -90.0
|
bone_angle = -90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
|
||||||
position = Vector2(3.0517578e-05, 1.9073486e-06)
|
position = Vector2(3.0517578e-05, 1.9073486e-06)
|
||||||
@@ -492,6 +462,7 @@ rest = Transform2D(0.9988364, -0.047680702, 0.047680702, 0.9988364, 0, -248)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 168.00006
|
length = 168.00006
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
|
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
|
||||||
position = Vector2(168, 0)
|
position = Vector2(168, 0)
|
||||||
@@ -500,6 +471,7 @@ rest = Transform2D(0.048656836, -0.9987903, 0.9987903, 0.048656836, 168, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
@@ -516,6 +488,7 @@ rest = Transform2D(0.88005984, 0.47480857, -0.47480857, 0.88005984, -9.536743e-0
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 90.0
|
bone_angle = 90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
|
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
|
||||||
position = Vector2(0, 200)
|
position = Vector2(0, 200)
|
||||||
@@ -524,6 +497,7 @@ rest = Transform2D(0.4702357, 0.8825176, -0.8825176, 0.4702357, 0, 200)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
@@ -540,6 +514,7 @@ rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 90.0
|
bone_angle = 90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
|
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
|
||||||
position = Vector2(0, 200)
|
position = Vector2(0, 200)
|
||||||
@@ -549,6 +524,7 @@ rest = Transform2D(-0.47026652, 0.8825017, -0.8825017, -0.47026652, 0, 200)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ config/name="stickman"
|
|||||||
run/main_scene="uid://c5jkoyu1ik6fo"
|
run/main_scene="uid://c5jkoyu1ik6fo"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
|
[display]
|
||||||
|
|
||||||
|
window/size/initial_position_type=0
|
||||||
|
window/size/initial_position=Vector2i(4000, 200)
|
||||||
|
|||||||
+13
-1
@@ -9,7 +9,19 @@
|
|||||||
"assignment_badge_radius": 9,
|
"assignment_badge_radius": 9,
|
||||||
"rule_label_font_size": 16,
|
"rule_label_font_size": 16,
|
||||||
"status_pill_font_size": 16,
|
"status_pill_font_size": 16,
|
||||||
"tooltip_font_size": 18
|
"tooltip_font_size": 18,
|
||||||
|
"ui_font_bold": "",
|
||||||
|
"ui_font_italic": "",
|
||||||
|
"queue_panel_font_size": 18,
|
||||||
|
"rule_panel_font_size": 18,
|
||||||
|
"action_editor_font_size": 18,
|
||||||
|
"rule_editor_font_size": 18,
|
||||||
|
"panel_row_font_size": 16,
|
||||||
|
"panel_title_font_size": 18,
|
||||||
|
"panel_title_bold": true,
|
||||||
|
"rule_label_bold": false,
|
||||||
|
"badge_bold": true,
|
||||||
|
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||||||
},
|
},
|
||||||
"grid": {
|
"grid": {
|
||||||
"snap_size": 15.0
|
"snap_size": 15.0
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/action_editor.gd" id="1_ae"]
|
||||||
|
|
||||||
|
[node name="ActionEditor" type="PopupPanel"]
|
||||||
|
script = ExtResource("1_ae")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/queue_panel.gd" id="1_qp"]
|
||||||
|
|
||||||
|
[node name="QueuePanel" type="PopupPanel"]
|
||||||
|
script = ExtResource("1_qp")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/rule_editor.gd" id="1_re"]
|
||||||
|
|
||||||
|
[node name="RuleEditor" type="PopupPanel"]
|
||||||
|
script = ExtResource("1_re")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/rule_panel.gd" id="1_rp"]
|
||||||
|
|
||||||
|
[node name="RulePanel" type="PopupPanel"]
|
||||||
|
script = ExtResource("1_rp")
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
class_name ActionEditor
|
||||||
|
extends PopupPanel
|
||||||
|
## ActionEditor - Single-action property editor popup (Phase 3c).
|
||||||
|
##
|
||||||
|
## Used for adding a new action and for editing an existing one (queue or rule).
|
||||||
|
## The type dropdown and the param fields are generated from ActionRegistry, so
|
||||||
|
## new action types appear automatically. For `walk_to`, the target position is
|
||||||
|
## captured on the stage: the editor emits `target_requested()` and the stage
|
||||||
|
## hides the editor, captures a click, then calls `set_walk_target()`.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||||
|
|
||||||
|
signal committed(action: Dictionary)
|
||||||
|
signal cancelled()
|
||||||
|
signal target_requested()
|
||||||
|
|
||||||
|
var _mode: String = "new"
|
||||||
|
var _initial: Dictionary = {}
|
||||||
|
var _type_option: OptionButton = null
|
||||||
|
var _params_box: VBoxContainer = null
|
||||||
|
var _text_edit: LineEdit = null
|
||||||
|
var _duration_spin: SpinBox = null
|
||||||
|
var _target_label: Label = null
|
||||||
|
var _set_target_btn: Button = null
|
||||||
|
var _has_target: bool = false
|
||||||
|
var _target_pos: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||||
|
## override (engine default); Font null = no override.
|
||||||
|
var _ui_font: Font = null
|
||||||
|
var _emoji_font: Font = null
|
||||||
|
var _base_font_size: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
exclusive = true
|
||||||
|
popup_window = true
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
|
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
cancelled.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||||
|
func open_new() -> void:
|
||||||
|
_mode = "new"
|
||||||
|
_initial = {}
|
||||||
|
_has_target = false
|
||||||
|
_target_pos = Vector2.ZERO
|
||||||
|
_select_type("walk_to")
|
||||||
|
_rebuild_params("walk_to")
|
||||||
|
title = "Add Action"
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Opens the editor pre-filled from a flat queue action.
|
||||||
|
func open_edit(action: Dictionary) -> void:
|
||||||
|
_mode = "edit"
|
||||||
|
_initial = action.duplicate(true)
|
||||||
|
var type := String(action.get("type", "walk_to"))
|
||||||
|
_has_target = action.has("target") and action["target"] is Vector2
|
||||||
|
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
|
||||||
|
_select_type(type)
|
||||||
|
_rebuild_params(type)
|
||||||
|
title = "Edit Action"
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Called by the stage after a walk-target capture click.
|
||||||
|
func set_walk_target(pos: Vector2) -> void:
|
||||||
|
_has_target = true
|
||||||
|
_target_pos = pos
|
||||||
|
_update_walk_target_label()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
var type_row := HBoxContainer.new()
|
||||||
|
vbox.add_child(type_row)
|
||||||
|
var type_label := Label.new()
|
||||||
|
type_label.text = "Type:"
|
||||||
|
type_row.add_child(type_label)
|
||||||
|
_type_option = OptionButton.new()
|
||||||
|
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
for type: String in ACTION_REGISTRY.types():
|
||||||
|
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||||
|
_type_option.item_selected.connect(_on_type_selected)
|
||||||
|
type_row.add_child(_type_option)
|
||||||
|
|
||||||
|
_params_box = VBoxContainer.new()
|
||||||
|
_params_box.add_theme_constant_override("separation", 6)
|
||||||
|
vbox.add_child(_params_box)
|
||||||
|
|
||||||
|
var buttons := HBoxContainer.new()
|
||||||
|
buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||||
|
buttons.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(buttons)
|
||||||
|
|
||||||
|
var cancel := Button.new()
|
||||||
|
cancel.text = "Cancel"
|
||||||
|
cancel.pressed.connect(func() -> void: cancelled.emit())
|
||||||
|
buttons.add_child(cancel)
|
||||||
|
|
||||||
|
var ok := Button.new()
|
||||||
|
ok.text = "OK"
|
||||||
|
ok.pressed.connect(_on_ok_pressed)
|
||||||
|
buttons.add_child(ok)
|
||||||
|
|
||||||
|
min_size = Vector2i(340, 200)
|
||||||
|
|
||||||
|
|
||||||
|
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||||
|
## the stage after add_child so the editor's UI is already built.
|
||||||
|
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||||
|
_ui_font = ui_font
|
||||||
|
_emoji_font = emoji_font
|
||||||
|
_base_font_size = int(sizes.get("action_editor", 18))
|
||||||
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
_apply_font_to(child as Control, size)
|
||||||
|
_apply_font_recursive(child, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_to(c: Control, size: int) -> void:
|
||||||
|
if c == null:
|
||||||
|
return
|
||||||
|
if _ui_font != null:
|
||||||
|
c.add_theme_font_override("font", _ui_font)
|
||||||
|
elif _emoji_font != null:
|
||||||
|
c.add_theme_font_override("font", _emoji_font)
|
||||||
|
if size > 0:
|
||||||
|
c.add_theme_font_size_override("font_size", size)
|
||||||
|
|
||||||
|
|
||||||
|
func _select_type(type: String) -> void:
|
||||||
|
var idx := ACTION_REGISTRY.types().find(type)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
_type_option.select(idx)
|
||||||
|
|
||||||
|
|
||||||
|
func _current_type() -> String:
|
||||||
|
var types := ACTION_REGISTRY.types()
|
||||||
|
var idx := _type_option.selected
|
||||||
|
if idx < 0 or idx >= types.size():
|
||||||
|
return "walk_to"
|
||||||
|
return types[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func _on_type_selected(_index: int) -> void:
|
||||||
|
_rebuild_params(_current_type())
|
||||||
|
|
||||||
|
|
||||||
|
## Rebuilds the param controls for the given action type.
|
||||||
|
func _rebuild_params(type: String) -> void:
|
||||||
|
for child: Node in _params_box.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
_text_edit = null
|
||||||
|
_duration_spin = null
|
||||||
|
_target_label = null
|
||||||
|
_set_target_btn = null
|
||||||
|
|
||||||
|
match type:
|
||||||
|
"walk_to":
|
||||||
|
var trow := HBoxContainer.new()
|
||||||
|
_params_box.add_child(trow)
|
||||||
|
_target_label = Label.new()
|
||||||
|
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
trow.add_child(_target_label)
|
||||||
|
_set_target_btn = Button.new()
|
||||||
|
_set_target_btn.text = "🎯 Click target…"
|
||||||
|
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||||
|
trow.add_child(_set_target_btn)
|
||||||
|
_update_walk_target_label()
|
||||||
|
"speak":
|
||||||
|
var tlabel := Label.new()
|
||||||
|
tlabel.text = "Text:"
|
||||||
|
_params_box.add_child(tlabel)
|
||||||
|
_text_edit = LineEdit.new()
|
||||||
|
_text_edit.placeholder_text = "Say something…"
|
||||||
|
_text_edit.text = String(_initial.get("text", ""))
|
||||||
|
_params_box.add_child(_text_edit)
|
||||||
|
var dlabel := Label.new()
|
||||||
|
dlabel.text = "Duration (s):"
|
||||||
|
_params_box.add_child(dlabel)
|
||||||
|
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
|
||||||
|
_params_box.add_child(_duration_spin)
|
||||||
|
"wait":
|
||||||
|
var wlabel := Label.new()
|
||||||
|
wlabel.text = "Duration (s):"
|
||||||
|
_params_box.add_child(wlabel)
|
||||||
|
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
||||||
|
_params_box.add_child(_duration_spin)
|
||||||
|
_:
|
||||||
|
# ragdoll / recover (and any future no-param action) need no fields.
|
||||||
|
pass
|
||||||
|
_apply_font_recursive(_params_box, _base_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_duration_spin(value: float) -> SpinBox:
|
||||||
|
var spin := SpinBox.new()
|
||||||
|
spin.min_value = 0.1
|
||||||
|
spin.max_value = 3600.0
|
||||||
|
spin.step = 0.1
|
||||||
|
spin.value = value
|
||||||
|
spin.custom_minimum_size = Vector2(120.0, 0.0)
|
||||||
|
return spin
|
||||||
|
|
||||||
|
|
||||||
|
func _update_walk_target_label() -> void:
|
||||||
|
if _target_label == null:
|
||||||
|
return
|
||||||
|
if _has_target:
|
||||||
|
_target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))]
|
||||||
|
else:
|
||||||
|
_target_label.text = "Target: not set"
|
||||||
|
|
||||||
|
|
||||||
|
func _on_ok_pressed() -> void:
|
||||||
|
var type := _current_type()
|
||||||
|
var action: Dictionary = { "type": type }
|
||||||
|
match type:
|
||||||
|
"walk_to":
|
||||||
|
if not _has_target:
|
||||||
|
# Ask the stage to capture the target before committing.
|
||||||
|
target_requested.emit()
|
||||||
|
return
|
||||||
|
action["target"] = _target_pos
|
||||||
|
"speak":
|
||||||
|
action["text"] = _text_edit.text if _text_edit != null else ""
|
||||||
|
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
|
||||||
|
"wait":
|
||||||
|
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||||
|
committed.emit(action)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d3nsgtnt1cvh5
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
class_name ActionRegistry
|
||||||
|
extends RefCounted
|
||||||
|
## ActionRegistry - Registry of director action templates (Phase 3c).
|
||||||
|
##
|
||||||
|
## Single source of truth for the action types the Director Tool and the event
|
||||||
|
## system understand. Adding a new action type is just appending an entry here;
|
||||||
|
## the ActionEditor and the panels generate their UI from this registry, so no
|
||||||
|
## other code changes are required.
|
||||||
|
|
||||||
|
const ACTION_TEMPLATES := {
|
||||||
|
"walk_to": {
|
||||||
|
"label": "Walk To",
|
||||||
|
"icon": "🚶",
|
||||||
|
"params": [
|
||||||
|
{ "key": "target", "type": "position", "required": true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"speak": {
|
||||||
|
"label": "Speak",
|
||||||
|
"icon": "💬",
|
||||||
|
"params": [
|
||||||
|
{ "key": "text", "type": "text", "required": true },
|
||||||
|
{ "key": "duration", "type": "float", "default": 2.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"wait": {
|
||||||
|
"label": "Wait",
|
||||||
|
"icon": "⏳",
|
||||||
|
"params": [
|
||||||
|
{ "key": "duration", "type": "float", "required": true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ragdoll": {
|
||||||
|
"label": "Ragdoll",
|
||||||
|
"icon": "💥",
|
||||||
|
"params": [],
|
||||||
|
},
|
||||||
|
"recover": {
|
||||||
|
"label": "Recover",
|
||||||
|
"icon": "🔄",
|
||||||
|
"params": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func types() -> Array[String]:
|
||||||
|
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
|
||||||
|
# typed Array[String] so callers can store it in typed locals (see
|
||||||
|
# ActionEditor._current_type / RuleEditor._select_action_type).
|
||||||
|
var out: Array[String] = []
|
||||||
|
out.assign(ACTION_TEMPLATES.keys())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func has_type(type: String) -> bool:
|
||||||
|
return ACTION_TEMPLATES.has(type)
|
||||||
|
|
||||||
|
|
||||||
|
static func label(type: String) -> String:
|
||||||
|
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||||
|
return String(tpl.get("label", type))
|
||||||
|
|
||||||
|
|
||||||
|
static func icon(type: String) -> String:
|
||||||
|
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||||
|
return String(tpl.get("icon", ""))
|
||||||
|
|
||||||
|
|
||||||
|
## Converts a flat queue action into a rule-action shape (adds `target` + nests
|
||||||
|
## params). `target_id` is the acting stickman's instance id.
|
||||||
|
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||||
|
var params: Dictionary = {}
|
||||||
|
match String(action.get("type", "")):
|
||||||
|
"walk_to":
|
||||||
|
params["target"] = action.get("target", Vector2.ZERO)
|
||||||
|
"speak":
|
||||||
|
params["text"] = String(action.get("text", ""))
|
||||||
|
params["duration"] = float(action.get("duration", 2.0))
|
||||||
|
"wait":
|
||||||
|
params["duration"] = float(action.get("duration", 0.0))
|
||||||
|
return {
|
||||||
|
"type": String(action.get("type", "")),
|
||||||
|
"target": target_id,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Converts a rule-action shape back into a flat queue action (drops `target`).
|
||||||
|
static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
||||||
|
var params: Dictionary = rule_action.get("params", {})
|
||||||
|
match String(rule_action.get("type", "")):
|
||||||
|
"walk_to":
|
||||||
|
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
|
||||||
|
"speak":
|
||||||
|
return {
|
||||||
|
"type": "speak",
|
||||||
|
"text": String(params.get("text", "")),
|
||||||
|
"duration": float(params.get("duration", 2.0)),
|
||||||
|
}
|
||||||
|
"wait":
|
||||||
|
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
||||||
|
_:
|
||||||
|
return { "type": String(rule_action.get("type", "")) }
|
||||||
|
|
||||||
|
|
||||||
|
## Human-readable one-line summary for both flat queue actions and rule-action
|
||||||
|
## shapes (the get() fallbacks tolerate either key layout).
|
||||||
|
static func summarize(action: Dictionary) -> String:
|
||||||
|
var type := String(action.get("type", ""))
|
||||||
|
var params: Dictionary = action.get("params", {})
|
||||||
|
match type:
|
||||||
|
"walk_to":
|
||||||
|
var t: Vector2 = action.get("target", params.get("target", Vector2.ZERO))
|
||||||
|
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
|
||||||
|
"speak":
|
||||||
|
var text := String(action.get("text", params.get("text", "")))
|
||||||
|
var dur := float(action.get("duration", params.get("duration", 2.0)))
|
||||||
|
return "Speak \"%s\" (%ss)" % [text, _fmt_duration(dur)]
|
||||||
|
"wait":
|
||||||
|
var wd := float(action.get("duration", params.get("duration", 0.0)))
|
||||||
|
return "Wait %ss" % _fmt_duration(wd)
|
||||||
|
"ragdoll":
|
||||||
|
return "Ragdoll"
|
||||||
|
"recover":
|
||||||
|
return "Recover"
|
||||||
|
_:
|
||||||
|
return type
|
||||||
|
|
||||||
|
|
||||||
|
static func _fmt_duration(v: float) -> String:
|
||||||
|
if v == roundf(v):
|
||||||
|
return str(int(v))
|
||||||
|
return str(v)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cncpyuy6cymwp
|
||||||
@@ -9,7 +9,7 @@ extends EditorScript
|
|||||||
## pose (a fixed first keyframe can never match an arbitrary rest pose). The
|
## pose (a fixed first keyframe can never match an arbitrary rest pose). The
|
||||||
## baked stand_up is kept as an authored reference / manual-play animation.
|
## baked stand_up is kept as an authored reference / manual-play animation.
|
||||||
|
|
||||||
const STAND_UP_DURATION := 0.8
|
const STAND_UP_DURATION := 2.0
|
||||||
|
|
||||||
## IK target marker node paths (relative to the rig root), keyed by marker name.
|
## IK target marker node paths (relative to the rig root), keyed by marker name.
|
||||||
const POSE_PATHS: Dictionary = {
|
const POSE_PATHS: Dictionary = {
|
||||||
@@ -58,24 +58,18 @@ func _run() -> void:
|
|||||||
push_error("EditorScript: AnimationPlayer node not found under root.")
|
push_error("EditorScript: AnimationPlayer node not found under root.")
|
||||||
return
|
return
|
||||||
|
|
||||||
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
|
_generate_walk_animation(anim_player, "walk_right", false)
|
||||||
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
|
_generate_walk_animation(anim_player, "walk_left", true)
|
||||||
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
|
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
|
||||||
|
|
||||||
|
|
||||||
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
|
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool) -> void:
|
||||||
var anim = Animation.new()
|
var anim = Animation.new()
|
||||||
anim.length = 0.8
|
anim.length = 0.8
|
||||||
anim.loop_mode = Animation.LOOP_LINEAR
|
anim.loop_mode = Animation.LOOP_LINEAR
|
||||||
var dir_mult: float = -1.0 if flip_x else 1.0
|
var dir_mult: float = -1.0 if flip_x else 1.0
|
||||||
|
|
||||||
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
|
# Keyframe positions
|
||||||
var profile_track = anim.add_track(Animation.TYPE_VALUE)
|
|
||||||
anim.track_set_path(profile_track, ".:facing_profile")
|
|
||||||
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
|
|
||||||
anim.track_insert_key(profile_track, 0.0, profile_enum)
|
|
||||||
|
|
||||||
# 2. Keyframe positions
|
|
||||||
var raw_tracks = {
|
var raw_tracks = {
|
||||||
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
|
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
|
||||||
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
|
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
|
||||||
|
|||||||
@@ -254,6 +254,9 @@ func _spawn_rig() -> void:
|
|||||||
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
||||||
return
|
return
|
||||||
rig.position = RIG_SPAWN_POSITION
|
rig.position = RIG_SPAWN_POSITION
|
||||||
|
# Manual recovery only ("Recover Now"): auto-recover is left off so the
|
||||||
|
# ragdoll stays down until the operator triggers it, matching sandbox PLAY.
|
||||||
|
rig.auto_recover = false
|
||||||
add_child(rig)
|
add_child(rig)
|
||||||
_rig = rig
|
_rig = rig
|
||||||
rig.state_changed.connect(_on_rig_state_changed)
|
rig.state_changed.connect(_on_rig_state_changed)
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
class_name QueuePanel
|
||||||
|
extends PopupPanel
|
||||||
|
## QueuePanel - Action Queue editor popup (Phase 3c.2).
|
||||||
|
##
|
||||||
|
## Shows all queued actions for one stickman, in order, with edit / delete /
|
||||||
|
## drag-reorder controls. Mutations go through the StickmanRig queue API
|
||||||
|
## (queue_action / remove_action / insert_action / clear_queue), which already
|
||||||
|
## emits `queue_changed` and redraws the director overlay. Editing and adding
|
||||||
|
## delegate to the stage via signals so this panel stays decoupled.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
|
||||||
|
|
||||||
|
signal edit_requested(index: int)
|
||||||
|
signal delete_requested(index: int)
|
||||||
|
signal add_requested()
|
||||||
|
signal clear_requested()
|
||||||
|
|
||||||
|
var rig: StickmanRig = null
|
||||||
|
|
||||||
|
var _title_label: Label = null
|
||||||
|
var _list: VBoxContainer = null
|
||||||
|
var _empty_label: Label = null
|
||||||
|
var _rows: Array[PanelContainer] = []
|
||||||
|
|
||||||
|
## Drag-reorder state.
|
||||||
|
var _drag_index: int = -1
|
||||||
|
var _drag_target: int = -1
|
||||||
|
|
||||||
|
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||||
|
## override (engine default); Font null = no override.
|
||||||
|
var _ui_font: Font = null
|
||||||
|
var _emoji_font: Font = null
|
||||||
|
var _base_font_size: int = 0
|
||||||
|
var _row_font_size: int = 0
|
||||||
|
var _title_font_size: int = 0
|
||||||
|
var _title_font: Font = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
exclusive = true
|
||||||
|
popup_window = true
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
|
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
hide()
|
||||||
|
|
||||||
|
|
||||||
|
## Attaches a rig and rebuilds the row list.
|
||||||
|
func setup(r: StickmanRig) -> void:
|
||||||
|
rig = r
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
if _list == null:
|
||||||
|
return
|
||||||
|
for child: Node in _list.get_children():
|
||||||
|
if child != _empty_label:
|
||||||
|
child.queue_free()
|
||||||
|
_rows.clear()
|
||||||
|
_drag_index = -1
|
||||||
|
_drag_target = -1
|
||||||
|
|
||||||
|
var queue: Array[Dictionary] = []
|
||||||
|
if rig != null and is_instance_valid(rig):
|
||||||
|
queue = rig.get_queue()
|
||||||
|
_title_label.text = "Stickman: %s" % (String(rig.name) if rig != null and is_instance_valid(rig) else "?")
|
||||||
|
_empty_label.visible = queue.is_empty()
|
||||||
|
|
||||||
|
for i: int in queue.size():
|
||||||
|
_rows.append(_make_row(i, queue[i]))
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
title = "Action Queue"
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
var title_bar := HBoxContainer.new()
|
||||||
|
vbox.add_child(title_bar)
|
||||||
|
_title_label = Label.new()
|
||||||
|
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_title_label.text = "Stickman: ?"
|
||||||
|
title_bar.add_child(_title_label)
|
||||||
|
var close_btn := Button.new()
|
||||||
|
close_btn.text = "× Close"
|
||||||
|
close_btn.pressed.connect(hide)
|
||||||
|
title_bar.add_child(close_btn)
|
||||||
|
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.custom_minimum_size = Vector2(0.0, 260.0)
|
||||||
|
vbox.add_child(scroll)
|
||||||
|
|
||||||
|
_list = VBoxContainer.new()
|
||||||
|
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_list.add_theme_constant_override("separation", 4)
|
||||||
|
scroll.add_child(_list)
|
||||||
|
|
||||||
|
_empty_label = Label.new()
|
||||||
|
_empty_label.text = "Queue is empty."
|
||||||
|
_empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||||
|
_list.add_child(_empty_label)
|
||||||
|
|
||||||
|
var footer := HBoxContainer.new()
|
||||||
|
footer.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(footer)
|
||||||
|
var add_btn := Button.new()
|
||||||
|
add_btn.text = "➕ Add Action"
|
||||||
|
add_btn.pressed.connect(func() -> void: add_requested.emit())
|
||||||
|
footer.add_child(add_btn)
|
||||||
|
var clear_btn := Button.new()
|
||||||
|
clear_btn.text = "🗑 Clear All"
|
||||||
|
clear_btn.pressed.connect(func() -> void: clear_requested.emit())
|
||||||
|
footer.add_child(clear_btn)
|
||||||
|
|
||||||
|
min_size = Vector2i(460, 360)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_row(index: int, action: Dictionary) -> PanelContainer:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 6)
|
||||||
|
panel.add_child(row)
|
||||||
|
|
||||||
|
var number := Label.new()
|
||||||
|
number.text = str(index + 1)
|
||||||
|
row.add_child(number)
|
||||||
|
|
||||||
|
var summary := Label.new()
|
||||||
|
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
|
||||||
|
row.add_child(summary)
|
||||||
|
|
||||||
|
var edit := Button.new()
|
||||||
|
edit.text = "✎"
|
||||||
|
edit.tooltip_text = "Edit"
|
||||||
|
edit.pressed.connect(func() -> void: edit_requested.emit(index))
|
||||||
|
row.add_child(edit)
|
||||||
|
|
||||||
|
var remove := Button.new()
|
||||||
|
remove.text = "✕"
|
||||||
|
remove.tooltip_text = "Delete"
|
||||||
|
remove.pressed.connect(func() -> void: delete_requested.emit(index))
|
||||||
|
row.add_child(remove)
|
||||||
|
|
||||||
|
var drag := Button.new()
|
||||||
|
drag.text = "≡"
|
||||||
|
drag.tooltip_text = "Drag to reorder"
|
||||||
|
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||||||
|
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||||||
|
row.add_child(drag)
|
||||||
|
|
||||||
|
# Parent the row into the list and apply the theme font/size overrides. Without
|
||||||
|
# add_child the row never renders and drag-reorder has no geometry to work with.
|
||||||
|
_list.add_child(panel)
|
||||||
|
_apply_font_recursive(panel, _row_font_size)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||||
|
## the stage after add_child so the panel's UI is already built. `sizes` carries the
|
||||||
|
## parsed per-widget size / title / row sizes, the panel-title bold flag, and the
|
||||||
|
## resolved bold/italic Font variants.
|
||||||
|
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||||
|
_ui_font = ui_font
|
||||||
|
_emoji_font = emoji_font
|
||||||
|
_base_font_size = int(sizes.get("queue_panel", 18))
|
||||||
|
_row_font_size = int(sizes.get("panel_row", 16))
|
||||||
|
_title_font_size = int(sizes.get("panel_title", 18))
|
||||||
|
var title_bold := bool(sizes.get("panel_title_bold", true))
|
||||||
|
var bold_font: Font = sizes.get("bold_font", null)
|
||||||
|
_title_font = bold_font if title_bold and bold_font != null else ui_font
|
||||||
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
if _title_label != null:
|
||||||
|
_apply_font_to(_title_label, _title_font_size, _title_font)
|
||||||
|
if _empty_label != null:
|
||||||
|
_apply_font_to(_empty_label, _row_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
_apply_font_to(child as Control, size)
|
||||||
|
_apply_font_recursive(child, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_to(c: Control, size: int, font: Font = null) -> void:
|
||||||
|
if c == null:
|
||||||
|
return
|
||||||
|
if font != null:
|
||||||
|
c.add_theme_font_override("font", font)
|
||||||
|
elif _ui_font != null:
|
||||||
|
c.add_theme_font_override("font", _ui_font)
|
||||||
|
elif _emoji_font != null:
|
||||||
|
c.add_theme_font_override("font", _emoji_font)
|
||||||
|
if size > 0:
|
||||||
|
c.add_theme_font_size_override("font_size", size)
|
||||||
|
|
||||||
|
|
||||||
|
func _row_style(bg: Color) -> StyleBoxFlat:
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = bg
|
||||||
|
sb.set_corner_radius_all(4)
|
||||||
|
sb.content_margin_left = 6.0
|
||||||
|
sb.content_margin_right = 6.0
|
||||||
|
sb.content_margin_top = 2.0
|
||||||
|
sb.content_margin_bottom = 2.0
|
||||||
|
return sb
|
||||||
|
|
||||||
|
|
||||||
|
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||||||
|
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if (event as InputEventMouseButton).pressed:
|
||||||
|
_drag_index = index
|
||||||
|
_drag_target = index
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
else:
|
||||||
|
_commit_drag()
|
||||||
|
elif event is InputEventMouseMotion and _drag_index >= 0:
|
||||||
|
_update_drag_target()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_drag_target() -> void:
|
||||||
|
var mouse_y := _list.get_local_mouse_position().y
|
||||||
|
var best := _drag_target
|
||||||
|
var best_d := INF
|
||||||
|
for i: int in _rows.size():
|
||||||
|
var row := _rows[i]
|
||||||
|
var d := absf((row.position.y + row.size.y * 0.5) - mouse_y)
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
best = i
|
||||||
|
if best != _drag_target:
|
||||||
|
_drag_target = best
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_drag_highlight() -> void:
|
||||||
|
for i: int in _rows.size():
|
||||||
|
var bg := Color(0.0, 0.0, 0.0, 0.0)
|
||||||
|
if _drag_index >= 0 and i == _drag_target:
|
||||||
|
bg = Color(0.15, 0.4, 0.9, 0.4)
|
||||||
|
(_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg))
|
||||||
|
|
||||||
|
|
||||||
|
func _commit_drag() -> void:
|
||||||
|
var from := _drag_index
|
||||||
|
var to := _drag_target
|
||||||
|
_drag_index = -1
|
||||||
|
_drag_target = -1
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
if from < 0 or to < 0 or from == to:
|
||||||
|
return
|
||||||
|
_move_action(from, to)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
## Moves the action at `from` to `to` in the rig's queue (indices in the
|
||||||
|
## pre-removal space), using the existing remove/insert API.
|
||||||
|
func _move_action(from: int, to: int) -> void:
|
||||||
|
if rig == null or not is_instance_valid(rig):
|
||||||
|
return
|
||||||
|
var queue := rig.get_queue()
|
||||||
|
if from < 0 or from >= queue.size() or to < 0 or to >= queue.size():
|
||||||
|
return
|
||||||
|
var action: Dictionary = queue[from]
|
||||||
|
rig.remove_action(from)
|
||||||
|
var target := to
|
||||||
|
if to > from:
|
||||||
|
target -= 1
|
||||||
|
rig.insert_action(target, action)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bkkscjlbhfmdq
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
class_name RuleEditor
|
||||||
|
extends PopupPanel
|
||||||
|
## RuleEditor - Rule property editor popup (Phase 3c).
|
||||||
|
##
|
||||||
|
## Two modes:
|
||||||
|
## * "full" - trigger type + target + actions are all editable.
|
||||||
|
## * "consequence" - trigger is read-only; only the actions are editable.
|
||||||
|
##
|
||||||
|
## Trigger targets and action actors are captured on the stage via the
|
||||||
|
## `trigger_target_requested` / `action_add_requested` / `action_edit_requested`
|
||||||
|
## signals; the stage hides this popup, captures a click, then calls
|
||||||
|
## `set_trigger_target()` / `set_action()` and re-pops it.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||||
|
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
|
||||||
|
|
||||||
|
signal committed(rule: Dictionary)
|
||||||
|
signal cancelled()
|
||||||
|
signal trigger_target_requested(trigger_type: String)
|
||||||
|
signal action_add_requested()
|
||||||
|
signal action_edit_requested(index: int)
|
||||||
|
|
||||||
|
var _mode: String = "full"
|
||||||
|
var _rule_id: int = -1
|
||||||
|
var _trigger: Dictionary = {}
|
||||||
|
var _actions: Array[Dictionary] = []
|
||||||
|
|
||||||
|
var _trigger_box: VBoxContainer = null
|
||||||
|
var _type_option: OptionButton = null
|
||||||
|
var _target_row: HBoxContainer = null
|
||||||
|
var _target_label: Label = null
|
||||||
|
var _set_target_btn: Button = null
|
||||||
|
var _action_type_option: OptionButton = null
|
||||||
|
var _trigger_readonly_label: Label = null
|
||||||
|
var _actions_box: VBoxContainer = null
|
||||||
|
var _empty_actions_label: Label = null
|
||||||
|
|
||||||
|
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||||
|
## override (engine default); Font null = no override.
|
||||||
|
var _ui_font: Font = null
|
||||||
|
var _emoji_font: Font = null
|
||||||
|
var _base_font_size: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
exclusive = true
|
||||||
|
popup_window = true
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
|
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
cancelled.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Opens the full editor pre-filled from a stored rule.
|
||||||
|
func open_full(rule: Dictionary) -> void:
|
||||||
|
_mode = "full"
|
||||||
|
_load_rule(rule)
|
||||||
|
title = "Edit Rule"
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Opens the consequence-only editor (trigger read-only) from a stored rule.
|
||||||
|
func open_consequence(rule: Dictionary) -> void:
|
||||||
|
_mode = "consequence"
|
||||||
|
_load_rule(rule)
|
||||||
|
title = "Edit Rule"
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Called by the stage after a trigger-target capture click. `target_id` is the
|
||||||
|
## instance id (or -1 for waypoint triggers), `params` holds type-specific data.
|
||||||
|
func set_trigger_target(target_id: int, params: Dictionary) -> void:
|
||||||
|
_trigger["target"] = target_id
|
||||||
|
_trigger["params"] = params
|
||||||
|
_refresh_trigger()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Called by the stage after an action is collected. `index` < 0 appends.
|
||||||
|
func set_action(index: int, action: Dictionary) -> void:
|
||||||
|
if index < 0:
|
||||||
|
_actions.append(action)
|
||||||
|
else:
|
||||||
|
_actions[index] = action
|
||||||
|
_refresh_actions()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the rule-action at `index` (or {} when out of range). Used by the
|
||||||
|
## stage to pre-fill the ActionEditor when editing an existing rule action.
|
||||||
|
func get_action(index: int) -> Dictionary:
|
||||||
|
if index < 0 or index >= _actions.size():
|
||||||
|
return {}
|
||||||
|
return _actions[index].duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_rule(rule: Dictionary) -> void:
|
||||||
|
_rule_id = int(rule.get("id", -1))
|
||||||
|
_trigger = (rule.get("trigger", {}) as Dictionary).duplicate(true)
|
||||||
|
_actions.clear()
|
||||||
|
for a: Dictionary in rule.get("actions", []):
|
||||||
|
_actions.append(a.duplicate(true))
|
||||||
|
var type := String(_trigger.get("type", "arrived_at_waypoint"))
|
||||||
|
if not TRIGGER_REGISTRY.has_type(type):
|
||||||
|
type = "arrived_at_waypoint"
|
||||||
|
_select_type(type)
|
||||||
|
_refresh_trigger()
|
||||||
|
_refresh_actions()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
# Trigger section.
|
||||||
|
var trigger_header := Label.new()
|
||||||
|
trigger_header.text = "Trigger:"
|
||||||
|
vbox.add_child(trigger_header)
|
||||||
|
|
||||||
|
_trigger_box = VBoxContainer.new()
|
||||||
|
_trigger_box.add_theme_constant_override("separation", 6)
|
||||||
|
vbox.add_child(_trigger_box)
|
||||||
|
|
||||||
|
_trigger_readonly_label = Label.new()
|
||||||
|
_trigger_readonly_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_trigger_box.add_child(_trigger_readonly_label)
|
||||||
|
|
||||||
|
var type_row := HBoxContainer.new()
|
||||||
|
_trigger_box.add_child(type_row)
|
||||||
|
var type_label := Label.new()
|
||||||
|
type_label.text = "When:"
|
||||||
|
type_row.add_child(type_label)
|
||||||
|
_type_option = OptionButton.new()
|
||||||
|
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
for type: String in TRIGGER_REGISTRY.types():
|
||||||
|
_type_option.add_item("%s %s" % [TRIGGER_REGISTRY.icon(type), TRIGGER_REGISTRY.label(type)])
|
||||||
|
_type_option.item_selected.connect(_on_type_selected)
|
||||||
|
type_row.add_child(_type_option)
|
||||||
|
|
||||||
|
_target_row = HBoxContainer.new()
|
||||||
|
_trigger_box.add_child(_target_row)
|
||||||
|
_target_label = Label.new()
|
||||||
|
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_target_row.add_child(_target_label)
|
||||||
|
_set_target_btn = Button.new()
|
||||||
|
_set_target_btn.text = "🎯 Click target…"
|
||||||
|
_set_target_btn.pressed.connect(_on_set_trigger_target)
|
||||||
|
_target_row.add_child(_set_target_btn)
|
||||||
|
|
||||||
|
_action_type_option = OptionButton.new()
|
||||||
|
_action_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_action_type_option.add_item("Any action", 0)
|
||||||
|
for type: String in ACTION_REGISTRY.types():
|
||||||
|
_action_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||||
|
_target_row.add_child(_action_type_option)
|
||||||
|
|
||||||
|
# Actions section.
|
||||||
|
var actions_header := Label.new()
|
||||||
|
actions_header.text = "Actions:"
|
||||||
|
vbox.add_child(actions_header)
|
||||||
|
|
||||||
|
_actions_box = VBoxContainer.new()
|
||||||
|
_actions_box.add_theme_constant_override("separation", 4)
|
||||||
|
vbox.add_child(_actions_box)
|
||||||
|
|
||||||
|
_empty_actions_label = Label.new()
|
||||||
|
_empty_actions_label.text = "No actions yet."
|
||||||
|
_empty_actions_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||||
|
_actions_box.add_child(_empty_actions_label)
|
||||||
|
|
||||||
|
var add_action := Button.new()
|
||||||
|
add_action.text = "➕ Add Action"
|
||||||
|
add_action.pressed.connect(func() -> void: action_add_requested.emit())
|
||||||
|
vbox.add_child(add_action)
|
||||||
|
|
||||||
|
# Footer buttons.
|
||||||
|
var buttons := HBoxContainer.new()
|
||||||
|
buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||||
|
buttons.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(buttons)
|
||||||
|
|
||||||
|
var cancel := Button.new()
|
||||||
|
cancel.text = "Cancel"
|
||||||
|
cancel.pressed.connect(func() -> void: cancelled.emit())
|
||||||
|
buttons.add_child(cancel)
|
||||||
|
|
||||||
|
var ok := Button.new()
|
||||||
|
ok.text = "OK"
|
||||||
|
ok.pressed.connect(_on_ok_pressed)
|
||||||
|
buttons.add_child(ok)
|
||||||
|
|
||||||
|
min_size = Vector2i(420, 320)
|
||||||
|
|
||||||
|
|
||||||
|
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||||
|
## the stage after add_child so the editor's UI is already built.
|
||||||
|
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||||
|
_ui_font = ui_font
|
||||||
|
_emoji_font = emoji_font
|
||||||
|
_base_font_size = int(sizes.get("rule_editor", 18))
|
||||||
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
_apply_font_to(child as Control, size)
|
||||||
|
_apply_font_recursive(child, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_to(c: Control, size: int) -> void:
|
||||||
|
if c == null:
|
||||||
|
return
|
||||||
|
if _ui_font != null:
|
||||||
|
c.add_theme_font_override("font", _ui_font)
|
||||||
|
elif _emoji_font != null:
|
||||||
|
c.add_theme_font_override("font", _emoji_font)
|
||||||
|
if size > 0:
|
||||||
|
c.add_theme_font_size_override("font_size", size)
|
||||||
|
|
||||||
|
|
||||||
|
func _select_type(type: String) -> void:
|
||||||
|
var idx := TRIGGER_REGISTRY.types().find(type)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
_type_option.select(idx)
|
||||||
|
|
||||||
|
|
||||||
|
func _current_type() -> String:
|
||||||
|
var types := TRIGGER_REGISTRY.types()
|
||||||
|
var idx := _type_option.selected
|
||||||
|
if idx < 0 or idx >= types.size():
|
||||||
|
return "arrived_at_waypoint"
|
||||||
|
return types[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func _on_type_selected(_index: int) -> void:
|
||||||
|
var type := _current_type()
|
||||||
|
_trigger["type"] = type
|
||||||
|
_trigger["target"] = -1
|
||||||
|
_trigger["params"] = {}
|
||||||
|
_refresh_trigger()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_set_trigger_target() -> void:
|
||||||
|
trigger_target_requested.emit(_current_type())
|
||||||
|
|
||||||
|
|
||||||
|
## Refreshes the trigger controls to match the current mode + type.
|
||||||
|
func _refresh_trigger() -> void:
|
||||||
|
if _type_option == null:
|
||||||
|
return
|
||||||
|
var type := String(_trigger.get("type", "arrived_at_waypoint"))
|
||||||
|
var full: bool = _mode == "full"
|
||||||
|
|
||||||
|
_trigger_readonly_label.visible = not full
|
||||||
|
_type_option.visible = full
|
||||||
|
_target_row.visible = full
|
||||||
|
|
||||||
|
if not full:
|
||||||
|
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)]
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show only the target control relevant to this trigger type.
|
||||||
|
_set_target_btn.visible = false
|
||||||
|
_action_type_option.visible = false
|
||||||
|
_target_label.text = ""
|
||||||
|
match TRIGGER_REGISTRY.target_type(type):
|
||||||
|
"waypoint":
|
||||||
|
var params: Dictionary = _trigger.get("params", {})
|
||||||
|
var wp: Vector2 = params.get("waypoint_pos", Vector2.INF)
|
||||||
|
_target_label.text = "Target: waypoint (%d, %d)" % [int(roundf(wp.x)), int(roundf(wp.y))] if wp.is_finite() else "Target: waypoint (not set)"
|
||||||
|
_set_target_btn.visible = true
|
||||||
|
"action_type":
|
||||||
|
var ap: Dictionary = _trigger.get("params", {})
|
||||||
|
var want := String(ap.get("action_type", ""))
|
||||||
|
_action_type_option.visible = true
|
||||||
|
_select_action_type(want)
|
||||||
|
"area":
|
||||||
|
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
|
||||||
|
_set_target_btn.visible = true
|
||||||
|
"prop":
|
||||||
|
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
|
||||||
|
_set_target_btn.visible = true
|
||||||
|
_:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
func _select_action_type(want: String) -> void:
|
||||||
|
if want.is_empty():
|
||||||
|
_action_type_option.select(0)
|
||||||
|
return
|
||||||
|
var types := ACTION_REGISTRY.types()
|
||||||
|
var idx := types.find(want)
|
||||||
|
_action_type_option.select(idx + 1 if idx >= 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
## Reads the action_type dropdown value back into the trigger params.
|
||||||
|
func _sync_action_type() -> void:
|
||||||
|
var sel := _action_type_option.selected
|
||||||
|
var params: Dictionary = _trigger.get("params", {})
|
||||||
|
if sel <= 0:
|
||||||
|
params.erase("action_type")
|
||||||
|
else:
|
||||||
|
var types := ACTION_REGISTRY.types()
|
||||||
|
if sel - 1 < types.size():
|
||||||
|
params["action_type"] = types[sel - 1]
|
||||||
|
_trigger["params"] = params
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_actions() -> void:
|
||||||
|
if _actions_box == null:
|
||||||
|
return
|
||||||
|
# Remove previous rows, keeping the empty-state label.
|
||||||
|
for child: Node in _actions_box.get_children():
|
||||||
|
if child != _empty_actions_label:
|
||||||
|
child.queue_free()
|
||||||
|
_empty_actions_label.visible = _actions.is_empty()
|
||||||
|
for i: int in _actions.size():
|
||||||
|
_actions_box.add_child(_make_action_row(i, _actions[i]))
|
||||||
|
_apply_font_recursive(_actions_box, _base_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_action_row(index: int, action: Dictionary) -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 6)
|
||||||
|
|
||||||
|
var number := Label.new()
|
||||||
|
number.text = str(index + 1)
|
||||||
|
row.add_child(number)
|
||||||
|
|
||||||
|
var summary := Label.new()
|
||||||
|
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
summary.text = "%s %s %s" % [
|
||||||
|
ACTION_REGISTRY.icon(String(action.get("type", ""))),
|
||||||
|
_actor_name(int(action.get("target", -1))),
|
||||||
|
ACTION_REGISTRY.summarize(action),
|
||||||
|
]
|
||||||
|
row.add_child(summary)
|
||||||
|
|
||||||
|
var edit := Button.new()
|
||||||
|
edit.text = "✎"
|
||||||
|
edit.pressed.connect(func() -> void: action_edit_requested.emit(index))
|
||||||
|
row.add_child(edit)
|
||||||
|
|
||||||
|
var remove := Button.new()
|
||||||
|
remove.text = "✕"
|
||||||
|
remove.pressed.connect(func() -> void: _remove_action(index))
|
||||||
|
row.add_child(remove)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _remove_action(index: int) -> void:
|
||||||
|
if index < 0 or index >= _actions.size():
|
||||||
|
return
|
||||||
|
_actions.remove_at(index)
|
||||||
|
_refresh_actions()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_ok_pressed() -> void:
|
||||||
|
if _mode == "full":
|
||||||
|
if _current_type() == "action_finished":
|
||||||
|
_sync_action_type()
|
||||||
|
_trigger["type"] = _current_type()
|
||||||
|
var rule := {
|
||||||
|
"id": _rule_id,
|
||||||
|
"trigger": _trigger.duplicate(true),
|
||||||
|
"actions": _actions.duplicate(true),
|
||||||
|
}
|
||||||
|
committed.emit(rule)
|
||||||
|
|
||||||
|
|
||||||
|
func _actor_name(id: int) -> String:
|
||||||
|
if id <= 0:
|
||||||
|
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
|
||||||
|
# spam an engine error, so resolve the display name only for real ids.
|
||||||
|
return "?"
|
||||||
|
var node := instance_from_id(id)
|
||||||
|
if node is Node and is_instance_valid(node):
|
||||||
|
return String((node as Node).name)
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
func _node_name(id: int) -> String:
|
||||||
|
return _actor_name(id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://8hgf3j8xc6tr
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
class_name RulePanel
|
||||||
|
extends PopupPanel
|
||||||
|
## RulePanel - Rule list editor popup (Phase 3c.4).
|
||||||
|
##
|
||||||
|
## Shows a list of rules (already filtered by the stage: by source stickman or
|
||||||
|
## by waypoint), with edit / delete / drag-reorder controls and Add Rule / Clear
|
||||||
|
## All. Mutations delegate to the stage via signals; reordering emits the new
|
||||||
|
## order of the *displayed* rules' ids, which the stage maps back onto its
|
||||||
|
## full `_event_rules` array (preserving un-filtered rules' positions).
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||||
|
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
|
||||||
|
|
||||||
|
signal edit_requested(rule_id: int)
|
||||||
|
signal delete_requested(rule_id: int)
|
||||||
|
signal add_requested()
|
||||||
|
signal clear_requested()
|
||||||
|
signal reorder_requested(ordered_ids: Array[int])
|
||||||
|
|
||||||
|
var _title_label: Label = null
|
||||||
|
var _list: VBoxContainer = null
|
||||||
|
var _empty_label: Label = null
|
||||||
|
var _rows: Array[PanelContainer] = []
|
||||||
|
var _rules: Array[Dictionary] = []
|
||||||
|
|
||||||
|
var _drag_index: int = -1
|
||||||
|
var _drag_target: int = -1
|
||||||
|
|
||||||
|
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||||
|
## override (engine default); Font null = no override.
|
||||||
|
var _ui_font: Font = null
|
||||||
|
var _emoji_font: Font = null
|
||||||
|
var _base_font_size: int = 0
|
||||||
|
var _row_font_size: int = 0
|
||||||
|
var _title_font_size: int = 0
|
||||||
|
var _title_font: Font = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
exclusive = true
|
||||||
|
popup_window = true
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
|
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
hide()
|
||||||
|
|
||||||
|
|
||||||
|
## Updates the displayed rule list + title without popping up.
|
||||||
|
func set_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||||
|
_rules = rules.duplicate(true)
|
||||||
|
_title_label.text = title_hint
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up.
|
||||||
|
func show_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||||
|
set_rules(rules, title_hint)
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
if _list == null:
|
||||||
|
return
|
||||||
|
for child: Node in _list.get_children():
|
||||||
|
if child != _empty_label:
|
||||||
|
child.queue_free()
|
||||||
|
_rows.clear()
|
||||||
|
_drag_index = -1
|
||||||
|
_drag_target = -1
|
||||||
|
_empty_label.visible = _rules.is_empty()
|
||||||
|
for i: int in _rules.size():
|
||||||
|
_rows.append(_make_row(i, _rules[i]))
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
title = "Rules"
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
var title_bar := HBoxContainer.new()
|
||||||
|
vbox.add_child(title_bar)
|
||||||
|
_title_label = Label.new()
|
||||||
|
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_title_label.text = "Source: ?"
|
||||||
|
title_bar.add_child(_title_label)
|
||||||
|
var close_btn := Button.new()
|
||||||
|
close_btn.text = "× Close"
|
||||||
|
close_btn.pressed.connect(hide)
|
||||||
|
title_bar.add_child(close_btn)
|
||||||
|
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.custom_minimum_size = Vector2(0.0, 260.0)
|
||||||
|
vbox.add_child(scroll)
|
||||||
|
|
||||||
|
_list = VBoxContainer.new()
|
||||||
|
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_list.add_theme_constant_override("separation", 4)
|
||||||
|
scroll.add_child(_list)
|
||||||
|
|
||||||
|
_empty_label = Label.new()
|
||||||
|
_empty_label.text = "No rules."
|
||||||
|
_empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||||
|
_list.add_child(_empty_label)
|
||||||
|
|
||||||
|
var footer := HBoxContainer.new()
|
||||||
|
footer.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(footer)
|
||||||
|
var add_btn := Button.new()
|
||||||
|
add_btn.text = "➕ Add Rule"
|
||||||
|
add_btn.pressed.connect(func() -> void: add_requested.emit())
|
||||||
|
footer.add_child(add_btn)
|
||||||
|
var clear_btn := Button.new()
|
||||||
|
clear_btn.text = "🗑 Clear All"
|
||||||
|
clear_btn.pressed.connect(func() -> void: clear_requested.emit())
|
||||||
|
footer.add_child(clear_btn)
|
||||||
|
|
||||||
|
min_size = Vector2i(520, 360)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_row(index: int, rule: Dictionary) -> PanelContainer:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 6)
|
||||||
|
panel.add_child(row)
|
||||||
|
|
||||||
|
var number := Label.new()
|
||||||
|
number.text = str(index + 1)
|
||||||
|
row.add_child(number)
|
||||||
|
|
||||||
|
var body := VBoxContainer.new()
|
||||||
|
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.add_child(body)
|
||||||
|
|
||||||
|
body.add_child(_make_label(_trigger_summary(rule)))
|
||||||
|
var actions := _action_summaries(rule)
|
||||||
|
if actions.is_empty():
|
||||||
|
body.add_child(_make_label("→ (no actions)", true))
|
||||||
|
else:
|
||||||
|
for text: String in actions:
|
||||||
|
body.add_child(_make_label(text, true))
|
||||||
|
|
||||||
|
var edit := Button.new()
|
||||||
|
edit.text = "✎"
|
||||||
|
edit.tooltip_text = "Edit"
|
||||||
|
edit.pressed.connect(func() -> void: edit_requested.emit(int(rule.get("id", -1))))
|
||||||
|
row.add_child(edit)
|
||||||
|
|
||||||
|
var remove := Button.new()
|
||||||
|
remove.text = "✕"
|
||||||
|
remove.tooltip_text = "Delete"
|
||||||
|
remove.pressed.connect(func() -> void: delete_requested.emit(int(rule.get("id", -1))))
|
||||||
|
row.add_child(remove)
|
||||||
|
|
||||||
|
var drag := Button.new()
|
||||||
|
drag.text = "≡"
|
||||||
|
drag.tooltip_text = "Drag to reorder"
|
||||||
|
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||||||
|
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||||||
|
row.add_child(drag)
|
||||||
|
|
||||||
|
# Parent the row into the list and apply the theme font/size overrides. Without
|
||||||
|
# add_child the row never renders and drag-reorder has no geometry to work with.
|
||||||
|
_list.add_child(panel)
|
||||||
|
_apply_font_recursive(panel, _row_font_size)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||||
|
## the stage after add_child so the panel's UI is already built.
|
||||||
|
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||||
|
_ui_font = ui_font
|
||||||
|
_emoji_font = emoji_font
|
||||||
|
_base_font_size = int(sizes.get("rule_panel", 18))
|
||||||
|
_row_font_size = int(sizes.get("panel_row", 16))
|
||||||
|
_title_font_size = int(sizes.get("panel_title", 18))
|
||||||
|
var title_bold := bool(sizes.get("panel_title_bold", true))
|
||||||
|
var bold_font: Font = sizes.get("bold_font", null)
|
||||||
|
_title_font = bold_font if title_bold and bold_font != null else ui_font
|
||||||
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
if _title_label != null:
|
||||||
|
_apply_font_to(_title_label, _title_font_size, _title_font)
|
||||||
|
if _empty_label != null:
|
||||||
|
_apply_font_to(_empty_label, _row_font_size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
_apply_font_to(child as Control, size)
|
||||||
|
_apply_font_recursive(child, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_font_to(c: Control, size: int, font: Font = null) -> void:
|
||||||
|
if c == null:
|
||||||
|
return
|
||||||
|
if font != null:
|
||||||
|
c.add_theme_font_override("font", font)
|
||||||
|
elif _ui_font != null:
|
||||||
|
c.add_theme_font_override("font", _ui_font)
|
||||||
|
elif _emoji_font != null:
|
||||||
|
c.add_theme_font_override("font", _emoji_font)
|
||||||
|
if size > 0:
|
||||||
|
c.add_theme_font_size_override("font_size", size)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_label(text: String, dim: bool = false) -> Label:
|
||||||
|
var lbl := Label.new()
|
||||||
|
lbl.text = text
|
||||||
|
if dim:
|
||||||
|
lbl.modulate = Color(1.0, 1.0, 1.0, 0.7)
|
||||||
|
return lbl
|
||||||
|
|
||||||
|
|
||||||
|
func _trigger_summary(rule: Dictionary) -> String:
|
||||||
|
var trigger: Dictionary = rule.get("trigger", {})
|
||||||
|
var type := String(trigger.get("type", ""))
|
||||||
|
return "%s %s %s" % [
|
||||||
|
TRIGGER_REGISTRY.icon(type),
|
||||||
|
_actor_name(int(trigger.get("source", -1))),
|
||||||
|
TRIGGER_REGISTRY.label(type),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func _action_summaries(rule: Dictionary) -> Array[String]:
|
||||||
|
var out: Array[String] = []
|
||||||
|
for a: Dictionary in rule.get("actions", []):
|
||||||
|
out.append("→ %s %s %s" % [
|
||||||
|
ACTION_REGISTRY.icon(String(a.get("type", ""))),
|
||||||
|
_actor_name(int(a.get("target", -1))),
|
||||||
|
ACTION_REGISTRY.summarize(a),
|
||||||
|
])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _row_style(bg: Color) -> StyleBoxFlat:
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = bg
|
||||||
|
sb.set_corner_radius_all(4)
|
||||||
|
sb.content_margin_left = 6.0
|
||||||
|
sb.content_margin_right = 6.0
|
||||||
|
sb.content_margin_top = 2.0
|
||||||
|
sb.content_margin_bottom = 2.0
|
||||||
|
return sb
|
||||||
|
|
||||||
|
|
||||||
|
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||||||
|
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if (event as InputEventMouseButton).pressed:
|
||||||
|
_drag_index = index
|
||||||
|
_drag_target = index
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
else:
|
||||||
|
_commit_drag()
|
||||||
|
elif event is InputEventMouseMotion and _drag_index >= 0:
|
||||||
|
_update_drag_target()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_drag_target() -> void:
|
||||||
|
var mouse_y := _list.get_local_mouse_position().y
|
||||||
|
var best := _drag_target
|
||||||
|
var best_d := INF
|
||||||
|
for i: int in _rows.size():
|
||||||
|
var row := _rows[i]
|
||||||
|
var d := absf((row.position.y + row.size.y * 0.5) - mouse_y)
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
best = i
|
||||||
|
if best != _drag_target:
|
||||||
|
_drag_target = best
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_drag_highlight() -> void:
|
||||||
|
for i: int in _rows.size():
|
||||||
|
var bg := Color(0.0, 0.0, 0.0, 0.0)
|
||||||
|
if _drag_index >= 0 and i == _drag_target:
|
||||||
|
bg = Color(0.15, 0.4, 0.9, 0.4)
|
||||||
|
(_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg))
|
||||||
|
|
||||||
|
|
||||||
|
func _commit_drag() -> void:
|
||||||
|
var from := _drag_index
|
||||||
|
var to := _drag_target
|
||||||
|
_drag_index = -1
|
||||||
|
_drag_target = -1
|
||||||
|
_refresh_drag_highlight()
|
||||||
|
if from < 0 or to < 0 or from == to or from >= _rules.size() or to >= _rules.size():
|
||||||
|
return
|
||||||
|
var moved: Dictionary = _rules[from]
|
||||||
|
_rules.remove_at(from)
|
||||||
|
_rules.insert(to, moved)
|
||||||
|
var ordered: Array[int] = []
|
||||||
|
for r: Dictionary in _rules:
|
||||||
|
ordered.append(int(r.get("id", -1)))
|
||||||
|
refresh()
|
||||||
|
reorder_requested.emit(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
func _actor_name(id: int) -> String:
|
||||||
|
if id <= 0:
|
||||||
|
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
|
||||||
|
# spam an engine error, so resolve the display name only for real ids.
|
||||||
|
return "?"
|
||||||
|
var node := instance_from_id(id)
|
||||||
|
if node is Node and is_instance_valid(node):
|
||||||
|
return String((node as Node).name)
|
||||||
|
return "?"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmiropsijs8g2
|
||||||
+897
-32
File diff suppressed because it is too large
Load Diff
@@ -45,11 +45,33 @@ var badge_radius: float = RULE_BADGE_RADIUS_PX
|
|||||||
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
|
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
|
||||||
var emoji_font: Font = null
|
var emoji_font: Font = null
|
||||||
|
|
||||||
|
## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is
|
||||||
|
## realised via ui_font_bold / FontVariation; null falls back to ui_font.
|
||||||
|
var ui_font: Font = null
|
||||||
|
var bold_font: Font = null
|
||||||
|
var italic_font: Font = null
|
||||||
|
var rule_label_bold: bool = false
|
||||||
|
var badge_bold: bool = true
|
||||||
|
|
||||||
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
|
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
|
||||||
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
|
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
|
||||||
var rules: Array[Dictionary] = []
|
var rules: Array[Dictionary] = []
|
||||||
var _rule_hit_regions: Array[Dictionary] = []
|
var _rule_hit_regions: Array[Dictionary] = []
|
||||||
|
|
||||||
|
## Phase 3c: the walk_to waypoint currently being visually edited (blinking
|
||||||
|
## highlight). Vector2.INF when none.
|
||||||
|
var _edit_waypoint: Vector2 = Vector2.INF
|
||||||
|
|
||||||
|
## Highlights a waypoint while its walk target is being re-placed on the stage.
|
||||||
|
func set_edit_waypoint(pos: Vector2) -> void:
|
||||||
|
_edit_waypoint = pos
|
||||||
|
mark_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
func clear_edit_waypoint() -> void:
|
||||||
|
_edit_waypoint = Vector2.INF
|
||||||
|
mark_dirty()
|
||||||
|
|
||||||
func set_enabled(value: bool) -> void:
|
func set_enabled(value: bool) -> void:
|
||||||
enabled = value
|
enabled = value
|
||||||
visible = value
|
visible = value
|
||||||
@@ -68,6 +90,8 @@ func set_style(cfg: Dictionary) -> void:
|
|||||||
badge_number_size = float(fonts.get("assignment_badge_font_size", NUMBER_FONT_SIZE_PX))
|
badge_number_size = float(fonts.get("assignment_badge_font_size", NUMBER_FONT_SIZE_PX))
|
||||||
badge_radius = float(fonts.get("assignment_badge_radius", RULE_BADGE_RADIUS_PX))
|
badge_radius = float(fonts.get("assignment_badge_radius", RULE_BADGE_RADIUS_PX))
|
||||||
rule_label_font_size = float(fonts.get("rule_label_font_size", RULE_LABEL_FONT_SIZE_PX))
|
rule_label_font_size = float(fonts.get("rule_label_font_size", RULE_LABEL_FONT_SIZE_PX))
|
||||||
|
rule_label_bold = bool(fonts.get("rule_label_bold", false))
|
||||||
|
badge_bold = bool(fonts.get("badge_bold", true))
|
||||||
mark_dirty()
|
mark_dirty()
|
||||||
|
|
||||||
|
|
||||||
@@ -88,18 +112,40 @@ func hit_test_rule(world_pos: Vector2) -> Dictionary:
|
|||||||
## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing
|
## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing
|
||||||
## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss.
|
## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss.
|
||||||
func hit_test_waypoint(world_pos: Vector2) -> Vector2:
|
func hit_test_waypoint(world_pos: Vector2) -> Vector2:
|
||||||
|
var hit := hit_test_waypoint_action(world_pos)
|
||||||
|
if hit.is_empty():
|
||||||
|
return Vector2.INF
|
||||||
|
return hit["pos"]
|
||||||
|
|
||||||
|
|
||||||
|
## Like hit_test_waypoint but also returns the owning rig and queue index:
|
||||||
|
## {"rig": StickmanRig, "index": int, "pos": Vector2}, or {} on miss. Used by
|
||||||
|
## the Phase 3c waypoint context menu to locate the exact walk action.
|
||||||
|
func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
|
||||||
var radius := WAYPOINT_HIT_RADIUS_PX / _zoom()
|
var radius := WAYPOINT_HIT_RADIUS_PX / _zoom()
|
||||||
var best := Vector2.INF
|
var best: Dictionary = {}
|
||||||
var best_dist := radius
|
var best_dist := radius
|
||||||
for wp: Vector2 in _collect_waypoints():
|
for rig: StickmanRig in _collect_rigs():
|
||||||
var d := world_pos.distance_to(wp)
|
var queue := rig.get_queue()
|
||||||
if d <= best_dist:
|
if queue.is_empty():
|
||||||
best_dist = d
|
continue
|
||||||
best = wp
|
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
|
||||||
|
for i: int in queue.size():
|
||||||
|
var action: Dictionary = queue[i]
|
||||||
|
if String(action.get("type", "")) == "walk_to":
|
||||||
|
var target: Vector2 = action.get("target", current)
|
||||||
|
var d := world_pos.distance_to(target)
|
||||||
|
if d <= best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best = { "rig": rig, "index": i, "pos": target }
|
||||||
|
current = target
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if _dirty:
|
# Redraw every frame while a waypoint is being edited (the blink is
|
||||||
|
# time-animated); otherwise only on the dirty flag.
|
||||||
|
if _dirty or (_edit_waypoint.is_finite() and enabled):
|
||||||
_dirty = false
|
_dirty = false
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
@@ -163,6 +209,10 @@ func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
|
|||||||
draw_circle(pos, radius, WAYPOINT_COLOR)
|
draw_circle(pos, radius, WAYPOINT_COLOR)
|
||||||
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
|
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
|
||||||
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
|
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
|
||||||
|
# Phase 3c: pulsing highlight ring while this waypoint is being edited.
|
||||||
|
if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5:
|
||||||
|
var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0)
|
||||||
|
draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true)
|
||||||
|
|
||||||
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
|
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
|
||||||
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
|
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
|
||||||
@@ -241,7 +291,7 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
|
|||||||
# Label at the line midpoint on a dark rounded rect.
|
# Label at the line midpoint on a dark rounded rect.
|
||||||
var summary := rule_summary(rule)
|
var summary := rule_summary(rule)
|
||||||
var mid := (trigger_anchor + action_anchor) * 0.5
|
var mid := (trigger_anchor + action_anchor) * 0.5
|
||||||
var font := ThemeDB.fallback_font
|
var font := _rule_label_font()
|
||||||
var font_size := int(rule_label_font_size / zoom)
|
var font_size := int(rule_label_font_size / zoom)
|
||||||
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||||
var padding := Vector2(6.0, 4.0) / zoom
|
var padding := Vector2(6.0, 4.0) / zoom
|
||||||
@@ -274,7 +324,20 @@ func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color)
|
|||||||
|
|
||||||
## Badge glyph font: the configured emoji font when set, else the fallback font.
|
## Badge glyph font: the configured emoji font when set, else the fallback font.
|
||||||
func _badge_font() -> Font:
|
func _badge_font() -> Font:
|
||||||
return emoji_font if emoji_font != null else ThemeDB.fallback_font
|
if badge_bold and bold_font != null:
|
||||||
|
return bold_font
|
||||||
|
if emoji_font != null:
|
||||||
|
return emoji_font
|
||||||
|
return ThemeDB.fallback_font
|
||||||
|
|
||||||
|
|
||||||
|
## Rule-label font: bold variant when enabled, else ui_font, else fallback.
|
||||||
|
func _rule_label_font() -> Font:
|
||||||
|
if rule_label_bold and bold_font != null:
|
||||||
|
return bold_font
|
||||||
|
if ui_font != null:
|
||||||
|
return ui_font
|
||||||
|
return ThemeDB.fallback_font
|
||||||
|
|
||||||
|
|
||||||
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
|
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
|
||||||
|
|||||||
+157
-21
@@ -120,7 +120,7 @@ const REST_LINEAR_THRESHOLD := 5.0
|
|||||||
const REST_ANGULAR_THRESHOLD := 0.1
|
const REST_ANGULAR_THRESHOLD := 0.1
|
||||||
|
|
||||||
## Duration of the stand-up tween (captured pose -> STAND_POSE).
|
## Duration of the stand-up tween (captured pose -> STAND_POSE).
|
||||||
const STAND_UP_DURATION := 0.8
|
const STAND_UP_DURATION := 2.0
|
||||||
|
|
||||||
## Extra hold after rest is detected before recovery captures the pose.
|
## Extra hold after rest is detected before recovery captures the pose.
|
||||||
const STABILIZATION_DELAY := 0.1
|
const STABILIZATION_DELAY := 0.1
|
||||||
@@ -169,6 +169,9 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
|||||||
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
|
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
|
||||||
const DEBUG_WALK := false
|
const DEBUG_WALK := false
|
||||||
|
|
||||||
|
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
|
||||||
|
const DEBUG_RECOVERY := false
|
||||||
|
|
||||||
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
|
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
|
||||||
func _walk_dbg(msg: String) -> void:
|
func _walk_dbg(msg: String) -> void:
|
||||||
if DEBUG_WALK:
|
if DEBUG_WALK:
|
||||||
@@ -290,6 +293,8 @@ var _nodes_ready: bool = false
|
|||||||
var _skeleton: Skeleton2D = null
|
var _skeleton: Skeleton2D = null
|
||||||
var _body_container: Node2D = null
|
var _body_container: Node2D = null
|
||||||
var _torso_bone: Bone2D = null
|
var _torso_bone: Bone2D = null
|
||||||
|
var _head_bone: Bone2D = null
|
||||||
|
var _head_look_at: SkeletonModification2DLookAt = null
|
||||||
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
||||||
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
||||||
|
|
||||||
@@ -314,6 +319,8 @@ var _cached_angular_velocity: float = 0.0
|
|||||||
var _rest_timer: float = 0.0
|
var _rest_timer: float = 0.0
|
||||||
var _stabilize_timer: float = 0.0
|
var _stabilize_timer: float = 0.0
|
||||||
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
|
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
|
||||||
|
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
|
||||||
|
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
|
||||||
var _stand_up_tween: Tween = null
|
var _stand_up_tween: Tween = null
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -369,6 +376,9 @@ func _ready() -> void:
|
|||||||
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||||
if _torso_bone == null:
|
if _torso_bone == null:
|
||||||
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
|
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
|
||||||
|
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||||
|
if _head_bone == null:
|
||||||
|
push_warning("StickmanRig: missing 'Torso/Head' bone in Skeleton2D.")
|
||||||
|
|
||||||
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||||
if _anim_player == null:
|
if _anim_player == null:
|
||||||
@@ -381,6 +391,7 @@ func _ready() -> void:
|
|||||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||||
if stack != null:
|
if stack != null:
|
||||||
stack.enabled = true
|
stack.enabled = true
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
|
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
|
||||||
|
|
||||||
@@ -421,6 +432,25 @@ func _physics_process(delta: float) -> void:
|
|||||||
_settle_walk_markers()
|
_settle_walk_markers()
|
||||||
_update_speech(delta)
|
_update_speech(delta)
|
||||||
_update_runner(delta)
|
_update_runner(delta)
|
||||||
|
_pin_mirrored_head_rotation()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
|
||||||
|
## facing LEFT with the kinematic puppet live (ANIMATED or RECOVERING). The
|
||||||
|
## LookAt modification is disabled in that state (_apply_head_lookat_mirror_mode)
|
||||||
|
## so nothing else rewrites the bone; the per-frame pin covers recovery, where
|
||||||
|
## the IK stack is re-enabled mid-tween.
|
||||||
|
func _pin_mirrored_head_rotation() -> void:
|
||||||
|
if facing_profile != FacingProfile.LEFT or state == RigState.RAGDOLL:
|
||||||
|
return
|
||||||
|
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
||||||
|
return
|
||||||
|
if _head_look_at.enabled:
|
||||||
|
_head_look_at.enabled = false
|
||||||
|
if _head_bone != null and is_instance_valid(_head_bone):
|
||||||
|
_head_bone.rotation = 0
|
||||||
|
|
||||||
|
|
||||||
func _track_momentum(delta: float) -> void:
|
func _track_momentum(delta: float) -> void:
|
||||||
@@ -562,6 +592,7 @@ func snap_to_standing() -> void:
|
|||||||
# Re-show the kinematic puppet and re-enable IK.
|
# Re-show the kinematic puppet and re-enable IK.
|
||||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||||
_skeleton.modification_stack.enabled = true
|
_skeleton.modification_stack.enabled = true
|
||||||
|
|
||||||
if _body_container != null and is_instance_valid(_body_container):
|
if _body_container != null and is_instance_valid(_body_container):
|
||||||
_body_container.visible = true
|
_body_container.visible = true
|
||||||
_body_container.modulate.a = 1.0
|
_body_container.modulate.a = 1.0
|
||||||
@@ -613,8 +644,12 @@ func _resolve_bend_modifications() -> void:
|
|||||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||||
if stack == null:
|
if stack == null:
|
||||||
return
|
return
|
||||||
|
_head_look_at = null
|
||||||
for i: int in stack.modification_count:
|
for i: int in stack.modification_count:
|
||||||
var mod := stack.get_modification(i)
|
var mod := stack.get_modification(i)
|
||||||
|
if mod is SkeletonModification2DLookAt:
|
||||||
|
_head_look_at = mod as SkeletonModification2DLookAt
|
||||||
|
continue
|
||||||
if not (mod is SkeletonModification2DTwoBoneIK):
|
if not (mod is SkeletonModification2DTwoBoneIK):
|
||||||
continue
|
continue
|
||||||
var ik := mod as SkeletonModification2DTwoBoneIK
|
var ik := mod as SkeletonModification2DTwoBoneIK
|
||||||
@@ -638,21 +673,39 @@ func _apply_profile() -> void:
|
|||||||
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
|
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
|
||||||
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
|
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
|
||||||
_apply_body_z_order()
|
_apply_body_z_order()
|
||||||
_apply_head_flip()
|
# Whole-rig Y-axis mirror (spec §9a): facing LEFT mirrors the entire figure
|
||||||
|
# -- head + body + IK targets + mounted Body/* geometry -- so the head AND
|
||||||
|
# body face the correct direction together. RIGHT/FORWARD keep an identity
|
||||||
|
# scale. An X-mirror does not affect depth (draw order), so
|
||||||
|
# Z_ORDER_BY_PROFILE is unchanged.
|
||||||
|
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
|
||||||
|
_apply_head_lookat_mirror_mode()
|
||||||
facing_profile_changed.emit(int(facing_profile))
|
facing_profile_changed.emit(int(facing_profile))
|
||||||
|
|
||||||
func _apply_head_flip() -> void:
|
|
||||||
var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D
|
## The SkeletonModification2DLookAt that aims the Head bone is NOT
|
||||||
var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D
|
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
|
||||||
if pivot != null:
|
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
|
||||||
var is_left := (facing_profile == FacingProfile.LEFT)
|
## bone rotation 180 degrees off the FORWARD aim (PI -> 0 headless). Because the
|
||||||
if is_left:
|
## mounted head geometry is offset from Body/Head's local origin (the chin sits
|
||||||
# Mirror local X and invert double the bone's rotation to mirror in world space
|
## at the neck and the head extends away from it), that 180-degree error flips
|
||||||
pivot.scale = Vector2(-1.0, 1.0)
|
## the head to hang BELOW the neck instead of sitting above it -- the whole-rig
|
||||||
pivot.rotation = -1.0 * head.rotation
|
## mirror then displaces the head ~2x its mount offset relative to the torso.
|
||||||
else:
|
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
|
||||||
pivot.scale = Vector2(1.0, 1.0)
|
## FORWARD canonical aim rotation (PI), so the head is a rigid mirror of the
|
||||||
pivot.rotation = 0.0
|
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
|
||||||
|
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
|
||||||
|
## because re-enabling the IK stack during recovery would otherwise let the
|
||||||
|
## LookAt flip the bone again.
|
||||||
|
func _apply_head_lookat_mirror_mode() -> void:
|
||||||
|
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
||||||
|
return
|
||||||
|
var mirrored := facing_profile == FacingProfile.LEFT
|
||||||
|
_head_look_at.enabled = false#not mirrored
|
||||||
|
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
|
||||||
|
_head_bone.rotation = PI
|
||||||
|
elif not mirrored:
|
||||||
|
_head_bone.rotation = 0
|
||||||
|
|
||||||
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
|
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
|
||||||
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
|
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
|
||||||
@@ -717,6 +770,8 @@ func _enter_ragdoll() -> void:
|
|||||||
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
|
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
|
||||||
func _capture_ragdoll_pose() -> void:
|
func _capture_ragdoll_pose() -> void:
|
||||||
_captured_pose.clear()
|
_captured_pose.clear()
|
||||||
|
_captured_landing_center = Vector2.ZERO
|
||||||
|
_captured_ground_y = 0.0
|
||||||
for key: String in _ragdoll_bodies:
|
for key: String in _ragdoll_bodies:
|
||||||
var body := _ragdoll_bodies[key] as RigidBody2D
|
var body := _ragdoll_bodies[key] as RigidBody2D
|
||||||
if body == null or not is_instance_valid(body):
|
if body == null or not is_instance_valid(body):
|
||||||
@@ -726,17 +781,46 @@ func _capture_ragdoll_pose() -> void:
|
|||||||
"rot": body.global_rotation - global_rotation,
|
"rot": body.global_rotation - global_rotation,
|
||||||
"half": float(body.get_meta("half_height", 0.0)),
|
"half": float(body.get_meta("half_height", 0.0)),
|
||||||
}
|
}
|
||||||
|
# Record the ragdoll torso's world center + its ground-contact line so
|
||||||
|
# recovery can re-anchor the root's feet onto wherever the ragdoll landed.
|
||||||
|
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||||||
|
if torso != null and is_instance_valid(torso):
|
||||||
|
_captured_landing_center = torso.global_position
|
||||||
|
_captured_ground_y = torso.global_position.y + RAGDOLL_TORSO_RADIUS
|
||||||
|
|
||||||
|
|
||||||
func _start_recovery() -> void:
|
func _start_recovery() -> void:
|
||||||
|
if state != RigState.RAGDOLL:
|
||||||
|
return
|
||||||
_capture_ragdoll_pose()
|
_capture_ragdoll_pose()
|
||||||
_destroy_ragdoll()
|
_destroy_ragdoll()
|
||||||
|
_reanchor_root_to_landing()
|
||||||
state = RigState.RECOVERING
|
state = RigState.RECOVERING
|
||||||
state_changed.emit(int(state))
|
state_changed.emit(int(state))
|
||||||
_snap_skeleton_to_pose()
|
_snap_skeleton_to_pose()
|
||||||
_play_stand_up()
|
_play_stand_up()
|
||||||
|
|
||||||
|
|
||||||
|
## Re-anchors the rig root so the standing figure's feet sit on the ground at
|
||||||
|
## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not
|
||||||
|
## the rig), so the rig root never moved while it fell; without this the
|
||||||
|
## stand-up tween would drag the figure back to the root's pre-ragdoll world
|
||||||
|
## position.
|
||||||
|
func _reanchor_root_to_landing() -> void:
|
||||||
|
if not _captured_pose.has("torso"):
|
||||||
|
return
|
||||||
|
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
|
||||||
|
var old_root := global_position
|
||||||
|
var new_root := feet_world + FOOT_OFFSET
|
||||||
|
global_position = new_root
|
||||||
|
# Re-base the captured (rig-local) pose onto the re-anchored root so the
|
||||||
|
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one.
|
||||||
|
var shift := old_root - new_root
|
||||||
|
for key: String in _captured_pose:
|
||||||
|
var entry: Dictionary = _captured_pose[key]
|
||||||
|
entry["pos"] = entry.get("pos", Vector2.ZERO) + shift
|
||||||
|
|
||||||
|
|
||||||
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
||||||
## clean slate.
|
## clean slate.
|
||||||
func _cancel_recovery() -> void:
|
func _cancel_recovery() -> void:
|
||||||
@@ -745,6 +829,59 @@ func _cancel_recovery() -> void:
|
|||||||
_stand_up_tween = null
|
_stand_up_tween = null
|
||||||
|
|
||||||
|
|
||||||
|
## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to
|
||||||
|
## resume solving. A disabled->enabled toggle alone can leave the stack's
|
||||||
|
## internal solve state stale (the classic Godot disable->enable quirk), so we
|
||||||
|
## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D
|
||||||
|
## processes internally (required for the stack to execute), and re-assert the
|
||||||
|
## Torso marker's RemoteTransform2D update flags so the hip bone keeps
|
||||||
|
## following IK_Targets/Torso through the stand-up tween.
|
||||||
|
func _rearm_ik_stack() -> void:
|
||||||
|
if _skeleton == null or not is_instance_valid(_skeleton):
|
||||||
|
return
|
||||||
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||||
|
if stack != null:
|
||||||
|
if not stack.get_is_setup():
|
||||||
|
stack.setup()
|
||||||
|
stack.enabled = true
|
||||||
|
_skeleton.set_process_internal(true)
|
||||||
|
# Torso marker -> Torso bone driver: ensure it forwards position/rotation.
|
||||||
|
var torso_marker := _get_ik_marker("Torso")
|
||||||
|
if torso_marker != null:
|
||||||
|
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
|
||||||
|
if driver != null:
|
||||||
|
driver.update_position = true
|
||||||
|
driver.update_rotation = true
|
||||||
|
driver.update_scale = true
|
||||||
|
_recovery_dbg()
|
||||||
|
|
||||||
|
|
||||||
|
## Logs the IK stack + bone-following state during recovery (off by default).
|
||||||
|
func _recovery_dbg() -> void:
|
||||||
|
if not DEBUG_RECOVERY:
|
||||||
|
return
|
||||||
|
var stack: SkeletonModificationStack2D = null
|
||||||
|
var stack_enabled := false
|
||||||
|
var stack_setup := false
|
||||||
|
var internal := false
|
||||||
|
if _skeleton != null and is_instance_valid(_skeleton):
|
||||||
|
stack = _skeleton.modification_stack
|
||||||
|
stack_enabled = stack != null and stack.enabled
|
||||||
|
stack_setup = stack != null and stack.get_is_setup()
|
||||||
|
internal = _skeleton.is_processing_internal()
|
||||||
|
var torso_bone_pos := Vector2.ZERO
|
||||||
|
if _torso_bone != null and is_instance_valid(_torso_bone):
|
||||||
|
torso_bone_pos = _torso_bone.global_position
|
||||||
|
var torso_marker := _get_ik_marker("Torso")
|
||||||
|
var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO
|
||||||
|
var limb := _bend_joint_bones.get("LeftLeg") as Bone2D
|
||||||
|
var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO
|
||||||
|
print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [
|
||||||
|
str(stack_enabled), str(stack_setup), str(internal),
|
||||||
|
str(torso_bone_pos), str(torso_marker_pos), str(limb_pos),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
|
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
|
||||||
## markers (NOT the Torso Bone2D, which is slaved to its marker via
|
## markers (NOT the Torso Bone2D, which is slaved to its marker via
|
||||||
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
|
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
|
||||||
@@ -786,8 +923,7 @@ func _snap_skeleton_to_pose() -> void:
|
|||||||
if _body_container != null and is_instance_valid(_body_container):
|
if _body_container != null and is_instance_valid(_body_container):
|
||||||
_body_container.visible = true
|
_body_container.visible = true
|
||||||
_body_container.modulate.a = 1.0
|
_body_container.modulate.a = 1.0
|
||||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
_rearm_ik_stack()
|
||||||
_skeleton.modification_stack.enabled = true
|
|
||||||
|
|
||||||
|
|
||||||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||||||
@@ -842,6 +978,7 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
|||||||
func _on_stand_up_finished() -> void:
|
func _on_stand_up_finished() -> void:
|
||||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||||
_skeleton.modification_stack.enabled = true
|
_skeleton.modification_stack.enabled = true
|
||||||
|
pass
|
||||||
if _body_container != null and is_instance_valid(_body_container):
|
if _body_container != null and is_instance_valid(_body_container):
|
||||||
_body_container.visible = true
|
_body_container.visible = true
|
||||||
_body_container.modulate.a = 1.0
|
_body_container.modulate.a = 1.0
|
||||||
@@ -1052,15 +1189,14 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
|
|||||||
])
|
])
|
||||||
|
|
||||||
var dx := target.x - global_position.x
|
var dx := target.x - global_position.x
|
||||||
var anim_name: String
|
|
||||||
if dx < -0.5:
|
if dx < -0.5:
|
||||||
set_facing_profile(FacingProfile.LEFT)
|
set_facing_profile(FacingProfile.LEFT)
|
||||||
anim_name = "walk_left"
|
|
||||||
elif dx > 0.5:
|
elif dx > 0.5:
|
||||||
set_facing_profile(FacingProfile.RIGHT)
|
set_facing_profile(FacingProfile.RIGHT)
|
||||||
anim_name = "walk_right"
|
# Always play the canonical walk_right clip. For LEFT the rig root is
|
||||||
else:
|
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
|
||||||
anim_name = "walk_right"
|
# walk_left is no longer used at runtime.
|
||||||
|
var anim_name := "walk_right"
|
||||||
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
|
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
|
||||||
_anim_player.play(anim_name)
|
_anim_player.play(anim_name)
|
||||||
_walking = true
|
_walking = true
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
class_name TriggerRegistry
|
||||||
|
extends RefCounted
|
||||||
|
## TriggerRegistry - Registry of rule trigger templates (Phase 3c).
|
||||||
|
##
|
||||||
|
## Single source of truth for the trigger types the event system understands.
|
||||||
|
## Adding a new trigger type is just appending an entry here; the RuleEditor and
|
||||||
|
## the panels generate their UI from this registry.
|
||||||
|
|
||||||
|
const TRIGGER_TEMPLATES := {
|
||||||
|
"arrived_at_waypoint": {
|
||||||
|
"label": "Arrives at waypoint",
|
||||||
|
"icon": "📍",
|
||||||
|
"target_type": "waypoint",
|
||||||
|
},
|
||||||
|
"action_finished": {
|
||||||
|
"label": "Completes any action",
|
||||||
|
"icon": "✅",
|
||||||
|
"target_type": "action_type",
|
||||||
|
},
|
||||||
|
"speech_finished": {
|
||||||
|
"label": "Finishes speaking",
|
||||||
|
"icon": "💬",
|
||||||
|
"target_type": "none",
|
||||||
|
},
|
||||||
|
"entered_area": {
|
||||||
|
"label": "Enters trigger area",
|
||||||
|
"icon": "🎯",
|
||||||
|
"target_type": "area",
|
||||||
|
},
|
||||||
|
"collided": {
|
||||||
|
"label": "Collides with something",
|
||||||
|
"icon": "💥",
|
||||||
|
"target_type": "prop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func types() -> Array[String]:
|
||||||
|
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
|
||||||
|
# typed Array[String] so callers can store it in typed locals (see
|
||||||
|
# RuleEditor._current_type).
|
||||||
|
var out: Array[String] = []
|
||||||
|
out.assign(TRIGGER_TEMPLATES.keys())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func has_type(type: String) -> bool:
|
||||||
|
return TRIGGER_TEMPLATES.has(type)
|
||||||
|
|
||||||
|
|
||||||
|
static func label(type: String) -> String:
|
||||||
|
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||||
|
return String(tpl.get("label", type))
|
||||||
|
|
||||||
|
|
||||||
|
static func icon(type: String) -> String:
|
||||||
|
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||||
|
return String(tpl.get("icon", ""))
|
||||||
|
|
||||||
|
|
||||||
|
## Which kind of stage target a trigger needs: "waypoint", "action_type",
|
||||||
|
## "area", "prop", or "none".
|
||||||
|
static func target_type(type: String) -> String:
|
||||||
|
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||||
|
return String(tpl.get("target_type", "none"))
|
||||||
|
|
||||||
|
|
||||||
|
## Human-readable summary of a trigger, e.g. "Arrives at waypoint".
|
||||||
|
static func summarize(trigger: Dictionary) -> String:
|
||||||
|
var type := String(trigger.get("type", ""))
|
||||||
|
return "%s %s" % [icon(type), label(type)]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cv1ng0wekc4gc
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
class_name WaypointContext
|
||||||
|
extends PopupMenu
|
||||||
|
## WaypointContext - Right-click context menu for a walk_to waypoint (Phase 3c.3).
|
||||||
|
##
|
||||||
|
## Provides Edit/Delete/Insert actions for the waypoint's walk action plus a
|
||||||
|
## "Edit Trigger Rules" entry (shown only when rules target this waypoint).
|
||||||
|
|
||||||
|
const EDIT_WALK := 0
|
||||||
|
const DELETE_WALK := 1
|
||||||
|
const INSERT_BEFORE := 2
|
||||||
|
const INSERT_AFTER := 3
|
||||||
|
const EDIT_TRIGGER_RULES := 4
|
||||||
|
|
||||||
|
const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after a separator at 4)
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
# _init runs before the node is in the tree; item order is fixed here.
|
||||||
|
add_item("✎ Edit this Walk", EDIT_WALK)
|
||||||
|
add_item("✕ Delete this Walk", DELETE_WALK)
|
||||||
|
add_item("⬆ Insert action before", INSERT_BEFORE)
|
||||||
|
add_item("⬇ Insert action after", INSERT_AFTER)
|
||||||
|
add_separator()
|
||||||
|
add_item("⚡ Edit Trigger Rules", EDIT_TRIGGER_RULES)
|
||||||
|
|
||||||
|
|
||||||
|
## Updates the trigger-rules entry (count / enabled) and pops up at `rect`.
|
||||||
|
func popup_for(rect: Rect2i, trigger_rule_count: int) -> void:
|
||||||
|
if trigger_rule_count > 0:
|
||||||
|
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules (%d rule%s)" % [trigger_rule_count, "" if trigger_rule_count == 1 else "s"])
|
||||||
|
set_item_disabled(_EDIT_TRIGGER_IDX, false)
|
||||||
|
else:
|
||||||
|
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules")
|
||||||
|
set_item_disabled(_EDIT_TRIGGER_IDX, true)
|
||||||
|
popup(rect)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://chrtaky4j2w25
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# test_phase3c_bugfix.gd
|
||||||
|
# Headless regression suite for the Phase 3c bugfix pass (five bugs):
|
||||||
|
#
|
||||||
|
# Bug 1 (queue rows never parented): QueuePanel._make_row now calls
|
||||||
|
# _list.add_child(panel), so rows are actually in the rendered tree.
|
||||||
|
# Regression: assert the rendered tree — _list.get_child_count() matches
|
||||||
|
# queue size + the always-present empty label, each _rows entry is a direct
|
||||||
|
# child of _list, and each row has non-zero geometry after the panel pops up
|
||||||
|
# (layout frame) instead of asserting only _rows.size().
|
||||||
|
#
|
||||||
|
# Bug 2 (rule rows never parented): same fix in RulePanel._make_row, same
|
||||||
|
# rendered-tree assertions for the filtered rule list.
|
||||||
|
#
|
||||||
|
# Bug 3 ("Edit Queue…" gating): in BOTH the Director action popup
|
||||||
|
# (ACT_EDIT_QUEUE) and the stickman right-click context popup
|
||||||
|
# (RIG_CTX_EDIT_QUEUE) the item is disabled when rig.get_queue().is_empty()
|
||||||
|
# and enabled otherwise, refreshed on about_to_popup by
|
||||||
|
# _refresh_action_popup_items() / _refresh_rig_context_items().
|
||||||
|
#
|
||||||
|
# Bug 4 ("Edit Rules…" gating): the same two popups disable
|
||||||
|
# ACT_EDIT_RULES / RIG_CTX_EDIT_RULES when no rule has
|
||||||
|
# trigger.source == rig.get_instance_id(), and enable them otherwise.
|
||||||
|
#
|
||||||
|
# Bug 5 (font/size config): sandbox_theme.json's extended fonts block is
|
||||||
|
# parsed — ui_font_bold/ui_font_italic, per-widget sizes
|
||||||
|
# (queue/rule/action_editor/rule_editor/panel_row/panel_title), the
|
||||||
|
# panel_title_bold / rule_label_bold / badge_bold flags, and the
|
||||||
|
# "action_popup" object form — with defaults preserved when the keys are
|
||||||
|
# absent. action_popup_emoji_size is consumed (previously dead) and applied
|
||||||
|
# by _apply_popup_theme; the four Phase 3c widgets expose apply_font(...)
|
||||||
|
# and tolerate null fonts; stage_director_visuals.set_style() honours the
|
||||||
|
# bold flags.
|
||||||
|
#
|
||||||
|
# Run with:
|
||||||
|
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_bugfix.gd --path .
|
||||||
|
#
|
||||||
|
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
|
||||||
|
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||||
|
|
||||||
|
var _checks := 0
|
||||||
|
var _failures := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var watchdog := create_timer(120.0)
|
||||||
|
watchdog.timeout.connect(func() -> void:
|
||||||
|
print("FAIL: watchdog timeout - test run aborted before quit()")
|
||||||
|
quit(2))
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("========================================================")
|
||||||
|
print(" PHASE 3c BUGFIX REGRESSION TEST (headless)")
|
||||||
|
print("========================================================")
|
||||||
|
|
||||||
|
await _test_queue_rows_in_rendered_tree()
|
||||||
|
await _test_rule_rows_in_rendered_tree()
|
||||||
|
await _test_edit_queue_gating()
|
||||||
|
await _test_edit_rules_gating()
|
||||||
|
await _test_theme_keys_parsed_and_defaults()
|
||||||
|
await _test_apply_font_null_fonts_and_bold_title()
|
||||||
|
await _test_popup_emoji_size_applied()
|
||||||
|
await _test_visuals_style_bold_flags()
|
||||||
|
|
||||||
|
print("--------------------------------------------------------")
|
||||||
|
if _failures == 0:
|
||||||
|
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 1: QueuePanel rows are parented into _list and render.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_queue_rows_in_rendered_tree() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- QueuePanel rows live in _list with real geometry ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
|
_check(rig != null, "stickman rig spawns for queue tree test")
|
||||||
|
if rig == null:
|
||||||
|
await _free_stage(stage)
|
||||||
|
return
|
||||||
|
|
||||||
|
rig.queue_action({ "type": "walk_to", "target": Vector2(100, -100) })
|
||||||
|
rig.queue_action({ "type": "speak", "text": "Hello", "duration": 2.0 })
|
||||||
|
rig.queue_action({ "type": "wait", "duration": 3.0 })
|
||||||
|
rig.queue_action({ "type": "ragdoll" })
|
||||||
|
|
||||||
|
stage._open_queue_panel(rig)
|
||||||
|
var qp: QueuePanel = stage._queue_panel
|
||||||
|
_check(qp._rows.size() == 4, "queue panel tracks 4 rows (got %d)" % qp._rows.size())
|
||||||
|
# The empty label is an always-present child of _list, so the rendered tree
|
||||||
|
# must hold label + one row per queue entry. This is the assertion the
|
||||||
|
# pre-fix suite missed (it only counted _rows.size()).
|
||||||
|
_check(qp._list.get_child_count() == 5,
|
||||||
|
"_list holds the empty label + 4 row children (got %d)" % qp._list.get_child_count())
|
||||||
|
var all_parented := true
|
||||||
|
var all_children := true
|
||||||
|
for row: PanelContainer in qp._rows:
|
||||||
|
if row.get_parent() != qp._list:
|
||||||
|
all_parented = false
|
||||||
|
if not qp._list.get_children().has(row):
|
||||||
|
all_children = false
|
||||||
|
_check(all_parented, "every queue row's parent is _list")
|
||||||
|
_check(all_children, "every queue row is a child of _list")
|
||||||
|
|
||||||
|
# After the popup lays out, every row must have non-zero geometry.
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
var geometry_ok := true
|
||||||
|
for i: int in qp._rows.size():
|
||||||
|
var row: PanelContainer = qp._rows[i]
|
||||||
|
if row.size.x <= 0.0 or row.size.y <= 0.0:
|
||||||
|
geometry_ok = false
|
||||||
|
print(" row %d size = %s" % [i, str(row.size)])
|
||||||
|
_check(geometry_ok, "all 4 queue rows have non-zero geometry after layout")
|
||||||
|
|
||||||
|
# Clear the queue: refresh must empty the rendered tree down to the label.
|
||||||
|
rig.clear_queue()
|
||||||
|
qp.refresh()
|
||||||
|
await process_frame
|
||||||
|
_check(qp._rows.is_empty(), "queue refresh clears _rows after clear")
|
||||||
|
_check(qp._list.get_child_count() == 1,
|
||||||
|
"queue _list drops to just the empty label after clear (got %d)" % qp._list.get_child_count())
|
||||||
|
_check(qp._empty_label.visible, "queue empty label visible after clear")
|
||||||
|
|
||||||
|
# Re-add one action and refresh: rendered tree follows.
|
||||||
|
rig.queue_action({ "type": "wait", "duration": 1.0 })
|
||||||
|
qp.refresh()
|
||||||
|
await process_frame
|
||||||
|
_check(qp._list.get_child_count() == 2,
|
||||||
|
"queue _list regrows one row after re-add (got %d)" % qp._list.get_child_count())
|
||||||
|
_check(qp._rows[0].get_parent() == qp._list, "regrown queue row is parented into _list")
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 2: RulePanel rows are parented into _list and render.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_rule_rows_in_rendered_tree() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- RulePanel rows live in _list with real geometry ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
|
var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0))
|
||||||
|
_check(rigA != null and rigB != null, "two stickmen spawn for rule tree test")
|
||||||
|
if rigA == null or rigB == null:
|
||||||
|
await _free_stage(stage)
|
||||||
|
return
|
||||||
|
|
||||||
|
var rule0 := {
|
||||||
|
"id": 0,
|
||||||
|
"trigger": { "type": "arrived_at_waypoint", "source": rigA.get_instance_id(), "target": -1, "params": { "waypoint_pos": Vector2(100, -100) } },
|
||||||
|
"actions": [{ "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "Hi", "duration": 1.0 } }],
|
||||||
|
}
|
||||||
|
var rule1 := {
|
||||||
|
"id": 1,
|
||||||
|
"trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} },
|
||||||
|
"actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 2.0 } }],
|
||||||
|
}
|
||||||
|
var rules: Array[Dictionary] = [rule0, rule1]
|
||||||
|
stage._event_rules = rules
|
||||||
|
|
||||||
|
stage._open_rules_panel_for_rig(rigA)
|
||||||
|
var rp: RulePanel = stage._rule_panel
|
||||||
|
_check(rp._rows.size() == 2, "rule panel tracks 2 filtered rows (got %d)" % rp._rows.size())
|
||||||
|
_check(rp._list.get_child_count() == 3,
|
||||||
|
"rule _list holds the empty label + 2 row children (got %d)" % rp._list.get_child_count())
|
||||||
|
var all_parented := true
|
||||||
|
for row: PanelContainer in rp._rows:
|
||||||
|
if row.get_parent() != rp._list:
|
||||||
|
all_parented = false
|
||||||
|
_check(all_parented, "every rule row's parent is _list")
|
||||||
|
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
var geometry_ok := true
|
||||||
|
for i: int in rp._rows.size():
|
||||||
|
var row: PanelContainer = rp._rows[i]
|
||||||
|
if row.size.x <= 0.0 or row.size.y <= 0.0:
|
||||||
|
geometry_ok = false
|
||||||
|
print(" rule row %d size = %s" % [i, str(row.size)])
|
||||||
|
_check(geometry_ok, "all rule rows have non-zero geometry after layout")
|
||||||
|
|
||||||
|
# A rig with no rules shows an empty rendered tree (label only).
|
||||||
|
stage._open_rules_panel_for_rig(rigB)
|
||||||
|
await process_frame
|
||||||
|
_check(stage._rule_panel._rows.is_empty(), "no-rule filter clears _rows")
|
||||||
|
_check(stage._rule_panel._list.get_child_count() == 1,
|
||||||
|
"no-rule filter leaves just the empty label (got %d)" % stage._rule_panel._list.get_child_count())
|
||||||
|
_check(stage._rule_panel._empty_label.visible, "rule empty label visible with no rules")
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 3: "Edit Queue…" gating (queue empty -> disabled, else enabled) in both
|
||||||
|
# popups, refreshed on about_to_popup.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_edit_queue_gating() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- Edit Queue… gating in action popup + rig context popup ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
|
_check(rig != null, "stickman rig spawns for queue gating test")
|
||||||
|
if rig == null:
|
||||||
|
await _free_stage(stage)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Empty queue -> ACT_EDIT_QUEUE disabled in the Director action popup.
|
||||||
|
stage._context_rig = rig
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
var act_idx: int = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE)
|
||||||
|
_check(act_idx >= 0, "action popup contains ACT_EDIT_QUEUE")
|
||||||
|
_check(stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Queue… disabled with an empty queue")
|
||||||
|
|
||||||
|
# Empty queue -> RIG_CTX_EDIT_QUEUE disabled in the rig right-click menu.
|
||||||
|
stage._open_rig_context(Vector2(0, 0), rig)
|
||||||
|
var ctx_idx: int = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_QUEUE)
|
||||||
|
_check(ctx_idx >= 0, "rig context popup contains RIG_CTX_EDIT_QUEUE")
|
||||||
|
_check(stage._rig_context_popup.is_item_disabled(ctx_idx),
|
||||||
|
"rig context Edit Queue… disabled with an empty queue")
|
||||||
|
stage._rig_context_popup.hide()
|
||||||
|
|
||||||
|
# Non-empty queue -> both items enabled on the next popup.
|
||||||
|
rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) })
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE)
|
||||||
|
_check(not stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Queue… enabled with a queued action")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
|
||||||
|
stage._open_rig_context(Vector2(0, 0), rig)
|
||||||
|
ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_QUEUE)
|
||||||
|
_check(not stage._rig_context_popup.is_item_disabled(ctx_idx),
|
||||||
|
"rig context Edit Queue… enabled with a queued action")
|
||||||
|
stage._rig_context_popup.hide()
|
||||||
|
|
||||||
|
# Back to empty: gating follows the live queue (disabled again).
|
||||||
|
rig.clear_queue()
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE)
|
||||||
|
_check(stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Queue… re-disabled after the queue clears")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 4: "Edit Rules…" gating (no rule with source == rig -> disabled, else
|
||||||
|
# enabled) in both popups.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_edit_rules_gating() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- Edit Rules… gating in action popup + rig context popup ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
|
var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0))
|
||||||
|
_check(rigA != null and rigB != null, "two stickmen spawn for rules gating test")
|
||||||
|
if rigA == null or rigB == null:
|
||||||
|
await _free_stage(stage)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No rules at all -> both popups disable Edit Rules… for rigA.
|
||||||
|
stage._context_rig = rigA
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
var act_idx: int = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES)
|
||||||
|
_check(stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Rules… disabled when rigA has no rules")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
stage._open_rig_context(Vector2(0, 0), rigA)
|
||||||
|
var ctx_idx: int = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES)
|
||||||
|
_check(stage._rig_context_popup.is_item_disabled(ctx_idx),
|
||||||
|
"rig context Edit Rules… disabled when rigA has no rules")
|
||||||
|
stage._rig_context_popup.hide()
|
||||||
|
|
||||||
|
# A rule owned by ANOTHER rig (rigB) must not enable rigA's entry.
|
||||||
|
var other_rule := {
|
||||||
|
"id": 0,
|
||||||
|
"trigger": { "type": "arrived_at_waypoint", "source": rigB.get_instance_id(), "target": -1, "params": { "waypoint_pos": Vector2(100, -100) } },
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
var other_rules: Array[Dictionary] = [other_rule]
|
||||||
|
stage._event_rules = other_rules
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES)
|
||||||
|
_check(stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Rules… stays disabled for rigA when only rigB owns rules")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
stage._open_rig_context(Vector2(0, 0), rigA)
|
||||||
|
ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES)
|
||||||
|
_check(stage._rig_context_popup.is_item_disabled(ctx_idx),
|
||||||
|
"rig context Edit Rules… stays disabled for rigA when only rigB owns rules")
|
||||||
|
stage._rig_context_popup.hide()
|
||||||
|
|
||||||
|
# A rule sourced by rigA enables both entries.
|
||||||
|
var my_rule := {
|
||||||
|
"id": 1,
|
||||||
|
"trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} },
|
||||||
|
"actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 1.0 } }],
|
||||||
|
}
|
||||||
|
var my_rules: Array[Dictionary] = [my_rule]
|
||||||
|
stage._event_rules = my_rules
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES)
|
||||||
|
_check(not stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Rules… enabled when rigA owns a rule")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
stage._open_rig_context(Vector2(0, 0), rigA)
|
||||||
|
ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES)
|
||||||
|
_check(not stage._rig_context_popup.is_item_disabled(ctx_idx),
|
||||||
|
"rig context Edit Rules… enabled when rigA owns a rule")
|
||||||
|
stage._rig_context_popup.hide()
|
||||||
|
|
||||||
|
# Removing the rigA rule re-disables the entries (live _event_rules read).
|
||||||
|
var empty_rules: Array[Dictionary] = []
|
||||||
|
stage._event_rules = empty_rules
|
||||||
|
stage._action_popup.popup(Rect2i(10, 10, 0, 0))
|
||||||
|
act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES)
|
||||||
|
_check(stage._action_popup.is_item_disabled(act_idx),
|
||||||
|
"action popup Edit Rules… re-disabled after the rigA rule is removed")
|
||||||
|
stage._action_popup.hide()
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 5a: sandbox_theme.json extended fonts block parses; defaults preserved.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_theme_keys_parsed_and_defaults() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- Theme parse: new keys honoured, absent keys keep defaults ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
|
||||||
|
var full_path := "user://bugfix_theme_full.json"
|
||||||
|
_write_theme(full_path, {
|
||||||
|
"fonts": {
|
||||||
|
"ui_font": "",
|
||||||
|
"emoji_font": "",
|
||||||
|
"ui_font_bold": "",
|
||||||
|
"ui_font_italic": "",
|
||||||
|
"action_popup_font_size": 30,
|
||||||
|
"action_popup_emoji_size": 19,
|
||||||
|
"queue_panel_font_size": 21,
|
||||||
|
"rule_panel_font_size": 22,
|
||||||
|
"action_editor_font_size": 23,
|
||||||
|
"rule_editor_font_size": 24,
|
||||||
|
"panel_row_font_size": 14,
|
||||||
|
"panel_title_font_size": 17,
|
||||||
|
"panel_title_bold": false,
|
||||||
|
"rule_label_bold": true,
|
||||||
|
"badge_bold": false,
|
||||||
|
"action_popup": { "size": 33, "bold": true, "italic": false },
|
||||||
|
},
|
||||||
|
"grid": { "snap_size": 35.0 },
|
||||||
|
})
|
||||||
|
stage._load_theme(full_path)
|
||||||
|
|
||||||
|
_check(stage._queue_panel_font_size == 21, "queue_panel_font_size parsed (got %d)" % stage._queue_panel_font_size)
|
||||||
|
_check(stage._rule_panel_font_size == 22, "rule_panel_font_size parsed (got %d)" % stage._rule_panel_font_size)
|
||||||
|
_check(stage._action_editor_font_size == 23, "action_editor_font_size parsed (got %d)" % stage._action_editor_font_size)
|
||||||
|
_check(stage._rule_editor_font_size == 24, "rule_editor_font_size parsed (got %d)" % stage._rule_editor_font_size)
|
||||||
|
_check(stage._panel_row_font_size == 14, "panel_row_font_size parsed (got %d)" % stage._panel_row_font_size)
|
||||||
|
_check(stage._panel_title_font_size == 17, "panel_title_font_size parsed (got %d)" % stage._panel_title_font_size)
|
||||||
|
_check(stage._panel_title_bold == false, "panel_title_bold parsed (got %s)" % str(stage._panel_title_bold))
|
||||||
|
_check(stage._rule_label_bold == true, "rule_label_bold parsed (got %s)" % str(stage._rule_label_bold))
|
||||||
|
_check(stage._badge_bold == false, "badge_bold parsed (got %s)" % str(stage._badge_bold))
|
||||||
|
# action_popup object form overrides the flat action_popup_font_size.
|
||||||
|
_check(stage._action_popup_font_size == 33,
|
||||||
|
"action_popup {size} overrides the flat key (got %d)" % stage._action_popup_font_size)
|
||||||
|
_check(stage._action_popup_bold == true, "action_popup {bold} parsed (got %s)" % str(stage._action_popup_bold))
|
||||||
|
_check(stage._action_popup_italic == false, "action_popup {italic} parsed (got %s)" % str(stage._action_popup_italic))
|
||||||
|
_check(stage._action_popup_emoji_size == 19,
|
||||||
|
"action_popup_emoji_size consumed from the theme (got %d)" % stage._action_popup_emoji_size)
|
||||||
|
_check(int(stage._font_sizes.get("queue_panel", -1)) == 21, "_font_sizes[queue_panel] matches")
|
||||||
|
_check(int(stage._font_sizes.get("action_editor", -1)) == 23, "_font_sizes[action_editor] matches")
|
||||||
|
_check(int(stage._font_sizes.get("panel_row", -1)) == 14, "_font_sizes[panel_row] matches")
|
||||||
|
_check(int(stage._font_sizes.get("panel_title", -1)) == 17, "_font_sizes[panel_title] matches")
|
||||||
|
_check(bool(stage._font_sizes.get("panel_title_bold", true)) == false, "_font_sizes[panel_title_bold] matches")
|
||||||
|
_check(stage._font_sizes.get("bold_font", null) == null, "_font_sizes[bold_font] null with no ui_font configured")
|
||||||
|
|
||||||
|
# Theme missing the new keys -> shipped defaults remain.
|
||||||
|
var bare_path := "user://bugfix_theme_bare.json"
|
||||||
|
_write_theme(bare_path, { "grid": { "snap_size": 10.0 } })
|
||||||
|
stage._load_theme(bare_path)
|
||||||
|
_check(stage._action_popup_font_size == 24, "absent action_popup_font_size keeps default 24 (got %d)" % stage._action_popup_font_size)
|
||||||
|
_check(stage._action_popup_emoji_size == 22, "absent action_popup_emoji_size keeps default 22 (got %d)" % stage._action_popup_emoji_size)
|
||||||
|
_check(stage._queue_panel_font_size == 18, "absent queue_panel_font_size keeps default 18 (got %d)" % stage._queue_panel_font_size)
|
||||||
|
_check(stage._rule_panel_font_size == 18, "absent rule_panel_font_size keeps default 18 (got %d)" % stage._rule_panel_font_size)
|
||||||
|
_check(stage._action_editor_font_size == 18, "absent action_editor_font_size keeps default 18 (got %d)" % stage._action_editor_font_size)
|
||||||
|
_check(stage._rule_editor_font_size == 18, "absent rule_editor_font_size keeps default 18 (got %d)" % stage._rule_editor_font_size)
|
||||||
|
_check(stage._panel_row_font_size == 16, "absent panel_row_font_size keeps default 16 (got %d)" % stage._panel_row_font_size)
|
||||||
|
_check(stage._panel_title_font_size == 18, "absent panel_title_font_size keeps default 18 (got %d)" % stage._panel_title_font_size)
|
||||||
|
_check(stage._panel_title_bold == true, "absent panel_title_bold keeps default true (got %s)" % str(stage._panel_title_bold))
|
||||||
|
_check(stage._rule_label_bold == false, "absent rule_label_bold keeps default false (got %s)" % str(stage._rule_label_bold))
|
||||||
|
_check(stage._badge_bold == true, "absent badge_bold keeps default true (got %s)" % str(stage._badge_bold))
|
||||||
|
_check(stage._action_popup_bold == false, "absent action_popup object form keeps default bold false (got %s)" % str(stage._action_popup_bold))
|
||||||
|
_check(stage._action_popup_italic == false, "absent action_popup object form keeps default italic false (got %s)" % str(stage._action_popup_italic))
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 5b: apply_font(...) tolerates null fonts and applies bold titles.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_apply_font_null_fonts_and_bold_title() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- apply_font: null fonts no-crash + bold title override ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
|
||||||
|
# The four Phase 3c widgets are already added by _build_ui; re-applying with
|
||||||
|
# null fonts + an empty sizes dict must be a safe no-op-ish walk.
|
||||||
|
stage._queue_panel.apply_font(null, null, {})
|
||||||
|
stage._rule_panel.apply_font(null, null, {})
|
||||||
|
stage._action_editor.apply_font(null, null, {})
|
||||||
|
stage._rule_editor.apply_font(null, null, {})
|
||||||
|
_check(true, "apply_font(null, null, {}) runs on all four widgets without error")
|
||||||
|
|
||||||
|
# With a ui font + bold font configured, the queue panel title label gets the
|
||||||
|
# bold font and the panel_title size; the body walks the base font/size.
|
||||||
|
var base_font := SystemFont.new()
|
||||||
|
var bold_font := SystemFont.new()
|
||||||
|
stage._queue_panel.apply_font(base_font, null, {
|
||||||
|
"queue_panel": 20,
|
||||||
|
"panel_row": 15,
|
||||||
|
"panel_title": 22,
|
||||||
|
"panel_title_bold": true,
|
||||||
|
"bold_font": bold_font,
|
||||||
|
})
|
||||||
|
_check(stage._queue_panel._title_label.get_theme_font("font") == bold_font,
|
||||||
|
"queue panel title uses the bold font when panel_title_bold is true")
|
||||||
|
_check(stage._queue_panel._title_label.get_theme_font_size("font_size") == 22,
|
||||||
|
"queue panel title uses the panel_title size (got %d)" % stage._queue_panel._title_label.get_theme_font_size("font_size"))
|
||||||
|
_check(stage._queue_panel._empty_label.get_theme_font_size("font_size") == 15,
|
||||||
|
"queue panel empty label uses the row size (got %d)" % stage._queue_panel._empty_label.get_theme_font_size("font_size"))
|
||||||
|
|
||||||
|
# With panel_title_bold false the title falls back to the ui font.
|
||||||
|
stage._queue_panel.apply_font(base_font, null, {
|
||||||
|
"queue_panel": 20,
|
||||||
|
"panel_row": 15,
|
||||||
|
"panel_title": 22,
|
||||||
|
"panel_title_bold": false,
|
||||||
|
"bold_font": bold_font,
|
||||||
|
})
|
||||||
|
_check(stage._queue_panel._title_label.get_theme_font("font") == base_font,
|
||||||
|
"queue panel title uses the ui font when panel_title_bold is false")
|
||||||
|
|
||||||
|
# apply_font must not break row creation: a panel refreshed afterwards still
|
||||||
|
# parents rows and applies the row size.
|
||||||
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
|
_check(rig != null, "rig spawns for apply_font row test")
|
||||||
|
if rig != null:
|
||||||
|
rig.queue_action({ "type": "wait", "duration": 1.0 })
|
||||||
|
stage._open_queue_panel(rig)
|
||||||
|
await process_frame
|
||||||
|
_check(stage._queue_panel._list.get_child_count() == 2,
|
||||||
|
"queue rows still build after apply_font (got %d)" % stage._queue_panel._list.get_child_count())
|
||||||
|
var row: PanelContainer = stage._queue_panel._rows[0]
|
||||||
|
# Row structure: PanelContainer -> HBoxContainer -> [number, summary, edit, remove, drag].
|
||||||
|
var row_hbox := row.get_child(0) as HBoxContainer
|
||||||
|
var summary_label := row_hbox.get_child(1) as Label
|
||||||
|
_check(summary_label.get_theme_font_size("font_size") == 15,
|
||||||
|
"queue row summary uses the row font size after apply_font (got %d)" % summary_label.get_theme_font_size("font_size"))
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 5c: action_popup_emoji_size is applied by _apply_popup_theme.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_popup_emoji_size_applied() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- action_popup_emoji_size applied by _apply_popup_theme ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
|
||||||
|
# Baseline: no fonts configured -> popup keeps the action_popup_font_size.
|
||||||
|
var no_font_path := "user://bugfix_theme_no_font.json"
|
||||||
|
_write_theme(no_font_path, { "fonts": { "action_popup_font_size": 24, "action_popup_emoji_size": 22 } })
|
||||||
|
stage._load_theme(no_font_path)
|
||||||
|
stage._apply_popup_theme(stage._action_popup)
|
||||||
|
_check(stage._action_popup.get_theme_font_size("font_size") == 24,
|
||||||
|
"popup keeps action_popup_font_size when no font is configured (got %d)" % stage._action_popup.get_theme_font_size("font_size"))
|
||||||
|
_check(not stage._action_popup.has_theme_font_override("font"),
|
||||||
|
"popup has no font override when no font is configured")
|
||||||
|
|
||||||
|
# With an emoji font configured the emoji size becomes the menu font size.
|
||||||
|
var emoji_font := SystemFont.new()
|
||||||
|
stage._emoji_font = emoji_font
|
||||||
|
stage._apply_popup_theme(stage._action_popup)
|
||||||
|
_check(stage._action_popup.has_theme_font_override("font"),
|
||||||
|
"popup uses the configured emoji font")
|
||||||
|
_check(stage._action_popup.get_theme_font_size("font_size") == 22,
|
||||||
|
"action_popup_emoji_size applied as the menu font size (got %d)" % stage._action_popup.get_theme_font_size("font_size"))
|
||||||
|
|
||||||
|
# A non-default emoji size from the theme also flows through.
|
||||||
|
var sized_path := "user://bugfix_theme_sized.json"
|
||||||
|
_write_theme(sized_path, { "fonts": { "action_popup_emoji_size": 19 } })
|
||||||
|
stage._load_theme(sized_path)
|
||||||
|
_check(stage._action_popup_emoji_size == 19,
|
||||||
|
"theme action_popup_emoji_size 19 parsed (got %d)" % stage._action_popup_emoji_size)
|
||||||
|
stage._emoji_font = emoji_font
|
||||||
|
stage._apply_popup_theme(stage._action_popup)
|
||||||
|
_check(stage._action_popup.get_theme_font_size("font_size") == 19,
|
||||||
|
"applied emoji size follows the parsed key (got %d)" % stage._action_popup.get_theme_font_size("font_size"))
|
||||||
|
|
||||||
|
# Style-variant bold flag: bold font wins over the emoji font.
|
||||||
|
var bold_font := SystemFont.new()
|
||||||
|
stage._action_popup_bold = true
|
||||||
|
stage._ui_font_bold = bold_font
|
||||||
|
stage._apply_popup_theme(stage._action_popup)
|
||||||
|
_check(stage._action_popup.get_theme_font("font") == bold_font,
|
||||||
|
"action_popup bold flag selects the bold font")
|
||||||
|
_check(stage._action_popup.get_theme_font_size("font_size") == 19,
|
||||||
|
"emoji size still applied when the bold font is active (got %d)" % stage._action_popup.get_theme_font_size("font_size"))
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bug 5d: stage_director_visuals.set_style() honours rule_label_bold / badge_bold.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _test_visuals_style_bold_flags() -> void:
|
||||||
|
print("")
|
||||||
|
print("--- StageDirectorVisuals set_style bold flags ---")
|
||||||
|
var stage := _new_stage()
|
||||||
|
var visuals = stage._director_visuals
|
||||||
|
_check(visuals != null, "stage owns a director visuals node")
|
||||||
|
|
||||||
|
var ui_font := SystemFont.new()
|
||||||
|
var bold_font := SystemFont.new()
|
||||||
|
var emoji_font := SystemFont.new()
|
||||||
|
visuals.ui_font = ui_font
|
||||||
|
visuals.bold_font = bold_font
|
||||||
|
visuals.emoji_font = emoji_font
|
||||||
|
|
||||||
|
# Defaults from an empty theme block: badge bold on, rule label not bold.
|
||||||
|
visuals.set_style({})
|
||||||
|
_check(visuals.rule_label_bold == false, "set_style default rule_label_bold false")
|
||||||
|
_check(visuals.badge_bold == true, "set_style default badge_bold true")
|
||||||
|
_check(visuals._rule_label_font() == ui_font,
|
||||||
|
"rule label uses ui_font when rule_label_bold is off")
|
||||||
|
_check(visuals._badge_font() == bold_font,
|
||||||
|
"badge uses the bold font when badge_bold is on")
|
||||||
|
|
||||||
|
# Bold rule labels + non-bold badges.
|
||||||
|
visuals.set_style({ "fonts": { "rule_label_bold": true, "badge_bold": false } })
|
||||||
|
_check(visuals.rule_label_bold == true, "set_style honours rule_label_bold true")
|
||||||
|
_check(visuals.badge_bold == false, "set_style honours badge_bold false")
|
||||||
|
_check(visuals._rule_label_font() == bold_font,
|
||||||
|
"rule label uses the bold font when rule_label_bold is on")
|
||||||
|
_check(visuals._badge_font() == emoji_font,
|
||||||
|
"badge falls back to the emoji font when badge_bold is off")
|
||||||
|
|
||||||
|
# Back off both: rule label returns to ui_font.
|
||||||
|
visuals.set_style({ "fonts": { "rule_label_bold": false, "badge_bold": false } })
|
||||||
|
_check(visuals._rule_label_font() == ui_font,
|
||||||
|
"rule label returns to ui_font when rule_label_bold toggles off")
|
||||||
|
_check(visuals._badge_font() == emoji_font, "badge stays on the emoji font with badge_bold off")
|
||||||
|
|
||||||
|
# Bold fallback when no bold font is configured: uses ui/emoji fonts.
|
||||||
|
visuals.bold_font = null
|
||||||
|
visuals.set_style({ "fonts": { "rule_label_bold": true, "badge_bold": true } })
|
||||||
|
_check(visuals._rule_label_font() == ui_font,
|
||||||
|
"rule label falls back to ui_font when bold_font is null")
|
||||||
|
_check(visuals._badge_font() == emoji_font,
|
||||||
|
"badge falls back to the emoji font when bold_font is null")
|
||||||
|
|
||||||
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _new_stage() -> Node2D:
|
||||||
|
var stage: Node2D = STAGE_SCENE.instantiate()
|
||||||
|
root.add_child(stage)
|
||||||
|
stage._snap_enabled = false
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
func _free_stage(stage: Node2D) -> void:
|
||||||
|
stage.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _write_theme(path: String, data: Dictionary) -> void:
|
||||||
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if file != null:
|
||||||
|
file.store_string(JSON.stringify(data, " ", false))
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if condition:
|
||||||
|
print("PASS: " + message)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
print("FAIL: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://mywqhvs3nwcv
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
uid://p0nqaul4t60h
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
uid://caj02fsf3uuf4
|
||||||
@@ -31,7 +31,9 @@
|
|||||||
# * `_cancel_rule_build()` clears the anchor.
|
# * `_cancel_rule_build()` clears the anchor.
|
||||||
# * `_finalize_rule()` clears the anchor.
|
# * `_finalize_rule()` clears the anchor.
|
||||||
# * `_clear_director_pending()` (mode exit) clears the anchor.
|
# * `_clear_director_pending()` (mode exit) clears the anchor.
|
||||||
# * `_begin_edit_rule(id)` records a fresh anchor.
|
# * `_begin_edit_rule(id)` (Phase 3c) opens the consequence-only RuleEditor
|
||||||
|
# for the rule (the old Phase 4b in-place builder flow was replaced by the
|
||||||
|
# Phase 3c editor tools).
|
||||||
# * `_reset_rule_builder()` does NOT clear the anchor (TRIG_BACK invariant).
|
# * `_reset_rule_builder()` does NOT clear the anchor (TRIG_BACK invariant).
|
||||||
#
|
#
|
||||||
# Run with:
|
# Run with:
|
||||||
@@ -71,7 +73,7 @@ func _run() -> void:
|
|||||||
_test_cancel_clears_anchor()
|
_test_cancel_clears_anchor()
|
||||||
_test_confirm_clears_anchor()
|
_test_confirm_clears_anchor()
|
||||||
_test_mode_exit_clears_anchor()
|
_test_mode_exit_clears_anchor()
|
||||||
await _test_edit_rule_records_fresh_anchor()
|
await _test_edit_rule_opens_consequence_editor()
|
||||||
_test_reset_preserves_anchor()
|
_test_reset_preserves_anchor()
|
||||||
|
|
||||||
print("--------------------------------------------------------")
|
print("--------------------------------------------------------")
|
||||||
@@ -369,9 +371,9 @@ func _test_mode_exit_clears_anchor() -> void:
|
|||||||
# Edit-rule entry records a fresh anchor
|
# Edit-rule entry records a fresh anchor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _test_edit_rule_records_fresh_anchor() -> void:
|
func _test_edit_rule_opens_consequence_editor() -> void:
|
||||||
print("")
|
print("")
|
||||||
print("--- _begin_edit_rule() records a fresh anchor ---")
|
print("--- _begin_edit_rule() opens the consequence-only rule editor (Phase 3c) ---")
|
||||||
var stage := _new_stage()
|
var stage := _new_stage()
|
||||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||||
_check(rig != null, "stickman rig spawns for the edit-rule test")
|
_check(rig != null, "stickman rig spawns for the edit-rule test")
|
||||||
@@ -396,23 +398,19 @@ func _test_edit_rule_records_fresh_anchor() -> void:
|
|||||||
var rules_arr: Array[Dictionary] = [stored_rule]
|
var rules_arr: Array[Dictionary] = [stored_rule]
|
||||||
stage._event_rules = rules_arr
|
stage._event_rules = rules_arr
|
||||||
|
|
||||||
# A stale anchor from a previous session must be replaced, not reused.
|
|
||||||
stage._set_popup_anchor(Rect2i(1, 2, 0, 0))
|
|
||||||
stage._begin_edit_rule(0)
|
stage._begin_edit_rule(0)
|
||||||
var mouse_rect: Rect2i = stage._mouse_popup_rect()
|
_check(stage._rule_editor != null, "rule editor exists on the stage")
|
||||||
_check(stage._popup_anchor_set,
|
_check(stage._rule_editor.visible, "edit-rule entry opens the RuleEditor popup")
|
||||||
"edit-rule entry records the anchor (set flag on)")
|
_check(stage._rule_editor._mode == "consequence",
|
||||||
_check(stage._popup_anchor == mouse_rect,
|
"edit-rule entry uses consequence-only mode (got '%s')" % stage._rule_editor._mode)
|
||||||
"edit-rule entry records a FRESH mouse rect (got %s, mouse %s)"
|
_check(stage._rule_editor._rule_id == 0,
|
||||||
% [str(stage._popup_anchor), str(mouse_rect)])
|
"edit-rule entry preserves the rule id (got %d)" % stage._rule_editor._rule_id)
|
||||||
_check(stage._rule_editing_id == 0,
|
_check(stage._rule_editor._actions.size() == 1,
|
||||||
"edit-rule entry arms _rule_editing_id (got %d)" % stage._rule_editing_id)
|
"edit-rule entry loads the rule's actions")
|
||||||
_check(int(stage._rule_step) == 3,
|
_check(stage._rule_editor._trigger_readonly_label.visible,
|
||||||
"edit-rule entry sits at SELECT_ACTION (got %d)" % int(stage._rule_step))
|
"consequence editor shows the trigger as read-only")
|
||||||
_check(stage._rule_context_rig == rig,
|
_check(stage._rule_editor._trigger_readonly_label.text.contains("Completes any action"),
|
||||||
"edit-rule entry resolves the rule source rig")
|
"consequence editor summarizes the trigger (got '%s')" % stage._rule_editor._trigger_readonly_label.text)
|
||||||
_check(stage._rule_action_popup.visible,
|
|
||||||
"edit-rule entry opens the rule-action popup")
|
|
||||||
|
|
||||||
await _free_stage(stage)
|
await _free_stage(stage)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user