246 lines
11 KiB
C++
246 lines
11 KiB
C++
#include "action/editor/ActionPreviewController.h"
|
|
|
|
#include "common/Diagnostic.h"
|
|
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#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 timelineFixture() {
|
|
eve::action::ActionTimeline timeline;
|
|
timeline.actionId = id("combat:preview-test");
|
|
timeline.duration = eve::Duration::fromNanoseconds(100);
|
|
timeline.animationUri = "asset://animations/preview-test.eva";
|
|
|
|
eve::action::ActionTrack track;
|
|
track.id = id("combat-track:presentation");
|
|
track.label = "Presentation";
|
|
track.kind = eve::action::ActionTrackKind::Effect;
|
|
track.notifies.push_back({id("combat-notify:vfx"), id("presentation:vfx"), eve::Duration::fromNanoseconds(20), {}});
|
|
track.notifies.push_back(
|
|
{id("combat-notify:audio"), id("presentation:audio"), eve::Duration::fromNanoseconds(30), {}});
|
|
track.notifies.push_back(
|
|
{id("combat-notify:camera"), id("presentation:camera"), eve::Duration::fromNanoseconds(40), {}});
|
|
track.states.push_back({id("combat-state:hitbox"),
|
|
id("combat:hitbox-window"),
|
|
eve::Duration::fromNanoseconds(10),
|
|
eve::Duration::fromNanoseconds(50),
|
|
{}});
|
|
timeline.tracks.push_back(std::move(track));
|
|
return timeline;
|
|
}
|
|
|
|
class RecordingSink final : public eve::action::IActionPreviewSink {
|
|
public:
|
|
[[nodiscard]] eve::Result<void> prepare(const eve::action::ActionPreviewFrame& frame) override {
|
|
++prepareCount;
|
|
prepared = frame;
|
|
if (rejectPrepare)
|
|
return eve::Result<void>::failure(
|
|
eve::Diagnostic::error(eve::DiagnosticCode::Failed, "preview resources unavailable"));
|
|
return eve::Result<void>::success();
|
|
}
|
|
|
|
void present(const eve::action::ActionPreviewFrame& frame) noexcept override {
|
|
++presentCount;
|
|
presented = frame;
|
|
}
|
|
|
|
void discardPrepared() noexcept override { ++discardCount; }
|
|
|
|
bool rejectPrepare = false;
|
|
int prepareCount = 0;
|
|
int presentCount = 0;
|
|
int discardCount = 0;
|
|
std::optional<eve::action::ActionPreviewFrame> prepared;
|
|
std::optional<eve::action::ActionPreviewFrame> presented;
|
|
};
|
|
|
|
class RecordingRootMotion final : public eve::action::IActionRootMotionSource {
|
|
public:
|
|
[[nodiscard]] eve::Result<std::vector<eve::action::ActionPreviewPoint3>> sampleRootMotion(
|
|
std::string_view animationUri, eve::Duration duration, std::uint32_t sampleCount) const override {
|
|
++calls;
|
|
lastAnimationUri = animationUri;
|
|
lastDuration = duration;
|
|
lastSampleCount = sampleCount;
|
|
if (reject)
|
|
return eve::Result<std::vector<eve::action::ActionPreviewPoint3>>::failure(
|
|
eve::Diagnostic::error(eve::DiagnosticCode::Failed, "animation cannot be sampled"));
|
|
return eve::Result<std::vector<eve::action::ActionPreviewPoint3>>::success(
|
|
{{0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 2.0f}, {3.0f, 1.0f, 2.0f}});
|
|
}
|
|
|
|
mutable bool reject = false;
|
|
mutable int calls = 0;
|
|
mutable std::string lastAnimationUri;
|
|
mutable eve::Duration lastDuration;
|
|
mutable std::uint32_t lastSampleCount = 0;
|
|
};
|
|
|
|
class RecordingOverlay final : public eve::editor::IEditorOverlay {
|
|
public:
|
|
void line(const eve::editor::OverlayPoint& from, const eve::editor::OverlayPoint& to,
|
|
const eve::editor::OverlayStyle&) override {
|
|
++lines;
|
|
first = from;
|
|
last = to;
|
|
}
|
|
void circle(const eve::editor::OverlayPoint&, float, const eve::editor::OverlayStyle&) override {}
|
|
void rectangle(const eve::editor::OverlayPoint&, const eve::editor::OverlayPoint&,
|
|
const eve::editor::OverlayStyle&) override {}
|
|
void text(const eve::editor::OverlayPoint&, const std::string&, const eve::editor::OverlayStyle&) override {}
|
|
|
|
int lines = 0;
|
|
eve::editor::OverlayPoint first;
|
|
eve::editor::OverlayPoint last;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("actionPreviewController.explicitlyReportsMissingRootMotionProvider") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
eve::editor::ActionPreviewController preview(editor, sink);
|
|
|
|
REQUIRE(preview.refresh().ok());
|
|
REQUIRE(sink.presented.has_value());
|
|
CHECK(static_cast<int>(sink.presented->rootMotionState) ==
|
|
static_cast<int>(eve::action::RootMotionPreviewState::Unavailable));
|
|
CHECK(sink.presented->rootMotionPath.empty());
|
|
}
|
|
|
|
TEST_CASE("actionPreviewController.samplesAndDrawsOwningRootMotionPath") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
RecordingRootMotion rootMotion;
|
|
eve::editor::ActionPreviewController preview(editor, sink, &rootMotion);
|
|
REQUIRE(preview.setRootMotionSampleCount(32).ok());
|
|
|
|
REQUIRE(preview.refresh().ok());
|
|
CHECK_EQ(rootMotion.calls, 1);
|
|
CHECK_EQ(rootMotion.lastAnimationUri, "asset://animations/preview-test.eva");
|
|
CHECK_EQ(rootMotion.lastDuration, eve::Duration::fromNanoseconds(100));
|
|
CHECK_EQ(rootMotion.lastSampleCount, 32u);
|
|
REQUIRE(sink.presented.has_value());
|
|
CHECK(static_cast<int>(sink.presented->rootMotionState) ==
|
|
static_cast<int>(eve::action::RootMotionPreviewState::Available));
|
|
|
|
RecordingOverlay overlay;
|
|
preview.drawRootMotion(overlay, {10.0f, 20.0f, 30.0f}, 2.0f);
|
|
CHECK_EQ(overlay.lines, 2);
|
|
CHECK_EQ(overlay.first.x, 12.0f);
|
|
CHECK_EQ(overlay.last.x, 16.0f);
|
|
CHECK_EQ(overlay.last.y, 22.0f);
|
|
}
|
|
|
|
TEST_CASE("actionPreviewController.routesPresentationAndStateBoundaryCues") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
eve::editor::ActionPreviewController preview(editor, sink);
|
|
editor.play();
|
|
|
|
auto advanced = preview.update(eve::Duration::fromNanoseconds(40));
|
|
REQUIRE(advanced.ok());
|
|
REQUIRE_EQ(advanced.value(), 4u);
|
|
REQUIRE(sink.presented.has_value());
|
|
CHECK(static_cast<int>(sink.presented->reason) == static_cast<int>(eve::action::ActionPreviewReason::Advance));
|
|
REQUIRE_EQ(sink.presented->cues.size(), 4u);
|
|
REQUIRE_EQ(sink.presented->activeBlocks.size(), 1u);
|
|
CHECK_EQ(sink.presented->activeBlocks.front().itemId, id("combat-state:hitbox"));
|
|
CHECK(static_cast<int>(sink.presented->cues[0].kind) ==
|
|
static_cast<int>(eve::action::ActionPreviewCueKind::StateEnter));
|
|
CHECK(static_cast<int>(sink.presented->cues[1].kind) == static_cast<int>(eve::action::ActionPreviewCueKind::Vfx));
|
|
CHECK(static_cast<int>(sink.presented->cues[2].kind) == static_cast<int>(eve::action::ActionPreviewCueKind::Audio));
|
|
CHECK(static_cast<int>(sink.presented->cues[3].kind) ==
|
|
static_cast<int>(eve::action::ActionPreviewCueKind::Camera));
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::fromNanoseconds(40));
|
|
}
|
|
|
|
TEST_CASE("actionPreviewController.prepareFailureLeavesTransportUnchanged") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
sink.rejectPrepare = true;
|
|
eve::editor::ActionPreviewController preview(editor, sink);
|
|
editor.play();
|
|
|
|
auto advanced = preview.update(eve::Duration::fromNanoseconds(40));
|
|
CHECK(!advanced.ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::zero());
|
|
CHECK_EQ(sink.prepareCount, 1);
|
|
CHECK_EQ(sink.presentCount, 0);
|
|
}
|
|
|
|
TEST_CASE("actionTimelineEditor.rejectsStaleAndForgedPreviewPlans") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
editor.play();
|
|
auto stale = editor.planPreview(eve::Duration::fromNanoseconds(40));
|
|
REQUIRE(stale.ok());
|
|
REQUIRE(editor
|
|
.addNotify(id("combat-track:presentation"), {id("combat-notify:late"),
|
|
id("gameplay:event"),
|
|
eve::Duration::fromNanoseconds(60),
|
|
{{"tag", eve::Value("Combat.Late")}}})
|
|
.ok());
|
|
auto staleCommit = editor.applyPreviewPlan(std::move(stale.value()));
|
|
CHECK(!staleCommit.ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::zero());
|
|
|
|
auto forged = editor.planPreview(eve::Duration::fromNanoseconds(40));
|
|
REQUIRE(forged.ok());
|
|
forged.value().events.clear();
|
|
auto forgedCommit = editor.applyPreviewPlan(std::move(forged.value()));
|
|
CHECK(!forgedCommit.ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::zero());
|
|
}
|
|
|
|
TEST_CASE("actionPreviewController.seekValidatesBeforePreparing") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
eve::editor::ActionPreviewController preview(editor, sink);
|
|
|
|
CHECK(!preview.seek(eve::Duration::fromNanoseconds(101)).ok());
|
|
CHECK_EQ(sink.prepareCount, 0);
|
|
REQUIRE(preview.seek(eve::Duration::fromNanoseconds(60)).ok());
|
|
CHECK_EQ(sink.prepareCount, 1);
|
|
CHECK_EQ(sink.presentCount, 1);
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::fromNanoseconds(60));
|
|
REQUIRE(sink.presented.has_value());
|
|
CHECK(sink.presented->cues.empty());
|
|
CHECK(sink.presented->activeBlocks.empty());
|
|
}
|
|
|
|
TEST_CASE("actionPreviewController.stepsFramesAndStopsAtomically") {
|
|
eve::editor::ActionTimelineEditor editor("asset.combat.preview", timelineFixture());
|
|
RecordingSink sink;
|
|
eve::editor::ActionPreviewController preview(editor, sink);
|
|
|
|
REQUIRE(preview.stepFrames(1, 20'000'000.0).ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::fromNanoseconds(50));
|
|
CHECK(!editor.playing());
|
|
REQUIRE(preview.stepFrames(-1, 20'000'000.0).ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::zero());
|
|
REQUIRE(preview.jumpToEnd().ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::fromNanoseconds(100));
|
|
|
|
editor.play();
|
|
sink.rejectPrepare = true;
|
|
auto rejected = preview.stop();
|
|
CHECK(!rejected.ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::fromNanoseconds(100));
|
|
CHECK(editor.playing());
|
|
sink.rejectPrepare = false;
|
|
REQUIRE(preview.stop().ok());
|
|
CHECK_EQ(editor.previewTime(), eve::Duration::zero());
|
|
CHECK(!editor.playing());
|
|
CHECK(!preview.stepFrames(1, 0.0).ok());
|
|
}
|