Phase 6.7 complete checkpoint
This commit is contained in:
@@ -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.
|
||||
@@ -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 + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user