Phase 6.7 complete checkpoint

This commit is contained in:
2026-10-28 12:43:16 +03:00
parent d0ca3d181a
commit 682ccee856
12 changed files with 677 additions and 165 deletions
+32 -13
View File
@@ -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)
@@ -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);
@@ -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";
}
};
/**
@@ -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.
+64 -13
View File
@@ -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)
@@ -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"
}
]
}
@@ -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 << ")"
@@ -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;
@@ -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<Ogre::String> 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). */
@@ -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 <cstdio>
@@ -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;
}
@@ -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
@@ -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 + "'");
}
}
}
}