diff --git a/cmake/module_manifest.cmake b/cmake/module_manifest.cmake index 28b164dc..514d5964 100644 --- a/cmake/module_manifest.cmake +++ b/cmake/module_manifest.cmake @@ -105,7 +105,7 @@ eve_declare_module(NAME ik LIB EVIK LAYER 0 SCRIPT IK eve_declare_module(NAME editor LAYER 6 SCRIPT Editor SLOT editor DEPS action property_access tags transaction GROUP 3d web - OPTIONAL_DEPS procgen map voxel) + OPTIONAL_DEPS animation procgen map voxel) # L0 -- foundation (continued) eve_declare_module(NAME plugins LAYER 0 SCRIPT Plugins GROUP 3d) diff --git a/docs/dev/战斗系统与动作编辑器.md b/docs/dev/战斗系统与动作编辑器.md index 87ea73e3..32d54442 100644 --- a/docs/dev/战斗系统与动作编辑器.md +++ b/docs/dev/战斗系统与动作编辑器.md @@ -146,3 +146,9 @@ Root Motion 由可选 `action::IActionRootMotionSource` 提供。缺少 Provider `Unavailable`,不会静默生成零轨迹;Provider 返回拥有型三维采样点,Controller 可通过 `IEditorOverlay` 绘制路径。Controller、Editor、Sink 和 Provider 都由宿主拥有并保证生命周期, 接口仅允许 owner thread 调用,不跨帧保留输入引用,也不在锁内调用未知回调。 + +完整 3D 配置可使用 `editor::AnimationRootMotionPreviewSource`。它按 URI 在 +`animation::AnimClipRegistry` 中即时解析唯一 Clip,并采样指定 root bone 的局部位移;不缓存 Clip +指针,所以 Clip 先销毁时下一次采样得到明确 NotFound,Clip 热重载则沿用 Registry 维护的稳定实例。 +同一 URI 存在多个 Clip 会以 Conflict 拒绝,避免预览结果依赖注册顺序。裁剪掉 animation 模块时, +同一适配器返回 Unsupported,Editor 核心仍可独立使用。 diff --git a/src/modules/editor/AnimationRootMotionPreviewSource.cpp b/src/modules/editor/AnimationRootMotionPreviewSource.cpp new file mode 100644 index 00000000..4838693e --- /dev/null +++ b/src/modules/editor/AnimationRootMotionPreviewSource.cpp @@ -0,0 +1,76 @@ +#include "editor/AnimationRootMotionPreviewSource.h" + +#include "common/Diagnostic.h" + +#if defined(EVENGINE_HAS_ANIMATION) +#include "animation/AnimClip.h" +#include "animation/AnimClipRegistry.h" +#include "animation/AnimPose.h" + +#include "common/Exception.h" +#endif + +#include +#include + +namespace eve::editor { +namespace { + +Result> sampleError(DiagnosticCode code, std::string message, + std::string path = {}) { + return Result>::failure( + Diagnostic::error(code, std::move(message), std::move(path))); +} + +} // namespace + +Result> AnimationRootMotionPreviewSource::sampleRootMotion( + std::string_view animationUri, Duration duration, std::uint32_t sampleCount) const { + if (animationUri.empty()) + return sampleError(DiagnosticCode::InvalidArgument, "Animation URI must not be empty", "animationUri"); + if (duration < Duration::zero()) + return sampleError(DiagnosticCode::InvalidArgument, "Preview duration must be non-negative", "duration"); + if (sampleCount < 2 || sampleCount > 1024) + return sampleError(DiagnosticCode::InvalidArgument, "Root-motion sample count must be between 2 and 1024", + "sampleCount"); + if (rootBone_ > static_cast(std::numeric_limits::max())) + return sampleError(DiagnosticCode::InvalidArgument, "Root-bone index exceeds the animation API range", + "rootBone"); + +#if !defined(EVENGINE_HAS_ANIMATION) + return sampleError(DiagnosticCode::Unsupported, "Animation module is not available in this build", + std::string(animationUri)); +#else + const std::string uri(animationUri); + auto clips = animation::AnimClipRegistry::findByPath(uri); + if (clips.empty()) + return sampleError(DiagnosticCode::NotFound, "No registered animation clip matches the preview URI", uri); + if (clips.size() != 1) + return sampleError(DiagnosticCode::Conflict, "Preview URI resolves to more than one animation clip", uri); + + animation::AnimClip* clip = clips.front(); + if (!clip) return sampleError(DiagnosticCode::StaleHandle, "Animation registry returned a stale clip", uri); + const int rootBone = static_cast(rootBone_); + if (rootBone >= clip->getTrackCount()) + return sampleError(DiagnosticCode::InvalidArgument, "Root-bone track is not present in the animation clip", + "rootBone"); + + std::vector path; + path.reserve(sampleCount); + animation::AnimPose pose; + try { + for (std::uint32_t index = 0; index < sampleCount; ++index) { + const double alpha = static_cast(index) / static_cast(sampleCount - 1); + const float sampleTime = static_cast(duration.seconds() * alpha); + clip->sample(sampleTime, &pose); + const auto& root = pose.local(rootBone); + path.push_back({root.px, root.py, root.pz}); + } + } catch (const Exception&) { + return sampleError(DiagnosticCode::Failed, "Animation clip failed while sampling root motion", uri); + } + return Result>::success(std::move(path)); +#endif +} + +} // namespace eve::editor diff --git a/src/modules/editor/AnimationRootMotionPreviewSource.h b/src/modules/editor/AnimationRootMotionPreviewSource.h new file mode 100644 index 00000000..54874e1d --- /dev/null +++ b/src/modules/editor/AnimationRootMotionPreviewSource.h @@ -0,0 +1,32 @@ +#pragma once + +/** @file AnimationRootMotionPreviewSource.h @brief Optional AnimClip-backed root-motion preview adapter. */ + +#include "action/ActionPreview.h" + +#include + +namespace eve::editor { + +/** + * @brief Samples root motion from the animation module's registered clip for an action URI. + * + * The source stores only a root-bone index. Each call resolves the clip from + * AnimClipRegistry and retains no pointer after returning, so clip destruction + * and hot reload remain observable through the registry. The class is usable + * in trimmed builds; without animation it returns Unsupported. + */ +class AnimationRootMotionPreviewSource final : public action::IActionRootMotionSource { +public: + /** @brief Construct a sampler for one zero-based root-bone track. */ + explicit AnimationRootMotionPreviewSource(std::uint32_t rootBone = 0) noexcept : rootBone_(rootBone) {} + + /** @brief Resolve one registered clip and sample its root track across the requested action duration. */ + [[nodiscard]] Result> sampleRootMotion( + std::string_view animationUri, Duration duration, std::uint32_t sampleCount) const override; + +private: + std::uint32_t rootBone_ = 0; +}; + +} // namespace eve::editor diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 562fa499..0f6b96cf 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -126,6 +126,7 @@ file(GLOB all_test_cpp action_notify_registry.cpp action_timeline_widget.cpp action_preview_controller.cpp + animation_action_preview.cpp ability_system.cpp combat_damage.cpp gameplay_action.cpp diff --git a/test/animation_action_preview.cpp b/test/animation_action_preview.cpp new file mode 100644 index 00000000..65be8526 --- /dev/null +++ b/test/animation_action_preview.cpp @@ -0,0 +1,70 @@ +#include "editor/AnimationRootMotionPreviewSource.h" + +#include "animation/AnimClip.h" +#include "animation/AnimClipRegistry.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +namespace { + +void configureClip(eve::animation::AnimClip& clip) { + clip.setDuration(1.0f); + clip.setLoop(false); + clip.addPositionKey(0, 0.0f, 0.0f, 0.0f, 0.0f); + clip.addPositionKey(0, 1.0f, 4.0f, 2.0f, 8.0f); +} + +} // namespace + +TEST_CASE("animationActionPreview.samplesRegisteredClipWithoutRetainingIt") { + const std::string uri = "asset://animations/root-motion-preview.eva"; + eve::animation::AnimClip clip("root-motion-preview"); + configureClip(clip); + eve::animation::AnimClipRegistry::registerPath(uri, &clip); + eve::editor::AnimationRootMotionPreviewSource source; + + auto path = source.sampleRootMotion(uri, eve::Duration::fromNanoseconds(1000000000), 3); + REQUIRE(path.ok()); + REQUIRE_EQ(path.value().size(), 3u); + CHECK(std::fabs(path.value()[1].x - 2.0f) < 1e-5f); + CHECK(std::fabs(path.value()[1].y - 1.0f) < 1e-5f); + CHECK(std::fabs(path.value()[2].z - 8.0f) < 1e-5f); +} + +TEST_CASE("animationActionPreview.rejectsMissingAmbiguousAndInvalidRootClips") { + const std::string uri = "asset://animations/ambiguous-preview.eva"; + eve::editor::AnimationRootMotionPreviewSource source; + auto missing = source.sampleRootMotion(uri, eve::Duration::fromNanoseconds(1000000000), 3); + CHECK(!missing.ok()); + + eve::animation::AnimClip first("first"); + eve::animation::AnimClip second("second"); + configureClip(first); + configureClip(second); + eve::animation::AnimClipRegistry::registerPath(uri, &first); + eve::animation::AnimClipRegistry::registerPath(uri, &second); + auto ambiguous = source.sampleRootMotion(uri, eve::Duration::fromNanoseconds(1000000000), 3); + CHECK(!ambiguous.ok()); + + const std::string uniqueUri = "asset://animations/invalid-root-preview.eva"; + eve::animation::AnimClipRegistry::registerPath(uniqueUri, &first); + eve::editor::AnimationRootMotionPreviewSource invalidRoot(1); + auto invalid = invalidRoot.sampleRootMotion(uniqueUri, eve::Duration::fromNanoseconds(1000000000), 3); + CHECK(!invalid.ok()); +} + +TEST_CASE("animationActionPreview.clipDestructionBecomesObservableNotStale") { + const std::string uri = "asset://animations/transient-preview.eva"; + eve::editor::AnimationRootMotionPreviewSource source; + { + eve::animation::AnimClip clip("transient"); + configureClip(clip); + eve::animation::AnimClipRegistry::registerPath(uri, &clip); + auto present = source.sampleRootMotion(uri, eve::Duration::fromNanoseconds(1000000000), 2); + REQUIRE(present.ok()); + } + auto destroyed = source.sampleRootMotion(uri, eve::Duration::fromNanoseconds(1000000000), 2); + CHECK(!destroyed.ok()); +}