feat: Implement Phase 16 - Move To: Walk and Run

- Introduced a new action type `move_to` allowing children to direct a stickman to a destination using either Walk or Run styles.
- Updated the ActionEditor UI to include Walk/Run buttons for selecting movement styles.
- Ensured compatibility with existing `walk_to` actions by converting them to the new `move_to` format during loading.
- Defined clear success criteria for the implementation, including distinct movement animations and behavior for both Walk and Run.
- Established a comprehensive verification contract to ensure functionality across various terrains and scenarios.
This commit is contained in:
2026-10-05 15:04:09 -04:00
parent 36596b2c28
commit 3f66cc3195
19 changed files with 2088 additions and 194 deletions
+321
View File
@@ -0,0 +1,321 @@
{
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 0.0,
"y": -400.0
},
"zoom": 0.5
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -1536.0,
"y": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 288.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": 288.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": 288.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": 288.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": 288.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": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -1368.0,
"y": -156.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 2580.0,
"y": 228.0
},
"style": "run"
}
]
}
],
"event_rules": [],
"metadata": {
"created_unix": 1791088389,
"modified_unix": 1791167929.379,
"autosave": false,
"source_path": ""
}
}
+567
View File
@@ -0,0 +1,567 @@
{
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 1932.47973632813,
"y": -1292.90002441406
},
"zoom": 0.413223147392273
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -1536.0,
"y": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 256.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": 288.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": 288.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": 288.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": 288.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": 288.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": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -1368.0,
"y": -156.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 2604.0,
"y": -1092.0
},
"style": "run"
},
{
"type": "speak",
"text": "Wow! Look how high I am!",
"duration": 2.2,
"stop_current": false,
"keep_acting": false,
"arrangement": "together",
"bubble_layout": "stack",
"connect_bubbles": false,
"reveal": "letter",
"pop_in": true,
"duration_mode": "just_enough",
"bubbles": [
{
"text": "Wow! Look how high I am!",
"visual": {
"bubble_style": "talk",
"font_preset": "comic",
"size_preset": "medium",
"color": "#202020",
"effect": "none",
"reveal": "letter",
"pop_in": true,
"word_styles": [
{
"start": 14,
"end": 18,
"effect": "shake",
"color": "#e53935"
}
]
},
"placement": {
"mode": "manual",
"center": {
"x": 17.1448974609375,
"y": -731.795837402344
}
}
}
]
},
{
"type": "move_to",
"target": {
"x": 4083.85961914063,
"y": -1101.71997070313
},
"style": "run"
}
]
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": 252.0,
"y": 120.0
},
"rotation": 0.0,
"asset_id": "step",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": 504.0,
"y": -132.0
},
"rotation": 0.0,
"asset_id": "step",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": 744.0,
"y": -384.0
},
"rotation": 0.0,
"asset_id": "step",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_24",
"type": "terrain",
"position": {
"x": 996.0,
"y": -636.0
},
"rotation": 0.0,
"asset_id": "step",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_25",
"type": "terrain",
"position": {
"x": 1248.0,
"y": -888.0
},
"rotation": 0.0,
"asset_id": "step",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_26",
"type": "terrain",
"position": {
"x": 1476.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_27",
"type": "terrain",
"position": {
"x": 1668.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_28",
"type": "terrain",
"position": {
"x": 1860.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_29",
"type": "terrain",
"position": {
"x": 2052.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_30",
"type": "terrain",
"position": {
"x": 2244.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_31",
"type": "terrain",
"position": {
"x": 2436.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_32",
"type": "terrain",
"position": {
"x": 2628.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_33",
"type": "terrain",
"position": {
"x": 2820.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_34",
"type": "terrain",
"position": {
"x": 3012.0,
"y": -996.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1791088389,
"modified_unix": 1791172671.377,
"autosave": false,
"source_path": ""
}
}
@@ -69,6 +69,8 @@ This document tracks known technical debt, optimization opportunities, and minor
| 57 | **Speech composition must temporarily own stage input** — normal scene controls can otherwise select or alter characters and props while the user is editing speech. | Medium | Planned | Gate stage object interactions while the composer is open; keep speech bubble selection/drag, explicit speech placement, and camera-only navigation available. A non-bubble stage click clears the token target but must not activate the object underneath. Verify deferred token clicks and drag thresholds. See `docs/specs/speech_style_toolbar_refinements.md`. (2026-09-28) |
| 58 | **Speech-wide Show settings retain per-bubble render copies** — action-level reveal and motion settings are mirrored into every bubble visual for the current renderer and duration calculations. | Low | Planned | `SpeechPayload.normalize()` is the synchronization boundary; scene and rule encoders must retain the new action-level fields. Older mixed bubble settings intentionally migrate from the first bubble when edited, so later distinct motion/reveal choices are no longer expressible in the group model. If a future renderer reads action-level settings directly, the duplicate visual keys can be removed in a versioned payload migration. See `docs/specs/speech_whole_sequence_show.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) |
| 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) |
---
@@ -145,5 +147,6 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-10-01 | Comic Chain speech behavior revised after preview feedback (`docs/specs/speech_comic_chain_layout.md`): Comic chain is the third persistent **Show bubbles** choice and builds cumulatively, while Stack/Same spot remain placement actions. Entering the mode applies measured Stack positions. Later balloon connectors use blunt joins drawn behind predecessor frames; Think uses dots. Older `bubble_layout: comic_chain` payloads migrate to `arrangement: comic_chain`. |
| 2026-10-03 | Speech bubble order badges now use selection-independent positions (`docs/specs/speech_badge_stability.md`). Badge clustering responds to actual proximity; Show Speech and Speech Style panels avoid the stable badge bounds instead of moving badges when selection changes. |
| 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. |
_End of Log_
+2 -2
View File
@@ -6,13 +6,13 @@ This is the live list of future Stickman Studio feature ideas. Designer maintain
Priorities are recommendations for discussion, not a committed release schedule. Keep higher-priority items first and explain the reason for each ranking.
No feature ideas have been captured yet.
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.
## Entry format
For each feature, record:
- **Status:** Idea, Exploring, Planned, or Parked
- **Status:** Idea, Exploring, Planned, Verified, or Parked
- **Suggested priority:** High, Medium, or Low
- **Intent:** The user goal or experience the feature should support
- **Rationale:** Why it may be valuable and how it fits the project
+104
View File
@@ -0,0 +1,104 @@
# Phase 16 — Running Stickman
> **Status:** Accepted Designer handoff for the spec-pipeline.
> **Audience:** Architect and implementation team.
> **Purpose:** Product/design brief; the final technical specification is a separate architect deliverable.
## Implementation update — DIRECT mode
The DIRECT-mode quick action uses one **Move To** command. The user places the destination first; a compact follow-up panel then offers a **Movement** dropdown and **Add Move To** / **Cancel**. The dropdown currently offers Walk and Run and is populated from the movement-style choices so more styles can be added without expanding the action menu. The first draft defaults to Walk. A stickman remembers its last successfully committed style while its scene is open; canceling a draft does not change that preference.
Cancel, Escape, right-click, dismissing the popup, or leaving DIRECT mode discards the uncommitted destination and style. The approved readability pass uses a 23 pt Movement label, a 25 pt selector (including style emoji), 22 pt buttons, 54 px selector height, 52 px action-button height, and a 380×172 px panel.
## Goal and audience
Give children a clear way to direct a stickman to a destination at different paces, starting with Walk and Run. Make Run read as a believable human gait in the existing stickman style, rather than a shortened or faster Walk cycle.
The action should describe the destination independently from the way the character travels. This leaves a clean product concept for future movement styles such as Fly, without including them in this phase.
## Desired user outcome
A child can make a stickman walk to one place, then run to another, using the same Move To action and waypoint workflow. The selected style affects both travel pace and animation, so a faster movement has a matching gait. Children do not need to tune a numeric speed to get the intended result.
## Core flow and behavior
1. In the Sandbox Stage action editor, the child adds or edits **Move To** and places a destination using the existing stage-click target flow.
2. The action offers **Walk** and **Run** as per-action movement styles. **Walk** is selected by default.
3. Each style uses a predictable default pace paired with its animation. Numeric speed adjustment is out of scope for the first version.
4. The queue and rule displays identify the style, for example **Move To · Run**, so the child can see which movement was chosen.
5. The existing waypoint drawing, re-placement, and path preview continue to represent the destination independently of the style.
6. In DIRECT mode, the child chooses **Move To**, places the destination, then chooses a style from the follow-up **Movement** dropdown and commits with **Add Move To**. **Cancel** discards the draft.
7. The first DIRECT-mode draft for a stickman defaults to Walk; later drafts use that stickman's last successfully committed style for the current open scene. Canceling never updates the remembered style.
## Run animation target
The target is a natural human run expressed through the project's simple stickman figure. The run should have recognizable gait phases: a foot contact and weight-bearing phase, push-off, a brief airborne phase, leg recovery, and the next contact. Arm swing should counter the legs, with a modest forward lean and controlled body bounce that support the motion without distracting from it.
The motion should be visibly different from walking. Its cycle cadence and foot travel should agree with the Run pace so the feet do not skate over level ground or pass through the floor. Entering and leaving the loop should feel smooth, including when the destination is close enough for only part of a cycle. The animation should read correctly while facing either direction.
The project already has a tool script that bakes walk animations from authored IK-target keyframes. This is a suitable starting point for a generated run clip, with visual tuning expected. Here, “generated” means authored pose/keyframe data is baked into an animation by the project tool; the brief does not assume automatic motion capture or an AI-generated gait.
## Story and directing value
Walk supports calm travel, exploration, and approaching something. Run supports urgency, chasing, escaping, and energetic entrances. Keeping both styles on Move To lets children reuse destinations and path editing while changing the performance of the character.
## Compatibility and project fit
The current action is `walk_to`. Existing `.scene` files and rules should load transparently as **Move To · Walk**, preserving targets, queue order, trigger behavior, and waypoint editing. Saving after load may write the new canonical form. Children should not need to repair or re-author existing stories.
Relevant current behavior and references:
- `scripts/stage/action_registry.gd` registers action names, summaries, and rule/queue conversions.
- `scripts/editors/stage/action_editor.gd` edits actions and captures destination clicks.
- `scripts/stage/stage_scene_io.gd` validates and serializes saved stage actions.
- `scripts/editors/stage/sandbox_stage.gd` builds direct/rule actions and converts rule actions for playback.
- `scripts/stage/stage_director_visuals.gd` draws waypoint paths and action markers.
- `scripts/characters/stickman/stickman_action_runner.gd` runs queued actions; `scripts/characters/stickman/stickman_locomotion.gd` currently owns navigation, walking animation, and terrain following.
- `scripts/tools/create_animations.gd` bakes the current walk clips and stand-up reference animation.
- `docs/specs/phase_3a_spec.md`, `docs/specs/phase_3c_editor_spec.md`, `docs/specs/phase_8_rig_modularization.md`, and `docs/specs/phase_12_live_director.md` describe the existing movement, director, and action workflows.
## Success criteria
- A child can add or edit Move To, choose Walk or Run for that action, and set its destination through the familiar click-on-stage flow.
- In DIRECT mode, destination placement comes before movement-style selection; the child can commit or cancel the staged Move To draft without reopening an action menu.
- DIRECT mode defaults to Walk per stickman and remembers the last committed style only for the lifetime of the open scene.
- Walk is the default, and unedited legacy `walk_to` actions keep walking.
- Walk and Run have distinct, understandable movement paces; choosing Run makes the character travel faster and show a recognizable run cycle.
- The run includes visible support, push-off, flight, recovery, and counter-swinging arms; on level ground, foot contact looks stable and the feet do not visibly skate or penetrate the surface.
- Run starts and finishes cleanly at both short and long destinations, and faces correctly in both directions.
- Queue actions, rule actions, summaries, action editing, waypoint re-placement, and saved/loaded scenes agree on the chosen style and destination.
- Existing arrival, action completion, interruption, and queue behavior remains understandable and intact.
## Scope and exclusions
Included:
- Rename the child-facing concept from Walk To to Move To across relevant authoring, queue/rule summaries, and waypoint interactions.
- Add per-action Walk/Run choice with Walk as the default and style-specific fixed paces.
- Add a destination-first DIRECT-mode Move To draft with an extensible Movement dropdown, Add Move To/Cancel controls, and per-stickman last-committed-style memory for the open scene.
- Add a generated, tuned run cycle and connect it to Run movement.
- Preserve compatibility for existing `walk_to` scene and rule data.
- Validate the animation against existing facing and grounded terrain behavior.
Not included in this phase:
- Fly or additional movement styles.
- Numeric speed controls or user-authored speed presets.
- New pathfinding, obstacle avoidance, slope navigation, or physically simulated locomotion.
- Replacing the stickman art/rig with motion capture.
## Dependencies and risks
- The style must survive all current conversions between queue actions, rule actions, runtime execution, and stage-file serialization.
- Old `walk_to` data must be recognized before new action validation rejects unknown types; migration must preserve waypoint identity and action/event semantics.
- The run cycle must be tuned together with its travel pace and current foot/terrain handling. A convincing in-place loop alone may still look slippery when the root travels.
- Arrival snapping and interruption/cancellation can stop a cycle between gait phases; the architect and developer should decide how to settle into the standing pose without visible popping.
- Current waypoints and event rules refer to movement actions. Renaming the action needs a consistent user-facing summary and migration story throughout both systems.
## Open questions for the spec-pipeline
- How should `ActionEditor` present Walk/Run: two large choice buttons, a segmented control, or another clear kid-facing selector? (The separate DIRECT-mode quick action uses the approved destination-first dropdown.)
- How should `walk_to` be canonicalized so old queue actions, rule actions, triggers that refer to action completion, and saved files retain their intended behavior?
- What run-cycle timing and foot-contact strategy best match the existing IK rig, terrain following, and fixed style pace?
- What transition behavior should apply when Run starts, reaches a short destination, or is interrupted by another action?
- Which level surfaces and terrain cases are required for first-pass visual verification?
+195
View File
@@ -0,0 +1,195 @@
# Phase 16 — Move To: Walk and Run
> **Status:** Architecture specification; the DIRECT-mode target-first quick action and its readability pass are implemented. Remaining Phase 16 behavior and visual checks are listed in section 6.
> **Inputs:** `docs/plans/phase_16_running_stickman.md`, accepted ActionEditor UI proposal (Walk/Run buttons), approved DIRECT-mode dropdown flow, and the Phase 1a architecture decisions.
> **Target:** Godot 4.7.1 Sandbox Stage and the existing modular stickman rig.
## 1. Goal and decisions
A child chooses Walk or Run on each **Move To** action, places one destination, and sees the style in queue and rule summaries. Run has a faster fixed pace and its own grounded, pushing, airborne, and recovering poses. The destination, waypoint identity, navigation behavior, and arrival trigger remain independent of style.
Approved decisions:
1. The canonical action type is `move_to`, with persisted `style: "walk" | "run"`. New actions use it. Loading old `walk_to` actions and action-completion filters converts them to `move_to`/Walk before validation.
2. Normal arrival has a brief pose settle. The physical arrival event fires when the root reaches the target; action completion fires when the settle ends. Ragdoll and other interruptions can preempt it.
3. Visual verification covers **every existing terrain shape** (Ground, Ramp, Step), their seams and edges, both facing directions, and existing ledge/fall/landing behavior. This does not add pathfinding or slope navigation.
The Walk/Run choice is a string in saved dictionaries because `.scene` is JSON. A new `Resource` hierarchy or a second navigation component would add conversions and scene dependencies to a two-choice field. The existing `ActionRegistry`, `StageSceneIO`, `StickmanActionRunner`, and `StickmanLocomotion` remain the ownership boundaries.
## 2. Data contracts and migration
The authoritative in-memory queue action is:
```gdscript
{"type": "move_to", "target": Vector2(640.0, 385.0), "style": "run"}
```
The equivalent rule consequence separates the acting stickman ID from the destination:
```gdscript
{
"type": "move_to",
"target": actor_instance_id, # int, -1 while choosing an actor
"params": {"target": Vector2(640.0, 385.0), "style": "run"},
}
```
The saved queue entry is `{"type":"move_to","target":{"x":640,"y":385},"style":"run"}`. The saved rule entry has `type: "move_to"`, its existing `target_id` actor reference, and `params.target` as an `{x,y}` object plus `params.style`. `target` remains a feet/ground destination, as in the current walking action. Neither form stores a numeric pace. Existing `.scene` `FILE_VERSION` can remain `1.1` because these fields are additive and old types are handled explicitly at decode; no other document shape changes.
### 2.1 `ActionRegistry` (`class_name ActionRegistry extends RefCounted`)
Replace the menu template for `walk_to` with one `move_to` template labeled **Move To**, with `target` position and `style` choice (default Walk). Keep `walk_to` out of `types()` so children never add the legacy type. Define statically typed helpers:
```gdscript
const MOVE_TO: String = "move_to"
const WALK: String = "walk"
const RUN: String = "run"
static func canonical_type(type: String) -> String # walk_to -> move_to; otherwise unchanged
static func checked_style(value: Variant) -> String # null/missing -> walk; invalid -> ""
static func is_move_action(action: Dictionary) -> bool
```
`checked_style` accepts exactly the two lowercase strings; missing or null means Walk for legacy data. An explicit unknown or wrong-type style is invalid at the file boundary and must not silently become Walk. `is_move_action()` accepts `move_to` and legacy `walk_to` at raw-input boundaries. `to_rule_action()` and `from_rule_action()` always produce canonical `move_to` with both `target` and `style`. `summarize()` produces `Move To · Walk` or `Move To · Run` for flat and nested rule shapes without exposing coordinates. Completion-filter labels use the same child-facing **Move To** wording.
### 2.2 `StageSceneIO` (`class_name StageSceneIO extends RefCounted`)
`_decode_flat_action()` canonicalizes `walk_to` **before** `ActionRegistry.has_type()` rejects unknown action types, validates the target vector, supplies Walk when style is absent, and rejects explicit invalid styles. `_encode_flat_action()` writes only canonical `move_to`; it also accepts an in-memory legacy `walk_to` input and normalizes it. Rule encode/decode must continue using the registry's flat converters so queue and rule payloads cannot diverge. No migration changes action order, rule ID, actor reference, or waypoint coordinates.
The `action_finished` trigger stores its filter in `trigger.params.action_type`. `_decode_trigger()` maps a legacy filter value of `walk_to` to `move_to`; `_encode_trigger()` canonicalizes it on save. `SandboxStage._rule_matches()` also compares canonicalized filter/action types so an in-memory rule supplied by a script retains its behavior. `arrived_at_waypoint` triggers keep matching `waypoint_pos` and fire for either style. The empty action filter continues to mean “any action.”
### 2.3 Runtime and programmatic compatibility
`StickmanActionRunner` should normalize any legacy action it receives from a programmatic queue or reactive rule before execution and emission, so `action_started`/`action_finished` report canonical `move_to` consistently. `StickmanRig.walk_to(target, speed)` stays as the public direct Walk API and may retain its positive speed override for existing scripts. The child-authored `move_to` action never contains `speed`: Walk uses the existing default pace and Run uses its paired fixed pace. Legacy `walk_to` with an explicit positive `speed` supplied programmatically can retain that override for compatibility during execution; `.scene` serialization remains style-only. Do not let that compatibility case create a speed control in the editor.
## 3. Authoring UI and stage flows
### 3.1 `ActionEditor` (`class_name ActionEditor extends PopupPanel`)
Keep the existing `committed(action: Dictionary)`, `cancelled()`, and `target_requested()` signals. Its committed Move To dictionary is fully typed by convention: `type: String`, `target: Vector2`, `style: String`. Add a private `_move_style: String = ActionRegistry.WALK`; `open_new()` resets it to Walk, and `open_edit(action)` reads canonical or legacy movement data, selecting Run when appropriate. `set_walk_target(pos: Vector2)` can remain as a compatibility method name, but it changes only the destination and must preserve `_move_style`. Cancel and Escape discard the draft without mutating a queue or rule action.
Layout inside `_params_box` for Move To:
```text
ActionEditor PopupPanel
└─ MarginContainer / VBoxContainer
├─ Type: [Move To ▾]
├─ VBoxContainer (Move To parameters)
│ ├─ Label: How should they move?
│ ├─ HBoxContainer: [🚶 Walk] [🏃 Run]
│ └─ HBoxContainer: Place to go: / Place chosen [🚩 Choose a Place / Change Place]
└─ HBoxContainer: [Cancel] [Done]
```
The Walk and Run buttons are equal-width choices, each at least 44×44 logical pixels. If the available popup width cannot contain both at that size, stack them vertically without shrinking the hit areas. Use `SandboxTheme`'s DIRECT amber accent, a pressed state, and a visible border for the selected option; use the theme surface for the other. Hover and keyboard focus remain visible, and text/icon/pressed state convey selection without color alone. Footer buttons are at least 44 px high. The existing theme font and emoji font pipeline applies to new controls. Keep the popup's current minimum dialog size unless the controls actually require a larger theme-driven size.
The destination row uses **Place to go:** and **🚩 Choose a Place** when unset, then **Place chosen** and **🚩 Change Place** once set. It never shows raw coordinates. Changing Walk/Run retains the destination. **Done** with no destination starts placement. Explicit placement hides the popup, retains the draft style and old target, and restores the popup after a valid stage click. The stage cursor pill says `🚩 Click where they should walk` or `🚩 Click where they should run`; existing line/ghost, green valid/red invalid target feedback, placement cursor, and prominent cancel action remain. Canceling capture reopens the popup with the old target and style. The final **Done** commits once.
### 3.2 `SandboxStage` (`class_name SandboxStage extends Node2D`)
All child-facing action menus say **Move To**. In DIRECT mode, the user chooses the single **Move To** command and places the destination first. That click stages the target and opens a compact follow-up panel with a **Movement** dropdown, **Add Move To**, and **Cancel**; no queue action is added until Add is selected. The dropdown is populated from the registered movement-style choices (currently Walk and Run), defaults to Walk for a stickman's first draft, and remembers the last successfully committed style per stickman while the scene is open. Cancel, Escape, right-click, popup dismissal, and leaving DIRECT mode discard the draft; canceled choices do not update the remembered style. The readability-approved panel is 380×172 px, with a 23 pt label, 25 pt selector/style emoji, 22 pt buttons, a 210×54 px selector, and 52 px-high buttons.
Queue Add/Insert opens `ActionEditor` and permits either style. Queue Edit and waypoint **Edit this Move** must open the editor with the existing target and style; the current `_begin_walk_edit()` stage-only shortcut bypasses pace selection and should be replaced or reserved solely for a clearly separate Change Place gesture. Resolve the edited action by existing `action_id` at commit, preserving action identity and consumed-action restrictions. Waypoint **Erase this Move** removes that same action. Marker hit testing, path drawing, and target re-placement work for both styles.
The rule-builder quick path currently creates a `walk_to` consequence and captures a position without opening `ActionEditor`. Route Move To through the shared editor after actor selection (or an equivalent shared draft using that same control), then put the returned target/style into `params` and resume the existing Add another/finish rule flow. Existing rule Edit/Add paths through `ActionRegistry` and `ActionEditor` must preserve the style in both directions. `SandboxStage._action_for_rig()` should delegate Move To conversion to `ActionRegistry.from_rule_action()` so reactive playback does not drop style. Normal direct movement remains Walk.
`StageDirectorVisuals` recognizes canonical `move_to` for every waypoint/path/hit-test branch and uses `ActionRegistry.summarize()` or equivalent for `Move To · Walk/Run` queue and rule descriptions. `WaypointContext` uses **Edit this Move** and **Erase this Move**. The marker itself represents the destination only and has no Walk/Run icon. Rule completion filters present Move To as one action type; both styles match it. Apply the same child-facing words to status hints and the rule builder. DIRECT-mode style choices are remembered only for the current open scene and are not serialized.
```mermaid
flowchart LR
UI[ActionEditor Walk/Run + destination] -->|committed flat action| ST[SandboxStage]
ST -->|queue action| AR[StickmanActionRunner]
ST -->|rule conversion| RG[ActionRegistry]
RG -->|rule consequence| IO[StageSceneIO save/load]
IO -->|canonical move_to| RG
RG -->|flat reactive action| AR
AR -->|style + target| LC[StickmanLocomotion]
LC -->|arrival| ST
AR -->|action_finished| ST
ST -->|queue and rule display| DV[StageDirectorVisuals]
```
## 4. Locomotion, gait, and terrain
### 4.1 Ownership and API
`StickmanActionRunner` keeps the existing action queue, `ActionPhase.WALKING`, status, interruption, and `action_started`/`action_finished` signals. It passes the normalized style to `StickmanLocomotion`. `StickmanLocomotion extends Node` keeps the single `NavigationAgent2D`, target/arrival logic, terrain following, and foot contacts. Add a typed movement entry such as:
```gdscript
func move_to(target: Vector2, style: String = ActionRegistry.WALK, speed_override: float = -1.0) -> bool
```
The existing `walk_to(target: Vector2, speed: float = -1.0) -> bool` delegates to `move_to(..., WALK, speed)` for `StickmanRig` and older callers. Keep `consume_walk_completed()` and `cancel_walk()` compatibility names if changing them would cause unnecessary API churn; their behavior covers both styles. Use an active style field and one style table/typed constants for clip name, default pace, cycle duration, and contact windows. This keeps travel speed, animation cadence, and contact logic paired at one boundary. `NavigationAgent2D.max_speed` and root movement use the same selected pace. Start with Walk at the existing 300 px/s and a Run candidate around 500 px/s, then tune Run pace and cycle together against visible foot travel; the final constants belong in locomotion, not in the saved action or UI theme.
The current `_stance_weight()` assumes a 0.8-second Walk cosine. Retain that behavior for Walk if it remains visually correct. Run uses an explicit repeating contact schedule aligned with its authored clip. As an initial 0.64-second two-step cycle, put right support at approximately 0.00–0.20 s, left support at 0.32–0.52 s, and brief flight at 0.20–0.32 and 0.52–0.64 s. Smooth the edge of each contact window; return zero support for both feet in flight. Tuning may change those numbers, but the clip and contact windows must change together. Reset foot locks when a foot leaves support; the terrain solver must not pin a recovering or airborne foot to the ground. Keep the existing collision guard against foot penetration when a surface is present.
### 4.2 Baked animation and facing
`scripts/tools/create_animations.gd` remains the authored IK keyframe baker. Add a dedicated Run pose sequence and bake looped `run_right` (and `run_left` only if the existing asset convention requires it) into the rig's `AnimationLibrary` in `scenes/characters/stickman/stickman_rig.tscn`. The authored cycle has contact/weight-bearing, push-off, a short flight, leg recovery, opposite contact, counter-swinging arms, modest forward lean, and controlled torso/head bounce. The final keyframe matches the first for seamless looping. It must be an actual distinct gait, not Walk played faster. The generated clip in the committed rig scene is the runtime asset; the tool source and baked scene must agree.
Runtime walking currently uses `walk_right` for both directions after changing `StickmanRig.FacingProfile`; preserve that proven facing policy for Run if it mirrors the authored pose correctly. Avoid a second independent flip of run marker X values, which would invert the gait twice. Check both directions in the Sandbox Stage, including held props and hand-target ownership if a prop is present.
### 4.3 Start, arrival, cancellation, and terrain state
Starting Move To selects the style's clip, sets facing from target X, enables limb modifiers, clears stale foot locks/settle state, and begins the existing nav/direct fallback. For a target already inside `ARRIVE_DISTANCE`, skip gait playback, snap to the target, emit one arrival, and finish without a visible partial stride. For other short targets, allow a partial cycle and settle from its actual final IK pose.
On physical arrival, keep the existing final-X snap and emit `arrived(target)` exactly once. Stop root travel and smoothly interpolate the six IK markers and torso rotation toward the standing pose for roughly 0.12–0.18 seconds, using the current pose as the start; then set the existing completion flag that `StickmanActionRunner` consumes. The settle is part of the action so a following queue item does not fight its marker writes. During a pause, its timer stays paused with the runner. Repeated completion checks must not emit arrival again. If terrain following is in a hard landing, defer movement and settle according to the existing landing barrier rather than writing competing foot targets.
Interruption cancels gait/settle ownership and releases contact locks. A ragdoll takeover preserves the current solved pose for the established instant physical handoff; it must not first force a standing tween. An interruption that leaves the rig animated can blend to idle only while no newer action owns the markers. Interrupted actions keep the existing interrupted status and emit no `action_finished`. The style and target in authored queue data remain unchanged through pause/resume, reorder, reactive insertion, or target edit.
`StickmanLocomotion` continues its ground probing and existing airborne/landing state machine. A gait's brief authored flight on supported terrain is separate from an actual ledge fall: the Run contact schedule releases foot support without entering `TerrainMotion.AIRBORNE`; the existing ledge probe controls real falls and pauses/resumes the active Run clip as it does Walk. Ground, Ramp, and Step must be checked across up/down travel, transitions at seams, and edge departures. Any needed correction should stay in the existing terrain-following component; adding a new path solver is outside Phase 16.
```mermaid
stateDiagram-v2
[*] --> Moving: Move To begins, target beyond arrival radius
[*] --> Arrived: target already reached
Moving --> Moving: nav/direct travel + gait cycle
Moving --> Arrived: final root snap
Arrived --> Settling: emit arrived once
Settling --> Completed: standing pose reached; completion flag
Moving --> Interrupted: cancel / ragdoll takeover
Settling --> Interrupted: cancel / ragdoll takeover
Completed --> [*]: action_finished once
Interrupted --> [*]: interrupted status, no finish signal
```
## 5. Target files and boundaries
| File or class | Required change |
| --- | --- |
| `scripts/stage/action_registry.gd` — `ActionRegistry` | Canonical type/style helpers; one Move To template; conversions and summaries. |
| `scripts/stage/stage_scene_io.gd` — `StageSceneIO` | Queue/rule canonical encode/decode; legacy `walk_to` and completion-filter migration. |
| `scripts/editors/stage/action_editor.gd` — `ActionEditor` | Two large choice buttons, destination row, draft retention, canonical commit. |
| `scripts/editors/stage/sandbox_stage.gd` — `SandboxStage` | Direct Walk action, capture hints, queue/waypoint/rule routes, runtime rule conversion, completion-filter matching. |
| `scripts/stage/stage_director_visuals.gd` — `StageDirectorVisuals` | Move waypoint detection and style summaries; destination-only markers. |
| `scripts/stage/waypoint_context.gd` — `WaypointContext` | Move wording and edit route. |
| `scripts/editors/stage/rule_editor.gd` and queue/rule panels | Filter label and any summary/selection paths that assume `walk_to`. |
| `scripts/characters/stickman/stickman_action_runner.gd` — `StickmanActionRunner` | Normalize legacy runtime inputs, pass style to locomotion, preserve completion/interruption semantics. |
| `scripts/characters/stickman/stickman_locomotion.gd` — `StickmanLocomotion` | Shared style-aware travel, Run contact timing, arrival settle, terrain ownership. |
| `scripts/characters/stickman/stickman_rig.gd` — `StickmanRig` | Retain public Walk facade; add a Move facade only if the runner needs it. |
| `scripts/tools/create_animations.gd` and `scenes/characters/stickman/stickman_rig.tscn` | Author and bake the Run clip into the committed rig asset. |
| `sandbox_theme.json` / `scripts/stage/sandbox_theme.gd` | Use existing DIRECT accent and dialog tokens; add only the minimum style overrides needed for large selected/focused buttons. |
No new autoload, global signal bus, node layer, persisted numeric speed, or separate Run action type is required.
## 6. Implementation and verification contract
Implementation order: (1) registry and I/O migration; (2) editor, stage routes, and visuals; (3) authored Run clip and locomotion/contact timing; (4) arrival/interruption settle; (5) focused verification and visual tuning. The developer should run headless syntax verification as required by the spec-pipeline, then fix parse errors immediately. Add focused regression coverage where it checks distinct behaviors rather than repeating implementation internals.
Required behavior checks:
1. Load a legacy scene with queued `walk_to`, rule consequence `walk_to`, and an `action_finished` trigger filtered to `walk_to`. Targets, order, actor IDs, waypoint rules, and completion behavior survive; saving writes canonical `move_to`/Walk throughout.
2. Queue and rule Walk/Run actions round-trip through flat/nested conversions and `.scene` save/load. Invalid explicit styles are rejected at the file boundary. New/direct actions default to Walk.
- In DIRECT mode, verify destination-first staging, dropdown choice, Add and every draft-cancel path (Cancel, Escape, right-click, popup dismissal, and mode exit). Confirm the first choice defaults to Walk, a successfully committed choice is remembered per stickman in the open scene, and a canceled choice is not remembered.
3. Add/edit from queue and waypoint menu, and add/edit from rule builder. The style survives target capture, target change, capture cancel, editor cancel, and final commit. Editing a consumed action remains blocked. Summaries say `Move To · Walk/Run` and waypoint markers remain destination-only.
4. Run visibly exceeds Walk's pace while its foot cycle tracks root travel on a long level path. Verify short (< one cycle) and zero-distance targets, one arrival and one completion, and no skating or penetration at support.
5. Check both facing directions on Ground, Ramp, and Step; travel over joins and on upward/downward surfaces; ledge fall and landing; reactive interruption, ragdoll takeover, queue pause/resume, and a following queued action. Flight frames show both feet released from contact while a true ledge fall still uses terrain airborne behavior.
The Sandbox Stage CLI scene playback in `AGENTS.md` is the visual verification route. Record the final tuned Run speed/cycle/contact windows and any terrain limitation observed during editor review. Phase 3 ends with a user hand-off for Godot Editor verification; documentation updates follow only after the user confirms the feature works.
## 7. Trade-offs and scope
- A canonical `move_to` type requires coordinated migration in serialization, trigger filters, editor branches, and waypoint visuals. It gives future movement styles one destination-oriented action and avoids carrying a misleading `walk_to` identifier indefinitely.
- A fixed style table keeps pace and gait together and avoids exposing a numeric control to children. Existing programmatic `walk_to(speed)` remains a compatibility route.
- Explicit Run contact windows duplicate a small amount of gait timing data between the animation baker and locomotion. This is preferable to pretending Run follows the Walk cosine stance; the values must be tuned and reviewed together.
- A short settle makes partial cycles readable but delays `action_finished` slightly after physical `arrived`. The events retain distinct meanings and each fires once.
- Full Ground/Ramp/Step visual coverage may require tuning terrain foot placement as well as the clip. It does not authorize new slope pathfinding, dynamic avoidance, or physically simulated locomotion.
@@ -73,6 +73,94 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao")
[sub_resource type="Animation" id="Animation_2leu7"]
length = 0.001
[sub_resource type="Animation" id="Animation_RunRight"]
length = 0.75
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IK_Targets/Torso:position")
tracks/0/interp = 2
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.1875, 0.375, 0.5625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, 15), Vector2(0, -30), Vector2(0, 15), Vector2(0, -30), Vector2(0, 15)]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("IK_Targets/Head:position")
tracks/1/interp = 2
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.1875, 0.375, 0.5625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(100, -605), Vector2(100, -642), Vector2(100, -605), Vector2(100, -642), Vector2(100, -605)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("AnimationTargets/Right_Leg_Base:position")
tracks/2/interp = 2
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.1875, 0.375, 0.5625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390)]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("AnimationTargets/Left_Leg_Base:position")
tracks/3/interp = 2
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0, 0.1875, 0.375, 0.5625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("IK_Targets/Right_Hand:position")
tracks/4/interp = 2
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.09375, 0.1875, 0.28125, 0.375, 0.46875, 0.5625, 0.65625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-250, -205), Vector2(-167, -40), Vector2(35, 0), Vector2(237, -40), Vector2(320, -225), Vector2(237, -35), Vector2(35, 15), Vector2(-167, -35), Vector2(-250, -205)]
}
tracks/5/type = "value"
tracks/5/imported = false
tracks/5/enabled = true
tracks/5/path = NodePath("IK_Targets/Left_Hand:position")
tracks/5/interp = 2
tracks/5/loop_wrap = true
tracks/5/keys = {
"times": PackedFloat32Array(0, 0.09375, 0.1875, 0.28125, 0.375, 0.46875, 0.5625, 0.65625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(320, -225), Vector2(237, -30), Vector2(55, 15), Vector2(-167, -35), Vector2(-250, -205), Vector2(-167, -40), Vector2(55, 0), Vector2(237, -40), Vector2(320, -225)]
}
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("IK_Targets/Torso:rotation")
tracks/6/interp = 2
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0, 0.1875, 0.375, 0.5625, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [0.3, 0.36, 0.28, 0.2, 0.3]
}
[sub_resource type="Animation" id="Animation_ylko5"]
length = 0.8
tracks/0/type = "value"
@@ -315,6 +403,7 @@ tracks/5/keys = {
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
_data = {
&"RESET": SubResource("Animation_2leu7"),
&"run_right": SubResource("Animation_RunRight"),
&"stand_up": SubResource("Animation_ylko5"),
&"walk_left": SubResource("Animation_t75yq"),
&"walk_right": SubResource("Animation_m3sq4")
@@ -385,9 +385,20 @@ func _advance_to_next_action() -> void:
_current_index = int(_current_run_item.get("queue_index", -1))
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
var action: Dictionary = _current_run_item.get("action", {})
var action: Dictionary = _current_run_item.get("action", {}).duplicate(true)
var legacy_speed_override: float = -1.0
if ACTION_REGISTRY.is_move_action(action):
var raw_type: String = String(action.get("type", ""))
var raw_speed: Variant = action.get("speed", -1.0)
if raw_type == "walk_to" and (raw_speed is int or raw_speed is float) and is_finite(float(raw_speed)) and float(raw_speed) > 0.0:
legacy_speed_override = float(raw_speed)
action["type"] = ACTION_REGISTRY.MOVE_TO
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK
action.erase("speed")
_current_run_item["action"] = action
var action_type := String(action.get("type", ""))
var requires_animated := action_type == "walk_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"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
@@ -403,14 +414,17 @@ func _advance_to_next_action() -> void:
_current_index = -1
return
action_started.emit(action, _current_index)
_begin_action(action)
_begin_action(action, legacy_speed_override)
func _begin_action(action: Dictionary) -> void:
func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void:
match String(action.get("type", "")):
"walk_to":
"move_to":
_action_phase = ActionPhase.WALKING
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty():
style = ACTION_REGISTRY.WALK
if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override):
_finish_action()
"speak":
if bool(action.get("keep_acting", false)):
@@ -3,6 +3,8 @@ extends Node
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
## StickmanRig schedules this component and exposes its public facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
const TERRAIN_PROBE_UP: float = 96.0
@@ -47,6 +49,19 @@ const NAV_PATH_DESIRED_DISTANCE: float = 8.0
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
const LATCH_PROBE_MAX_FRAMES: int = 6
const WALK_CYCLE_DURATION: float = 0.8
const RUN_CYCLE_DURATION: float = 0.75
const RUN_RIGHT_CONTACT_START: float = 0.0
const RUN_LEFT_CONTACT_START: float = 0.375
const RUN_CONTACT_DURATION: float = 0.1875
const RUN_CONTACT_EDGE_FADE: float = 0.03125
const ARRIVAL_SETTLE_DURATION: float = 0.15
const LEFT_STANDING_ANIMATION: StringName = &"walk_left"
const RIGHT_STANDING_ANIMATION: StringName = &"walk_right"
const MOVE_STYLE_DATA: Dictionary = {
"walk": {"clip": "walk_right", "pace": 300.0, "cycle": WALK_CYCLE_DURATION, "run": false},
"run": {"clip": "run_right", "pace": 500.0, "cycle": RUN_CYCLE_DURATION, "run": true},
}
const MOVE_MARKER_NAMES: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
const HEAD_COLLISION_RADIUS: float = 90.0
const TORSO_COLLISION_RADIUS: float = 20.0
@@ -106,12 +121,22 @@ var _terrain_paused_walk_animation: bool = false
var _nav_agent: NavigationAgent2D = null
var _nav_anchor: Node2D = null
var _walking: bool = false
var _settling: bool = false
var _settle_waiting_for_ground: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _active_move_style: String = "walk"
var _active_animation_name: StringName = &"walk_right"
var _active_cycle_duration: float = WALK_CYCLE_DURATION
var _walk_mode: String = "nav"
var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_settle_elapsed: float = 0.0
var _walk_settle_start_pose: Dictionary = {}
var _walk_settle_end_pose: Dictionary = {}
var _walk_debug_settle_frames: int = 0
var _walk_debug_progress_elapsed: float = 0.0
var _arrival_emitted: bool = false
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _last_grounded_walk_velocity_x: float = 0.0
@@ -309,27 +334,65 @@ func tick_movement(delta: float, _rig_state: int) -> void:
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
_last_grounded_walk_velocity_x = 0.0
_update_walking(delta)
_settle_walk_markers(delta)
_update_terrain_following(delta)
_settle_walk_markers()
_resume_settle_after_terrain()
if _walk_debug_enabled() and _walking:
_walk_debug_progress_elapsed += delta
if _walk_debug_progress_elapsed >= 1.0:
_walk_debug_progress_elapsed = 0.0
_debug_walk_pose("RUN_PROGRESS terrain=%s" % TerrainMotion.keys()[int(terrain_motion)])
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
if _rig != null:
apply_impact_reactions(delta)
sync_collision_bodies()
if _walk_debug_settle_frames > 0:
_walk_debug_settle_frames -= 1
if _walk_debug_settle_frames == 0:
_debug_walk_pose("SETTLED")
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
return move_to(target, "walk", speed)
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
if _walk_debug_enabled():
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
push_warning("StickmanRig: Move To ignored; not ANIMATED.")
return false
if _rig._is_action_queue_blocked():
push_warning("StickmanRig: walk_to ignored during landing impact.")
push_warning("StickmanRig: Move To ignored during landing impact.")
return false
if _nav_agent == null or _nav_anchor == null:
push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.")
push_warning("StickmanLocomotion: Move To ignored; navigation is unavailable.")
return false
var checked_style: String = style if MOVE_STYLE_DATA.has(style) else ACTION_REGISTRY.WALK
var style_data: Dictionary = MOVE_STYLE_DATA[checked_style]
_active_move_style = checked_style
_active_animation_name = StringName(style_data.get("clip", "walk_right"))
_active_cycle_duration = float(style_data.get("cycle", WALK_CYCLE_DURATION))
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else _rig.walk_speed
var default_pace: float = _rig.walk_speed if checked_style == "walk" else float(style_data.get("pace", 500.0))
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
_nav_agent.max_speed = _walk_speed_current
_walk_done = false
_arrival_emitted = false
_settling = false
_settle_waiting_for_ground = false
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_reset_foot_contacts()
var target_root: Vector2 = target + FOOT_OFFSET
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
_rig.global_position = target_root
_walking = false
_walk_done = true
_arrival_emitted = true
arrived.emit(_walk_target_feet)
return true
_nav_agent.target_position = target
var dx: float = target.x - _rig.global_position.x
if dx < -0.5:
@@ -337,14 +400,12 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool:
elif dx > 0.5:
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
_rig._set_limb_mods_enabled(true)
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"):
_rig._anim_player.play("walk_right")
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
_rig._anim_player.play(_active_animation_name)
_walking = true
_walk_done = false
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
return true
func cancel_walk(restore_idle_pose: bool = true) -> void:
@@ -352,19 +413,24 @@ func cancel_walk(restore_idle_pose: bool = true) -> void:
return
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(true)
_rig._anim_player.stop(false)
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_settling = false
_settle_waiting_for_ground = false
_walk_done = false
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_reset_foot_contacts()
if restore_idle_pose:
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
func is_walking() -> bool:
return _walking
return _walking or _settling
func ensure_grounded_walk_animation() -> void:
@@ -372,7 +438,7 @@ func ensure_grounded_walk_animation() -> void:
return
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
return
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation("walk_right"):
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation(_active_animation_name):
return
if _terrain_paused_walk_animation:
if _rig._anim_player.is_playing():
@@ -380,7 +446,7 @@ func ensure_grounded_walk_animation() -> void:
else:
_resume_walk_animation()
elif not _rig._anim_player.is_playing():
_rig._anim_player.play("walk_right")
_rig._anim_player.play(_active_animation_name)
func get_foot_offset() -> Vector2:
return FOOT_OFFSET
@@ -439,21 +505,213 @@ func _update_walking(delta: float) -> void:
_rig.global_position.x = final_root.x
_finish_walk()
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_rig._restore_standing_markers()
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
if _rig.state != StickmanRig.RigState.ANIMATED:
cancel_walk(false)
return
if terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED:
_settle_waiting_for_ground = true
return
_walk_settle_elapsed = minf(_walk_settle_elapsed + delta, ARRIVAL_SETTLE_DURATION)
var weight: float = smoothstep(0.0, ARRIVAL_SETTLE_DURATION, _walk_settle_elapsed)
_apply_walk_settle_pose(weight)
if _walk_settle_elapsed >= ARRIVAL_SETTLE_DURATION:
_settling = false
_settle_waiting_for_ground = false
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
_walk_debug_settle_frames = 2 if _walk_debug_enabled() else 0
_walk_done = true
func _finish_walk() -> void:
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if not _walking or _arrival_emitted:
return
# Capture the evaluated cycle before stopping it so the settle can blend from
# the actual run pose rather than from the animation reset values.
_walk_settle_start_pose = _capture_walk_marker_pose()
_debug_walk_pose("ARRIVAL_RUN_POSE")
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
# Keep the evaluated run pose for the first frame of the settle blend.
_rig._anim_player.stop(true)
_walking = false
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
_walk_settle_frames = 0
_walk_done = true
_settling = true
_settle_waiting_for_ground = false
_walk_settle_elapsed = 0.0
_arrival_emitted = true
_walk_done = false
_restore_walk_standing_pose()
_walk_settle_end_pose = _capture_walk_marker_pose()
_apply_walk_settle_pose(0.0)
if _walk_debug_enabled():
print("[WalkDebug] SETTLE_TARGETS start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
_debug_walk_pose("SETTLE_BEGIN")
arrived.emit(_walk_target_feet)
func _capture_walk_marker_pose() -> Dictionary:
var pose: Dictionary = {}
for marker_name: String in MOVE_MARKER_NAMES:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker == null:
continue
pose[marker_name] = {"position": marker.position, "rotation": marker.rotation}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
if base != null:
pose["base:" + leg_name] = {"position": base.position, "rotation": base.rotation}
return pose
func _apply_walk_settle_pose(weight: float) -> void:
for pose_key: String in _walk_settle_end_pose:
var marker: Marker2D
if pose_key.begins_with("base:"):
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
else:
marker = _rig._get_ik_marker(pose_key)
var start: Dictionary = _walk_settle_start_pose.get(pose_key, {})
var finish: Dictionary = _walk_settle_end_pose.get(pose_key, {})
if marker == null or start.is_empty() or finish.is_empty():
continue
marker.position = (start["position"] as Vector2).lerp(finish["position"] as Vector2, weight)
marker.rotation = lerpf(float(start["rotation"]), float(finish["rotation"]), weight)
func _restore_walk_standing_pose() -> void:
_rig._restore_standing_markers()
var standing_pose: Dictionary = {}
var standing_animation: StringName = &""
if _rig.facing_profile == StickmanRig.FacingProfile.LEFT:
standing_animation = LEFT_STANDING_ANIMATION
elif _rig.facing_profile == StickmanRig.FacingProfile.RIGHT:
standing_animation = RIGHT_STANDING_ANIMATION
if not standing_animation.is_empty():
standing_pose = _sample_animation_first_frame(standing_animation)
_apply_walk_marker_pose(standing_pose)
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
var target: Marker2D = _rig._get_ik_marker(leg_name)
if base == null or target == null:
continue
var base_key := "base:" + leg_name
if standing_pose.has(base_key):
# The baked walk clip keys the animated base target. Seed the IK target
# from the same frame so it also works while terrain following is off.
target.position = base.position
else:
base.position = target.position
func _sample_animation_first_frame(animation_name: StringName) -> Dictionary:
var sampled: Dictionary = {}
if _rig == null or _rig._anim_player == null or not _rig._anim_player.has_animation(animation_name):
return sampled
var animation: Animation = _rig._anim_player.get_animation(animation_name)
for track_idx: int in animation.get_track_count():
var track_path := String(animation.track_get_path(track_idx))
var separator_idx := track_path.rfind(":")
if separator_idx < 0:
continue
var property_name := track_path.substr(separator_idx + 1)
if property_name != "position" and property_name != "rotation":
continue
var node_path := track_path.substr(0, separator_idx)
var pose_key := ""
if node_path.begins_with("IK_Targets/"):
pose_key = node_path.get_file()
elif node_path.begins_with("AnimationTargets/") and node_path.get_file().ends_with("_Base"):
pose_key = "base:" + node_path.get_file().trim_suffix("_Base")
if pose_key.is_empty():
continue
var key_count := animation.track_get_key_count(track_idx)
if key_count == 0:
continue
var first_key_idx := 0
for key_idx: int in range(1, key_count):
if animation.track_get_key_time(track_idx, key_idx) < animation.track_get_key_time(track_idx, first_key_idx):
first_key_idx = key_idx
var key_value: Variant = animation.track_get_key_value(track_idx, first_key_idx)
if not sampled.has(pose_key):
sampled[pose_key] = {}
var properties: Dictionary = sampled[pose_key]
properties[property_name] = key_value
return sampled
func _apply_walk_marker_pose(pose: Dictionary) -> void:
for pose_key: String in pose:
var marker: Marker2D
if pose_key.begins_with("base:"):
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
else:
marker = _rig._get_ik_marker(pose_key)
if marker == null:
continue
var properties: Dictionary = pose[pose_key]
if properties.get("position") is Vector2:
marker.position = properties["position"]
if properties.get("rotation") is float or properties.get("rotation") is int:
marker.rotation = float(properties["rotation"])
func _resume_settle_after_terrain() -> void:
if not _settling or not _settle_waiting_for_ground or terrain_motion != TerrainMotion.GROUNDED:
return
_walk_settle_start_pose = _capture_walk_marker_pose()
_restore_walk_standing_pose()
_walk_settle_end_pose = _capture_walk_marker_pose()
_apply_walk_settle_pose(0.0)
_walk_settle_elapsed = 0.0
_settle_waiting_for_ground = false
if _walk_debug_enabled():
print("[WalkDebug] SETTLE_RESUMED start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
func _walk_debug_enabled() -> bool:
return OS.get_environment("STICKMAN_WALK_DEBUG") == "1"
func _debug_walk_pose(label: String) -> void:
if not _walk_debug_enabled() or _rig == null:
return
var markers: Dictionary = {}
for marker_name: String in MOVE_MARKER_NAMES:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker != null:
markers[marker_name] = marker.position
var bases: Dictionary = {}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
if base != null:
bases[leg_name] = base.position
var bend_flags: Dictionary = {}
for joint: String in ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]:
bend_flags[joint] = _rig.get_joint_bend_flipped(joint)
var tips: Dictionary = {}
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton != null:
var bone_paths: Dictionary = {
"Left_Upper_Arm": "Torso/LeftUpperArm",
"Left_Lower_Arm": "Torso/LeftUpperArm/LeftLowerArm",
"Right_Upper_Arm": "Torso/RightUpperArm",
"Right_Lower_Arm": "Torso/RightUpperArm/RightLowerArm",
"Left_Upper_Leg": "Torso/LeftUpperLeg",
"Left_Lower_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
"Right_Upper_Leg": "Torso/RightUpperLeg",
"Right_Lower_Leg": "Torso/RightUpperLeg/RightLowerLeg",
}
for bone_name: String in bone_paths:
var bone := skeleton.get_node_or_null(NodePath(String(bone_paths[bone_name]))) as Bone2D
if bone == null:
continue
var tip_world := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
tips[bone_name] = _rig.to_local(tip_world)
print("[WalkDebug] %s facing=%d bends=%s root=%s markers=%s leg_bases=%s limb_rotations=%s limb_tips=%s" % [label, int(_rig.facing_profile), bend_flags, _rig.global_position, markers, bases, _rig._capture_limb_rotations(), tips])
func begin_terrain_fall() -> void:
if _rig == null:
return
@@ -562,10 +820,24 @@ func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Arra
func _stance_weight(leg_name: String) -> float:
if not _walking or _rig._anim_player == null:
return 1.0
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), _active_cycle_duration)
if _active_move_style == ACTION_REGISTRY.RUN:
var contact_start: float = RUN_RIGHT_CONTACT_START if leg_name == "Right_Leg" else RUN_LEFT_CONTACT_START
return _run_contact_weight(phase, contact_start)
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
func _run_contact_weight(phase: float, contact_start: float) -> float:
var cycle_phase: float = fposmod(phase, RUN_CYCLE_DURATION)
var contact_phase: float = fposmod(cycle_phase - contact_start, RUN_CYCLE_DURATION)
if contact_phase >= RUN_CONTACT_DURATION:
return 0.0
var fade: float = minf(RUN_CONTACT_EDGE_FADE, RUN_CONTACT_DURATION * 0.5)
var fade_in: float = smoothstep(0.0, fade, contact_phase)
var fade_out: float = 1.0 - smoothstep(RUN_CONTACT_DURATION - fade, RUN_CONTACT_DURATION, contact_phase)
return clampf(fade_in * fade_out, 0.0, 1.0)
func _update_terrain_following(delta: float) -> void:
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
terrain_pelvis_offset = 0.0
@@ -3,6 +3,7 @@ extends Node
## Owns one-stickman/one-hand prop interaction state during Sandbox PLAY.
const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
@@ -243,8 +244,10 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
if bool(entry.get("active", false)):
uncertain = true
var action_type := String(action.get("type", ""))
if ACTION_REGISTRY.is_move_action(action):
action_type = ACTION_REGISTRY.MOVE_TO
match action_type:
"walk_to":
"move_to":
var feet_target: Variant = action.get("target", Vector2.INF)
if feet_target is Vector2 and (feet_target as Vector2).is_finite() and _locomotion != null:
var previous_root := root_position
+12 -3
View File
@@ -54,6 +54,8 @@ const BEND_JOINT_BONE_PATHS: Dictionary = {
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
## Side-facing profiles share a branch pairing. FORWARD keeps its camera-facing
## pairing, which separates the limbs when the character faces the viewer.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
@@ -92,6 +94,7 @@ const Z_ORDER_BY_PROFILE: Dictionary = {
}
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
## Upper + lower Bone2D paths (Skeleton2D-relative), retained for mirrored
## idle-pose preservation and legacy test access.
@@ -790,7 +793,13 @@ func _set_limb_mods_enabled(enabled: bool) -> void:
## uses walk_speed. No-op (push_warning) unless state == ANIMATED.
func walk_to(target: Vector2, speed: float = -1.0) -> void:
if _locomotion_component != null:
_locomotion_component.walk_to(target, speed)
_locomotion_component.move_to(target, ACTION_REGISTRY.WALK, speed)
## Start a style-aware Move To action. Walk remains the compatibility default.
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> void:
if _locomotion_component != null:
_locomotion_component.move_to(target, style, speed_override)
func is_walking() -> bool:
@@ -865,7 +874,7 @@ func remove_action(index: int) -> void:
func insert_action(index: int, action: Dictionary) -> void:
if _action_runner_component != null:
var normalized: Dictionary = _with_recover_barrier(action)
if is_ragdolled() and String(normalized.get("type", "")) == "walk_to":
if is_ragdolled() and ACTION_REGISTRY.is_move_action(normalized):
var queue: Array[Dictionary] = get_queue()
var at: int = clampi(index, 0, queue.size())
var has_recover_barrier: bool = false
@@ -885,7 +894,7 @@ func insert_action(index: int, action: Dictionary) -> void:
func replace_action(index: int, action: Dictionary) -> bool:
if _action_runner_component == null:
return false
if is_ragdolled() and String(action.get("type", "")) == "walk_to":
if is_ragdolled() and ACTION_REGISTRY.is_move_action(action):
var queue: Array[Dictionary] = get_queue()
var has_recover_barrier: bool = false
if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover":
+113 -15
View File
@@ -4,9 +4,8 @@ extends PopupPanel
##
## Used for adding a new action and for editing an existing one (queue or rule).
## The type dropdown and the param fields are generated from ActionRegistry, so
## new action types appear automatically. For `walk_to`, the target position is
## captured on the stage: the editor emits `target_requested()` and the stage
## hides the editor, captures a click, then calls `set_walk_target()`.
## new action types appear automatically. A Move To destination is captured on
## the stage while its Walk/Run draft remains in this editor.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
@@ -23,6 +22,13 @@ var _text_edit: LineEdit = null
var _duration_spin: SpinBox = null
var _target_label: Label = null
var _set_target_btn: Button = null
var _walk_style_btn: Button = null
var _run_style_btn: Button = null
var _move_style: String = ACTION_REGISTRY.WALK
var _move_style_group: ButtonGroup = null
var _direct_accent_color: Color = Color("#FFB300")
var _surface_color: Color = Color("#252B38")
var _border_color: Color = Color("#FFFFFF33")
var _prop_target_label: Label = null
var _set_prop_target_btn: Button = null
var _hand_option: OptionButton = null
@@ -53,18 +59,19 @@ func _unhandled_input(event: InputEvent) -> void:
cancelled.emit()
## Opens the editor in "add" mode (empty, default type walk_to).
## Opens the editor in "add" mode (empty, default Move To/Walk).
func open_new(show_reactive_options: bool = false) -> void:
_show_reactive_options = show_reactive_options
_mode = "new"
_initial = {}
_has_target = false
_target_pos = Vector2.ZERO
_move_style = ACTION_REGISTRY.WALK
_prop_target_id = ""
_has_throw_target = false
_throw_target_pos = Vector2.ZERO
_select_type("walk_to")
_rebuild_params("walk_to")
_select_type(ACTION_REGISTRY.MOVE_TO)
_rebuild_params(ACTION_REGISTRY.MOVE_TO)
title = "Add Action"
popup_centered()
@@ -74,7 +81,7 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_show_reactive_options = show_reactive_options
_mode = "edit"
_initial = action.duplicate(true)
var type := String(action.get("type", "walk_to"))
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", ACTION_REGISTRY.MOVE_TO)))
if type == "speak":
speech_requested.emit(_initial.duplicate(true))
return
@@ -83,6 +90,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_prop_target_id = String(action.get("target_prop_id", ""))
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
_move_style = ACTION_REGISTRY.checked_style(action.get("style", null))
if _move_style.is_empty():
_move_style = ACTION_REGISTRY.WALK
_select_type(type)
_rebuild_params(type)
title = "Edit Action"
@@ -114,6 +124,10 @@ func current_action_type() -> String:
return _current_type()
func current_move_style() -> String:
return _move_style
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
@@ -149,11 +163,13 @@ func _build_ui() -> void:
var cancel := Button.new()
cancel.text = "Cancel"
cancel.custom_minimum_size.y = 44.0
cancel.pressed.connect(func() -> void: cancelled.emit())
buttons.add_child(cancel)
var ok := Button.new()
ok.text = "OK"
ok.text = "Done"
ok.custom_minimum_size.y = 44.0
ok.pressed.connect(_on_ok_pressed)
buttons.add_child(ok)
@@ -169,7 +185,12 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
_apply_font_recursive(self, _base_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
if sandbox_theme != null:
self.theme = sandbox_theme.control_theme()
_direct_accent_color = sandbox_theme.color("mode_colors.direct", _direct_accent_color)
_surface_color = sandbox_theme.color("global.surface_color", _surface_color)
_border_color = sandbox_theme.color("global.border_color", _border_color)
_sync_move_style_buttons()
func _apply_font_recursive(node: Node, size: int) -> void:
@@ -201,7 +222,7 @@ func _current_type() -> String:
var types := ACTION_REGISTRY.types()
var idx := _type_option.selected
if idx < 0 or idx >= types.size():
return "walk_to"
return ACTION_REGISTRY.MOVE_TO
return types[idx]
@@ -224,16 +245,30 @@ func _rebuild_params(type: String) -> void:
_set_prop_target_btn = null
_hand_option = null
_stop_current_check = null
_walk_style_btn = null
_run_style_btn = null
match type:
"walk_to":
"move_to":
var move_label := Label.new()
move_label.text = "How should they move?"
_params_box.add_child(move_label)
var style_row := HBoxContainer.new()
style_row.add_theme_constant_override("separation", 8)
_params_box.add_child(style_row)
_move_style_group = ButtonGroup.new()
_walk_style_btn = _make_move_style_button("🚶 Walk", ACTION_REGISTRY.WALK)
_run_style_btn = _make_move_style_button("🏃 Run", ACTION_REGISTRY.RUN)
style_row.add_child(_walk_style_btn)
style_row.add_child(_run_style_btn)
_sync_move_style_buttons()
var trow := HBoxContainer.new()
_params_box.add_child(trow)
_target_label = Label.new()
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
trow.add_child(_target_label)
_set_target_btn = Button.new()
_set_target_btn.text = "🎯 Click target…"
_set_target_btn.text = "🚩 Choose a Place"
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
trow.add_child(_set_target_btn)
_update_walk_target_label()
@@ -300,9 +335,71 @@ func _update_walk_target_label() -> void:
if _target_label == null:
return
if _has_target:
_target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))]
_target_label.text = "Place chosen"
if _set_target_btn != null:
_set_target_btn.text = "🚩 Change Place"
else:
_target_label.text = "Target: not set"
_target_label.text = "Place to go:"
if _set_target_btn != null:
_set_target_btn.text = "🚩 Choose a Place"
func _make_move_style_button(label: String, style: String) -> Button:
var button := Button.new()
button.text = label
button.toggle_mode = true
button.button_group = _move_style_group
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(44.0, 44.0)
button.focus_mode = Control.FOCUS_ALL
button.pressed.connect(_on_move_style_pressed.bind(style))
return button
func _on_move_style_pressed(style: String) -> void:
_move_style = style
_sync_move_style_buttons()
func _sync_move_style_buttons() -> void:
for pair: Array in [[_walk_style_btn, ACTION_REGISTRY.WALK, "🚶 Walk"], [_run_style_btn, ACTION_REGISTRY.RUN, "🏃 Run"]]:
var button: Button = pair[0] as Button
if button == null:
continue
var style: String = String(pair[1])
var selected: bool = _move_style == style
button.set_pressed_no_signal(selected)
button.text = String(pair[2]) + (" ✓" if selected else "")
button.add_theme_stylebox_override("normal", _move_style_stylebox(selected, false))
button.add_theme_stylebox_override("pressed", _move_style_stylebox(selected, false))
button.add_theme_stylebox_override("hover", _move_style_stylebox(selected, true))
button.add_theme_stylebox_override("focus", _move_style_focus_stylebox())
button.add_theme_color_override("font_color", Color("#161616") if selected else Color.WHITE)
button.add_theme_color_override("font_hover_color", Color("#161616") if selected else Color.WHITE)
func _move_style_stylebox(selected: bool, hovered: bool) -> StyleBoxFlat:
var stylebox := StyleBoxFlat.new()
stylebox.bg_color = _direct_accent_color if selected else _surface_color
if hovered:
stylebox.bg_color = stylebox.bg_color.lightened(0.12)
stylebox.border_color = Color("#FFF4CF") if selected else (_direct_accent_color if hovered else _border_color)
stylebox.set_border_width_all(3 if selected else 1)
stylebox.set_corner_radius_all(8)
stylebox.content_margin_left = 8.0
stylebox.content_margin_right = 8.0
stylebox.content_margin_top = 6.0
stylebox.content_margin_bottom = 6.0
return stylebox
func _move_style_focus_stylebox() -> StyleBoxFlat:
var stylebox := StyleBoxFlat.new()
stylebox.bg_color = Color(0.0, 0.0, 0.0, 0.0)
stylebox.border_color = Color.WHITE
stylebox.set_border_width_all(2)
stylebox.set_corner_radius_all(8)
return stylebox
func _update_prop_target_label() -> void:
@@ -323,12 +420,13 @@ func _on_ok_pressed() -> void:
var type := _current_type()
var action: Dictionary = { "type": type }
match type:
"walk_to":
"move_to":
if not _has_target:
# Ask the stage to capture the target before committing.
target_requested.emit()
return
action["target"] = _target_pos
action["style"] = _move_style
"pick_up_prop":
if _prop_target_id.is_empty():
target_requested.emit()
+1
View File
@@ -301,6 +301,7 @@ func _refresh_trigger() -> void:
func _select_action_type(want: String) -> void:
want = ACTION_REGISTRY.canonical_type(want)
if want.is_empty():
_action_type_option.select(0)
return
+232 -103
View File
@@ -83,6 +83,7 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SELECTOR_DIM_ALPHA := 0.5
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const DIRECT_MOVE_POPUP_SIZE := Vector2i(380, 172)
const SETTINGS_PATH := "user://sandbox_settings.json"
const THEME_PATH := "res://sandbox_theme.json"
@@ -291,6 +292,12 @@ var _edit_physics_settle_frames: int = 0
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _direct_move_popup: PopupPanel = null
var _direct_move_style_option: OptionButton = null
var _direct_move_style_ids: Array[String] = []
var _direct_move_target: Vector2 = Vector2.INF
## Instance IDs keep this preference local to the current open stage scene.
var _direct_move_last_style: Dictionary = {}
var _next_stage_prop_serial: int = 0
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
@@ -458,12 +465,6 @@ var _capture_throw_preview_rig: StickmanRig = null
var _capture_throw_preview_prefix: Array[Dictionary] = []
var _throw_preview_cache: Dictionary = {}
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
var _walk_edit_rig: StickmanRig = null
var _walk_edit_index: int = -1
var _walk_edit_action_id: int = -1
var _walk_edit_from_panel: bool = false
## ActionEditor context for queue/rule add/edit flows.
var _action_editor_kind: String = ""
var _action_editor_index: int = -1
@@ -1326,10 +1327,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
_cancel_direct_move_draft()
elif _direct_move_target.is_finite():
_cancel_direct_move_draft()
elif _terrain_dragging:
_cancel_terrain_drag()
elif _selector_open:
@@ -1387,6 +1387,8 @@ func _space_should_pass_to_gui() -> bool:
return true
if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible):
return true
if _direct_move_popup != null and _direct_move_popup.visible:
return true
for popup: PopupMenu in [_action_popup, _trigger_popup, _rule_action_popup, _rule_more_popup, _waypoint_context, _rig_context_popup]:
if popup != null and popup.visible:
return true
@@ -1421,6 +1423,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return
if current_mode == StageMode.PLAY:
return
if _direct_move_target.is_finite():
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_cancel_direct_move_draft()
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed:
if _capture_kind != CaptureKind.NONE:
@@ -1428,10 +1434,7 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
_cancel_direct_move_draft()
else:
_handle_right_click()
return
@@ -1569,7 +1572,7 @@ func _clear_director_pending() -> void:
if _speech_composer != null and _speech_composer.visible:
_finish_speech_composition(false)
_clear_popup_anchor()
_pending_walk_target = false
_cancel_direct_move_draft()
_context_rig = null
if _capture_kind != CaptureKind.NONE:
_cancel_capture()
@@ -1777,7 +1780,7 @@ func _finish_resume_baseline() -> void:
func _pause_toggle_blocked() -> bool:
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _gizmos.is_dragging() or _selector_open:
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _direct_move_target.is_finite() or _gizmos.is_dragging() or _selector_open:
return true
var focus: Control = get_viewport().gui_get_focus_owner()
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
@@ -2482,6 +2485,7 @@ func _reset_scene_content() -> void:
_free_ghost(); _free_terrain_ghosts(); _end_terrain_drag()
_selection.clear_selection(); _selection.clear_hover(); _authored.clear(); _edit_physics_targets.clear(); _event_rules.clear(); _next_rule_id = 0
_clear_director_pending(); _reset_rule_builder(); _nav_dirty = false
_direct_move_last_style.clear()
for child: Node in _world.get_children():
_world.remove_child(child)
child.queue_free()
@@ -2695,6 +2699,8 @@ func _check_autosave_recovery() -> void:
func _handle_cli_startup(args: Array) -> void:
if OS.get_environment("STICKMAN_WALK_DEBUG") == "1":
print("[WalkDebug] CLI_STARTUP args=%s" % [args])
var result: Dictionary = CliSceneLauncher.parse(args)
if not String(result.get("error", "")).is_empty():
_show_cli_error_dialog(String(result["error"]))
@@ -2753,7 +2759,9 @@ func _refresh_status() -> void:
sel_text = "%d objects" % sel.size()
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
if _pending_walk_target:
text += " | Click stage for walk target (Esc or RMB to cancel)"
text += " | Click stage to choose a Move To place (Esc or RMB to cancel)"
elif _direct_move_target.is_finite():
text += " | Choose Movement, then Add Move To (Esc or RMB to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint
if not _toast_text.is_empty():
@@ -3239,7 +3247,7 @@ func _build_ui() -> void:
_set_timestamp(0.0)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
_action_popup.add_item("🚩 Move To", ACT_WALK)
_action_popup.add_item("💬 Speak", ACT_SPEAK)
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
@@ -3256,6 +3264,62 @@ func _build_ui() -> void:
_action_popup.about_to_popup.connect(_refresh_action_popup_items)
ui.add_child(_action_popup)
_direct_move_popup = PopupPanel.new()
_direct_move_popup.name = "DirectMovePopup"
_direct_move_popup.exclusive = true
_direct_move_popup.popup_window = true
_direct_move_popup.min_size = DIRECT_MOVE_POPUP_SIZE
if _sandbox_theme != null:
_direct_move_popup.theme = _sandbox_theme.control_theme()
ui.add_child(_direct_move_popup)
var move_margin := MarginContainer.new()
move_margin.add_theme_constant_override("margin_left", 14)
move_margin.add_theme_constant_override("margin_right", 14)
move_margin.add_theme_constant_override("margin_top", 12)
move_margin.add_theme_constant_override("margin_bottom", 12)
_direct_move_popup.add_child(move_margin)
var move_column := VBoxContainer.new()
move_column.add_theme_constant_override("separation", 10)
move_margin.add_child(move_column)
var move_row := HBoxContainer.new()
move_row.add_theme_constant_override("separation", 12)
move_column.add_child(move_row)
var move_label := Label.new()
move_label.text = "Movement"
_apply_ui_font(move_label)
move_label.add_theme_font_size_override("font_size", 23)
move_row.add_child(move_label)
_direct_move_style_option = OptionButton.new()
_direct_move_style_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_direct_move_style_option.custom_minimum_size = Vector2(210.0, 54.0)
_direct_move_style_option.add_theme_font_size_override("font_size", 25)
if _emoji_font != null:
_direct_move_style_option.add_theme_font_override("font", _emoji_font)
else:
_apply_ui_font(_direct_move_style_option)
move_row.add_child(_direct_move_style_option)
var move_buttons := HBoxContainer.new()
move_buttons.alignment = BoxContainer.ALIGNMENT_END
move_buttons.add_theme_constant_override("separation", 8)
move_column.add_child(move_buttons)
var move_cancel := Button.new()
move_cancel.text = "Cancel"
move_cancel.custom_minimum_size = Vector2(112.0, 52.0)
move_cancel.pressed.connect(_cancel_direct_move_draft)
_apply_ui_font(move_cancel)
move_cancel.add_theme_font_size_override("font_size", 22)
move_buttons.add_child(move_cancel)
var move_add := Button.new()
move_add.text = "Add Move To"
move_add.custom_minimum_size = Vector2(166.0, 52.0)
move_add.pressed.connect(_commit_direct_move_draft)
_apply_ui_font(move_add)
move_add.add_theme_font_size_override("font_size", 22)
move_buttons.add_child(move_add)
_direct_move_popup.popup_hide.connect(_cancel_direct_move_draft)
_direct_move_popup.window_input.connect(_on_direct_move_popup_input)
_populate_direct_move_styles()
_trigger_popup = PopupMenu.new()
_trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED)
_trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED)
@@ -3271,7 +3335,7 @@ func _build_ui() -> void:
ui.add_child(_trigger_popup)
_rule_action_popup = PopupMenu.new()
_rule_action_popup.add_item("🚶 Walk To", ACT_WALK)
_rule_action_popup.add_item("🚶 Move To", ACT_WALK)
_rule_action_popup.add_item("💬 Speak", ACT_SPEAK)
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
@@ -3463,6 +3527,7 @@ func _build_ui() -> void:
_action_editor.speech_requested.connect(_on_action_editor_speech_requested)
ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.apply_theme(_sandbox_theme)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
@@ -4082,14 +4147,22 @@ func _sync_editable_physics_transform(node: Node2D) -> void:
# ---------------------------------------------------------------------------
func _handle_direct_click(world_pos: Vector2) -> void:
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
if _pending_walk_target:
if _context_rig == null or not is_instance_valid(_context_rig):
_cancel_direct_move_draft()
return
if not _is_target_valid(world_pos):
_show_toast("Choose an open place for Move To")
return
_stage_dbg("move_to staged target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
_direct_move_target = world_pos
_pending_walk_target = false
_context_rig = null
_show_direct_move_popup(_world_to_screen(world_pos))
_apply_cursor()
_refresh_status()
return
if _direct_move_target.is_finite():
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
@@ -4099,6 +4172,87 @@ func _handle_direct_click(world_pos: Vector2) -> void:
_action_popup.popup(rect)
func _populate_direct_move_styles() -> void:
_direct_move_style_ids.clear()
_direct_move_style_option.clear()
var template: Dictionary = ACTION_REGISTRY.ACTION_TEMPLATES.get(ACTION_REGISTRY.MOVE_TO, {})
var params: Array = template.get("params", [])
for entry: Variant in params:
if not entry is Dictionary:
continue
var param: Dictionary = entry
if String(param.get("key", "")) != "style":
continue
var choices: Array = param.get("choices", [])
for raw_style: Variant in choices:
var style := String(raw_style)
if style.is_empty() or _direct_move_style_ids.has(style):
continue
_direct_move_style_ids.append(style)
var icon := "🚶 " if style == ACTION_REGISTRY.WALK else ("🏃 " if style == ACTION_REGISTRY.RUN else "")
_direct_move_style_option.add_item(icon + style.capitalize())
break
if _direct_move_style_ids.is_empty():
_direct_move_style_ids.append(ACTION_REGISTRY.WALK)
_direct_move_style_option.add_item("🚶 Walk")
func _show_direct_move_popup(screen_pos: Vector2) -> void:
if _direct_move_popup == null or _direct_move_style_option == null:
_cancel_direct_move_draft()
return
var rig_id: int = _context_rig.get_instance_id()
var preferred: String = String(_direct_move_last_style.get(rig_id, ACTION_REGISTRY.WALK))
var index: int = _direct_move_style_ids.find(preferred)
_direct_move_style_option.select(index if index >= 0 else 0)
var viewport_size: Vector2 = get_viewport_rect().size
var popup_size := DIRECT_MOVE_POPUP_SIZE
_direct_move_popup.size = popup_size
var popup_pos := Vector2i(screen_pos + Vector2(18.0, 12.0))
popup_pos.x = clampi(popup_pos.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
popup_pos.y = clampi(popup_pos.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
_direct_move_popup.position = popup_pos
_direct_move_popup.popup()
_direct_move_style_option.grab_focus()
func _commit_direct_move_draft() -> void:
if not _direct_move_target.is_finite() or _context_rig == null or not is_instance_valid(_context_rig):
_cancel_direct_move_draft()
return
var index: int = _direct_move_style_option.selected
if index < 0 or index >= _direct_move_style_ids.size():
return
var style: String = _direct_move_style_ids[index]
var rig_id: int = _context_rig.get_instance_id()
var action: Dictionary = {"type": ACTION_REGISTRY.MOVE_TO, "target": _direct_move_target, "style": style}
if not _queue_action_and_confirm(_context_rig, action):
_show_toast("Move To was not added. Try again or Cancel.")
return
_direct_move_last_style[rig_id] = style
_cancel_direct_move_draft()
_show_toast("Move To · %s added" % style.capitalize())
func _cancel_direct_move_draft() -> void:
if not _pending_walk_target and not _direct_move_target.is_finite():
return
_pending_walk_target = false
_direct_move_target = Vector2.INF
_context_rig = null
if _direct_move_popup != null and _direct_move_popup.visible:
_direct_move_popup.hide()
_apply_cursor()
_refresh_status()
func _on_direct_move_popup_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_cancel_direct_move_draft()
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
_cancel_direct_move_draft()
## Re-enables/disables the "📋 Edit Queue…" / "⚡ Edit Rules…" items in the Direct
## action popup based on the context rig's queue/rules (entry-point gating, spec §2
## decision 8). Runs on about_to_popup.
@@ -4146,6 +4300,7 @@ func _on_action_popup_id_pressed(id: int) -> void:
return
match id:
ACT_WALK:
_direct_move_target = Vector2.INF
_pending_walk_target = true
_apply_cursor()
_refresh_status()
@@ -4214,7 +4369,7 @@ func _is_awaiting_click() -> bool:
func _action_hint_text() -> String:
if _pending_walk_target:
return "🚩 Click to set walk target"
return "🚩 Click where they should go"
if _capture_kind != CaptureKind.NONE:
if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty():
if not bool(_throw_preview_cache.get("preview_available", true)):
@@ -4238,7 +4393,12 @@ func _action_hint_text() -> String:
RuleStep.ACTION_TARGET:
return "🎯 Click the stickman"
RuleStep.ACTION_POSITION:
return "🚩 Click where to walk"
var actions: Array = _rule_builder.get("actions", [])
var style: String = ACTION_REGISTRY.WALK
if not actions.is_empty():
var move_action: Dictionary = actions.back()
style = ACTION_REGISTRY.checked_style(move_action.get("params", {}).get("style", null))
return "🚩 Click where they should %s" % ("run" if style == ACTION_REGISTRY.RUN else "walk")
return ""
@@ -4252,8 +4412,10 @@ func _action_origin() -> Vector2:
return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO
if _capture_kind != CaptureKind.NONE:
if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig):
return _walk_edit_rig.global_position - StickmanLocomotion.FOOT_OFFSET
if _capture_kind == CaptureKind.POSITION:
var editor_rig: StickmanRig = _action_editor_preview_rig()
if editor_rig != null and is_instance_valid(editor_rig):
return editor_rig.global_position - StickmanLocomotion.FOOT_OFFSET
if _panel_rig != null and is_instance_valid(_panel_rig):
return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO
@@ -4281,7 +4443,7 @@ func _update_action_overlay() -> void:
if awaiting:
var origin := _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.THROW_POSITION):
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.POSITION or _capture_kind == CaptureKind.THROW_POSITION):
target = _snap_to_grid(target)
if _capture_kind == CaptureKind.THROW_POSITION:
if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
@@ -4312,6 +4474,9 @@ func _update_action_overlay() -> void:
)
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
elif _direct_move_target.is_finite() and _context_rig != null and is_instance_valid(_context_rig):
var origin: Vector2 = _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
_placement_overlay.set_action_trajectory(origin, _direct_move_target, true)
else:
_placement_overlay.clear_action()
if _tooltip != null:
@@ -4501,7 +4666,7 @@ func _trigger_type_string(id: int) -> String:
func _action_type_string(id: int) -> String:
match id:
ACT_WALK:
return "walk_to"
return ACTION_REGISTRY.MOVE_TO
ACT_SPEAK:
return "speak"
ACT_WAIT:
@@ -4523,7 +4688,7 @@ func _action_type_string(id: int) -> String:
func _action_verb(id: int) -> String:
match id:
ACT_WALK:
return "walk"
return "move to a place"
ACT_SPEAK:
return "speak"
ACT_WAIT:
@@ -4601,10 +4766,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
match String(action.get("type", "")):
"walk_to":
_rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
_refresh_status()
"move_to":
_open_action_editor("rule_builder_move_to", {}, -1, null, int((hit as StickmanRig).get_instance_id()), false)
"speak":
_rule_step = RuleStep.PARAMS
_open_speech_composer(hit as StickmanRig, {"type": "speak", "text": "", "duration": 2.0}, true, "rule_builder")
@@ -4911,7 +5074,7 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
var trig_params: Dictionary = trigger.get("params", {})
var want := String(trig_params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return want.is_empty() or want == String(action.get("type", ""))
return want.is_empty() or ACTION_REGISTRY.canonical_type(want) == ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
"speech_finished":
return _ids_match(source_id, source)
"entered_area":
@@ -4948,21 +5111,7 @@ func _execute_rule_actions(rule: Dictionary) -> void:
## Converts a rule action into the queue-action shape the runner understands.
func _action_for_rig(a: Dictionary) -> Dictionary:
var params: Dictionary = a.get("params", {})
var out: Dictionary = { "type": String(a.get("type", "")) }
match String(a.get("type", "")):
"walk_to":
out["target"] = params.get("target", Vector2.ZERO)
"speak":
return ACTION_REGISTRY.from_rule_action(a)
"wait":
out["duration"] = float(params.get("duration", 0.0))
"pick_up_prop":
out["target_prop_id"] = String(params.get("target_prop_id", ""))
out["hand"] = String(params.get("hand", "auto"))
"throw_prop":
out["target_position"] = params.get("target_position", Vector2.ZERO)
return out
## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props.
@@ -5093,7 +5242,7 @@ func _on_waypoint_context_id_pressed(id: int) -> void:
_begin_walk_edit(rig, index, false)
WaypointContext.DELETE_WALK:
rig.remove_action(index)
_show_toast("Walk deleted")
_show_toast("Move erased")
WaypointContext.INSERT_BEFORE:
if _queue_action_consumed(status) or status == StickmanActionRunner.QueueStatus.ACTIVE:
_show_toast("Insert after the active action")
@@ -5234,54 +5383,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
# ---------------------------------------------------------------------------
func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
if index < 0 or index >= rig.queue_size():
return
_walk_edit_rig = rig
_walk_edit_index = index
_walk_edit_action_id = rig.action_id_at(index)
_walk_edit_from_panel = from_panel
var queue := rig.get_queue()
var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
_director_visuals.set_edit_waypoint(pos)
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
func _on_walk_edit_captured(value: Vector2) -> void:
var rig := _walk_edit_rig
var action_id: int = _walk_edit_action_id
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
if rig != null and is_instance_valid(rig):
var index: int = rig.index_for_action_id(action_id)
var status: int = rig.get_action_status(action_id)
var queue: Array[Dictionary] = rig.get_queue()
if index >= 0 and index < queue.size() and not _queue_action_consumed(status):
var action: Dictionary = queue[index]
action["target"] = value
if rig.replace_action(index, action):
_show_toast("Waypoint moved")
else:
_show_toast("That action changed while the waypoint was open")
if from_panel and _queue_panel != null:
_queue_panel.refresh()
_queue_panel.popup_centered()
func _cb_walk_edit_cancel() -> void:
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
if from_panel and _queue_panel != null:
_queue_panel.popup_centered()
if index < 0 or index >= queue.size() or not ACTION_REGISTRY.is_move_action(queue[index]):
return
_open_action_editor("queue_edit", queue[index].duplicate(true), index, rig, -1, from_panel)
# ---------------------------------------------------------------------------
@@ -5304,10 +5409,6 @@ func _on_queue_panel_edit_requested(index: int) -> void:
_show_toast("Completed actions can only be deleted")
return
var action: Dictionary = queue[index]
if String(action.get("type", "")) == "walk_to":
_queue_panel.hide()
_begin_walk_edit(_panel_rig, index, true)
else:
_open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, true)
@@ -6395,6 +6496,21 @@ func _on_action_editor_committed(flat: Dictionary) -> bool:
if _rule_editor != null and actor_id >= 0:
_rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, actor_id))
succeeded = true
"rule_builder_move_to":
var actions: Array = _rule_builder.get("actions", [])
if not actions.is_empty() and _action_editor_actor_id >= 0:
var rule_action: Dictionary = actions.back()
rule_action["type"] = ACTION_REGISTRY.MOVE_TO
rule_action["target"] = _action_editor_actor_id
rule_action["params"] = {
"target": flat.get("target", Vector2.ZERO),
"style": ACTION_REGISTRY.checked_style(flat.get("style", null)),
}
actions[actions.size() - 1] = rule_action
_rule_builder["actions"] = actions
_rule_step = RuleStep.SELECT_ACTION
_open_rule_more_popup()
succeeded = true
if not succeeded:
return false
_action_editor.hide()
@@ -6426,6 +6542,16 @@ func _on_action_editor_cancelled() -> void:
"rule_add", "rule_edit":
if _rule_editor != null:
_rule_editor.popup_centered()
"rule_builder_move_to":
var actions: Array = _rule_builder.get("actions", [])
if not actions.is_empty():
actions.pop_back()
_rule_builder["actions"] = actions
_rule_step = RuleStep.SELECT_ACTION
if actions.is_empty():
_open_rule_action_popup()
else:
_open_rule_more_popup()
_:
pass
@@ -6433,7 +6559,7 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
var action_type := _action_editor.current_action_type()
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == "pick_up_prop" or action_type == "throw_prop"):
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "throw_prop"):
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel)
return
_begin_editor_action_target_capture()
@@ -6441,6 +6567,9 @@ func _on_action_editor_target_requested() -> void:
func _begin_editor_action_target_capture() -> void:
match _action_editor.current_action_type():
"move_to":
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
_begin_capture(CaptureKind.POSITION, "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, _cb_editor_walk_target, _cb_editor_walk_target_cancel)
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
"throw_prop":
@@ -6458,7 +6587,7 @@ func _begin_editor_action_target_capture() -> void:
queue_prefix
)
_:
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
_begin_capture(CaptureKind.POSITION, "🚩 Click the target (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
+40 -17
View File
@@ -9,12 +9,17 @@ extends RefCounted
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
const MOVE_TO: String = "move_to"
const WALK: String = "walk"
const RUN: String = "run"
const ACTION_TEMPLATES := {
"walk_to": {
"label": "Walk To",
MOVE_TO: {
"label": "Move To",
"icon": "🚶",
"params": [
{ "key": "target", "type": "position", "required": true },
{ "key": "style", "type": "choice", "default": WALK, "choices": [WALK, RUN] },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
@@ -86,6 +91,24 @@ static func has_type(type: String) -> bool:
return ACTION_TEMPLATES.has(type)
static func canonical_type(type: String) -> String:
return MOVE_TO if type == "walk_to" else type
static func checked_style(value: Variant) -> String:
if value == null:
return WALK
if not value is String:
return ""
var style: String = value
return style if style == WALK or style == RUN else ""
static func is_move_action(action: Dictionary) -> bool:
var type: String = String(action.get("type", ""))
return type == MOVE_TO or type == "walk_to"
static func label(type: String) -> String:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return String(tpl.get("label", type))
@@ -118,9 +141,11 @@ static func reactive_role(action: Dictionary) -> ReactiveRole:
## params). `target_id` is the acting stickman's instance id.
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
var params: Dictionary = {}
match String(action.get("type", "")):
"walk_to":
var type: String = canonical_type(String(action.get("type", "")))
match type:
MOVE_TO:
params["target"] = action.get("target", Vector2.ZERO)
params["style"] = checked_style(action.get("style", null))
"speak":
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if action.has(key):
@@ -133,7 +158,7 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": String(action.get("type", "")),
"type": type,
"target": target_id,
"params": params,
}
@@ -142,9 +167,10 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
## Converts a rule-action shape back into a flat queue action (drops `target`).
static func from_rule_action(rule_action: Dictionary) -> Dictionary:
var params: Dictionary = rule_action.get("params", {})
match String(rule_action.get("type", "")):
"walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
var type: String = canonical_type(String(rule_action.get("type", "")))
match type:
MOVE_TO:
return { "type": MOVE_TO, "target": params.get("target", Vector2.ZERO), "style": checked_style(params.get("style", null)) }
"speak":
var speak: Dictionary = {"type": "speak"}
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
@@ -162,22 +188,19 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": String(rule_action.get("type", "")) }
return { "type": type }
## Human-readable one-line summary for both flat queue actions and rule-action
## shapes (the get() fallbacks tolerate either key layout).
static func summarize(action: Dictionary) -> String:
var type := String(action.get("type", ""))
var type := canonical_type(String(action.get("type", "")))
var params: Dictionary = action.get("params", {})
match type:
"walk_to":
# Rule actions use top-level `target` for the actor instance id and
# nested `params.target` for the waypoint. Prefer the nested value and
# type-guard legacy/malformed payloads so summaries never crash.
var raw_target: Variant = params.get("target", action.get("target", Vector2.ZERO))
var t: Vector2 = raw_target as Vector2 if raw_target is Vector2 else Vector2.ZERO
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
MOVE_TO:
var style: String = checked_style(params.get("style", action.get("style", null)))
var style_label: String = "Run" if style == RUN else "Walk"
return "Move To · %s" % style_label
"speak":
var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0)))
+6 -5
View File
@@ -8,6 +8,7 @@ extends Node2D
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const WAYPOINT_RADIUS_PX := 6.0
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
@@ -158,7 +159,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
for i: int in queue_view.size():
var entry: Dictionary = queue_view[i]
var action: Dictionary = entry.get("action", {})
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
var d := world_pos.distance_to(target)
if d <= best_dist:
@@ -218,7 +219,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var tint: Color = _status_color(status)
var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
if not (_live_session and consumed and not has_authored_cursor):
_draw_dashed(current, target, zoom, tint)
@@ -319,7 +320,7 @@ func _collect_waypoints() -> Array[Vector2]:
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
result.append(target)
current = target
@@ -481,8 +482,8 @@ static func _trigger_verb(type: String) -> String:
static func _action_desc(action: Dictionary) -> String:
var params: Dictionary = action.get("params", {})
match String(action.get("type", "")):
"walk_to":
return "Walks"
"move_to", "walk_to":
return ACTION_REGISTRY.summarize(action)
"speak":
return "Speak '%s'" % String(params.get("text", ""))
"wait":
+20 -8
View File
@@ -199,12 +199,14 @@ static func _finite_number(value: Variant) -> bool:
return (value is float or value is int) and is_finite(float(value))
static func _encode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
"move_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty(): return {}
return {"type": type, "target": vector_to_data(action["target"]), "style": style}
"speak": return _encode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
@@ -221,12 +223,14 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
"move_to":
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty(): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO), "style": style}
"speak":
return _decode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
@@ -359,20 +363,28 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
if source == null and int(trigger.get("source", -1)) != -1: return {}
if target == null and int(trigger.get("target", -1)) != -1: return {}
var params: Dictionary = trigger.get("params", {})
var params: Dictionary = (trigger.get("params", {}) as Dictionary).duplicate(true)
if type == "arrived_at_waypoint":
if not params.get("waypoint_pos") is Vector2: return {}
params = {"waypoint_pos": vector_to_data(params["waypoint_pos"])}
elif type == "action_finished":
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
return {"type": type, "source_id": source, "target_id": target, "params": params}
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
if source == -2 or target == -2: return {}
var params: Dictionary = value.get("params", {})
var params: Dictionary = (value.get("params", {}) as Dictionary).duplicate(true)
if type == "arrived_at_waypoint":
if not is_vector_data(params.get("waypoint_pos")): return {}
params = {"waypoint_pos": data_to_vector(params["waypoint_pos"], Vector2.ZERO)}
elif type == "action_finished":
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
return {"type": type, "source": source, "target": target, "params": params}
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
+3 -3
View File
@@ -1,6 +1,6 @@
class_name WaypointContext
extends PopupMenu
## WaypointContext - Right-click context menu for a walk_to waypoint (Phase 3c.3).
## WaypointContext - Right-click context menu for a Move To destination.
##
## Provides Edit/Delete/Insert actions for the waypoint's walk action plus a
## "Edit Trigger Rules" entry (shown only when rules target this waypoint).
@@ -17,8 +17,8 @@ const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after
func _init() -> void:
# _init runs before the node is in the tree; item order is fixed here.
add_item("✎ Edit this Walk", EDIT_WALK)
add_item("✕ Delete this Walk", DELETE_WALK)
add_item("✎ Edit this Move", EDIT_WALK)
add_item("✕ Erase this Move", DELETE_WALK)
add_item("⬆ Insert action before", INSERT_BEFORE)
add_item("⬇ Insert action after", INSERT_AFTER)
add_separator()
+57 -4
View File
@@ -1,7 +1,7 @@
@tool
extends EditorScript
## Unified animation generator: bakes walk_left, walk_right and stand_up into
## Unified animation generator: bakes walk_left, walk_right, run_right and stand_up into
## the open scene's AnimationPlayer. Supersedes the old create_walk.gd.
##
## Note: runtime ragdoll recovery does NOT play the baked stand_up — StickmanRig
@@ -10,6 +10,7 @@ extends EditorScript
## baked stand_up is kept as an authored reference / manual-play animation.
const STAND_UP_DURATION := 2.0
const RUN_ANIMATION_DURATION := 0.75
## IK target marker node paths (relative to the rig root), keyed by marker name.
const POSE_PATHS: Dictionary = {
@@ -58,9 +59,11 @@ func _run() -> void:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", false)
_generate_walk_animation(anim_player, "walk_left", true)
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
_generate_run_in_place_animation(anim_player, "run_in_place", false)
_generate_run_in_place_animation(anim_player, "run_right", false)
#_generate_walk_animation(anim_player, "walk_right", false)
#_generate_walk_animation(anim_player, "walk_left", true)
#_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool) -> void:
@@ -132,3 +135,53 @@ func _add_animation(anim_player: AnimationPlayer, anim_name: String, anim: Anima
lib.add_animation(anim_name, anim)
print("Successfully generated '%s' animation!" % anim_name)
func _generate_run_in_place_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool = false) -> void:
var anim = Animation.new()
anim.length = RUN_ANIMATION_DURATION
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
# Contact, down, break, and up poses with arms counter-swinging against the legs.
var raw_tracks = {
"IK_Targets/Torso:position": [
Vector2(0, 15), Vector2(0, -30), Vector2(0, 15), Vector2(0, -30), Vector2(0, 15)
],
"IK_Targets/Torso:rotation": [0.30, 0.36, 0.28, 0.20, 0.30],
"IK_Targets/Head:position": [
Vector2(100, -605), Vector2(100, -642), Vector2(100, -605), Vector2(100, -642), Vector2(100, -605)
],
"AnimationTargets/Right_Leg_Base:position": [
Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390)
],
"AnimationTargets/Left_Leg_Base:position": [
Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260)
],
"IK_Targets/Right_Hand:position": [
Vector2(-250, -205), Vector2(-167, -40), Vector2(35, 0), Vector2(237, -40),
Vector2(320, -225), Vector2(237, -35), Vector2(35, 15), Vector2(-167, -35), Vector2(-250, -205)
],
"IK_Targets/Left_Hand:position": [
Vector2(320, -225), Vector2(237, -30), Vector2(55, 15), Vector2(-167, -35),
Vector2(-250, -205), Vector2(-167, -40), Vector2(55, 0), Vector2(237, -40), Vector2(320, -225)
]
}
var times = [0.0, 0.1875, 0.375, 0.5625, RUN_ANIMATION_DURATION]
var arm_times = [0.0, 0.09375, 0.1875, 0.28125, 0.375, 0.46875, 0.5625, 0.65625, RUN_ANIMATION_DURATION]
for path in raw_tracks:
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
anim.track_set_interpolation_loop_wrap(track_idx, true)
var key_times: Array = arm_times if path.ends_with("_Hand:position") else times
for i in range(raw_tracks[path].size()):
var value: Variant = raw_tracks[path][i]
if value is Vector2:
value = Vector2(value.x * dir_mult, value.y)
anim.track_insert_key(track_idx, key_times[i], value)
_add_animation(anim_player, anim_name, anim)