Phase 6.7 complete checkpoint

This commit is contained in:
2026-10-28 10:49:22 +03:00
parent 72851b442f
commit d0ca3d181a
19 changed files with 2648 additions and 68 deletions
+83
View File
@@ -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
+25
View File
@@ -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;
+99 -9
View File
@@ -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 &reg = 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 &reg = 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());