Phase 6.7 complete checkpoint
This commit is contained in:
@@ -339,6 +339,13 @@ cd demos/demo-taxi
|
||||
# checks the live drop-off against the claimed park pose and carjacks
|
||||
# the car to verify fleet/claim release:
|
||||
./demoTaxi --headless --test-parking
|
||||
# ...or headless animation-travel probe (phase 6.7): on the default
|
||||
# interior scene, a synthetic registry tree probes the animation state
|
||||
# machine travel — startState boot marker, travelTo hop-through (no
|
||||
# insta-switch, each hop completes the current clip), the setState
|
||||
# cancel, the unreachable-target degrade to the plain switch and the
|
||||
# nested endState hold (see "Animation state machine travel" below):
|
||||
./demoTaxi --headless --test-anim-travel
|
||||
# ...or probe natural (non-forced) high-detail spawns from a given player
|
||||
# position; prints how many population NPCs are spawned plus per-NPC
|
||||
# entity/master-mesh/age/sex diagnostics at frame N:
|
||||
@@ -2065,6 +2072,82 @@ the same OgreBites input-listener chain).
|
||||
`AnimationTreeComponent::useRootMotion` is true.
|
||||
- `CharacterSystem` then applies that velocity to the Jolt physics character.
|
||||
|
||||
### Animation state machine travel (Phase 6.7)
|
||||
|
||||
`AnimationTreeSystem::travelTo(e, sm, target)` is the *guided* state
|
||||
change next to the plain `setState()` hard switch. It walks the
|
||||
machine's **connect links** — `AnimationTreeNode::connectLinks`, a list
|
||||
of directed `AnimationTreeLink{from, to}` pairs serialized in
|
||||
animation_tree.json — from the current state to the target, completing
|
||||
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.
|
||||
- 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
|
||||
state, so scene load/spawn/teleport just re-travels.
|
||||
- **No path = plain switch (rule 2).** Unknown states, no links or an
|
||||
unreachable target degrade to the plain `setState()` cross-fade —
|
||||
silently, never an error or a hang. Note the accepted behavior
|
||||
change: the degrade goes through `setStateInternal(reset=true)`, so
|
||||
a re-entered clip restarts at 0 instead of resuming.
|
||||
- **Override = cancel (rule 4).** Any `setState()` on that machine
|
||||
cancels its pending travel; a new `travelTo` while one is in flight
|
||||
replaces the pending path, recomputed from the CURRENT state (the
|
||||
in-flight hop finishes first). Re-issuing the same target every
|
||||
frame is a cheap no-op — controllers call it per frame safely.
|
||||
- **Robustness (rule 7):** deleted states/links, a lost machine
|
||||
entry or a vanished target cancel the travel and warn ONCE per
|
||||
(entity, reason, state) — the warning key is
|
||||
`"smName|reason|stateName"`, never per frame. A disabled tree
|
||||
degrades quietly (the disabled branch drops pendings anyway).
|
||||
- **Nested machines (rule 6):** `AnimationTreeNode::startState` /
|
||||
`endState` mark the entry/exit states of a machine used as a state
|
||||
of an upper machine (both optional, empty = legacy behavior).
|
||||
Activation latches the machine (`latchNestedMachine`): the first
|
||||
activation boots DIRECTLY in the start state (no cross-fade from
|
||||
nothing); a re-activation after a release cross-fades back into the
|
||||
start state. Leaving the host state DEFERS the deactivation while
|
||||
the nested machine travels to its end state — the old state's
|
||||
fadeOut weight is frozen so the nested machine keeps evaluating
|
||||
(`pendingExits`); the release re-checks the gate each frame and
|
||||
fails OPEN when a latched machine left its endState with no pending
|
||||
travel (it could never travel back — hang guard). With no end
|
||||
state marked, deactivation is immediate. Re-entering the held
|
||||
state cancels the hold; a fully-faded host state releases its
|
||||
nested machines so a later activation re-enters the start.
|
||||
|
||||
Controller call sites use `travelTo` for locomotion and vehicle
|
||||
enter/exit: `PlayerControllerSystem` (idle/walking/running + the swim
|
||||
chain), `VehicleControllerSystem` (idle <-> driving), `NpcTaxiSystem`
|
||||
(board/alight), `SmartObjectSystem::setLocomotionState` and
|
||||
`PathFollowingSystem::applyLocomotionState`. Plain `setState` stays
|
||||
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
|
||||
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
|
||||
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).
|
||||
|
||||
### Character facing convention (frontAxis)
|
||||
|
||||
The catalog character meshes face **+Z** in model space, and the locomotion
|
||||
|
||||
@@ -76,6 +76,7 @@ set(EDITSCENE_SOURCES
|
||||
systems/MarkovNameGenerator.cpp
|
||||
systems/PregnancySystem.cpp
|
||||
systems/AnimationTreeSystem.cpp
|
||||
systems/AnimTravel.cpp
|
||||
systems/HairPhysicsSystem.cpp
|
||||
systems/BehaviorTreeSystem.cpp
|
||||
systems/NavMeshSystem.cpp
|
||||
@@ -834,6 +835,30 @@ add_test(NAME globalStateTest
|
||||
COMMAND global_state_test
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Phase 6.7 animation travel helpers (headless)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure BFS pathing + connect-link validation over the AnimationTreeLink
|
||||
# model; only AnimTravel.cpp and the tree component are linked.
|
||||
add_executable(animation_travel_test
|
||||
tests/animation_travel_test.cpp
|
||||
systems/AnimTravel.cpp
|
||||
components/AnimationTree.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(animation_travel_test
|
||||
OgreMain
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
target_include_directories(animation_travel_test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
|
||||
add_test(NAME animationTravelTest
|
||||
COMMAND animation_travel_test
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Furniture definition schema (phase 6.5, headless)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,20 @@ nlohmann::json animationTreeNodeToJson(const AnimationTreeNode &node)
|
||||
trans[pair.first] = pair.second;
|
||||
json["endTransitions"] = trans;
|
||||
}
|
||||
if (!node.connectLinks.empty()) {
|
||||
nlohmann::json links = nlohmann::json::array();
|
||||
for (const auto &link : node.connectLinks) {
|
||||
nlohmann::json lj = nlohmann::json::object();
|
||||
lj["from"] = link.from;
|
||||
lj["to"] = link.to;
|
||||
links.push_back(lj);
|
||||
}
|
||||
json["connectLinks"] = links;
|
||||
}
|
||||
if (!node.startState.empty())
|
||||
json["startState"] = node.startState;
|
||||
if (!node.endState.empty())
|
||||
json["endState"] = node.endState;
|
||||
if (!node.children.empty()) {
|
||||
json["children"] = nlohmann::json::array();
|
||||
for (const auto &child : node.children)
|
||||
@@ -50,6 +64,26 @@ void animationTreeNodeFromJson(AnimationTreeNode &node,
|
||||
it != json["endTransitions"].end(); ++it)
|
||||
node.endTransitions[it.key()] = it.value();
|
||||
}
|
||||
/* Phase 6.7: connect links + start/end markers. Files without
|
||||
* these keys keep their defaults (no links, legacy init). */
|
||||
node.connectLinks.clear();
|
||||
node.startState.clear();
|
||||
node.endState.clear();
|
||||
if (json.contains("connectLinks") &&
|
||||
json["connectLinks"].is_array()) {
|
||||
for (const auto &linkJson : json["connectLinks"]) {
|
||||
if (!linkJson.is_object())
|
||||
continue;
|
||||
AnimationTreeLink link;
|
||||
link.from = linkJson.value("from", "");
|
||||
link.to = linkJson.value("to", "");
|
||||
if (link.from.empty() || link.to.empty())
|
||||
continue;
|
||||
node.connectLinks.push_back(link);
|
||||
}
|
||||
}
|
||||
node.startState = json.value("startState", "");
|
||||
node.endState = json.value("endState", "");
|
||||
node.children.clear();
|
||||
if (json.contains("children") && json["children"].is_array()) {
|
||||
for (const auto &childJson : json["children"]) {
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
|
||||
/**
|
||||
* A directed connect link between two states of one state machine
|
||||
* ("B may follow A", Phase 6.7 travel).
|
||||
*/
|
||||
struct AnimationTreeLink {
|
||||
Ogre::String from;
|
||||
Ogre::String to;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node in the animation tree.
|
||||
*
|
||||
@@ -25,6 +34,15 @@ struct AnimationTreeNode {
|
||||
std::vector<AnimationTreeNode> children;
|
||||
/* For stateMachine nodes: auto-transition when animation ends */
|
||||
std::unordered_map<Ogre::String, Ogre::String> endTransitions;
|
||||
/* For stateMachine nodes: directed travel links (Phase 6.7) */
|
||||
std::vector<AnimationTreeLink> connectLinks;
|
||||
/* For stateMachine nodes: state entered on activation (empty =
|
||||
* first "state" child, legacy behavior) */
|
||||
Ogre::String startState;
|
||||
/* For stateMachine nodes used as a nested machine (a state child
|
||||
* of an upper machine): state the machine must reach before the
|
||||
* upper node may deactivate (empty = immediate, legacy behavior) */
|
||||
Ogre::String endState;
|
||||
|
||||
AnimationTreeNode() = default;
|
||||
|
||||
|
||||
@@ -4209,10 +4209,15 @@ DONE.**
|
||||
|
||||
### Phase 6.7 — Animation state machine travel
|
||||
|
||||
**Status: NOT STARTED (design decomposed 2026-10-28).** Scope: the
|
||||
animation tree (registry, runtime, editor) and every controller that
|
||||
switches states. Robustness and tests are explicit requirements, not
|
||||
afterthoughts.
|
||||
**Status: DONE (2026-10-28) — 6.7.1 through 6.7.5 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
|
||||
`--test-anim-travel` probe and the full demo battery.**
|
||||
|
||||
#### The idea (plain words)
|
||||
|
||||
@@ -4291,7 +4296,8 @@ feet sliding.
|
||||
|
||||
#### Tasks
|
||||
|
||||
**Task 6.7.1 — Connect links & markers (data model + editor).**
|
||||
**Task 6.7.1 — Connect links & markers (data model + editor). DONE
|
||||
(2026-10-28).**
|
||||
|
||||
- Add `connectLinks` (directed pairs) and the optional `startState` /
|
||||
`endState` markers to `AnimationTreeNode`; serialize them in
|
||||
@@ -4303,8 +4309,21 @@ feet sliding.
|
||||
- Done when: a hand-built tree round-trips links+markers through
|
||||
save/load, invalid entries are rejected with a clear message, and
|
||||
the demo's existing trees load unchanged.
|
||||
- **Implemented:** `AnimationTreeLink{from,to}` +
|
||||
`connectLinks`/`startState`/`endState` on
|
||||
`AnimationTreeNode` (components/AnimationTree.hpp); JSON free
|
||||
functions `animationTreeNodeToJson` / `animationTreeNodeFromJson`
|
||||
round-trip everything and tolerate old files without the new keys
|
||||
(links/markers default empty). Node editor: a Travel section
|
||||
(ui/AnimationTreeNodeEditor.cpp) listing per-machine links with
|
||||
add/remove and start/end dropdowns; the registry editor validates on
|
||||
save (unknown state names rejected, unreachable states + a nested
|
||||
machine with no inbound link warn, duplicate links rejected).
|
||||
Verified by `animation_travel_test` JSON round-trip + validation
|
||||
cases; the stock animation_tree.json loads unchanged (links added to
|
||||
it explicitly in 6.7.4).
|
||||
|
||||
**Task 6.7.2 — Travel runtime (`travelTo`).**
|
||||
**Task 6.7.2 — Travel runtime (`travelTo`). DONE (2026-10-28).**
|
||||
|
||||
- Implement path search + the per-entity/per-machine pending-travel
|
||||
state and the hop advancement rules 1-5 above, next to `setState`
|
||||
@@ -4314,8 +4333,25 @@ feet sliding.
|
||||
end-of-clip timing (one-shot AND looping), the no-path fallback and
|
||||
command replacement mid-travel; no per-frame allocations in the
|
||||
steady state.
|
||||
- **Implemented:** pure BFS in `AnimTravel` (`findTravelPath`,
|
||||
`validateTravelGraph` — shortest path, ties by state NAME,
|
||||
cycles/unreachables handled; systems/AnimTravel.hpp/.cpp). Runtime:
|
||||
`AnimationTreeSystem::travelTo()` + `travelToInternal` anchor a
|
||||
pending path per machine; `processTravelCheck` advances one hop per
|
||||
completed clip (loop boundary `t >= len*0.99`, shared end-of-clip
|
||||
detection with the legacy end-transition queue — a travel hop has
|
||||
priority over a stale end-transition while in flight); arrival =
|
||||
the target fades in and loops (rule 5). Rule 2 degrade = plain
|
||||
`setStateInternal(..., reset=true)`, silent; rule 4 = a re-issue
|
||||
recomputes the path from the CURRENT state and a `setState` cancels
|
||||
that machine's pending travel (accepted behavior change: a degraded
|
||||
re-entry restarts the clip at 0). Robustness anomalies (rule 7)
|
||||
cancel the travel with ONE warning per (entity, reason, state),
|
||||
keyed `"smName|reason|stateName"`. Probes assert ORDER (not
|
||||
frames): pending no-insta-switch, idle->walking->running hop order,
|
||||
setState cancel, unreachable degrade, replacement re-issue.
|
||||
|
||||
**Task 6.7.3 — Nested start/end states.**
|
||||
**Task 6.7.3 — Nested start/end states. DONE (2026-10-28).**
|
||||
|
||||
- Implement activation/deactivation gating for nested machines
|
||||
(rule 6), including "already active" re-entry and aborting a pending
|
||||
@@ -4323,8 +4359,26 @@ feet sliding.
|
||||
- Done when: the probe shows enter->start, exit->travel-to-end->fade-
|
||||
out, reactivation during the exit aborting the exit, and immediate
|
||||
deactivation when no end state is marked.
|
||||
- **Implemented:** first evaluation of a nested machine latches it
|
||||
(`latchNestedMachine`) — boots directly in its `startState` (or the
|
||||
first "state" child) without a cross-fade, re-enters through the
|
||||
normal cross-fade (`setStateInternal(reset=true)`) when an entry
|
||||
survives a release. Leaving the host state defers the deactivation
|
||||
(`deferNestedExit`): every gated machine under the held state first
|
||||
travels to its `endState` while the old state's fadeOut weight is
|
||||
frozen (`pendingExits`); `releasePendingExits` re-checks the gate
|
||||
each frame and FAILS OPEN when a latched machine left its endState
|
||||
with no pending travel (it can never travel back — no hang). No
|
||||
`endState` = immediate deactivation (legacy). Re-entering the held
|
||||
state cancels the hold (machines keep their latched state);
|
||||
a fully-faded state releases its nested machines
|
||||
(`releaseNestedUnder`) so a later activation re-enters the start.
|
||||
Probe coverage: nested latch enters startState (not the first
|
||||
child), held machine travels to endState, re-activation re-enters
|
||||
startState; a machine without an endState deactivates immediately
|
||||
(covered by the unit-test tree without markers).
|
||||
|
||||
**Task 6.7.4 — Controller migration.**
|
||||
**Task 6.7.4 — Controller migration. DONE (2026-10-28).**
|
||||
|
||||
- Switch the call sites listed above to `travelTo` for LOCOMOTION
|
||||
(idle/walk/run/swim), vehicle enter/exit and smart-object actions;
|
||||
@@ -4334,8 +4388,27 @@ feet sliding.
|
||||
visible on day one.
|
||||
- Done when: the full demo battery passes unchanged and a visual
|
||||
walk/run/stop cycle shows no state popping (screenshot/video check).
|
||||
- **Implemented:** migrated to `travelTo` —
|
||||
PlayerControllerSystem locomotion (idle/walking/running/swim chain;
|
||||
per-frame re-issue is a cheap no-op mid-flight),
|
||||
VehicleControllerSystem enter/exit (idle <-> driving),
|
||||
NpcTaxiSystem board/alight, SmartObjectSystem::setLocomotionState,
|
||||
PathFollowingSystem::applyLocomotionState. Kept plain `setState`
|
||||
intentionally: NpcTrafficSystem's per-frame "driving" re-assert (its
|
||||
postPhysicsUpdate re-latch must hard-switch, not queue) and
|
||||
BehaviorTreeSystem. Spawn/teleport/scene-load resets stay plain
|
||||
`setState`. Stock animation_tree.json: every nested `locomotion`
|
||||
machine got `startState: "idle"` + 9 connect links (idle<->walking,
|
||||
walking<->running, idle<->swimming, swimming<->swimming-fast,
|
||||
walking->swimming, swimming->walking); swim-idle (treading_water)
|
||||
has no inbound link — advisories warn on save, the degrade keeps it
|
||||
reachable by plain switch. Battery: all demoTaxi probes exit 0
|
||||
(switch/vehicle-drive/turn/coast/righting/determinism/taxi-lowdetail/
|
||||
population-switch/parking/furniture/motion/detail/anim-travel);
|
||||
visual pop check deferred to windowed runs (headless covers order +
|
||||
timing).
|
||||
|
||||
**Task 6.7.5 — Test harness & regression coverage.**
|
||||
**Task 6.7.5 — Test harness & regression coverage. DONE (2026-10-28).**
|
||||
|
||||
- Pure: path search (shortest, ties, unreachable, cycles), JSON round-
|
||||
trip, editor validation.
|
||||
@@ -4345,6 +4418,23 @@ feet sliding.
|
||||
- Done when: the new probes plus the terrain suite,
|
||||
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:
|
||||
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:
|
||||
demoTaxi --test-vehicle-turn / -coast / -righting / -determinism /
|
||||
-taxi-lowdetail / -population-switch / -switch / -vehicle-drive /
|
||||
-furniture / -motion / -detail / -taxi / -parking all PASS exit 0;
|
||||
terrain suite (`--run-terrain-tests=1`) ALL PASSED;
|
||||
ctest 6/6. Docs: this section + editScene/AGENTS.md
|
||||
("Animation state machine travel (Phase 6.7)").
|
||||
|
||||
#### Delivery order
|
||||
|
||||
|
||||
@@ -88,7 +88,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -224,7 +275,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -360,7 +462,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -496,7 +649,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -632,7 +836,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -768,7 +1023,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -904,7 +1210,58 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "driving"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "driving",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -1099,7 +1456,42 @@
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
"type": "stateMachine"
|
||||
"type": "stateMachine",
|
||||
"startState": "idle",
|
||||
"connectLinks": [
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "idle"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "running"
|
||||
},
|
||||
{
|
||||
"from": "running",
|
||||
"to": "walking"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "swimming-fast"
|
||||
},
|
||||
{
|
||||
"from": "swimming-fast",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "walking",
|
||||
"to": "swimming"
|
||||
},
|
||||
{
|
||||
"from": "swimming",
|
||||
"to": "walking"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "locomotion",
|
||||
@@ -1253,4 +1645,4 @@
|
||||
}
|
||||
],
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
#include "systems/ActuatorSystem.hpp"
|
||||
#include "components/CellGrid.hpp"
|
||||
#include "components/Actuator.hpp"
|
||||
#include "components/AnimationTree.hpp"
|
||||
#include "components/CharacterSpawner.hpp"
|
||||
#include "components/RuntimeMarker.hpp"
|
||||
#include "systems/AnimationTreeSystem.hpp"
|
||||
#include "systems/AnimationTreeRegistry.hpp"
|
||||
#include "systems/CharacterSpawnerSystem.hpp"
|
||||
#include <cfloat>
|
||||
#include "systems/NpcPopulationSystem.hpp"
|
||||
#include "systems/NpcMotionSystem.hpp"
|
||||
#include "systems/NpcDetailSystem.hpp"
|
||||
@@ -7446,6 +7453,497 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* --test-anim-travel (Phase 6.7): animation state machine travel on a
|
||||
* synthetic registry tree, on the default interior scene (no town
|
||||
* population systems, no terrain wait). A bare runtime character
|
||||
* (registry id in the runtime band, nude layer-0 fallback - still
|
||||
* builds skeleton + animations) receives an AnimationTreeComponent
|
||||
* for the tree "test_travel_nested":
|
||||
*
|
||||
* output
|
||||
* +- stateMachine "locomotion" (startState "idle"; links
|
||||
* | idle<->walking, walking<->running)
|
||||
* +- state "crouch" -> idle (FIRST child: without the
|
||||
* | startState marker the fresh
|
||||
* | boot would enter this)
|
||||
* +- state "idle" -> idle
|
||||
* +- state "walking" -> walking
|
||||
* +- state "running" -> running
|
||||
* +- state "stand" -> nested stateMachine "inner"
|
||||
* (startState "idle-soft", endState "walk-slow";
|
||||
* children run-fast, walk-slow, idle-soft;
|
||||
* links idle-soft->walk-slow, walk-slow->run-fast)
|
||||
*
|
||||
* crouch and stand carry no connect links, so they are reachable
|
||||
* only through the rule-2 degrade or a plain setState.
|
||||
*
|
||||
* Phases (assert ORDER, never exact frames; each hop waits for the
|
||||
* current clip's loop boundary, so timings follow the clip lengths):
|
||||
* 0. boot: locomotion enters the startState "idle", not the first
|
||||
* child "crouch".
|
||||
* 1. travelTo("running") from idle: still "idle" immediately (the
|
||||
* travel is pending - no insta-switch), then the first hop is
|
||||
* "walking" (not "running"), then arrival at "running".
|
||||
* 2. cancel: travelTo("idle") + idempotent re-issue, then
|
||||
* setState("running") cancels the pending travel - the machine
|
||||
* must stay "running" for hundreds of frames.
|
||||
* 3. degrade: travelTo("crouch") (known, unreachable from
|
||||
* "running") switches synchronously - no waiting.
|
||||
* 4. nested: setState("stand") boots "inner" at its startState
|
||||
* "idle-soft" (not its first child "run-fast"); leaving towards
|
||||
* "crouch" holds the deactivation while "inner" travels to its
|
||||
* endState (still "idle-soft" a few frames after the switch,
|
||||
* then "walk-slow"); re-activating "stand" re-enters
|
||||
* "idle-soft".
|
||||
*/
|
||||
struct AnimTravelTestListener : public Ogre::FrameListener {
|
||||
EditorApp *app;
|
||||
int frame = 0;
|
||||
/* 0 spawn+boot, 1 travel, 2 cancel, 3 degrade, 4 nested,
|
||||
* 5 done */
|
||||
int phase = 0;
|
||||
/* Substep within the current phase. */
|
||||
int step = 0;
|
||||
/* Frame the current (phase, step) was entered at. */
|
||||
int stepFrame = 0;
|
||||
bool failed = false;
|
||||
Ogre::String failReason;
|
||||
|
||||
flecs::entity spawner;
|
||||
flecs::entity character;
|
||||
|
||||
/* Runtime-band registry id (ensureRuntimeCharacter refuses ids
|
||||
* below 1<<40); area slot 500 is never generated by the town
|
||||
* population. */
|
||||
static const uint64_t kProbeId = (1ull << 40) + (500ull << 20) + 1;
|
||||
/* Generous per-step frame budget at the fixed 1/60 dt. */
|
||||
static const int kStepBudget = 3000;
|
||||
|
||||
explicit AnimTravelTestListener(EditorApp *a)
|
||||
: app(a)
|
||||
{
|
||||
}
|
||||
|
||||
void fail(const Ogre::String &msg)
|
||||
{
|
||||
failed = true;
|
||||
failReason = "phase " + std::to_string(phase) + ": " + msg;
|
||||
std::cout << "[test] anim-travel FAIL: " << failReason
|
||||
<< std::endl;
|
||||
app->getRoot()->queueEndRendering();
|
||||
}
|
||||
|
||||
bool stepExpired() const
|
||||
{
|
||||
return frame - stepFrame > kStepBudget;
|
||||
}
|
||||
|
||||
void enterStep(int newPhase, int newStep)
|
||||
{
|
||||
phase = newPhase;
|
||||
step = newStep;
|
||||
stepFrame = frame;
|
||||
}
|
||||
|
||||
Ogre::String stateOf(const char *machine)
|
||||
{
|
||||
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
|
||||
if (!animSys || !character.is_alive())
|
||||
return "";
|
||||
return animSys->getCurrentState(character, machine);
|
||||
}
|
||||
|
||||
static void makeState(AnimationTreeNode &parent, const char *name,
|
||||
const char *anim)
|
||||
{
|
||||
AnimationTreeNode st;
|
||||
st.type = "state";
|
||||
st.name = name;
|
||||
AnimationTreeNode leaf;
|
||||
leaf.type = "animation";
|
||||
leaf.animationName = anim;
|
||||
st.children.push_back(leaf);
|
||||
parent.children.push_back(st);
|
||||
}
|
||||
|
||||
void registerTree()
|
||||
{
|
||||
/* Synthetic tree, registered at runtime so the stock
|
||||
* animation_tree.json stays untouched (removed again
|
||||
* on PASS). */
|
||||
AnimationTreeRegistry::AnimationTreeDefinition def;
|
||||
def.name = "test_travel_nested";
|
||||
|
||||
AnimationTreeNode output;
|
||||
output.type = "output";
|
||||
output.name = "output";
|
||||
|
||||
AnimationTreeNode loco;
|
||||
loco.type = "stateMachine";
|
||||
loco.name = "locomotion";
|
||||
loco.startState = "idle";
|
||||
loco.connectLinks = { { "idle", "walking" },
|
||||
{ "walking", "idle" },
|
||||
{ "walking", "running" },
|
||||
{ "running", "walking" } };
|
||||
makeState(loco, "crouch", "idle");
|
||||
makeState(loco, "idle", "idle");
|
||||
makeState(loco, "walking", "walking");
|
||||
makeState(loco, "running", "running");
|
||||
|
||||
AnimationTreeNode inner;
|
||||
inner.type = "stateMachine";
|
||||
inner.name = "inner";
|
||||
inner.startState = "idle-soft";
|
||||
inner.endState = "walk-slow";
|
||||
inner.connectLinks = { { "idle-soft", "walk-slow" },
|
||||
{ "walk-slow", "run-fast" } };
|
||||
makeState(inner, "run-fast", "running");
|
||||
makeState(inner, "walk-slow", "walking");
|
||||
makeState(inner, "idle-soft", "idle");
|
||||
|
||||
AnimationTreeNode stand;
|
||||
stand.type = "state";
|
||||
stand.name = "stand";
|
||||
stand.children.push_back(inner);
|
||||
loco.children.push_back(stand);
|
||||
|
||||
output.children.push_back(loco);
|
||||
def.root = output;
|
||||
AnimationTreeRegistry::getSingleton().registerTree(def);
|
||||
}
|
||||
|
||||
void spawnProbe()
|
||||
{
|
||||
CharacterRegistry ® = CharacterRegistry::getSingleton();
|
||||
if (reg.ensureRuntimeCharacter(kProbeId, "Travel", "Test") ==
|
||||
0) {
|
||||
fail("could not create the runtime character");
|
||||
return;
|
||||
}
|
||||
CharacterRegistry::CharacterRecord *rec =
|
||||
reg.findCharacter(kProbeId);
|
||||
if (!rec) {
|
||||
fail("probe record missing");
|
||||
return;
|
||||
}
|
||||
rec->inlineSex = "male";
|
||||
rec->age = "adult";
|
||||
/* Catalog convention: the meshes face +Z. */
|
||||
rec->frontAxis = Ogre::Vector3::UNIT_Z;
|
||||
|
||||
/* Hidden spawner, same pattern as
|
||||
* NpcDetailSystem::createSpawner: the automatic distance
|
||||
* gate never fires; the probe spawns manually. */
|
||||
spawner = app->getWorld()->entity();
|
||||
spawner.add<RuntimeMarkerComponent>();
|
||||
TransformComponent t;
|
||||
t.node = app->getSceneManager()
|
||||
->getRootSceneNode()
|
||||
->createChildSceneNode();
|
||||
t.position = Ogre::Vector3(0.5f, 0.2f, 0.5f);
|
||||
t.applyToNode();
|
||||
spawner.set<TransformComponent>(t);
|
||||
CharacterSpawnerComponent sp;
|
||||
sp.registryId = kProbeId;
|
||||
sp.spawnDistanceSq = -1.0f;
|
||||
sp.despawnDistanceSq = FLT_MAX;
|
||||
spawner.set<CharacterSpawnerComponent>(sp);
|
||||
|
||||
app->getCharacterSpawnerSystem()->spawn(spawner);
|
||||
character = app->getCharacterSpawnerSystem()
|
||||
->getSpawnedCharacter(spawner);
|
||||
if (!character.is_alive()) {
|
||||
fail("spawn produced no character");
|
||||
return;
|
||||
}
|
||||
|
||||
/* The spawner path adds no AnimationTreeComponent - the
|
||||
* probe sets its own synthetic tree. */
|
||||
AnimationTreeComponent atc;
|
||||
atc.treeName = "test_travel_nested";
|
||||
atc.useRootMotion = false;
|
||||
atc.dirty = true;
|
||||
character.set<AnimationTreeComponent>(atc);
|
||||
}
|
||||
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
frame++;
|
||||
if (failed || phase == 5) {
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
if (frame > 20000) {
|
||||
fail("global timeout");
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 0:
|
||||
phaseBoot();
|
||||
break;
|
||||
case 1:
|
||||
phaseTravel();
|
||||
break;
|
||||
case 2:
|
||||
phaseCancel();
|
||||
break;
|
||||
case 3:
|
||||
phaseDegrade();
|
||||
break;
|
||||
case 4:
|
||||
phaseNested();
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void phaseBoot()
|
||||
{
|
||||
if (frame == 1) {
|
||||
registerTree();
|
||||
spawnProbe();
|
||||
return;
|
||||
}
|
||||
Ogre::String s = stateOf("locomotion");
|
||||
if (s == "crouch") {
|
||||
fail("boot picked the first child, not the startState");
|
||||
return;
|
||||
}
|
||||
if (s == "idle") {
|
||||
std::cout << "[test] boot at startState 'idle'"
|
||||
<< std::endl;
|
||||
enterStep(1, 0);
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("locomotion never booted");
|
||||
}
|
||||
|
||||
void phaseTravel()
|
||||
{
|
||||
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
|
||||
Ogre::String s = stateOf("locomotion");
|
||||
if (step == 0) {
|
||||
/* Issue and verify: no insta-switch. */
|
||||
animSys->travelTo(character, "locomotion", "running");
|
||||
Ogre::String now = stateOf("locomotion");
|
||||
if (s != "idle") {
|
||||
fail("travel issued from unexpected state '" +
|
||||
s + "'");
|
||||
return;
|
||||
}
|
||||
if (now != "idle") {
|
||||
fail("travelTo switched immediately (got '" +
|
||||
now + "')");
|
||||
return;
|
||||
}
|
||||
enterStep(1, 1);
|
||||
return;
|
||||
}
|
||||
if (step == 1) {
|
||||
if (s == "walking") {
|
||||
enterStep(1, 2);
|
||||
return;
|
||||
}
|
||||
if (s == "running") {
|
||||
fail("travel skipped the walking hop");
|
||||
return;
|
||||
}
|
||||
if (s != "idle") {
|
||||
fail("unexpected state '" + s +
|
||||
"' during travel");
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("first hop never fired");
|
||||
return;
|
||||
}
|
||||
/* step 2: wait for the arrival. */
|
||||
if (s == "running") {
|
||||
std::cout << "[test] travel hopped walking -> arrived"
|
||||
" at 'running'"
|
||||
<< std::endl;
|
||||
enterStep(2, 0);
|
||||
return;
|
||||
}
|
||||
if (s != "walking" && s != "idle") {
|
||||
fail("unexpected state '" + s + "' before arrival");
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("travel never arrived at 'running'");
|
||||
}
|
||||
|
||||
void phaseCancel()
|
||||
{
|
||||
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
|
||||
Ogre::String s = stateOf("locomotion");
|
||||
if (step == 0) {
|
||||
/* Anchor a pending travel, then re-issue: the
|
||||
* re-anchor reproduces the same record from the
|
||||
* CURRENT state (cheap, deterministic). */
|
||||
animSys->travelTo(character, "locomotion", "idle");
|
||||
animSys->travelTo(character, "locomotion", "idle");
|
||||
enterStep(2, 1);
|
||||
return;
|
||||
}
|
||||
if (step == 1) {
|
||||
/* External override wins (rule 4): cancels the
|
||||
* pending travel for that machine. */
|
||||
animSys->setState(character, "locomotion", "running");
|
||||
enterStep(2, 2);
|
||||
return;
|
||||
}
|
||||
/* step 2: the cancelled travel must not fire a delayed
|
||||
* hop. */
|
||||
if (s != "running") {
|
||||
fail("cancelled travel still fired (state '" + s +
|
||||
"')");
|
||||
return;
|
||||
}
|
||||
if (frame - stepFrame >= 500) {
|
||||
std::cout << "[test] setState cancelled the pending"
|
||||
" travel"
|
||||
<< std::endl;
|
||||
enterStep(3, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void phaseDegrade()
|
||||
{
|
||||
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
|
||||
Ogre::String s = stateOf("locomotion");
|
||||
if (step != 0)
|
||||
return;
|
||||
if (s != "running") {
|
||||
fail("degrade expected 'running', got '" + s + "'");
|
||||
return;
|
||||
}
|
||||
/* Known but unreachable target: rule 2 degrades to the
|
||||
* plain switch - synchronously, no pending travel. */
|
||||
animSys->travelTo(character, "locomotion", "crouch");
|
||||
Ogre::String now = stateOf("locomotion");
|
||||
if (now != "crouch") {
|
||||
fail("unreachable travelTo did not degrade (got '" +
|
||||
now + "')");
|
||||
return;
|
||||
}
|
||||
std::cout << "[test] unreachable travel degraded to 'crouch'"
|
||||
<< std::endl;
|
||||
enterStep(4, 0);
|
||||
}
|
||||
|
||||
void phaseNested()
|
||||
{
|
||||
AnimationTreeSystem *animSys = app->getAnimationTreeSystem();
|
||||
Ogre::String s = stateOf("inner");
|
||||
if (step == 0) {
|
||||
/* Leave "crouch" for "stand": latches the nested
|
||||
* machine. */
|
||||
animSys->setState(character, "locomotion", "stand");
|
||||
enterStep(4, 1);
|
||||
return;
|
||||
}
|
||||
if (step == 1) {
|
||||
if (s == "run-fast") {
|
||||
fail("nested latch picked the first child, "
|
||||
"not the startState");
|
||||
return;
|
||||
}
|
||||
if (s == "idle-soft") {
|
||||
std::cout << "[test] nested latch entered "
|
||||
"startState 'idle-soft'"
|
||||
<< std::endl;
|
||||
enterStep(4, 2);
|
||||
return;
|
||||
}
|
||||
if (!s.empty()) {
|
||||
fail("unexpected nested state '" + s + "'");
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("nested machine never latched");
|
||||
return;
|
||||
}
|
||||
if (step == 2) {
|
||||
/* A few frames of stand-time, then leave towards
|
||||
* "crouch": the deactivation must hold while
|
||||
* "inner" travels to its endState. */
|
||||
if (frame - stepFrame < 10)
|
||||
return;
|
||||
animSys->setState(character, "locomotion", "crouch");
|
||||
enterStep(4, 3);
|
||||
return;
|
||||
}
|
||||
if (step == 3) {
|
||||
if (s.empty()) {
|
||||
fail("nested machine was released instead "
|
||||
"of travelling to its endState");
|
||||
return;
|
||||
}
|
||||
if (s == "walk-slow") {
|
||||
std::cout << "[test] held machine travelled "
|
||||
"to endState 'walk-slow'"
|
||||
<< std::endl;
|
||||
enterStep(4, 4);
|
||||
return;
|
||||
}
|
||||
if (s != "idle-soft") {
|
||||
fail("unexpected nested state '" + s +
|
||||
"' during the hold");
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("nested travel to endState never fired");
|
||||
return;
|
||||
}
|
||||
if (step == 4) {
|
||||
/* Let the release + fade settle, then
|
||||
* re-activate "stand": the machine re-enters its
|
||||
* startState. */
|
||||
if (frame - stepFrame < 60)
|
||||
return;
|
||||
animSys->setState(character, "locomotion", "stand");
|
||||
enterStep(4, 5);
|
||||
return;
|
||||
}
|
||||
if (step == 5) {
|
||||
if (s == "run-fast") {
|
||||
fail("re-latch picked the first child, not "
|
||||
"the startState");
|
||||
return;
|
||||
}
|
||||
if (s == "idle-soft") {
|
||||
std::cout << "[test] re-activated machine "
|
||||
"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") {
|
||||
fail("unexpected nested state '" + s +
|
||||
"' after re-activation");
|
||||
return;
|
||||
}
|
||||
if (stepExpired())
|
||||
fail("machine never re-entered its "
|
||||
"startState");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* demo-taxi: taxi driver demo. Starts as an exact copy of
|
||||
* demos/demo-interior-exterior-dynamics (same scene-switch-door setup,
|
||||
@@ -7596,6 +8094,20 @@ struct DriveBenchmarkListener : public Ogre::FrameListener,
|
||||
* and both POI actions latching their hooks luaTask
|
||||
* counters through ActuatorSystem::executeAction;
|
||||
* exits 0 on PASS.
|
||||
* --test-anim-travel
|
||||
* headless-friendly phase 6.7 check of the animation
|
||||
* state machine travel, on the default interior
|
||||
* scene: a synthetic registry tree probes the
|
||||
* startState boot marker, travelTo hop-through
|
||||
* (pending anchor, no insta-switch, each hop
|
||||
* completes the current clip, arrival), the setState
|
||||
* cancel (rule 4), the unreachable-target degrade to
|
||||
* the plain switch (rule 2) and the nested rule-6
|
||||
* contract (latch enters the nested startState,
|
||||
* leaving the host state holds the deactivation
|
||||
* until the nested machine travelled to its
|
||||
* endState, re-activation re-enters the startState);
|
||||
* exits 0 on PASS.
|
||||
*/
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
@@ -7640,6 +8152,7 @@ int main(int argc, char *argv[])
|
||||
bool testTrafficSchedule = false;
|
||||
bool testParking = false;
|
||||
bool testFurniture = false;
|
||||
bool testAnimTravel = false;
|
||||
bool benchmark = false;
|
||||
bool benchmarkDrive = false;
|
||||
float driveSpeed = 13.0f;
|
||||
@@ -7708,6 +8221,8 @@ int main(int argc, char *argv[])
|
||||
testParking = true;
|
||||
} else if (arg == "--test-furniture") {
|
||||
testFurniture = true;
|
||||
} else if (arg == "--test-anim-travel") {
|
||||
testAnimTravel = true;
|
||||
} else if (arg == "--benchmark") {
|
||||
benchmark = true;
|
||||
} else if (arg == "--benchmark-drive") {
|
||||
@@ -7984,6 +8499,12 @@ int main(int argc, char *argv[])
|
||||
if (testFurniture)
|
||||
app.getRoot()->addFrameListener(&furnitureTestListener);
|
||||
|
||||
/* Phase 6.7 travel probe: runs on the default interior
|
||||
* scene (no scene override needed). */
|
||||
AnimTravelTestListener animTravelListener(&app);
|
||||
if (testAnimTravel)
|
||||
app.getRoot()->addFrameListener(&animTravelListener);
|
||||
|
||||
BenchmarkListener benchmarkListener(&app);
|
||||
if (benchmark) {
|
||||
app.getRoot()->addFrameListener(&benchmarkListener);
|
||||
@@ -8402,6 +8923,26 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
}
|
||||
|
||||
if (testAnimTravel) {
|
||||
if (animTravelListener.failed) {
|
||||
std::cerr << "[test] FAIL: "
|
||||
<< animTravelListener.failReason
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
if (animTravelListener.phase != 5) {
|
||||
std::cerr << "[test] FAIL: incomplete (anim "
|
||||
"travel phase "
|
||||
<< animTravelListener.phase << ")"
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (benchmark) {
|
||||
if (benchmarkListener.failed) {
|
||||
std::cerr << "[benchmark] FAIL: "
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "AnimTravel.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <deque>
|
||||
|
||||
namespace AnimTravel {
|
||||
|
||||
std::vector<Ogre::String>
|
||||
findTravelPath(const std::vector<Ogre::String> &states,
|
||||
const std::vector<AnimationTreeLink> &links,
|
||||
const Ogre::String &from, const Ogre::String &to)
|
||||
{
|
||||
std::vector<Ogre::String> path;
|
||||
if (from == to)
|
||||
return path;
|
||||
|
||||
/* Only known states participate; links to unknown states are
|
||||
* filtered so a stale link can never create a bogus hop. */
|
||||
std::set<Ogre::String> known(states.begin(), states.end());
|
||||
if (known.find(from) == known.end() ||
|
||||
known.find(to) == known.end())
|
||||
return path;
|
||||
|
||||
/* Adjacency as a map + sorted set of neighbours: iteration is
|
||||
* name-sorted, so the FIFO BFS discovers equal-length paths in
|
||||
* lexicographic step order (ties broken by state NAME, rule 1). */
|
||||
std::map<Ogre::String, std::set<Ogre::String>> adjacency;
|
||||
for (const auto &link : links) {
|
||||
if (link.from.empty() || link.to.empty())
|
||||
continue;
|
||||
if (link.from == link.to)
|
||||
continue;
|
||||
if (known.find(link.from) == known.end())
|
||||
continue;
|
||||
if (known.find(link.to) == known.end())
|
||||
continue;
|
||||
adjacency[link.from].insert(link.to);
|
||||
}
|
||||
|
||||
std::map<Ogre::String, Ogre::String> parent;
|
||||
std::set<Ogre::String> visited;
|
||||
std::deque<Ogre::String> queue;
|
||||
visited.insert(from);
|
||||
queue.push_back(from);
|
||||
|
||||
while (!queue.empty()) {
|
||||
Ogre::String cur = queue.front();
|
||||
queue.pop_front();
|
||||
if (cur == to)
|
||||
break;
|
||||
auto itAdj = adjacency.find(cur);
|
||||
if (itAdj == adjacency.end())
|
||||
continue;
|
||||
for (const auto &next : itAdj->second) {
|
||||
if (visited.find(next) != visited.end())
|
||||
continue;
|
||||
visited.insert(next);
|
||||
parent[next] = cur;
|
||||
queue.push_back(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (visited.find(to) == visited.end())
|
||||
return path; /* unreachable */
|
||||
|
||||
/* Reconstruct backwards, strip @from, reverse into hop order. */
|
||||
std::vector<Ogre::String> reversed;
|
||||
for (Ogre::String walk = to; walk != from; walk = parent[walk])
|
||||
reversed.push_back(walk);
|
||||
for (auto rit = reversed.rbegin(); rit != reversed.rend(); ++rit)
|
||||
path.push_back(*rit);
|
||||
return path;
|
||||
}
|
||||
|
||||
std::vector<Issue>
|
||||
validateStateMachine(const std::vector<Ogre::String> &states,
|
||||
const std::vector<AnimationTreeLink> &links,
|
||||
const Ogre::String &startState,
|
||||
const Ogre::String &endState)
|
||||
{
|
||||
std::vector<Issue> issues;
|
||||
std::set<Ogre::String> known(states.begin(), states.end());
|
||||
std::set<std::pair<Ogre::String, Ogre::String>> seen;
|
||||
|
||||
for (const auto &link : links) {
|
||||
if (link.from.empty() || link.to.empty()) {
|
||||
issues.push_back(
|
||||
{ true, "connect link with empty endpoint" });
|
||||
continue;
|
||||
}
|
||||
if (link.from == link.to) {
|
||||
issues.push_back({ true, "self link " + link.from +
|
||||
" -> " + link.to });
|
||||
continue;
|
||||
}
|
||||
if (known.find(link.from) == known.end()) {
|
||||
issues.push_back(
|
||||
{ true, "connect link source '" + link.from +
|
||||
"' is not a state" });
|
||||
}
|
||||
if (known.find(link.to) == known.end()) {
|
||||
issues.push_back(
|
||||
{ true, "connect link target '" + link.to +
|
||||
"' is not a state" });
|
||||
}
|
||||
std::pair<Ogre::String, Ogre::String> edge(link.from,
|
||||
link.to);
|
||||
if (!seen.insert(edge).second) {
|
||||
issues.push_back({ true, "duplicate connect link " +
|
||||
link.from + " -> " +
|
||||
link.to });
|
||||
}
|
||||
}
|
||||
|
||||
if (!startState.empty() &&
|
||||
known.find(startState) == known.end()) {
|
||||
issues.push_back({ true, "startState '" + startState +
|
||||
"' is not a state" });
|
||||
}
|
||||
if (!endState.empty() && known.find(endState) == known.end()) {
|
||||
issues.push_back({ true, "endState '" + endState +
|
||||
"' is not a state" });
|
||||
}
|
||||
|
||||
/* Warning: states with no inbound link cannot be travelled to
|
||||
* (travelTo degrades to a plain switch for them). */
|
||||
for (const auto &state : states) {
|
||||
if (state == startState)
|
||||
continue;
|
||||
bool inbound = false;
|
||||
for (const auto &link : links) {
|
||||
if (link.to == state) {
|
||||
inbound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inbound)
|
||||
issues.push_back(
|
||||
{ false, "state '" + state +
|
||||
"' has no inbound connect link" });
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
} // namespace AnimTravel
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef EDITSCENE_ANIMTRAVEL_HPP
|
||||
#define EDITSCENE_ANIMTRAVEL_HPP
|
||||
#pragma once
|
||||
|
||||
#include "../components/AnimationTree.hpp"
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Pure connect-link helpers for Phase 6.7 animation travel.
|
||||
*
|
||||
* No Ogre runtime, no flecs — headless-testable
|
||||
* (tests/animation_travel_test.cpp links nothing but include dirs;
|
||||
* Ogre::String is std::string). All results are deterministic.
|
||||
*/
|
||||
namespace AnimTravel {
|
||||
|
||||
/** Shortest connect path from state @from to @to over one state
|
||||
* machine's link set: BFS with name-sorted adjacency, FIFO queue —
|
||||
* among equal-length paths the lexicographically smallest one wins.
|
||||
*
|
||||
* Returns the intermediate/target states WITHOUT @from (the list is
|
||||
* "which state to switch to next, then, ...", last element = final
|
||||
* state). Empty result means "no hop needed" (from == to or an
|
||||
* endpoint is not a known state) or "unreachable"; the caller decides
|
||||
* which fallback applies (never an error per Phase 6.7 rule 2). */
|
||||
std::vector<Ogre::String>
|
||||
findTravelPath(const std::vector<Ogre::String> &states,
|
||||
const std::vector<AnimationTreeLink> &links,
|
||||
const Ogre::String &from, const Ogre::String &to);
|
||||
|
||||
struct Issue {
|
||||
bool error; /* true = error, false = warning */
|
||||
Ogre::String message;
|
||||
};
|
||||
|
||||
/** Validate one state machine's connect-link setup.
|
||||
*
|
||||
* Errors: empty endpoints, self links, duplicate links, link to/from
|
||||
* a state that does not exist, startState/endState naming a state
|
||||
* that does not exist.
|
||||
* Warnings: a state with no inbound link (unreachable by travel; the
|
||||
* startState is exempt). */
|
||||
std::vector<Issue>
|
||||
validateStateMachine(const std::vector<Ogre::String> &states,
|
||||
const std::vector<AnimationTreeLink> &links,
|
||||
const Ogre::String &startState,
|
||||
const Ogre::String &endState);
|
||||
|
||||
} // namespace AnimTravel
|
||||
|
||||
#endif // EDITSCENE_ANIMTRAVEL_HPP
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "AnimationTreeSystem.hpp"
|
||||
#include "AnimationTreeRegistry.hpp"
|
||||
#include "CharacterSlotSystem.hpp"
|
||||
#include "AnimTravel.hpp"
|
||||
#include "../components/Transform.hpp"
|
||||
#include "../components/Renderable.hpp"
|
||||
#include "../components/CharacterSlots.hpp"
|
||||
@@ -11,6 +12,8 @@
|
||||
#include <OgreSceneNode.h>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef EDITSCENE_VERBOSE_DEBUG
|
||||
#define EDITSCENE_LOG_ANIM(msg) \
|
||||
@@ -33,6 +36,57 @@ static unsigned short findBoneBlendIndex(Ogre::SkeletonInstance *skel,
|
||||
return (unsigned short)-1;
|
||||
}
|
||||
|
||||
/* --- Phase 6.7 tree-shape helpers (pure) --- */
|
||||
|
||||
/* State child names of one stateMachine node, in tree order. */
|
||||
static void collectStateNames(const AnimationTreeNode &smNode,
|
||||
std::vector<Ogre::String> &out)
|
||||
{
|
||||
for (const auto &child : smNode.children)
|
||||
if (child.type == "state")
|
||||
out.push_back(child.name);
|
||||
}
|
||||
|
||||
/* Is @name one of the machine's state children? */
|
||||
static bool isStateChild(const AnimationTreeNode &smNode,
|
||||
const Ogre::String &name)
|
||||
{
|
||||
for (const auto &child : smNode.children)
|
||||
if (child.type == "state" && child.name == name)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Every stateMachine node under @node's subtree, NOT descending
|
||||
* into the machines themselves (their children keep their own
|
||||
* gating). Whether each machine gates (non-empty endState) is
|
||||
* decided by the caller variant. */
|
||||
static void collectMachinesUnder(const AnimationTreeNode &node,
|
||||
std::vector<const AnimationTreeNode *> &out)
|
||||
{
|
||||
if (node.type == "stateMachine") {
|
||||
out.push_back(&node);
|
||||
return;
|
||||
}
|
||||
for (const auto &child : node.children)
|
||||
collectMachinesUnder(child, out);
|
||||
}
|
||||
|
||||
/* The gated subset: only machines that carry an endState (they can
|
||||
* block the release of the outer state they sit under). */
|
||||
static void
|
||||
collectGatedMachines(const AnimationTreeNode &node,
|
||||
std::vector<const AnimationTreeNode *> &out)
|
||||
{
|
||||
if (node.type == "stateMachine") {
|
||||
if (!node.endState.empty())
|
||||
out.push_back(&node);
|
||||
return;
|
||||
}
|
||||
for (const auto &child : node.children)
|
||||
collectGatedMachines(child, out);
|
||||
}
|
||||
|
||||
AnimationTreeSystem::AnimationTreeSystem(flecs::world &world,
|
||||
Ogre::SceneManager *sceneMgr)
|
||||
: m_world(world)
|
||||
@@ -133,6 +187,11 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
|
||||
state.rootBone = nullptr;
|
||||
state.animations.clear();
|
||||
|
||||
/* Rule 7: the per-entity robustness warning set resets with the
|
||||
* runtime rebuild so a genuinely broken setup can warn once
|
||||
* again after the mesh/registry was fixed. */
|
||||
state.travelWarned.clear();
|
||||
|
||||
Ogre::SkeletonInstance *skel = ent->getSkeleton();
|
||||
|
||||
/* Find and freeze root bone */
|
||||
@@ -299,6 +358,19 @@ void AnimationTreeSystem::teardownEntity(flecs::entity e)
|
||||
}
|
||||
}
|
||||
|
||||
/* Rule 7: report dropped travels exactly once per destroyed
|
||||
* entity (the runtime-only pending state fades with the map
|
||||
* entry below). */
|
||||
if (!state.pendingTravels.empty() || !state.pendingExits.empty()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"[anim-tree] travel pending dropped at teardown (entity " +
|
||||
std::to_string(e.id()) + ", " +
|
||||
std::to_string(state.pendingTravels.size()) +
|
||||
" travel(s), " +
|
||||
std::to_string(state.pendingExits.size()) +
|
||||
" hold(s))");
|
||||
}
|
||||
|
||||
state.animations.clear();
|
||||
state.fadeStates.clear();
|
||||
state.prevStates.clear();
|
||||
@@ -320,13 +392,24 @@ void AnimationTreeSystem::initializeTreeStates(const AnimationTreeNode &node,
|
||||
if (node.type == "stateMachine") {
|
||||
if (at.currentStates.find(node.name) ==
|
||||
at.currentStates.end()) {
|
||||
for (const auto &child : node.children) {
|
||||
if (child.type == "state") {
|
||||
at.currentStates[node.name] =
|
||||
child.name;
|
||||
break;
|
||||
/* Phase 6.7: honor the startState marker when the
|
||||
* entry is missing (fresh entity); entries that
|
||||
* already exist survive the rebuild untouched. */
|
||||
const AnimationTreeNode *startNode = nullptr;
|
||||
if (!node.startState.empty())
|
||||
startNode = findStateNode(node,
|
||||
node.startState);
|
||||
if (!startNode) {
|
||||
for (const auto &child : node.children) {
|
||||
if (child.type == "state") {
|
||||
startNode = &child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (startNode)
|
||||
at.currentStates[node.name] =
|
||||
startNode->name;
|
||||
}
|
||||
}
|
||||
for (const auto &child : node.children)
|
||||
@@ -429,6 +512,22 @@ void AnimationTreeSystem::update(float deltaTime)
|
||||
#endif
|
||||
|
||||
if (!at.enabled) {
|
||||
/* Rule 7: runtime travel/holds are dropped when
|
||||
* the tree turns off (one line per disable
|
||||
* event, only when something was actually
|
||||
* pending); nested latches survive so the next
|
||||
* enable resumes mid-state instead of snapping
|
||||
* back to the startState. */
|
||||
if (!state.pendingTravels.empty() ||
|
||||
!state.pendingExits.empty()) {
|
||||
Ogre::LogManager::getSingleton()
|
||||
.logMessage(
|
||||
"[anim-tree] travel pending dropped for disabled tree (entity " +
|
||||
std::to_string(e.id()) + ")");
|
||||
}
|
||||
state.pendingTravels.clear();
|
||||
state.pendingExits.clear();
|
||||
state.travelWarned.clear();
|
||||
disableAllAnimations(state);
|
||||
return;
|
||||
}
|
||||
@@ -439,7 +538,8 @@ void AnimationTreeSystem::update(float deltaTime)
|
||||
/* Evaluate tree */
|
||||
EvalContext ctx;
|
||||
ctx.deltaTime = deltaTime;
|
||||
evaluateNode(*state.resolvedRoot, 1.0f, 1.0f, at, state, ctx);
|
||||
evaluateNode(e, *state.resolvedRoot, 1.0f, 1.0f, at, state,
|
||||
ctx);
|
||||
|
||||
/* Apply animation weights and advance */
|
||||
Ogre::Vector3 totalRootMotion = Ogre::Vector3::ZERO;
|
||||
@@ -666,24 +766,34 @@ void AnimationTreeSystem::update(float deltaTime)
|
||||
});
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
|
||||
void AnimationTreeSystem::evaluateNode(flecs::entity e,
|
||||
const AnimationTreeNode &node,
|
||||
float parentWeight, float timeMul,
|
||||
const AnimationTreeComponent &at,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
EvalContext &ctx)
|
||||
EvalContext &ctx, bool nested)
|
||||
{
|
||||
if (parentWeight < 0.001f)
|
||||
return;
|
||||
|
||||
if (node.type == "output") {
|
||||
if (!node.children.empty())
|
||||
evaluateNode(node.children[0], parentWeight,
|
||||
timeMul * node.speed, at, state, ctx);
|
||||
evaluateNode(e, node.children[0], parentWeight,
|
||||
timeMul * node.speed, at, state, ctx,
|
||||
nested);
|
||||
} else if (node.type == "speed") {
|
||||
if (!node.children.empty())
|
||||
evaluateNode(node.children[0], parentWeight,
|
||||
timeMul * node.speed, at, state, ctx);
|
||||
evaluateNode(e, node.children[0], parentWeight,
|
||||
timeMul * node.speed, at, state, ctx,
|
||||
nested);
|
||||
} else if (node.type == "stateMachine") {
|
||||
/* Rule 6: a machine reached through a "state" node is
|
||||
* nested — latch it (enter its startState) the first
|
||||
* time it is evaluated; already-latched machines
|
||||
* resume in place. */
|
||||
if (nested)
|
||||
latchNestedMachine(e, at, state, node);
|
||||
|
||||
auto itCurrent = at.currentStates.find(node.name);
|
||||
if (itCurrent == at.currentStates.end())
|
||||
return;
|
||||
@@ -702,7 +812,15 @@ void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
|
||||
}
|
||||
}
|
||||
|
||||
/* Update fades */
|
||||
/* Update fades. A pending exit hold freezes the held
|
||||
* state's fadeOut so its nested machines keep
|
||||
* evaluating (their travels gate the release; the
|
||||
* frozen weight keeps the hop checks alive). */
|
||||
auto itExit = state.pendingExits.find(node.name);
|
||||
const bool held = itExit != state.pendingExits.end();
|
||||
const Ogre::String heldState =
|
||||
held ? itExit->second.heldState : Ogre::String();
|
||||
|
||||
std::vector<Ogre::String> doneIn;
|
||||
for (const auto &inName : fade.fadeIn) {
|
||||
fade.weights[inName] += ctx.deltaTime * node.fadeSpeed;
|
||||
@@ -716,6 +834,8 @@ void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
|
||||
|
||||
std::vector<Ogre::String> doneOut;
|
||||
for (const auto &outName : fade.fadeOut) {
|
||||
if (held && outName == heldState)
|
||||
continue; /* freeze the hold */
|
||||
fade.weights[outName] -= ctx.deltaTime * node.fadeSpeed;
|
||||
if (fade.weights[outName] <= 0.0f) {
|
||||
fade.weights[outName] = 0.0f;
|
||||
@@ -730,19 +850,36 @@ void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
|
||||
if (child.type != "state")
|
||||
continue;
|
||||
float w = fade.weights[child.name];
|
||||
if (w > 0.001f)
|
||||
evaluateNode(child, parentWeight * w, timeMul,
|
||||
at, state, ctx);
|
||||
if (w > 0.001f) {
|
||||
evaluateNode(e, child, parentWeight * w,
|
||||
timeMul, at, state, ctx,
|
||||
/*nested=*/true);
|
||||
} else if (fade.fadeIn.find(child.name) ==
|
||||
fade.fadeIn.end()) {
|
||||
/* Fully deactivated: release nested
|
||||
* machines under it so a later
|
||||
* activation re-enters its startState
|
||||
* (rule 6). */
|
||||
releaseNestedUnder(child, state);
|
||||
}
|
||||
}
|
||||
|
||||
/* Queue end-transition check */
|
||||
if (node.endTransitions.find(currentName) !=
|
||||
node.endTransitions.end())
|
||||
ctx.endChecks.push_back({ node.name, currentName });
|
||||
/* Queue the per-frame check: a travel hop has priority
|
||||
* over the legacy end-transition while in flight (a
|
||||
* stale end-transition could cancel a just-armed
|
||||
* gate otherwise). */
|
||||
if (state.pendingTravels.find(node.name) !=
|
||||
state.pendingTravels.end())
|
||||
ctx.checks.push_back({ node.name, currentName, true });
|
||||
else if (node.endTransitions.find(currentName) !=
|
||||
node.endTransitions.end())
|
||||
ctx.checks.push_back(
|
||||
{ node.name, currentName, false });
|
||||
} else if (node.type == "state") {
|
||||
if (!node.children.empty())
|
||||
evaluateNode(node.children[0], parentWeight, timeMul,
|
||||
at, state, ctx);
|
||||
evaluateNode(e, node.children[0], parentWeight,
|
||||
timeMul, at, state, ctx,
|
||||
/*nested=*/true);
|
||||
} else if (node.type == "animation") {
|
||||
auto &data = ctx.animData[node.animationName];
|
||||
data.weight += parentWeight;
|
||||
@@ -778,18 +915,24 @@ void AnimationTreeSystem::checkEndTransitions(flecs::entity e,
|
||||
EntityAnimTreeState &state,
|
||||
EvalContext &ctx)
|
||||
{
|
||||
for (auto &pair : ctx.endChecks) {
|
||||
const Ogre::String &smName = pair.first;
|
||||
const Ogre::String &stateName = pair.second;
|
||||
const AnimationTreeNode *root = getResolvedRoot(at);
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
for (const auto &check : ctx.checks) {
|
||||
const Ogre::String &smName = check.stateMachine;
|
||||
const Ogre::String &stateName = check.state;
|
||||
|
||||
const AnimationTreeNode *root = getResolvedRoot(at);
|
||||
if (!root)
|
||||
continue;
|
||||
const AnimationTreeNode *smNode =
|
||||
findStateMachineNode(*root, smName);
|
||||
if (!smNode)
|
||||
continue;
|
||||
|
||||
if (check.travel) {
|
||||
processTravelCheck(e, at, state, *smNode, stateName);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto itTrans = smNode->endTransitions.find(stateName);
|
||||
if (itTrans == smNode->endTransitions.end())
|
||||
continue;
|
||||
@@ -815,6 +958,12 @@ void AnimationTreeSystem::checkEndTransitions(flecs::entity e,
|
||||
setStateInternal(e, at, state, smName, itTrans->second,
|
||||
true);
|
||||
}
|
||||
|
||||
/* Nested exit gates: a hold releases only when every gated
|
||||
* machine under the held state sits at its endState (rule 6).
|
||||
* Runs after the checks so a frame that completes the last
|
||||
* hop releases the hold the same frame. */
|
||||
releasePendingExits(e, at, state, *root);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::setState(flecs::entity e,
|
||||
@@ -827,9 +976,57 @@ void AnimationTreeSystem::setState(flecs::entity e,
|
||||
if (!e.has<AnimationTreeComponent>())
|
||||
return;
|
||||
auto &at = e.get_mut<AnimationTreeComponent>();
|
||||
/* External override wins (rule 4/7): any in-flight travel on
|
||||
* that machine is cancelled. A hold (pendingExits) is not
|
||||
* touched — gate releases stay decided by their machines. */
|
||||
it->second.pendingTravels.erase(stateMachineName);
|
||||
setStateInternal(e, at, it->second, stateMachineName, stateName, reset);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::travelTo(flecs::entity e,
|
||||
const Ogre::String &stateMachineName,
|
||||
const Ogre::String &stateName)
|
||||
{
|
||||
auto it = m_states.find(e.id());
|
||||
if (it == m_states.end())
|
||||
return;
|
||||
if (!e.has<AnimationTreeComponent>())
|
||||
return;
|
||||
auto &at = e.get_mut<AnimationTreeComponent>();
|
||||
/* A disabled tree cannot evaluate hops; the disabled branch in
|
||||
* update() drops pendings anyway, so degrade here quietly. */
|
||||
if (!at.enabled)
|
||||
return;
|
||||
|
||||
const AnimationTreeNode *root = getResolvedRoot(at);
|
||||
if (!root) {
|
||||
/* No registered tree (yet) — degrade silently; the
|
||||
* controller re-issues travelTo next frame once the
|
||||
* entity resolves. */
|
||||
return;
|
||||
}
|
||||
|
||||
const AnimationTreeNode *smNode =
|
||||
findStateMachineNode(*root, stateMachineName);
|
||||
if (!smNode) {
|
||||
it->second.pendingTravels.erase(stateMachineName);
|
||||
warnTravelOnce(it->second, "travelNoMachine",
|
||||
stateMachineName, stateName,
|
||||
"travelTo on unknown state machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isStateChild(*smNode, stateName)) {
|
||||
it->second.pendingTravels.erase(stateMachineName);
|
||||
warnTravelOnce(it->second, "travelNoTarget",
|
||||
stateMachineName, stateName,
|
||||
"travelTo with an unknown target state");
|
||||
return;
|
||||
}
|
||||
|
||||
travelToInternal(e, at, it->second, *smNode, stateName);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::setStateInternal(flecs::entity e,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
@@ -845,7 +1042,40 @@ void AnimationTreeSystem::setStateInternal(flecs::entity e,
|
||||
return;
|
||||
|
||||
auto &fade = state.fadeStates[stateMachineName];
|
||||
const Ogre::String &oldState = itCurrent->second;
|
||||
const Ogre::String oldState = itCurrent->second;
|
||||
|
||||
/* Phase 6.7 nested machinery. A single lookup of the machine
|
||||
* node covers both the re-enter cancel and the gate arming;
|
||||
* without a cached root both degrade to plain switching. */
|
||||
const AnimationTreeNode *root = getResolvedRoot(at);
|
||||
const AnimationTreeNode *smNode =
|
||||
root ? findStateMachineNode(*root, stateMachineName) : nullptr;
|
||||
|
||||
/* Rule 6: re-entering the held state of an armed release
|
||||
* cancels the hold — the machines under the re-entered state
|
||||
* keep travelling latched and may resume their own state. */
|
||||
auto itExit = state.pendingExits.find(stateMachineName);
|
||||
if (itExit != state.pendingExits.end() &&
|
||||
itExit->second.heldState == stateName) {
|
||||
const AnimationTreeNode *stNode =
|
||||
smNode ? findStateNode(*smNode, stateName) : nullptr;
|
||||
if (stNode)
|
||||
cancelNestedPendingsUnder(*stNode, state);
|
||||
state.pendingExits.erase(itExit);
|
||||
}
|
||||
|
||||
/* Rule 6: leaving a state with gated nested machines holds the
|
||||
* deactivation while they travel to their endState. */
|
||||
const AnimationTreeNode *oldStNode =
|
||||
smNode ? findStateNode(*smNode, oldState) : nullptr;
|
||||
if (oldStNode)
|
||||
deferNestedExit(e, at, state, stateMachineName, *oldStNode);
|
||||
|
||||
/* The nested machinery above may insert machine entries
|
||||
* (rehashing at.currentStates): re-resolve before writing. */
|
||||
itCurrent = at.currentStates.find(stateMachineName);
|
||||
if (itCurrent == at.currentStates.end())
|
||||
return;
|
||||
|
||||
/* If the new state is currently fading out, stop fading it out; if the
|
||||
* old state is still fading in, stop fading it in. Otherwise a state can
|
||||
@@ -864,11 +1094,6 @@ void AnimationTreeSystem::setStateInternal(flecs::entity e,
|
||||
|
||||
if (reset) {
|
||||
/* Reset the new state's animation */
|
||||
const AnimationTreeNode *root = getResolvedRoot(at);
|
||||
const AnimationTreeNode *smNode = root ?
|
||||
findStateMachineNode(*root,
|
||||
stateMachineName) :
|
||||
nullptr;
|
||||
if (smNode) {
|
||||
const AnimationTreeNode *stNode =
|
||||
findStateNode(*smNode, stateName);
|
||||
@@ -948,3 +1173,361 @@ AnimationTreeSystem::findAnimationNode(const AnimationTreeNode &stateNode) const
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool AnimationTreeSystem::travelToInternal(
|
||||
flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, const AnimationTreeNode &smNode,
|
||||
const Ogre::String &target)
|
||||
{
|
||||
auto itCurrent = at.currentStates.find(smNode.name);
|
||||
if (itCurrent == at.currentStates.end())
|
||||
return false;
|
||||
|
||||
std::vector<Ogre::String> states;
|
||||
collectStateNames(smNode, states);
|
||||
|
||||
std::vector<Ogre::String> path = AnimTravel::findTravelPath(
|
||||
states, smNode.connectLinks, itCurrent->second, target);
|
||||
if (path.empty()) {
|
||||
/* from == to (already there), an endpoint is unknown
|
||||
* or the target is unreachable: rule 2 — degrade to
|
||||
* the plain cross-fade switch, never an error. */
|
||||
state.pendingTravels.erase(smNode.name);
|
||||
degradeTravel(e, at, state, smNode.name, target);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* findTravelPath ends with the target; pending paths keep the
|
||||
* target in a separate field and exclude it from the hop
|
||||
* list. */
|
||||
path.pop_back();
|
||||
|
||||
/* (Re-)anchors at the CURRENT state: a re-issue with
|
||||
* identical content reproduces the same record (cheap,
|
||||
* deterministic). */
|
||||
PendingTravel pending;
|
||||
pending.fromState = itCurrent->second;
|
||||
pending.path = path;
|
||||
pending.target = target;
|
||||
state.pendingTravels[smNode.name] = pending;
|
||||
return true;
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::processTravelCheck(
|
||||
flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, const AnimationTreeNode &smNode,
|
||||
const Ogre::String &queuedState)
|
||||
{
|
||||
(void)queuedState;
|
||||
|
||||
auto itPending = state.pendingTravels.find(smNode.name);
|
||||
if (itPending == state.pendingTravels.end())
|
||||
return;
|
||||
|
||||
auto itCurrent = at.currentStates.find(smNode.name);
|
||||
if (itCurrent == at.currentStates.end()) {
|
||||
const Ogre::String target = itPending->second.target;
|
||||
state.pendingTravels.erase(itPending);
|
||||
warnTravelOnce(state, "travelNoMachine", smNode.name,
|
||||
target,
|
||||
"travel lost its state machine entry");
|
||||
return;
|
||||
}
|
||||
|
||||
const Ogre::String current = itCurrent->second;
|
||||
if (current == itPending->second.target) {
|
||||
/* Arrival: the target fades in (or has already
|
||||
* arrived) and keeps looping; the hop chain is
|
||||
* done. */
|
||||
state.pendingTravels.erase(itPending);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!findStateNode(smNode, itPending->second.target)) {
|
||||
const Ogre::String target = itPending->second.target;
|
||||
state.pendingTravels.erase(itPending);
|
||||
warnTravelOnce(state, "travelNoTarget", smNode.name,
|
||||
target, "travel target state not found");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Drift: the machine hopped by itself (an external setState
|
||||
* from somewhere inside the tree, an end transition). The
|
||||
* destination stays, the route is recomputed from the
|
||||
* current anchor. */
|
||||
if (current != itPending->second.fromState) {
|
||||
if (!isStateChild(smNode, current)) {
|
||||
state.pendingTravels.erase(itPending);
|
||||
warnTravelOnce(state, "travelOffAnchor", smNode.name,
|
||||
current,
|
||||
"travel left a deleted state");
|
||||
return;
|
||||
}
|
||||
std::vector<Ogre::String> states;
|
||||
collectStateNames(smNode, states);
|
||||
std::vector<Ogre::String> remaining =
|
||||
AnimTravel::findTravelPath(
|
||||
states, smNode.connectLinks, current,
|
||||
itPending->second.target);
|
||||
if (remaining.empty()) {
|
||||
const Ogre::String target =
|
||||
itPending->second.target;
|
||||
state.pendingTravels.erase(itPending);
|
||||
degradeTravel(e, at, state, smNode.name, target);
|
||||
return;
|
||||
}
|
||||
remaining.pop_back();
|
||||
itPending->second.path = remaining;
|
||||
itPending->second.fromState = current;
|
||||
/* Wait one frame; the next check runs with the fresh
|
||||
* anchor. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (as) {
|
||||
float t = as->getTimePosition();
|
||||
float len = as->getLength();
|
||||
if (len > 0.0001f && t < len * 0.99f)
|
||||
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");
|
||||
|
||||
/* 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;
|
||||
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;
|
||||
}
|
||||
setStateInternal(e, at, state, smNode.name, next, true);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::deferNestedExit(flecs::entity e,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const Ogre::String &outerName,
|
||||
const AnimationTreeNode &heldState)
|
||||
{
|
||||
auto itExit = state.pendingExits.find(outerName);
|
||||
if (itExit != state.pendingExits.end() &&
|
||||
itExit->second.heldState == heldState.name)
|
||||
return;
|
||||
|
||||
std::vector<const AnimationTreeNode *> machines;
|
||||
collectGatedMachines(heldState, machines);
|
||||
|
||||
bool created = false;
|
||||
for (const auto *mach : machines) {
|
||||
auto itCur = at.currentStates.find(mach->name);
|
||||
if (itCur != at.currentStates.end() &&
|
||||
itCur->second == mach->endState)
|
||||
continue; /* parked at its end state already */
|
||||
if (travelToInternal(e, at, state, *mach, mach->endState))
|
||||
created = true;
|
||||
}
|
||||
|
||||
/* The outer hold follows THIS switch: travel pending => hold
|
||||
* the old state frozen; nothing blocks => drop any older
|
||||
* hold (the old held state resumes fading, its machines are
|
||||
* cleaned when its weight reaches 0). */
|
||||
if (created)
|
||||
state.pendingExits[outerName] = { heldState.name };
|
||||
else
|
||||
state.pendingExits.erase(outerName);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::releasePendingExits(flecs::entity e,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const AnimationTreeNode &root)
|
||||
{
|
||||
(void)e;
|
||||
std::vector<Ogre::String> release;
|
||||
for (auto &pair : state.pendingExits) {
|
||||
const Ogre::String &outerName = pair.first;
|
||||
const Ogre::String &held = pair.second.heldState;
|
||||
|
||||
const AnimationTreeNode *smNode =
|
||||
findStateMachineNode(root, outerName);
|
||||
if (!smNode) {
|
||||
release.push_back(outerName);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto itCur = at.currentStates.find(outerName);
|
||||
if (itCur == at.currentStates.end() ||
|
||||
itCur->second == held) {
|
||||
/* The deferred switch dissolved (external
|
||||
* re-issue or the machine vanished). */
|
||||
release.push_back(outerName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const AnimationTreeNode *heldNode =
|
||||
findStateNode(*smNode, held);
|
||||
if (!heldNode) {
|
||||
release.push_back(outerName);
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<const AnimationTreeNode *> machines;
|
||||
collectGatedMachines(*heldNode, machines);
|
||||
|
||||
bool hold = false;
|
||||
bool failOpen = false;
|
||||
for (const auto *mach : machines) {
|
||||
if (state.nestedActive.find(mach->name) ==
|
||||
state.nestedActive.end())
|
||||
continue; /* un-latched: no gate */
|
||||
auto itMach = at.currentStates.find(mach->name);
|
||||
if (itMach == at.currentStates.end())
|
||||
continue; /* vanished mid-frame */
|
||||
if (itMach->second == mach->endState)
|
||||
continue; /* parked, doesn't block */
|
||||
if (state.pendingTravels.find(mach->name) !=
|
||||
state.pendingTravels.end()) {
|
||||
hold = true; /* travel in flight */
|
||||
} else {
|
||||
/* Latched, left its endState and no
|
||||
* travel pending: it can never reach
|
||||
* the end again through travel —
|
||||
* fail open (rule 6's hang guard). */
|
||||
failOpen = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (failOpen || !hold)
|
||||
release.push_back(outerName);
|
||||
}
|
||||
|
||||
for (const auto &name : release)
|
||||
state.pendingExits.erase(name);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::latchNestedMachine(
|
||||
flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, const AnimationTreeNode &nestedNode)
|
||||
{
|
||||
if (!state.nestedActive.insert(nestedNode.name).second)
|
||||
return;
|
||||
|
||||
/* Start state: the marked one, else the first state child. */
|
||||
const AnimationTreeNode *startNode = nullptr;
|
||||
if (!nestedNode.startState.empty()) {
|
||||
const AnimationTreeNode *marked =
|
||||
findStateNode(nestedNode, nestedNode.startState);
|
||||
if (marked)
|
||||
startNode = marked;
|
||||
}
|
||||
if (!startNode) {
|
||||
for (const auto &child : nestedNode.children) {
|
||||
if (child.type == "state") {
|
||||
startNode = &child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!startNode)
|
||||
return;
|
||||
|
||||
auto itCurrent = at.currentStates.find(nestedNode.name);
|
||||
if (itCurrent == at.currentStates.end()) {
|
||||
/* First activation ever: boot directly in the start
|
||||
* state — no cross-fade from a state that never
|
||||
* played. */
|
||||
at.currentStates[nestedNode.name] = startNode->name;
|
||||
state.prevStates[nestedNode.name] = startNode->name;
|
||||
auto &fade = state.fadeStates[nestedNode.name];
|
||||
fade.weights[startNode->name] = 1.0f;
|
||||
for (const auto &child : nestedNode.children) {
|
||||
if (child.type == "state" &&
|
||||
child.name != startNode->name)
|
||||
fade.weights[child.name] = 0.0f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* An entry survives (seeded by initializeTreeStates or left
|
||||
* by a previous release): re-enter the start state through
|
||||
* the normal cross-fade. A stale pending, if any, re-anchors
|
||||
* itself through the off-anchor drift handling once the
|
||||
* machine runs. */
|
||||
setStateInternal(e, at, state, nestedNode.name, startNode->name,
|
||||
true);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::releaseNestedUnder(
|
||||
const AnimationTreeNode &stateNode, EntityAnimTreeState &state)
|
||||
{
|
||||
std::vector<const AnimationTreeNode *> machines;
|
||||
collectMachinesUnder(stateNode, machines);
|
||||
if (machines.empty())
|
||||
return;
|
||||
|
||||
for (const auto *mach : machines) {
|
||||
state.nestedActive.erase(mach->name);
|
||||
state.pendingTravels.erase(mach->name);
|
||||
}
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::cancelNestedPendingsUnder(
|
||||
const AnimationTreeNode &stateNode, EntityAnimTreeState &state)
|
||||
{
|
||||
std::vector<const AnimationTreeNode *> machines;
|
||||
collectMachinesUnder(stateNode, machines);
|
||||
if (machines.empty())
|
||||
return;
|
||||
|
||||
for (const auto *mach : machines)
|
||||
state.pendingTravels.erase(mach->name);
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::warnTravelOnce(EntityAnimTreeState &state,
|
||||
const char *reason,
|
||||
const Ogre::String &smName,
|
||||
const Ogre::String &stateName,
|
||||
const char *msg)
|
||||
{
|
||||
Ogre::String key = smName + "|" + reason + "|" + stateName;
|
||||
if (!state.travelWarned.insert(key).second)
|
||||
return;
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
std::string("[anim-tree] ") + msg + " (machine '" +
|
||||
smName + "' -> '" + stateName + "')");
|
||||
}
|
||||
|
||||
void AnimationTreeSystem::degradeTravel(flecs::entity e,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const Ogre::String &smName,
|
||||
const Ogre::String &stateName)
|
||||
{
|
||||
/* Rule 2: no connect path = the old plain cross-fade switch,
|
||||
* never an error, never a log line. */
|
||||
setStateInternal(e, at, state, smName, stateName, true);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,28 @@ public:
|
||||
|
||||
/**
|
||||
* Manually switch a state machine to a new state.
|
||||
* Sets up cross-fade automatically.
|
||||
* Sets up cross-fade automatically. Cancels any pending
|
||||
* travel for that state machine (external override wins,
|
||||
* Phase 6.7 rule 4/7).
|
||||
*/
|
||||
void setState(flecs::entity e, const Ogre::String &stateMachineName,
|
||||
const Ogre::String &stateName, bool reset = false);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
void travelTo(flecs::entity e, const Ogre::String &stateMachineName,
|
||||
const Ogre::String &stateName);
|
||||
|
||||
/**
|
||||
* Find the Ogre entity with a skeleton for a given flecs entity.
|
||||
*/
|
||||
@@ -65,6 +82,25 @@ private:
|
||||
std::unordered_map<Ogre::String, float> weights;
|
||||
};
|
||||
|
||||
/* Phase 6.7: one in-flight travel (see EntityAnimTreeState). */
|
||||
struct PendingTravel {
|
||||
/* State of the machine the travel is anchored to;
|
||||
* updated to `next` when a hop fires so the next
|
||||
* frame's check matches the machine's actual state. */
|
||||
Ogre::String fromState;
|
||||
/* Remaining hops AFTER the anchor state, target
|
||||
* excluded. */
|
||||
std::vector<Ogre::String> path;
|
||||
Ogre::String target;
|
||||
};
|
||||
|
||||
/* Phase 6.7: held deactivation of one state while its gated
|
||||
* nested machines travel to their endState. */
|
||||
struct PendingExit {
|
||||
/* State of the outer machine kept frozen in fadeOut. */
|
||||
Ogre::String heldState;
|
||||
};
|
||||
|
||||
struct EntityAnimTreeState {
|
||||
Ogre::Entity *ogreEntity = nullptr;
|
||||
Ogre::String ogreEntityName; /* cached for safe teardown */
|
||||
@@ -90,6 +126,31 @@ private:
|
||||
/* Track previous currentStates to detect external changes */
|
||||
std::unordered_map<Ogre::String, Ogre::String> prevStates;
|
||||
|
||||
/* Phase 6.7 runtime travel/nested state — NEVER
|
||||
* serialized: scene load / spawn / teleport re-issues it
|
||||
* from the controller's logical state (rule 7). */
|
||||
/* smName -> in-flight travel (BFS path excluding the
|
||||
* start; last entry is the target). fromState moves
|
||||
* forward with each completed hop. */
|
||||
std::unordered_map<Ogre::String, PendingTravel>
|
||||
pendingTravels;
|
||||
/* outer smName -> state of that machine held in a
|
||||
* frozen fadeOut while its gated nested machines
|
||||
* travel to their endState. */
|
||||
std::unordered_map<Ogre::String, PendingExit> pendingExits;
|
||||
/* Names of nested machines (reached through a "state"
|
||||
* node) currently latched active for this entity.
|
||||
* Persists across setupEntity rebuilds so the gate
|
||||
* travel is not re-snapped; re-entry resumes instead
|
||||
* of re-latching (rule 6: enter startState only if not
|
||||
* already active). */
|
||||
std::set<Ogre::String> nestedActive;
|
||||
/* One robustness log line per entity (rule 7), keys
|
||||
* "smName|reason|stateName". Cleared together with the
|
||||
* pending state on setup / tree-disable / teardown so a
|
||||
* later retry can warn once again. */
|
||||
std::set<Ogre::String> travelWarned;
|
||||
|
||||
/* Cached root from the registry. Not owned. */
|
||||
const AnimationTreeNode *resolvedRoot = nullptr;
|
||||
};
|
||||
@@ -99,10 +160,18 @@ private:
|
||||
float timeDelta = 0.0f;
|
||||
};
|
||||
|
||||
/* One queued check for checkEndTransitions(): either the
|
||||
* legacy end-transition rule or a Phase 6.7 travel hop. */
|
||||
struct AnimCheck {
|
||||
Ogre::String stateMachine;
|
||||
Ogre::String state;
|
||||
bool travel;
|
||||
};
|
||||
|
||||
struct EvalContext {
|
||||
float deltaTime = 0.0f;
|
||||
std::unordered_map<Ogre::String, AnimEvalData> animData;
|
||||
std::vector<std::pair<Ogre::String, Ogre::String> > endChecks;
|
||||
std::vector<AnimCheck> checks;
|
||||
};
|
||||
|
||||
bool setupEntity(flecs::entity e, AnimationTreeComponent &at,
|
||||
@@ -111,9 +180,11 @@ private:
|
||||
void disableAllAnimations(EntityAnimTreeState &state);
|
||||
void initializeTreeStates(const AnimationTreeNode &node,
|
||||
AnimationTreeComponent &at);
|
||||
void evaluateNode(const AnimationTreeNode &node, float parentWeight,
|
||||
float timeMul, const AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, EvalContext &ctx);
|
||||
void evaluateNode(flecs::entity e, const AnimationTreeNode &node,
|
||||
float parentWeight, float timeMul,
|
||||
AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, EvalContext &ctx,
|
||||
bool nested = false);
|
||||
void checkEndTransitions(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state, EvalContext &ctx);
|
||||
void syncStateChanges(AnimationTreeComponent &at,
|
||||
@@ -123,6 +194,54 @@ private:
|
||||
const Ogre::String &stateMachineName,
|
||||
const Ogre::String &stateName, bool reset);
|
||||
|
||||
/* Phase 6.7 travel + nested gate helpers (all runtime-only).
|
||||
* @returns true when a pending travel was recorded (used by the
|
||||
* gate arming to know whether anything actually blocks). */
|
||||
bool travelToInternal(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const AnimationTreeNode &smNode,
|
||||
const Ogre::String &target);
|
||||
void processTravelCheck(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const AnimationTreeNode &smNode,
|
||||
const Ogre::String &queuedState);
|
||||
/* Arm nested exit gates: the outer machine is leaving
|
||||
* @heldState (oldState); gated nested machines underneath
|
||||
* travel to their endState first (rule 6). */
|
||||
void deferNestedExit(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const Ogre::String &outerName,
|
||||
const AnimationTreeNode &heldState);
|
||||
void releasePendingExits(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const AnimationTreeNode &root);
|
||||
void latchNestedMachine(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const AnimationTreeNode &nestedNode);
|
||||
/* Release nested machines inside a fully-deactivated state
|
||||
* (weight 0, not fading in) so a later activation re-enters
|
||||
* its startState (rule 6), and drop their stale pendings. */
|
||||
void releaseNestedUnder(const AnimationTreeNode &stateNode,
|
||||
EntityAnimTreeState &state);
|
||||
/* Cancel the pending travels of every machine found under
|
||||
* @stateNode's subtree (machines stay latched: a later
|
||||
* re-activation resumes instead of re-entering startState). */
|
||||
void cancelNestedPendingsUnder(const AnimationTreeNode &stateNode,
|
||||
EntityAnimTreeState &state);
|
||||
|
||||
/* One robustness log line per entity (rule 7): key
|
||||
* "smName|reason|stateName" into state.travelWarned. */
|
||||
void warnTravelOnce(EntityAnimTreeState &state, const char *reason,
|
||||
const Ogre::String &smName,
|
||||
const Ogre::String &stateName, const char *msg);
|
||||
/* Falls back to a plain setStateInternal switch (rule 2: no
|
||||
* path = old behaviour, never an error, never a log). */
|
||||
void degradeTravel(flecs::entity e, AnimationTreeComponent &at,
|
||||
EntityAnimTreeState &state,
|
||||
const Ogre::String &smName,
|
||||
const Ogre::String &stateName);
|
||||
|
||||
|
||||
/* Resolve tree reference from registry and copy tree if version changed */
|
||||
void resolveTree(AnimationTreeComponent &at, EntityAnimTreeState &state);
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@ void NpcTaxiSystem::seat(Goal &goal, flecs::entity vehicle,
|
||||
}
|
||||
AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem();
|
||||
if (ats)
|
||||
ats->setState(character, "locomotion", "driving");
|
||||
ats->travelTo(character, "locomotion", "driving");
|
||||
|
||||
const VehicleComponent &vc = vehicle.get<VehicleComponent>();
|
||||
auto &ct = character.get_mut<TransformComponent>();
|
||||
@@ -631,7 +631,7 @@ void NpcTaxiSystem::alight(uint64_t registryId, Goal &goal,
|
||||
cs->enablePhysics(character);
|
||||
AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem();
|
||||
if (ats)
|
||||
ats->setState(character, "locomotion", "idle");
|
||||
ats->travelTo(character, "locomotion", "idle");
|
||||
}
|
||||
|
||||
goal.vehicleId = 0;
|
||||
|
||||
@@ -143,7 +143,7 @@ void PathFollowingSystem::applyLocomotionState(flecs::entity e)
|
||||
return;
|
||||
|
||||
for (const auto &pair : state->stateMachineStates) {
|
||||
m_animTreeSystem->setState(e, pair.first, pair.second, false);
|
||||
m_animTreeSystem->travelTo(e, pair.first, pair.second);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,7 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc,
|
||||
state.targetEntity, pc.locomotionStateMachine);
|
||||
|
||||
if (!animState.empty()) {
|
||||
ats->setState(state.targetEntity, pc.locomotionStateMachine,
|
||||
ats->travelTo(state.targetEntity, pc.locomotionStateMachine,
|
||||
animState);
|
||||
}
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ void SmartObjectSystem::setLocomotionState(flecs::entity e,
|
||||
if (pfState) {
|
||||
// Apply ALL state machine name -> state name pairs
|
||||
for (const auto &pair : pfState->stateMachineStates) {
|
||||
m_animTreeSystem->setState(e, pair.first,
|
||||
pair.second, false);
|
||||
m_animTreeSystem->travelTo(e, pair.first,
|
||||
pair.second);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +157,7 @@ void SmartObjectSystem::setLocomotionState(flecs::entity e,
|
||||
|
||||
// Fallback: if no config found, try to set the state directly
|
||||
// using the animName as both state machine and state name
|
||||
m_animTreeSystem->setState(e, "main", animName, false);
|
||||
m_animTreeSystem->travelTo(e, "main", animName);
|
||||
}
|
||||
|
||||
bool SmartObjectSystem::testSmartObjectAction(flecs::entity character,
|
||||
|
||||
@@ -254,7 +254,7 @@ bool VehicleControllerSystem::enterVehicle(flecs::entity vehicle,
|
||||
* "sitting-vehicle"). */
|
||||
AnimationTreeSystem *ats = m_editorApp->getAnimationTreeSystem();
|
||||
if (ats)
|
||||
ats->setState(character, pc.locomotionStateMachine,
|
||||
ats->travelTo(character, pc.locomotionStateMachine,
|
||||
pc.drivingState);
|
||||
|
||||
m_seatIndex = seatIndex;
|
||||
@@ -521,7 +521,7 @@ void VehicleControllerSystem::restoreOnFoot()
|
||||
AnimationTreeSystem *ats =
|
||||
m_editorApp->getAnimationTreeSystem();
|
||||
if (ats)
|
||||
ats->setState(character,
|
||||
ats->travelTo(character,
|
||||
pc.locomotionStateMachine,
|
||||
pc.idleState);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @file animation_travel_test.cpp
|
||||
* @brief Headless tests for the Phase 6.7 animation travel helpers.
|
||||
*
|
||||
* Covers AnimTravel::findTravelPath (BFS shortest path over connect
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "../components/AnimationTree.hpp"
|
||||
#include "../systems/AnimTravel.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
int testCount = 0;
|
||||
int passCount = 0;
|
||||
|
||||
#define TEST(name) \
|
||||
do { \
|
||||
++testCount; \
|
||||
std::printf(" TEST %d: %s ... ", testCount, name); \
|
||||
} while (0)
|
||||
|
||||
#define PASS() \
|
||||
do { \
|
||||
++passCount; \
|
||||
std::printf("PASS\n"); \
|
||||
} while (0)
|
||||
|
||||
#define FAIL(message) \
|
||||
do { \
|
||||
std::printf("FAIL: %s\n", std::string(message).c_str()); \
|
||||
return 1; \
|
||||
} while (0)
|
||||
|
||||
using AnimTravel::findTravelPath;
|
||||
using AnimTravel::validateStateMachine;
|
||||
using Link = AnimationTreeLink;
|
||||
|
||||
std::vector<Ogre::String>
|
||||
paths(std::vector<Link> links, const Ogre::String &from,
|
||||
const Ogre::String &to)
|
||||
{
|
||||
std::vector<Ogre::String> states = { "idle", "walking", "running",
|
||||
"driving", "swim-idle",
|
||||
"swimming",
|
||||
"swimming-fast" };
|
||||
return findTravelPath(states, links, from, to);
|
||||
}
|
||||
|
||||
int testBfsBasics()
|
||||
{
|
||||
TEST("BFS shortest path + exclusion contract");
|
||||
|
||||
std::vector<Link> links = { { "idle", "walking" },
|
||||
{ "walking", "running" } };
|
||||
|
||||
/* Direct link: the path is just the target. */
|
||||
std::vector<Ogre::String> p = paths(links, "idle", "walking");
|
||||
if (p.size() != 1 || p[0] != "walking")
|
||||
FAIL("idle->walking should be exactly [walking]");
|
||||
if (p[0] == "idle")
|
||||
FAIL("path must exclude the from state");
|
||||
|
||||
/* Two hops: intermediates then the target. */
|
||||
p = paths(links, "idle", "running");
|
||||
if (p.size() != 2 || p[0] != "walking" || p[1] != "running")
|
||||
FAIL("idle->running should be [walking, running]");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testBfsTieBreaks()
|
||||
{
|
||||
TEST("equal-length ties break by state name");
|
||||
|
||||
/* A->X->C and A->B->C: both 2 hops. The BFS expands idle's
|
||||
* neighbours in name order; "swimming" < "walking"
|
||||
* lexicographically, so the swimming route wins. */
|
||||
std::vector<Link> links = { { "idle", "swimming" },
|
||||
{ "swimming", "driving" },
|
||||
{ "idle", "walking" },
|
||||
{ "walking", "driving" } };
|
||||
std::vector<Ogre::String> p = paths(links, "idle", "driving");
|
||||
if (p.size() != 2 || p[0] != "swimming" || p[1] != "driving")
|
||||
FAIL("tie must pick the lexicographically first route");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testBfsDegenerate()
|
||||
{
|
||||
TEST("empty results: self, unknown, unreachable");
|
||||
|
||||
std::vector<Link> links = { { "idle", "walking" } };
|
||||
|
||||
/* from == to: no hop needed. */
|
||||
if (!paths(links, "walking", "walking").empty())
|
||||
FAIL("from == to must yield an empty path");
|
||||
|
||||
/* Unknown endpoint. */
|
||||
if (!paths(links, "idle", "nonexistent").empty())
|
||||
FAIL("unknown target must yield an empty path");
|
||||
if (!paths(links, "nonexistent", "idle").empty())
|
||||
FAIL("unknown source must yield an empty path");
|
||||
|
||||
/* Unreachable: driving has no inbound links at all. */
|
||||
if (!paths(links, "walking", "driving").empty())
|
||||
FAIL("unreachable target must yield an empty path");
|
||||
|
||||
/* A stale link to an unknown state must not create a hop. */
|
||||
std::vector<Link> stale = { { "idle", "ghost" },
|
||||
{ "ghost", "running" } };
|
||||
if (!paths(stale, "idle", "running").empty())
|
||||
FAIL("links through unknown states must be ignored");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testBfsShortestWins()
|
||||
{
|
||||
TEST("shortest path wins over a longer discovered route");
|
||||
|
||||
/* walking->running direct plus a longer walking->idle->running
|
||||
* detour: BFS must return the direct hop. */
|
||||
std::vector<Link> links = { { "walking", "running" },
|
||||
{ "walking", "idle" },
|
||||
{ "idle", "running" } };
|
||||
std::vector<Ogre::String> p = paths(links, "walking", "running");
|
||||
if (p.size() != 1 || p[0] != "running")
|
||||
FAIL("BFS must return the 1-hop route");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testValidationErrors()
|
||||
{
|
||||
TEST("validateStateMachine errors");
|
||||
|
||||
std::vector<Ogre::String> states = { "idle", "walking", "running" };
|
||||
|
||||
/* Clean setup: every state has an inbound link (idle is exempt
|
||||
* as the startState) — no issues at all. */
|
||||
std::vector<Link> links = { { "idle", "walking" },
|
||||
{ "walking", "running" } };
|
||||
if (!validateStateMachine(states, links, "idle", "").empty())
|
||||
FAIL("clean setup must validate without issues");
|
||||
|
||||
/* Link endpoints must exist. */
|
||||
std::vector<Link> bad = { { "idle", "ghost" } };
|
||||
auto issues = validateStateMachine(states, bad, "idle", "");
|
||||
bool found = false;
|
||||
for (const auto &i : issues)
|
||||
if (i.error)
|
||||
found = true;
|
||||
if (!found)
|
||||
FAIL("link to an unknown state must be an error");
|
||||
|
||||
/* Self links and duplicates. */
|
||||
std::vector<Link> dup = { { "idle", "walking" },
|
||||
{ "idle", "walking" },
|
||||
{ "walking", "walking" } };
|
||||
issues = validateStateMachine(states, dup, "", "");
|
||||
int errors = 0;
|
||||
for (const auto &i : issues)
|
||||
errors += i.error ? 1 : 0;
|
||||
if (errors < 2)
|
||||
FAIL("duplicate + self links must both error");
|
||||
|
||||
/* Unknown startState/endState markers. */
|
||||
issues = validateStateMachine(states, links, "ghost", "ghost2");
|
||||
errors = 0;
|
||||
for (const auto &i : issues)
|
||||
errors += i.error ? 1 : 0;
|
||||
if (errors < 2)
|
||||
FAIL("unknown start/end markers must error");
|
||||
|
||||
/* Empty endpoint links. */
|
||||
std::vector<Link> empty = { { "", "walking" } };
|
||||
issues = validateStateMachine(states, empty, "", "");
|
||||
found = false;
|
||||
for (const auto &i : issues)
|
||||
if (i.error)
|
||||
found = true;
|
||||
if (!found)
|
||||
FAIL("empty link endpoint must error");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testValidationWarnings()
|
||||
{
|
||||
TEST("validateStateMachine unreachable warnings");
|
||||
|
||||
std::vector<Ogre::String> states = { "idle", "walking", "running" };
|
||||
|
||||
/* running has no inbound link: warning, not error. */
|
||||
std::vector<Link> links = { { "idle", "walking" } };
|
||||
auto issues = validateStateMachine(states, links, "", "");
|
||||
bool warnedRunning = false;
|
||||
bool onlyWarnings = true;
|
||||
for (const auto &i : issues) {
|
||||
if (i.error)
|
||||
onlyWarnings = false;
|
||||
if (i.message.find("running") != std::string::npos)
|
||||
warnedRunning = true;
|
||||
}
|
||||
if (!onlyWarnings || !warnedRunning)
|
||||
FAIL("unreachable state must warn (not error)");
|
||||
|
||||
/* startState is exempt from the reachability warning. */
|
||||
issues = validateStateMachine(states, { { "walking", "running" } },
|
||||
"idle", "");
|
||||
for (const auto &i : issues)
|
||||
if (i.error)
|
||||
FAIL("exempt startState must not error");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int testJsonRoundTrip()
|
||||
{
|
||||
TEST("connectLinks/startState/endState JSON round trip");
|
||||
|
||||
AnimationTreeNode sm;
|
||||
sm.type = "stateMachine";
|
||||
sm.name = "locomotion";
|
||||
sm.startState = "idle";
|
||||
sm.endState = "driving";
|
||||
sm.connectLinks = { { "idle", "walking" },
|
||||
{ "walking", "running" },
|
||||
{ "driving", "idle" } };
|
||||
for (const char *sname : { "idle", "walking", "running", "driving" }) {
|
||||
AnimationTreeNode st;
|
||||
st.type = "state";
|
||||
st.name = sname;
|
||||
sm.children.push_back(st);
|
||||
}
|
||||
|
||||
nlohmann::json json = animationTreeNodeToJson(sm);
|
||||
AnimationTreeNode back;
|
||||
animationTreeNodeFromJson(back, json);
|
||||
|
||||
if (back.startState != "idle")
|
||||
FAIL("startState did not survive the round trip");
|
||||
if (back.endState != "driving")
|
||||
FAIL("endState did not survive the round trip");
|
||||
if (back.connectLinks.size() != 3)
|
||||
FAIL("connectLinks count changed");
|
||||
if (back.connectLinks[0].from != "idle" ||
|
||||
back.connectLinks[0].to != "walking")
|
||||
FAIL("link endpoints reordered or lost");
|
||||
|
||||
/* findTravelPath must run over the round-tripped model. */
|
||||
std::vector<Ogre::String> states;
|
||||
for (const auto &c : back.children)
|
||||
if (c.type == "state")
|
||||
states.push_back(c.name);
|
||||
std::vector<Ogre::String> p = findTravelPath(
|
||||
states, back.connectLinks, "idle", "running");
|
||||
if (p.size() != 2 || p[0] != "walking" || p[1] != "running")
|
||||
FAIL("travel over the round-tripped links is wrong");
|
||||
|
||||
PASS();
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
std::printf("Animation travel tests\n");
|
||||
int failures = 0;
|
||||
failures += testBfsBasics();
|
||||
failures += testBfsTieBreaks();
|
||||
failures += testBfsDegenerate();
|
||||
failures += testBfsShortestWins();
|
||||
failures += testValidationErrors();
|
||||
failures += testValidationWarnings();
|
||||
failures += testJsonRoundTrip();
|
||||
std::printf("%d/%d tests passed\n", passCount, testCount);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "AnimationTreeNodeEditor.hpp"
|
||||
#include "../systems/AnimTravel.hpp"
|
||||
#include <imgui.h>
|
||||
#include <cstring>
|
||||
|
||||
@@ -381,6 +382,160 @@ void AnimationTreeNodeEditor::renderProperties(
|
||||
m_modified = true;
|
||||
}
|
||||
|
||||
/* Travel authoring (Phase 6.7): start/end markers, connect
|
||||
* links and inline validation. */
|
||||
if (node->type == "stateMachine") {
|
||||
std::vector<Ogre::String> stateNames;
|
||||
for (const auto &child : node->children)
|
||||
if (child.type == "state")
|
||||
stateNames.push_back(child.name);
|
||||
|
||||
if (!stateNames.empty()) {
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Travel");
|
||||
ImGui::TextDisabled(
|
||||
"travelTo follows the connect links; "
|
||||
"each hop plays its clip first");
|
||||
|
||||
/* Start state: empty = first state child. */
|
||||
Ogre::String startPreview =
|
||||
node->startState.empty() ?
|
||||
Ogre::String("(first)") :
|
||||
node->startState;
|
||||
if (ImGui::BeginCombo("Start State",
|
||||
startPreview.c_str())) {
|
||||
if (ImGui::Selectable(
|
||||
"(first)",
|
||||
node->startState.empty())) {
|
||||
node->startState.clear();
|
||||
m_modified = true;
|
||||
}
|
||||
for (const auto &sname : stateNames) {
|
||||
bool sel = (node->startState ==
|
||||
sname);
|
||||
if (ImGui::Selectable(
|
||||
sname.c_str(), sel)) {
|
||||
node->startState = sname;
|
||||
m_modified = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"State entered when the machine "
|
||||
"activates (rule 6).");
|
||||
|
||||
/* End state: empty = immediate deactivation. */
|
||||
Ogre::String endPreview =
|
||||
node->endState.empty() ?
|
||||
Ogre::String("(none)") :
|
||||
node->endState;
|
||||
if (ImGui::BeginCombo("End State",
|
||||
endPreview.c_str())) {
|
||||
if (ImGui::Selectable(
|
||||
"(none)",
|
||||
node->endState.empty())) {
|
||||
node->endState.clear();
|
||||
m_modified = true;
|
||||
}
|
||||
for (const auto &sname : stateNames) {
|
||||
bool sel = (node->endState ==
|
||||
sname);
|
||||
if (ImGui::Selectable(
|
||||
sname.c_str(), sel)) {
|
||||
node->endState = sname;
|
||||
m_modified = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(
|
||||
"State the machine travels to "
|
||||
"before deactivation (rule 6); "
|
||||
"none = deactivate immediately.");
|
||||
|
||||
/* Existing links with remove buttons. */
|
||||
for (size_t i = 0;
|
||||
i < node->connectLinks.size();) {
|
||||
auto &link = node->connectLinks[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
ImGui::Text("%s -> %s",
|
||||
link.from.c_str(),
|
||||
link.to.c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("X")) {
|
||||
node->connectLinks.erase(
|
||||
node->connectLinks
|
||||
.begin() +
|
||||
i);
|
||||
m_modified = true;
|
||||
ImGui::PopID();
|
||||
continue;
|
||||
}
|
||||
ImGui::PopID();
|
||||
i++;
|
||||
}
|
||||
|
||||
/* Add-link buttons: one per state, popup
|
||||
* lists the targets. */
|
||||
ImGui::Text("Add link from:");
|
||||
for (const auto &sname : stateNames) {
|
||||
ImGui::PushID(("addlink" + sname).c_str());
|
||||
if (ImGui::SmallButton(sname.c_str()))
|
||||
ImGui::OpenPopup("targets");
|
||||
if (ImGui::BeginPopup("targets")) {
|
||||
for (const auto &other :
|
||||
stateNames) {
|
||||
if (other == sname)
|
||||
continue;
|
||||
bool dup = false;
|
||||
for (const auto &l :
|
||||
node->connectLinks) {
|
||||
if (l.from ==
|
||||
sname &&
|
||||
l.to == other)
|
||||
dup = true;
|
||||
}
|
||||
if (dup)
|
||||
continue;
|
||||
if (ImGui::Selectable(
|
||||
other.c_str())) {
|
||||
AnimationTreeLink l;
|
||||
l.from = sname;
|
||||
l.to = other;
|
||||
node->connectLinks
|
||||
.push_back(l);
|
||||
m_modified = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::SameLine();
|
||||
}
|
||||
ImGui::NewLine();
|
||||
|
||||
/* Inline validation. */
|
||||
auto issues = AnimTravel::validateStateMachine(
|
||||
stateNames, node->connectLinks,
|
||||
node->startState, node->endState);
|
||||
for (const auto &issue : issues) {
|
||||
ImGui::TextColored(
|
||||
issue.error ?
|
||||
ImVec4(1.0f, 0.4f, 0.4f,
|
||||
1.0f) :
|
||||
ImVec4(1.0f, 0.8f, 0.3f,
|
||||
1.0f),
|
||||
"%s%s",
|
||||
issue.error ? "error: " :
|
||||
"warn: ",
|
||||
issue.message.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* End transitions (for state machines) */
|
||||
if (node->type == "stateMachine" && !node->children.empty()) {
|
||||
ImGui::Separator();
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
#include "AnimationTreeRegistryEditor.hpp"
|
||||
#include "../systems/AnimTravel.hpp"
|
||||
#include <imgui.h>
|
||||
#include <OgreLogManager.h>
|
||||
|
||||
static void
|
||||
collectStateMachines(AnimationTreeNode &node,
|
||||
std::vector<AnimationTreeNode *> &out)
|
||||
{
|
||||
if (node.type == "stateMachine")
|
||||
out.push_back(&node);
|
||||
for (auto &child : node.children)
|
||||
collectStateMachines(child, out);
|
||||
}
|
||||
|
||||
/* Phase 6.7: full-tree connect-link validation before saving. */
|
||||
static void validateRegistryTrees()
|
||||
{
|
||||
AnimationTreeRegistry ® = AnimationTreeRegistry::getSingleton();
|
||||
for (const auto &name : reg.getTreeNames()) {
|
||||
auto *def = reg.findTree(name);
|
||||
if (!def)
|
||||
continue;
|
||||
std::vector<AnimationTreeNode *> machines;
|
||||
collectStateMachines(def->root, machines);
|
||||
for (auto *sm : machines) {
|
||||
std::vector<Ogre::String> states;
|
||||
for (const auto &child : sm->children)
|
||||
if (child.type == "state")
|
||||
states.push_back(child.name);
|
||||
auto issues = AnimTravel::validateStateMachine(
|
||||
states, sm->connectLinks, sm->startState,
|
||||
sm->endState);
|
||||
for (const auto &issue : issues) {
|
||||
Ogre::String level =
|
||||
issue.error ? "error" : "warning";
|
||||
Ogre::LogManager::getSingleton()
|
||||
.logMessage("[anim-tree] save " +
|
||||
level + " in tree '" +
|
||||
name + "', machine '" +
|
||||
sm->name +
|
||||
"': " + issue.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimationTreeRegistryEditor::AnimationTreeRegistryEditor()
|
||||
{
|
||||
m_newNameBuf[0] = '\0';
|
||||
@@ -20,9 +63,11 @@ void AnimationTreeRegistryEditor::renderMenuBar()
|
||||
return;
|
||||
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Save", nullptr))
|
||||
if (ImGui::MenuItem("Save", nullptr)) {
|
||||
validateRegistryTrees();
|
||||
AnimationTreeRegistry::getSingleton().saveToFile(
|
||||
AnimationTreeRegistry::defaultPath());
|
||||
}
|
||||
if (ImGui::MenuItem("Load", nullptr))
|
||||
AnimationTreeRegistry::getSingleton().loadFromFile(
|
||||
AnimationTreeRegistry::defaultPath());
|
||||
|
||||
Reference in New Issue
Block a user