Files
EVEngine/test/action_montage.cpp

609 lines
30 KiB
C++

#include "action/Action.h"
#include "action/ActionTimeline.h"
#include "action/editor/ActionTimelineEditor.h"
#include "action/editor/ActionTimelineWidget.h"
#include "animation/AnimClip.h"
#include "animation/AnimLayerMixer.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "animation/MontageCoordinator.h"
#include "animation/MontagePlayer.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <cmath>
#include <limits>
#include <memory>
#include <string_view>
namespace {
eve::LogicalId id(std::string_view value) {
auto parsed = eve::LogicalId::parse(value);
REQUIRE(parsed.has_value());
return std::move(*parsed);
}
eve::action::ActionTimeline montageTimeline() {
eve::action::ActionTimeline timeline;
timeline.actionId = id("combat:montage-test");
timeline.duration = eve::Duration::fromSeconds(2.0).takeValue();
timeline.animationSections = {
{id("montage-section:anticipation"), "memory://clips/anticipation", eve::Duration::zero(),
eve::Duration::fromSeconds(1.0).takeValue(), eve::Duration::zero()},
{id("montage-section:strike"), "memory://clips/strike", eve::Duration::fromSeconds(1.0).takeValue(),
eve::Duration::fromSeconds(2.0).takeValue(), eve::Duration::fromSeconds(0.2).takeValue()},
};
timeline.splitTimestamps = {eve::Duration::fromSeconds(0.8).takeValue(),
eve::Duration::fromSeconds(1.2).takeValue()};
eve::action::ActionTrack gameplay;
gameplay.id = id("combat-track:gameplay");
gameplay.label = "Gameplay";
gameplay.kind = eve::action::ActionTrackKind::Gameplay;
gameplay.states.push_back({id("combat-state:hit"),
id("combat:hitbox"),
eve::Duration::fromSeconds(0.8).takeValue(),
eve::Duration::fromSeconds(1.2).takeValue(),
{}});
timeline.tracks.push_back(std::move(gameplay));
return timeline;
}
std::unique_ptr<eve::animation::AnimClip> rootClip(std::string name, float distance) {
auto clip = std::make_unique<eve::animation::AnimClip>(std::move(name));
clip->setDuration(1.0f);
clip->setLoop(false);
clip->addPositionKey(0, 0.0f, 0.0f, 0.0f, 0.0f);
clip->addPositionKey(0, 1.0f, distance, 0.0f, 0.0f);
return clip;
}
std::vector<eve::animation::MontageClipAsset> montageClips() {
std::vector<eve::animation::MontageClipAsset> clips;
clips.push_back({"memory://clips/anticipation", rootClip("anticipation", 1.0f)});
clips.push_back({"memory://clips/strike", rootClip("strike", 2.0f)});
return clips;
}
std::vector<eve::animation::MontageClipAsset> maskedMontageClips() {
auto clip = std::make_unique<eve::animation::AnimClip>("masked");
clip->setDuration(1.0f);
clip->setLoop(false);
clip->addPositionKey(0, 0.0f, 0.0f, 0.0f, 0.0f);
clip->addPositionKey(0, 1.0f, 2.0f, 0.0f, 0.0f);
clip->addPositionKey(1, 0.0f, 0.0f, 1.0f, 0.0f);
clip->addPositionKey(1, 1.0f, 0.0f, 3.0f, 0.0f);
std::vector<eve::animation::MontageClipAsset> clips;
clips.push_back({"memory://clips/anticipation", std::move(clip)});
clips.push_back({"memory://clips/strike", rootClip("unused", 0.0f)});
return clips;
}
class RootReceiver final : public eve::animation::IMontageRootMotionReceiver {
public:
void applyMontageRootMotion(const eve::animation::TransformTRS& delta) noexcept override {
x += delta.px;
y += delta.py;
++calls;
}
float x = 0.0f;
float y = 0.0f;
int calls = 0;
};
} // namespace
TEST_CASE("actionMontage.schemaV4RoundTripAndLegacyMigration") {
auto timeline = montageTimeline();
timeline.montage.basePlayRate = 1.25;
timeline.montage.looping = true;
timeline.montage.footIk = true;
timeline.montage.animationLayer = 2;
timeline.montage.rootMotionVertical = true;
auto encoded = timeline.toValue();
REQUIRE(encoded.ok());
auto decoded = eve::action::ActionTimeline::fromValue(encoded.value());
REQUIRE(decoded.ok());
REQUIRE_EQ(decoded.value().animationSections.size(), 2U);
CHECK_EQ(decoded.value().animationSections[1].blendIn, eve::Duration::fromSeconds(0.2).takeValue());
CHECK_EQ(decoded.value().sectionCount(), 3U);
CHECK_EQ(decoded.value().montage.basePlayRate, 1.25);
CHECK(decoded.value().montage.looping);
CHECK(decoded.value().montage.footIk);
CHECK_EQ(decoded.value().montage.animationLayer, 2U);
auto middleRange = decoded.value().sectionRange(1);
REQUIRE(middleRange.ok());
CHECK_EQ(middleRange.value().first, eve::Duration::fromSeconds(0.8).takeValue());
CHECK_EQ(middleRange.value().second, eve::Duration::fromSeconds(1.2).takeValue());
auto versionTwo = encoded.value();
auto* versionTwoObject = versionTwo.getIf<eve::Value::Object>();
REQUIRE(versionTwoObject != nullptr);
(*versionTwoObject)["schemaVersion"] = static_cast<std::int64_t>(2);
versionTwoObject->erase("splitTimestampsNs");
versionTwoObject->erase("montage");
auto* sections = (*versionTwoObject)["animationSections"].getIf<eve::Value::Array>();
REQUIRE(sections != nullptr);
for (auto& section : *sections) {
auto* object = section.getIf<eve::Value::Object>();
REQUIRE(object != nullptr);
object->erase("sourceStartNs");
object->erase("sourceEndNs");
object->erase("blendCurve");
}
auto* tracks = (*versionTwoObject)["tracks"].getIf<eve::Value::Array>();
REQUIRE(tracks != nullptr);
for (auto& track : *tracks) {
auto* trackObject = track.getIf<eve::Value::Object>();
REQUIRE(trackObject != nullptr);
for (const char* collection : {"notifies", "states"}) {
auto* items = (*trackObject)[collection].getIf<eve::Value::Array>();
REQUIRE(items != nullptr);
for (auto& item : *items) {
auto* itemObject = item.getIf<eve::Value::Object>();
REQUIRE(itemObject != nullptr);
itemObject->erase("enabled");
}
}
}
auto migratedV2 = eve::action::ActionTimeline::fromValue(versionTwo);
REQUIRE(migratedV2.ok());
CHECK(migratedV2.value().splitTimestamps.empty());
CHECK_EQ(migratedV2.value().animationSections[0].blendCurve, eve::action::ActionBlendCurve::EaseInOut);
CHECK(migratedV2.value().tracks[0].states[0].enabled);
auto legacy = encoded.value();
auto* legacyObject = legacy.getIf<eve::Value::Object>();
REQUIRE(legacyObject != nullptr);
(*legacyObject)["schemaVersion"] = static_cast<std::int64_t>(1);
legacyObject->erase("animationSections");
(*legacyObject)["animationUri"] = "memory://clips/legacy";
auto migrated = eve::action::ActionTimeline::fromValue(legacy);
REQUIRE(migrated.ok());
REQUIRE_EQ(migrated.value().animationSections.size(), 1U);
CHECK_EQ(migrated.value().animationSections[0].animationUri, "memory://clips/legacy");
}
TEST_CASE("actionMontage.settingsUpdateIsValidatedAndPreservesPreparedPlayback") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontagePlayer player(skeleton);
REQUIRE(player.prepare(montageTimeline(), montageClips()).ok());
REQUIRE(player.play(eve::action::ActionExecutionId(77)).ok());
auto settings = montageTimeline().montage;
settings.basePlayRate = 1.5;
settings.animationLayer = 3;
settings.footIk = true;
settings.rootMotionVertical = true;
REQUIRE(player.setSettings(settings).ok());
CHECK(player.isPlaying());
CHECK_EQ(player.animationLayer(), 3U);
CHECK(player.footIkEnabled());
settings.basePlayRate = 0.0;
CHECK(!player.setSettings(settings).ok());
CHECK_EQ(player.animationLayer(), 3U);
CHECK(player.footIkEnabled());
}
TEST_CASE("actionMontage.sectionSplitUpdateIsAtomicAndPreservesPreparedPlayback") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontagePlayer player(skeleton);
REQUIRE(player.prepare(montageTimeline(), montageClips()).ok());
REQUIRE(player.play(eve::action::ActionExecutionId(78)).ok());
REQUIRE(player.setSectionSplits({eve::Duration::fromSeconds(0.25).takeValue(),
eve::Duration::fromSeconds(1.5).takeValue()})
.ok());
REQUIRE(player.jumpToSection(eve::action::ActionExecutionId(78), 1, eve::SimulationTick(1)).ok());
REQUIRE(player.physicalSectionIndex().ok());
CHECK_EQ(player.physicalSectionIndex().value(), 1U);
CHECK(!player.setSectionSplits({eve::Duration::zero()}).ok());
REQUIRE(player.jumpToSection(eve::action::ActionExecutionId(78), 2, eve::SimulationTick(2)).ok());
REQUIRE(player.physicalSectionIndex().ok());
CHECK_EQ(player.physicalSectionIndex().value(), 2U);
CHECK(player.isPlaying());
}
TEST_CASE("actionMontage.clipTrimAndBlendCurveUseAuthoredSectionDuration") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
auto timeline = montageTimeline();
timeline.animationSections[0].sourceStart = eve::Duration::fromSeconds(0.25).takeValue();
timeline.animationSections[0].sourceEnd = eve::Duration::fromSeconds(0.75).takeValue();
timeline.animationSections[0].blendCurve = eve::action::ActionBlendCurve::Linear;
eve::animation::MontagePlayer player(skeleton);
std::vector<eve::animation::MontageClipAsset> clips;
clips.push_back({"memory://clips/anticipation", rootClip("anticipation", 1.0f)});
clips.push_back({"memory://clips/strike", rootClip("strike", 2.0f)});
REQUIRE(player.prepare(timeline, std::move(clips)).ok());
REQUIRE(player.play(eve::action::ActionExecutionId(9)).ok());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId(9);
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
auto presented = player.present(advance, eve::SimulationTick(1));
REQUIRE(presented.ok());
CHECK(std::fabs(player.pose().local(0).px - 0.5f) < 1e-4f);
}
TEST_CASE("actionMontage.lowFrameRateSweepCrossesEveryBoundaryAndSection") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontagePlayer player(skeleton);
auto timeline = montageTimeline();
std::vector<eve::animation::MontageClipAsset> clips;
clips.push_back({"memory://clips/anticipation", rootClip("anticipation", 1.0f)});
clips.push_back({"memory://clips/strike", rootClip("strike", 2.0f)});
REQUIRE(player.prepare(timeline, std::move(clips)).ok());
RootReceiver receiver;
player.setRootMotionReceiver(receiver);
eve::action::ActionDefinition definition;
definition.id = timeline.actionId;
definition.timing.active = timeline.duration;
definition.timeline = timeline;
eve::action::ActionRequest request;
request.actionId = definition.id;
eve::action::ActionRuntime runtime;
auto idResult = runtime.submit(std::move(definition), std::move(request));
REQUIRE(idResult.ok());
REQUIRE(player.play(idResult.value()).ok());
auto actionAdvance =
runtime.advance(idResult.value(), eve::SimulationTick(1), eve::Duration::fromSeconds(1.5).takeValue());
REQUIRE(actionAdvance.ok());
auto advanced = player.present(actionAdvance.value(), eve::SimulationTick(1));
REQUIRE(advanced.ok());
REQUIRE_EQ(advanced.value().events.size(), 2U);
CHECK(static_cast<int>(advanced.value().events[0].kind) ==
static_cast<int>(eve::action::ActionTimelineEventKind::StateEnter));
CHECK(static_cast<int>(advanced.value().events[1].kind) ==
static_cast<int>(eve::action::ActionTimelineEventKind::StateExit));
CHECK_EQ(advanced.value().sectionId, std::optional<eve::LogicalId>(id("montage-section:strike")));
CHECK(std::fabs(receiver.x - 2.0f) < 1e-4f);
CHECK_EQ(receiver.calls, 1);
}
TEST_CASE("actionMontage.jumpSectionProgressAndInterruptedBlendOutKeepBlockPairs") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontagePlayer player(skeleton);
auto timeline = montageTimeline();
std::vector<eve::animation::MontageClipAsset> clips;
clips.push_back({"memory://clips/anticipation", rootClip("anticipation", 1.0f)});
clips.push_back({"memory://clips/strike", rootClip("strike", 2.0f)});
REQUIRE(player.prepare(timeline, std::move(clips)).ok());
const eve::action::ActionExecutionId execution(5);
REQUIRE(player.play(execution).ok());
eve::action::ActionAdvance advance;
advance.id = execution;
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.9).takeValue();
auto activeBlocks = timeline.activeBlocks(advance.totalElapsed);
REQUIRE(activeBlocks.ok());
advance.activeBlocks = std::move(activeBlocks).takeValue();
auto entered = player.present(advance, eve::SimulationTick(1));
REQUIRE(entered.ok());
REQUIRE_EQ(entered.value().activeBlocks.size(), 1U);
CHECK_EQ(entered.value().activeBlocks[0].itemId, id("combat-state:hit"));
auto jumped = player.jumpToTime(execution, eve::Duration::fromSeconds(1.5).takeValue(), eve::SimulationTick(2));
REQUIRE(jumped.ok());
REQUIRE_EQ(jumped.value().events.size(), 1U);
CHECK_EQ(jumped.value().events[0].kind, eve::action::ActionTimelineEventKind::StateExit);
CHECK(jumped.value().activeBlocks.empty());
auto physical = player.physicalSectionIndex();
REQUIRE(physical.ok());
CHECK_EQ(physical.value(), 2U);
auto evaluated = player.evaluateSectionProgress(execution, 1, 0.5, eve::SimulationTick(3));
REQUIRE(evaluated.ok());
CHECK_EQ(evaluated.value().current, eve::Duration::fromSeconds(1.0).takeValue());
REQUIRE_EQ(evaluated.value().events.size(), 1U);
CHECK_EQ(evaluated.value().events[0].kind, eve::action::ActionTimelineEventKind::StateEnter);
auto stopping = player.beginBlendOut(eve::Duration::fromSeconds(0.2).takeValue(), eve::SimulationTick(4));
REQUIRE(stopping.ok());
REQUIRE_EQ(stopping.value().events.size(), 1U);
CHECK_EQ(stopping.value().events[0].kind, eve::action::ActionTimelineEventKind::StateExit);
auto half = player.advanceBlendOut(eve::Duration::fromSeconds(0.1).takeValue(), eve::SimulationTick(5));
REQUIRE(half.ok());
CHECK(std::fabs(half.value().weight - 0.5) < 1e-6);
CHECK(!half.value().completed);
auto finished = player.advanceBlendOut(eve::Duration::fromSeconds(0.1).takeValue(), eve::SimulationTick(6));
REQUIRE(finished.ok());
CHECK(finished.value().completed);
CHECK(!player.isPlaying());
}
TEST_CASE("actionMontage.editorSectionsUseCanonicalUndoableTransactions") {
eve::editor::ActionTimelineEditor editor("asset.combat.montage", montageTimeline());
REQUIRE(editor.renameTrack(id("combat-track:gameplay"), "Damage Windows").ok());
REQUIRE(editor.setTrackLocked(id("combat-track:gameplay"), true).ok());
CHECK(!editor.moveItem(id("combat-state:hit"), eve::Duration::fromSeconds(0.1).takeValue()).ok());
REQUIRE(editor.setTrackLocked(id("combat-track:gameplay"), false).ok());
REQUIRE(editor.setTrackMuted(id("combat-track:gameplay"), true).ok());
REQUIRE(editor.undo().ok());
CHECK(!editor.target().timeline().tracks[0].muted);
auto moved = editor.moveAnimationSection(id("montage-section:strike"), eve::Duration::fromSeconds(0.1).takeValue());
CHECK(!moved.ok());
CHECK_EQ(editor.target().timeline().animationSections[1].start, eve::Duration::fromSeconds(1.0).takeValue());
REQUIRE(editor.removeAnimationSection(id("montage-section:strike")).ok());
REQUIRE_EQ(editor.target().timeline().animationSections.size(), 1U);
REQUIRE(editor.undo().ok());
CHECK_EQ(editor.target().timeline().animationSections.size(), 2U);
REQUIRE(editor.removeAnimationSection(id("montage-section:strike")).ok());
REQUIRE(editor.selectItem(id("montage-section:anticipation"), false).ok());
auto copied = editor.copySelection();
REQUIRE(copied.ok());
CHECK_EQ(copied.value(), 1U);
auto pasted = editor.paste(eve::Duration::fromSeconds(1.0).takeValue());
REQUIRE(pasted.ok());
CHECK_EQ(pasted.value(), 1U);
CHECK_EQ(editor.target().timeline().animationSections.size(), 2U);
eve::action::ActionNotifyRegistry registry;
eve::editor::ActionTimelineWidget widget(editor, registry);
REQUIRE(widget.setViewport(800.0f, 24.0f).ok());
REQUIRE(widget.setSnapInterval(eve::Duration::fromSeconds(0.25).takeValue()).ok());
REQUIRE(widget.seek(300.0f).ok());
CHECK_EQ(editor.previewTime(), eve::Duration::fromSeconds(0.5).takeValue());
const auto layout = widget.layout();
CHECK_EQ(layout.height, 48.0f);
CHECK_EQ(layout.items.size(), 3U);
CHECK_EQ(layout.rulerTicks.size(), 9U);
REQUIRE(widget.zoom(2.0, 0.5).ok());
REQUIRE(widget.seek(120.0f).ok());
CHECK_EQ(editor.previewTime(), eve::Duration::fromSeconds(0.5).takeValue());
REQUIRE(widget.pan(eve::Duration::fromSeconds(0.25).takeValue()).ok());
REQUIRE(widget.seek(120.0f).ok());
CHECK_EQ(editor.previewTime(), eve::Duration::fromSeconds(0.75).takeValue());
}
TEST_CASE("actionMontage.coordinatorPingPongsSlotsAndRejectsStaleHandles") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontageCoordinator coordinator(skeleton);
auto firstTimeline = montageTimeline();
firstTimeline.montage.defaultBlendIn = eve::Duration::fromSeconds(0.2).takeValue();
auto first =
coordinator.play(0, firstTimeline, montageClips(), eve::action::ActionExecutionId(11), eve::SimulationTick(1));
REQUIRE(first.ok());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId(11);
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(first.value(), advance, eve::SimulationTick(1)).ok());
auto secondTimeline = montageTimeline();
secondTimeline.montage.defaultBlendIn = eve::Duration::fromSeconds(0.2).takeValue();
auto second =
coordinator.play(0, secondTimeline, montageClips(), eve::action::ActionExecutionId(12), eve::SimulationTick(2));
REQUIRE(second.ok());
CHECK_NE(first.value().index(), second.value().index());
REQUIRE(coordinator.resolve(first.value()).ok());
eve::action::ActionAdvance secondAdvance;
secondAdvance.id = eve::action::ActionExecutionId(12);
secondAdvance.phase = eve::action::ActionPhase::Active;
secondAdvance.totalElapsed = eve::Duration::fromSeconds(0.1).takeValue();
REQUIRE(coordinator.present(second.value(), secondAdvance, eve::SimulationTick(3)).ok());
REQUIRE(coordinator.advanceBlendOuts(eve::Duration::fromSeconds(0.1).takeValue(), eve::SimulationTick(3)).ok());
auto layerPose = coordinator.pose(0);
REQUIRE(layerPose.ok());
CHECK(std::fabs(layerPose.value().get().local(0).px - 0.3f) < 1e-5f);
REQUIRE(coordinator.advanceBlendOuts(eve::Duration::fromSeconds(0.1).takeValue(), eve::SimulationTick(4)).ok());
auto stale = coordinator.resolve(first.value());
CHECK(!stale.ok());
auto third = coordinator.play(0, montageTimeline(), montageClips(), eve::action::ActionExecutionId(13),
eve::SimulationTick(5));
REQUIRE(third.ok());
CHECK_EQ(third.value().index(), first.value().index());
CHECK_NE(third.value().generation(), first.value().generation());
}
TEST_CASE("actionMontage.coordinatorRetainsCompletedPoseUntilNextCrossFade") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontageCoordinator coordinator(skeleton);
auto first = coordinator.play(0, montageTimeline(), montageClips(), eve::action::ActionExecutionId(41),
eve::SimulationTick(1));
REQUIRE(first.ok());
eve::action::ActionAdvance completed;
completed.id = eve::action::ActionExecutionId(41);
completed.phase = eve::action::ActionPhase::Completed;
completed.totalElapsed = eve::Duration::fromSeconds(2.0).takeValue();
REQUIRE(coordinator.present(first.value(), completed, eve::SimulationTick(2)).ok());
REQUIRE(coordinator.advanceBlendOuts(eve::Duration::zero(), eve::SimulationTick(3)).ok());
auto retained = coordinator.resolve(first.value());
REQUIRE(retained.ok());
CHECK(retained.value().get().isFinished());
auto retainedPose = coordinator.pose(0);
REQUIRE(retainedPose.ok());
CHECK(std::fabs(retainedPose.value().get().local(0).px - 2.0f) < 1e-5f);
auto nextTimeline = montageTimeline();
nextTimeline.montage.defaultBlendIn = eve::Duration::fromSeconds(0.2).takeValue();
auto second = coordinator.play(0, nextTimeline, montageClips(), eve::action::ActionExecutionId(42),
eve::SimulationTick(4));
REQUIRE(second.ok());
eve::action::ActionAdvance entering;
entering.id = eve::action::ActionExecutionId(42);
entering.phase = eve::action::ActionPhase::Active;
entering.totalElapsed = eve::Duration::fromSeconds(0.1).takeValue();
REQUIRE(coordinator.present(second.value(), entering, eve::SimulationTick(5)).ok());
REQUIRE(coordinator.advanceBlendOuts(eve::Duration::fromSeconds(0.1).takeValue(),
eve::SimulationTick(5), second.value()).ok());
auto blendedPose = coordinator.pose(0);
REQUIRE(blendedPose.ok());
CHECK(std::fabs(blendedPose.value().get().local(0).px - 1.05f) < 1e-5f);
REQUIRE(coordinator.advanceBlendOuts(eve::Duration::fromSeconds(0.1).takeValue(),
eve::SimulationTick(6), second.value()).ok());
CHECK(!coordinator.resolve(first.value()).ok());
}
TEST_CASE("actionMontage.coordinatorLayerMaskRestrictsCompositionPerBone") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
skeleton.addBone("upper", 0);
skeleton.setBindPosition(1, 0.0f, 1.0f, 0.0f);
eve::animation::MontageCoordinator coordinator(skeleton);
CHECK(!coordinator.setLayerBoneMask(0, {1.0f}).ok());
CHECK(!coordinator.setLayerBoneMask(0, {0.0f, std::numeric_limits<float>::infinity()}).ok());
eve::animation::AnimBoneMask upperBody(&skeleton);
REQUIRE(upperBody.setBoneAndChildren("upper", 0.5f));
REQUIRE(coordinator.setLayerBoneMask(0, upperBody).ok());
eve::animation::AnimSkeleton otherSkeleton;
otherSkeleton.addBone("root");
eve::animation::AnimBoneMask foreignMask(&otherSkeleton);
CHECK(!coordinator.setLayerBoneMask(0, foreignMask).ok());
auto configured = coordinator.layerBoneMask(0);
REQUIRE(configured.ok());
REQUIRE_EQ(configured.value().size(), 2U);
CHECK_EQ(configured.value()[1], 0.5f);
upperBody.setAll(0.0f);
configured = coordinator.layerBoneMask(0);
REQUIRE(configured.ok());
CHECK_EQ(configured.value()[1], 0.5f);
auto handle = coordinator.play(0, montageTimeline(), maskedMontageClips(),
eve::action::ActionExecutionId(21), eve::SimulationTick(1));
REQUIRE(handle.ok());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId(21);
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(handle.value(), advance, eve::SimulationTick(2)).ok());
auto pose = coordinator.pose(0);
REQUIRE(pose.ok());
CHECK(std::fabs(pose.value().get().local(0).px) < 1e-5f);
CHECK(std::fabs(pose.value().get().local(1).py - 1.5f) < 1e-5f);
REQUIRE(coordinator.clearLayerBoneMask(0).ok());
REQUIRE(coordinator.pose(0).ok());
auto unmasked = coordinator.pose(0);
REQUIRE(unmasked.ok());
CHECK(std::fabs(unmasked.value().get().local(0).px - 1.0f) < 1e-5f);
CHECK(std::fabs(unmasked.value().get().local(1).py - 2.0f) < 1e-5f);
}
TEST_CASE("actionMontage.coordinatorComposesWeightedOverrideAndAdditiveLayersOverBasePose") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
skeleton.addBone("upper", 0);
skeleton.setBindPosition(1, 0.0f, 1.0f, 0.0f);
eve::animation::MontageCoordinator coordinator(skeleton);
CHECK(!coordinator.setLayerWeight(0, -0.1f).ok());
CHECK(!coordinator.setLayerWeight(0, std::numeric_limits<float>::infinity()).ok());
REQUIRE(coordinator.setLayerWeight(0, 0.5f).ok());
REQUIRE(coordinator.setLayerBoneMask(0, {0.0f, 1.0f}).ok());
auto overrideHandle = coordinator.play(0, montageTimeline(), maskedMontageClips(),
eve::action::ActionExecutionId(51), eve::SimulationTick(1));
REQUIRE(overrideHandle.ok());
eve::action::ActionAdvance overrideAdvance;
overrideAdvance.id = eve::action::ActionExecutionId(51);
overrideAdvance.phase = eve::action::ActionPhase::Active;
overrideAdvance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(overrideHandle.value(), overrideAdvance, eve::SimulationTick(2)).ok());
REQUIRE(coordinator.setLayerWeight(1, 0.5f).ok());
REQUIRE(coordinator.setLayerAdditive(1, true).ok());
auto additiveHandle = coordinator.play(1, montageTimeline(), montageClips(), eve::action::ActionExecutionId(52),
eve::SimulationTick(3));
REQUIRE(additiveHandle.ok());
eve::action::ActionAdvance additiveAdvance;
additiveAdvance.id = eve::action::ActionExecutionId(52);
additiveAdvance.phase = eve::action::ActionPhase::Active;
additiveAdvance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(additiveHandle.value(), additiveAdvance, eve::SimulationTick(4)).ok());
eve::animation::AnimPose base(skeleton.getBoneCount());
skeleton.applyBindPose(&base);
base.setLocalPosition(0, 10.0f, 0.0f, 0.0f);
base.setLocalPosition(1, 0.0f, 10.0f, 0.0f);
auto composed = coordinator.compose(base);
REQUIRE(composed.ok());
CHECK(std::fabs(composed.value().get().local(0).px - 10.25f) < 1e-5f);
CHECK(std::fabs(composed.value().get().local(1).py - 6.0f) < 1e-5f);
auto weight = coordinator.layerWeight(0);
REQUIRE(weight.ok());
CHECK_EQ(weight.value(), 0.5f);
auto additive = coordinator.layerAdditive(1);
REQUIRE(additive.ok());
CHECK(additive.value());
eve::animation::AnimPose foreignBase(1);
CHECK(!coordinator.compose(foreignBase).ok());
}
TEST_CASE("actionMontage.coordinatorRootMotionReceiverFollowsLayerSlots") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontageCoordinator coordinator(skeleton);
RootReceiver receiver;
REQUIRE(coordinator.setLayerRootMotionReceiver(0, receiver).ok());
auto first = coordinator.play(0, montageTimeline(), montageClips(), eve::action::ActionExecutionId(31),
eve::SimulationTick(1));
REQUIRE(first.ok());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId(31);
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(first.value(), advance, eve::SimulationTick(2)).ok());
CHECK_EQ(receiver.calls, 1);
CHECK(std::fabs(receiver.x - 0.5f) < 1e-5f);
auto second = coordinator.play(0, montageTimeline(), montageClips(), eve::action::ActionExecutionId(32),
eve::SimulationTick(3));
REQUIRE(second.ok());
advance.id = eve::action::ActionExecutionId(32);
REQUIRE(coordinator.present(second.value(), advance, eve::SimulationTick(4)).ok());
CHECK_EQ(receiver.calls, 2);
REQUIRE(coordinator.clearLayerRootMotionReceiver(0).ok());
advance.totalElapsed = eve::Duration::fromSeconds(0.75).takeValue();
REQUIRE(coordinator.present(second.value(), advance, eve::SimulationTick(5)).ok());
CHECK_EQ(receiver.calls, 2);
REQUIRE(coordinator.clearLayerRootMotionReceiver(0).ok());
}
TEST_CASE("actionMontage.hotReloadIsTransactionalAndPreservesCursor") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
eve::animation::MontagePlayer player(skeleton);
REQUIRE(player.prepare(montageTimeline(), montageClips()).ok());
const eve::action::ActionExecutionId execution(17);
REQUIRE(player.play(execution).ok());
eve::action::ActionAdvance advance;
advance.id = execution;
advance.phase = eve::action::ActionPhase::Active;
advance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(player.present(advance, eve::SimulationTick(1)).ok());
CHECK(std::fabs(player.pose().local(0).px - 0.5f) < 1e-4f);
auto invalidClip = rootClip("invalid", 9.0f);
invalidClip->setDuration(0.0f);
CHECK(!player.replaceClip("memory://clips/anticipation", std::move(invalidClip)).ok());
CHECK(std::fabs(player.pose().local(0).px - 0.5f) < 1e-4f);
auto replacement = rootClip("replacement", 3.0f);
auto cloned = replacement->clone();
replacement->setDuration(2.0f);
CHECK_EQ(cloned->getName(), std::string("replacement"));
CHECK(std::fabs(cloned->getDuration() - 1.0f) < 1e-5f);
REQUIRE(player.replaceClip("memory://clips/anticipation", std::move(cloned)).ok());
CHECK_EQ(player.time(), eve::Duration::fromSeconds(0.5).takeValue());
CHECK(std::fabs(player.pose().local(0).px - 1.5f) < 1e-4f);
}