Implement Phase 17a Prop Roles UI/UX Specification and associated functionality
- Added UI/UX specification for prop roles and set down mechanics in phase 17a. - Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings. - Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors. - Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior. - Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
@@ -13,6 +13,15 @@ You are the Lead Systems Architect specializing in Godot 4 and idiomatic GDScrip
|
|||||||
3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows.
|
3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows.
|
||||||
4. **Log:** Append trade-offs to `docs/architecture/tech_debt_and_optimizations.md`.
|
4. **Log:** Append trade-offs to `docs/architecture/tech_debt_and_optimizations.md`.
|
||||||
|
|
||||||
|
### Shared Stage inspector UI
|
||||||
|
For an in-stage selected-object inspector, first read `docs/specs/stage_panel_ui_system.md`
|
||||||
|
and inspect `scripts/shared/stage_ui/` plus the `stage_panels` theme tokens. Use the existing
|
||||||
|
`StageInspectorPanel`, `StagePanelChoiceGroup`, and `StagePanelToggleRow` contracts as the
|
||||||
|
starting point; propose extending them before adding duplicate panel controls or styling.
|
||||||
|
State explicitly in the spec which shared pieces the feature adopts or extends. The kit is
|
||||||
|
currently scoped to in-stage inspector cards, so evaluate dialogs and other editor workflows
|
||||||
|
on their own interaction needs instead of forcing this component hierarchy onto them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TWO-PHASE WORKFLOW
|
## TWO-PHASE WORKFLOW
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ sandbox_mode = "read-only"
|
|||||||
developer_instructions = """
|
developer_instructions = """
|
||||||
## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
|
## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
|
||||||
|
|
||||||
|
* **Shared Stage Inspector UI:** Before proposing an in-stage selected-object inspector, read
|
||||||
|
`docs/specs/stage_panel_ui_system.md` and inspect the existing controls in
|
||||||
|
`scripts/shared/stage_ui/`. Treat the shared shell, choice group, toggle row, and
|
||||||
|
`stage_panels` theme tokens as the default visual and interaction system. Prefer extending
|
||||||
|
them over inventing a parallel panel style. The system is scoped to in-stage inspectors;
|
||||||
|
for dialogs or other editor workflows, reuse its visual principles only when their layout
|
||||||
|
and interaction model fit, and call out any exception.
|
||||||
|
|
||||||
* **Touch & Click Target Rules:**
|
* **Touch & Click Target Rules:**
|
||||||
* Minimum pointer hit-target: **44×44 logical pixels** (never build tiny 16px/24px buttons).
|
* Minimum pointer hit-target: **44×44 logical pixels** (never build tiny 16px/24px buttons).
|
||||||
* Control heights (buttons, dropdowns, rows) must obey `button_min_height` tokens from the theme.
|
* Control heights (buttons, dropdowns, rows) must obey `button_min_height` tokens from the theme.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: feature-ideation
|
||||||
|
description: "Review possible Stickman Studio features with the Designer, compare value and risks, then help a chosen idea become a saved design brief."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Feature Ideation
|
||||||
|
|
||||||
|
Use this skill when the user wants to compare, discuss, or plan one or more future Stickman Studio features. Keep the work at the product/design level. The skill has two distinct stages: review possible features and stop for the user's choice; later, after discussion and the user's readiness, have Designer create a phase brief and report its path.
|
||||||
|
|
||||||
|
## Delegate to Designer
|
||||||
|
|
||||||
|
Use the `designer` subagent for feature evaluation and design discussion. Spawn it with `agent_type: "designer"` and `model: "gpt-6-sol"`; that role is configured for GPT-6-sol. Pass the user's goal and candidate features in the first message, along with the relevant project context, so Designer can begin from the stated goal. Reuse the same designer agent during follow-up turns when available. If it is no longer available, spawn another with a concise summary of the candidates, decisions, and unresolved questions.
|
||||||
|
|
||||||
|
Have Designer inspect relevant current project docs and code before judging fit, dependencies, or likely implementation issues. Prefer maintained docs and current code; treat archived roadmap material as history, not a priority list. Do not invent certainty where details are unknown: label assumptions and open questions.
|
||||||
|
|
||||||
|
When the discussion involves a new in-stage selected-object inspector, have Designer review
|
||||||
|
`docs/specs/stage_panel_ui_system.md` and the current shared controls in
|
||||||
|
`scripts/shared/stage_ui/`. Treat them as the established panel design to extend where it fits;
|
||||||
|
do not assume they apply unchanged to dialogs or other editor workflows.
|
||||||
|
|
||||||
|
## Stage 1: Compare ideas, then stop
|
||||||
|
|
||||||
|
When the user provides one or more candidate features:
|
||||||
|
|
||||||
|
1. Identify the child-facing outcome and the project's current behavior relevant to each feature. Ask the user for more input only if there is no usable feature goal; otherwise let Designer state assumptions and uncertainties in the review.
|
||||||
|
2. Ask Designer to assess each candidate's user value, fit with the project direction, relevant dependencies, likely product/design/technical issues, and relative priority. Ask for a clear recommendation on what to discuss first, with reasons. Avoid treating priority as a release commitment.
|
||||||
|
3. Ask Designer for one to three additional feature ideas that fit the project, with a short rationale and any important dependency or risk.
|
||||||
|
4. Keep `docs/ideas/feature_backlog.md` current as ideas become clear: add or update substantive user candidates and Designer-suggested ideas, preserving existing entries, rationale, links, and priorities that remain valid. Use its existing status and entry conventions. Do not duplicate an existing idea, promote an idea to Verified without evidence, or import the archived roadmap wholesale.
|
||||||
|
5. Present a compact comparison, normally a table with feature, expected child outcome/value, fit/dependencies, likely issues, and suggested priority. Then show the recommended next feature and the additional ideas. Link the backlog if it changed.
|
||||||
|
6. End with the question **“Which feature would you like to discuss further?”** and stop. Do not start the deeper discussion, write a phase plan, invoke the architect/developer, or begin implementation in this turn.
|
||||||
|
|
||||||
|
## Stage 2: Discuss the chosen feature
|
||||||
|
|
||||||
|
After the user chooses a feature, use Designer as a product-design partner to refine the goal, intended audience, child flow and feedback, scope, success criteria, risks, and relevant project references. Keep the discussion conversational; ask only focused questions that materially affect the idea. Reflect the user's answers in the discussion and keep its backlog entry marked `Exploring` until a brief is accepted. Do not write a phase plan merely because the user chose the idea or asked to talk about it.
|
||||||
|
|
||||||
|
Treat the user's clear statement that they are ready to work on the discussed, sufficiently settled feature as acceptance to prepare a Designer handoff. If scope or a key product decision is still unresolved, ask Designer to identify it and resolve it with the user before drafting. If the user has not supplied the phase number, ask exactly **“What phase number will this be?”** and wait. If the user already supplied it, use that number without asking again.
|
||||||
|
|
||||||
|
## Stage 3: Write and hand off the brief
|
||||||
|
|
||||||
|
Once the user is ready and the phase number is known, have Designer create the accepted product/design brief at `docs/plans/phase_<number>_<feature_slug>.md`, following `docs/plans/README.md`. The brief should cover the goal and audience, desired child outcome, behavior/flow and feedback, story or directing value where relevant, success criteria, included and excluded scope, known risks/dependencies, project references, and remaining questions for the spec-pipeline. Keep architecture/API decisions for the architect's later technical specification in `docs/specs/`.
|
||||||
|
|
||||||
|
Do not overwrite an existing phase brief without the user's direction. Confirm the resulting file exists, update the matching backlog entry to `Planned` with a link to the brief, then tell the user where the plan is using a clickable local file link. Stop there; this skill creates the design handoff, not the technical spec or implementation.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Feature Ideation"
|
||||||
|
short_description: "Help with Feature Ideation tasks"
|
||||||
@@ -7,6 +7,7 @@ description: "Lean implementation loop: UI/UX (Ideas -> Spec) -> Architect (Clar
|
|||||||
|
|
||||||
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
|
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
|
||||||
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
|
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
|
||||||
|
- For an in-stage selected-object inspector, `@uiux` first reviews `docs/specs/stage_panel_ui_system.md` and the current shared controls under `scripts/shared/stage_ui/`; proposals should build on that system unless the interaction needs justify a specific exception.
|
||||||
- `@uiux` pitches **1–3 alternative interaction approaches** (e.g., comparing floating tooltips vs. top-center banners, custom cursor reticles, or streamlined dialogs).
|
- `@uiux` pitches **1–3 alternative interaction approaches** (e.g., comparing floating tooltips vs. top-center banners, custom cursor reticles, or streamlined dialogs).
|
||||||
- `@uiux` highlights the most intuitive option with `[Recommended]` and asks up to 3 clarification questions.
|
- `@uiux` highlights the most intuitive option with `[Recommended]` and asks up to 3 clarification questions.
|
||||||
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
|
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
|
||||||
@@ -15,9 +16,11 @@ description: "Lean implementation loop: UI/UX (Ideas -> Spec) -> Architect (Clar
|
|||||||
2. **Phase 0b: UI Spec Generation**
|
2. **Phase 0b: UI Spec Generation**
|
||||||
- User approves an option or provides feedback.
|
- User approves an option or provides feedback.
|
||||||
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic).
|
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic).
|
||||||
|
- For Stage inspector work, name which shared components and theme tokens the design uses or proposes to extend. Treat dialogs and other editor panels as separate workflows unless their interaction model fits the inspector kit.
|
||||||
|
|
||||||
3. **Phase 1a: Architecture Proposal & Clarification**
|
3. **Phase 1a: Architecture Proposal & Clarification**
|
||||||
- `@architect` analyzes existing patterns and the Phase 0b UI/UX specification.
|
- `@architect` analyzes existing patterns and the Phase 0b UI/UX specification.
|
||||||
|
- For Stage inspector work, `@architect` inspects the shared panel implementation and theme as well as the design spec, and identifies reuse or justified extension before proposing new controls.
|
||||||
- `@architect` pitches 1–3 Godot best-practice implementation options and asks up to 3 clarification questions with `[Recommended]` choices.
|
- `@architect` pitches 1–3 Godot best-practice implementation options and asks up to 3 clarification questions with `[Recommended]` choices.
|
||||||
- `@architect` stops without writing the spec file yet.
|
- `@architect` stops without writing the spec file yet.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ authoring counterpart to the stickman editor — for creating, editing, and savi
|
|||||||
vector prop assets. It is launched via **F6** on `res://scenes/editors/prop/prop_editor.tscn` and is **not**
|
vector prop assets. It is launched via **F6** on `res://scenes/editors/prop/prop_editor.tscn` and is **not**
|
||||||
the configured `run/main_scene`.
|
the configured `run/main_scene`.
|
||||||
|
|
||||||
|
## Shared Stage inspector UI
|
||||||
|
|
||||||
|
- For new **in-stage selected-object inspectors**, use the approved
|
||||||
|
[Stage panel UI system spec](docs/specs/stage_panel_ui_system.md) as the design and
|
||||||
|
implementation reference. The shared controls live in `scripts/shared/stage_ui/`:
|
||||||
|
`StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePanelToggleRow`, and
|
||||||
|
`StagePanelSwitchVisual`; shared visual tokens live under `stage_panels` in
|
||||||
|
`SandboxTheme` (`scripts/stage/sandbox_theme.gd` and `sandbox_theme.json`). Extend these
|
||||||
|
components and tokens before creating parallel panel styling or behavior.
|
||||||
|
- This kit currently targets in-stage selection/inspector cards. For modal dialogs, editor
|
||||||
|
inspectors, or other distinct UI workflows, review the spec for reusable visual rules but
|
||||||
|
decide whether the components' layout and interaction model fit before adopting them.
|
||||||
|
|
||||||
## Required addon
|
## Required addon
|
||||||
|
|
||||||
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
|
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
|||||||
|
{
|
||||||
|
"version": "1.2",
|
||||||
|
"scene_name": "Untitled",
|
||||||
|
"camera": {
|
||||||
|
"position": {
|
||||||
|
"x": -5.46832084655762,
|
||||||
|
"y": -891.389526367188
|
||||||
|
},
|
||||||
|
"zoom": 0.500000059604645
|
||||||
|
},
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"file_id": "terrain_1",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1536.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_2",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1344.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_3",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1152.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_4",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -960.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_5",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -768.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_6",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -576.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_7",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -384.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_8",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -192.0,
|
||||||
|
"y": -192.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_9",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 0.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_10",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 192.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_11",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 384.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_12",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 576.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_13",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 768.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_14",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 960.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_15",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 1152.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_16",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 1344.0,
|
||||||
|
"y": -224.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "stickman_1",
|
||||||
|
"type": "stickman",
|
||||||
|
"position": {
|
||||||
|
"x": -1152.0,
|
||||||
|
"y": -600.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"stk_path": "res://content/stickmen/break.stk",
|
||||||
|
"facing_profile": "FORWARD",
|
||||||
|
"action_queue": [
|
||||||
|
{
|
||||||
|
"type": "pick_up_prop",
|
||||||
|
"target_prop_id": "prop_38150657_356201114_1",
|
||||||
|
"hand": "auto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": -1352.0,
|
||||||
|
"y": -246.0
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "set_down_prop",
|
||||||
|
"target_position": {
|
||||||
|
"x": 324.0,
|
||||||
|
"y": -552.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": -1452.0,
|
||||||
|
"y": -276.0
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "speak",
|
||||||
|
"text": "I Don't feel good",
|
||||||
|
"duration": 1.8,
|
||||||
|
"stop_current": false,
|
||||||
|
"keep_acting": false,
|
||||||
|
"arrangement": "together",
|
||||||
|
"bubble_layout": "stack",
|
||||||
|
"connect_bubbles": false,
|
||||||
|
"reveal": "instant",
|
||||||
|
"pop_in": false,
|
||||||
|
"duration_mode": "just_enough",
|
||||||
|
"bubbles": [
|
||||||
|
{
|
||||||
|
"text": "I Don't feel good",
|
||||||
|
"visual": {
|
||||||
|
"bubble_style": "talk",
|
||||||
|
"font_preset": "comic",
|
||||||
|
"size_preset": "medium",
|
||||||
|
"color": "#202020",
|
||||||
|
"effect": "none",
|
||||||
|
"reveal": "instant",
|
||||||
|
"pop_in": false,
|
||||||
|
"word_styles": [
|
||||||
|
{
|
||||||
|
"start": 8,
|
||||||
|
"end": 12,
|
||||||
|
"effect": "wave",
|
||||||
|
"color": "#e53935"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placement": {
|
||||||
|
"mode": "manual",
|
||||||
|
"center": {
|
||||||
|
"x": -19.8551330566406,
|
||||||
|
"y": -681.649047851563
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ragdoll"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_1",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": 324.0,
|
||||||
|
"y": -684.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "res://content/props/championship_belt.prop",
|
||||||
|
"stage_object_id": "prop_38150657_356201114_1",
|
||||||
|
"roles": {
|
||||||
|
"depth": "behind",
|
||||||
|
"start_motion": "physics",
|
||||||
|
"holds_other_props": false,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": true,
|
||||||
|
"released_blocks_stickmen": false,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
},
|
||||||
|
"material_preset": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_2",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": 288.0,
|
||||||
|
"y": -408.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "res://content/props/end_table.prop",
|
||||||
|
"stage_object_id": "prop_43861651_287430243_2",
|
||||||
|
"roles": {
|
||||||
|
"depth": "behind",
|
||||||
|
"start_motion": "physics",
|
||||||
|
"holds_other_props": true,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": false,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
},
|
||||||
|
"material_preset": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_3",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": 288.0,
|
||||||
|
"y": -1236.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "res://content/props/flower_pot.prop",
|
||||||
|
"stage_object_id": "prop_250061258_3458839433_3",
|
||||||
|
"roles": {
|
||||||
|
"depth": "behind",
|
||||||
|
"start_motion": "physics",
|
||||||
|
"holds_other_props": false,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": true,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
},
|
||||||
|
"material_preset": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "stickman_2",
|
||||||
|
"type": "stickman",
|
||||||
|
"position": {
|
||||||
|
"x": 888.0,
|
||||||
|
"y": -636.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"stk_path": "res://content/stickmen/test.stk",
|
||||||
|
"facing_profile": "FORWARD",
|
||||||
|
"action_queue": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_rules": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"trigger": {
|
||||||
|
"type": "prop_picked_up",
|
||||||
|
"source_id": "stickman_1",
|
||||||
|
"target_id": "prop_1",
|
||||||
|
"params": {}
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "speak",
|
||||||
|
"target_id": "stickman_2",
|
||||||
|
"params": {
|
||||||
|
"text": "It's JOHN CENA",
|
||||||
|
"duration": 1.6,
|
||||||
|
"stop_current": false,
|
||||||
|
"keep_acting": true,
|
||||||
|
"arrangement": "together",
|
||||||
|
"bubble_layout": "stack",
|
||||||
|
"connect_bubbles": true,
|
||||||
|
"reveal": "instant",
|
||||||
|
"pop_in": false,
|
||||||
|
"duration_mode": "just_enough",
|
||||||
|
"bubbles": [
|
||||||
|
{
|
||||||
|
"text": "It's JOHN CENA",
|
||||||
|
"visual": {
|
||||||
|
"bubble_style": "talk",
|
||||||
|
"font_preset": "scary",
|
||||||
|
"size_preset": "huge",
|
||||||
|
"color": "#202020",
|
||||||
|
"effect": "shake",
|
||||||
|
"reveal": "instant",
|
||||||
|
"pop_in": false,
|
||||||
|
"word_styles": []
|
||||||
|
},
|
||||||
|
"placement": {
|
||||||
|
"mode": "manual",
|
||||||
|
"center": {
|
||||||
|
"x": -17.8550415039063,
|
||||||
|
"y": -674.849975585938
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "I feel sick",
|
||||||
|
"visual": {
|
||||||
|
"bubble_style": "talk",
|
||||||
|
"font_preset": "scary",
|
||||||
|
"size_preset": "huge",
|
||||||
|
"color": "#202020",
|
||||||
|
"effect": "shake",
|
||||||
|
"reveal": "instant",
|
||||||
|
"pop_in": false,
|
||||||
|
"word_styles": [
|
||||||
|
{
|
||||||
|
"start": 7,
|
||||||
|
"end": 11,
|
||||||
|
"effect": "wave"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placement": {
|
||||||
|
"mode": "manual",
|
||||||
|
"center": {
|
||||||
|
"x": 7.14495849609375,
|
||||||
|
"y": -841.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ragdoll",
|
||||||
|
"target_id": "stickman_2",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"created_unix": 1791598662.56,
|
||||||
|
"modified_unix": 1791598662.56,
|
||||||
|
"autosave": false,
|
||||||
|
"source_path": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
{
|
||||||
|
"version": "1.2",
|
||||||
|
"scene_name": "Untitled",
|
||||||
|
"camera": {
|
||||||
|
"position": {
|
||||||
|
"x": -795.213195800781,
|
||||||
|
"y": -804.61474609375
|
||||||
|
},
|
||||||
|
"zoom": 0.550000071525574
|
||||||
|
},
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"file_id": "terrain_1",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1344.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_2",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -1152.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_3",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -960.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_4",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -768.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_5",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -576.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_6",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -384.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_7",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": -192.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_8",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 0.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_9",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 192.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_10",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 384.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_11",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 576.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_12",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 768.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "terrain_13",
|
||||||
|
"type": "terrain",
|
||||||
|
"position": {
|
||||||
|
"x": 960.0,
|
||||||
|
"y": -160.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "ground",
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "stickman_1",
|
||||||
|
"type": "stickman",
|
||||||
|
"position": {
|
||||||
|
"x": -1140.0,
|
||||||
|
"y": -564.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"stk_path": "res://content/stickmen/break.stk",
|
||||||
|
"facing_profile": "FORWARD",
|
||||||
|
"action_queue": [
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": -491.576904296875,
|
||||||
|
"y": -206.43310546875
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "pick_up_prop",
|
||||||
|
"target_prop_id": "prop_87555809_220550165_4",
|
||||||
|
"hand": "auto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": -878.849609375,
|
||||||
|
"y": -197.342041015625
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "set_down_prop",
|
||||||
|
"target_position": {
|
||||||
|
"x": -156.0,
|
||||||
|
"y": -516.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": -1157.03149414063,
|
||||||
|
"y": -200.978515625
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "move_to",
|
||||||
|
"target": {
|
||||||
|
"x": 735.69580078125,
|
||||||
|
"y": -200.978515625
|
||||||
|
},
|
||||||
|
"style": "run"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_1",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": -156.0,
|
||||||
|
"y": -1512.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "res://content/props/briefcase.prop",
|
||||||
|
"stage_object_id": "prop_87555809_220550165_4",
|
||||||
|
"roles": {
|
||||||
|
"depth": "front",
|
||||||
|
"start_motion": "physics",
|
||||||
|
"holds_other_props": false,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": true,
|
||||||
|
"released_blocks_stickmen": false,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 1.0
|
||||||
|
},
|
||||||
|
"material_preset": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_id": "prop_2",
|
||||||
|
"type": "prop",
|
||||||
|
"position": {
|
||||||
|
"x": -168.0,
|
||||||
|
"y": -324.0
|
||||||
|
},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"asset_id": "crate",
|
||||||
|
"stage_object_id": "prop_1055778436_3931179398_5",
|
||||||
|
"roles": {
|
||||||
|
"depth": "front",
|
||||||
|
"start_motion": "still",
|
||||||
|
"holds_other_props": true,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": false,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"x": 4.12768173217773,
|
||||||
|
"y": 4.12768173217773
|
||||||
|
},
|
||||||
|
"material_preset": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_rules": [],
|
||||||
|
"metadata": {
|
||||||
|
"created_unix": 1791342771,
|
||||||
|
"modified_unix": 1791471934.192,
|
||||||
|
"autosave": false,
|
||||||
|
"source_path": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 59 | **Stage-only speech edits remain drafts until session completion** — a recoverable draft can differ from its queue/rule target while the child is styling. | Medium | Planned | Done speech is the single validated in-memory commit boundary. A Scene Save request during composition is deferred until the user explicitly completes speech; it never commits implicitly or writes the stale queue/rule target. Cancel speech confirms before discarding an edited draft. This avoids premature persistence of blank bubbles and stale scene data. Recovery remains draft-only. See `docs/specs/speech_stage_only_editor.md`. (2026-09-30) |
|
| 59 | **Stage-only speech edits remain drafts until session completion** — a recoverable draft can differ from its queue/rule target while the child is styling. | Medium | Planned | Done speech is the single validated in-memory commit boundary. A Scene Save request during composition is deferred until the user explicitly completes speech; it never commits implicitly or writes the stale queue/rule target. Cancel speech confirms before discarding an edited draft. This avoids premature persistence of blank bubbles and stale scene data. Recovery remains draft-only. See `docs/specs/speech_stage_only_editor.md`. (2026-09-30) |
|
||||||
| 60 | **Run contact timing and baked IK poses share authored gait facts** — clip duration/contact phases must agree with locomotion's stance weights and fixed travel pace. | Medium | Planned | Phase 16 keeps one locomotion controller and an authored Run clip. Tune the speed, cycle duration, foot travel, and contact windows together; flight windows must release both foot locks. The generator and runtime each need phase data, so a future data-driven gait asset may be useful if more styles are added. See `docs/specs/phase_16_running_stickman.md`. (2026-10-03) |
|
| 60 | **Run contact timing and baked IK poses share authored gait facts** — clip duration/contact phases must agree with locomotion's stance weights and fixed travel pace. | Medium | Planned | Phase 16 keeps one locomotion controller and an authored Run clip. Tune the speed, cycle duration, foot travel, and contact windows together; flight windows must release both foot locks. The generator and runtime each need phase data, so a future data-driven gait asset may be useful if more styles are added. See `docs/specs/phase_16_running_stickman.md`. (2026-10-03) |
|
||||||
| 61 | **Move To migration crosses action and trigger identities** — an old `walk_to` completion filter can silently stop matching after an action becomes `move_to`. | High | Planned | Phase 16 canonicalizes old queue/rule movement actions and `action_finished.params.action_type` filters before validation, while preserving actor IDs, waypoint positions, action order, and rule IDs. Verify old scenes at the boundary and retain the `StickmanRig.walk_to` scripting facade. See `docs/specs/phase_16_running_stickman.md`. (2026-10-03) |
|
| 61 | **Move To migration crosses action and trigger identities** — an old `walk_to` completion filter can silently stop matching after an action becomes `move_to`. | High | Planned | Phase 16 canonicalizes old queue/rule movement actions and `action_finished.params.action_type` filters before validation, while preserving actor IDs, waypoint positions, action order, and rule IDs. Verify old scenes at the boundary and retain the `StickmanRig.walk_to` scripting facade. See `docs/specs/phase_16_running_stickman.md`. (2026-10-03) |
|
||||||
|
| 62 | **Grounded movement currently writes the stickman root transform directly** — limb proxy contacts alone cannot prevent walking through a prop marked Blocks stickmen. | High | Planned | Phase 17a adds a swept body check to grounded movement and arrival snaps, and reports a blocked approach without false arrival. Keep ordinary ground and navigation tied to terrain; use the same actor-blocking profile for walking, airborne sweeps, ragdoll bodies, and contact events. See `docs/specs/phase_17a_prop_roles.md`. (2026-10-05) |
|
||||||
|
| 63 | **Per-placement prop contact permissions need pair reconciliation** — layer/mask changes on one body alone can leave contacts from the other body's scan. | Medium | Planned | Phase 17a centralizes authored-start/held/released profiles on `PropBlock`, uses named physics bits, and adds symmetric pair exceptions on discrete role/scene transitions. Reconcile affected pairs on role edits, pickup/release, load, and reset; avoid per-frame pair work. See `docs/specs/phase_17a_prop_roles.md`. (2026-10-05) |
|
||||||
|
| 64 | **Legacy scene draw order can interleave props and characters** — the new two-choice depth control cannot exactly encode an older prop between two characters. | Low | Planned | Preserve `World` order with a persisted `legacy_draw_order` marker and z-index zero until the child explicitly picks Behind or In front. Show an Old scene order cue in the card; selecting a depth applies that choice relative to every character. See `docs/specs/phase_17a_prop_roles.md`. (2026-10-05) |
|
||||||
|
| 65 | **Large custom prop switches need state parity with native buttons** — a drawn 64 × 34 px switch inside a full-row `Button` replaces the stock small `CheckButton` icon. | Low | Planned | The proposed `PropSettingsToggleRow` should keep native focus, keyboard activation, disabled state, and one `toggled` emission per press; its child labels must ignore pointer input. A compact depth cue lives inside each choice button, while the stage remains the live preview. See `docs/specs/prop_settings_card_refinement.md`. (2026-10-09) |
|
||||||
|
| 66 | **Stage selection panels duplicate layout and visual rules** — the prop card owns its own shell sizing, choice styles, and `stage_prop_settings` tokens, making later inspector cards prone to drift. | Low | Planned | Promote the current prop card to a small shared Stage inspector shell, choice group, and toggle row with `stage_panels` theme tokens. Keep domain handlers in `SandboxStage`; preserve the existing prop role schema. During migration, resolve old prop tokens as fallback only temporarily and remove duplicate reads once the prop card uses the shared controls. See `docs/specs/stage_panel_ui_system.md`. (2026-10-09) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,4 +154,8 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 2026-10-03 | Speech badge clusters now also form when bubble frames intersect. For overlapping bubbles, the sorted badge row anchors above the largest frame, preventing a smaller bubble's badge from appearing through the larger bubble's text. |
|
| 2026-10-03 | Speech badge clusters now also form when bubble frames intersect. For overlapping bubbles, the sorted badge row anchors above the largest frame, preventing a smaller bubble's badge from appearing through the larger bubble's text. |
|
||||||
| 2026-10-03 | Phase 16 Move To/Run architecture specification written for user sign-off (`docs/specs/phase_16_running_stickman.md`). Approved canonical `move_to` plus Walk/Run style, migration of old movement and completion-filter types, a short arrival settle, and visual verification across Ground, Ramp, Step, seams, edges, and existing fall/landing behavior. Logged #60 for duplicated gait phase facts and #61 for the migration boundary. No implementation code changed. |
|
| 2026-10-03 | Phase 16 Move To/Run architecture specification written for user sign-off (`docs/specs/phase_16_running_stickman.md`). Approved canonical `move_to` plus Walk/Run style, migration of old movement and completion-filter types, a short arrival settle, and visual verification across Ground, Ramp, Step, seams, edges, and existing fall/landing behavior. Logged #60 for duplicated gait phase facts and #61 for the migration boundary. No implementation code changed. |
|
||||||
|
|
||||||
|
| 2026-10-05 | Phase 17a prop roles and Set Down architecture specification written for final user sign-off (`docs/specs/phase_17a_prop_roles.md`). Retain `PropBlock` as a stable `RigidBody2D` with authored-start/held/released profiles; make Set Down a world release target with terrain-grounded reach and physics settling; use explicit contact-pair policy and a grounded walking sweep. Legacy scenes migrate with dynamic prop behavior and a saved draw-order marker. Logged #62–#64. No implementation code changed. |
|
||||||
|
| 2026-10-09 | Proposed Prop settings card refinement written for user approval (`docs/specs/prop_settings_card_refinement.md`): full-width 64 × 34 px switches, explicit selected choice styling, plain-language copy, nearby help, compact scrollable card, and small front/behind cues. Logged #65 for custom control state parity. No implementation code changed. |
|
||||||
|
| 2026-10-09 | Proposed Stage selection panel UI system drafted for user approval (`docs/specs/stage_panel_ui_system.md`). The current prop card is the reference; a shared shell, choice group, toggle row, and theme tokens support later Stage inspectors. Logged #66 for duplicated panel presentation. No implementation code changed. |
|
||||||
|
|
||||||
_End of Log_
|
_End of Log_
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Priorities are recommendations for discussion, not a committed release schedule.
|
|||||||
|
|
||||||
1. **Move To with selectable movement styles** — **Status:** Verified · **Suggested priority:** High. **Intent:** Give a stickman one destination action with a per-action style choice, starting with Walk (default) and Run; leave room for future styles like Fly. Use a fixed pace for each style initially, matched to its animation. **Rationale:** Children can direct the same route at different paces without choosing duplicate action types. The first run cycle should read as a believable human gait on the existing stickman rig, with clear foot contact and push-off, a flight phase, alternating arm swing, and cadence matched to travel speed. **Connections:** [Phase 16 design brief](../plans/phase_16_running_stickman.md), `scripts/stage/action_registry.gd`, `scripts/editors/stage/action_editor.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/characters/stickman/stickman_locomotion.gd`, `scripts/tools/create_animations.gd`; existing scenes and rules currently store `walk_to` and should continue as Move To · Walk.
|
1. **Move To with selectable movement styles** — **Status:** Verified · **Suggested priority:** High. **Intent:** Give a stickman one destination action with a per-action style choice, starting with Walk (default) and Run; leave room for future styles like Fly. Use a fixed pace for each style initially, matched to its animation. **Rationale:** Children can direct the same route at different paces without choosing duplicate action types. The first run cycle should read as a believable human gait on the existing stickman rig, with clear foot contact and push-off, a flight phase, alternating arm swing, and cadence matched to travel speed. **Connections:** [Phase 16 design brief](../plans/phase_16_running_stickman.md), `scripts/stage/action_registry.gd`, `scripts/editors/stage/action_editor.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/characters/stickman/stickman_locomotion.gd`, `scripts/tools/create_animations.gd`; existing scenes and rules currently store `walk_to` and should continue as Move To · Walk.
|
||||||
|
|
||||||
|
2. **Prop depth and behavior settings** — **Status:** Planned · **Suggested priority:** Medium. **Intent:** Let placed props draw behind or in front of characters, and keep every prop selectable in EDIT even when covered. Dynamic sorting with characters is deferred until the Stage has a clear front/back movement convention. **Rationale:** Children can build layered sets with independently configurable visual depth, physics/support behavior, and interaction. Some props can provide surfaces that support other props while remaining pass-through for characters; others can block characters, move under physics, be picked up, or react to events. These roles should work across prop types rather than depend on one table-and-pot example. New placements start still and use a selected-prop settings card. **Set Down** targets a clicked stage-world release point, alongside free Drop and Throw; physics determines the subsequent landing on existing collision geometry, with no support-surface authoring. Door open/close behavior and scene travel stay in the later doorway feature. This is **Phase 17a** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md), with an approved [17a UI specification](../specs/phase_17a_prop_roles_ui.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/stage/stage_spawner.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, [Phase 11 prop interactions](../specs/phase_11_prop_interactions.md).
|
||||||
|
|
||||||
|
3. **Functional doorways and doors** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Let placed door artwork serve as scenery, an open passage, or a closed wall door that blocks characters until opened; an open passage may optionally link to another scene and arrival point. **Rationale:** Children can reuse door art for different stage roles. Door open/close actions and scene travel add state and depend on the basic prop blocking model and dynamic scene loading; keep this as later work. Whether a doorway activates automatically or through a directed action remains open. **Connections:** `scripts/stage/stage_scene_io.gd`, `scripts/stage/trigger_area.gd`, `scripts/stage/trigger_registry.gd`, `scripts/editors/stage/sandbox_stage.gd`.
|
||||||
|
|
||||||
|
4. **Vector stage backgrounds and wall finishes** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Let children build zoomable backdrop scenery such as brick or wooden room walls, with no collision. **Rationale:** A broad background wall should be quick to paint and sit behind the action; its material/look is independent from whether a wall is physically solid. Walls that block characters fit static structure/terrain behavior, while loose bricks and planks remain movable props. The current Stage has vector terrain and props but no saved background-art system. **Connections:** `scripts/terrain/terrain_block.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/sandbox_stage.gd`.
|
||||||
|
|
||||||
|
5. **Use spots on props** — **Status:** Planned · **Suggested priority:** Medium. **Intent:** Let children mark a handle, button, or lever on a placed prop and direct a stickman to use it. **Rationale:** A Use spot is distinct from a grip anchor: the prop stays in place while the stickman reaches to it. Initial direction is an instance-level spot that produces a **Used prop** event after the stickman reaches and completes the interaction. Rules can respond to use of the whole prop or filter for one specific spot; the event identifies the stickman, prop, and spot. Built-in effects such as toggling a light are deferred. The Stage needs a clear standing/reach target, visible EDIT feedback, and an understandable unreachable cue. This is **Phase 17b** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_anchor_data.gd`, `scripts/props/prop_block.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`.
|
||||||
|
|
||||||
|
6. **Prop impact reactions** — **Status:** Planned · **Suggested priority:** Medium. **Intent:** Let a prop wobble, topple, or break when struck or thrown. **Rationale:** Adds slapstick and cause-and-effect stories using the existing collision events. Initial direction: physics-derived hit strength drives a short controlled wobble, with no child-facing force slider; PLAY reset restores the authored state. Topple and break remain later options. This is **Phase 17c** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/trigger_registry.gd`.
|
||||||
|
|
||||||
|
7. **Group props into set pieces** — **Status:** Planned · **Suggested priority:** Low. **Intent:** Let children group objects for organization and moving together within one scene. **Rationale:** Scene-only groups make arranging furnished sets faster. In PLAY, each item keeps its own physics and interaction; picking up one prop does not move the rest, and stopping PLAY returns the grouped items to their authored arrangement. Reusable set-piece assets are deferred. This is **Phase 17d** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/stage_scene_io.gd`.
|
||||||
|
|
||||||
## Entry format
|
## Entry format
|
||||||
|
|
||||||
For each feature, record:
|
For each feature, record:
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Phase 17 — Props for Storytelling
|
||||||
|
|
||||||
|
> **Status:** Accepted product brief. User approval: Phase 17a implementation and UI accepted. Phases 17b–17d remain deferred.
|
||||||
|
> **Audience:** UI/UX, architect, and implementation team.
|
||||||
|
> **Purpose:** Product and design brief; technical contracts belong in later specifications.
|
||||||
|
|
||||||
|
## Goal and audience
|
||||||
|
|
||||||
|
Help children build layered sets and direct clear cause-and-effect stories with props. A table can sit behind a character and hold a flower pot without blocking the character. The character can pick up the pot, set it down deliberately, drop or throw it, press a button on another prop, and make a struck prop wobble. Children can also keep related set pieces together while arranging a scene.
|
||||||
|
|
||||||
|
The audience is a child working in the Sandbox Stage's EDIT, DIRECT, and PLAY modes. The controls should describe visible outcomes, show the relevant target on the stage, and let the child correct a misplaced object or action without rebuilding the scene.
|
||||||
|
|
||||||
|
## Desired user outcome and delivery order
|
||||||
|
|
||||||
|
Phase 17 is one product milestone with four ordered parts. Each part should be independently reviewable; later parts build on the placed-prop behavior established in 17a.
|
||||||
|
|
||||||
|
| Part | Child-facing outcome | Priority | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **17a — Prop roles and placement** | Arrange props behind or in front of characters; choose independent physical and interaction capabilities; build pass-through support props; select covered props in EDIT; use **Set Down** to release a held prop at a chosen stage spot. | First and highest | Implemented and user-approved |
|
||||||
|
| **17b — Use spots** | Mark a handle, button, or lever on a placed prop, direct a stickman to use it, and respond through a rule. | Next | Deferred |
|
||||||
|
| **17c — Impact wobble** | Make a prop briefly wobble after a meaningful hit. | Next | Deferred |
|
||||||
|
| **17d — Scene groups** | Organize and move several placed props together while editing a scene. | Last; lower priority | Deferred |
|
||||||
|
|
||||||
|
17a's role model, saved-scene migration, fixed depth tiers, covered-object chooser, collision handling, Set Down action, and UI are implemented and user-approved. Automated test run: no execution result is documented for `tests/test_phase17a_prop_roles.gd`. The success criteria below remain regression guidance. Depth is currently a fixed behind/in-front choice; dynamic Y sorting remains deferred.
|
||||||
|
|
||||||
|
## Core child flow and feedback
|
||||||
|
|
||||||
|
### 17a — Prop roles and placement
|
||||||
|
|
||||||
|
The child places a prop, selects it in EDIT, and chooses its visual depth separately from what it does in PLAY. The initial depth choices are **Behind characters** and **In front of characters**. Props keep a clear selection outline and a way to select or edit them even when another object covers them. The stage should make the chosen depth and behavior legible without exposing physics terminology as the primary labels.
|
||||||
|
|
||||||
|
Behavior is expressed through independent capabilities, because a prop may support other props while characters pass through it, and a pickable prop may be nonblocking at the start of PLAY but collide after release. Newly placed props start still by default. A table can remain fixed, sit behind a stickman, and support a flower pot without blocking that stickman. The pot stays at its authored starting position until picked up. While held, it follows the hand without collisions; after Set Down, Drop, or Throw, it interacts with configured surfaces, stickmen, and other props as appropriate. It remains available for another pickup. Stopping PLAY restores the authored arrangement and behavior for a repeatable replay.
|
||||||
|
|
||||||
|
**Set Down** complements the existing free **Drop** and **Throw** actions. The child directs a stickman holding a prop, chooses Set Down, then clicks any stage-world spot, including open air. The stickman approaches from a reachable grounded position and moves the held prop to the requested release point. If that point embeds the prop in a support surface, the stage raises the release point just enough to clear its collision shape. The arm reaches smoothly; low placements bend the torso and knees. At release, the object becomes physical and falls onto the crate, table, terrain, or other surface below. The stage previews the requested point, corrected release origin when needed, actor approach, and reach, and gives a clear, recoverable cue for an unreachable or blocked release point. A support prop uses its collision geometry; the child does not mark a special support surface.
|
||||||
|
|
||||||
|
### 17b — Use spots
|
||||||
|
|
||||||
|
In EDIT, the child adds visible Use spots to a **placed prop instance** and moves each marker onto a handle, button, or lever. A Use spot differs from a pickup grip: the stickman reaches to the spot while the target prop stays in place. In DIRECT, the child chooses **Use**, selects the prop and intended spot, and sees where the stickman will approach and reach. If the spot cannot be reached, the stage explains the problem at the target rather than silently skipping the action.
|
||||||
|
|
||||||
|
A successfully completed interaction emits a child-facing **Used prop** event that identifies the stickman, prop, and spot. A rule can respond to use of the whole prop or only one specific spot. The spot gets a brief, visible completion cue. Built-in results such as opening a door or toggling a light are later work; the rule system supplies story consequences in this phase.
|
||||||
|
|
||||||
|
### 17c — Impact wobble
|
||||||
|
|
||||||
|
The child can choose a simple impact response for a placed prop: **None** or **Wobble**. A noticeable physical hit produces one short, controlled wobble that settles cleanly. Ordinary resting contact should not make a prop shake repeatedly. The stage's existing collision rules remain available for more elaborate consequences. Stopping PLAY cancels any active wobble and restores the authored prop appearance and placement.
|
||||||
|
|
||||||
|
### 17d — Scene groups
|
||||||
|
|
||||||
|
In EDIT, the child selects related placed props such as a table, cloth, and pot, chooses **Group**, and gets a visible shared selection that can be moved together. The child can open the group to adjust one item and can ungroup without losing the items. A group organizes this scene; it does not turn its members into one physical object. In PLAY, each member keeps its own depth, collision, pickup, and event behavior. Picking up the pot moves only the pot. Stopping PLAY returns the authored arrangement, with the group still available in EDIT.
|
||||||
|
|
||||||
|
## Story and directing value
|
||||||
|
|
||||||
|
Depth lets children stage a character behind a table or in front of scenery. Independent support and collision roles make an apparently simple set behave as expected. Set Down gives deliberate blocking and placement moments, while Drop and Throw keep playful physical outcomes. Use spots let a character visibly press or handle a story object; rules let that moment trigger dialogue or another action. Wobble adds an immediate, readable response to a bump. Groups reduce repeated arranging as a set becomes more detailed.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- A child can save, reload, and replay a scene in which a fixed table is visually behind a stickman, supports a pot, and lets the stickman pass through. The pot can be picked up, set down onto the table with a smooth reach, released collision-free until that moment, and then fall onto it through physics. It can also be dropped or thrown and picked up again. PLAY reset restores the authored setup.
|
||||||
|
- Visual depth and physical/interaction capabilities remain independent on placed copies of the same prop asset. A covered prop remains discoverable and editable in EDIT.
|
||||||
|
- A child can place more than one Use spot on a prop instance, direct a stickman to a chosen spot, and make a rule fire for either the whole prop or that specific spot only after the interaction completes. Failed reach attempts give useful feedback.
|
||||||
|
- A meaningful collision produces one readable wobble when enabled; resting contact does not produce continuous wobbling, and stopping PLAY clears the effect.
|
||||||
|
- A saved scene group can be moved together in EDIT, opened to edit a member, and ungrouped. Members behave independently in PLAY and return to their authored arrangement on reset.
|
||||||
|
- Older scenes and reusable `.prop` assets continue to open with sensible behavior. A child does not have to re-author a scene to use the new controls.
|
||||||
|
|
||||||
|
## Scope and exclusions
|
||||||
|
|
||||||
|
Included: per-placement depth and independent capabilities; support for props that need not block characters; predictable start, held, and released prop behavior; Set Down by clicking a stage-world release point, followed by a physical fall; EDIT selection of obscured props; instance Use spots and Used prop rules; controlled impact wobble; and persistent scene-only groups. No support-surface authoring control is part of this phase.
|
||||||
|
|
||||||
|
Deferred: dynamic Y sorting by character and prop ground points; built-in Use effects; topple and break reactions; reusable set-piece assets; group physics or jointed assemblies; door opening/closing, functional doorways, and scene travel. Large vector background walls belong to the stage backgrounds work, while fixed actor-blocking walls are structure/terrain concerns. Loose bricks or planks remain movable props.
|
||||||
|
|
||||||
|
## As-built touchpoints and remaining risks
|
||||||
|
|
||||||
|
- `scripts/props/prop_block.gd` and `scripts/props/prop_stage_roles.gd` now hold per-placement role settings and EDIT, start-still, start-physics, held, and released profiles. `scripts/stage/stage_scene_io.gd` writes version 1.2 and migrates older scenes; the `.prop` asset schema is unchanged. Keep save/load compatibility and PLAY reset in the regression checklist.
|
||||||
|
- `scripts/characters/stickman/stickman_prop_interactor.gd` handles pickup, Drop, Throw, and Set Down. It clears collisions while a prop is held and applies its authored release profile afterward. Set Down previews and runtime checks share the target solver; complex queued/rule sequences and changed runtime conditions remain useful regression cases.
|
||||||
|
- `scripts/characters/stickman/stickman_locomotion.gd` and `scripts/stage/stage_physics_policy.gd` include actor-blocking prop collision bits and movement sweeps. Props set to block stickmen are not walkable platforms; terrain remains the movement surface. Keep obstruction, pause/reset, and ragdoll interactions in the regression checklist.
|
||||||
|
- `scripts/stage/stage_selection.gd` provides `hit_test_all()` and the stage shows an **Objects here** chooser for overlapping candidates. The current hit test uses world-space AABBs rather than exact vector-shape containment, so the chooser may include an object when the pointer is inside its bounds but outside its artwork.
|
||||||
|
- Props use fixed behind/in-front draw tiers. Dynamic ground-point Y sorting remains deferred. `scripts/props/prop_anchor_data.gd` continues to describe reusable grip anchors; 17b Use spots will be placed-instance data and must remain distinct from those grips.
|
||||||
|
- Stage collision events remain contact based. Phase 17c still needs a dependable hit-strength rule for a moving stickman striking a stationary prop without causing resting-contact jitter or requiring a child-facing force slider.
|
||||||
|
- Stage multiselection can move several objects together in one edit, but it does not save persistent group membership. Phase 17d must preserve independent member identities and prop-targeted actions when adding saved groups.
|
||||||
|
- Relevant prior context: [Phase 11 prop interactions](../specs/phase_11_prop_interactions.md) and the live [feature backlog](../ideas/feature_backlog.md).
|
||||||
|
|
||||||
|
## Regression checks and future design questions
|
||||||
|
|
||||||
|
17a's settings controls, overlap chooser, Set Down release-point behavior, legacy scene defaults, implementation, and UI are user-approved. Keep the table/pot support and pass-through case, blocking movement, open-air and corrected Set Down targets, invalid-target recovery, covered-object selection, repeat PLAY reset, and old-scene replay as regression guidance.
|
||||||
|
|
||||||
|
The following design questions remain for the deferred parts of Phase 17:
|
||||||
|
|
||||||
|
- **17b — Use spots:** How should EDIT show and target multiple instance spots without confusing them with reusable grip anchors, and how should DIRECT explain an unreachable spot?
|
||||||
|
- **17c — Impact wobble:** What impact measure and visual treatment make a wobble consistent across stationary props, moving props, and stickmen without repeated jitter?
|
||||||
|
- **17d — Scene groups:** What EDIT gesture makes it clear whether the child is selecting a whole group or one member? How should group membership be shown without covering the set artwork?
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Phase 17a — Prop roles and Set Down: architecture specification
|
||||||
|
|
||||||
|
> **Status:** User-approved and accepted: Phase 17a implementation and architecture.
|
||||||
|
> **Product contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [approved 17a UI/UX specification](phase_17a_prop_roles_ui.md).
|
||||||
|
> **Scope:** Placed prop depth and behavior, covered-object selection, and Set Down in the Sandbox Stage. Use spots, wobble, groups, and doorways belong to later work.
|
||||||
|
|
||||||
|
## Decisions and behavior
|
||||||
|
|
||||||
|
- A placed `PropBlock` stays a `RigidBody2D`. An authored role profile determines its PLAY-start state; pickup temporarily makes it held and collision-free; every intentional release makes it a dynamic body with the authored after-release collision profile. This avoids replacing nodes and preserves `stage_object_id`, queue references, signals, and scale handling.
|
||||||
|
- New placements start still. Existing scene props without 17a fields retain their legacy dynamic, collidable behavior. The chosen default for **After letting go** is **Bumps into stickmen: On** and **Bumps into other props: On**. Explicitly turning the latter Off means the released prop passes through other props, including support props; terrain still catches it.
|
||||||
|
- **Holds other props** advertises a prop's existing physical shape as a support. It creates no support line. A prop can support another prop and let characters pass through. A released prop can rest on a support only if its **Bumps into other props** setting is On. When both **Holds other props** and a released prop's **Bumps into other props** are Off, the Off setting wins for that released body's own prop contacts.
|
||||||
|
- **Implemented behavior:** A newly configured **Blocks stickmen** prop blocks the character's body and movement route but does not become a walkable terrain platform. Terrain supplies normal ground and navigation. Legacy scene behavior, including recovery probes that previously found props, is preserved. Keep obstruction and recovery cases in the regression checklist.
|
||||||
|
|
||||||
|
## Implementation snapshot
|
||||||
|
|
||||||
|
- `PropStageRoles`, `PropBlock`, and `StagePhysicsPolicy` now hold per-placement roles and the EDIT, start-still, start-physics, held, and released collision profiles. Stage files use version 1.2; 1.0 and 1.1 migrate, missing role data receives legacy behavior, and invalid present role values are rejected before the scene is replaced. The reusable `.prop` format remains unchanged.
|
||||||
|
- `StageSelection.hit_test_all()` returns geometric candidates in draw order. EDIT opens an **Objects here** chooser for overlapping objects, including covered props.
|
||||||
|
- The prop settings card is built from the shared `scripts/shared/stage_ui/` controls and `stage_panels` theme tokens. See the [Stage panel UI system](stage_panel_ui_system.md) and the [17a UI specification](phase_17a_prop_roles_ui.md).
|
||||||
|
- `set_down_prop` is wired through the queue, rules, editor, save/load, target capture, and `StickmanPropInteractor`. The implementation provides a conditional preview when the held prop cannot be inferred, checks release clearance and reach, keeps the held prop non-collidable through approach/reach, restores the pickup orientation, and releases it with the configured profile.
|
||||||
|
- Depth currently uses fixed behind/in-front draw tiers. There is no ground-point Y sorting. Use spots, impact wobble, scene groups, and functional doorways remain outside 17a.
|
||||||
|
|
||||||
|
`tests/test_phase17a_prop_roles.gd` has been added for role defaults/migration, serialization, runtime profiles, support contact, Set Down clearance/release, orientation, and low reach. Automated test run: no execution result is documented.
|
||||||
|
|
||||||
|
## Pre-17a baseline and integration points
|
||||||
|
|
||||||
|
| Existing code | Consequence for 17a |
|
||||||
|
| --- | --- |
|
||||||
|
| Before 17a, `PropBlock` at `scripts/props/prop_block.gd` was one vector `RigidBody2D` with generated collision shape and a stable `stage_object_id`. | Instance settings are stored on this node, independent of the reusable `.prop` asset; shape generation and material physics remain in place. |
|
||||||
|
| Before 17a, `SandboxStage._start_run()` unfreezed every `RigidBody2D`; `_reset_run()` froze them and `_restore_authored_state()` restored transforms. | PLAY now applies each prop's authored start profile, and reset restores role state, masks, depth, velocity, and transform after held props are detached. |
|
||||||
|
| Before 17a, `StickmanPropInteractor._commit_pickup()` saved freeze, masks, and z order before clearing collisions, and `_release_held()` restored that snapshot. | Drop, Throw, and Set Down now use the authored *released* profile; scene reset restores the authored start profile. |
|
||||||
|
| Before 17a, `StickmanLocomotion._update_walking()` wrote `rig.global_position` directly, and animated body proxies and terrain shared collision bit 1. | Named physics bits and movement sweeps now let **Blocks stickmen** stop grounded movement; changing a prop mask alone would not stop direct root movement. |
|
||||||
|
| Before 17a, `StageSelection.hit_test()` considered one world AABB winner, with ragdoll hit priority. | `hit_test_all()` now returns ordered candidates for the **Objects here** chooser. |
|
||||||
|
| Before 17a, `StageSceneIO.FILE_VERSION` was 1.1; stage save wrote prop ID, asset, scale, and material. | Stage files now write 1.2 with validated optional per-placement roles and migration defaults; `.prop` files and queue target IDs remain unchanged. |
|
||||||
|
|
||||||
|
## Static contracts and ownership
|
||||||
|
|
||||||
|
Add a small `class_name PropStageRoles extends RefCounted` in `scripts/props/prop_stage_roles.gd`. It owns constants, allowed strings, default/migration profiles, validation, and the pure contact predicate. It is not a node or an event bus. The stage and interactor call it through `PropBlock` methods. Example contract:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name PropStageRoles
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const DEPTH_BEHIND: String = "behind"
|
||||||
|
const DEPTH_FRONT: String = "front"
|
||||||
|
const START_STILL: String = "still"
|
||||||
|
const START_PHYSICS: String = "physics"
|
||||||
|
|
||||||
|
static func new_placement() -> Dictionary: ...
|
||||||
|
static func legacy_placement() -> Dictionary: ...
|
||||||
|
static func normalize(raw: Variant, fallback: Dictionary) -> Dictionary: ...
|
||||||
|
static func props_should_collide(a_roles: Dictionary, a_state: int, b_roles: Dictionary, b_state: int) -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`PropBlock` owns an explicit, typed API rather than letting UI code assign physics flags:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED }
|
||||||
|
var stage_roles: Dictionary = {} # normalized per-placement profile
|
||||||
|
var runtime_role: RuntimeRole = RuntimeRole.EDIT
|
||||||
|
|
||||||
|
func set_stage_roles(roles: Dictionary) -> void: ...
|
||||||
|
func apply_runtime_role(role: RuntimeRole) -> void: ...
|
||||||
|
func can_be_picked_up() -> bool: ...
|
||||||
|
func is_actor_blocking() -> bool: ...
|
||||||
|
func can_receive_prop_contact() -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The saved per-prop entry gains one `roles` dictionary. The values below are JSON-safe and remain independent; a copied placement receives a new `stage_object_id` and a deep copy of the roles.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "prop_2",
|
||||||
|
"type": "prop",
|
||||||
|
"stage_object_id": "existing-stable-id",
|
||||||
|
"roles": {
|
||||||
|
"depth": "behind",
|
||||||
|
"start_motion": "still",
|
||||||
|
"holds_other_props": true,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": false,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `StageSceneIO.FILE_VERSION` to `"1.2"` and accept/migrate `"1.0"`, `"1.1"`, and `"1.2"`. Apply the existing 1.0 stable-ID migration first, then add 17a role fields to old props as `legacy_placement()`. A 1.2 prop with no `roles` also receives that legacy profile, so an incomplete older export is safe. For a present `roles` dictionary, missing known keys take `new_placement()` defaults; present known keys must have valid strings or strict booleans, otherwise reject the document in `StageSceneIO` before `_apply_scene_data()` destroys the current scene. `legacy_draw_order` is a persisted boolean inside `roles`; it is true only for migrated props or a saved legacy placement, and becomes false when the child explicitly chooses a depth. New placement uses `new_placement()`: behind characters, starts still, does not hold others, does not block stickmen, pickup On for assets with a usable grip, both after-release bumps On, and `legacy_draw_order=false`. For assets without a compatible grip, `can_pick_up` defaults Off and is unavailable until the grip exists. Do not infer settings from an asset filename. Keep `ContentPaths.canonicalize()` for path-bearing entries; roles contain no paths. Existing `stage_object_id` remains the action reference. Do not alter `.prop` version 1.0. Preserve missing-prop placeholder `original_entry`, including unknown role data, when round-tripping.
|
||||||
|
|
||||||
|
An older prop was previously dynamic at PLAY start, collided with other props and characters, and was pickable if it had a grip. Legacy defaults reflect those facts. Old files can contain props interleaved with characters in `World` child order; two new depth choices cannot exactly represent every old interleaving. Preserve object order and keep migrated props at `z_index == 0` while `legacy_draw_order=true`. Derive the displayed Behind/In front choice from the number of characters before versus after the prop in saved order (ties choose Behind); show a short **Old scene order** cue on the card because a mixed old arrangement may sit between characters. An explicit depth click sets `legacy_draw_order=false` and moves the prop to its chosen tier relative to every character. Re-save the marker and object order so reload does not silently move the prop. New props use fixed behind/front tiers, without Y sorting.
|
||||||
|
|
||||||
|
## Physics and collision policy
|
||||||
|
|
||||||
|
Define named physics bit constants in one stage physics policy module; keep terrain's existing bit 1 so navigation, ground rays, and old content retain it. Reserve distinct bits for animated/ragdoll actors, general props, and props that currently block actors. Configure animated collision proxies, ragdoll bodies, props, and physics queries from these constants. Update `StickmanLocomotion` ground/landing probes and airborne sweeps deliberately: normal terrain following still accepts `TerrainBlock` only, recovery keeps its previous legacy prop behavior, and the airborne horizontal sweep also sees props with the current actor-blocking bit.
|
||||||
|
|
||||||
|
Do not rely on a one-sided mask to make an unwanted contact disappear. Godot masks describe what each body scans, so a second body's scan can still create a contact. The policy sets both sides' relevant bits and maintains symmetric `PhysicsBody2D.add_collision_exception_with()` / `remove_collision_exception_with()` for prop pairs whose instance settings forbid contact. Reconcile exceptions when a prop is placed, deleted, edited, picked up, released, reset, or loaded; update affected pairs, not every pair every frame. Held props clear both layer and mask and are excluded from overlap queries. For a modest scene, pair reconciliation on these discrete transitions is simpler than body replacement or one physics layer per placed prop.
|
||||||
|
|
||||||
|
The intended matrix is:
|
||||||
|
|
||||||
|
| Pair | Contact rule |
|
||||||
|
| --- | --- |
|
||||||
|
| Any unheld prop / terrain | Yes; terrain still catches released props. |
|
||||||
|
| Prop / animated or ragdoll stickman | The prop's current `blocks_stickmen` choice at PLAY start, or `released_blocks_stickmen` after release. Held: No. |
|
||||||
|
| Start-still prop / moving prop | Yes only when still prop **Holds other props** and moving prop accepts prop contact. |
|
||||||
|
| Two moving props | Yes when both accept prop contact; an explicit after-release **Bumps into other props: Off** vetoes the pair. |
|
||||||
|
| Two start-still props | No physical response is needed; authored placements remain independently selectable. |
|
||||||
|
|
||||||
|
Starting dynamic props accept contact with configured supports and other dynamic props; the initial card has no separate outgoing prop-bump switch. A start-still prop advertises prop support only when `holds_other_props` is On. A released prop with `released_bumps_other_props` Off passes through every prop, even a support. If a released prop itself has `holds_other_props` On, it can support another prop only while its own prop-bump setting is On. Collision events must be emitted only for actual allowed contacts. On reset, clear contact-pair suppression/baselines to avoid fake collision events on the next PLAY. Avoid changing material mass, bounce, or shape geometry to express these roles.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authored EDIT ──PLAY──> START_STILL (frozen) or START_PHYSICS (dynamic)
|
||||||
|
^ │ │
|
||||||
|
│ successful pickup successful pickup
|
||||||
|
│ └─────────┬───────────────┘
|
||||||
|
│ v
|
||||||
|
│ HELD (no collisions)
|
||||||
|
│ │ Set Down / Drop / Throw
|
||||||
|
│ v
|
||||||
|
└────────────STOP/RESET──────── RELEASED (dynamic)
|
||||||
|
```
|
||||||
|
|
||||||
|
In EDIT, props remain frozen and physically quiescent but geometrically selectable. On PLAY start, `START_STILL` uses `freeze = true` with the existing generated collider active; `START_PHYSICS` uses `freeze = false`. On pickup, verify `can_pick_up` and a valid grip, reserve the prop, save only the temporary pose state needed for cancellation, freeze it, and clear collisions. On Set Down, Drop, or Throw, apply `RELEASED`, wake the body, and apply its authored release masks regardless of its start motion. Set Down gives zero launch velocity and spin, so it falls under gravity; Drop retains existing loose-release behavior unless the revised implementation makes a small compatibility correction. Throw retains its computed impulse. After release, it remains pickable when `can_pick_up` is On. Ragdoll forced release uses the same released profile. Scene exit/reset detaches held props, clears their owner/reservation, restores the authored role, depth, transform, and zero velocity before another PLAY. Pause/resume preserves each prop's current runtime role and frozen state rather than turning start-still props dynamic.
|
||||||
|
|
||||||
|
Draw order is per placement: new behind props render beneath characters and new front props above them, independent of physics. Use stable `CanvasItem.z_index` tiers for unheld props and the existing held-hand offset while held; restore authored depth on release. Selection outlines/gizmos draw in a separate overlay above stage artwork. Sorting and selection compare effective z tier, then sibling order; AABB size is only a tie-breaker for objects with equal draw rank.
|
||||||
|
|
||||||
|
## Character movement must honor blocking props
|
||||||
|
|
||||||
|
`StickmanLocomotion._update_walking()` currently assigns the root transform and can cross a physical body even when its limb proxies collide. Add a horizontal shape sweep of the animated body's relevant torso/head proxy shapes before every grounded movement step and before the final arrival snap, excluding the actor's own proxy RIDs and the held prop. Query terrain and the actor-blocking prop bit; retain the largest safe fraction that does not penetrate a blocking prop. On obstruction, stop advancing, keep grounded terrain following, and fail/finish the affected Move To or Set Down approach with a visible reason after a short no-progress interval; do not report arrival at an unreachable point. Use the same actor-blocking predicate for preview route checks. Continue to use the existing terrain navigation for walkable ground. When a blocking prop moves into the route during PLAY, recheck from the current pose; do not tunnel through it.
|
||||||
|
|
||||||
|
This collision sweep also needs to cover direct steering, close-target fast paths in `move_to()`, and any other root-position shortcut that can cross a prop. Airborne horizontal motion already casts shapes; update its mask to include actor-blocking props. Ragdoll bodies receive compatible masks and must not collide with a prop set to pass through stickmen. Existing prop collision reaction/event logic still receives allowed contacts.
|
||||||
|
|
||||||
|
## Set Down action and reach
|
||||||
|
|
||||||
|
Use the flat action `{ "type": "set_down_prop", "target_position": Vector2 }`. The corresponding saved queue value is `{ "type": "set_down_prop", "target_position": {"x": 420.0, "y": -260.0} }`; rule actions use `ActionRegistry.to_rule_action()` / `from_rule_action()` and the existing `StageSceneIO` rule encoder, with the same parameter inside `params`. `target_position` is the requested prop origin at release, not a landing target, grip position, table attachment, or support ID. It must be finite. Runtime may raise this point only when needed to clear the collision geometry of a support beneath it; the authored request stays unchanged. The queue runner waits until the approach, reach, and smooth body recovery complete or fail, and a failed action completes safely so later actions can proceed.
|
||||||
|
|
||||||
|
Add `set_down_prop` to `ActionRegistry`, `ActionEditor`, stickman action popup, stage target-capture enum, cursor overlay, queue summaries, and `StageSceneIO` encode/decode. The Action Editor uses **Choose release place** / **Change release place**, Done only after a finite stage point has been chosen, and the existing popup hide/restore pattern for stage capture. Both direct queue and rule authoring reach the same capture function. Esc, right-click, or visible Cancel restores the prior draft point when editing and discards an unsaved new candidate. For a known prop, a green click records the coordinate and a red click leaves capture open. When exact prop/reach/occupancy cannot be known from earlier actions, a finite click records a **conditional** target with the neutral marker and **Prop depends on earlier actions**; it is never colored green or presented as verified. PLAY performs the full check and gives a recoverable failure cue if needed. Snap, if enabled, changes only the clicked world point. `ContentPaths` is unaffected.
|
||||||
|
|
||||||
|
The interactor adds SET_DOWN_APPROACHING and SET_DOWN_REACHING (or equivalent explicit substates), with one shared nonmutating solver for authoring preview and PLAY validation. It reuses the queued held-prop preview prefix from Throw to determine the prop, grip, hand, and predicted actor pose. The prefix evaluator must also consume earlier Set Down actions as release actions and account for Drop/Throw; it may return `known=false` when a rule or earlier action makes the held prop uncertain. Do not move or reserve a live prop during preview. When unknown, show the neutral marker and **Prop depends on earlier actions**, and assess grounded approach/reach where possible without pretending to know exact occupancy. As with the existing Throw preview, cache physics shape checks in `_physics_process()` and draw the latest result in `_process()`; do not perform fresh direct-space queries from every pointer draw.
|
||||||
|
|
||||||
|
For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. Do not add an implicit owner-collision grace period or permit release inside the torso. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop.
|
||||||
|
|
||||||
|
During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; ragdoll aborts Set Down and uses the existing forced-release path with the authored released profile. STOP restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor.
|
||||||
|
|
||||||
|
## Stage UI, selection, and theme
|
||||||
|
|
||||||
|
Create the `PanelContainer` Prop settings card in the stage UI, with `MarginContainer`, scrollable `VBoxContainer`, header thumbnail/name, a large Close control, the exact copy and controls in the [UI spec](phase_17a_prop_roles_ui.md). Bind it only to one selected `PropBlock`; selecting a stickman/terrain hides it, and multiple selected nodes show **Select one prop to change its settings** rather than editing several silently. Choice changes normalize roles, update EDIT depth immediately, mark the scene dirty, and refresh the card. Close preserves selection and settings. Existing thumbnail cache can provide the prop header image; a type icon is acceptable until an asset thumbnail is ready.
|
||||||
|
|
||||||
|
Expose `StageSelection.hit_test_all(world_pos: Vector2) -> Array[Node2D]`: gather selectable `World` children geometrically even when occluded, sort by effective canvas z and sibling order, and return the top candidate first. Keep ragdoll-to-rig mapping. Normal click selects the top visible candidate; when several candidates overlap, show the **Objects here** button near the pointer. The chooser lists every candidate with at least 44 px rows, thumbnail/type icon, name, depth cue, and current-selection check. Choosing a row calls `select_only()` with that exact node, so a fully covered prop stays editable. The chooser has no effect on shift multiselect or box selection; dismissal leaves current selection intact. A selection overlay remains above all prop/actor art regardless of depth.
|
||||||
|
|
||||||
|
Use `SandboxTheme.control_theme()` on the card and popup. Read dimensions, fonts, accents, selected, valid, and invalid colors from `SandboxTheme` / `sandbox_theme.json`, adding narrowly scoped fallback tokens for the Prop card and Set Down preview. Every interactive target is at least 44 × 44 logical pixels and no shorter than the theme's button minimum; the card scrolls at narrow widths. Set Down overlay includes amber DIRECT framing, **Click where to let go**, a release cross, known-prop ghost or neutral marker, route/reach guide, green **Ready to set down** or the specific red failure cue, and a visible Cancel control. The ghost forecasts release pose only, not final landing. Selection and state use outlines/checks and text as well as color.
|
||||||
|
|
||||||
|
## Regression checklist
|
||||||
|
|
||||||
|
For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, and support contact, followed by Godot headless syntax validation. A small runtime scene can check that a support-only table catches a released pot while a stickman passes through, that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release.
|
||||||
|
|
||||||
|
The approved table/pot, open-air Set Down, invalid-target feedback, overlap chooser, card readability, old-scene replay, and movement-blocking behavior are useful visual/runtime regression scenarios.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Phase 17a — Prop Roles and Set Down: UI/UX Specification
|
||||||
|
|
||||||
|
> **Status:** User-approved and accepted: Phase 17a UI.
|
||||||
|
> **Source:** [Phase 17 — Props for Storytelling](../plans/phase_17_props_for_storytelling.md).
|
||||||
|
> **Scope:** Sandbox Stage EDIT and DIRECT controls for placed props. Phase 17b Use spots, 17c wobble, and 17d scene groups follow separately.
|
||||||
|
|
||||||
|
## Child flow
|
||||||
|
|
||||||
|
In EDIT, the child places a prop and selects it. A settings card opens near the right edge of the stage, leaves the artwork visible, and identifies the selected placed prop by icon and name. The header shows the selected prop's name without a second **Prop settings** title. The child chooses how it draws, how it begins PLAY, and what it collides with. Changes are visible on the stage immediately and belong to that placement, so another copy of the same asset can behave differently.
|
||||||
|
|
||||||
|
In DIRECT, the child selects a stickman and adds **Set Down** beside **Pick Up Prop**, **Drop Prop**, and **Throw Prop**. A cursor guide invites a stage click. The click records the requested release point, including a point in open air. If that point embeds the prop inside terrain or a support prop, the runtime moves the release origin straight up enough to clear the collision shape. The stickman approaches from the ground and releases the held prop there; the prop then falls under physics. The preview does not promise a final landing spot.
|
||||||
|
|
||||||
|
## Control layout and node structure
|
||||||
|
|
||||||
|
- Keep the Stage's current top mode switcher, left asset palette, and bottom status bar. Place the Prop settings card in a `PanelContainer` over the right side of the EDIT viewport, inset from the screen edge and below mode/run controls. Use a `MarginContainer` and scrollable `VBoxContainer` so longer labels and raised font sizes do not clip. The card may shrink with the window, but must leave a usable central stage view.
|
||||||
|
- The card header contains a prop icon, the placed prop's display name, and a 44×44 or larger **✕ Close** button. Closing the card leaves the prop selected and exposes a **Settings** reopen control; it stays closed while that same prop remains selected. Selecting a different prop updates and reopens the card. Selecting terrain or a stickman hides the prop card. A scene with multiple selected objects does not silently change all their settings: show **Select one prop to change its settings** and a visible way to return to the stage.
|
||||||
|
- Under **Where it appears**, use two full-width, mutually exclusive large buttons: **Behind stickmen** and **In front of stickmen**, each with a small depth illustration. Show the chosen button with the EDIT accent. The stage redraws the prop immediately so the result can be seen among characters; no ground-point sorting control appears in 17a.
|
||||||
|
- Under **When PLAY starts**, use two mutually exclusive buttons: **Stays in place** and **Can fall and move**. New placements select **Stays in place**. The child can change the setting for each placement. Existing saved scenes should display their migrated legacy behavior instead of silently adopting the new default.
|
||||||
|
- Below those buttons, use three independent, at least 44 px high switch rows: **Props can sit on top**, **Blocks stickmen**, and **Stickmen can pick it up**. Add a short plain-language line beneath a row only when its outcome might surprise the child. Under **Props can sit on top**, show **Other props can land on this one.** When **Blocks stickmen** is on, show **Stickmen cannot walk through it.** For **Stays in place**, show **Stays here while PLAY runs.** when pickup is off and **Stays here until picked up.** when pickup is on. **Blocks stickmen** describes the prop before pickup or release and does not force either of the other capabilities on.
|
||||||
|
- If **Stickmen can pick it up** is on, reveal an **After letting go** section with the intro **After Set Down, Drop, or Throw:** and independent **Can bump into stickmen** and **Can bump into other props** switches. These settings apply after Set Down, Drop, or Throw. They remain separately editable from the initial **Blocks stickmen** and **Props can sit on top** settings. A prop can therefore let stickmen walk through at the start and collide with them after release. Hide this section when pickup is off; keep its values if the child toggles pickup off and back on during the same edit.
|
||||||
|
- For new placements, both **After letting go** switches default to On. **Can bump into other props** lets a released object land on a support prop such as a table. If the child turns it Off, the released object passes through props, including support props; terrain such as the floor can still catch it.
|
||||||
|
- Do not expose collision layers, mass, z-index, or numeric force in the card. Use the existing prop's selected outline and a small stage badge when a setting changes so the child can see which object changed. Use a brief, nonblocking confirmation such as **This prop is behind characters** when a depth change is visually obscured.
|
||||||
|
- Card choices apply as soon as the child presses them and mark the scene as changed. **✕ Close** dismisses the card and keeps the chosen settings; it is not a rollback button. The child can reopen the card and change a choice again. PLAY reset restores the authored settings and placement.
|
||||||
|
- Add an **Objects here** button near the cursor when an EDIT click covers multiple selectable objects. It opens a themed popup with a vertical list of the objects under that point, each row containing a thumbnail or recognizable type icon, name, and depth cue. Every row is at least 44 px high. The currently selected object is marked. Choosing a row selects that exact object and opens its card; the menu remains available when the chosen object is completely covered. Normal click still selects the visible object; the chooser makes covered props reachable without relying on a hidden repeated-click or keyboard gesture. The chooser must not interfere with box selection or shift multiselection.
|
||||||
|
- In DIRECT, add **Set Down…** to the same action entry points as Drop and Throw, including the stickman action popup and Action Editor. The Action Editor has a **Choose release place** button and the existing visible **Cancel** / **Done** pattern. A saved Set Down action can reopen its target and use **Change release place**.
|
||||||
|
|
||||||
|
## Theme, size, and accessibility
|
||||||
|
|
||||||
|
- Use `SandboxTheme.control_theme()` for the card and all popups. Read fonts, selected states, neutral surfaces, borders, EDIT cyan, DIRECT amber, and valid/invalid preview colors from `SandboxTheme` and its JSON contract. Any new visual tokens belong in that contract with fallback values; avoid fixed UI colors and font sizes in implementation.
|
||||||
|
- Interactive card rows, segmented buttons, popup rows, and the DIRECT Cancel button have a minimum pointer target of 44×44 logical pixels and a height no smaller than the applicable `button_min_height` theme token. Containers wrap or scroll at narrow widths and larger font settings. Labels should remain readable without hover tooltips.
|
||||||
|
- Selection uses both an outline and a name or icon cue, so color alone never identifies a prop. Selected switches show a check as well as accent color. Popup and dialog windows receive `theme.control_theme()` explicitly.
|
||||||
|
- Each active cursor workflow has an obvious **✕ Cancel** button close to the pointer or action card, and Esc/right-click may also cancel. The child does not need a keyboard shortcut to finish or leave Set Down target capture.
|
||||||
|
|
||||||
|
## On-canvas and cursor feedback
|
||||||
|
|
||||||
|
- In EDIT, maintain a high-contrast selection outline above stage artwork even when the selected prop draws behind characters. The **Objects here** chooser uses geometric candidates from the clicked position, including obscured props; draw order cannot make a selected prop uneditable. The Prop settings card stays outside the prop's immediate drag area where space permits.
|
||||||
|
- During Set Down target capture, use the DIRECT amber mode frame and a cursor-attached pill: **Click where to let go**. Draw a small cross at the candidate world point, a translucent ghost of the known held prop centered at the intended release point, and a visible guide from the stickman's grounded approach/reach pose to that point. The guide follows the cursor continuously. It is a release preview, not a forecast of where the prop will settle.
|
||||||
|
- A reachable point with room to release shows a green ghost/guide and **Ready to set down**. An unreachable point or a point blocked by the character or a non-support prop shows red and a short cause near the cursor, such as **Too far to reach** or **No room to let go here**. Open air can be valid. When a point is inside a support surface, keep the requested click visible with an amber cross and show the corrected ghost above it, joined by a vertical guide. This is collision clearance, not surface snapping or a special support marker; no landing surface is required.
|
||||||
|
- When the queued actions make the held prop known, preview its shape. When the held prop cannot be known during authoring, show a neutral object marker and **Prop depends on earlier actions**; do not invent a specific silhouette or landing path. The stage can still assess the actor's route and reach where possible. If no prop is held when Set Down executes, give a concise cue at the stickman and continue safely according to the architecture spec.
|
||||||
|
- Invalid targets do not close capture or erase the draft action. A valid click records the release point and returns to the action's editor or queue flow, with a visible **Change release place** path.
|
||||||
|
- **✕ Cancel** during target capture discards the new candidate point and returns to the previous action editing state. When changing an existing Set Down action, cancellation keeps its earlier release point. **Done** commits a valid draft action; a draft without a chosen point cannot be committed. If the actor or scene changes after authoring, PLAY should show a recoverable failure cue rather than silently placing the prop elsewhere.
|
||||||
|
|
||||||
|
## Copy map
|
||||||
|
|
||||||
|
| UI location | Exact child-facing copy |
|
||||||
|
| --- | --- |
|
||||||
|
| Card header | Selected prop name with type icon; no separate **Prop settings** title |
|
||||||
|
| Depth heading/options | **Where it appears** · **Behind stickmen** · **In front of stickmen** |
|
||||||
|
| Initial behavior heading/options | **When PLAY starts** · **Stays in place** · **Can fall and move** |
|
||||||
|
| Initial switches | **Props can sit on top** · **Blocks stickmen** · **Stickmen can pick it up** |
|
||||||
|
| Release section/switches | **After letting go** · **Can bump into stickmen** · **Can bump into other props** |
|
||||||
|
| Overlap chooser | **Objects here** · **Choose an object** |
|
||||||
|
| DIRECT action | **Set Down…** · **Choose release place** · **Change release place** |
|
||||||
|
| Cursor and target feedback | **Click where to let go** · **Ready to set down** · **Too far to reach** · **No room to let go here** · **Prop depends on earlier actions** |
|
||||||
|
| Exit/action buttons | **Settings** · **✕ Cancel** · **Done** · **✕ Close** |
|
||||||
|
|
||||||
|
## UI boundaries for architecture
|
||||||
|
|
||||||
|
- Depth is a per-placement visual choice and remains independent of collision, pickup, and starting behavior. A fixed prop can hold another prop while stickmen pass through it. A staged pickup prop can begin nonblocking and gain chosen collisions after release.
|
||||||
|
- **Holds other props** is a capability backed by the prop's existing collision geometry. There is no authored support line, suggested surface, surface snapping, or special table targeting in this UI.
|
||||||
|
- Set Down's clicked point is the requested release point; if its collision shape would overlap terrain or a prop configured to hold other props, runtime raises the release origin vertically until it clears. Actor and reach checks still apply, and physics determines the subsequent fall and landing. There is no named support surface, sideways snapping, or requirement for a landing surface.
|
||||||
|
- The 17b Use spot controls may later extend the selected prop card; 17c can add a simple impact response row; 17d can add group organization controls. Their detailed UI is outside this 17a spec.
|
||||||
|
|
||||||
|
## Implementation and verification status
|
||||||
|
|
||||||
|
- The card uses `StageInspectorPanel`, `StagePanelChoiceGroup`, and `StagePanelToggleRow` from `scripts/shared/stage_ui/`, styled from `stage_panels` theme tokens. Future in-stage inspectors should follow the [shared Stage panel UI system](stage_panel_ui_system.md).
|
||||||
|
- The overlap chooser lists candidate names and depth/type cues in 44 px rows. It uses type icons rather than prop thumbnails. The prop settings header likewise currently uses a type icon and the selected prop name.
|
||||||
|
- Set Down target capture, visible Cancel, conditional unknown-prop messaging, release ghost, approach/reach guide, and valid/invalid feedback are implemented and accepted. The accepted as-built UI shows feedback text in the stage status line and places DIRECT Cancel in the top-right action area; it does not include a cursor-attached hint or a separate depth-change badge/toast.
|
||||||
|
- Automated test run: no execution result is documented. Card sizing, overlap selection, Set Down feedback, and old-scene presentation remain useful visual regression guidance.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Prop settings card refinement — proposal for approval
|
||||||
|
|
||||||
|
> **Status:** Proposed; approval requested before implementation.
|
||||||
|
> **Scope:** The Sandbox Stage EDIT card for one selected placed prop. This refines the card portions of [Phase 17a UI](phase_17a_prop_roles_ui.md). It changes presentation and input targets only; the saved `PropStageRoles` keys and PLAY behavior retain their current meanings.
|
||||||
|
|
||||||
|
## Child-facing result
|
||||||
|
|
||||||
|
The card names the selected prop, then presents two clear choices about drawing order, two choices about starting motion, and large on/off rows for what the prop can do. Every choice has a visible selected state. A child can press anywhere on a toggle row, including its words, help line, and switch. When there is room, the card ends after its content instead of filling the entire right side of the stage.
|
||||||
|
|
||||||
|
```text
|
||||||
|
╭─ 📦 Prop settings ───────────── ✕ Close ─╮
|
||||||
|
│ Crate │
|
||||||
|
│ Where it appears │
|
||||||
|
│ [✓ ▣ Behind stickmen ] │
|
||||||
|
│ [ ▣ In front of stickmen ] │
|
||||||
|
│ When PLAY starts │
|
||||||
|
│ [✓ Stays in place ] │
|
||||||
|
│ Stays here until picked up. │
|
||||||
|
│ [ Can fall and move ] │
|
||||||
|
│ │
|
||||||
|
│ Props can sit on top [ ✓ ON ] │
|
||||||
|
│ Other props can land on this one. │
|
||||||
|
│ Blocks stickmen [ OFF ] │
|
||||||
|
│ Stickmen cannot walk through it. │
|
||||||
|
│ Stickmen can pick it up [ OFF ] │
|
||||||
|
╰──────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
The two `▣` marks above stand for small overlap pictures, not the final icon art. Each picture shows a stickman and a prop in the selected front/behind order. The stage remains the live preview after a choice changes. There is no separate preview pane.
|
||||||
|
|
||||||
|
## Copy and meaning
|
||||||
|
|
||||||
|
| Current control | Proposed label | Associated help / behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Behind characters / In front of characters | **Behind stickmen** / **In front of stickmen** | The picture shows only draw order. The chosen button gets a leading check, EDIT cyan border, and distinct fill. |
|
||||||
|
| Starts still | **Stays in place** | When pickup is enabled: **Stays here until picked up.** Otherwise: **Stays here while PLAY runs.** A start-still prop remains frozen until an explicit pickup; a bump alone does not start motion. |
|
||||||
|
| Moves with physics | **Can fall and move** | **Gravity and bumps can move it.** This avoids promising that a prop resting on terrain will always fall. |
|
||||||
|
| Holds other props | **Props can sit on top** | **Other props can land on this one.** Help sits inside this row, directly under its label, when On. |
|
||||||
|
| Blocks stickmen | **Blocks stickmen** | **Stickmen cannot walk through it.** Help sits inside this row when On; this setting describes its starting state. |
|
||||||
|
| Can be picked up | **Stickmen can pick it up** | When unavailable because there is no usable grip: **Add a hand hold in Prop Editor first.** Row is visibly disabled. |
|
||||||
|
| After letting go | **After letting go** | Appears only when pickup is enabled. A one-line intro says **After Set Down, Drop, or Throw:** |
|
||||||
|
| Bumps into stickmen | **Can bump into stickmen** | The release state is independent of **Blocks stickmen** at PLAY start. |
|
||||||
|
| Bumps into other props | **Can bump into other props** | When On, a released prop can land on a support prop; when Off, it passes through props and can still land on terrain. |
|
||||||
|
|
||||||
|
Copy is derived from roles during refresh, never stored in the scene. For a migrated scene with `legacy_draw_order: true`, show **Old scene order** and leave both front/behind buttons visually unselected. The child makes a new depth choice by pressing one; that action clears `legacy_draw_order` and selects the chosen button. The old scene's drawing order remains visible until then.
|
||||||
|
|
||||||
|
## Control and layout contract
|
||||||
|
|
||||||
|
| Item | Proposed logical size / behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Card | Preferred width 380 px; inset 12 px from the right edge. Clamp to viewport width minus 24 px. Fit height to visible content plus padding, capped between the top controls and status bar; scroll only when content exceeds that available height. |
|
||||||
|
| Depth and motion choice | Full card width, minimum 56 px high. A selected button shows **✓** and EDIT cyan fill/border; unselected buttons retain a distinct neutral surface. Pressed hover and focus must preserve the selected border. |
|
||||||
|
| Toggle row | Full card width, minimum 72 px high with one-line help; minimum 64 px without help. The entire row is one focusable/toggleable `Button`. The complete label and help area are clickable. |
|
||||||
|
| Switch drawing | At least **64 × 34 px** visible track with **26 px** knob and 12 px gap from text. Show **ON / OFF** inside or beside the track as well as a check on On. Disabled state retains readable text and a clear reason. |
|
||||||
|
| Type | Body 18–20 px, helper at least 16 px, headings 18–20 px, title 22–24 px. Wrap long labels; never clip them to keep the switch on one line. |
|
||||||
|
| Spacing | 12 px between groups, 6–8 px between options; at least 12 px inside row edges. Close remains at least 44 × 44 px. |
|
||||||
|
|
||||||
|
The existing card is anchored from a fixed top to a fixed bottom, creating a tall blank area. Size it from the content after the selection and conditional release controls refresh. Refit on viewport resize, font/theme change, and pickup toggle; clamp its height before enabling the `ScrollContainer`. Keep the selected prop visible where possible by leaving the central stage unobstructed. Reset scroll to top when selecting another prop; preserve scroll while toggling the current prop.
|
||||||
|
|
||||||
|
## Godot 4 implementation contract
|
||||||
|
|
||||||
|
```text
|
||||||
|
CanvasLayer
|
||||||
|
└─ PropSettingsCard : PanelContainer
|
||||||
|
└─ MarginContainer
|
||||||
|
└─ VBoxContainer
|
||||||
|
├─ Header : HBoxContainer (title, Close; remains visible)
|
||||||
|
└─ ScrollContainer
|
||||||
|
└─ VBoxContainer
|
||||||
|
├─ PropName : Label
|
||||||
|
├─ DepthChoices : VBoxContainer (two ButtonGroup buttons + overlap cues)
|
||||||
|
├─ MotionChoices : VBoxContainer (two ButtonGroup buttons + selected help)
|
||||||
|
├─ InitialRoles : VBoxContainer (three PropSettingsToggleRow buttons)
|
||||||
|
├─ AfterLettingGo : VBoxContainer (two PropSettingsToggleRow buttons)
|
||||||
|
└─ LegacyOrderCue : Label
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SandboxStage` (`scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D`) retains selection ownership and `PropBlock` role writes. `_on_prop_role_choice(key: String, value: String) -> void` and `_on_prop_role_toggle(enabled: bool, key: String) -> void` remain the only mutation entry points; they normalize roles through `PropBlock.set_stage_roles`, save the placement, reconcile contacts, mark the scene dirty, and refresh the card. `_refresh_prop_settings_card() -> void` sets all pressed states without emitting signals, applies selected styles/copy, and requests one deferred size fit.
|
||||||
|
- A small reusable `scripts/editors/stage/prop_settings_toggle_row.gd` class, `class_name PropSettingsToggleRow extends Button`, owns label/help presentation and vector switch drawing. Public methods: `configure(caption: String, help_text: String) -> void`, `set_help_text(value: String) -> void`, and `set_checked_no_signal(value: bool) -> void`. It uses inherited typed `toggled(toggled_on: bool)` and `disabled`. `toggle_mode = true`; all child labels use `MOUSE_FILTER_IGNORE`, so mouse, touch, and keyboard activation pass through the same button. Its `button_pressed` and `disabled` states drive `queue_redraw()` for the switch; there is no separate clickable switch node.
|
||||||
|
- Keep depth/motion choices as `ButtonGroup` members with stable role values (`PropStageRoles.DEPTH_BEHIND`, `DEPTH_FRONT`, `START_STILL`, `START_PHYSICS`). When `legacy_draw_order` is true, refresh leaves both depth buttons unpressed even though the normalized role dictionary carries a fallback `depth` value; pressing a depth button clears the legacy marker through the existing handler. Once the child has chosen a depth, refresh always restores exactly one pressed depth button. A compact vector overlap cue can be a private drawn `Control` inside each depth button; it has `MOUSE_FILTER_IGNORE` and no role logic. A choice's check, accent, and focus styling are refreshed from its `button_pressed` state, including after `set_pressed_no_signal()`.
|
||||||
|
- `SandboxTheme` (`scripts/stage/sandbox_theme.gd`, `class_name SandboxTheme extends RefCounted`) adds fallback values under `stage_prop_settings` for card width, choice height, toggle row height, switch width/height/knob size, row padding, helper size, and selected/neutral/disabled surfaces. Mirror them in `sandbox_theme.json`. Use `mode_colors.edit` for selected borders; use `global.text_color` and `global.muted_text_color` for label/help. Do not mutate the shared `Theme` returned by `control_theme()`; apply local stylebox overrides to this card's buttons. The switch and depth cues draw from resolved theme colors.
|
||||||
|
- `PropStageRoles` and `StageSceneIO` need no schema changes. The role values remain the same. The card must refresh when selection changes or `can_pick_up` reveals/hides release controls.
|
||||||
|
|
||||||
|
### Signal flow and example
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Tap[Press entire toggle row] --> Signal[Button.toggled enabled]
|
||||||
|
Signal --> Stage[SandboxStage._on_prop_role_toggle]
|
||||||
|
Stage --> Roles[PropBlock.set_stage_roles]
|
||||||
|
Stage --> Save[Save placement + mark dirty]
|
||||||
|
Stage --> Refresh[Refresh card + fit content height]
|
||||||
|
Refresh --> UI[Check, larger switch, helper, stage result]
|
||||||
|
```
|
||||||
|
|
||||||
|
For a crate with `{ "start_motion": "still", "holds_other_props": true, "blocks_stickmen": false, "can_pick_up": false }`, the card shows **Stays in place**, **Props can sit on top: ON**, **Blocks stickmen: OFF**, and **Stickmen can pick it up: OFF**. The crate holds a falling prop while the stickman can walk through it. Turning pickup On changes the still-state help to **Stays here until picked up** and reveals the two release collision choices; no change to the initial blocking setting is implied.
|
||||||
|
|
||||||
|
## Trade-offs and boundaries
|
||||||
|
|
||||||
|
- A dedicated drawn switch gives the requested 64 × 34 px visual size; increasing a stock `CheckButton`'s minimum row height only enlarges its click area and leaves its theme icon small. The custom row adds one modest UI class and must maintain focus, disabled, and keyboard states.
|
||||||
|
- The overlap cue is a small explanation of front/behind. The actual stage gives immediate visual feedback, so a separate preview area would consume useful stage space and repeat information.
|
||||||
|
- The role text is deliberately plain. **Can fall and move** describes what physics permits; it does not imply the prop always moves. **Stays in place** still allows explicit pickup, so its helper changes with pickup permission.
|
||||||
|
- This refinement covers the card only. It does not change prop physics, role persistence, Set Down, the overlap chooser, or future Use spot and impact controls.
|
||||||
|
|
||||||
|
## Acceptance for implementation
|
||||||
|
|
||||||
|
1. With a 4× scaled crate selected, all five role switches that can appear have visible tracks at least 64 × 34 px; clicking the label, helper, or switch changes exactly the intended role once.
|
||||||
|
2. New placements and scenes with an explicit depth show exactly one selected depth button. A migrated **Old scene order** placement shows the cue with neither front/behind button selected until the child makes a choice; then exactly one is selected and the cue disappears. The motion choices always show exactly one selected state. Checks and selected borders remain visible under mouse hover and keyboard focus. Front/behind cues agree with the selected depth, and the stage visibly updates.
|
||||||
|
3. A prop set to **Stays in place** remains still until a pickup action occurs; its helper says **Stays here until picked up** only when pickup is enabled. **Can fall and move** can remain at rest on terrain without contradictory wording.
|
||||||
|
4. **Props can sit on top** shows its help directly below that row, including when the release section appears. The two release choices remain independent and retain their values when pickup is toggled off and on.
|
||||||
|
5. On a tall viewport, the card ends near its final visible row. On a shorter viewport or with larger fonts, content scrolls, all controls remain reachable, and the Close button stays usable. Disabled pickup explains the missing hand hold.
|
||||||
|
6. Loading a scene with legacy draw order preserves its cue until the child chooses front or behind. Save/reopen preserves role values, and closing the card does not undo them.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Stage selection panel UI system — architecture specification
|
||||||
|
|
||||||
|
> **Status:** Approved for implementation. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. Modal dialogs, the Prop Editor, and the Stickman Editor are later candidates, outside this change.
|
||||||
|
|
||||||
|
## Purpose and current reference
|
||||||
|
|
||||||
|
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon and Close button; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls.
|
||||||
|
|
||||||
|
Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSettingsToggleRow` and its drawn switch implement one reusable control, but carry prop-specific names. `SandboxTheme` has `stage_prop_settings` tokens. `QueuePanel`, `RulePanel`, and action dialogs also create local style overrides, but they are popup workflows and do not join this first migration.
|
||||||
|
|
||||||
|
The goal is one shared look and interaction contract for the next Stage inspector, without moving prop rules, selection, or saved data into UI widgets.
|
||||||
|
|
||||||
|
## Child-facing contract
|
||||||
|
|
||||||
|
- A selection card has a recognizable icon, the selected object's name, and a large Close button. Long names wrap to at most two lines and expose their full name in a tooltip; Close never overlaps the title.
|
||||||
|
- Content uses the same heading, choice, switch, helper, spacing, corners, focus, hover, selected, and disabled styles. Each setting can still supply its own words, optional illustration, and reason for being unavailable.
|
||||||
|
- Choice labels stay stable when selected. Selection is shown by fill and border; text does not gain a checkmark or change width.
|
||||||
|
- A switch is clickable anywhere in its row. Showing or hiding help may change the row height, but the switch and ON/OFF label stay anchored at the top, so a second click at the same pointer position hits the same control.
|
||||||
|
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and Close remain reachable. Focus and selected states remain distinct from hover.
|
||||||
|
- After Close, a compact **Settings** button appears at the card's usual top-right position for the selected object. It reopens the card without requiring the child to deselect and reselect the object.
|
||||||
|
- Keyboard users can Tab to choices, switches, and Close; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
|
||||||
|
|
||||||
|
## Component hierarchy
|
||||||
|
|
||||||
|
```text
|
||||||
|
CanvasLayer (owned by SandboxStage)
|
||||||
|
└─ StageInspectorPanel : PanelContainer
|
||||||
|
└─ MarginContainer
|
||||||
|
└─ VBoxContainer
|
||||||
|
├─ Header : HBoxContainer
|
||||||
|
│ ├─ Icon : Label
|
||||||
|
│ ├─ Title : Label (selected item name)
|
||||||
|
│ └─ Close : Button
|
||||||
|
└─ ScrollContainer (vertical only)
|
||||||
|
└─ Body : VBoxContainer (caller adds sections and controls)
|
||||||
|
├─ Section heading : Label
|
||||||
|
├─ StagePanelChoiceGroup : VBoxContainer
|
||||||
|
│ └─ Choice : Button (+ optional decorative cue)
|
||||||
|
└─ StagePanelToggleRow : Button
|
||||||
|
└─ caption/help, vector switch, ON/OFF
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell owns layout and presentation. The caller owns the body composition and all domain values. A section heading remains a styled `Label` made by a small helper; it does not need its own subclass. Illustrations remain optional `Control` children with `MOUSE_FILTER_IGNORE`.
|
||||||
|
|
||||||
|
## Typed Godot 4 contracts
|
||||||
|
|
||||||
|
| Target | Public contract | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `func apply_theme(stage_theme: SandboxTheme) -> void`; `func set_header(title: String, icon_text: String = "") -> void`; `func body() -> VBoxContainer`; `func set_safe_rect(rect: Rect2) -> void`; `func request_fit() -> void`; `func reset_scroll() -> void` | Fixed header, scroll body, viewport fit, local theme. Emits Close; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
|
||||||
|
| `scripts/shared/stage_ui/stage_panel_choice_group.gd`, `class_name StagePanelChoiceGroup extends VBoxContainer` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func clear_no_signal() -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Exclusive choices with stable captions and native focus/activation. `clear_no_signal()` supports migrated prop draw order. Repressing the selected option leaves it selected. An optional cue is decorative, not clickable. |
|
||||||
|
| `scripts/shared/stage_ui/stage_panel_toggle_row.gd`, `class_name StagePanelToggleRow extends Button` | `signal preferred_height_changed`; inherited `signal toggled(toggled_on: bool)`; `func configure(caption: String) -> void`; `func set_help_text(text: String) -> void`; `func set_disabled_state(value: bool, reason: String = "") -> void`; `func set_checked_no_signal(value: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | One focusable full-row toggle, large drawn switch, ON/OFF label, help layout. Stores `_normal_help` and `_disabled_reason` separately; displayed help derives from `disabled`. Re-enabling restores normal help and never leaves a stale disabled reason. Child labels and switch ignore pointer events. |
|
||||||
|
| `scripts/shared/stage_ui/stage_panel_switch_visual.gd`, `class_name StagePanelSwitchVisual extends Control` | `func set_state(enabled: bool, unavailable: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Vector switch drawing only. No input or business signals. Reuses the current 64 × 34 track and 26 px knob. |
|
||||||
|
| `scripts/stage/sandbox_theme.gd`, `class_name SandboxTheme extends RefCounted` | Existing `integer()`, `color()`, `font()`, `stylebox()`, `control_theme()` accessors; add typed panel-style helpers only if needed by more than one control. | Validated shared token source. Return styles or local overrides; never mutate the cached `control_theme()` for one panel. |
|
||||||
|
| `scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D` | Existing prop role handlers and refresh path; use the shared controls' typed methods/signals. | Selection, visible panel decision, prop role writes, dirty state, contact reconciliation, and `CanvasLayer` ownership. Supplies safe rect from Stage chrome. |
|
||||||
|
|
||||||
|
The new shared scripts live under `scripts/shared/stage_ui/` because Stage-specific inspectors can be used by future stage controllers. `PropSettingsDepthCue` stays prop-specific under `scripts/editors/stage/`: the overlap drawing explains a prop role, not a general choice. A separate `Resource` schema or global signal bus is unnecessary for this small, synchronous UI.
|
||||||
|
|
||||||
|
### Signal and refresh flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Input[Choice or toggle pressed] --> Widget[Shared widget signal]
|
||||||
|
Widget --> Stage[SandboxStage prop handler]
|
||||||
|
Stage --> Role[PropBlock.set_stage_roles]
|
||||||
|
Stage --> Save[Save placement, reconcile, mark dirty]
|
||||||
|
Stage --> Refresh[Refresh controls without signals]
|
||||||
|
Refresh --> Fit[Panel request_fit, deferred once]
|
||||||
|
Close[Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
|
||||||
|
CloseSignal --> Stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Signal payloads are `StringName` for a choice ID and `bool` for a toggle. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. The panel emits `close_requested`; `SandboxStage` records the currently selected object as dismissed and keeps its card hidden through unrelated refreshes. `SandboxStage` shows a compact Settings button at the panel anchor while dismissed. A different selection or pressing Settings clears that dismissal. Closing does not undo a saved setting.
|
||||||
|
|
||||||
|
### Example prop adapter
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var depth_group: StagePanelChoiceGroup = StagePanelChoiceGroup.new()
|
||||||
|
depth_group.add_choice(&"behind", "Behind stickmen", behind_cue)
|
||||||
|
depth_group.add_choice(&"front", "In front of stickmen", front_cue)
|
||||||
|
depth_group.choice_selected.connect(_on_prop_depth_selected)
|
||||||
|
|
||||||
|
func _refresh_depth(roles: Dictionary) -> void:
|
||||||
|
if bool(roles.get("legacy_draw_order", false)):
|
||||||
|
depth_group.clear_no_signal()
|
||||||
|
else:
|
||||||
|
depth_group.select_no_signal(StringName(roles.get("depth", "behind")))
|
||||||
|
|
||||||
|
func _on_prop_depth_selected(id: StringName) -> void:
|
||||||
|
_on_prop_role_choice("depth", String(id))
|
||||||
|
```
|
||||||
|
|
||||||
|
The example omits the prop-specific depth cue construction. Existing `PropStageRoles` values and `StageSceneIO` serialization remain authoritative. No stage scene file format changes.
|
||||||
|
|
||||||
|
## Shared theme and layout tokens
|
||||||
|
|
||||||
|
Move reusable `stage_prop_settings` values into `stage_panels` in both `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json`. Resolve colors through `SandboxTheme` and apply per-node stylebox overrides without editing the shared `Theme` instance. Keep prop cue colors under `stage_prop_settings` or a prop-specific `stage_panels.prop` subtree.
|
||||||
|
|
||||||
|
| Token group | Shared values | Reference |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `stage_panels.shell` | preferred width, edge inset, padding, header gap, corner radius, surface/border | 380 px width; 12 px inset/padding |
|
||||||
|
| `stage_panels.type` | title, section, body, helper font sizes and text colors | 24 / 20 / 20 / 16 px |
|
||||||
|
| `stage_panels.choice` | minimum height, row gap, neutral/hover/selected/disabled surfaces, border, focus | 56 px height; 6 px gap |
|
||||||
|
| `stage_panels.toggle` | minimum height, help height, row padding, switch dimensions, track/knob colors, row surfaces | 64 / 72 px row; 64 × 34 px switch |
|
||||||
|
| `stage_panels.action` | minimum button target and Close width | at least 44 px target; Close at least 88 × 44 px |
|
||||||
|
|
||||||
|
Use `mode_colors.edit` as the current prop panel's accent, passed to shared controls through a small typed `set_accent(color: Color)` API if another inspector needs a different mode. Prefer existing `global.text_color` and `global.muted_text_color` as token fallbacks. Existing `stage_prop_settings` keys can be read as fallback for one migration release; afterward remove duplicated reads. `SandboxTheme` still owns validation and fallback behavior.
|
||||||
|
|
||||||
|
The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `SandboxStage` after the top and status bars and any reserved right-side chrome. It clamps width to `rect.size.x`, calculates natural content height, caps height at `rect.size.y`, and enables body scrolling as needed. It refits when the viewport or theme changes, conditional content appears, or a row's preferred height changes. Scroll returns to the top only when the selected object changes; toggling a setting preserves position.
|
||||||
|
|
||||||
|
## Interaction and accessibility rules
|
||||||
|
|
||||||
|
1. A `Button` or `ButtonGroup` supplies pointer, touch, and keyboard semantics. All decorative children use `MOUSE_FILTER_IGNORE`. Do not place a second active button inside a toggle row.
|
||||||
|
2. Switch and ON/OFF remain at a fixed top alignment while help grows below the caption. Control height comes from wrapped content and a minimum, never from vertically centering the switch in a changing row.
|
||||||
|
3. Selected, hovered, focused, and disabled styles each have a visible state. Focus remains visible over selected fill. Disabled rows retain readable text and show their reason in the row. If no reason exists, the caller must provide one before disabling a child-facing setting.
|
||||||
|
4. Choice state is apparent through fill and border; its label stays unchanged. The toggle also has ON/OFF text and knob position, so it does not depend on color alone.
|
||||||
|
5. The title can wrap to two lines; Close reserves its width. Full title is available as tooltip. The shell recomputes height after wrapping and keeps Close outside the scroll area.
|
||||||
|
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape follows the Stage's existing panel dismissal convention. Focus moves into the card when opened from keyboard and returns to the originating Stage control on Close when one exists.
|
||||||
|
|
||||||
|
## Migration sequence
|
||||||
|
|
||||||
|
1. Add shared theme tokens and small controls. Copy the current prop card's appearance and interaction rules as the reference. Keep current prop cue drawing and role handlers.
|
||||||
|
2. Rebuild only the placed-prop card using `StageInspectorPanel`, `StagePanelChoiceGroup`, and `StagePanelToggleRow`. Replace `PropSettingsToggleRow` / its switch visual with the shared classes or leave temporary thin compatibility aliases while existing preloads are changed. Avoid two independent implementations of switch behavior.
|
||||||
|
3. Delete prop-specific style and fit helpers from `SandboxStage` once the card uses the shell. Leave prop wording and role refresh in `SandboxStage` or a small prop-card adapter; do not put `PropBlock` dependencies in the shared controls.
|
||||||
|
4. Use the components for the next Stage selection inspector. Migrate the queue/rule popup panels or dialogs only in a later, separately scoped pass because their modal focus and layout differ from an in-stage inspector.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. The prop card retains the current selected-name header, fixed Close, no text checkmarks, front/behind cues, role semantics, and saved scene format. Legacy draw order still shows no selected depth until a child chooses one.
|
||||||
|
2. At the same viewport and theme, the migrated prop card's width, spacing, typography, colors, switch size, and selected states match the current card. Long prop names and wrapped labels cannot collide with Close or the switch.
|
||||||
|
3. Switching a row On and Off at the same pointer position works twice without moving the switch. Help appears beneath the caption. A disabled reason disappears on re-enable and normal help returns.
|
||||||
|
4. On short viewports, every body control is reachable by scroll while header and Close stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. After Close, the card stays hidden while that same object remains selected, including after role or viewport refreshes; a compact Settings button remains available at the card anchor to reopen it. Changing selection shows the new selection's card.
|
||||||
|
5. Pointer, touch, and keyboard activation each change a setting once. Disabled controls cannot mutate a role, and focus remains visible on both selected and neutral choices.
|
||||||
|
6. The next Stage inspector can create a shell, a choice group, and a toggle row using only `SandboxTheme` and shared APIs, with no copied panel geometry or stylebox code.
|
||||||
|
|
||||||
|
## Trade-offs and decisions
|
||||||
|
|
||||||
|
- **Small composable controls:** A shell, choice group, and toggle row remove repeated UI mechanics while keeping panel content explicit. A data-driven panel schema would hide conditional prop rules in dictionaries and complicate authoring.
|
||||||
|
- **Stage-owned state:** Shared controls emit intent and display values. `SandboxStage` remains the source of selection and prop mutations, preventing a generic inspector from depending on prop physics or serialization.
|
||||||
|
- **Theme tokens first:** Keeping token values in `SandboxTheme` makes new panels match by default. Local StyleBox instances prevent one panel's accent or focus override from changing every control that shares the cached `Theme`.
|
||||||
|
- **Incremental migration:** The prop card is the pilot; modal popup workflows have different focus and close semantics and are deferred.
|
||||||
|
|
||||||
|
## Decision and next adopter
|
||||||
|
|
||||||
|
1. **Panel dismissal — decided:** Closing a selection card keeps it dismissed while the same object remains selected. Changing selection or pressing the compact Settings button shows it again. The implementation tracks this in `SandboxStage`, separate from the shared shell.
|
||||||
|
2. **Next adopter — recommended:** Apply the shared kit to the next in-stage selected-object inspector as it is built. Existing queue/rule popups have different focus and sizing behavior and can be evaluated in a later pass.
|
||||||
|
|
||||||
+64
-1
@@ -120,6 +120,64 @@
|
|||||||
"drag_threshold": 8,
|
"drag_threshold": 8,
|
||||||
"selected_border_width": 3
|
"selected_border_width": 3
|
||||||
},
|
},
|
||||||
|
"stage_panels": {
|
||||||
|
"shell": {
|
||||||
|
"preferred_width": 380,
|
||||||
|
"edge_inset": 12,
|
||||||
|
"padding": 12,
|
||||||
|
"header_gap": 8,
|
||||||
|
"section_gap": 12,
|
||||||
|
"corner_radius": 10,
|
||||||
|
"surface_color": "#252B38FA",
|
||||||
|
"border_color": "#FFFFFF33"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"title_size": 24,
|
||||||
|
"section_size": 20,
|
||||||
|
"body_size": 20,
|
||||||
|
"helper_size": 16,
|
||||||
|
"text_color": "#F5F7FA",
|
||||||
|
"muted_text_color": "#AAB2C0"
|
||||||
|
},
|
||||||
|
"choice": {
|
||||||
|
"minimum_height": 56,
|
||||||
|
"row_gap": 6,
|
||||||
|
"neutral_surface": "#171B24",
|
||||||
|
"hover_surface": "#303A4B",
|
||||||
|
"selected_surface": "#12394A",
|
||||||
|
"disabled_surface": "#252B38",
|
||||||
|
"border_color": "#FFFFFF33"
|
||||||
|
},
|
||||||
|
"toggle": {
|
||||||
|
"minimum_height": 64,
|
||||||
|
"help_minimum_height": 72,
|
||||||
|
"row_padding": 12,
|
||||||
|
"switch_width": 64,
|
||||||
|
"switch_height": 34,
|
||||||
|
"knob_size": 26,
|
||||||
|
"neutral_surface": "#202633",
|
||||||
|
"hover_surface": "#2B3442",
|
||||||
|
"selected_surface": "#173443",
|
||||||
|
"disabled_surface": "#252B38",
|
||||||
|
"border_color": "#FFFFFF33",
|
||||||
|
"switch_on_color": "#22C6FF",
|
||||||
|
"switch_off_color": "#4A5362",
|
||||||
|
"switch_disabled_color": "#3B414D",
|
||||||
|
"knob_color": "#F5F7FA"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"min_target_size": 48,
|
||||||
|
"close_width": 88,
|
||||||
|
"surface_color": "#171B24",
|
||||||
|
"hover_surface": "#303A4B",
|
||||||
|
"selected_surface": "#12394A"
|
||||||
|
},
|
||||||
|
"prop": {
|
||||||
|
"stickman_cue_color": "#F5F7FA",
|
||||||
|
"prop_cue_color": "#B7824D",
|
||||||
|
"cue_outline_color": "#171B24"
|
||||||
|
}
|
||||||
|
},
|
||||||
"overlays": {
|
"overlays": {
|
||||||
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
|
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
|
||||||
"terrain_guide": { "color": "#22C6FF" },
|
"terrain_guide": { "color": "#22C6FF" },
|
||||||
@@ -127,7 +185,12 @@
|
|||||||
"completed_path_color": "#959EAD",
|
"completed_path_color": "#959EAD",
|
||||||
"valid": { "color": "#4DE66680" },
|
"valid": { "color": "#4DE66680" },
|
||||||
"same_type": { "color": "#FFFFFF26" },
|
"same_type": { "color": "#FFFFFF26" },
|
||||||
"invalid": { "color": "#FF4D4D80" }
|
"invalid": { "color": "#FF4D4D80" },
|
||||||
|
"set_down": {
|
||||||
|
"unknown_color": "#C5CAD4",
|
||||||
|
"reach_color": "#FFB300",
|
||||||
|
"ghost_alpha": 0.36
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
"swatches": [
|
"swatches": [
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
|||||||
ActionPhase.NONE:
|
ActionPhase.NONE:
|
||||||
_advance_to_next_action()
|
_advance_to_next_action()
|
||||||
ActionPhase.WALKING:
|
ActionPhase.WALKING:
|
||||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
if _locomotion != null:
|
||||||
_finish_action()
|
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
|
||||||
|
_finish_action()
|
||||||
ActionPhase.SPEAKING:
|
ActionPhase.SPEAKING:
|
||||||
if _primary_speech.is_empty():
|
if _primary_speech.is_empty():
|
||||||
_finish_action()
|
_finish_action()
|
||||||
@@ -398,7 +399,7 @@ func _advance_to_next_action() -> void:
|
|||||||
action.erase("speed")
|
action.erase("speed")
|
||||||
_current_run_item["action"] = action
|
_current_run_item["action"] = action
|
||||||
var action_type := String(action.get("type", ""))
|
var action_type := String(action.get("type", ""))
|
||||||
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
|
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop"
|
||||||
if requires_animated and _rig != null:
|
if requires_animated and _rig != null:
|
||||||
if _rig.state == StickmanRig.RigState.RECOVERING:
|
if _rig.state == StickmanRig.RigState.RECOVERING:
|
||||||
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
||||||
@@ -442,7 +443,7 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
|
|||||||
"recover":
|
"recover":
|
||||||
_action_phase = ActionPhase.RECOVERING
|
_action_phase = ActionPhase.RECOVERING
|
||||||
recovery_requested.emit()
|
recovery_requested.emit()
|
||||||
"pick_up_prop", "drop_prop", "throw_prop":
|
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop":
|
||||||
if _rig == null or not _rig.start_prop_action(action):
|
if _rig == null or not _rig.start_prop_action(action):
|
||||||
_finish_action()
|
_finish_action()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ extends Node
|
|||||||
## StickmanRig schedules this component and exposes its public facade.
|
## StickmanRig schedules this component and exposes its public facade.
|
||||||
|
|
||||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
|
|
||||||
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const ARM_COLLISION_RADIUS: float = 12.0
|
|||||||
const LEG_COLLISION_RADIUS: float = 14.0
|
const LEG_COLLISION_RADIUS: float = 14.0
|
||||||
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
||||||
const COLLISION_LAYER: int = 1
|
const COLLISION_LAYER: int = 1
|
||||||
|
const WALK_BLOCKED_TIMEOUT: float = 0.45
|
||||||
const COLLISION_PARTS: Dictionary = {
|
const COLLISION_PARTS: Dictionary = {
|
||||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||||
@@ -140,6 +142,8 @@ var _arrival_emitted: bool = false
|
|||||||
var _walk_done: bool = false
|
var _walk_done: bool = false
|
||||||
var _leg_base_markers: Dictionary = {}
|
var _leg_base_markers: Dictionary = {}
|
||||||
var _last_grounded_walk_velocity_x: float = 0.0
|
var _last_grounded_walk_velocity_x: float = 0.0
|
||||||
|
var _blocked_walk_elapsed: float = 0.0
|
||||||
|
var _walk_failed: bool = false
|
||||||
var collision_bodies: Dictionary = {}
|
var collision_bodies: Dictionary = {}
|
||||||
var collision_lengths: Dictionary = {}
|
var collision_lengths: Dictionary = {}
|
||||||
var _collision_visuals: Dictionary = {}
|
var _collision_visuals: Dictionary = {}
|
||||||
@@ -190,8 +194,8 @@ func setup_collision_bodies() -> void:
|
|||||||
continue
|
continue
|
||||||
var proxy := AnimatableBody2D.new()
|
var proxy := AnimatableBody2D.new()
|
||||||
proxy.name = "CollisionBody"
|
proxy.name = "CollisionBody"
|
||||||
proxy.collision_layer = COLLISION_LAYER
|
proxy.collision_layer = STAGE_PHYSICS.ACTOR_LAYER
|
||||||
proxy.collision_mask = COLLISION_LAYER
|
proxy.collision_mask = STAGE_PHYSICS.actor_mask()
|
||||||
proxy.sync_to_physics = false
|
proxy.sync_to_physics = false
|
||||||
proxy.set_meta("stickman_rig", _rig)
|
proxy.set_meta("stickman_rig", _rig)
|
||||||
proxy.set_meta("part_name", key)
|
proxy.set_meta("part_name", key)
|
||||||
@@ -254,11 +258,12 @@ func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||||||
var layer: int = COLLISION_LAYER if enabled else 0
|
var layer: int = STAGE_PHYSICS.ACTOR_LAYER if enabled else 0
|
||||||
|
var mask: int = STAGE_PHYSICS.actor_mask() if enabled else 0
|
||||||
for proxy: AnimatableBody2D in collision_bodies.values():
|
for proxy: AnimatableBody2D in collision_bodies.values():
|
||||||
if proxy != null and is_instance_valid(proxy):
|
if proxy != null and is_instance_valid(proxy):
|
||||||
proxy.collision_layer = layer
|
proxy.collision_layer = layer
|
||||||
proxy.collision_mask = layer
|
proxy.collision_mask = mask
|
||||||
|
|
||||||
|
|
||||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
|
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
|
||||||
@@ -378,6 +383,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
|
|||||||
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
|
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
|
||||||
_nav_agent.max_speed = _walk_speed_current
|
_nav_agent.max_speed = _walk_speed_current
|
||||||
_walk_done = false
|
_walk_done = false
|
||||||
|
_walk_failed = false
|
||||||
|
_blocked_walk_elapsed = 0.0
|
||||||
_arrival_emitted = false
|
_arrival_emitted = false
|
||||||
_settling = false
|
_settling = false
|
||||||
_settle_waiting_for_ground = false
|
_settle_waiting_for_ground = false
|
||||||
@@ -387,6 +394,10 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
|
|||||||
_reset_foot_contacts()
|
_reset_foot_contacts()
|
||||||
var target_root: Vector2 = target + FOOT_OFFSET
|
var target_root: Vector2 = target + FOOT_OFFSET
|
||||||
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
|
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
|
||||||
|
var close_motion: Vector2 = target_root - _rig.global_position
|
||||||
|
var close_sweep: Dictionary = _sweep_actor_blocking_props(close_motion)
|
||||||
|
if float(close_sweep.get("fraction", 1.0)) < 0.999:
|
||||||
|
return false
|
||||||
_rig.global_position = target_root
|
_rig.global_position = target_root
|
||||||
_walking = false
|
_walking = false
|
||||||
_walk_done = true
|
_walk_done = true
|
||||||
@@ -411,6 +422,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
|
|||||||
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
||||||
if _rig == null:
|
if _rig == null:
|
||||||
return
|
return
|
||||||
|
_walk_failed = false
|
||||||
|
_blocked_walk_elapsed = 0.0
|
||||||
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
||||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
||||||
_rig._anim_player.stop(false)
|
_rig._anim_player.stop(false)
|
||||||
@@ -457,6 +470,12 @@ func consume_walk_completed() -> bool:
|
|||||||
_walk_done = false
|
_walk_done = false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func consume_walk_failed() -> bool:
|
||||||
|
if not _walk_failed:
|
||||||
|
return false
|
||||||
|
_walk_failed = false
|
||||||
|
return true
|
||||||
|
|
||||||
func _update_walking(delta: float) -> void:
|
func _update_walking(delta: float) -> void:
|
||||||
if not _walking:
|
if not _walking:
|
||||||
return
|
return
|
||||||
@@ -485,6 +504,12 @@ func _update_walking(delta: float) -> void:
|
|||||||
var root_target: Vector2
|
var root_target: Vector2
|
||||||
if _walk_mode == "nav":
|
if _walk_mode == "nav":
|
||||||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||||
|
var snap_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
|
||||||
|
var snap_sweep: Dictionary = _sweep_actor_blocking_props(snap_motion)
|
||||||
|
if float(snap_sweep.get("fraction", 1.0)) < 0.999:
|
||||||
|
_update_blocked_walk(delta)
|
||||||
|
return
|
||||||
|
_blocked_walk_elapsed = 0.0
|
||||||
_rig.global_position.x = final_root.x
|
_rig.global_position.x = final_root.x
|
||||||
_finish_walk()
|
_finish_walk()
|
||||||
return
|
return
|
||||||
@@ -499,12 +524,68 @@ func _update_walking(delta: float) -> void:
|
|||||||
else:
|
else:
|
||||||
root_target = Vector2(final_root.x, _rig.global_position.y)
|
root_target = Vector2(final_root.x, _rig.global_position.y)
|
||||||
var before_x: float = _rig.global_position.x
|
var before_x: float = _rig.global_position.x
|
||||||
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
|
var requested_motion := _rig.global_position.move_toward(root_target, _walk_speed_current * delta) - _rig.global_position
|
||||||
|
var sweep: Dictionary = _sweep_actor_blocking_props(requested_motion)
|
||||||
|
var safe_fraction: float = float(sweep.get("fraction", 1.0))
|
||||||
|
_rig.global_position += requested_motion * safe_fraction
|
||||||
|
if safe_fraction < 0.999:
|
||||||
|
_update_blocked_walk(delta)
|
||||||
|
else:
|
||||||
|
_blocked_walk_elapsed = 0.0
|
||||||
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
|
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
|
||||||
|
if _walk_failed:
|
||||||
|
return
|
||||||
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
|
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
|
||||||
|
var arrival_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
|
||||||
|
var arrival_sweep: Dictionary = _sweep_actor_blocking_props(arrival_motion)
|
||||||
|
if float(arrival_sweep.get("fraction", 1.0)) < 0.999:
|
||||||
|
_update_blocked_walk(delta)
|
||||||
|
return
|
||||||
_rig.global_position.x = final_root.x
|
_rig.global_position.x = final_root.x
|
||||||
_finish_walk()
|
_finish_walk()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_blocked_walk(delta: float) -> void:
|
||||||
|
_blocked_walk_elapsed += delta
|
||||||
|
if _blocked_walk_elapsed < WALK_BLOCKED_TIMEOUT:
|
||||||
|
return
|
||||||
|
cancel_walk(false)
|
||||||
|
_walk_failed = true
|
||||||
|
_blocked_walk_elapsed = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
|
||||||
|
if _rig == null or is_zero_approx(motion.x):
|
||||||
|
return {"fraction": 1.0}
|
||||||
|
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||||
|
if space_state == null:
|
||||||
|
return {"fraction": 1.0}
|
||||||
|
var excluded: Array[RID] = []
|
||||||
|
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
||||||
|
if own_proxy != null and is_instance_valid(own_proxy):
|
||||||
|
excluded.append(own_proxy.get_rid())
|
||||||
|
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
|
||||||
|
if held != null and is_instance_valid(held):
|
||||||
|
excluded.append(held.get_rid())
|
||||||
|
var safe_fraction: float = 1.0
|
||||||
|
for part_name: String in ["torso", "head"]:
|
||||||
|
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
||||||
|
if proxy == null or not is_instance_valid(proxy):
|
||||||
|
continue
|
||||||
|
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||||
|
if shape_node == null or shape_node.shape == null:
|
||||||
|
continue
|
||||||
|
var query := PhysicsShapeQueryParameters2D.new()
|
||||||
|
query.shape = shape_node.shape
|
||||||
|
query.transform = shape_node.global_transform
|
||||||
|
query.motion = motion
|
||||||
|
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||||
|
query.exclude = excluded
|
||||||
|
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||||
|
if cast.size() >= 1:
|
||||||
|
safe_fraction = minf(safe_fraction, cast[0])
|
||||||
|
return {"fraction": safe_fraction}
|
||||||
|
|
||||||
func _settle_walk_markers(delta: float) -> void:
|
func _settle_walk_markers(delta: float) -> void:
|
||||||
if not _settling or _rig == null:
|
if not _settling or _rig == null:
|
||||||
return
|
return
|
||||||
@@ -984,7 +1065,7 @@ func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
|
|||||||
query.shape = shape_node.shape
|
query.shape = shape_node.shape
|
||||||
query.transform = shape_node.global_transform
|
query.transform = shape_node.global_transform
|
||||||
query.motion = motion
|
query.motion = motion
|
||||||
query.collision_mask = COLLISION_LAYER
|
query.collision_mask = STAGE_PHYSICS.ACTOR_QUERY_MASK
|
||||||
query.exclude = excluded
|
query.exclude = excluded
|
||||||
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||||
if cast.size() >= 1:
|
if cast.size() >= 1:
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ extends Node
|
|||||||
|
|
||||||
const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
|
const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
|
||||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
|
|
||||||
enum Hand { LEFT, RIGHT }
|
enum Hand { LEFT, RIGHT }
|
||||||
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
|
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING }
|
||||||
|
|
||||||
const MAX_REACH: float = 390.0
|
const MAX_REACH: float = 390.0
|
||||||
const APPROACH_GAP: float = 90.0
|
const APPROACH_GAP: float = 90.0
|
||||||
const APPROACH_REPLAN_LIMIT: int = 1
|
const APPROACH_REPLAN_LIMIT: int = 1
|
||||||
const REACH_DURATION: float = 0.34
|
const REACH_DURATION: float = 0.34
|
||||||
|
const SET_DOWN_REACH_DURATION: float = 0.22
|
||||||
|
const SET_DOWN_RECOVER_DURATION: float = 0.24
|
||||||
const PICKUP_RECOVER_DURATION: float = 0.32
|
const PICKUP_RECOVER_DURATION: float = 0.32
|
||||||
const LOW_PICKUP_THRESHOLD: float = 110.0
|
const LOW_PICKUP_THRESHOLD: float = 110.0
|
||||||
const LOW_PICKUP_MAX_DROP: float = 110.0
|
const LOW_PICKUP_MAX_DROP: float = 110.0
|
||||||
@@ -29,6 +33,7 @@ const THROW_SPIN_SPEED: float = 2.4
|
|||||||
const THROW_TRAJECTORY_SEGMENTS: int = 24
|
const THROW_TRAJECTORY_SEGMENTS: int = 24
|
||||||
const THROW_HEAD_CLEARANCE: float = 36.0
|
const THROW_HEAD_CLEARANCE: float = 36.0
|
||||||
const THROW_REACH_MARGIN: float = 8.0
|
const THROW_REACH_MARGIN: float = 8.0
|
||||||
|
const PROP_TRACE_ENABLED: bool = true
|
||||||
|
|
||||||
signal action_completed
|
signal action_completed
|
||||||
signal prop_event(event_type: String, prop: PropBlock, reason: String)
|
signal prop_event(event_type: String, prop: PropBlock, reason: String)
|
||||||
@@ -69,9 +74,33 @@ var _throw_torso_windup_rotation: float = 0.0
|
|||||||
var _approach_replans: int = 0
|
var _approach_replans: int = 0
|
||||||
var _marker_start_position: Vector2 = Vector2.ZERO
|
var _marker_start_position: Vector2 = Vector2.ZERO
|
||||||
var _saved_body_state: Dictionary = {}
|
var _saved_body_state: Dictionary = {}
|
||||||
|
var _pickup_transform: Transform2D = Transform2D.IDENTITY
|
||||||
|
var _pickup_transform_valid: bool = false
|
||||||
var _last_transform: Transform2D = Transform2D.IDENTITY
|
var _last_transform: Transform2D = Transform2D.IDENTITY
|
||||||
var _sampled_linear_velocity: Vector2 = Vector2.ZERO
|
var _sampled_linear_velocity: Vector2 = Vector2.ZERO
|
||||||
var _sampled_angular_velocity: float = 0.0
|
var _sampled_angular_velocity: float = 0.0
|
||||||
|
var _set_down_target: Vector2 = Vector2.ZERO
|
||||||
|
var _set_down_grip_target: Vector2 = Vector2.ZERO
|
||||||
|
var _set_down_approach_root: Vector2 = Vector2.ZERO
|
||||||
|
var _set_down_facing_left: bool = false
|
||||||
|
var _set_down_release_transform: Transform2D = Transform2D.IDENTITY
|
||||||
|
var _set_down_reach_start_transform: Transform2D = Transform2D.IDENTITY
|
||||||
|
var _set_down_recover_elapsed: float = 0.0
|
||||||
|
var _set_down_recover_hand_start: Vector2 = Vector2.ZERO
|
||||||
|
var _trace_released_prop: PropBlock
|
||||||
|
var _trace_released_elapsed: float = 0.0
|
||||||
|
var _trace_released_watch_remaining: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not PROP_TRACE_ENABLED or _trace_released_prop == null or not is_instance_valid(_trace_released_prop) or _trace_released_watch_remaining <= 0.0:
|
||||||
|
return
|
||||||
|
_trace_released_elapsed += delta
|
||||||
|
_trace_released_watch_remaining -= delta
|
||||||
|
if _trace_released_elapsed < 0.5:
|
||||||
|
return
|
||||||
|
_trace_released_elapsed = 0.0
|
||||||
|
_trace_prop_state("released_watch", _trace_released_prop)
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
@@ -135,6 +164,8 @@ func start_action(action: Dictionary) -> bool:
|
|||||||
_throw_released = false
|
_throw_released = false
|
||||||
_phase = Phase.THROWING
|
_phase = Phase.THROWING
|
||||||
return true
|
return true
|
||||||
|
"set_down_prop":
|
||||||
|
return _begin_set_down(action)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
@@ -143,10 +174,24 @@ func tick(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
match _phase:
|
match _phase:
|
||||||
Phase.APPROACHING:
|
Phase.APPROACHING:
|
||||||
|
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
|
||||||
|
_fail_pickup_approach("Too far to reach")
|
||||||
|
return
|
||||||
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
|
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
|
||||||
_on_approach_arrived()
|
_on_approach_arrived()
|
||||||
|
Phase.SET_DOWN_APPROACHING:
|
||||||
|
_update_held_transform(delta)
|
||||||
|
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
|
||||||
|
_fail_set_down("Too far to reach")
|
||||||
|
return
|
||||||
|
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
|
||||||
|
_on_set_down_approach_arrived()
|
||||||
Phase.REACHING:
|
Phase.REACHING:
|
||||||
_update_reach(delta)
|
_update_reach(delta)
|
||||||
|
Phase.SET_DOWN_REACHING:
|
||||||
|
_update_set_down_reach(delta)
|
||||||
|
Phase.SET_DOWN_RECOVERING:
|
||||||
|
_update_set_down_recovery(delta)
|
||||||
Phase.PICKUP_RECOVERING:
|
Phase.PICKUP_RECOVERING:
|
||||||
_update_pickup_recovery(delta)
|
_update_pickup_recovery(delta)
|
||||||
Phase.HELD:
|
Phase.HELD:
|
||||||
@@ -156,8 +201,14 @@ func tick(delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func cancel_action() -> void:
|
func cancel_action() -> void:
|
||||||
if _phase == Phase.APPROACHING and _locomotion != null:
|
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING) and _locomotion != null:
|
||||||
_locomotion.call("cancel_walk")
|
_locomotion.call("cancel_walk")
|
||||||
|
if _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING:
|
||||||
|
_restore_hand_target()
|
||||||
|
_restore_pickup_torso()
|
||||||
|
_phase = Phase.HELD if _held_prop != null and is_instance_valid(_held_prop) else Phase.IDLE
|
||||||
|
_active_action.clear()
|
||||||
|
return
|
||||||
if _phase == Phase.APPROACHING or _phase == Phase.REACHING:
|
if _phase == Phase.APPROACHING or _phase == Phase.REACHING:
|
||||||
_clear_reservation()
|
_clear_reservation()
|
||||||
_restore_hand_target()
|
_restore_hand_target()
|
||||||
@@ -224,6 +275,33 @@ func preview_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dic
|
|||||||
return _clip_preview_to_collision(solution, prop)
|
return _clip_preview_to_collision(solution, prop)
|
||||||
|
|
||||||
|
|
||||||
|
func preview_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
|
||||||
|
if not target.is_finite():
|
||||||
|
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
|
||||||
|
var context := _build_throw_preview_context(queue_prefix)
|
||||||
|
if not bool(context.get("available", false)) or bool(context.get("uncertain", false)):
|
||||||
|
return {
|
||||||
|
"known": false,
|
||||||
|
"valid": false,
|
||||||
|
"reason": "Prop depends on earlier actions",
|
||||||
|
"target_position": target,
|
||||||
|
}
|
||||||
|
var prop: PropBlock = context["prop"]
|
||||||
|
var result := _solve_set_down_target(
|
||||||
|
target,
|
||||||
|
prop,
|
||||||
|
context.get("anchor", {}),
|
||||||
|
int(context.get("hand", Hand.RIGHT)),
|
||||||
|
context.get("root_position", _rig.global_position),
|
||||||
|
context.get("root_offset", Vector2.ZERO),
|
||||||
|
bool(context.get("facing_left", _is_facing_left())),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
result["known"] = true
|
||||||
|
result["prop"] = prop
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary:
|
func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary:
|
||||||
if _rig == null or not is_instance_valid(_rig):
|
if _rig == null or not is_instance_valid(_rig):
|
||||||
return {"available": false, "reason": "The acting stickman is unavailable."}
|
return {"available": false, "reason": "The acting stickman is unavailable."}
|
||||||
@@ -297,7 +375,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
|
|||||||
has_prop = true
|
has_prop = true
|
||||||
planned = true
|
planned = true
|
||||||
plan_step = int(entry.get("step", 0))
|
plan_step = int(entry.get("step", 0))
|
||||||
"drop_prop", "throw_prop":
|
"drop_prop", "throw_prop", "set_down_prop":
|
||||||
if has_prop:
|
if has_prop:
|
||||||
prop = null
|
prop = null
|
||||||
anchor.clear()
|
anchor.clear()
|
||||||
@@ -315,6 +393,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
|
|||||||
"hand": hand_value,
|
"hand": hand_value,
|
||||||
"root_position": root_position,
|
"root_position": root_position,
|
||||||
"root_offset": root_offset,
|
"root_offset": root_offset,
|
||||||
|
"facing_left": predicted_facing_left,
|
||||||
"planned": planned,
|
"planned": planned,
|
||||||
"uncertain": uncertain,
|
"uncertain": uncertain,
|
||||||
"plan_step": plan_step,
|
"plan_step": plan_step,
|
||||||
@@ -479,19 +558,22 @@ func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2)
|
|||||||
|
|
||||||
func disarm_for_ragdoll() -> void:
|
func disarm_for_ragdoll() -> void:
|
||||||
var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING
|
var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING
|
||||||
|
var interrupted_set_down := _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING
|
||||||
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
|
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
|
||||||
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING:
|
if interrupted_pickup or interrupted_set_down:
|
||||||
cancel_action()
|
cancel_action()
|
||||||
elif interrupted_windup:
|
elif interrupted_windup:
|
||||||
cancel_action()
|
cancel_action()
|
||||||
if _held_prop != null and is_instance_valid(_held_prop):
|
if _held_prop != null and is_instance_valid(_held_prop):
|
||||||
_release_held("ragdoll", false)
|
_release_held("ragdoll", false)
|
||||||
if interrupted_pickup or interrupted_windup:
|
if interrupted_pickup or interrupted_set_down or interrupted_windup:
|
||||||
action_completed.emit()
|
action_completed.emit()
|
||||||
|
|
||||||
|
|
||||||
func reset_for_scene_exit() -> void:
|
func reset_for_scene_exit() -> void:
|
||||||
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
|
if PROP_TRACE_ENABLED:
|
||||||
|
print("[PropTrace] reset_for_scene_exit actor=%s phase=%s held=%s" % [_trace_actor_name(), Phase.keys()[int(_phase)], _trace_prop_id(_held_prop)])
|
||||||
|
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
|
||||||
cancel_action()
|
cancel_action()
|
||||||
if _held_prop != null and is_instance_valid(_held_prop):
|
if _held_prop != null and is_instance_valid(_held_prop):
|
||||||
_release_held("scene_reset", false)
|
_release_held("scene_reset", false)
|
||||||
@@ -501,6 +583,8 @@ func reset_for_scene_exit() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
|
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
print("[PropTrace] pickup_begin actor=%s target_id=%s hand=%s phase=%s" % [_trace_actor_name(), prop_id, requested_hand, Phase.keys()[int(_phase)]])
|
||||||
if _phase != Phase.IDLE or _held_prop != null or _rig == null:
|
if _phase != Phase.IDLE or _held_prop != null or _rig == null:
|
||||||
return false
|
return false
|
||||||
if int(_rig.get("state")) != 0:
|
if int(_rig.get("state")) != 0:
|
||||||
@@ -511,8 +595,11 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
|
|||||||
if not resolved is PropBlock:
|
if not resolved is PropBlock:
|
||||||
return false
|
return false
|
||||||
var prop := resolved as PropBlock
|
var prop := resolved as PropBlock
|
||||||
|
_trace_prop_state("pickup_resolved", prop)
|
||||||
if not is_instance_valid(prop) or prop.is_queued_for_deletion():
|
if not is_instance_valid(prop) or prop.is_queued_for_deletion():
|
||||||
return false
|
return false
|
||||||
|
if not prop.can_be_picked_up():
|
||||||
|
return false
|
||||||
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
|
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
|
||||||
return false
|
return false
|
||||||
var picked := _choose_grip_and_hand(prop, requested_hand)
|
var picked := _choose_grip_and_hand(prop, requested_hand)
|
||||||
@@ -567,6 +654,506 @@ func _choose_grip_and_hand(prop: PropBlock, requested_hand: String) -> Dictionar
|
|||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_set_down(action: Dictionary) -> bool:
|
||||||
|
if _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
|
prop_event.emit("set_down_failed", null, "No prop is held")
|
||||||
|
return false
|
||||||
|
var raw_target: Variant = action.get("target_position", Vector2.INF)
|
||||||
|
if not raw_target is Vector2 or not (raw_target as Vector2).is_finite():
|
||||||
|
prop_event.emit("set_down_failed", _held_prop, "Choose a release place")
|
||||||
|
return false
|
||||||
|
if _phase != Phase.HELD and _phase != Phase.IDLE:
|
||||||
|
return false
|
||||||
|
var target := raw_target as Vector2
|
||||||
|
var preview := _solve_set_down_target(target, _held_prop, _held_anchor, int(_hand), _rig.global_position, Vector2.ZERO, _is_facing_left(), true)
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
var resolved_transform: Transform2D = preview.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
print("[PropTrace] set_down_begin actor=%s prop=%s requested=%s valid=%s reason=%s resolved=%s approach=%s grip=%s" % [_trace_actor_name(), _trace_prop_id(_held_prop), str(target), str(preview.get("valid", false)), String(preview.get("reason", "")), str(resolved_transform.origin), str(preview.get("approach_position", Vector2.INF)), str(preview.get("grip_position", Vector2.INF))])
|
||||||
|
if not bool(preview.get("valid", false)):
|
||||||
|
prop_event.emit("set_down_failed", _held_prop, String(preview.get("reason", "Too far to reach")))
|
||||||
|
return false
|
||||||
|
_set_down_target = target
|
||||||
|
_set_down_grip_target = preview["grip_position"]
|
||||||
|
_set_down_approach_root = preview["approach_position"]
|
||||||
|
_set_down_facing_left = bool(preview["facing_left"])
|
||||||
|
_set_down_release_transform = preview["release_transform"]
|
||||||
|
_active_action = action.duplicate(true)
|
||||||
|
if absf(_set_down_approach_root.x - _rig.global_position.x) > 8.0:
|
||||||
|
if _set_down_facing_left != _is_facing_left():
|
||||||
|
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
|
||||||
|
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
|
||||||
|
var target_feet := _set_down_approach_root - foot_offset
|
||||||
|
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
|
||||||
|
_active_action.clear()
|
||||||
|
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
|
||||||
|
return false
|
||||||
|
_phase = Phase.SET_DOWN_APPROACHING
|
||||||
|
_trace_prop_state("set_down_approaching", _held_prop)
|
||||||
|
return true
|
||||||
|
_begin_set_down_reach()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _solve_set_down_target(target: Vector2, prop: PropBlock, anchor: Dictionary, hand_value: int, actor_root: Vector2, root_offset: Vector2, facing_left: bool, check_occupancy: bool) -> Dictionary:
|
||||||
|
if not target.is_finite() or prop == null or not is_instance_valid(prop):
|
||||||
|
return {"valid": false, "reason": "Too far to reach"}
|
||||||
|
var release_transform := _set_down_transform_for_target(prop, target)
|
||||||
|
var clearance_lift := 0.0
|
||||||
|
if check_occupancy:
|
||||||
|
var resolved := _resolve_set_down_release_transform(prop, target)
|
||||||
|
if not bool(resolved.get("valid", false)):
|
||||||
|
return {"valid": false, "reason": String(resolved.get("reason", "No room to let go here")), "release_transform": release_transform}
|
||||||
|
release_transform = resolved["release_transform"]
|
||||||
|
clearance_lift = float(resolved.get("clearance_lift", 0.0))
|
||||||
|
var anchor_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
|
||||||
|
var prop_geometry := _set_down_collision_geometry(prop)
|
||||||
|
var prop_shape: Shape2D = prop_geometry.get("shape") as Shape2D
|
||||||
|
var prop_bounds := _set_down_shape_bounds(prop_shape, release_transform * (prop_geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)) if prop_shape != null else Rect2(target, Vector2.ZERO)
|
||||||
|
var candidates: Array[Dictionary] = []
|
||||||
|
var current_grip_local := anchor_position
|
||||||
|
if facing_left:
|
||||||
|
current_grip_local.x = -current_grip_local.x
|
||||||
|
var current_grip := release_transform * current_grip_local
|
||||||
|
var current_wrist := _preview_wrist_position(hand_value, root_offset, facing_left)
|
||||||
|
if current_wrist.distance_to(current_grip) <= MAX_REACH and not _set_down_actor_overlaps_release(prop_bounds, actor_root, facing_left):
|
||||||
|
candidates.append({
|
||||||
|
"root": actor_root,
|
||||||
|
"facing_left": facing_left,
|
||||||
|
"grip": current_grip,
|
||||||
|
"distance": current_wrist.distance_to(current_grip),
|
||||||
|
})
|
||||||
|
for actor_on_right: bool in [false, true]:
|
||||||
|
var candidate_facing_left := actor_on_right
|
||||||
|
var grip_local := anchor_position
|
||||||
|
if candidate_facing_left:
|
||||||
|
grip_local.x = -grip_local.x
|
||||||
|
var grip_world := release_transform * grip_local
|
||||||
|
var side := 1.0 if actor_on_right else -1.0
|
||||||
|
var approach_root := Vector2(grip_world.x + side * APPROACH_GAP, actor_root.y)
|
||||||
|
for body_rect: Rect2 in _set_down_actor_body_rects_relative(candidate_facing_left):
|
||||||
|
var body_world := body_rect
|
||||||
|
body_world.position += actor_root
|
||||||
|
if body_world.end.y <= prop_bounds.position.y or body_world.position.y >= prop_bounds.end.y:
|
||||||
|
continue
|
||||||
|
if actor_on_right:
|
||||||
|
approach_root.x = maxf(approach_root.x, prop_bounds.end.x - body_rect.position.x + 8.0)
|
||||||
|
else:
|
||||||
|
approach_root.x = minf(approach_root.x, prop_bounds.position.x - body_rect.end.x - 8.0)
|
||||||
|
var candidate_offset := root_offset + (approach_root - actor_root)
|
||||||
|
var wrist := _preview_wrist_position(hand_value, candidate_offset, candidate_facing_left)
|
||||||
|
var distance := wrist.distance_to(grip_world)
|
||||||
|
if distance <= MAX_REACH and _set_down_route_is_clear(actor_root, approach_root, root_offset):
|
||||||
|
candidates.append({
|
||||||
|
"root": approach_root,
|
||||||
|
"facing_left": candidate_facing_left,
|
||||||
|
"grip": grip_world,
|
||||||
|
"distance": distance,
|
||||||
|
})
|
||||||
|
if candidates.is_empty():
|
||||||
|
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
|
||||||
|
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
|
return float(a.get("distance", INF)) < float(b.get("distance", INF))
|
||||||
|
)
|
||||||
|
var chosen: Dictionary = candidates[0]
|
||||||
|
if not _set_down_route_is_clear(actor_root, chosen["root"], root_offset):
|
||||||
|
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
|
||||||
|
return {
|
||||||
|
"valid": true,
|
||||||
|
"reason": "",
|
||||||
|
"release_transform": release_transform,
|
||||||
|
"requested_target": target,
|
||||||
|
"clearance_lift": clearance_lift,
|
||||||
|
"target_adjusted": clearance_lift > 0.01,
|
||||||
|
"grip_position": chosen["grip"],
|
||||||
|
"approach_position": chosen["root"],
|
||||||
|
"facing_left": bool(chosen["facing_left"]),
|
||||||
|
"distance": float(chosen["distance"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_route_is_clear(start_root: Vector2, target_root: Vector2, root_offset: Vector2) -> bool:
|
||||||
|
if _rig == null or not is_instance_valid(_rig):
|
||||||
|
return false
|
||||||
|
var nav_agent := _rig.get_node_or_null(NodePath("NavigationAgentAnchor/NavigationAgent2D")) as NavigationAgent2D
|
||||||
|
if nav_agent != null:
|
||||||
|
var nav_map: RID = nav_agent.get_navigation_map()
|
||||||
|
if NavigationServer2D.map_get_iteration_id(nav_map) > 0:
|
||||||
|
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
|
||||||
|
var path: PackedVector2Array = NavigationServer2D.map_get_path(nav_map, start_root - foot_offset, target_root - foot_offset, true)
|
||||||
|
if path.is_empty():
|
||||||
|
return false
|
||||||
|
var delta := Vector2(target_root.x - start_root.x, 0.0)
|
||||||
|
if is_zero_approx(delta.x):
|
||||||
|
return true
|
||||||
|
var space_state := _rig.get_world_2d().direct_space_state
|
||||||
|
if space_state == null:
|
||||||
|
return true
|
||||||
|
var excluded: Array[RID] = []
|
||||||
|
for rid: RID in _rig.call("get_collision_body_rids"):
|
||||||
|
excluded.append(rid)
|
||||||
|
if _held_prop != null and is_instance_valid(_held_prop):
|
||||||
|
excluded.append(_held_prop.get_rid())
|
||||||
|
var collision_bodies: Dictionary = _locomotion.get("collision_bodies") if _locomotion != null else {}
|
||||||
|
for part_name: String in ["torso", "head"]:
|
||||||
|
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
|
||||||
|
if proxy == null or not is_instance_valid(proxy):
|
||||||
|
continue
|
||||||
|
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||||
|
if shape_node == null or shape_node.shape == null:
|
||||||
|
continue
|
||||||
|
var query := PhysicsShapeQueryParameters2D.new()
|
||||||
|
query.shape = shape_node.shape
|
||||||
|
query.transform = shape_node.global_transform
|
||||||
|
query.transform.origin += root_offset
|
||||||
|
query.motion = delta
|
||||||
|
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||||
|
query.exclude = excluded
|
||||||
|
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||||
|
if not cast.is_empty() and cast[0] < 0.999:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_set_down_release_transform(prop: PropBlock, target: Vector2) -> Dictionary:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
return {"valid": false, "reason": "No room to let go here"}
|
||||||
|
var release_transform := _set_down_transform_for_target(prop, target)
|
||||||
|
var total_lift := 0.0
|
||||||
|
for _attempt: int in 8:
|
||||||
|
var overlap_info := _set_down_target_overlap_info(prop, release_transform)
|
||||||
|
var overlaps: Array = overlap_info.get("overlaps", [])
|
||||||
|
if overlaps.is_empty():
|
||||||
|
return {
|
||||||
|
"valid": true,
|
||||||
|
"release_transform": release_transform,
|
||||||
|
"clearance_lift": total_lift,
|
||||||
|
}
|
||||||
|
if bool(overlap_info.get("blocked", false)):
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
for entry: Dictionary in overlaps:
|
||||||
|
var blocker: Object = entry.get("collider")
|
||||||
|
var blocker_position := (blocker as Node2D).global_position if blocker is Node2D else Vector2.INF
|
||||||
|
var part_name := String((blocker as Node).get_meta("part_name", "")) if blocker is Node else ""
|
||||||
|
print("[PropTrace] set_down_blocker prop=%s collider=%s class=%s part=%s adjustable=%s position=%s" % [_trace_prop_id(prop), String((blocker as Node).name) if blocker is Node else "<unknown>", blocker.get_class() if blocker is Object else "<unknown>", part_name, str(entry.get("adjustable", false)), str(blocker_position)])
|
||||||
|
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
|
||||||
|
var highest_support_top := INF
|
||||||
|
for entry: Dictionary in overlaps:
|
||||||
|
var collider: Object = entry.get("collider")
|
||||||
|
var support_top := _set_down_support_top_y(collider)
|
||||||
|
if not is_finite(support_top):
|
||||||
|
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
|
||||||
|
highest_support_top = minf(highest_support_top, support_top)
|
||||||
|
var geometry: Dictionary = _set_down_collision_geometry(prop)
|
||||||
|
var shape: Shape2D = geometry.get("shape") as Shape2D
|
||||||
|
if shape == null:
|
||||||
|
return {"valid": true, "release_transform": release_transform, "clearance_lift": total_lift}
|
||||||
|
var shape_transform: Transform2D = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
|
||||||
|
var shape_bounds := _set_down_shape_bounds(shape, shape_transform)
|
||||||
|
var lift := shape_bounds.end.y - highest_support_top + 2.0
|
||||||
|
if not is_finite(lift) or lift <= 0.0:
|
||||||
|
lift = 2.0
|
||||||
|
total_lift += lift
|
||||||
|
if total_lift > 8192.0:
|
||||||
|
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
|
||||||
|
release_transform.origin.y -= lift
|
||||||
|
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_collision_geometry(prop: PropBlock) -> Dictionary:
|
||||||
|
var collision_shape: Shape2D = null
|
||||||
|
var local_transform := Transform2D.IDENTITY
|
||||||
|
var polygon_node := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
|
||||||
|
if polygon_node != null and not polygon_node.disabled and polygon_node.polygon.size() >= 3:
|
||||||
|
var points := Geometry2D.convex_hull(polygon_node.polygon)
|
||||||
|
if points.size() >= 3:
|
||||||
|
var convex := ConvexPolygonShape2D.new()
|
||||||
|
convex.points = points
|
||||||
|
collision_shape = convex
|
||||||
|
local_transform = prop.global_transform.affine_inverse() * polygon_node.global_transform
|
||||||
|
var circle_node := prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||||
|
if collision_shape == null and circle_node != null and circle_node.shape != null and not circle_node.disabled:
|
||||||
|
collision_shape = circle_node.shape
|
||||||
|
local_transform = prop.global_transform.affine_inverse() * circle_node.global_transform
|
||||||
|
if collision_shape == null:
|
||||||
|
return {}
|
||||||
|
return {"shape": collision_shape, "local_transform": local_transform}
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_target_overlap_info(prop: PropBlock, release_transform: Transform2D) -> Dictionary:
|
||||||
|
var geometry := _set_down_collision_geometry(prop)
|
||||||
|
var collision_shape: Shape2D = geometry.get("shape") as Shape2D
|
||||||
|
if collision_shape == null:
|
||||||
|
return {"overlaps": [], "blocked": false}
|
||||||
|
var space_state: PhysicsDirectSpaceState2D = prop.get_world_2d().direct_space_state
|
||||||
|
if space_state == null:
|
||||||
|
return {"overlaps": [], "blocked": false}
|
||||||
|
var query := PhysicsShapeQueryParameters2D.new()
|
||||||
|
query.shape = collision_shape
|
||||||
|
query.transform = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
|
||||||
|
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.PROP_LAYER
|
||||||
|
if bool(prop.stage_roles.get("released_blocks_stickmen", true)):
|
||||||
|
query.collision_mask |= STAGE_PHYSICS.ACTOR_LAYER
|
||||||
|
query.exclude = [prop.get_rid()]
|
||||||
|
var overlaps: Array[Dictionary] = space_state.intersect_shape(query, 64)
|
||||||
|
var blocking_overlaps: Array[Dictionary] = []
|
||||||
|
var blocked := false
|
||||||
|
for overlap: Dictionary in overlaps:
|
||||||
|
var collider: Object = overlap.get("collider")
|
||||||
|
if collider == null:
|
||||||
|
continue
|
||||||
|
if collider is TerrainBlock:
|
||||||
|
blocking_overlaps.append({"collider": collider, "adjustable": true})
|
||||||
|
continue
|
||||||
|
if collider is PropBlock:
|
||||||
|
var other := collider as PropBlock
|
||||||
|
if PROP_STAGE_ROLES.props_should_collide(prop.stage_roles, PropBlock.RuntimeRole.RELEASED, other.stage_roles, int(other.runtime_role)):
|
||||||
|
var adjustable := bool(other.stage_roles.get("holds_other_props", false))
|
||||||
|
blocking_overlaps.append({"collider": collider, "adjustable": adjustable})
|
||||||
|
blocked = blocked or not adjustable
|
||||||
|
continue
|
||||||
|
if collider is CollisionObject2D and ((collider as CollisionObject2D).collision_layer & STAGE_PHYSICS.ACTOR_LAYER) != 0:
|
||||||
|
if collider is Node and (collider as Node).has_meta("stickman_rig") and (collider as Node).get_meta("stickman_rig") == _rig:
|
||||||
|
var part_name := String((collider as Node).get_meta("part_name", ""))
|
||||||
|
if part_name != "head" and part_name != "torso":
|
||||||
|
continue
|
||||||
|
blocking_overlaps.append({"collider": collider, "adjustable": false})
|
||||||
|
blocked = true
|
||||||
|
return {"overlaps": blocking_overlaps, "blocked": blocked}
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_actor_body_rects_relative(facing_left: bool) -> Array[Rect2]:
|
||||||
|
var result: Array[Rect2] = []
|
||||||
|
if _rig == null or not is_instance_valid(_rig) or _locomotion == null:
|
||||||
|
return result
|
||||||
|
var collision_bodies: Dictionary = _locomotion.get("collision_bodies")
|
||||||
|
for part_name: String in ["torso", "head"]:
|
||||||
|
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
|
||||||
|
if proxy == null or not is_instance_valid(proxy):
|
||||||
|
continue
|
||||||
|
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||||
|
if shape_node == null or shape_node.shape == null:
|
||||||
|
continue
|
||||||
|
var bounds := _set_down_shape_bounds(shape_node.shape, shape_node.global_transform)
|
||||||
|
var local_rect := Rect2(bounds.position - _rig.global_position, bounds.size)
|
||||||
|
if facing_left != _is_facing_left():
|
||||||
|
local_rect.position.x = -local_rect.end.x
|
||||||
|
result.append(local_rect)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_actor_overlaps_release(prop_bounds: Rect2, actor_root: Vector2, facing_left: bool) -> bool:
|
||||||
|
for body_rect: Rect2 in _set_down_actor_body_rects_relative(facing_left):
|
||||||
|
var body_world := body_rect
|
||||||
|
body_world.position += actor_root
|
||||||
|
if body_world.intersects(prop_bounds):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_shape_bounds(shape: Shape2D, shape_transform: Transform2D) -> Rect2:
|
||||||
|
var local_rect := shape.get_rect()
|
||||||
|
var corners := [
|
||||||
|
local_rect.position,
|
||||||
|
Vector2(local_rect.end.x, local_rect.position.y),
|
||||||
|
local_rect.end,
|
||||||
|
Vector2(local_rect.position.x, local_rect.end.y),
|
||||||
|
]
|
||||||
|
var first := true
|
||||||
|
var bounds := Rect2()
|
||||||
|
for corner: Vector2 in corners:
|
||||||
|
var point := shape_transform * corner
|
||||||
|
if first:
|
||||||
|
bounds = Rect2(point, Vector2.ZERO)
|
||||||
|
first = false
|
||||||
|
else:
|
||||||
|
bounds = bounds.expand(point)
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_transform_for_target(prop: PropBlock, target: Vector2) -> Transform2D:
|
||||||
|
var transform := prop.global_transform
|
||||||
|
if prop == _held_prop and _pickup_transform_valid:
|
||||||
|
transform = _pickup_transform
|
||||||
|
transform.origin = target
|
||||||
|
return transform
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_support_top_y(collider: Object) -> float:
|
||||||
|
var collision_polygon: CollisionPolygon2D = null
|
||||||
|
if collider is TerrainBlock:
|
||||||
|
collision_polygon = (collider as TerrainBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
|
||||||
|
elif collider is PropBlock:
|
||||||
|
collision_polygon = (collider as PropBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
|
||||||
|
if collision_polygon != null and not collision_polygon.disabled and collision_polygon.polygon.size() >= 3:
|
||||||
|
var top_y := INF
|
||||||
|
for point: Vector2 in collision_polygon.polygon:
|
||||||
|
top_y = minf(top_y, (collision_polygon.global_transform * point).y)
|
||||||
|
return top_y
|
||||||
|
if collider is PropBlock:
|
||||||
|
var circle_node := (collider as PropBlock).get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||||
|
if circle_node != null and circle_node.shape != null and not circle_node.disabled:
|
||||||
|
return _set_down_shape_bounds(circle_node.shape, circle_node.global_transform).position.y
|
||||||
|
return INF
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_set_down_reach() -> void:
|
||||||
|
var marker := _hand_marker()
|
||||||
|
if marker == null or _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
|
_fail_set_down("Too far to reach")
|
||||||
|
return
|
||||||
|
if _set_down_facing_left != _is_facing_left():
|
||||||
|
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
|
||||||
|
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
|
||||||
|
_reach_start = marker.global_position
|
||||||
|
_reach_target = _set_down_grip_target
|
||||||
|
_reach_elapsed = 0.0
|
||||||
|
_set_down_reach_start_transform = _held_prop.global_transform
|
||||||
|
_prepare_set_down_crouch(_set_down_grip_target)
|
||||||
|
_phase = Phase.SET_DOWN_REACHING
|
||||||
|
_trace_prop_state("set_down_reaching", _held_prop)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_set_down_approach_arrived() -> void:
|
||||||
|
_trace_prop_state("set_down_approach_arrived", _held_prop)
|
||||||
|
if _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
|
_fail_set_down("No prop is held")
|
||||||
|
return
|
||||||
|
if not _refresh_set_down_release_target():
|
||||||
|
_fail_set_down("No room to let go here")
|
||||||
|
return
|
||||||
|
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
|
||||||
|
_fail_set_down("Too far to reach")
|
||||||
|
return
|
||||||
|
_begin_set_down_reach()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_set_down_reach(delta: float) -> void:
|
||||||
|
if _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
|
_fail_set_down("No prop is held")
|
||||||
|
return
|
||||||
|
var marker := _hand_marker()
|
||||||
|
if marker == null:
|
||||||
|
_fail_set_down("Too far to reach")
|
||||||
|
return
|
||||||
|
_reach_elapsed += delta
|
||||||
|
var weight := smoothstep(0.0, 1.0, clampf(_reach_elapsed / SET_DOWN_REACH_DURATION, 0.0, 1.0))
|
||||||
|
marker.global_position = _reach_start.lerp(_set_down_grip_target, weight)
|
||||||
|
_update_pickup_crouch(weight)
|
||||||
|
_update_held_transform(delta, true)
|
||||||
|
if _pickup_transform_valid:
|
||||||
|
_held_prop.global_transform = _set_down_reach_attachment_transform(marker.global_position, weight)
|
||||||
|
if weight < 1.0:
|
||||||
|
return
|
||||||
|
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
|
||||||
|
_fail_set_down("Too far to reach")
|
||||||
|
return
|
||||||
|
var previous_release_position := _set_down_release_transform.origin
|
||||||
|
if not _refresh_set_down_release_target():
|
||||||
|
_fail_set_down("No room to let go here")
|
||||||
|
return
|
||||||
|
if previous_release_position.distance_to(_set_down_release_transform.origin) > 1.0:
|
||||||
|
_reach_start = marker.global_position
|
||||||
|
_reach_target = _set_down_grip_target
|
||||||
|
_reach_elapsed = 0.0
|
||||||
|
_set_down_reach_start_transform = _held_prop.global_transform
|
||||||
|
return
|
||||||
|
marker.global_position = _set_down_grip_target
|
||||||
|
_held_prop.global_transform = _set_down_release_transform
|
||||||
|
_set_down_recover_hand_start = marker.position
|
||||||
|
_set_down_recover_elapsed = 0.0
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
print("[PropTrace] set_down_release requested=%s final=%s" % [str(_set_down_target), str(_set_down_release_transform.origin)])
|
||||||
|
_release_held("set_down", false, Vector2.ZERO, 0.0, true, false)
|
||||||
|
_phase = Phase.SET_DOWN_RECOVERING
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_set_down_release_target() -> bool:
|
||||||
|
if _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
|
return false
|
||||||
|
var result := _resolve_set_down_release_transform(_held_prop, _set_down_target)
|
||||||
|
if not bool(result.get("valid", false)):
|
||||||
|
return false
|
||||||
|
_set_down_release_transform = result["release_transform"]
|
||||||
|
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _set_down_reach_attachment_transform(grip_position: Vector2, weight: float) -> Transform2D:
|
||||||
|
var start_rotation := _set_down_reach_start_transform.get_rotation()
|
||||||
|
var target_rotation := _pickup_transform.get_rotation()
|
||||||
|
var start_scale := _set_down_reach_start_transform.get_scale()
|
||||||
|
var target_scale := _pickup_transform.get_scale()
|
||||||
|
var clamped_weight := clampf(weight, 0.0, 1.0)
|
||||||
|
var transform := Transform2D(
|
||||||
|
lerp_angle(start_rotation, target_rotation, clamped_weight),
|
||||||
|
Vector2.ZERO
|
||||||
|
).scaled(start_scale.lerp(target_scale, clamped_weight))
|
||||||
|
transform.origin = grip_position - transform.basis_xform(_scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left))
|
||||||
|
return transform
|
||||||
|
|
||||||
|
|
||||||
|
func _prepare_set_down_crouch(grip_position: Vector2) -> void:
|
||||||
|
var torso_marker := _torso_marker()
|
||||||
|
_pickup_uses_crouch = false
|
||||||
|
if torso_marker == null:
|
||||||
|
return
|
||||||
|
_pickup_torso_start = torso_marker.position
|
||||||
|
_pickup_torso_start_rotation = torso_marker.rotation
|
||||||
|
_pickup_torso_crouched = _pickup_torso_start
|
||||||
|
_pickup_torso_crouched_rotation = _pickup_torso_start_rotation
|
||||||
|
var grip_local := _rig.to_local(grip_position)
|
||||||
|
var low_amount := clampf((grip_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
|
||||||
|
if low_amount <= 0.0:
|
||||||
|
return
|
||||||
|
var toward_prop := signf(grip_local.x - _pickup_torso_start.x)
|
||||||
|
if is_zero_approx(toward_prop):
|
||||||
|
toward_prop = -1.0 if _set_down_facing_left else 1.0
|
||||||
|
_pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount)
|
||||||
|
_pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount
|
||||||
|
_pickup_uses_crouch = true
|
||||||
|
_rig.call("_set_limb_mods_enabled", true)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_set_down_recovery(delta: float) -> void:
|
||||||
|
_set_down_recover_elapsed += delta
|
||||||
|
var weight := smoothstep(0.0, 1.0, clampf(_set_down_recover_elapsed / SET_DOWN_RECOVER_DURATION, 0.0, 1.0))
|
||||||
|
var marker := _hand_marker()
|
||||||
|
if marker != null:
|
||||||
|
marker.position = _set_down_recover_hand_start.lerp(_marker_start_position, weight)
|
||||||
|
_update_pickup_crouch(1.0 - weight)
|
||||||
|
if weight < 1.0:
|
||||||
|
return
|
||||||
|
_restore_hand_target()
|
||||||
|
_restore_pickup_torso()
|
||||||
|
_phase = Phase.IDLE
|
||||||
|
_active_action.clear()
|
||||||
|
action_completed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _fail_set_down(reason: String) -> void:
|
||||||
|
if _locomotion != null and _phase == Phase.SET_DOWN_APPROACHING:
|
||||||
|
_locomotion.call("cancel_walk")
|
||||||
|
var prop := _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
print("[PropTrace] set_down_failed actor=%s prop=%s reason=%s phase=%s actor_position=%s" % [_trace_actor_name(), _trace_prop_id(prop), reason, Phase.keys()[int(_phase)], str(_rig.global_position if _rig != null else Vector2.INF)])
|
||||||
|
_restore_hand_target()
|
||||||
|
_restore_pickup_torso()
|
||||||
|
_phase = Phase.HELD if prop != null else Phase.IDLE
|
||||||
|
_active_action.clear()
|
||||||
|
prop_event.emit("set_down_failed", prop, reason)
|
||||||
|
action_completed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _fail_pickup_approach(reason: String) -> void:
|
||||||
|
_clear_reservation()
|
||||||
|
_restore_hand_target()
|
||||||
|
_restore_pickup_torso()
|
||||||
|
_phase = Phase.IDLE
|
||||||
|
_active_action.clear()
|
||||||
|
prop_event.emit("pickup_failed", null, reason)
|
||||||
|
action_completed.emit()
|
||||||
|
|
||||||
|
|
||||||
func _start_approach(grip_position: Vector2) -> bool:
|
func _start_approach(grip_position: Vector2) -> bool:
|
||||||
if _locomotion == null:
|
if _locomotion == null:
|
||||||
return false
|
return false
|
||||||
@@ -683,13 +1270,12 @@ func _commit_pickup(marker: Marker2D) -> void:
|
|||||||
"linear_velocity": _held_prop.linear_velocity,
|
"linear_velocity": _held_prop.linear_velocity,
|
||||||
"angular_velocity": _held_prop.angular_velocity,
|
"angular_velocity": _held_prop.angular_velocity,
|
||||||
}
|
}
|
||||||
|
_pickup_transform = _held_prop.global_transform
|
||||||
|
_pickup_transform_valid = true
|
||||||
_held_prop.interaction_reserved_by = 0
|
_held_prop.interaction_reserved_by = 0
|
||||||
_held_prop.interaction_owner_id = _rig.get_instance_id()
|
_held_prop.interaction_owner_id = _rig.get_instance_id()
|
||||||
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
_held_prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||||
_held_prop.freeze = true
|
_trace_prop_state("pickup_committed", _held_prop)
|
||||||
_held_prop.sleeping = false
|
|
||||||
_held_prop.collision_layer = 0
|
|
||||||
_held_prop.collision_mask = 0
|
|
||||||
if _pickup_uses_crouch:
|
if _pickup_uses_crouch:
|
||||||
_pickup_recover_elapsed = 0.0
|
_pickup_recover_elapsed = 0.0
|
||||||
_phase = Phase.PICKUP_RECOVERING
|
_phase = Phase.PICKUP_RECOVERING
|
||||||
@@ -986,45 +1572,66 @@ func _quadratic_bezier(from: Vector2, control: Vector2, to: Vector2, weight: flo
|
|||||||
return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t
|
return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t
|
||||||
|
|
||||||
|
|
||||||
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0) -> void:
|
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0, zero_velocity: bool = false, restore_hand: bool = true) -> void:
|
||||||
if _held_prop == null or not is_instance_valid(_held_prop):
|
if _held_prop == null or not is_instance_valid(_held_prop):
|
||||||
_clear_held_state()
|
_clear_held_state()
|
||||||
return
|
return
|
||||||
var prop := _held_prop
|
var prop := _held_prop
|
||||||
var release_velocity := throw_velocity if throwing else _sampled_linear_velocity
|
_trace_prop_state("release_begin:%s" % reason, prop)
|
||||||
var release_angular := _sampled_angular_velocity
|
var force_still_release: bool = zero_velocity or reason == "scene_reset"
|
||||||
|
var release_velocity := Vector2.ZERO if force_still_release else (throw_velocity if throwing else _sampled_linear_velocity)
|
||||||
|
var release_angular := 0.0 if force_still_release else (throw_spin if throwing else _sampled_angular_velocity)
|
||||||
if not release_velocity.is_finite():
|
if not release_velocity.is_finite():
|
||||||
release_velocity = Vector2.ZERO
|
release_velocity = Vector2.ZERO
|
||||||
if not is_finite(release_angular):
|
if not is_finite(release_angular):
|
||||||
release_angular = 0.0
|
release_angular = 0.0
|
||||||
prop.interaction_owner_id = 0
|
prop.interaction_owner_id = 0
|
||||||
prop.interaction_reserved_by = 0
|
prop.interaction_reserved_by = 0
|
||||||
prop.collision_layer = int(_saved_body_state.get("collision_layer", 1))
|
var target_role := PropBlock.RuntimeRole.RELEASED
|
||||||
prop.collision_mask = int(_saved_body_state.get("collision_mask", 1))
|
if reason == "scene_reset":
|
||||||
prop.z_index = int(_saved_body_state.get("z_index", prop.z_index))
|
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
|
||||||
prop.z_as_relative = bool(_saved_body_state.get("z_as_relative", prop.z_as_relative))
|
prop.apply_runtime_role(target_role)
|
||||||
prop.freeze_mode = int(_saved_body_state.get("freeze_mode", RigidBody2D.FREEZE_MODE_KINEMATIC))
|
prop.sleeping = false
|
||||||
prop.freeze = false if throwing else bool(_saved_body_state.get("freeze", false))
|
prop.linear_velocity = release_velocity
|
||||||
if throwing:
|
prop.angular_velocity = release_angular
|
||||||
prop.sleeping = false
|
|
||||||
prop.linear_velocity = release_velocity
|
|
||||||
prop.angular_velocity = throw_spin
|
|
||||||
elif not prop.freeze:
|
|
||||||
prop.sleeping = false
|
|
||||||
prop.linear_velocity = release_velocity
|
|
||||||
prop.angular_velocity = release_angular
|
|
||||||
_held_prop = null
|
_held_prop = null
|
||||||
_held_anchor.clear()
|
_held_anchor.clear()
|
||||||
_saved_body_state.clear()
|
_saved_body_state.clear()
|
||||||
|
_pickup_transform_valid = false
|
||||||
_sampled_linear_velocity = Vector2.ZERO
|
_sampled_linear_velocity = Vector2.ZERO
|
||||||
_sampled_angular_velocity = 0.0
|
_sampled_angular_velocity = 0.0
|
||||||
if not throwing:
|
if not throwing and restore_hand:
|
||||||
_restore_hand_target()
|
_restore_hand_target()
|
||||||
_phase = Phase.IDLE if not throwing else Phase.THROWING
|
_phase = Phase.IDLE if not throwing else Phase.THROWING
|
||||||
if reason == "manual" or reason == "ragdoll":
|
if reason == "manual" or reason == "ragdoll" or reason == "set_down":
|
||||||
prop_event.emit("prop_dropped", prop, reason)
|
prop_event.emit("prop_dropped", prop, reason)
|
||||||
elif reason == "throw":
|
elif reason == "throw":
|
||||||
prop_event.emit("prop_thrown", prop, "")
|
prop_event.emit("prop_thrown", prop, "")
|
||||||
|
if PROP_TRACE_ENABLED:
|
||||||
|
_trace_released_prop = prop
|
||||||
|
_trace_released_elapsed = 0.0
|
||||||
|
_trace_released_watch_remaining = 5.0
|
||||||
|
_trace_prop_state("release_complete:%s" % reason, prop)
|
||||||
|
|
||||||
|
|
||||||
|
func _trace_actor_name() -> String:
|
||||||
|
return String(_rig.name) if _rig != null and is_instance_valid(_rig) else "<no actor>"
|
||||||
|
|
||||||
|
|
||||||
|
func _trace_prop_id(prop: PropBlock) -> String:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
return "<none>"
|
||||||
|
return prop.stage_object_id if not prop.stage_object_id.is_empty() else String(prop.name)
|
||||||
|
|
||||||
|
|
||||||
|
func _trace_prop_state(event: String, prop: PropBlock) -> void:
|
||||||
|
if not PROP_TRACE_ENABLED:
|
||||||
|
return
|
||||||
|
var timestamp_ms := Time.get_ticks_msec()
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
print("[PropTrace t=%dms] %s actor=%s prop=<none> phase=%s" % [timestamp_ms, event, _trace_actor_name(), Phase.keys()[int(_phase)]])
|
||||||
|
return
|
||||||
|
print("[PropTrace t=%dms] %s actor=%s prop=%s phase=%s role=%d freeze=%s layer=%d mask=%d position=%s rotation=%.3f velocity=%s owner=%d reserved=%d" % [timestamp_ms, event, _trace_actor_name(), _trace_prop_id(prop), Phase.keys()[int(_phase)], int(prop.runtime_role), str(prop.freeze), prop.collision_layer, prop.collision_mask, str(prop.global_position), prop.global_rotation, str(prop.linear_velocity), prop.interaction_owner_id, prop.interaction_reserved_by])
|
||||||
|
|
||||||
|
|
||||||
func _clear_reservation() -> void:
|
func _clear_reservation() -> void:
|
||||||
@@ -1041,6 +1648,7 @@ func _clear_held_state() -> void:
|
|||||||
_held_prop = null
|
_held_prop = null
|
||||||
_held_anchor.clear()
|
_held_anchor.clear()
|
||||||
_saved_body_state.clear()
|
_saved_body_state.clear()
|
||||||
|
_pickup_transform_valid = false
|
||||||
_phase = Phase.IDLE
|
_phase = Phase.IDLE
|
||||||
_active_action.clear()
|
_active_action.clear()
|
||||||
|
|
||||||
@@ -1091,6 +1699,13 @@ func _scaled_anchor(anchor: Dictionary, prop: PropBlock) -> Dictionary:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _scaled_anchor_position(anchor: Dictionary, prop: PropBlock, facing_left: bool) -> Vector2:
|
||||||
|
var local_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
|
||||||
|
if facing_left:
|
||||||
|
local_position.x = -local_position.x
|
||||||
|
return local_position
|
||||||
|
|
||||||
|
|
||||||
func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2:
|
func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2:
|
||||||
return _grip_world_position_for_facing(prop, anchor, _is_facing_left())
|
return _grip_world_position_for_facing(prop, anchor, _is_facing_left())
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
|
|||||||
const GROUND_PROBE_START_OFFSET := 50.0
|
const GROUND_PROBE_START_OFFSET := 50.0
|
||||||
const GROUND_PROBE_END_OFFSET := 1500.0
|
const GROUND_PROBE_END_OFFSET := 1500.0
|
||||||
const GROUND_PROBE_TOLERANCE := 200.0
|
const GROUND_PROBE_TOLERANCE := 200.0
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||||||
const STAND_UP_DURATION := 2.0
|
const STAND_UP_DURATION := 2.0
|
||||||
const STAND_POSE: Dictionary = {
|
const STAND_POSE: Dictionary = {
|
||||||
@@ -818,7 +819,7 @@ func _on_stand_up_finished(generation: int) -> void:
|
|||||||
|
|
||||||
func _build_body(entry: Dictionary) -> void:
|
func _build_body(entry: Dictionary) -> void:
|
||||||
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
|
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
|
||||||
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
|
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = STAGE_PHYSICS.ACTOR_LAYER; body.collision_mask = STAGE_PHYSICS.actor_mask()
|
||||||
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
|
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
|
||||||
var shape := CollisionShape2D.new()
|
var shape := CollisionShape2D.new()
|
||||||
if str(entry["kind"]) == "visual":
|
if str(entry["kind"]) == "visual":
|
||||||
|
|||||||
@@ -538,6 +538,12 @@ func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -
|
|||||||
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
|
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
|
||||||
|
if _prop_interactor_component == null:
|
||||||
|
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
|
||||||
|
return _prop_interactor_component.call("preview_set_down", target, queue_prefix)
|
||||||
|
|
||||||
|
|
||||||
func reset_prop_interactions_for_scene_exit() -> void:
|
func reset_prop_interactions_for_scene_exit() -> void:
|
||||||
if _prop_interactor_component != null:
|
if _prop_interactor_component != null:
|
||||||
_prop_interactor_component.call("reset_for_scene_exit")
|
_prop_interactor_component.call("reset_for_scene_exit")
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ func set_throw_target(pos: Vector2) -> void:
|
|||||||
popup_centered()
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func set_down_target(pos: Vector2) -> void:
|
||||||
|
_has_throw_target = true
|
||||||
|
_throw_target_pos = pos
|
||||||
|
_update_throw_target_label()
|
||||||
|
popup_centered()
|
||||||
|
|
||||||
|
|
||||||
func current_action_type() -> String:
|
func current_action_type() -> String:
|
||||||
return _current_type()
|
return _current_type()
|
||||||
|
|
||||||
@@ -294,14 +301,14 @@ func _rebuild_params(type: String) -> void:
|
|||||||
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
|
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
|
||||||
_hand_option.select(maxi(hand_idx, 0))
|
_hand_option.select(maxi(hand_idx, 0))
|
||||||
hand_row.add_child(_hand_option)
|
hand_row.add_child(_hand_option)
|
||||||
"throw_prop":
|
"throw_prop", "set_down_prop":
|
||||||
var throw_row := HBoxContainer.new()
|
var throw_row := HBoxContainer.new()
|
||||||
_params_box.add_child(throw_row)
|
_params_box.add_child(throw_row)
|
||||||
_target_label = Label.new()
|
_target_label = Label.new()
|
||||||
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
throw_row.add_child(_target_label)
|
throw_row.add_child(_target_label)
|
||||||
_set_target_btn = Button.new()
|
_set_target_btn = Button.new()
|
||||||
_set_target_btn.text = "🎯 Click target…"
|
_set_target_btn.text = ("📍 Change release place" if _has_throw_target else "📍 Choose release place") if type == "set_down_prop" else ("🎯 Change target…" if _has_throw_target else "🎯 Click target…")
|
||||||
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||||
throw_row.add_child(_set_target_btn)
|
throw_row.add_child(_set_target_btn)
|
||||||
_update_throw_target_label()
|
_update_throw_target_label()
|
||||||
@@ -410,10 +417,11 @@ func _update_prop_target_label() -> void:
|
|||||||
func _update_throw_target_label() -> void:
|
func _update_throw_target_label() -> void:
|
||||||
if _target_label == null:
|
if _target_label == null:
|
||||||
return
|
return
|
||||||
|
var label_prefix: String = "Release place" if _current_type() == "set_down_prop" else "Target"
|
||||||
if _has_throw_target:
|
if _has_throw_target:
|
||||||
_target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
|
_target_label.text = "%s: (%d, %d)" % [label_prefix, int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
|
||||||
else:
|
else:
|
||||||
_target_label.text = "Target: not set"
|
_target_label.text = "%s: not set" % label_prefix
|
||||||
|
|
||||||
|
|
||||||
func _on_ok_pressed() -> void:
|
func _on_ok_pressed() -> void:
|
||||||
@@ -434,7 +442,7 @@ func _on_ok_pressed() -> void:
|
|||||||
var hands: Array[String] = ["auto", "left", "right"]
|
var hands: Array[String] = ["auto", "left", "right"]
|
||||||
action["target_prop_id"] = _prop_target_id
|
action["target_prop_id"] = _prop_target_id
|
||||||
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
|
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
|
||||||
"throw_prop":
|
"throw_prop", "set_down_prop":
|
||||||
if not _has_throw_target:
|
if not _has_throw_target:
|
||||||
target_requested.emit()
|
target_requested.emit()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class_name PropSettingsDepthCue
|
||||||
|
extends Control
|
||||||
|
## Small vector picture showing whether a prop is drawn behind or in front.
|
||||||
|
|
||||||
|
var _in_front: bool = false
|
||||||
|
var _stickman_color: Color = Color("#F5F7FA")
|
||||||
|
var _prop_color: Color = Color("#B7824D")
|
||||||
|
var _outline_color: Color = Color("#171B24")
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
custom_minimum_size = Vector2(40.0, 34.0)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func configure(in_front: bool, stickman_color: Color, prop_color: Color, outline_color: Color) -> void:
|
||||||
|
_in_front = in_front
|
||||||
|
_stickman_color = stickman_color
|
||||||
|
_prop_color = prop_color
|
||||||
|
_outline_color = outline_color
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if _in_front:
|
||||||
|
_draw_stickman()
|
||||||
|
_draw_prop()
|
||||||
|
else:
|
||||||
|
_draw_prop()
|
||||||
|
_draw_stickman()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_prop() -> void:
|
||||||
|
var box := Rect2(Vector2(18.0, 10.0), Vector2(19.0, 19.0))
|
||||||
|
draw_rect(box, _prop_color)
|
||||||
|
draw_rect(box, _outline_color, false, 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_stickman() -> void:
|
||||||
|
var head := Vector2(17.0, 5.5)
|
||||||
|
draw_circle(head, 4.0, _stickman_color)
|
||||||
|
draw_line(Vector2(17.0, 10.0), Vector2(17.0, 21.0), _stickman_color, 2.5, true)
|
||||||
|
draw_line(Vector2(17.0, 13.5), Vector2(9.0, 18.0), _stickman_color, 2.5, true)
|
||||||
|
draw_line(Vector2(17.0, 13.5), Vector2(25.0, 17.5), _stickman_color, 2.5, true)
|
||||||
|
draw_line(Vector2(17.0, 21.0), Vector2(11.5, 30.0), _stickman_color, 2.5, true)
|
||||||
|
draw_line(Vector2(17.0, 21.0), Vector2(23.0, 30.0), _stickman_color, 2.5, true)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dm8lgubovno3v
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ extends RigidBody2D
|
|||||||
|
|
||||||
const PropVisuals := preload("res://scripts/props/prop_visuals.gd")
|
const PropVisuals := preload("res://scripts/props/prop_visuals.gd")
|
||||||
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
|
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enums
|
# Enums
|
||||||
@@ -20,6 +22,7 @@ const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
|
|||||||
enum ShapeType { POLYGON, CIRCLE }
|
enum ShapeType { POLYGON, CIRCLE }
|
||||||
|
|
||||||
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||||
|
enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED }
|
||||||
|
|
||||||
## Stable library provenance used by Sandbox Stage scene files.
|
## Stable library provenance used by Sandbox Stage scene files.
|
||||||
var spawn_id: String = ""
|
var spawn_id: String = ""
|
||||||
@@ -30,6 +33,8 @@ var stage_object_id: String = ""
|
|||||||
var interaction_anchors: Dictionary = {}
|
var interaction_anchors: Dictionary = {}
|
||||||
var interaction_reserved_by: int = 0
|
var interaction_reserved_by: int = 0
|
||||||
var interaction_owner_id: int = 0
|
var interaction_owner_id: int = 0
|
||||||
|
var stage_roles: Dictionary = {}
|
||||||
|
var runtime_role: RuntimeRole = RuntimeRole.EDIT
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Child node names
|
# Child node names
|
||||||
@@ -115,6 +120,7 @@ var _play_scale_active: bool = false
|
|||||||
## `other` is the body that entered contact (Phase 4 trigger source).
|
## `other` is the body that entered contact (Phase 4 trigger source).
|
||||||
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
||||||
signal separated(other: Node)
|
signal separated(other: Node)
|
||||||
|
signal physics_profile_changed
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
@@ -124,6 +130,9 @@ func _ready() -> void:
|
|||||||
_ensure_children()
|
_ensure_children()
|
||||||
_apply_shape()
|
_apply_shape()
|
||||||
_apply_style()
|
_apply_style()
|
||||||
|
if stage_roles.is_empty():
|
||||||
|
stage_roles = PROP_STAGE_ROLES.new_placement(false)
|
||||||
|
apply_runtime_role(RuntimeRole.EDIT)
|
||||||
# Enable contact reporting so the body_entered signal fires (Phase 4).
|
# Enable contact reporting so the body_entered signal fires (Phase 4).
|
||||||
contact_monitor = true
|
contact_monitor = true
|
||||||
max_contacts_reported = MAX_CONTACTS_REPORTED
|
max_contacts_reported = MAX_CONTACTS_REPORTED
|
||||||
@@ -134,6 +143,84 @@ func _ready() -> void:
|
|||||||
body_exited.connect(_on_body_exited)
|
body_exited.connect(_on_body_exited)
|
||||||
|
|
||||||
|
|
||||||
|
func set_stage_roles(roles: Dictionary) -> void:
|
||||||
|
stage_roles = PROP_STAGE_ROLES.normalize(roles, PROP_STAGE_ROLES.new_placement(has_usable_grip()))
|
||||||
|
apply_stage_depth()
|
||||||
|
if runtime_role != RuntimeRole.EDIT:
|
||||||
|
apply_runtime_role(runtime_role)
|
||||||
|
else:
|
||||||
|
physics_profile_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_stage_depth() -> void:
|
||||||
|
var normalized: Dictionary = PROP_STAGE_ROLES.normalize(stage_roles)
|
||||||
|
stage_roles = normalized
|
||||||
|
z_as_relative = false
|
||||||
|
if bool(normalized.get("legacy_draw_order", false)):
|
||||||
|
z_index = 0
|
||||||
|
else:
|
||||||
|
z_index = -1 if String(normalized.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else 1
|
||||||
|
|
||||||
|
|
||||||
|
func apply_runtime_role(role: RuntimeRole) -> void:
|
||||||
|
runtime_role = role
|
||||||
|
match role:
|
||||||
|
RuntimeRole.EDIT:
|
||||||
|
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
freeze = true
|
||||||
|
collision_layer = 0
|
||||||
|
collision_mask = 0
|
||||||
|
RuntimeRole.START_STILL:
|
||||||
|
freeze_mode = RigidBody2D.FREEZE_MODE_STATIC
|
||||||
|
freeze = true
|
||||||
|
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
|
||||||
|
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
|
||||||
|
RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED:
|
||||||
|
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
freeze = false
|
||||||
|
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
|
||||||
|
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
|
||||||
|
RuntimeRole.HELD:
|
||||||
|
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
freeze = true
|
||||||
|
collision_layer = 0
|
||||||
|
collision_mask = 0
|
||||||
|
if role != RuntimeRole.HELD:
|
||||||
|
apply_stage_depth()
|
||||||
|
# A frozen RigidBody2D can push its cached EDIT transform back onto the
|
||||||
|
# node during the next physics sync. When PLAY scale has been moved into the
|
||||||
|
# child geometry, keep the physics server's body transform at unit scale too
|
||||||
|
# so that a START_STILL prop is not scaled twice on its first physics tick.
|
||||||
|
if _play_scale_active and role != RuntimeRole.EDIT:
|
||||||
|
_sync_transform_to_physics_server()
|
||||||
|
physics_profile_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func has_usable_grip() -> bool:
|
||||||
|
var anchors: Dictionary = get_interaction_anchors()
|
||||||
|
return not PropAnchorDataScript.compatible_grip_ids(anchors, 0).is_empty() or not PropAnchorDataScript.compatible_grip_ids(anchors, 1).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func can_be_picked_up() -> bool:
|
||||||
|
return bool(stage_roles.get("can_pick_up", false)) and has_usable_grip()
|
||||||
|
|
||||||
|
|
||||||
|
func is_actor_blocking() -> bool:
|
||||||
|
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
|
||||||
|
return false
|
||||||
|
if runtime_role == RuntimeRole.RELEASED:
|
||||||
|
return bool(stage_roles.get("released_blocks_stickmen", true))
|
||||||
|
return bool(stage_roles.get("blocks_stickmen", false))
|
||||||
|
|
||||||
|
|
||||||
|
func can_receive_prop_contact() -> bool:
|
||||||
|
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
|
||||||
|
return false
|
||||||
|
if runtime_role == RuntimeRole.RELEASED:
|
||||||
|
return bool(stage_roles.get("released_bumps_other_props", true))
|
||||||
|
return runtime_role == RuntimeRole.START_PHYSICS
|
||||||
|
|
||||||
|
|
||||||
func set_interaction_anchors(value: Dictionary) -> void:
|
func set_interaction_anchors(value: Dictionary) -> void:
|
||||||
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
|
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
|
||||||
|
|
||||||
@@ -328,6 +415,7 @@ func begin_play_instance_scale() -> void:
|
|||||||
_play_scale_active = true
|
_play_scale_active = true
|
||||||
scale = Vector2.ONE
|
scale = Vector2.ONE
|
||||||
_apply_play_scale()
|
_apply_play_scale()
|
||||||
|
_sync_transform_to_physics_server()
|
||||||
|
|
||||||
func end_play_instance_scale() -> void:
|
func end_play_instance_scale() -> void:
|
||||||
if not _play_scale_active:
|
if not _play_scale_active:
|
||||||
@@ -364,6 +452,13 @@ func _set_visual_scale(value: Vector2) -> void:
|
|||||||
if visuals != null:
|
if visuals != null:
|
||||||
visuals.scale = value
|
visuals.scale = value
|
||||||
|
|
||||||
|
func _sync_transform_to_physics_server() -> void:
|
||||||
|
var rid := get_rid()
|
||||||
|
if not rid.is_valid():
|
||||||
|
return
|
||||||
|
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, global_transform)
|
||||||
|
reset_physics_interpolation()
|
||||||
|
|
||||||
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
|
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
|
||||||
var result: PackedVector2Array = PackedVector2Array()
|
var result: PackedVector2Array = PackedVector2Array()
|
||||||
for point: Vector2 in points:
|
for point: Vector2 in points:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
class_name PropStageRoles
|
||||||
|
extends RefCounted
|
||||||
|
## Normalized, per-placement behavior for a prop in the Sandbox Stage.
|
||||||
|
|
||||||
|
const DEPTH_BEHIND: String = "behind"
|
||||||
|
const DEPTH_FRONT: String = "front"
|
||||||
|
const START_STILL: String = "still"
|
||||||
|
const START_PHYSICS: String = "physics"
|
||||||
|
|
||||||
|
const ROLE_KEYS: Array[String] = [
|
||||||
|
"depth", "start_motion", "holds_other_props", "blocks_stickmen",
|
||||||
|
"can_pick_up", "released_blocks_stickmen", "released_bumps_other_props",
|
||||||
|
"legacy_draw_order",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func new_placement(has_grip: bool = false) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"depth": DEPTH_BEHIND,
|
||||||
|
"start_motion": START_STILL,
|
||||||
|
"holds_other_props": false,
|
||||||
|
"blocks_stickmen": false,
|
||||||
|
"can_pick_up": has_grip,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func legacy_placement(has_grip: bool = true) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"depth": DEPTH_BEHIND,
|
||||||
|
"start_motion": START_PHYSICS,
|
||||||
|
"holds_other_props": false,
|
||||||
|
"blocks_stickmen": true,
|
||||||
|
"can_pick_up": has_grip,
|
||||||
|
"released_blocks_stickmen": true,
|
||||||
|
"released_bumps_other_props": true,
|
||||||
|
"legacy_draw_order": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func normalize(raw: Variant, fallback: Dictionary = {}) -> Dictionary:
|
||||||
|
var roles: Dictionary = new_placement(bool(fallback.get("can_pick_up", false)))
|
||||||
|
if not fallback.is_empty():
|
||||||
|
roles.merge(fallback, true)
|
||||||
|
if raw is Dictionary:
|
||||||
|
roles.merge((raw as Dictionary).duplicate(true), true)
|
||||||
|
if String(roles.get("depth", DEPTH_BEHIND)) not in [DEPTH_BEHIND, DEPTH_FRONT]:
|
||||||
|
roles["depth"] = DEPTH_BEHIND
|
||||||
|
if String(roles.get("start_motion", START_STILL)) not in [START_STILL, START_PHYSICS]:
|
||||||
|
roles["start_motion"] = START_STILL
|
||||||
|
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
|
||||||
|
roles[key] = bool(roles.get(key, false))
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
static func props_should_collide(a_roles: Dictionary, a_state: int, b_roles: Dictionary, b_state: int) -> bool:
|
||||||
|
if a_state == 3 or b_state == 3 or a_state == 0 or b_state == 0:
|
||||||
|
return false
|
||||||
|
var a_still: bool = a_state == 1
|
||||||
|
var b_still: bool = b_state == 1
|
||||||
|
if a_still and b_still:
|
||||||
|
return false
|
||||||
|
if a_still:
|
||||||
|
return bool(a_roles.get("holds_other_props", false)) and _accepts_prop_contact(b_roles, b_state)
|
||||||
|
if b_still:
|
||||||
|
return bool(b_roles.get("holds_other_props", false)) and _accepts_prop_contact(a_roles, a_state)
|
||||||
|
return _accepts_prop_contact(a_roles, a_state) and _accepts_prop_contact(b_roles, b_state)
|
||||||
|
|
||||||
|
|
||||||
|
static func _accepts_prop_contact(roles: Dictionary, state: int) -> bool:
|
||||||
|
if state == 2:
|
||||||
|
return true
|
||||||
|
if state == 4:
|
||||||
|
return bool(roles.get("released_bumps_other_props", true))
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c1dsjejgn2r1v
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
class_name StageInspectorPanel
|
||||||
|
extends PanelContainer
|
||||||
|
## Reusable shell for an in-stage selection inspector.
|
||||||
|
|
||||||
|
signal close_requested
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme = null
|
||||||
|
var _safe_rect := Rect2()
|
||||||
|
var _fit_pending: bool = false
|
||||||
|
var _margin: MarginContainer
|
||||||
|
var _layout: VBoxContainer
|
||||||
|
var _header: HBoxContainer
|
||||||
|
var _icon: Label
|
||||||
|
var _title: Label
|
||||||
|
var _close_button: Button
|
||||||
|
var _scroll: ScrollContainer
|
||||||
|
var _body: VBoxContainer
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_build_shell()
|
||||||
|
if _stage_theme != null:
|
||||||
|
apply_theme(_stage_theme)
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
|
if not visible or not event is InputEventKey:
|
||||||
|
return
|
||||||
|
var key_event := event as InputEventKey
|
||||||
|
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE:
|
||||||
|
close_requested.emit()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
if not is_node_ready():
|
||||||
|
return
|
||||||
|
theme = stage_theme.control_theme() if stage_theme != null else null
|
||||||
|
var shell_bg := _color("stage_panels.shell.surface_color", Color("#252B38FA"))
|
||||||
|
var shell_border := _color("stage_panels.shell.border_color", Color("#FFFFFF33"))
|
||||||
|
var panel_style := _make_style(shell_bg, shell_border, 1, 0, 0)
|
||||||
|
panel_style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
|
||||||
|
add_theme_stylebox_override("panel", panel_style)
|
||||||
|
var padding := _integer("stage_panels.shell.padding", 12, 4, 28)
|
||||||
|
for side: String in ["left", "right", "top", "bottom"]:
|
||||||
|
_margin.add_theme_constant_override("margin_" + side, padding)
|
||||||
|
_layout.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
|
_header.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
|
_close_button.custom_minimum_size = Vector2(
|
||||||
|
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
|
||||||
|
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
||||||
|
)
|
||||||
|
_title.add_theme_font_size_override("font_size", _integer("stage_panels.type.title_size", 24, 12, 48))
|
||||||
|
_title.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||||
|
if stage_theme != null:
|
||||||
|
var ui_font := stage_theme.font()
|
||||||
|
if ui_font != null:
|
||||||
|
_title.add_theme_font_override("font", ui_font)
|
||||||
|
_close_button.add_theme_font_override("font", ui_font)
|
||||||
|
var emoji_font := stage_theme.emoji_font()
|
||||||
|
if emoji_font != null:
|
||||||
|
_icon.add_theme_font_override("font", emoji_font)
|
||||||
|
_close_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
_style_action_button(_close_button, false)
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_header(title: String, icon_text: String = "") -> void:
|
||||||
|
if _title == null:
|
||||||
|
return
|
||||||
|
_title.text = title
|
||||||
|
_title.tooltip_text = title
|
||||||
|
_icon.text = icon_text
|
||||||
|
_icon.visible = not icon_text.is_empty()
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func body() -> VBoxContainer:
|
||||||
|
return _body
|
||||||
|
|
||||||
|
|
||||||
|
func set_safe_rect(rect: Rect2) -> void:
|
||||||
|
_safe_rect = rect
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func request_fit() -> void:
|
||||||
|
if _fit_pending:
|
||||||
|
return
|
||||||
|
_fit_pending = true
|
||||||
|
call_deferred("_fit_to_content")
|
||||||
|
|
||||||
|
|
||||||
|
func reset_scroll() -> void:
|
||||||
|
if _scroll != null:
|
||||||
|
_scroll.scroll_vertical = 0
|
||||||
|
|
||||||
|
|
||||||
|
func focus_first_control() -> void:
|
||||||
|
if _body == null:
|
||||||
|
return
|
||||||
|
for node: Node in _body.find_children("*", "Control", true, false):
|
||||||
|
var control := node as Control
|
||||||
|
if control == null or not control.visible or not control.is_visible_in_tree():
|
||||||
|
continue
|
||||||
|
if control.focus_mode == Control.FOCUS_NONE or control is Button and (control as Button).disabled:
|
||||||
|
continue
|
||||||
|
control.grab_focus()
|
||||||
|
return
|
||||||
|
_close_button.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
func style_action_button(button: Button, prominent: bool = false) -> void:
|
||||||
|
_style_action_button(button, prominent)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_shell() -> void:
|
||||||
|
if _body != null:
|
||||||
|
return
|
||||||
|
set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
var safe := MarginContainer.new()
|
||||||
|
add_child(safe)
|
||||||
|
_margin = safe
|
||||||
|
_layout = VBoxContainer.new()
|
||||||
|
_layout.add_theme_constant_override("separation", 8)
|
||||||
|
safe.add_child(_layout)
|
||||||
|
|
||||||
|
_header = HBoxContainer.new()
|
||||||
|
_header.add_theme_constant_override("separation", 8)
|
||||||
|
_layout.add_child(_header)
|
||||||
|
_icon = Label.new()
|
||||||
|
_icon.custom_minimum_size.x = 28.0
|
||||||
|
_icon.add_theme_font_size_override("font_size", 24)
|
||||||
|
_icon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_icon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_header.add_child(_icon)
|
||||||
|
_title = Label.new()
|
||||||
|
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_title.max_lines_visible = 2
|
||||||
|
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_header.add_child(_title)
|
||||||
|
_close_button = Button.new()
|
||||||
|
_close_button.text = "✕ Close"
|
||||||
|
_close_button.custom_minimum_size = Vector2(
|
||||||
|
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
|
||||||
|
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
||||||
|
)
|
||||||
|
_close_button.focus_mode = Control.FOCUS_ALL
|
||||||
|
_close_button.pressed.connect(func() -> void: close_requested.emit())
|
||||||
|
_header.add_child(_close_button)
|
||||||
|
|
||||||
|
_scroll = ScrollContainer.new()
|
||||||
|
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_layout.add_child(_scroll)
|
||||||
|
_body = VBoxContainer.new()
|
||||||
|
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_body.add_theme_constant_override("separation", 8)
|
||||||
|
_scroll.add_child(_body)
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_content() -> void:
|
||||||
|
_fit_pending = false
|
||||||
|
if not is_inside_tree() or _body == null:
|
||||||
|
return
|
||||||
|
var rect := _safe_rect
|
||||||
|
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
|
||||||
|
rect = get_viewport_rect()
|
||||||
|
var inset := float(_integer("stage_panels.shell.edge_inset", 12, 0, 48))
|
||||||
|
var preferred_width := float(_integer("stage_panels.shell.preferred_width", 380, 220, 720))
|
||||||
|
var card_width := minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
|
||||||
|
custom_minimum_size.x = card_width
|
||||||
|
size.x = card_width
|
||||||
|
await get_tree().process_frame
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
|
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
|
||||||
|
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
|
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
|
||||||
|
var body_height := _body.get_combined_minimum_size().y
|
||||||
|
var fixed_height := header_height + gap + padding * 2.0
|
||||||
|
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
|
||||||
|
var natural_height := fixed_height + body_height
|
||||||
|
var card_height := minf(natural_height, available_height)
|
||||||
|
var scroll_height := maxf(0.0, card_height - fixed_height)
|
||||||
|
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
|
||||||
|
size = Vector2(card_width, card_height)
|
||||||
|
_scroll.custom_minimum_size.y = scroll_height
|
||||||
|
_scroll.update_minimum_size()
|
||||||
|
|
||||||
|
|
||||||
|
func _style_action_button(button: Button, prominent: bool) -> void:
|
||||||
|
if button == null:
|
||||||
|
return
|
||||||
|
var accent := _color("mode_colors.edit", Color("#22C6FF"))
|
||||||
|
var neutral := _color("stage_panels.action.surface_color", Color("#171B24"))
|
||||||
|
var hover := _color("stage_panels.action.hover_surface", Color("#303A4B"))
|
||||||
|
var selected := _color("stage_panels.action.selected_surface", Color("#12394A"))
|
||||||
|
var border := _color("global.border_color", Color("#FFFFFF33"))
|
||||||
|
button.add_theme_stylebox_override("normal", _make_style(selected if prominent else neutral, accent if prominent else border, 1, 10, 6))
|
||||||
|
button.add_theme_stylebox_override("hover", _make_style(selected.lightened(0.06) if prominent else hover, accent, 2, 10, 6))
|
||||||
|
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2, 10, 6))
|
||||||
|
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2, 10, 6))
|
||||||
|
button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), border, 1, 10, 6))
|
||||||
|
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 0, 0))
|
||||||
|
button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
button.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||||
|
if _stage_theme != null:
|
||||||
|
var ui_font := _stage_theme.font()
|
||||||
|
if ui_font != null:
|
||||||
|
button.add_theme_font_override("font", ui_font)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_style(bg: Color, border: Color, border_width: int, hpad: int, vpad: int) -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = bg
|
||||||
|
style.border_color = border
|
||||||
|
style.set_border_width_all(border_width)
|
||||||
|
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
|
||||||
|
style.content_margin_left = hpad
|
||||||
|
style.content_margin_right = hpad
|
||||||
|
style.content_margin_top = vpad
|
||||||
|
style.content_margin_bottom = vpad
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
func _integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _color(path: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxpgiyw4mxljt
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
class_name StagePanelChoiceGroup
|
||||||
|
extends VBoxContainer
|
||||||
|
## Exclusive, full-width choices with optional decorative cues.
|
||||||
|
|
||||||
|
signal choice_selected(id: StringName)
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme = null
|
||||||
|
var _group := ButtonGroup.new()
|
||||||
|
var _buttons: Dictionary = {}
|
||||||
|
var _selected_id: StringName = &""
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_group.allow_unpress = true
|
||||||
|
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
|
||||||
|
|
||||||
|
|
||||||
|
func add_choice(id: StringName, caption: String, cue: Control = null) -> Button:
|
||||||
|
var button := Button.new()
|
||||||
|
button.name = "Choice_%s" % String(id)
|
||||||
|
button.toggle_mode = true
|
||||||
|
button.button_group = _group
|
||||||
|
button.custom_minimum_size.y = float(_integer("stage_panels.choice.minimum_height", 56, 44, 112))
|
||||||
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
button.focus_mode = Control.FOCUS_ALL
|
||||||
|
button.text = ""
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
margin.add_theme_constant_override("margin_left", 10)
|
||||||
|
margin.add_theme_constant_override("margin_right", 10)
|
||||||
|
margin.add_theme_constant_override("margin_top", 4)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 4)
|
||||||
|
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
button.add_child(margin)
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
margin.add_child(row)
|
||||||
|
if cue != null:
|
||||||
|
cue.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
row.add_child(cue)
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = caption
|
||||||
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
row.add_child(label)
|
||||||
|
button.pressed.connect(_on_choice_pressed.bind(id, button))
|
||||||
|
add_child(button)
|
||||||
|
_buttons[id] = {"button": button, "label": label}
|
||||||
|
if _stage_theme != null:
|
||||||
|
_apply_choice_theme(button, label)
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
|
func select_no_signal(id: StringName) -> void:
|
||||||
|
if not _buttons.has(id):
|
||||||
|
clear_no_signal()
|
||||||
|
return
|
||||||
|
_selected_id = id
|
||||||
|
for key: Variant in _buttons:
|
||||||
|
var button: Button = _buttons[key]["button"]
|
||||||
|
button.set_pressed_no_signal(StringName(key) == id)
|
||||||
|
|
||||||
|
|
||||||
|
func clear_no_signal() -> void:
|
||||||
|
_selected_id = &""
|
||||||
|
for entry: Dictionary in _buttons.values():
|
||||||
|
(entry["button"] as Button).set_pressed_no_signal(false)
|
||||||
|
|
||||||
|
|
||||||
|
func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
|
||||||
|
for entry: Dictionary in _buttons.values():
|
||||||
|
_apply_choice_theme(entry["button"] as Button, entry["label"] as Label)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_choice_pressed(id: StringName, button: Button) -> void:
|
||||||
|
if not button.button_pressed:
|
||||||
|
if _buttons.has(_selected_id):
|
||||||
|
(_buttons[_selected_id]["button"] as Button).set_pressed_no_signal(true)
|
||||||
|
return
|
||||||
|
_selected_id = id
|
||||||
|
choice_selected.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_choice_theme(button: Button, label: Label) -> void:
|
||||||
|
if _stage_theme == null:
|
||||||
|
return
|
||||||
|
var font := _stage_theme.font()
|
||||||
|
if font != null:
|
||||||
|
label.add_theme_font_override("font", font)
|
||||||
|
label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||||
|
var neutral := _color("stage_panels.choice.neutral_surface", Color("#171B24"))
|
||||||
|
var hover := _color("stage_panels.choice.hover_surface", Color("#303A4B"))
|
||||||
|
var selected := _color("stage_panels.choice.selected_surface", Color("#12394A"))
|
||||||
|
var disabled := _color("stage_panels.choice.disabled_surface", Color("#252B38"))
|
||||||
|
var border := _color("stage_panels.choice.border_color", Color("#FFFFFF33"))
|
||||||
|
var accent := _color("mode_colors.edit", Color("#22C6FF"))
|
||||||
|
button.add_theme_stylebox_override("normal", _make_style(neutral, border, 1))
|
||||||
|
button.add_theme_stylebox_override("hover", _make_style(hover, border, 1))
|
||||||
|
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2))
|
||||||
|
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2))
|
||||||
|
button.add_theme_stylebox_override("disabled", _make_style(disabled, border, 1))
|
||||||
|
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2))
|
||||||
|
|
||||||
|
|
||||||
|
func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = bg
|
||||||
|
style.border_color = border
|
||||||
|
style.set_border_width_all(width)
|
||||||
|
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
func _integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _color(path: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://du3kqlsq5t10
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
class_name StagePanelSwitchVisual
|
||||||
|
extends Control
|
||||||
|
## Draw-only switch. Input belongs to its containing toggle row.
|
||||||
|
|
||||||
|
var _enabled: bool = false
|
||||||
|
var _unavailable: bool = false
|
||||||
|
var _stage_theme: SandboxTheme = null
|
||||||
|
var _track_size := Vector2(64.0, 34.0)
|
||||||
|
var _knob_size: float = 26.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_apply_metrics()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func set_state(enabled: bool, unavailable: bool) -> void:
|
||||||
|
_enabled = enabled
|
||||||
|
_unavailable = unavailable
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
_track_size.x = float(_integer("stage_panels.toggle.switch_width", 64, 48, 112))
|
||||||
|
_track_size.y = float(_integer("stage_panels.toggle.switch_height", 34, 28, 64))
|
||||||
|
_knob_size = float(_integer("stage_panels.toggle.knob_size", 26, 20, 52))
|
||||||
|
_apply_metrics()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if size.x <= 0.0 or size.y <= 0.0:
|
||||||
|
return
|
||||||
|
var on_color := _color("stage_panels.toggle.switch_on_color", Color("#22C6FF"))
|
||||||
|
var off_color := _color("stage_panels.toggle.switch_off_color", Color("#4A5362"))
|
||||||
|
var disabled_color := _color("stage_panels.toggle.switch_disabled_color", Color("#3B414D"))
|
||||||
|
var knob_color := _color("stage_panels.toggle.knob_color", Color("#F5F7FA"))
|
||||||
|
var track_color := disabled_color if _unavailable else (on_color if _enabled else off_color)
|
||||||
|
var center_y := size.y * 0.5
|
||||||
|
var radius := center_y
|
||||||
|
draw_rect(Rect2(radius, 0.0, maxf(0.0, size.x - radius * 2.0), size.y), track_color)
|
||||||
|
draw_circle(Vector2(radius, center_y), radius, track_color)
|
||||||
|
draw_circle(Vector2(size.x - radius, center_y), radius, track_color)
|
||||||
|
var knob_radius := minf(_knob_size, size.y - 4.0) * 0.5
|
||||||
|
var knob_center := Vector2(size.x - radius, center_y) if _enabled else Vector2(radius, center_y)
|
||||||
|
draw_circle(knob_center, knob_radius, knob_color)
|
||||||
|
if _enabled and not _unavailable:
|
||||||
|
var check_color := on_color.darkened(0.25)
|
||||||
|
draw_line(knob_center + Vector2(-4.5, 0.0), knob_center + Vector2(-1.0, 3.5), check_color, 2.0, true)
|
||||||
|
draw_line(knob_center + Vector2(-1.0, 3.5), knob_center + Vector2(5.0, -4.0), check_color, 2.0, true)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_metrics() -> void:
|
||||||
|
custom_minimum_size = _track_size
|
||||||
|
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
|
||||||
|
|
||||||
|
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _color(path: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d24xh8pdqpyyn
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
class_name StagePanelToggleRow
|
||||||
|
extends Button
|
||||||
|
## Full-row toggle with stable switch placement and optional help.
|
||||||
|
|
||||||
|
signal preferred_height_changed
|
||||||
|
|
||||||
|
const SWITCH_VISUAL := preload("res://scripts/shared/stage_ui/stage_panel_switch_visual.gd")
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme = null
|
||||||
|
var _caption: String = ""
|
||||||
|
var _normal_help: String = ""
|
||||||
|
var _disabled_reason: String = ""
|
||||||
|
var _caption_label: Label
|
||||||
|
var _help_label: Label
|
||||||
|
var _label_stack: VBoxContainer
|
||||||
|
var _state_label: Label
|
||||||
|
var _switch_visual: StagePanelSwitchVisual
|
||||||
|
var _content_margin: MarginContainer
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
text = ""
|
||||||
|
toggle_mode = true
|
||||||
|
focus_mode = Control.FOCUS_ALL
|
||||||
|
_build_content()
|
||||||
|
toggled.connect(_on_toggled)
|
||||||
|
if _stage_theme != null:
|
||||||
|
apply_theme(_stage_theme)
|
||||||
|
_sync_text()
|
||||||
|
_sync_switch()
|
||||||
|
|
||||||
|
|
||||||
|
func configure(caption: String) -> void:
|
||||||
|
_caption = caption
|
||||||
|
_sync_text()
|
||||||
|
|
||||||
|
|
||||||
|
func set_help_text(text_value: String) -> void:
|
||||||
|
_normal_help = text_value
|
||||||
|
_sync_text()
|
||||||
|
|
||||||
|
|
||||||
|
func set_disabled_state(value: bool, reason: String = "") -> void:
|
||||||
|
if value:
|
||||||
|
_disabled_reason = reason
|
||||||
|
else:
|
||||||
|
_disabled_reason = ""
|
||||||
|
disabled = value
|
||||||
|
_sync_text()
|
||||||
|
_sync_switch()
|
||||||
|
|
||||||
|
|
||||||
|
func set_checked_no_signal(value: bool) -> void:
|
||||||
|
set_pressed_no_signal(value)
|
||||||
|
_sync_switch()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
if _caption_label == null:
|
||||||
|
return
|
||||||
|
var font := stage_theme.font() if stage_theme != null else null
|
||||||
|
if font != null:
|
||||||
|
for label: Label in [_caption_label, _help_label, _state_label]:
|
||||||
|
label.add_theme_font_override("font", font)
|
||||||
|
_caption_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
_help_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32))
|
||||||
|
_state_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32))
|
||||||
|
_content_margin.add_theme_constant_override("margin_left", _integer("stage_panels.toggle.row_padding", 12, 4, 28))
|
||||||
|
_content_margin.add_theme_constant_override("margin_right", _integer("stage_panels.toggle.row_padding", 12, 4, 28))
|
||||||
|
_switch_visual.apply_theme(stage_theme)
|
||||||
|
var neutral := _color("stage_panels.toggle.neutral_surface", Color("#202633"))
|
||||||
|
var hover := _color("stage_panels.toggle.hover_surface", Color("#2B3442"))
|
||||||
|
var selected := _color("stage_panels.toggle.selected_surface", Color("#173443"))
|
||||||
|
var disabled_surface := _color("stage_panels.toggle.disabled_surface", Color("#252B38"))
|
||||||
|
var border := _color("stage_panels.toggle.border_color", Color("#FFFFFF33"))
|
||||||
|
var accent := _color("mode_colors.edit", Color("#22C6FF"))
|
||||||
|
add_theme_stylebox_override("normal", _make_style(neutral, border, 1))
|
||||||
|
add_theme_stylebox_override("hover", _make_style(hover, border, 1))
|
||||||
|
add_theme_stylebox_override("pressed", _make_style(selected, accent, 1))
|
||||||
|
add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 1))
|
||||||
|
add_theme_stylebox_override("disabled", _make_style(disabled_surface, border, 1))
|
||||||
|
add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2))
|
||||||
|
_sync_text()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_content() -> void:
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 6)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 6)
|
||||||
|
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(margin)
|
||||||
|
_content_margin = margin
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
margin.add_child(row)
|
||||||
|
_label_stack = VBoxContainer.new()
|
||||||
|
_label_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_label_stack.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
_label_stack.add_theme_constant_override("separation", 2)
|
||||||
|
_label_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
row.add_child(_label_stack)
|
||||||
|
_caption_label = Label.new()
|
||||||
|
_caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_caption_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_caption_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_label_stack.add_child(_caption_label)
|
||||||
|
_help_label = Label.new()
|
||||||
|
_help_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_help_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_label_stack.add_child(_help_label)
|
||||||
|
_switch_visual = SWITCH_VISUAL.new()
|
||||||
|
_switch_visual.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
row.add_child(_switch_visual)
|
||||||
|
_state_label = Label.new()
|
||||||
|
_state_label.custom_minimum_size.x = 28.0
|
||||||
|
_state_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
|
_state_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_state_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
_state_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
row.add_child(_state_label)
|
||||||
|
# Connect after all labels exist: adding the first label can trigger a resize
|
||||||
|
# synchronously, before the help and state labels have been constructed.
|
||||||
|
_caption_label.resized.connect(_update_preferred_height)
|
||||||
|
_help_label.resized.connect(_update_preferred_height)
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_text() -> void:
|
||||||
|
var help := _disabled_reason if disabled and not _disabled_reason.is_empty() else _normal_help
|
||||||
|
var base_height := float(_integer("stage_panels.toggle.minimum_height", 64, 48, 120))
|
||||||
|
var help_height := float(_integer("stage_panels.toggle.help_minimum_height", 72, 56, 144))
|
||||||
|
if _caption_label == null or _help_label == null or _label_stack == null or _state_label == null:
|
||||||
|
custom_minimum_size.y = help_height if not help.is_empty() else base_height
|
||||||
|
return
|
||||||
|
_caption_label.text = _caption
|
||||||
|
_help_label.text = help
|
||||||
|
_help_label.visible = not help.is_empty()
|
||||||
|
_sync_label_colors()
|
||||||
|
var content_height := _label_stack.get_combined_minimum_size().y
|
||||||
|
var next_height := maxf(help_height if not help.is_empty() else base_height, content_height + 12.0)
|
||||||
|
if not is_equal_approx(custom_minimum_size.y, next_height):
|
||||||
|
custom_minimum_size.y = next_height
|
||||||
|
update_minimum_size()
|
||||||
|
preferred_height_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_preferred_height() -> void:
|
||||||
|
_sync_text()
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_switch() -> void:
|
||||||
|
if _switch_visual == null or _state_label == null:
|
||||||
|
return
|
||||||
|
_switch_visual.set_state(button_pressed, disabled)
|
||||||
|
_state_label.text = "ON" if button_pressed else "OFF"
|
||||||
|
_sync_label_colors()
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_label_colors() -> void:
|
||||||
|
if _caption_label == null or _help_label == null or _state_label == null:
|
||||||
|
return
|
||||||
|
_caption_label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||||
|
_help_label.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
_state_label.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_toggled(_value: bool) -> void:
|
||||||
|
_sync_switch()
|
||||||
|
|
||||||
|
|
||||||
|
func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = bg
|
||||||
|
style.border_color = border
|
||||||
|
style.set_border_width_all(width)
|
||||||
|
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _color(path: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://4so64df7s3if
|
||||||
@@ -75,6 +75,14 @@ const ACTION_TEMPLATES := {
|
|||||||
],
|
],
|
||||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||||
},
|
},
|
||||||
|
"set_down_prop": {
|
||||||
|
"label": "Set Down Prop",
|
||||||
|
"icon": "🫳",
|
||||||
|
"params": [
|
||||||
|
{ "key": "target_position", "type": "position", "required": true },
|
||||||
|
],
|
||||||
|
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +165,8 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
|||||||
params["hand"] = String(action.get("hand", "auto"))
|
params["hand"] = String(action.get("hand", "auto"))
|
||||||
"throw_prop":
|
"throw_prop":
|
||||||
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
||||||
|
"set_down_prop":
|
||||||
|
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
||||||
return {
|
return {
|
||||||
"type": type,
|
"type": type,
|
||||||
"target": target_id,
|
"target": target_id,
|
||||||
@@ -187,6 +197,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
|||||||
return { "type": "drop_prop" }
|
return { "type": "drop_prop" }
|
||||||
"throw_prop":
|
"throw_prop":
|
||||||
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
|
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
|
||||||
|
"set_down_prop":
|
||||||
|
return { "type": "set_down_prop", "target_position": params.get("target_position", Vector2.ZERO) }
|
||||||
_:
|
_:
|
||||||
return { "type": type }
|
return { "type": type }
|
||||||
|
|
||||||
@@ -224,6 +236,10 @@ static func summarize(action: Dictionary) -> String:
|
|||||||
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
|
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
|
||||||
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
|
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
|
||||||
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
|
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
|
||||||
|
"set_down_prop":
|
||||||
|
var raw_release: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
|
||||||
|
var release_position: Vector2 = raw_release as Vector2 if raw_release is Vector2 else Vector2.ZERO
|
||||||
|
return "Set Down Prop (%d, %d)" % [int(roundf(release_position.x)), int(roundf(release_position.y))]
|
||||||
_:
|
_:
|
||||||
return type
|
return type
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ const DEFAULT_CONFIG: Dictionary = {
|
|||||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||||
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
||||||
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
||||||
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
"stage_panels": {
|
||||||
|
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
|
||||||
|
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
|
||||||
|
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
|
||||||
|
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
|
||||||
|
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
|
||||||
|
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}
|
||||||
|
},
|
||||||
|
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}},
|
||||||
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
|
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
|
||||||
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
|
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
|
||||||
}
|
}
|
||||||
@@ -222,12 +230,48 @@ func _apply_legacy_aliases(source: Dictionary) -> void:
|
|||||||
var modes: Dictionary = source.get("mode_colors", {})
|
var modes: Dictionary = source.get("mode_colors", {})
|
||||||
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
||||||
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
||||||
|
var old_panel_colors: Dictionary = source.get("stage_prop_settings", {})
|
||||||
|
if old_panel_colors.has("edit_accent") and not modes.has("edit"):
|
||||||
|
modes["edit"] = old_panel_colors["edit_accent"]
|
||||||
var fonts: Dictionary = source.get("fonts", {})
|
var fonts: Dictionary = source.get("fonts", {})
|
||||||
var sizes: Dictionary = fonts.get("sizes", {})
|
var sizes: Dictionary = fonts.get("sizes", {})
|
||||||
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
||||||
var legacy: String = name + "_font_size"
|
var legacy: String = name + "_font_size"
|
||||||
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
||||||
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
||||||
|
var old_panel: Dictionary = source.get("stage_prop_settings", {})
|
||||||
|
if not old_panel.is_empty():
|
||||||
|
var stage_panels: Dictionary = source.get("stage_panels", {})
|
||||||
|
for mapping: Array in [
|
||||||
|
["card_width", "shell", "preferred_width"], ["padding", "shell", "padding"],
|
||||||
|
["corner_radius", "shell", "corner_radius"], ["title_size", "type", "title_size"],
|
||||||
|
["section_size", "type", "section_size"], ["body_size", "type", "body_size"],
|
||||||
|
["helper_font_size", "type", "helper_size"], ["choice_text_color", "type", "text_color"],
|
||||||
|
["helper_color", "type", "muted_text_color"], ["choice_min_height", "choice", "minimum_height"],
|
||||||
|
["button_min_height", "action", "min_target_size"],
|
||||||
|
["choice_neutral_surface", "choice", "neutral_surface"], ["choice_hover_surface", "choice", "hover_surface"],
|
||||||
|
["choice_selected_surface", "choice", "selected_surface"], ["neutral_border", "choice", "border_color"],
|
||||||
|
["disabled_surface", "choice", "disabled_surface"],
|
||||||
|
["toggle_row_min_height", "toggle", "minimum_height"], ["toggle_help_min_height", "toggle", "help_minimum_height"],
|
||||||
|
["row_padding", "toggle", "row_padding"], ["switch_width", "toggle", "switch_width"],
|
||||||
|
["switch_height", "toggle", "switch_height"], ["switch_knob_size", "toggle", "knob_size"],
|
||||||
|
["toggle_surface", "toggle", "neutral_surface"], ["toggle_hover_surface", "toggle", "hover_surface"],
|
||||||
|
["toggle_selected_surface", "toggle", "selected_surface"], ["switch_on_color", "toggle", "switch_on_color"],
|
||||||
|
["disabled_surface", "toggle", "disabled_surface"], ["neutral_border", "toggle", "border_color"],
|
||||||
|
["switch_off_color", "toggle", "switch_off_color"], ["switch_disabled_color", "toggle", "switch_disabled_color"],
|
||||||
|
["switch_knob_color", "toggle", "knob_color"], ["stickman_cue_color", "prop", "stickman_cue_color"],
|
||||||
|
["prop_cue_color", "prop", "prop_cue_color"], ["cue_outline_color", "prop", "cue_outline_color"]
|
||||||
|
]:
|
||||||
|
var old_key: String = mapping[0]
|
||||||
|
var group_key: String = mapping[1]
|
||||||
|
var new_key: String = mapping[2]
|
||||||
|
if not old_panel.has(old_key):
|
||||||
|
continue
|
||||||
|
var group: Dictionary = stage_panels.get(group_key, {})
|
||||||
|
if not group.has(new_key):
|
||||||
|
group[new_key] = old_panel[old_key]
|
||||||
|
stage_panels[group_key] = group
|
||||||
|
source["stage_panels"] = stage_panels
|
||||||
|
|
||||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||||
var merged: Dictionary = base.duplicate(true)
|
var merged: Dictionary = base.duplicate(true)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class_name StagePhysicsPolicy
|
||||||
|
extends RefCounted
|
||||||
|
## Named physics bits shared by Stage objects and stickman collision probes.
|
||||||
|
|
||||||
|
const TERRAIN_LAYER: int = 1 << 0
|
||||||
|
const ACTOR_LAYER: int = 1 << 1
|
||||||
|
const PROP_LAYER: int = 1 << 2
|
||||||
|
const ACTOR_BLOCKING_PROP_LAYER: int = 1 << 3
|
||||||
|
|
||||||
|
const ACTOR_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
|
||||||
|
const PROP_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | PROP_LAYER
|
||||||
|
|
||||||
|
static func prop_layer(blocks_stickmen: bool) -> int:
|
||||||
|
return PROP_LAYER | (ACTOR_BLOCKING_PROP_LAYER if blocks_stickmen else 0)
|
||||||
|
|
||||||
|
|
||||||
|
static func prop_mask(blocks_stickmen: bool) -> int:
|
||||||
|
return TERRAIN_LAYER | PROP_LAYER | (ACTOR_LAYER if blocks_stickmen else 0)
|
||||||
|
|
||||||
|
|
||||||
|
static func actor_mask() -> int:
|
||||||
|
return TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://gpw1hqukuefb
|
||||||
@@ -29,8 +29,17 @@ var action_show_collision: bool = false
|
|||||||
var action_preview_unavailable: bool = false
|
var action_preview_unavailable: bool = false
|
||||||
var action_preview_planned: bool = false
|
var action_preview_planned: bool = false
|
||||||
var action_preview_uncertain: bool = false
|
var action_preview_uncertain: bool = false
|
||||||
|
var set_down_preview_visible: bool = false
|
||||||
|
var set_down_preview_known: bool = false
|
||||||
|
var set_down_prop: PropBlock = null
|
||||||
|
var set_down_transform: Transform2D = Transform2D.IDENTITY
|
||||||
|
var set_down_approach: Vector2 = Vector2.ZERO
|
||||||
|
var set_down_grip: Vector2 = Vector2.ZERO
|
||||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||||
|
var set_down_unknown_color: Color = Color(0.77, 0.81, 0.87, 0.9)
|
||||||
|
var set_down_reach_color: Color = Color(1.0, 0.7, 0.1, 0.9)
|
||||||
|
var set_down_ghost_alpha: float = 0.36
|
||||||
var line_width_px: float = 2.0
|
var line_width_px: float = 2.0
|
||||||
var dash_length_px: float = 8.0
|
var dash_length_px: float = 8.0
|
||||||
var dash_gap_px: float = 5.0
|
var dash_gap_px: float = 5.0
|
||||||
@@ -44,6 +53,10 @@ func apply_style(style: Dictionary) -> void:
|
|||||||
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
|
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
|
||||||
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
|
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
|
||||||
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
|
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
|
||||||
|
var set_down_style: Dictionary = style.get("set_down", {})
|
||||||
|
set_down_unknown_color = Color.from_string(String(set_down_style.get("unknown_color", "")), set_down_unknown_color)
|
||||||
|
set_down_reach_color = Color.from_string(String(set_down_style.get("reach_color", "")), set_down_reach_color)
|
||||||
|
set_down_ghost_alpha = clampf(float(set_down_style.get("ghost_alpha", set_down_ghost_alpha)), 0.0, 1.0)
|
||||||
var defaults: Dictionary = style.get("style_defaults", {})
|
var defaults: Dictionary = style.get("style_defaults", {})
|
||||||
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
|
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
|
||||||
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
|
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
|
||||||
@@ -78,6 +91,8 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
|
|||||||
action_preview_unavailable = false
|
action_preview_unavailable = false
|
||||||
action_preview_planned = false
|
action_preview_planned = false
|
||||||
action_preview_uncertain = false
|
action_preview_uncertain = false
|
||||||
|
set_down_preview_visible = false
|
||||||
|
set_down_prop = null
|
||||||
|
|
||||||
|
|
||||||
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
|
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
|
||||||
@@ -94,6 +109,8 @@ func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_ta
|
|||||||
action_preview_unavailable = false
|
action_preview_unavailable = false
|
||||||
action_preview_planned = planned
|
action_preview_planned = planned
|
||||||
action_preview_uncertain = uncertain
|
action_preview_uncertain = uncertain
|
||||||
|
set_down_preview_visible = false
|
||||||
|
set_down_prop = null
|
||||||
|
|
||||||
|
|
||||||
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
|
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
|
||||||
@@ -108,6 +125,24 @@ func set_throw_preview_unavailable(requested_target: Vector2) -> void:
|
|||||||
action_preview_unavailable = true
|
action_preview_unavailable = true
|
||||||
action_preview_planned = false
|
action_preview_planned = false
|
||||||
action_preview_uncertain = false
|
action_preview_uncertain = false
|
||||||
|
set_down_preview_visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid: bool, prop: PropBlock = null, release_transform: Transform2D = Transform2D.IDENTITY, approach: Vector2 = Vector2.ZERO, grip: Vector2 = Vector2.ZERO) -> void:
|
||||||
|
action_visible = true
|
||||||
|
action_origin = origin
|
||||||
|
action_target = target
|
||||||
|
action_curve_points.clear()
|
||||||
|
action_valid = valid
|
||||||
|
action_preview_unavailable = not known
|
||||||
|
action_show_requested_target = false
|
||||||
|
action_show_collision = false
|
||||||
|
set_down_preview_visible = true
|
||||||
|
set_down_preview_known = known
|
||||||
|
set_down_prop = prop
|
||||||
|
set_down_transform = release_transform
|
||||||
|
set_down_approach = approach
|
||||||
|
set_down_grip = grip
|
||||||
|
|
||||||
|
|
||||||
func clear_action() -> void:
|
func clear_action() -> void:
|
||||||
@@ -118,6 +153,8 @@ func clear_action() -> void:
|
|||||||
action_preview_unavailable = false
|
action_preview_unavailable = false
|
||||||
action_preview_planned = false
|
action_preview_planned = false
|
||||||
action_preview_uncertain = false
|
action_preview_uncertain = false
|
||||||
|
set_down_preview_visible = false
|
||||||
|
set_down_prop = null
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +164,9 @@ func _draw() -> void:
|
|||||||
_draw_cell_marker(terrain_anchor, guide_line_color)
|
_draw_cell_marker(terrain_anchor, guide_line_color)
|
||||||
_draw_cell_marker(terrain_target, guide_line_color)
|
_draw_cell_marker(terrain_target, guide_line_color)
|
||||||
if action_visible:
|
if action_visible:
|
||||||
if action_preview_unavailable:
|
if set_down_preview_visible:
|
||||||
|
_draw_set_down_preview()
|
||||||
|
elif action_preview_unavailable:
|
||||||
var zoom := _zoom()
|
var zoom := _zoom()
|
||||||
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
|
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
|
||||||
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
|
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
|
||||||
@@ -152,6 +191,43 @@ func _draw() -> void:
|
|||||||
_draw_ghost_marker(action_target, color)
|
_draw_ghost_marker(action_target, color)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_set_down_preview() -> void:
|
||||||
|
var zoom := _zoom()
|
||||||
|
var color := valid_color if action_valid else invalid_color
|
||||||
|
if not set_down_preview_known:
|
||||||
|
color = set_down_unknown_color
|
||||||
|
_draw_dashed(action_origin, set_down_approach if set_down_approach.is_finite() and set_down_approach != Vector2.ZERO else action_target, color)
|
||||||
|
if set_down_preview_known and set_down_prop != null and is_instance_valid(set_down_prop):
|
||||||
|
var polygon_node := set_down_prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||||
|
if polygon_node != null and polygon_node.polygon.size() >= 3:
|
||||||
|
var local_transform: Transform2D = set_down_prop.global_transform.affine_inverse() * polygon_node.global_transform
|
||||||
|
var ghost_transform: Transform2D = set_down_transform * local_transform
|
||||||
|
var ghost_points := PackedVector2Array()
|
||||||
|
for point: Vector2 in polygon_node.polygon:
|
||||||
|
ghost_points.append(ghost_transform * point)
|
||||||
|
var fill := set_down_prop.fill_color
|
||||||
|
fill.a = set_down_ghost_alpha
|
||||||
|
draw_colored_polygon(ghost_points, fill)
|
||||||
|
var outline_points := ghost_points.duplicate()
|
||||||
|
outline_points.append(ghost_points[0])
|
||||||
|
draw_polyline(outline_points, color, line_width_px / zoom, true)
|
||||||
|
else:
|
||||||
|
_draw_ghost_marker(action_target, color)
|
||||||
|
else:
|
||||||
|
_draw_ghost_marker(action_target, color)
|
||||||
|
var release_origin := set_down_transform.origin
|
||||||
|
var target_adjusted := set_down_preview_known and release_origin.distance_to(action_target) > 1.0
|
||||||
|
if target_adjusted:
|
||||||
|
_draw_dashed(action_target, release_origin, set_down_reach_color)
|
||||||
|
draw_arc(release_origin, 4.0 / zoom, 0.0, TAU, 16, set_down_reach_color, LINE_WIDTH_PX / zoom, true)
|
||||||
|
var cross_radius := 9.0 / zoom
|
||||||
|
var target_color := set_down_reach_color if target_adjusted else color
|
||||||
|
draw_line(action_target + Vector2(-cross_radius, 0.0), action_target + Vector2(cross_radius, 0.0), target_color, line_width_px / zoom, true)
|
||||||
|
draw_line(action_target + Vector2(0.0, -cross_radius), action_target + Vector2(0.0, cross_radius), target_color, line_width_px / zoom, true)
|
||||||
|
if set_down_grip.is_finite() and set_down_grip != Vector2.ZERO:
|
||||||
|
draw_line(set_down_approach, set_down_grip, set_down_reach_color, line_width_px / zoom, true)
|
||||||
|
|
||||||
|
|
||||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||||
var zoom := _zoom()
|
var zoom := _zoom()
|
||||||
var dash := dash_length_px / zoom
|
var dash := dash_length_px / zoom
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ extends RefCounted
|
|||||||
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
||||||
## scene-tree dependencies, which makes rejected documents non-destructive.
|
## scene-tree dependencies, which makes rejected documents non-destructive.
|
||||||
|
|
||||||
const FILE_VERSION: String = "1.1"
|
const FILE_VERSION: String = "1.2"
|
||||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
|
||||||
static func vector_to_data(value: Vector2) -> Dictionary:
|
static func vector_to_data(value: Vector2) -> Dictionary:
|
||||||
return {"x": value.x, "y": value.y}
|
return {"x": value.x, "y": value.y}
|
||||||
@@ -157,8 +158,13 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
|
|||||||
static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||||
var version := String(data.get("version", ""))
|
var version := String(data.get("version", ""))
|
||||||
if version == FILE_VERSION:
|
if version == FILE_VERSION:
|
||||||
return data
|
var current: Dictionary = data.duplicate(true)
|
||||||
if version != "1.0":
|
for raw: Variant in current.get("objects", []):
|
||||||
|
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop" and not (raw as Dictionary).has("roles"):
|
||||||
|
(raw as Dictionary)["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
|
||||||
|
_assign_legacy_depths(current.get("objects", []))
|
||||||
|
return current
|
||||||
|
if version not in ["1.0", "1.1"]:
|
||||||
return {}
|
return {}
|
||||||
var migrated: Dictionary = data.duplicate(true)
|
var migrated: Dictionary = data.duplicate(true)
|
||||||
for raw: Variant in migrated.get("objects", []):
|
for raw: Variant in migrated.get("objects", []):
|
||||||
@@ -166,10 +172,39 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
|||||||
continue
|
continue
|
||||||
var object: Dictionary = raw
|
var object: Dictionary = raw
|
||||||
if String(object.get("type", "")) == "prop":
|
if String(object.get("type", "")) == "prop":
|
||||||
object["stage_object_id"] = String(object.get("file_id", ""))
|
if version == "1.0":
|
||||||
|
object["stage_object_id"] = String(object.get("file_id", ""))
|
||||||
|
if not object.has("roles"):
|
||||||
|
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
|
||||||
migrated["version"] = FILE_VERSION
|
migrated["version"] = FILE_VERSION
|
||||||
|
_assign_legacy_depths(migrated.get("objects", []))
|
||||||
return migrated
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
static func _assign_legacy_depths(raw_objects: Variant) -> void:
|
||||||
|
if not raw_objects is Array:
|
||||||
|
return
|
||||||
|
var objects: Array = raw_objects
|
||||||
|
for index: int in objects.size():
|
||||||
|
if not objects[index] is Dictionary:
|
||||||
|
continue
|
||||||
|
var entry: Dictionary = objects[index]
|
||||||
|
if String(entry.get("type", "")) != "prop" or not entry.get("roles", {}) is Dictionary:
|
||||||
|
continue
|
||||||
|
var roles: Dictionary = entry["roles"]
|
||||||
|
if not bool(roles.get("legacy_draw_order", false)):
|
||||||
|
continue
|
||||||
|
var characters_before: int = 0
|
||||||
|
var characters_after: int = 0
|
||||||
|
for other_index: int in objects.size():
|
||||||
|
if not objects[other_index] is Dictionary or String((objects[other_index] as Dictionary).get("type", "")) != "stickman":
|
||||||
|
continue
|
||||||
|
if other_index < index:
|
||||||
|
characters_before += 1
|
||||||
|
else:
|
||||||
|
characters_after += 1
|
||||||
|
roles["depth"] = PROP_STAGE_ROLES.DEPTH_FRONT if characters_before > characters_after else PROP_STAGE_ROLES.DEPTH_BEHIND
|
||||||
|
|
||||||
static func _validate_root(data: Dictionary) -> Dictionary:
|
static func _validate_root(data: Dictionary) -> Dictionary:
|
||||||
var seen: Dictionary = {}
|
var seen: Dictionary = {}
|
||||||
var seen_stage_ids: Dictionary = {}
|
var seen_stage_ids: Dictionary = {}
|
||||||
@@ -187,6 +222,17 @@ static func _validate_root(data: Dictionary) -> Dictionary:
|
|||||||
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
||||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
|
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
|
||||||
seen_stage_ids[stage_id] = true
|
seen_stage_ids[stage_id] = true
|
||||||
|
if entry.has("roles"):
|
||||||
|
var roles_value: Variant = entry["roles"]
|
||||||
|
if not roles_value is Dictionary:
|
||||||
|
return {"ok": false, "error": "Prop roles must be an object."}
|
||||||
|
var roles: Dictionary = roles_value
|
||||||
|
for key: String in ["depth", "start_motion"]:
|
||||||
|
if roles.has(key) and (not roles[key] is String or (key == "depth" and not [PROP_STAGE_ROLES.DEPTH_BEHIND, PROP_STAGE_ROLES.DEPTH_FRONT].has(roles[key])) or (key == "start_motion" and not [PROP_STAGE_ROLES.START_STILL, PROP_STAGE_ROLES.START_PHYSICS].has(roles[key]))):
|
||||||
|
return {"ok": false, "error": "Prop role '%s' is invalid." % key}
|
||||||
|
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
|
||||||
|
if roles.has(key) and not roles[key] is bool:
|
||||||
|
return {"ok": false, "error": "Prop role '%s' must be true or false." % key}
|
||||||
return {"ok": true, "error": ""}
|
return {"ok": true, "error": ""}
|
||||||
|
|
||||||
static func _recover_backup(path: String) -> void:
|
static func _recover_backup(path: String) -> void:
|
||||||
@@ -220,6 +266,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
|||||||
var target_position: Vector2 = action["target_position"]
|
var target_position: Vector2 = action["target_position"]
|
||||||
if not target_position.is_finite(): return {}
|
if not target_position.is_finite(): return {}
|
||||||
return {"type": type, "target_position": vector_to_data(target_position)}
|
return {"type": type, "target_position": vector_to_data(target_position)}
|
||||||
|
"set_down_prop":
|
||||||
|
if not action.get("target_position") is Vector2: return {}
|
||||||
|
var release_position: Vector2 = action["target_position"]
|
||||||
|
if not release_position.is_finite(): return {}
|
||||||
|
return {"type": type, "target_position": vector_to_data(release_position)}
|
||||||
_: return {"type": type}
|
_: return {"type": type}
|
||||||
|
|
||||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||||
@@ -243,6 +294,9 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
|||||||
"throw_prop":
|
"throw_prop":
|
||||||
if not is_vector_data(action.get("target_position")): return {}
|
if not is_vector_data(action.get("target_position")): return {}
|
||||||
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
|
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
|
||||||
|
"set_down_prop":
|
||||||
|
if not is_vector_data(action.get("target_position")): return {}
|
||||||
|
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
|
||||||
_: return {"type": type}
|
_: return {"type": type}
|
||||||
|
|
||||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||||
|
|||||||
@@ -97,7 +97,30 @@ func is_selected(node: Node2D) -> bool:
|
|||||||
|
|
||||||
## Returns the frontmost selectable node under `world_pos`, or null.
|
## Returns the frontmost selectable node under `world_pos`, or null.
|
||||||
func hit_test(world_pos: Vector2) -> Node2D:
|
func hit_test(world_pos: Vector2) -> Node2D:
|
||||||
return _frontmost_at(world_pos)
|
var hits: Array[Node2D] = hit_test_all(world_pos)
|
||||||
|
return hits[0] if not hits.is_empty() else null
|
||||||
|
|
||||||
|
|
||||||
|
## Returns every geometrically selectable object under the point, frontmost first.
|
||||||
|
func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
|
||||||
|
var hits: Array[Node2D] = []
|
||||||
|
for child: Node in _world.get_children():
|
||||||
|
var node := child as Node2D
|
||||||
|
if node == null or not _is_selectable(node):
|
||||||
|
continue
|
||||||
|
if get_world_aabb(node).has_point(world_pos):
|
||||||
|
hits.append(node)
|
||||||
|
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
|
||||||
|
if ragdoll_hit != null and not hits.has(ragdoll_hit):
|
||||||
|
hits.append(ragdoll_hit)
|
||||||
|
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
|
||||||
|
if a.z_index != b.z_index:
|
||||||
|
return a.z_index > b.z_index
|
||||||
|
if a.get_index() != b.get_index():
|
||||||
|
return a.get_index() > b.get_index()
|
||||||
|
return get_world_aabb(a).get_area() < get_world_aabb(b).get_area()
|
||||||
|
)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
|
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
|
||||||
@@ -223,20 +246,5 @@ func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
|||||||
## Highest World child index wins; on equal area the frontmost (first hit in
|
## Highest World child index wins; on equal area the frontmost (first hit in
|
||||||
## reverse iteration) stays.
|
## reverse iteration) stays.
|
||||||
func _frontmost_at(world_pos: Vector2) -> Node2D:
|
func _frontmost_at(world_pos: Vector2) -> Node2D:
|
||||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
|
var hits: Array[Node2D] = hit_test_all(world_pos)
|
||||||
if ragdoll_hit != null:
|
return hits[0] if not hits.is_empty() else null
|
||||||
return ragdoll_hit
|
|
||||||
var children := _world.get_children()
|
|
||||||
var best: Node2D = null
|
|
||||||
var best_area := INF
|
|
||||||
for i: int in range(children.size() - 1, -1, -1):
|
|
||||||
var node := children[i] as Node2D
|
|
||||||
if node == null or not _is_selectable(node):
|
|
||||||
continue
|
|
||||||
var aabb := get_world_aabb(node)
|
|
||||||
if aabb.has_point(world_pos):
|
|
||||||
var area := aabb.get_area()
|
|
||||||
if best == null or area < best_area:
|
|
||||||
best = node
|
|
||||||
best_area = area
|
|
||||||
return best
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ extends RefCounted
|
|||||||
const TERRAIN_UTILS := preload("res://scripts/terrain/terrain_utils.gd")
|
const TERRAIN_UTILS := preload("res://scripts/terrain/terrain_utils.gd")
|
||||||
const PROP_UTILS := preload("res://scripts/props/prop_utils.gd")
|
const PROP_UTILS := preload("res://scripts/props/prop_utils.gd")
|
||||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||||
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
||||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
@@ -180,6 +181,7 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
|
|||||||
prop.spawn_id = String(template.get("id", asset_id))
|
prop.spawn_id = String(template.get("id", asset_id))
|
||||||
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
|
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
|
||||||
prop.ensure_default_grip()
|
prop.ensure_default_grip()
|
||||||
|
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(prop.has_usable_grip()))
|
||||||
return prop
|
return prop
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func _test_custom_prop_lifecycle() -> void:
|
|||||||
var authored_scale: Vector2 = prop.scale
|
var authored_scale: Vector2 = prop.scale
|
||||||
_check(prop.freeze, "custom prop is frozen in EDIT")
|
_check(prop.freeze, "custom prop is frozen in EDIT")
|
||||||
stage.set_mode(stage.StageMode.PLAY)
|
stage.set_mode(stage.StageMode.PLAY)
|
||||||
_check(not prop.freeze, "custom prop unfreezes in PLAY")
|
_check(prop.freeze and prop.runtime_role == PROP_BLOCK.RuntimeRole.START_STILL, "new custom prop starts still in PLAY")
|
||||||
var play_scene: Dictionary = stage._collect_scene_data()
|
var play_scene: Dictionary = stage._collect_scene_data()
|
||||||
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
|
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
|
||||||
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
|
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func _run() -> void:
|
|||||||
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
|
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
|
||||||
prop.freeze = true
|
prop.freeze = true
|
||||||
root.add_child(prop)
|
root.add_child(prop)
|
||||||
|
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
|
||||||
await process_frame
|
await process_frame
|
||||||
var gizmos: StageGizmos = STAGE_GIZMOS.new()
|
var gizmos: StageGizmos = STAGE_GIZMOS.new()
|
||||||
root.add_child(gizmos)
|
root.add_child(gizmos)
|
||||||
@@ -128,6 +129,7 @@ func _run() -> void:
|
|||||||
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
|
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
|
||||||
gizmos.end_drag()
|
gizmos.end_drag()
|
||||||
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
|
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
|
||||||
|
prop.begin_play_instance_scale()
|
||||||
await physics_frame
|
await physics_frame
|
||||||
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
|
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
|
||||||
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||||
@@ -138,8 +140,7 @@ func _run() -> void:
|
|||||||
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
|
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
|
||||||
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
|
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
|
||||||
prop.global_rotation = deg_to_rad(20.0)
|
prop.global_rotation = deg_to_rad(20.0)
|
||||||
prop.begin_play_instance_scale()
|
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||||
prop.freeze = false
|
|
||||||
for frame: int in 3:
|
for frame: int in 3:
|
||||||
await physics_frame
|
await physics_frame
|
||||||
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
|
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
|
||||||
@@ -154,6 +155,7 @@ func _run() -> void:
|
|||||||
circle.scale = Vector2(2.0, 2.0)
|
circle.scale = Vector2(2.0, 2.0)
|
||||||
circle.freeze = true
|
circle.freeze = true
|
||||||
root.add_child(circle)
|
root.add_child(circle)
|
||||||
|
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
|
||||||
await physics_frame
|
await physics_frame
|
||||||
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||||
@@ -161,7 +163,7 @@ func _run() -> void:
|
|||||||
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
|
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
|
||||||
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
|
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
|
||||||
circle.begin_play_instance_scale()
|
circle.begin_play_instance_scale()
|
||||||
circle.freeze = false
|
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||||
for frame: int in 3:
|
for frame: int in 3:
|
||||||
await physics_frame
|
await physics_frame
|
||||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||||
@@ -171,6 +173,7 @@ func _run() -> void:
|
|||||||
circle.end_play_instance_scale()
|
circle.end_play_instance_scale()
|
||||||
circle.scale = Vector2(2.0, 1.0)
|
circle.scale = Vector2(2.0, 1.0)
|
||||||
circle.begin_play_instance_scale()
|
circle.begin_play_instance_scale()
|
||||||
|
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||||
await physics_frame
|
await physics_frame
|
||||||
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||||
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
|
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd")
|
||||||
|
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
|
||||||
|
|
||||||
|
var _checks: int = 0
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
_test_role_defaults_and_matrix()
|
||||||
|
_test_legacy_migration()
|
||||||
|
_test_set_down_serialization()
|
||||||
|
_test_unknown_preview()
|
||||||
|
await _test_runtime_profiles()
|
||||||
|
await _test_start_still_support_contact()
|
||||||
|
await _test_set_down_clearance_and_release()
|
||||||
|
await _test_set_down_preserves_pickup_orientation()
|
||||||
|
await _test_set_down_low_reach_pose()
|
||||||
|
await _test_set_down_prop_follows_approaching_actor()
|
||||||
|
print("----------------------------------------")
|
||||||
|
if _failures == 0:
|
||||||
|
print("RESULT: ALL PASSED (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _expect(condition: bool, message: String) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if condition:
|
||||||
|
print("PASS: " + message)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error("Phase 17a check failed: " + message)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_role_defaults_and_matrix() -> void:
|
||||||
|
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||||
|
_expect(roles["depth"] == PROP_STAGE_ROLES.DEPTH_BEHIND, "new props start behind characters")
|
||||||
|
_expect(roles["start_motion"] == PROP_STAGE_ROLES.START_STILL, "new props start still")
|
||||||
|
_expect(not bool(roles["can_pick_up"]), "props without a grip are not pickable")
|
||||||
|
_expect(bool(roles["released_blocks_stickmen"]) and bool(roles["released_bumps_other_props"]), "released contacts default On")
|
||||||
|
var table_roles: Dictionary = roles.duplicate(true)
|
||||||
|
table_roles["holds_other_props"] = true
|
||||||
|
var pot_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||||
|
_expect(PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still support catches a physics prop")
|
||||||
|
table_roles["holds_other_props"] = false
|
||||||
|
_expect(not PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still prop without support capability does not catch props")
|
||||||
|
var released_roles: Dictionary = pot_roles.duplicate(true)
|
||||||
|
released_roles["released_bumps_other_props"] = false
|
||||||
|
_expect(not PROP_STAGE_ROLES.props_should_collide(released_roles, 4, table_roles, 2), "released other prop Off wins over dynamic contacts")
|
||||||
|
_expect(PROP_STAGE_ROLES.props_should_collide(pot_roles, 2, table_roles, 2), "two physics props can collide")
|
||||||
|
_expect(not PROP_STAGE_ROLES.props_should_collide(pot_roles, 3, table_roles, 2), "held props never collide")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_legacy_migration() -> void:
|
||||||
|
var legacy := {
|
||||||
|
"version": "1.0",
|
||||||
|
"objects": [
|
||||||
|
{"file_id": "character_a", "type": "stickman"},
|
||||||
|
{"file_id": "prop_a", "type": "prop"},
|
||||||
|
{"file_id": "character_b", "type": "stickman"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy)
|
||||||
|
var prop_entry: Dictionary = migrated["objects"][1]
|
||||||
|
var roles: Dictionary = prop_entry["roles"]
|
||||||
|
_expect(migrated["version"] == "1.2", "old stage version migrates to 1.2")
|
||||||
|
_expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs")
|
||||||
|
_expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior")
|
||||||
|
_expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order")
|
||||||
|
var current_incomplete := {"version": "1.2", "objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p"}]}
|
||||||
|
var repaired: Dictionary = STAGE_SCENE_IO._migrate_to_current(current_incomplete)
|
||||||
|
_expect(bool(repaired["objects"][0]["roles"]["legacy_draw_order"]), "1.2 prop missing roles receives legacy behavior")
|
||||||
|
var invalid := {"objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p", "roles": {"depth": 5}}]}
|
||||||
|
_expect(not bool(STAGE_SCENE_IO._validate_root(invalid)["ok"]), "malformed present role data is rejected")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_set_down_serialization() -> void:
|
||||||
|
var position := Vector2(420.0, -260.0)
|
||||||
|
var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([{"type": "set_down_prop", "target_position": position}])
|
||||||
|
var queue_entries: Array = encoded_queue["actions"]
|
||||||
|
var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(queue_entries)
|
||||||
|
var queue_action: Dictionary = decoded_queue["actions"][0]
|
||||||
|
_expect(queue_action["type"] == "set_down_prop" and queue_action["target_position"] == position, "Set Down queue target round-trips")
|
||||||
|
var flat_action := {"type": "set_down_prop", "target_position": position}
|
||||||
|
var rule_action: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 7)
|
||||||
|
var rules: Array[Dictionary] = [{
|
||||||
|
"id": 1,
|
||||||
|
"trigger": {"type": "speech_finished", "source": 7, "target": -1, "params": {}},
|
||||||
|
"actions": [rule_action],
|
||||||
|
}]
|
||||||
|
var runtime_to_file := {7: "actor_file"}
|
||||||
|
var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file)
|
||||||
|
var file_to_runtime := {"actor_file": 7}
|
||||||
|
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], file_to_runtime, {})
|
||||||
|
var decoded_action: Dictionary = decoded_rules["rules"][0]["actions"][0]
|
||||||
|
_expect(decoded_action["type"] == "set_down_prop" and decoded_action["params"]["target_position"] == position, "Set Down rule target round-trips")
|
||||||
|
_expect(String(ACTION_REGISTRY.summarize(flat_action)).contains("Set Down Prop"), "Set Down queue summary is available")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_unknown_preview() -> void:
|
||||||
|
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||||
|
root.add_child(interactor)
|
||||||
|
var empty_prefix: Array[Dictionary] = []
|
||||||
|
var preview: Dictionary = interactor.call("preview_set_down", Vector2(30.0, 60.0), empty_prefix)
|
||||||
|
_expect(not bool(preview.get("known", true)), "unknown held prop preview remains conditional")
|
||||||
|
_expect(String(preview.get("reason", "")) == "Prop depends on earlier actions", "unknown preview shows the conditional cue")
|
||||||
|
interactor.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_runtime_profiles() -> void:
|
||||||
|
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
root.add_child(prop)
|
||||||
|
await process_frame
|
||||||
|
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||||
|
roles["blocks_stickmen"] = true
|
||||||
|
roles["released_blocks_stickmen"] = false
|
||||||
|
prop.set_stage_roles(roles)
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||||
|
_expect(prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.START_STILL, "start still profile freezes the body")
|
||||||
|
_expect((prop.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "start still prop keeps its physical shape active")
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||||
|
_expect(prop.freeze and prop.collision_layer == 0 and prop.collision_mask == 0, "held profile removes physics contacts")
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
|
||||||
|
_expect(not prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.RELEASED, "released profile wakes a dynamic body")
|
||||||
|
_expect((prop.collision_layer & STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER) == 0, "released actor collision setting is independent of initial blocking")
|
||||||
|
prop.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _test_start_still_support_contact() -> void:
|
||||||
|
var world := Node2D.new()
|
||||||
|
root.add_child(world)
|
||||||
|
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
support.position = Vector2(0.0, 100.0)
|
||||||
|
support.polygon_points = PackedVector2Array([Vector2(-60.0, -10.0), Vector2(60.0, -10.0), Vector2(60.0, 10.0), Vector2(-60.0, 10.0)])
|
||||||
|
world.add_child(support)
|
||||||
|
await process_frame
|
||||||
|
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||||
|
support_roles["holds_other_props"] = true
|
||||||
|
support.set_stage_roles(support_roles)
|
||||||
|
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||||
|
var falling: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
falling.position = Vector2(0.0, 55.0)
|
||||||
|
falling.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
|
||||||
|
world.add_child(falling)
|
||||||
|
await process_frame
|
||||||
|
var falling_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||||
|
falling_roles["start_motion"] = PROP_STAGE_ROLES.START_PHYSICS
|
||||||
|
falling.set_stage_roles(falling_roles)
|
||||||
|
falling.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS)
|
||||||
|
var contact: Dictionary = {"other": false}
|
||||||
|
falling.collided.connect(func(other: Node, _normal: Vector2, _position: Vector2) -> void:
|
||||||
|
if other == support:
|
||||||
|
contact["other"] = true
|
||||||
|
)
|
||||||
|
for _frame: int in 45:
|
||||||
|
await physics_frame
|
||||||
|
_expect(support.freeze and support.freeze_mode == RigidBody2D.FREEZE_MODE_STATIC, "start still support remains a static frozen collider")
|
||||||
|
_expect(bool(contact["other"]) and falling.get_colliding_bodies().has(support), "dynamic prop makes an ordinary body contact with the frozen support")
|
||||||
|
_expect(falling.global_position.y >= 78.0 and falling.global_position.y <= 82.0, "dynamic prop rests on the frozen support geometry")
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _test_set_down_clearance_and_release() -> void:
|
||||||
|
var world := Node2D.new()
|
||||||
|
root.add_child(world)
|
||||||
|
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
support.position = Vector2(0.0, 100.0)
|
||||||
|
support.polygon_points = PackedVector2Array([Vector2(-100.0, -50.0), Vector2(100.0, -50.0), Vector2(100.0, 50.0), Vector2(-100.0, 50.0)])
|
||||||
|
world.add_child(support)
|
||||||
|
await process_frame
|
||||||
|
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||||
|
support_roles["holds_other_props"] = true
|
||||||
|
support.set_stage_roles(support_roles)
|
||||||
|
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||||
|
var held: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
held.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
|
||||||
|
world.add_child(held)
|
||||||
|
await process_frame
|
||||||
|
var held_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||||
|
held.set_stage_roles(held_roles)
|
||||||
|
held.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||||
|
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||||
|
world.add_child(interactor)
|
||||||
|
await physics_frame
|
||||||
|
var requested := Vector2(0.0, 120.0)
|
||||||
|
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", held, requested)
|
||||||
|
_expect(bool(resolved.get("valid", false)), "Set Down raises a point embedded in a support prop")
|
||||||
|
var release_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
_expect(float(resolved.get("clearance_lift", 0.0)) > 50.0 and release_transform.origin.y < requested.y, "support clearance moves only the release origin upward")
|
||||||
|
_expect(is_equal_approx(release_transform.origin.x, requested.x), "support clearance preserves the clicked horizontal point")
|
||||||
|
var above_support: Vector2 = Vector2(0.0, 25.0)
|
||||||
|
var unchanged: Dictionary = interactor.call("_resolve_set_down_release_transform", held, above_support)
|
||||||
|
var unchanged_transform: Transform2D = unchanged.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
_expect(bool(unchanged.get("valid", false)) and unchanged_transform.origin.is_equal_approx(above_support), "a clear point above the support stays exactly where clicked")
|
||||||
|
var open_air := Vector2(300.0, -200.0)
|
||||||
|
var open_air_result: Dictionary = interactor.call("_resolve_set_down_release_transform", held, open_air)
|
||||||
|
var open_air_transform: Transform2D = open_air_result.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
_expect(bool(open_air_result.get("valid", false)) and open_air_transform.origin.is_equal_approx(open_air), "an open-air release stays exactly where clicked")
|
||||||
|
_expect(held.collision_layer == 0 and held.collision_mask == 0, "Set Down keeps the object non-collidable while held")
|
||||||
|
if bool(resolved.get("valid", false)):
|
||||||
|
held.global_transform = release_transform
|
||||||
|
held.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
|
||||||
|
held.sleeping = false
|
||||||
|
for _frame: int in 45:
|
||||||
|
await physics_frame
|
||||||
|
_expect(held.runtime_role == PropBlock.RuntimeRole.RELEASED and (held.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "release enables the configured prop collisions")
|
||||||
|
_expect(held.get_colliding_bodies().has(support), "released object falls onto the support")
|
||||||
|
_expect(absf(held.global_position.y - 40.0) <= 3.0, "released object settles on top of the crate")
|
||||||
|
interactor.queue_free()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _test_set_down_low_reach_pose() -> void:
|
||||||
|
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
|
||||||
|
root.add_child(rig)
|
||||||
|
await physics_frame
|
||||||
|
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
|
||||||
|
var torso_marker := rig.get_node(NodePath("IK_Targets/Torso")) as Marker2D
|
||||||
|
var upper_leg := rig.get_node(NodePath("Skeleton2D/Torso/RightUpperLeg")) as Bone2D
|
||||||
|
var start_position := torso_marker.position
|
||||||
|
var start_rotation := torso_marker.rotation
|
||||||
|
var start_leg_rotation := upper_leg.rotation
|
||||||
|
interactor.set("_set_down_facing_left", false)
|
||||||
|
interactor.call("_prepare_set_down_crouch", rig.to_global(Vector2(60.0, 430.0)))
|
||||||
|
interactor.call("_update_pickup_crouch", 0.5)
|
||||||
|
await physics_frame
|
||||||
|
_expect(torso_marker.position.y > start_position.y and torso_marker.rotation != start_rotation, "low Set Down reach lowers and leans the torso smoothly")
|
||||||
|
_expect(absf(wrapf(upper_leg.rotation - start_leg_rotation, -PI, PI)) > 0.01, "the lowered torso lets leg IK bend a knee")
|
||||||
|
interactor.call("_update_pickup_crouch", 0.0)
|
||||||
|
await physics_frame
|
||||||
|
_expect(torso_marker.position.is_equal_approx(start_position), "Set Down body recovery returns the torso to its starting pose")
|
||||||
|
rig.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _test_set_down_preserves_pickup_orientation() -> void:
|
||||||
|
var world := Node2D.new()
|
||||||
|
root.add_child(world)
|
||||||
|
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
world.add_child(prop)
|
||||||
|
await process_frame
|
||||||
|
prop.global_transform = Transform2D(PI * 0.5, Vector2(0.0, -100.0))
|
||||||
|
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||||
|
var pickup_transform := prop.global_transform
|
||||||
|
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||||
|
world.add_child(interactor)
|
||||||
|
interactor.set("_held_prop", prop)
|
||||||
|
interactor.set("_pickup_transform", pickup_transform)
|
||||||
|
interactor.set("_pickup_transform_valid", true)
|
||||||
|
prop.global_transform = Transform2D(0.0, Vector2(0.0, -100.0))
|
||||||
|
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", prop, Vector2(1000.0, -300.0))
|
||||||
|
var resolved_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
_expect(bool(resolved.get("valid", false)), "orientation-preserving Set Down target remains valid")
|
||||||
|
_expect(absf(wrapf(resolved_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down restores the prop's pickup orientation")
|
||||||
|
interactor.set("_held_anchor", {"position": Vector2(20.0, 0.0)})
|
||||||
|
interactor.set("_set_down_facing_left", false)
|
||||||
|
interactor.set("_set_down_reach_start_transform", Transform2D(PI, Vector2.ZERO))
|
||||||
|
var hand_position := Vector2(80.0, 120.0)
|
||||||
|
var eased_transform: Transform2D = interactor.call("_set_down_reach_attachment_transform", hand_position, 1.0)
|
||||||
|
_expect(absf(wrapf(eased_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down reach smoothly returns the prop to its pickup rotation")
|
||||||
|
_expect((eased_transform * Vector2(20.0, 0.0)).distance_to(hand_position) < 0.001, "restored prop orientation keeps its grip anchor at the hand")
|
||||||
|
interactor.queue_free()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _test_set_down_prop_follows_approaching_actor() -> void:
|
||||||
|
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
|
||||||
|
root.add_child(rig)
|
||||||
|
await physics_frame
|
||||||
|
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||||
|
prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)])
|
||||||
|
prop.position = Vector2(1400.0, 900.0)
|
||||||
|
root.add_child(prop)
|
||||||
|
await process_frame
|
||||||
|
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||||
|
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
|
||||||
|
interactor.set("_held_prop", prop)
|
||||||
|
interactor.set("_held_anchor", {"position": Vector2.ZERO})
|
||||||
|
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.SET_DOWN_APPROACHING)
|
||||||
|
var before := prop.global_position
|
||||||
|
interactor.call("tick", 0.016)
|
||||||
|
_expect(prop.global_position.distance_to(before) > 500.0, "a held prop follows the hand while the actor walks to Set Down")
|
||||||
|
_expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and prop.collision_layer == 0 and prop.collision_mask == 0, "the following prop remains collision-free during the approach")
|
||||||
|
interactor.set("_held_prop", null)
|
||||||
|
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.IDLE)
|
||||||
|
prop.queue_free()
|
||||||
|
rig.queue_free()
|
||||||
|
await process_frame
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ewdgod63htwq
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
|
|
||||||
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
|
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
|
||||||
|
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||||
|
|
||||||
var _checks := 0
|
var _checks := 0
|
||||||
var _failures := 0
|
var _failures := 0
|
||||||
@@ -23,7 +24,7 @@ func _run() -> void:
|
|||||||
_test_collision_proxy_geometry(rig)
|
_test_collision_proxy_geometry(rig)
|
||||||
await _test_collision_proxy_physics_alignment(rig)
|
await _test_collision_proxy_physics_alignment(rig)
|
||||||
_test_reactions(rig)
|
_test_reactions(rig)
|
||||||
await _test_direct_restores_animated_pose(stage, rig)
|
await _test_direct_pause_and_edit_restore(stage, rig)
|
||||||
_test_ghost_collision(stage)
|
_test_ghost_collision(stage)
|
||||||
await _test_stage_contact_routing(stage, rig)
|
await _test_stage_contact_routing(stage, rig)
|
||||||
_test_ragdoll_transitions(rig)
|
_test_ragdoll_transitions(rig)
|
||||||
@@ -54,7 +55,7 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
|||||||
_check(body.name == "CollisionBody", "%s proxy uses the standard name" % part_name)
|
_check(body.name == "CollisionBody", "%s proxy uses the standard name" % part_name)
|
||||||
_check(not body.sync_to_physics, "%s proxy uses explicit transform synchronization" % part_name)
|
_check(not body.sync_to_physics, "%s proxy uses explicit transform synchronization" % part_name)
|
||||||
_check(body.top_level, "%s proxy is isolated from RemoteTransform2D parent updates" % part_name)
|
_check(body.top_level, "%s proxy is isolated from RemoteTransform2D parent updates" % part_name)
|
||||||
_check(body.collision_layer == 1 and body.collision_mask == 1, "%s proxy starts enabled" % part_name)
|
_check(body.collision_layer == STAGE_PHYSICS.ACTOR_LAYER and body.collision_mask == STAGE_PHYSICS.actor_mask(), "%s proxy starts with the actor policy" % part_name)
|
||||||
_check(body.get_meta("stickman_rig", null) == rig, "%s proxy stores its rig" % part_name)
|
_check(body.get_meta("stickman_rig", null) == rig, "%s proxy stores its rig" % part_name)
|
||||||
_check(String(body.get_meta("part_name", "")) == part_name, "%s proxy stores its part name" % part_name)
|
_check(String(body.get_meta("part_name", "")) == part_name, "%s proxy stores its part name" % part_name)
|
||||||
|
|
||||||
@@ -90,9 +91,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
|||||||
empty_visual.free()
|
empty_visual.free()
|
||||||
|
|
||||||
rig.set_collision_bodies_enabled(false)
|
rig.set_collision_bodies_enabled(false)
|
||||||
_check(_all_proxy_layers_equal(rig, 0), "public collision hook disables every proxy")
|
_check(_all_proxy_layers_equal(rig, 0, 0), "public collision hook disables every proxy")
|
||||||
rig.set_collision_bodies_enabled(true)
|
rig.set_collision_bodies_enabled(true)
|
||||||
_check(_all_proxy_layers_equal(rig, 1), "public collision hook re-enables every proxy")
|
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "public collision hook re-enables every proxy with the actor policy")
|
||||||
|
|
||||||
var sizes_before := _proxy_shape_sizes(rig)
|
var sizes_before := _proxy_shape_sizes(rig)
|
||||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||||
@@ -181,7 +182,7 @@ func _test_reactions(rig: StickmanRig) -> void:
|
|||||||
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
|
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
|
||||||
|
|
||||||
|
|
||||||
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
|
func _test_direct_pause_and_edit_restore(stage: Node2D, rig: StickmanRig) -> void:
|
||||||
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
||||||
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||||
var authored_markers := ragdoll.get_spawn_pose()
|
var authored_markers := ragdoll.get_spawn_pose()
|
||||||
@@ -194,18 +195,37 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
|
|||||||
torso_marker.rotation = PI
|
torso_marker.rotation = PI
|
||||||
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
|
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
|
||||||
head_marker.position += Vector2(120.0, 80.0)
|
head_marker.position += Vector2(120.0, 80.0)
|
||||||
|
var direct_position: Vector2 = rig.global_position
|
||||||
|
var direct_rotation: float = rig.global_rotation
|
||||||
|
var direct_facing: int = rig.get_facing_profile()
|
||||||
|
var direct_markers: Dictionary = {}
|
||||||
|
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||||
|
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||||
|
direct_markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
|
||||||
stage.set_mode(stage.StageMode.DIRECT)
|
stage.set_mode(stage.StageMode.DIRECT)
|
||||||
for _frame in 4:
|
for _frame in 4:
|
||||||
await physics_frame
|
await physics_frame
|
||||||
await process_frame
|
await process_frame
|
||||||
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position")
|
_check(rig.global_position.distance_to(direct_position) < 0.01, "PLAY to DIRECT preserves the live stickman position while paused")
|
||||||
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation")
|
_check(absf(angle_difference(direct_rotation, rig.global_rotation)) < 0.001, "PLAY to DIRECT preserves the live stickman rotation while paused")
|
||||||
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
|
_check(rig.get_facing_profile() == direct_facing, "PLAY to DIRECT preserves the live facing profile while paused")
|
||||||
|
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||||
|
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||||
|
var paused_pose: Dictionary = direct_markers[marker_name]
|
||||||
|
_check(marker.position.distance_to(paused_pose["pos"] as Vector2) < 0.01, "%s marker stays in its live pose while DIRECT is paused" % marker_name)
|
||||||
|
_check(absf(angle_difference(float(paused_pose["rot"]), marker.rotation)) < 0.001, "%s marker keeps its live rotation while DIRECT is paused" % marker_name)
|
||||||
|
stage.set_mode(stage.StageMode.EDIT)
|
||||||
|
for _frame in 4:
|
||||||
|
await physics_frame
|
||||||
|
await process_frame
|
||||||
|
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "EDIT/STOP reset restores the authored stickman position")
|
||||||
|
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "EDIT/STOP reset restores the authored stickman rotation")
|
||||||
|
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "EDIT/STOP reset restores the authored facing profile")
|
||||||
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||||
var expected: Dictionary = authored_markers[marker_name]
|
var expected: Dictionary = authored_markers[marker_name]
|
||||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
|
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored pose after EDIT/STOP reset" % marker_name)
|
||||||
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
|
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "EDIT/STOP reset restores the authored torso marker rotation")
|
||||||
|
|
||||||
|
|
||||||
func _test_ghost_collision(stage: Node2D) -> void:
|
func _test_ghost_collision(stage: Node2D) -> void:
|
||||||
@@ -213,12 +233,14 @@ func _test_ghost_collision(stage: Node2D) -> void:
|
|||||||
if ghost == null:
|
if ghost == null:
|
||||||
return
|
return
|
||||||
stage._configure_ghost_collision(ghost)
|
stage._configure_ghost_collision(ghost)
|
||||||
_check(_all_proxy_layers_equal(ghost, 0), "stickman placement ghosts have no collision")
|
_check(_all_proxy_layers_equal(ghost, 0, 0), "stickman placement ghosts have no collision")
|
||||||
ghost.queue_free()
|
ghost.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
||||||
stage.set_mode(stage.StageMode.PLAY)
|
stage.set_mode(stage.StageMode.PLAY)
|
||||||
|
for _frame: int in 3:
|
||||||
|
await physics_frame
|
||||||
var prop := PropBlock.new()
|
var prop := PropBlock.new()
|
||||||
prop.position = Vector2(50.0, 25.0)
|
prop.position = Vector2(50.0, 25.0)
|
||||||
prop.linear_velocity = Vector2(400.0, 0.0)
|
prop.linear_velocity = Vector2(400.0, 0.0)
|
||||||
@@ -332,26 +354,26 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void:
|
|||||||
_check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
|
_check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
|
||||||
rig.set_ragdoll(true)
|
rig.set_ragdoll(true)
|
||||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
|
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
|
||||||
_check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies")
|
_check(_all_proxy_layers_equal(rig, 0, 0), "ragdoll disables animated collision proxies")
|
||||||
_check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions")
|
_check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions")
|
||||||
|
|
||||||
rig.request_recovery()
|
rig.request_recovery()
|
||||||
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
|
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
|
||||||
_check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery")
|
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "collision proxies stay enabled throughout recovery")
|
||||||
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
||||||
_check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions")
|
_check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions")
|
||||||
|
|
||||||
rig.set_ragdoll(true)
|
rig.set_ragdoll(true)
|
||||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
|
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
|
||||||
_check(_all_proxy_layers_equal(rig, 0), "recovery interruption disables proxies again")
|
_check(_all_proxy_layers_equal(rig, 0, 0), "recovery interruption disables proxies again")
|
||||||
rig.snap_to_standing()
|
rig.snap_to_standing()
|
||||||
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap returns the rig to animated")
|
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap returns the rig to animated")
|
||||||
_check(_all_proxy_layers_equal(rig, 1), "snap restores animated collision proxies")
|
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "snap restores animated collision proxies")
|
||||||
|
|
||||||
|
|
||||||
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
|
func _all_proxy_layers_equal(rig: StickmanRig, expected_layer: int, expected_mask: int) -> bool:
|
||||||
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
|
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
|
||||||
if body.collision_layer != expected or body.collision_mask != expected:
|
if body.collision_layer != expected_layer or body.collision_mask != expected_mask:
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user