Add unique identifier for test_phase3c_walk_recovery.gd

This commit is contained in:
2026-09-06 13:09:37 -04:00
parent 1f91f3d2e5
commit 80595a0273
53 changed files with 6806 additions and 471 deletions
+163
View File
@@ -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 35 in a single response. Always stop after presenting questions or choices and wait for the users 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 23 `code-explorer` sub-tasks in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
- Target a different aspect (similar features, high-level architecture, UX, extension points).
- Return a list of 510 key files to read.
2. After they return, read every file they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns to the user.
## Phase 3: Clarifying questions
**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 23 approaches with tradeoffs.
- 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
```
+102
View File
@@ -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
```
+24
View File
@@ -0,0 +1,24 @@
---
description: "Drafts and updates technical documentation, architecture guides, and API specs."
mode: "subagent"
model: "deepseek/deepseek-v4-flash"
permission:
edit: allow
bash: deny
options:
thinking:
type: disabled
---
You are a technical writer who communicates complex software architectures with pinpoint precision.
### Deliverables
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
### Style Guide
1. Keep prose technical, precise, and highly scannable.
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
+25
View File
@@ -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
View File
@@ -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 35 in a single response. Always stop after presenting questions or choices and wait for the users 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 23 `code-explorer` sub-tasks in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
- Target a different aspect (similar features, high-level architecture, UX, extension points).
- Return a list of 510 key files to read.
2. After they return, read every file they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns to the user.
## Phase 3: Clarifying questions
**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 23 approaches with tradeoffs.
- 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
```
+11 -87
View File
@@ -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
```
+8 -8
View File
@@ -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.
+15 -17
View File
@@ -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.
+46 -9
View File
@@ -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()`).
+31
View File
@@ -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.
+90 -15
View File
@@ -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 46 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 46 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.110 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.110 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`.
+31
View File
@@ -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):
+7 -6
View File
@@ -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).
+700
View File
@@ -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.13600 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.
+6
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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")
+6
View File
@@ -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")
+6
View File
@@ -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")
+6
View File
@@ -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")
+252
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://d3nsgtnt1cvh5
+133
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cncpyuy6cymwp
+5 -11
View File
@@ -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)],
+3
View File
@@ -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)
+286
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bkkscjlbhfmdq
+397
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://8hgf3j8xc6tr
+321
View File
@@ -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 "?"
+1
View File
@@ -0,0 +1 @@
uid://dmiropsijs8g2
+897 -32
View File
File diff suppressed because it is too large Load Diff
+72 -9
View File
@@ -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
+158 -22
View File
@@ -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:
@@ -814,7 +950,7 @@ func _get_ik_marker(name: String) -> Marker2D:
## (sine ease-in-out). No baked animation — a fixed first keyframe can never ## (sine ease-in-out). No baked animation — a fixed first keyframe can never
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the ## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
## snap left the markers. ## snap left the markers.
func _play_stand_up() -> void: func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION) _stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
if _stand_up_tween != null: if _stand_up_tween != null:
_stand_up_tween.finished.connect(_on_stand_up_finished) _stand_up_tween.finished.connect(_on_stand_up_finished)
@@ -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
+71
View File
@@ -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)]
+1
View File
@@ -0,0 +1 @@
uid://cv1ng0wekc4gc
+35
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://chrtaky4j2w25
+620
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://mywqhvs3nwcv
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://p0nqaul4t60h
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://caj02fsf3uuf4
+18 -20
View File
@@ -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)