From 682ccee856bd1fe0628e86403fc9a9c02ec4bd5f Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Wed, 28 Oct 2026 12:43:16 +0300 Subject: [PATCH] Phase 6.7 complete checkpoint --- src/features/editScene/AGENTS.md | 45 ++- .../editScene/components/AnimationTree.cpp | 4 + .../editScene/components/AnimationTree.hpp | 18 ++ .../editScene/demos/demo-taxi/BUGS.md | 3 + .../editScene/demos/demo-taxi/PLAN.md | 77 ++++- .../demos/demo-taxi/animation_tree.json | 276 ++++++++++++------ .../editScene/demos/demo-taxi/demo_main.cpp | 179 +++++++++++- .../editScene/systems/AnimationTreeSystem.cpp | 94 ++++-- .../editScene/systems/AnimationTreeSystem.hpp | 26 +- .../editScene/tests/animation_travel_test.cpp | 66 ++++- .../editScene/ui/AnimationTreeNodeEditor.cpp | 37 ++- .../ui/AnimationTreeRegistryEditor.cpp | 17 ++ 12 files changed, 677 insertions(+), 165 deletions(-) create mode 100644 src/features/editScene/demos/demo-taxi/BUGS.md diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 19c6c23..2fc182e 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -2083,11 +2083,26 @@ each hop's clip before cross-fading to the next: - Path = BFS shortest connect path; ties break by state NAME (`AnimTravel::findTravelPath`, pure and headless-testable). Each - hop fires only when the CURRENT clip is done: a one-shot at its end, - a looping clip at the loop boundary (`t >= len * 0.99`, shared - end-of-clip detection with the legacy `endTransitions`; a travel hop - has priority over a stale end-transition while in flight). So - `idle -> walking` waits up to one idle loop — by design. + hop fires only when the CURRENT clip is done: a one-shot at its + end, a looping clip at the loop boundary (observed within one + frame step: `t >= len * 0.99` OR `t + dt >= len` — a clip exactly + N frames long skips the exact boundary frame on wrap; shared + end-of-clip detection with the legacy `endTransitions`; a travel + hop has priority over a stale end-transition while in flight). +- **Wait modes (per link):** every `AnimationTreeLink` carries a + `waitMode` — "atEnd" (the rule above, the default/legacy contract) + or "immediate" (the hop fires on the next runtime update, plain + cross-fade). Stock locomotion/driving links are IMMEDIATE: waiting + out a 10.2 s looping idle clip freezes the character for the whole + loop (the original strict rule made player walking start ~10 s + after pressing W). Reserve "atEnd" for intermediate chain states + that must play out. Editors: the node editor has a per-link + Immediate/At-end combo; the registry editor flags unknown waitMode + values on save. For setups that cannot mark the link there is a + one-shot escape hatch, `travelTo(..., immediateFirstHop=true)`: + the FIRST hop fires regardless of the link's mode (consumed by + that hop; re-issuing with the flag re-arms it) — avoid it unless + there is no other way. - Arrival = the target fades in and keeps looping (travel never force-stops it). A pending travel is runtime-only, never serialized (rule 7): controllers re-issue it from their logical @@ -2132,21 +2147,25 @@ where a hard switch is the point: NpcTrafficSystem's per-frame "driving" re-assert (a re-latch must switch, not queue), BehaviorTreeSystem and spawn/teleport/scene-load resets. The stock animation_tree.json gives every nested `locomotion` machine -`startState: "idle"` plus idle<->walking<->running and the swim-chain -links; `swim-idle` (treading_water) intentionally has no inbound link -— the registry editor's save validation WARNS about unreachable +`startState: "idle"` plus idle<->walking<->running, the swim-chain +and driving links — ALL of them `"waitMode": "immediate"` so the +machine stays responsive (92 links across the male1* + nav-test_1 +trees); `swim-idle` (treading_water) intentionally has no inbound +link — the registry editor's save validation WARNS about unreachable states and machines without an inbound link (advisory, not an error; unknown state names and duplicate links ARE errors). UI: the node editor's Travel section (ui/AnimationTreeNodeEditor.cpp) -edits links and the start/end dropdowns per machine; the registry -editor validates on save. Coverage: `tests/animation_travel_test.cpp` -(CTest `animationTravelTest` — BFS ties/cycles/unreachable, JSON round -trip with backward compatibility, validation, degrade/cancel) and the +edits links (with the per-link wait mode), the start/end dropdowns per +machine; the registry editor validates on save. Coverage: +`tests/animation_travel_test.cpp` (CTest `animationTravelTest` — BFS +ties/cycles/unreachable, JSON round trip with backward compatibility, +link waitMode serialization, validation, degrade/cancel) and the demo probe `demoTaxi --headless --test-anim-travel` (synthetic registry tree `test_travel_nested` registered at runtime and removed on PASS; asserts startState boot, hop order with no insta-switch, -cancel, degrade and the nested hold). +cancel, degrade, the nested hold, immediate-vs-atEnd link timing and +the forced first hop). ### Character facing convention (frontAxis) diff --git a/src/features/editScene/components/AnimationTree.cpp b/src/features/editScene/components/AnimationTree.cpp index 5728b64..c79d446 100644 --- a/src/features/editScene/components/AnimationTree.cpp +++ b/src/features/editScene/components/AnimationTree.cpp @@ -33,6 +33,9 @@ nlohmann::json animationTreeNodeToJson(const AnimationTreeNode &node) nlohmann::json lj = nlohmann::json::object(); lj["from"] = link.from; lj["to"] = link.to; + /* Omit the default so old files stay stable. */ + if (link.waitMode != "atEnd") + lj["waitMode"] = link.waitMode; links.push_back(lj); } json["connectLinks"] = links; @@ -77,6 +80,7 @@ void animationTreeNodeFromJson(AnimationTreeNode &node, AnimationTreeLink link; link.from = linkJson.value("from", ""); link.to = linkJson.value("to", ""); + link.waitMode = linkJson.value("waitMode", "atEnd"); if (link.from.empty() || link.to.empty()) continue; node.connectLinks.push_back(link); diff --git a/src/features/editScene/components/AnimationTree.hpp b/src/features/editScene/components/AnimationTree.hpp index adc91de..41a8824 100644 --- a/src/features/editScene/components/AnimationTree.hpp +++ b/src/features/editScene/components/AnimationTree.hpp @@ -9,10 +9,28 @@ /** * A directed connect link between two states of one state machine * ("B may follow A", Phase 6.7 travel). + * + * waitMode picks when a hop along this link may fire: + * "atEnd" - (default, the Phase 6.7 contract) the hop fires when + * the current clip completes: a one-shot at its end, a + * looping clip at the loop boundary. Use for + * intermediate chain states that must play out. + * "immediate" - the hop fires on the next runtime update through the + * usual cross-fade, without waiting for the clip. + * Use for locomotion links (idle/walking/running/swim, + * driving) where responsiveness beats the chain feel - + * a looping idle clip would otherwise hold the hop for + * its whole length (the stock idle clip is ~10 s). */ struct AnimationTreeLink { Ogre::String from; Ogre::String to; + Ogre::String waitMode = "atEnd"; + + bool isImmediate() const + { + return waitMode == "immediate"; + } }; /** diff --git a/src/features/editScene/demos/demo-taxi/BUGS.md b/src/features/editScene/demos/demo-taxi/BUGS.md new file mode 100644 index 0000000..c829b7a --- /dev/null +++ b/src/features/editScene/demos/demo-taxi/BUGS.md @@ -0,0 +1,3 @@ +- Water: + - sometimes when entering shallow water, characters float above water playing swimming animation. + - sometimes in shallow water characters start switch between swimming/walking animations, looks like hysteresis prevention needs to be tweaked and more tests added. \ No newline at end of file diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 77a5ae3..1fe40ff 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -4209,14 +4209,15 @@ DONE.** ### Phase 6.7 — Animation state machine travel -**Status: DONE (2026-10-28) — 6.7.1 through 6.7.5 complete: connect +**Status: DONE (2026-10-28) — 6.7.1 through 6.7.6 complete: connect links + start/end markers (data model, JSON round trip, registry/node editors with save validation), the `travelTo` runtime (BFS over connect links, per-hop clip completion, rule-2 degrade, rule-4 cancel), nested rule-6 start/end gating with the hold-abort re-entry, the controller migration (locomotion, vehicle enter/exit, taxi -board/alight, smart objects, path following) and the stock-tree links, -covered by `animation_travel_test` (7 cases), the demoTaxi +board/alight, smart objects, path following), the stock-tree links and +the per-link wait modes with the `travelTo` first-hop escape hatch, +covered by `animation_travel_test` (8 cases), the demoTaxi `--test-anim-travel` probe and the full demo battery.** #### The idea (plain words) @@ -4254,7 +4255,12 @@ feet sliding. 3. Each hop completes the CURRENT state's animation before the next state starts: a one-shot clip advances at its end; a LOOPING clip advances at the loop boundary (end of the current iteration), never - mid-cycle. The hop itself is the usual cross-fade. + mid-cycle. The hop itself is the usual cross-fade. The boundary + is observed within one frame step (a clip exactly N frames long + skips the exact boundary frame on wrap; `t + dt >= len` counts). + Each connect link also carries a `waitMode` (6.7.6): "atEnd" + (this rule, the default) or "immediate" — the hop fires on the + next runtime update without waiting the clip out. 4. A new `travelTo` while a travel is running REPLACES the pending path: the in-flight hop finishes, then the machine travels from there to the new target (recomputed). No queue of stale commands. @@ -4419,16 +4425,17 @@ feet sliding. road_geometry_overlap_test and all demoTaxi tests exit 0, and the docs (this file + editScene/AGENTS.md) describe travel semantics. - **Implemented:** `tests/animation_travel_test.cpp` (target - `animation_travel_test`, CTest `animationTravelTest`, 7/7 pass: + `animation_travel_test`, CTest `animationTravelTest`, 8/8 pass: BFS shortest/ties/cycles/unreachable, graph validation, JSON round - trip, backward-compatible load, degrade, cancel semantics, nested - markers defaulting). Demo probe `demoTaxi --headless - --test-anim-travel` (AnimTravelTestListener in demo_main.cpp): a - runtime-registered synthetic tree `test_travel_nested` (removed on - PASS, animation_tree.json untouched) on a bare runtime character - covers startState boot, hop-through with no insta-switch, setState - cancel, unreachable degrade and the nested rule-6 contract; 3/3 - stable passes, ~3.5 s wall. Full battery green 2026-10-28: + trip, link waitMode round trip + defaults, backward-compatible load, + degrade, cancel semantics, nested markers defaulting). Demo probe + `demoTaxi --headless --test-anim-travel` (AnimTravelTestListener in + demo_main.cpp): a runtime-registered synthetic tree + `test_travel_nested` (removed on PASS, animation_tree.json + untouched) on a bare runtime character covers startState boot, + hop-through with no insta-switch, setState cancel, unreachable + degrade, the nested rule-6 contract, immediate-vs-atEnd link timing + and the forced first hop. Full battery green 2026-10-28: demoTaxi --test-vehicle-turn / -coast / -righting / -determinism / -taxi-lowdetail / -population-switch / -switch / -vehicle-drive / -furniture / -motion / -detail / -taxi / -parking all PASS exit 0; @@ -4436,6 +4443,48 @@ feet sliding. ctest 6/6. Docs: this section + editScene/AGENTS.md ("Animation state machine travel (Phase 6.7)"). +**Task 6.7.6 — Per-link wait modes + first-hop escape. DONE +(2026-10-28).** + +- Give each `AnimationTreeLink` a wait mode ("atEnd" = rule 3 above, + the default/legacy contract; "immediate" = the hop fires on the + next runtime update without waiting the clip out), editable in the + node editor and validated on registry save; add a `travelTo` + escape hatch that forces the FIRST hop immediately. +- Mark the stock locomotion/driving links immediate: a 10.2 s looping + idle clip otherwise gates the first idle->walking hop for its whole + loop, which froze player locomotion for ~10 s per input (walking + arrived at frame 608 instead of frame 4). +- Done when: the stock male1*/nav-test_1 trees switch idle -> walking + -> running responsively, atEnd intermediate chains still wait for + the loop boundary, the escape hatch forces exactly one hop, the + boundary wait is robust for clips exactly N frames long, and the + player regression probe (player's exact tree + root motion) walks + immediately. +- **Implemented:** `AnimationTreeLink::waitMode` ("atEnd" default, + `isImmediate()` helper; JSON writes the key only when non-default, + load tolerates missing/unknown modes — unknown reads as atEnd), + `PendingTravel::forceFirst` consumed by the hop it forces, + `travelTo(..., immediateFirstHop=false)` (prefer configuring the + link; avoid the hatch unless there is no other way). + `processTravelCheck` looks up the link current -> next and hops now + when the link is immediate or the hatch is armed; missing link = + atEnd. Node editor: per-link Immediate/At-end combo; registry + editor save flags unknown waitMode values. Stock + animation_tree.json: all 92 links across the male1* + nav-test_1 + trees are immediate (locomotion, swim chain, driving). Boundary + robustness fix (hit by the new probe): the loop-boundary test + `t >= len*0.99` can be skipped forever when a clip is exactly N + frames long (float-accumulated t jumps from just below the window + straight past the wrap), so both the travel hop and the legacy + end-transition also fire when `t + dt >= len`. Verified: + `animation_travel_test` 8/8; `--test-anim-travel` PASS (immediate + hop in 1 frame, atEnd waits 606 frames for the 10.2 s idle loop, + forced first hop in 1 frame, post-force hop still waits 49 frames + for the 0.83 s running loop); the player-setup diagnostic walks at + frame 4 / runs at frame 6 (was 608 / 683); quick battery + (switch / vehicle-drive / taxi / parking) PASS exit 0. + #### Delivery order 1. 6.7.1 data model (runtime can already read links). @@ -4444,6 +4493,8 @@ feet sliding. 4. 6.7.4 controller migration, one system at a time, re-running the demo battery after each. 5. 6.7.5 final coverage + docs. +6. 6.7.6 per-link wait modes + the first-hop escape hatch (stock + trees immediate). #### Explicit non-goals (for later phases) diff --git a/src/features/editScene/demos/demo-taxi/animation_tree.json b/src/features/editScene/demos/demo-taxi/animation_tree.json index 16a0f0b..4d8ca30 100644 --- a/src/features/editScene/demos/demo-taxi/animation_tree.json +++ b/src/features/editScene/demos/demo-taxi/animation_tree.json @@ -93,51 +93,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -280,51 +292,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -467,51 +491,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -654,51 +690,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -841,51 +889,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -1028,51 +1088,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -1215,51 +1287,63 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "idle", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "walking", - "to": "driving" + "to": "driving", + "waitMode": "immediate" }, { "from": "driving", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "driving", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } @@ -1461,35 +1545,43 @@ "connectLinks": [ { "from": "idle", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "walking", - "to": "idle" + "to": "idle", + "waitMode": "immediate" }, { "from": "walking", - "to": "running" + "to": "running", + "waitMode": "immediate" }, { "from": "running", - "to": "walking" + "to": "walking", + "waitMode": "immediate" }, { "from": "swimming", - "to": "swimming-fast" + "to": "swimming-fast", + "waitMode": "immediate" }, { "from": "swimming-fast", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "walking", - "to": "swimming" + "to": "swimming", + "waitMode": "immediate" }, { "from": "swimming", - "to": "walking" + "to": "walking", + "waitMode": "immediate" } ] } diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index e3f38cd..009f47d 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -7501,7 +7501,7 @@ struct AnimTravelTestListener : public Ogre::FrameListener { EditorApp *app; int frame = 0; /* 0 spawn+boot, 1 travel, 2 cancel, 3 degrade, 4 nested, - * 5 done */ + * 5 wait modes, 6 forced first hop, 7 done */ int phase = 0; /* Substep within the current phase. */ int step = 0; @@ -7598,10 +7598,22 @@ struct AnimTravelTestListener : public Ogre::FrameListener { inner.startState = "idle-soft"; inner.endState = "walk-slow"; inner.connectLinks = { { "idle-soft", "walk-slow" }, - { "walk-slow", "run-fast" } }; + { "walk-slow", "run-fast" }, + /* Wait-mode coverage: the first + * link hops on the next update, + * the rest wait for the clip + * (loop boundary for loops). */ + { "idle-soft", "wm-a", + "immediate" }, + { "wm-a", "wm-b" }, + { "wm-b", "wm-c" }, + { "wm-c", "wm-a" } }; makeState(inner, "run-fast", "running"); makeState(inner, "walk-slow", "walking"); makeState(inner, "idle-soft", "idle"); + makeState(inner, "wm-a", "idle"); + makeState(inner, "wm-b", "walking"); + makeState(inner, "wm-c", "running"); AnimationTreeNode stand; stand.type = "state"; @@ -7671,7 +7683,7 @@ struct AnimTravelTestListener : public Ogre::FrameListener { bool frameRenderingQueued(const Ogre::FrameEvent &) override { frame++; - if (failed || phase == 5) { + if (failed || phase == 7) { app->getRoot()->queueEndRendering(); return true; } @@ -7696,6 +7708,12 @@ struct AnimTravelTestListener : public Ogre::FrameListener { case 4: phaseNested(); break; + case 5: + phaseWaitModes(); + break; + case 6: + phaseForcedFirst(); + break; } return true; } @@ -7921,14 +7939,6 @@ struct AnimTravelTestListener : public Ogre::FrameListener { "re-entered 'idle-soft'" << std::endl; enterStep(5, 0); - AnimationTreeRegistry::getSingleton() - .removeTree("test_travel_nested"); - std::cout << "[test] PASS: animation travel" - " (startState boot, hop-through," - " cancel, degrade, nested " - "endState hold)" - << std::endl; - app->getRoot()->queueEndRendering(); return; } if (s != "walk-slow") { @@ -7942,6 +7952,151 @@ struct AnimTravelTestListener : public Ogre::FrameListener { return; } } + + /* Wait-mode coverage on the nested "inner" machine: the + * idle-soft -> wm-a link is marked immediate, the rest are + * atEnd. */ + void phaseWaitModes() + { + AnimationTreeSystem *animSys = app->getAnimationTreeSystem(); + Ogre::String s = stateOf("inner"); + if (step == 0) { + /* Immediate link: the hop must land within a + * few frames instead of waiting out the idle + * clip's ~10 s loop. */ + animSys->travelTo(character, "inner", "wm-a"); + enterStep(5, 1); + return; + } + if (step == 1) { + if (s == "wm-a") { + std::cout << "[test] immediate link hopped " + "idle-soft -> wm-a in " + << (frame - stepFrame) + << " frames" << std::endl; + enterStep(5, 2); + return; + } + if (s != "idle-soft") { + fail("unexpected state '" + s + + "' during the immediate hop"); + return; + } + if (stepExpired()) + fail("immediate link never fired"); + return; + } + if (step == 2) { + /* atEnd link: no insta-switch, the hop waits + * for the wm-a clip (idle, ~10.2 s loop) to + * wrap. */ + animSys->travelTo(character, "inner", "wm-b"); + Ogre::String now = stateOf("inner"); + if (now != "wm-a") { + fail("travelTo switched immediately (got '" + + now + "')"); + return; + } + enterStep(5, 3); + return; + } + if (step == 3) { + if (s == "wm-b") { + int waited = frame - stepFrame; + if (waited < 300) { + fail("atEnd hop fired early after " + + std::to_string(waited) + + " frames"); + return; + } + std::cout << "[test] atEnd link waited " + << waited << " frames -> wm-b" + << std::endl; + enterStep(6, 0); + return; + } + if (s != "wm-a") { + fail("unexpected state '" + s + + "' during the atEnd wait"); + return; + } + if (stepExpired()) + fail("atEnd hop never fired"); + return; + } + } + + /* travelTo's immediateFirstHop escape hatch: the only path + * wm-b -> wm-c -> wm-a is entirely atEnd, so the forced first + * hop must reach wm-c at once while the following hop still + * waits for its clip (the flag is consumed). */ + void phaseForcedFirst() + { + AnimationTreeSystem *animSys = app->getAnimationTreeSystem(); + Ogre::String s = stateOf("inner"); + if (step == 0) { + animSys->travelTo(character, "inner", "wm-a", true); + enterStep(6, 1); + return; + } + if (step == 1) { + if (s == "wm-c") { + int waited = frame - stepFrame; + if (waited > 10) { + fail("forced first hop took " + + std::to_string(waited) + + " frames"); + return; + } + std::cout << "[test] forced first hop " + "landed 'wm-c' in " + << waited << " frames" << std::endl; + enterStep(6, 2); + return; + } + if (s != "wm-b") { + fail("unexpected state '" + s + + "' during the forced hop"); + return; + } + if (stepExpired()) + fail("forced first hop never fired"); + return; + } + if (step == 2) { + if (s == "wm-a") { + int waited = frame - stepFrame; + if (waited < 30) { + fail("second hop fired early after " + + std::to_string(waited) + + " frames (flag not consumed)"); + return; + } + std::cout << "[test] post-force hop waited " + << waited << " frames -> wm-a" + << std::endl; + AnimationTreeRegistry::getSingleton() + .removeTree("test_travel_nested"); + std::cout << "[test] PASS: animation travel " + "(startState boot, hop-through, " + "cancel, degrade, nested " + "endState hold, wait modes, " + "forced first hop)" + << std::endl; + enterStep(7, 0); + app->getRoot()->queueEndRendering(); + return; + } + if (s != "wm-c") { + fail("unexpected state '" + s + + "' during the post-force wait"); + return; + } + if (stepExpired()) + fail("post-force hop never fired"); + return; + } + } }; /* @@ -8932,7 +9087,7 @@ int main(int argc, char *argv[]) app.closeApp(); return 1; } - if (animTravelListener.phase != 5) { + if (animTravelListener.phase != 7) { std::cerr << "[test] FAIL: incomplete (anim " "travel phase " << animTravelListener.phase << ")" diff --git a/src/features/editScene/systems/AnimationTreeSystem.cpp b/src/features/editScene/systems/AnimationTreeSystem.cpp index 54cd3b4..6e94c7d 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.cpp +++ b/src/features/editScene/systems/AnimationTreeSystem.cpp @@ -929,7 +929,8 @@ void AnimationTreeSystem::checkEndTransitions(flecs::entity e, continue; if (check.travel) { - processTravelCheck(e, at, state, *smNode, stateName); + processTravelCheck(e, at, state, *smNode, stateName, + ctx.deltaTime); continue; } @@ -954,7 +955,10 @@ void AnimationTreeSystem::checkEndTransitions(flecs::entity e, float t = as->getTimePosition(); float len = as->getLength(); - if (len > 0.0f && t >= len * 0.99f) + /* Same boundary tolerance as the travel hop: the + * window can be narrower than a frame step. */ + if (len > 0.0f && (t >= len * 0.99f || + t + ctx.deltaTime >= len)) setStateInternal(e, at, state, smName, itTrans->second, true); } @@ -985,7 +989,8 @@ void AnimationTreeSystem::setState(flecs::entity e, void AnimationTreeSystem::travelTo(flecs::entity e, const Ogre::String &stateMachineName, - const Ogre::String &stateName) + const Ogre::String &stateName, + bool immediateFirstHop) { auto it = m_states.find(e.id()); if (it == m_states.end()) @@ -1025,6 +1030,15 @@ void AnimationTreeSystem::travelTo(flecs::entity e, } travelToInternal(e, at, it->second, *smNode, stateName); + /* The escape hatch rides on the (fresh or re-anchored) pending; + * travelToInternal reproduced the record when re-issued, so set + * the flag afterwards either way. */ + if (immediateFirstHop) { + auto itPending = it->second.pendingTravels.find( + stateMachineName); + if (itPending != it->second.pendingTravels.end()) + itPending->second.forceFirst = true; + } } void AnimationTreeSystem::setStateInternal(flecs::entity e, @@ -1216,7 +1230,7 @@ bool AnimationTreeSystem::travelToInternal( void AnimationTreeSystem::processTravelCheck( flecs::entity e, AnimationTreeComponent &at, EntityAnimTreeState &state, const AnimationTreeNode &smNode, - const Ogre::String &queuedState) + const Ogre::String &queuedState, float deltaTime) { (void)queuedState; @@ -1284,41 +1298,65 @@ void AnimationTreeSystem::processTravelCheck( return; } - /* Wait for the current hop's clip: one-shot end or a looping - * loop boundary (t >= len * 0.99). */ - const AnimationTreeNode *stNode = findStateNode(smNode, current); - Ogre::AnimationState *as = nullptr; - if (stNode) { - const AnimationTreeNode *animNode = - findAnimationNode(*stNode); - if (animNode) { - auto itAnim = state.animations.find( - animNode->animationName); - if (itAnim != state.animations.end()) - as = itAnim->second.ogreAnimState; + /* The hop's link decides when it may fire: the link + * current -> next is "immediate" (hop on the next update) or + * "atEnd" (wait for the current clip: one-shot end or a + * looping loop boundary, t >= len * 0.99). The one-shot + * travelTo escape hatch forces the first hop regardless of + * the link's mode; it is consumed by the hop that fires. */ + const Ogre::String next = itPending->second.path.empty() ? + itPending->second.target : + itPending->second.path.front(); + bool hopNow = itPending->second.forceFirst; + if (!hopNow) { + for (const auto &link : smNode.connectLinks) { + if (link.from == current && link.to == next) { + hopNow = link.isImmediate(); + break; + } } } - if (as) { - float t = as->getTimePosition(); - float len = as->getLength(); - if (len > 0.0001f && t < len * 0.99f) - return; /* hop still in flight */ + if (!hopNow) { + const AnimationTreeNode *stNode = + findStateNode(smNode, current); + Ogre::AnimationState *as = nullptr; + if (stNode) { + const AnimationTreeNode *animNode = + findAnimationNode(*stNode); + if (animNode) { + auto itAnim = state.animations.find( + animNode->animationName); + if (itAnim != state.animations.end()) + as = itAnim->second.ogreAnimState; + } + } + if (as) { + float t = as->getTimePosition(); + float len = as->getLength(); + /* The boundary window [len*0.99, len) can be + * narrower than a frame step (a clip exactly N + * frames long jumps past it on wrap), so also + * accept the frame that would carry t past + * len. */ + if (len > 0.0001f && t < len * 0.99f && + t + deltaTime < len) + return; /* hop still in flight */ + } + if (!as || as->getLength() <= 0.0001f) + warnTravelOnce(state, "travelNoClip", smNode.name, + current, + "travel waiting on a missing or " + "degenerate clip"); } - if (!as || as->getLength() <= 0.0001f) - warnTravelOnce(state, "travelNoClip", smNode.name, current, - "travel waiting on a missing or " - "degenerate clip"); /* Hop time: switch to the next route state; the record is * advanced BEFORE setStateInternal because it can re-enter * nested bookkeeping. */ PendingTravel advanced = itPending->second; - Ogre::String next; + advanced.forceFirst = false; if (advanced.path.empty()) { - next = advanced.target; state.pendingTravels.erase(itPending); } else { - next = advanced.path.front(); advanced.path.erase(advanced.path.begin()); advanced.fromState = next; itPending->second = advanced; diff --git a/src/features/editScene/systems/AnimationTreeSystem.hpp b/src/features/editScene/systems/AnimationTreeSystem.hpp index ceb595d..ac24acd 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.hpp +++ b/src/features/editScene/systems/AnimationTreeSystem.hpp @@ -41,17 +41,30 @@ public: /** * Phase 6.7 travel: walk the state machine's connect links from * its current state to @stateName, completing each intermediate - * clip (one-shot end / looping loop boundary at t >= len*0.99) - * before the next hop connects through the usual cross-fades. + * clip (one-shot end / looping loop boundary; observed within + * one frame step — the exact boundary frame can be skipped by + * time-advance aliasing) before the next hop connects through + * the usual cross-fades — UNLESS the link taken says otherwise: + * each AnimationTreeLink carries a waitMode, "atEnd" (the + * default above) or "immediate" (hop fires on the next update, + * no clip wait — use for locomotion/driving links; a looping + * idle clip would otherwise hold the hop for its whole length). * Ties break by state NAME (deterministic BFS). When no connect * path exists (no links, unreachable or unknown states) the * request degrades to the plain setState() switch — never an * error. Re-issuing the same target every frame is a cheap * no-op while the travel runs; a different target replaces the * pending path (recomputed from the CURRENT state). + * + * @param immediateFirstHop one-shot escape hatch for setups that + * cannot mark the anchor link "immediate": the FIRST hop fires + * regardless of the link's wait mode (consumed by that hop). + * Prefer configuring the link; avoid using this unless there is + * no other way. */ void travelTo(flecs::entity e, const Ogre::String &stateMachineName, - const Ogre::String &stateName); + const Ogre::String &stateName, + bool immediateFirstHop = false); /** * Find the Ogre entity with a skeleton for a given flecs entity. @@ -92,6 +105,10 @@ private: * excluded. */ std::vector path; Ogre::String target; + /* One-shot escape hatch (travelTo's immediateFirstHop): + * the FIRST hop fires regardless of the link's wait + * mode; the flag is consumed by that hop. */ + bool forceFirst = false; }; /* Phase 6.7: held deactivation of one state while its gated @@ -204,7 +221,8 @@ private: void processTravelCheck(flecs::entity e, AnimationTreeComponent &at, EntityAnimTreeState &state, const AnimationTreeNode &smNode, - const Ogre::String &queuedState); + const Ogre::String &queuedState, + float deltaTime); /* Arm nested exit gates: the outer machine is leaving * @heldState (oldState); gated nested machines underneath * travel to their endState first (rule 6). */ diff --git a/src/features/editScene/tests/animation_travel_test.cpp b/src/features/editScene/tests/animation_travel_test.cpp index 790b38c..670929e 100644 --- a/src/features/editScene/tests/animation_travel_test.cpp +++ b/src/features/editScene/tests/animation_travel_test.cpp @@ -6,7 +6,8 @@ * links, name tie-breaks, endpoint/unreachable contract) and * AnimTravel::validateStateMachine (link/markers error and warning * detection), plus the AnimationTreeNode connectLinks/startState/ - * endState JSON round trip. No OGRE runtime, no flecs. + * endState JSON round trip and the per-link waitMode serialization. + * No OGRE runtime, no flecs. */ #include @@ -279,6 +280,68 @@ int testJsonRoundTrip() return 0; } +int testWaitModeRoundTrip() +{ + TEST("link waitMode JSON round trip + defaults"); + + AnimationTreeNode sm; + sm.type = "stateMachine"; + sm.name = "locomotion"; + sm.connectLinks = { { "idle", "walking" }, + { "walking", "running", "immediate" }, + { "running", "walking", "atEnd" } }; + for (const char *sname : { "idle", "walking", "running" }) { + AnimationTreeNode st; + st.type = "state"; + st.name = sname; + sm.children.push_back(st); + } + + nlohmann::json json = animationTreeNodeToJson(sm); + const auto &linksJson = json["connectLinks"]; + if (linksJson.size() != 3) + FAIL("connectLinks count changed"); + /* The default mode is omitted so old files stay stable. */ + if (linksJson[0].contains("waitMode")) + FAIL("default waitMode was written"); + if (linksJson[1]["waitMode"] != "immediate") + FAIL("non-default waitMode was not written"); + if (linksJson[2].contains("waitMode")) + FAIL("explicit atEnd was written"); + + AnimationTreeNode back; + animationTreeNodeFromJson(back, json); + if (back.connectLinks.size() != 3) + FAIL("connectLinks count changed"); + if (back.connectLinks[0].waitMode != "atEnd" || + back.connectLinks[0].isImmediate()) + FAIL("missing waitMode did not default to atEnd"); + if (back.connectLinks[1].waitMode != "immediate" || + !back.connectLinks[1].isImmediate()) + FAIL("immediate mode did not survive the round trip"); + if (back.connectLinks[2].waitMode != "atEnd" || + back.connectLinks[2].isImmediate()) + FAIL("explicit atEnd did not survive the round trip"); + + /* An unknown mode loads verbatim but never reads as + * immediate: the runtime treats it like atEnd. */ + nlohmann::json weird = nlohmann::json::object(); + weird["from"] = "running"; + weird["to"] = "idle"; + weird["waitMode"] = "sometimes"; + nlohmann::json weirdNode = nlohmann::json::object(); + weirdNode["type"] = "stateMachine"; + weirdNode["connectLinks"] = nlohmann::json::array({ weird }); + animationTreeNodeFromJson(back, weirdNode); + if (back.connectLinks.size() != 1) + FAIL("the odd-mode link was not loaded"); + if (back.connectLinks[0].isImmediate()) + FAIL("unknown waitMode read as immediate"); + + PASS(); + return 0; +} + } // namespace int main() @@ -292,6 +355,7 @@ int main() failures += testValidationErrors(); failures += testValidationWarnings(); failures += testJsonRoundTrip(); + failures += testWaitModeRoundTrip(); std::printf("%d/%d tests passed\n", passCount, testCount); return failures ? 1 : 0; } diff --git a/src/features/editScene/ui/AnimationTreeNodeEditor.cpp b/src/features/editScene/ui/AnimationTreeNodeEditor.cpp index 061b83b..ed00485 100644 --- a/src/features/editScene/ui/AnimationTreeNodeEditor.cpp +++ b/src/features/editScene/ui/AnimationTreeNodeEditor.cpp @@ -395,7 +395,7 @@ void AnimationTreeNodeEditor::renderProperties( ImGui::Text("Travel"); ImGui::TextDisabled( "travelTo follows the connect links; " - "each hop plays its clip first"); + "each hop waits per the link mode"); /* Start state: empty = first state child. */ Ogre::String startPreview = @@ -456,7 +456,9 @@ void AnimationTreeNodeEditor::renderProperties( "before deactivation (rule 6); " "none = deactivate immediately."); - /* Existing links with remove buttons. */ + /* Existing links with remove buttons and a + * per-link wait mode (hop fires on the next + * update or when the current clip ends). */ for (size_t i = 0; i < node->connectLinks.size();) { auto &link = node->connectLinks[i]; @@ -465,6 +467,37 @@ void AnimationTreeNodeEditor::renderProperties( link.from.c_str(), link.to.c_str()); ImGui::SameLine(); + const char *modeLabel = + link.isImmediate() ? + "Immediate" : + "At end"; + if (ImGui::SmallButton(modeLabel)) + ImGui::OpenPopup("waitmode"); + if (ImGui::BeginPopup("waitmode")) { + if (ImGui::Selectable( + "Immediate", + link.isImmediate())) { + link.waitMode = "immediate"; + m_modified = true; + } + if (ImGui::Selectable( + "At end", + !link.isImmediate())) { + link.waitMode = "atEnd"; + m_modified = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Immediate: hop " + "fires on the next " + "update.\nAt end: " + "hop waits for the " + "current clip " + "(loop boundary " + "for loops)."); + ImGui::EndPopup(); + } + ImGui::SameLine(); if (ImGui::SmallButton("X")) { node->connectLinks.erase( node->connectLinks diff --git a/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp b/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp index b8e0d63..85a9839 100644 --- a/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp +++ b/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp @@ -41,6 +41,23 @@ static void validateRegistryTrees() sm->name + "': " + issue.message); } + /* Unknown wait modes would silently fall back + * to "atEnd" at runtime; flag them on save. */ + for (const auto &link : sm->connectLinks) { + if (link.waitMode == "atEnd" || + link.waitMode == "immediate") + continue; + Ogre::LogManager::getSingleton() + .logMessage("[anim-tree] save " + "error in tree '" + + name + "', machine '" + + sm->name + "': link '" + + link.from + "' -> '" + + link.to + + "' has unknown " + "waitMode '" + + link.waitMode + "'"); + } } } }