From c4d642c615042a7699ff668b4b49ae227d10edd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Sun, 13 Sep 2026 01:34:43 +0800 Subject: [PATCH 001/160] feat(action): add montage block lifecycle runtime --- docs/dev/战斗系统与动作编辑器.md | 10 ++- src/modules/action/Action.cpp | 13 +++- src/modules/action/Action.h | 2 + src/modules/action/ActionBlockRuntime.cpp | 59 ++++++++++++++++++ src/modules/action/ActionBlockRuntime.h | 63 +++++++++++++++++++ src/modules/action/ActionNotifyRegistry.cpp | 25 ++++++++ src/modules/action/ActionNotifyRegistry.h | 69 ++++++++++++++++++--- src/modules/action/ActionTimeline.cpp | 26 ++++++++ src/modules/action/ActionTimeline.h | 19 ++++++ src/modules/animation/MontagePlayer.cpp | 21 ++----- src/modules/animation/MontagePlayer.h | 13 +--- test/action_montage.cpp | 3 + test/action_notify_registry.cpp | 59 ++++++++++++++++++ test/action_timeline.cpp | 14 +++++ 14 files changed, 361 insertions(+), 35 deletions(-) create mode 100644 src/modules/action/ActionBlockRuntime.cpp create mode 100644 src/modules/action/ActionBlockRuntime.h diff --git a/docs/dev/战斗系统与动作编辑器.md b/docs/dev/战斗系统与动作编辑器.md index 1df65e82..03738f22 100644 --- a/docs/dev/战斗系统与动作编辑器.md +++ b/docs/dev/战斗系统与动作编辑器.md @@ -23,10 +23,17 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模 - `ActionNotify` 表示单点事件;`ActionNotifyState` 表示有明确 Enter/Exit 的区间 - 事件按时间、边界种类、轨道 id、条目 id 稳定排序;静音轨道不投射事件 - `ActionRuntime::advance` 使用注入的确定性 `Duration` 采样 `(previous, current]`,首次启动可包含零时刻 +- `ActionRuntime::advance` 同时返回当前激活区间的拥有型 `ActionActiveBlock`,包括局部时间与区间时长 投射结果拥有自己的 payload,不借用热重载定义中的指针。下游适配器根据 `type` 和 payload 处理伤害窗口、 输入缓冲、碰撞忽略、运动扭曲、VFX、音频或镜头,不需要 `action` 依赖这些提供方。 +`ActionBlockRuntime` 将同一个 advance 依次投递为 Enter/单点事件、持续 Update,并按 execution 保存最后一份 +激活投影。动作被打断时,宿主调用 `interrupt`,它会为仍激活的区间补齐 Exit;已经成功退出的条目立即从 +清理集合删除,因此失败重试不会重复退出它们。编辑器跳转使用 `sample`,该路径强制设置 preview/scrubbing +上下文,handler 契约禁止产生持久副作用。handler 由 `ActionNotifyRegistry` 共享持有,回调同步发生在 owner +thread,不得保存借用参数或重入同一个 `ActionBlockRuntime`。 + ## 持久化约定 编码器接受 v1-v3,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略, @@ -57,7 +64,8 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模 - 时间标尺、帧/时间吸附、围绕锚点缩放与有界平移 - 轨道增删改名、静音和锁定,以及动画分段的 Trim In/Out 和 Blend Curve Inspector -运行宿主由 `ActionRuntime` 唯一负责 gameplay Enter/Update/Exit,`MontagePlayer` 只消费其 advance 并驱动多 Clip 姿态。 +运行宿主由 `ActionRuntime` 唯一负责 gameplay 时间与激活状态,`ActionBlockRuntime` 路由 Enter/Update/Exit, +`MontagePlayer` 只消费同一份 advance 中的事件和 `ActionActiveBlock` 并驱动多 Clip 姿态,不再自行计算第二份激活状态。 `MontageCoordinator` 每层固定两个槽位做 ping-pong 混合,generation handle 在槽位复用后拒绝旧引用。运行时支持跳转、物理分段同步、 暂停、中断混出、低帧率边界扫描和循环尾/头两段扫描。Clip 热更新先完整验证候选,再原子替换并保留当前时间、权重与执行身份。 diff --git a/src/modules/action/Action.cpp b/src/modules/action/Action.cpp index 7b07a858..dbde6919 100644 --- a/src/modules/action/Action.cpp +++ b/src/modules/action/Action.cpp @@ -505,6 +505,7 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic } std::vector timelineEvents; + std::vector activeBlocks; if (execution->definition_.timeline) { auto sampled = execution->definition_.timeline->sample(timelinePrevious, execution->totalElapsed_, !execution->timelineStarted_); @@ -514,6 +515,12 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic } timelineEvents = std::move(sampled).takeValue(); execution->timelineStarted_ = true; + auto active = execution->definition_.timeline->activeBlocks(execution->totalElapsed_); + if (!active) { + failExecution(*execution, active.status(), tick, &transitions); + return failureFrom(execution->status()); + } + activeBlocks = std::move(active).takeValue(); } if (execution->phase_ == ActionPhase::Completed) { ActionAdvance result{id, @@ -521,7 +528,8 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic std::move(transitions), execution->phaseElapsed_, execution->totalElapsed_, - std::move(timelineEvents)}; + std::move(timelineEvents), + std::move(activeBlocks)}; return Result::success(std::move(result), Status::success(StatusCode::Applied)); } ActionAdvance result{id, @@ -529,7 +537,8 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic std::move(transitions), execution->phaseElapsed_, execution->totalElapsed_, - std::move(timelineEvents)}; + std::move(timelineEvents), + std::move(activeBlocks)}; return Result::success(std::move(result), pendingStatus()); } diff --git a/src/modules/action/Action.h b/src/modules/action/Action.h index 05c18c98..13ea2825 100644 --- a/src/modules/action/Action.h +++ b/src/modules/action/Action.h @@ -183,6 +183,8 @@ struct ActionAdvance { Duration totalElapsed = Duration::zero(); /** @brief Owning timeline boundaries crossed by this advance. */ std::vector timelineEvents; + /** @brief Owning samples of timeline states active after this advance. */ + std::vector activeBlocks; }; /** diff --git a/src/modules/action/ActionBlockRuntime.cpp b/src/modules/action/ActionBlockRuntime.cpp new file mode 100644 index 00000000..84cafc42 --- /dev/null +++ b/src/modules/action/ActionBlockRuntime.cpp @@ -0,0 +1,59 @@ +#include "action/ActionBlockRuntime.h" + +#include + +namespace eve::action { +namespace { + +Result invalidContext() { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "Action block context does not match advance", "executionId")); +} + +} // namespace + +Result ActionBlockRuntime::apply(const ActionAdvance& advance, ActionNotifyContext context) { + if (context.executionId != advance.id) return invalidContext(); + context.time = advance.totalElapsed; + for (const auto& event : advance.timelineEvents) { + auto dispatched = registry_.dispatch(event, context); + if (!dispatched) return dispatched; + } + for (const auto& block : advance.activeBlocks) { + auto updated = registry_.dispatchUpdate(block, context); + if (!updated) return updated; + } + if (advance.activeBlocks.empty()) + active_.erase(advance.id); + else + active_[advance.id] = advance.activeBlocks; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result ActionBlockRuntime::interrupt(ActionNotifyContext context) { + const auto found = active_.find(context.executionId); + if (found == active_.end()) return Result::success(Status::success(StatusCode::NoOp)); + auto& blocks = found->second; + while (!blocks.empty()) { + const auto& block = blocks.front(); + ActionTimelineEvent event{ActionTimelineEventKind::StateExit, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto dispatched = registry_.dispatch(event, context); + if (!dispatched) return dispatched; + blocks.erase(blocks.begin()); + } + active_.erase(found); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result ActionBlockRuntime::sample(const std::vector& blocks, ActionNotifyContext context) const { + context.preview = true; + context.scrubbing = true; + for (const auto& block : blocks) { + auto sampled = registry_.dispatchSample(block, context); + if (!sampled) return sampled; + } + return Result::success(Status::success(blocks.empty() ? StatusCode::NoOp : StatusCode::Applied)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionBlockRuntime.h b/src/modules/action/ActionBlockRuntime.h new file mode 100644 index 00000000..53288b88 --- /dev/null +++ b/src/modules/action/ActionBlockRuntime.h @@ -0,0 +1,63 @@ +#pragma once + +/** @file ActionBlockRuntime.h @brief Deterministic notify-state lifecycle routing. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include + +namespace eve::action { + +/** + * @brief Owner-thread lifecycle router for timeline notifies and active blocks. + * + * ActionRuntime remains the timeline authority. This adapter retains only the + * last owning block projection per execution so interruption can emit paired + * exits. The borrowed registry must outlive this object. Handler callbacks are + * synchronous, run without locks, and may reenter unrelated systems but must + * not reenter this instance. + */ +class ActionBlockRuntime { +public: + /** + * @brief Bind the canonical registry used for all synchronous callbacks. + * @param registry Borrowed owner-thread registry. + */ + explicit ActionBlockRuntime(ActionNotifyRegistry& registry) noexcept : registry_(registry) {} + + ActionBlockRuntime(const ActionBlockRuntime&) = delete; + ActionBlockRuntime& operator=(const ActionBlockRuntime&) = delete; + + /** + * @brief Deliver boundaries and active updates from one authoritative advance. + * @param advance Owning result produced by ActionRuntime. + * @param context Owning execution/target context; its id must match advance. + * @return Applied after all handlers succeed, otherwise the first handler failure. + */ + [[nodiscard]] Result apply(const ActionAdvance& advance, ActionNotifyContext context); + + /** + * @brief Emit paired exits for all blocks retained for one interrupted execution. + * @param context Owning context identifying the execution to interrupt. + * @return Applied/NoOp or the first exit failure; successfully exited blocks are removed. + */ + [[nodiscard]] Result interrupt(ActionNotifyContext context); + + /** + * @brief Evaluate active blocks for editor scrubbing without retaining lifecycle state. + * @param blocks Owning block samples from ActionTimeline::activeBlocks. + * @param context Preview context; preview and scrubbing are forced true. + */ + [[nodiscard]] Result sample(const std::vector& blocks, + ActionNotifyContext context) const; + + /** @brief Number of execution projections retained for interruption cleanup. */ + [[nodiscard]] std::size_t executionCount() const noexcept { return active_.size(); } + +private: + ActionNotifyRegistry& registry_; + std::map> active_; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionNotifyRegistry.cpp b/src/modules/action/ActionNotifyRegistry.cpp index 0d3fa7d1..ce407894 100644 --- a/src/modules/action/ActionNotifyRegistry.cpp +++ b/src/modules/action/ActionNotifyRegistry.cpp @@ -113,4 +113,29 @@ Result ActionNotifyRegistry::dispatch(const ActionTimelineEvent& event, co return handler->second->handle(event, context); } +Result ActionNotifyRegistry::dispatchUpdate(const ActionActiveBlock& block, const ActionNotifyContext& context) { + ActionTimelineEvent event{ActionTimelineEventKind::StateEnter, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto valid = validate(event); + if (!valid) return valid; + const auto handler = handlers_.find(block.type.format()); + if (handler == handlers_.end()) + return failure(DiagnosticCode::NotFound, "No runtime handler is registered for notify type", + block.type.format()); + return handler->second->update(block, context); +} + +Result ActionNotifyRegistry::dispatchSample(const ActionActiveBlock& block, + const ActionNotifyContext& context) const { + ActionTimelineEvent event{ActionTimelineEventKind::StateEnter, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto valid = validate(event); + if (!valid) return valid; + const auto handler = handlers_.find(block.type.format()); + if (handler == handlers_.end()) + return failure(DiagnosticCode::NotFound, "No preview handler is registered for notify type", + block.type.format()); + return handler->second->sample(block, context); +} + } // namespace eve::action diff --git a/src/modules/action/ActionNotifyRegistry.h b/src/modules/action/ActionNotifyRegistry.h index 3fd39190..f18d4201 100644 --- a/src/modules/action/ActionNotifyRegistry.h +++ b/src/modules/action/ActionNotifyRegistry.h @@ -26,9 +26,18 @@ struct ActionNotifyDescriptor { /** @brief Context linking a routed event to its action execution. */ struct ActionNotifyContext { + /** @brief Execution whose lifecycle is being routed. */ ActionExecutionId executionId; + /** @brief Optional source entity; resolved by consumers at call time. */ std::optional source; + /** @brief Owning target handle list for this synchronous dispatch. */ std::vector targets; + /** @brief Authoritative timeline time associated with this dispatch. */ + Duration time = Duration::zero(); + /** @brief Whether the callback is an editor/runtime preview projection. */ + bool preview = false; + /** @brief Whether preview time was positioned discontinuously. */ + bool scrubbing = false; }; /** @@ -40,8 +49,33 @@ struct ActionNotifyContext { class IActionNotifyHandler { public: virtual ~IActionNotifyHandler() = default; - /** @brief Consume one already validated event. */ + /** + * @brief Consume one already validated event. + * @param event Owning boundary projection borrowed only for this call. + * @param context Owning dispatch context borrowed only for this call. + * @return Applied/NoOp or a structured consumer failure. + */ [[nodiscard]] virtual Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) = 0; + /** + * @brief Update one active state block after its boundary events were delivered. + * @param block Owning active-state projection borrowed only for this call. + * @param context Owning dispatch context borrowed only for this call. + * @return Applied/NoOp or a structured consumer failure. + * @remarks The default is an explicit no-op for boundary-only handlers. + */ + [[nodiscard]] virtual Result update(const ActionActiveBlock&, const ActionNotifyContext&) { + return Result::success(Status::success(StatusCode::NoOp)); + } + /** + * @brief Evaluate one preview sample without creating persistent side effects. + * @param block Owning active-state projection borrowed only for this call. + * @param context Owning preview context borrowed only for this call. + * @return Applied/NoOp or a structured preview failure. + * @remarks Implementations must be side-effect-free and must not retain references. + */ + [[nodiscard]] virtual Result sample(const ActionActiveBlock&, const ActionNotifyContext&) const { + return Result::success(Status::success(StatusCode::NoOp)); + } }; /** @@ -55,20 +89,41 @@ class ActionNotifyRegistry { public: /** @brief Build a registry containing the engine's standard semantic notify types. */ [[nodiscard]] static Result withBuiltins(); - /** @brief Register a unique descriptor after validating its canonical type. */ + /** @brief Register a unique descriptor after validating its canonical type. @param descriptor Owning contract. */ [[nodiscard]] Result registerDescriptor(ActionNotifyDescriptor descriptor); - /** @brief Register an owning handler for an existing descriptor. */ + /** + * @brief Register an owning handler for an existing descriptor. + * @param type Registered type. + * @param handler Shared handler owner. + */ [[nodiscard]] Result registerHandler(std::string_view type, std::shared_ptr handler); - /** @brief Remove a handler while retaining its descriptor for authoring. */ + /** @brief Remove a handler while retaining its descriptor for authoring. @param type Registered type. */ [[nodiscard]] Result unregisterHandler(std::string_view type); - /** @brief Find an owning descriptor copy. */ + /** @brief Find an owning descriptor copy. @param type Registered type. */ [[nodiscard]] Result descriptor(std::string_view type) const; /** @brief Return descriptors in lexical type order. */ [[nodiscard]] std::vector descriptors() const; - /** @brief Validate type, shape and required payload fields. */ + /** @brief Validate type, shape and required payload fields. @param event Owning event projection. */ [[nodiscard]] Result validate(const ActionTimelineEvent& event) const; - /** @brief Validate and route an event, failing observably when no handler is present. */ + /** + * @brief Validate and route an event, failing observably when no handler is present. + * @param event Event projection. + * @param context Dispatch context. + */ [[nodiscard]] Result dispatch(const ActionTimelineEvent& event, const ActionNotifyContext& context); + /** + * @brief Route one authoritative active-state update to its registered handler. + * @param block Active block. + * @param context Dispatch context. + */ + [[nodiscard]] Result dispatchUpdate(const ActionActiveBlock& block, const ActionNotifyContext& context); + /** + * @brief Route one side-effect-free preview sample to its registered handler. + * @param block Active block. + * @param context Preview context. + */ + [[nodiscard]] Result dispatchSample(const ActionActiveBlock& block, + const ActionNotifyContext& context) const; private: std::map> descriptors_; diff --git a/src/modules/action/ActionTimeline.cpp b/src/modules/action/ActionTimeline.cpp index 7bcdab1f..6f644b09 100644 --- a/src/modules/action/ActionTimeline.cpp +++ b/src/modules/action/ActionTimeline.cpp @@ -295,6 +295,32 @@ Result> ActionTimeline::sample(Duration previou return Result>::success(std::move(out)); } +Result> ActionTimeline::activeBlocks(Duration time) const { + auto valid = validate(); + if (!valid) return Result>::failure(valid.status()); + if (time < Duration::zero() || time > duration) + return invalid>("active-block sample is outside the timeline", "time"); + + std::vector out; + for (const auto& track : tracks) { + if (track.muted) continue; + for (const auto& state : track.states) { + if (time < state.start || time >= state.end) continue; + out.push_back({track.id, + state.id, + state.type, + Duration::fromNanoseconds(time.nanoseconds() - state.start.nanoseconds()), + Duration::fromNanoseconds(state.end.nanoseconds() - state.start.nanoseconds()), + state.payload}); + } + } + std::stable_sort(out.begin(), out.end(), [](const auto& left, const auto& right) { + if (left.trackId != right.trackId) return left.trackId.format() < right.trackId.format(); + return left.itemId.format() < right.itemId.format(); + }); + return Result>::success(std::move(out)); +} + Result ActionTimeline::toValue() const { auto valid = validate(); if (!valid) return Result::failure(valid.status()); diff --git a/src/modules/action/ActionTimeline.h b/src/modules/action/ActionTimeline.h index 9bafa5e8..1f1f2dc1 100644 --- a/src/modules/action/ActionTimeline.h +++ b/src/modules/action/ActionTimeline.h @@ -144,6 +144,18 @@ struct ActionTimelineEvent { auto operator<=>(const ActionTimelineEvent&) const = default; }; +/** @brief Owning per-step sample of one currently active timeline state. */ +struct ActionActiveBlock { + LogicalId trackId; + LogicalId itemId; + LogicalId type; + Duration localTime = Duration::zero(); + Duration duration = Duration::zero(); + Value::Object payload; + + auto operator<=>(const ActionActiveBlock&) const = default; +}; + /** * @brief Canonical action timeline asset shared by runtime and editor. * @@ -179,6 +191,13 @@ struct ActionTimeline { [[nodiscard]] Result> sample(Duration previous, Duration current, bool includePrevious = false) const; + /** + * @brief Sample every unmuted state active at one authoritative timeline time. + * @param time Timestamp in the closed timeline range; state ends remain exclusive. + * @return Owning blocks in stable track/item order, or a validation/range diagnostic. + */ + [[nodiscard]] Result> activeBlocks(Duration time) const; + /** @brief Encode the canonical schema as an owning deterministic Value. */ [[nodiscard]] Result toValue() const; /** @brief Decode and validate one canonical schema value transactionally. */ diff --git a/src/modules/animation/MontagePlayer.cpp b/src/modules/animation/MontagePlayer.cpp index 2a8e04d4..523ee197 100644 --- a/src/modules/animation/MontagePlayer.cpp +++ b/src/modules/animation/MontagePlayer.cpp @@ -280,7 +280,7 @@ Result MontagePlayer::present(const action::ActionAdvance& advan } result.events = advance.timelineEvents; - result.activeBlocks = activeBlocksAt(result.current); + result.activeBlocks = advance.activeBlocks; result.sectionId = activeSection_ ? std::optional(activeSection_->id) : std::nullopt; result.completed = advance.phase == action::ActionPhase::Completed; const double currentSeconds = result.current.seconds(); @@ -328,19 +328,8 @@ std::vector MontagePlayer::realignStateEvents(Durat return events; } -std::vector MontagePlayer::activeBlocksAt(Duration target) const { - std::vector blocks; - for (const auto& track : timeline_->tracks) { - if (track.muted) continue; - for (const auto& state : track.states) { - if (target < state.start || target >= state.end) continue; - blocks.push_back({track.id, state.id, state.type, - Duration::fromNanoseconds(target.nanoseconds() - state.start.nanoseconds()), - Duration::fromNanoseconds(state.end.nanoseconds() - state.start.nanoseconds()), - state.payload}); - } - } - return blocks; +Result> MontagePlayer::activeBlocksAt(Duration target) const { + return timeline_->activeBlocks(target); } Result MontagePlayer::jumpToTime(action::ActionExecutionId executionId, Duration target, @@ -358,7 +347,9 @@ Result MontagePlayer::jumpToTime(action::ActionExecutionId execu result.previous = time_; result.current = target; result.events = realignStateEvents(target); - result.activeBlocks = activeBlocksAt(target); + auto active = activeBlocksAt(target); + if (!active) return Result::failure(active.status()); + result.activeBlocks = std::move(active).takeValue(); player_->stop(); activeSection_ = nullptr; if (const auto* section = sectionAt(target)) { diff --git a/src/modules/animation/MontagePlayer.h b/src/modules/animation/MontagePlayer.h index 49986716..9c8b1138 100644 --- a/src/modules/animation/MontagePlayer.h +++ b/src/modules/animation/MontagePlayer.h @@ -71,15 +71,8 @@ public: virtual void applyMontageRootMotion(const TransformTRS& delta) noexcept = 0; }; -/** @brief Owning per-step update context for one active timeline state block. */ -struct MontageActiveBlock { - LogicalId trackId; - LogicalId itemId; - LogicalId type; - Duration localTime = Duration::zero(); - Duration duration = Duration::zero(); - Value::Object payload; -}; +/** @brief Compatibility name for the canonical action-owned active block projection. */ +using MontageActiveBlock = action::ActionActiveBlock; /** @brief Owning observation returned by one montage presentation step. */ struct MontageAdvance { @@ -201,7 +194,7 @@ private: void activate(const action::ActionAnimationSection& section, Duration localTime); void accumulateRootMotion(TransformTRS& total) const; [[nodiscard]] std::vector realignStateEvents(Duration target) const; - [[nodiscard]] std::vector activeBlocksAt(Duration target) const; + [[nodiscard]] Result> activeBlocksAt(Duration target) const; AnimSkeleton& skeleton_; std::unique_ptr player_; diff --git a/test/action_montage.cpp b/test/action_montage.cpp index d426cd97..e626cf1a 100644 --- a/test/action_montage.cpp +++ b/test/action_montage.cpp @@ -209,6 +209,9 @@ TEST_CASE("actionMontage.jumpSectionProgressAndInterruptedBlendOutKeepBlockPairs 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); diff --git a/test/action_notify_registry.cpp b/test/action_notify_registry.cpp index db7af4a4..b9650688 100644 --- a/test/action_notify_registry.cpp +++ b/test/action_notify_registry.cpp @@ -1,3 +1,4 @@ +#include "action/ActionBlockRuntime.h" #include "action/ActionNotifyRegistry.h" #include "zeroerr/assert.h" @@ -29,9 +30,28 @@ public: return eve::Result::success(); } + eve::Result update(const eve::action::ActionActiveBlock& block, + const eve::action::ActionNotifyContext&) override { + ++updates; + lastLocalTime = block.localTime; + return eve::Result::success(); + } + + eve::Result sample(const eve::action::ActionActiveBlock&, + const eve::action::ActionNotifyContext& context) const override { + if (!context.preview || !context.scrubbing) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "sample context is not preview", "context")); + ++samples; + return eve::Result::success(); + } + int calls = 0; + int updates = 0; + mutable int samples = 0; std::string lastType; eve::action::ActionExecutionId lastExecution; + eve::Duration lastLocalTime = eve::Duration::zero(); }; } // namespace @@ -54,6 +74,45 @@ TEST_CASE("actionNotifyRegistry.builtinsExposeStableEditorContracts") { CHECK(static_cast(hitbox.value().shape) == static_cast(eve::action::ActionNotifyShape::State)); } +TEST_CASE("actionBlockRuntime.routesEnterUpdateExitAndSideEffectFreeSample") { + eve::action::ActionNotifyRegistry registry; + REQUIRE(registry + .registerDescriptor({"project:combat.window", "Window", "Project", + eve::action::ActionNotifyShape::State, {}}) + .ok()); + auto handler = std::make_shared(); + REQUIRE(registry.registerHandler("project:combat.window", handler).ok()); + eve::action::ActionBlockRuntime runtime(registry); + + eve::action::ActionAdvance advance; + advance.id = eve::action::ActionExecutionId{7}; + advance.totalElapsed = eve::Duration::fromNanoseconds(25); + advance.timelineEvents.push_back(event(eve::action::ActionTimelineEventKind::StateEnter, + "project:combat.window")); + advance.activeBlocks.push_back({id("combat-track:gameplay"), id("combat-notify:test"), + id("project:combat.window"), eve::Duration::fromNanoseconds(15), + eve::Duration::fromNanoseconds(40), {}}); + eve::action::ActionNotifyContext context; + context.executionId = advance.id; + REQUIRE(runtime.apply(advance, context).ok()); + CHECK_EQ(handler->calls, 1); + CHECK_EQ(handler->updates, 1); + CHECK_EQ(handler->lastLocalTime, eve::Duration::fromNanoseconds(15)); + CHECK_EQ(runtime.executionCount(), 1u); + + REQUIRE(runtime.sample(advance.activeBlocks, context).ok()); + CHECK_EQ(handler->samples, 1); + CHECK_EQ(runtime.executionCount(), 1u); + + context.time = eve::Duration::fromNanoseconds(30); + REQUIRE(runtime.interrupt(context).ok()); + CHECK_EQ(handler->calls, 2); + CHECK_EQ(runtime.executionCount(), 0u); + auto noOp = runtime.interrupt(context); + REQUIRE(noOp.ok()); + CHECK_EQ(noOp.status().code(), eve::StatusCode::NoOp); +} + TEST_CASE("actionNotifyRegistry.validatesShapeAndRequiredPayload") { auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); diff --git a/test/action_timeline.cpp b/test/action_timeline.cpp index 2c82e66b..02b97c66 100644 --- a/test/action_timeline.cpp +++ b/test/action_timeline.cpp @@ -68,6 +68,17 @@ TEST_CASE("actionTimeline.versionedRoundTripAndDeterministicSampling") { CHECK_EQ(sampled.value()[2].itemId, id("combat-notify:hit")); CHECK_EQ(static_cast(sampled.value()[3].kind), static_cast(eve::action::ActionTimelineEventKind::StateExit)); + + auto active = decoded.value().activeBlocks(eve::Duration::fromNanoseconds(20)); + REQUIRE(active.ok()); + REQUIRE_EQ(active.value().size(), 1u); + CHECK_EQ(active.value()[0].itemId, id("combat-state:buffer")); + CHECK_EQ(active.value()[0].localTime, eve::Duration::fromNanoseconds(10)); + CHECK_EQ(active.value()[0].duration, eve::Duration::fromNanoseconds(20)); + auto ended = decoded.value().activeBlocks(eve::Duration::fromNanoseconds(30)); + REQUIRE(ended.ok()); + CHECK(ended.value().empty()); + CHECK(!decoded.value().activeBlocks(eve::Duration::fromNanoseconds(101)).ok()); } TEST_CASE("gameplayAction.advanceProjectsAuthoredTimelineBoundaries") { @@ -93,6 +104,9 @@ TEST_CASE("gameplayAction.advanceProjectsAuthoredTimelineBoundaries") { REQUIRE_EQ(crossed.value().timelineEvents.size(), 2u); CHECK_EQ(crossed.value().timelineEvents[0].itemId, id("combat-state:buffer")); CHECK_EQ(crossed.value().timelineEvents[1].itemId, id("combat-notify:hit")); + REQUIRE_EQ(crossed.value().activeBlocks.size(), 1u); + CHECK_EQ(crossed.value().activeBlocks[0].itemId, id("combat-state:buffer")); + CHECK_EQ(crossed.value().activeBlocks[0].localTime, eve::Duration::fromNanoseconds(10)); } TEST_CASE("actionTimelineEditor.editsPreviewAndUndoThroughCanonicalTarget") { From a01772c8e30fd81c03772834f64df047ab990152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Sun, 13 Sep 2026 01:42:12 +0800 Subject: [PATCH 002/160] feat(action): define spatial montage block contract --- docs/dev/战斗系统与动作编辑器.md | 6 ++ src/modules/action/ActionSpatialBlock.cpp | 109 ++++++++++++++++++++++ src/modules/action/ActionSpatialBlock.h | 68 ++++++++++++++ test/action_notify_registry.cpp | 28 ++++++ 4 files changed, 211 insertions(+) create mode 100644 src/modules/action/ActionSpatialBlock.cpp create mode 100644 src/modules/action/ActionSpatialBlock.h diff --git a/docs/dev/战斗系统与动作编辑器.md b/docs/dev/战斗系统与动作编辑器.md index 03738f22..c7de3f4d 100644 --- a/docs/dev/战斗系统与动作编辑器.md +++ b/docs/dev/战斗系统与动作编辑器.md @@ -34,6 +34,12 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模 上下文,handler 契约禁止产生持久副作用。handler 由 `ActionNotifyRegistry` 共享持有,回调同步发生在 owner thread,不得保存借用参数或重入同一个 `ActionBlockRuntime`。 +Audio、VFX 与 Prefab Spawn 共用 `ActionSpatialBinding` 解析 payload 中的空间字段:`attachment` 支持 +`follow_target`、`follow_position_only`、`world_transform_at_start`;`spatialTarget` 选择 `source` 或 +`target`,后者可配 `targetIndex`;`bone` 保存稳定骨骼/插槽名;`positionOffset`、 +`rotationOffsetDegrees`、`scale` 是三个有限数值组成的数组。scale 必须逐轴大于零。该值只保存拥有型数据, +实体 handle 与骨骼名由具体表现模块在每次同步调用时解析,不把 Scene/Bone 裸指针跨帧带入 action 核心。 + ## 持久化约定 编码器接受 v1-v3,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略, diff --git a/src/modules/action/ActionSpatialBlock.cpp b/src/modules/action/ActionSpatialBlock.cpp new file mode 100644 index 00000000..e9b19dcd --- /dev/null +++ b/src/modules/action/ActionSpatialBlock.cpp @@ -0,0 +1,109 @@ +#include "action/ActionSpatialBlock.h" + +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result number(const Value& value, std::string path) { + double result = 0.0; + if (const auto* integer = value.getIf()) + result = static_cast(*integer); + else if (const auto* decimal = value.getIf()) + result = *decimal; + else + return invalid("spatial component must be numeric", std::move(path)); + if (!std::isfinite(result)) return invalid("spatial component must be finite", std::move(path)); + return Result::success(result); +} + +Result vector(const Value::Object& payload, std::string_view key, + ActionSpatialVector3 fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(fallback); + const auto* values = found->second.getIf(); + if (!values || values->size() != 3) + return invalid("spatial vector must contain exactly three numbers", std::string(key)); + ActionSpatialVector3 result; + double* components[] = {&result.x, &result.y, &result.z}; + for (std::size_t index = 0; index < 3; ++index) { + auto parsed = number((*values)[index], std::string(key) + "[" + std::to_string(index) + "]"); + if (!parsed) return Result::failure(parsed.status()); + *components[index] = std::move(parsed).takeValue(); + } + return Result::success(result); +} + +Result text(const Value::Object& payload, std::string_view key, std::string fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(std::move(fallback)); + const auto* value = found->second.getIf(); + if (!value) return invalid("spatial field must be text", std::string(key)); + return Result::success(*value); +} + +} // namespace + +std::string_view actionSpatialAttachmentModeName(ActionSpatialAttachmentMode mode) noexcept { + switch (mode) { + case ActionSpatialAttachmentMode::FollowTarget: return "follow_target"; + case ActionSpatialAttachmentMode::FollowPositionOnly: return "follow_position_only"; + case ActionSpatialAttachmentMode::WorldTransformAtStart: return "world_transform_at_start"; + } + return "follow_target"; +} + +Result ActionSpatialBinding::fromPayload(const Value::Object& payload) { + ActionSpatialBinding candidate; + auto mode = text(payload, "attachment", "follow_target"); + if (!mode) return Result::failure(mode.status()); + if (mode.value() == "follow_target") + candidate.mode = ActionSpatialAttachmentMode::FollowTarget; + else if (mode.value() == "follow_position_only") + candidate.mode = ActionSpatialAttachmentMode::FollowPositionOnly; + else if (mode.value() == "world_transform_at_start") + candidate.mode = ActionSpatialAttachmentMode::WorldTransformAtStart; + else + return invalid("unknown spatial attachment mode", "attachment"); + + auto target = text(payload, "spatialTarget", "source"); + if (!target) return Result::failure(target.status()); + if (target.value() == "source") + candidate.target = ActionSpatialTarget::Source; + else if (target.value() == "target") + candidate.target = ActionSpatialTarget::Target; + else + return invalid("unknown spatial target", "spatialTarget"); + + if (const auto found = payload.find("targetIndex"); found != payload.end()) { + const auto* index = found->second.getIf(); + if (!index || *index < 0 || static_cast(*index) > std::numeric_limits::max()) + return invalid("target index must be a non-negative integer", "targetIndex"); + candidate.targetIndex = static_cast(*index); + } + auto bone = text(payload, "bone", {}); + if (!bone) return Result::failure(bone.status()); + candidate.bone = std::move(bone).takeValue(); + + auto position = vector(payload, "positionOffset", {}); + if (!position) return Result::failure(position.status()); + candidate.positionOffset = std::move(position).takeValue(); + auto rotation = vector(payload, "rotationOffsetDegrees", {}); + if (!rotation) return Result::failure(rotation.status()); + candidate.rotationOffsetDegrees = std::move(rotation).takeValue(); + auto scale = vector(payload, "scale", {1.0, 1.0, 1.0}); + if (!scale) return Result::failure(scale.status()); + candidate.scale = std::move(scale).takeValue(); + if (candidate.scale.x <= 0.0 || candidate.scale.y <= 0.0 || candidate.scale.z <= 0.0) + return invalid("spatial scale components must be positive", "scale"); + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionSpatialBlock.h b/src/modules/action/ActionSpatialBlock.h new file mode 100644 index 00000000..5c244632 --- /dev/null +++ b/src/modules/action/ActionSpatialBlock.h @@ -0,0 +1,68 @@ +#pragma once + +/** @file ActionSpatialBlock.h @brief Typed spatial payload shared by presentation action blocks. */ + +#include "common/Result.h" +#include "common/Value.h" + +#include +#include +#include +#include + +namespace eve::action { + +/** @brief How a spawned presentation object follows its resolved anchor. */ +enum class ActionSpatialAttachmentMode : std::uint8_t { + /** @brief Re-evaluate position and rotation from the anchor every update. */ + FollowTarget, + /** @brief Re-evaluate position only and retain the spawn rotation. */ + FollowPositionOnly, + /** @brief Capture the complete world transform once on Enter. */ + WorldTransformAtStart, +}; + +/** @brief Which handle in ActionNotifyContext supplies the spatial anchor. */ +enum class ActionSpatialTarget : std::uint8_t { Source, Target }; + +/** @brief Owning three-component value used at the action/presentation boundary. */ +struct ActionSpatialVector3 { + double x = 0.0; + double y = 0.0; + double z = 0.0; + + auto operator<=>(const ActionSpatialVector3&) const = default; +}; + +/** + * @brief Validated spatial settings decoded from a notify payload. + * + * The action module owns only these backend-neutral values. Scene, animation, + * audio and VFX adapters resolve entity handles and bone names synchronously; + * this value never stores a scene node, component pointer or bone pointer. + */ +struct ActionSpatialBinding { + ActionSpatialAttachmentMode mode = ActionSpatialAttachmentMode::FollowTarget; + ActionSpatialTarget target = ActionSpatialTarget::Source; + std::size_t targetIndex = 0; + std::string bone; + ActionSpatialVector3 positionOffset; + ActionSpatialVector3 rotationOffsetDegrees; + ActionSpatialVector3 scale{1.0, 1.0, 1.0}; + + auto operator<=>(const ActionSpatialBinding&) const = default; + + /** + * @brief Decode and validate the common spatial fields in an owning payload. + * @param payload Notify payload; unrelated provider-specific fields are ignored. + * @return Typed settings or a stable field-path diagnostic without mutation. + * @remarks Accepted vector fields are three-element numeric arrays. All + * components must be finite and scale components must be positive. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** @brief Return the stable payload spelling for an attachment mode. */ +[[nodiscard]] std::string_view actionSpatialAttachmentModeName(ActionSpatialAttachmentMode mode) noexcept; + +} // namespace eve::action diff --git a/test/action_notify_registry.cpp b/test/action_notify_registry.cpp index b9650688..d3adaf6d 100644 --- a/test/action_notify_registry.cpp +++ b/test/action_notify_registry.cpp @@ -1,5 +1,6 @@ #include "action/ActionBlockRuntime.h" #include "action/ActionNotifyRegistry.h" +#include "action/ActionSpatialBlock.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" @@ -113,6 +114,33 @@ TEST_CASE("actionBlockRuntime.routesEnterUpdateExitAndSideEffectFreeSample") { CHECK_EQ(noOp.status().code(), eve::StatusCode::NoOp); } +TEST_CASE("actionSpatialBlock.decodesSharedAttachmentContractTransactionally") { + eve::Value::Object payload{ + {"attachment", "follow_position_only"}, + {"spatialTarget", "target"}, + {"targetIndex", 2}, + {"bone", "hand_r"}, + {"positionOffset", eve::Value::Array{1.0, 2, 3.5}}, + {"rotationOffsetDegrees", eve::Value::Array{0, 90.0, 0}}, + {"scale", eve::Value::Array{1.0, 2.0, 1.0}}, + {"uri", "asset://vfx/sword-trail"}, + }; + auto binding = eve::action::ActionSpatialBinding::fromPayload(payload); + REQUIRE(binding.ok()); + CHECK_EQ(binding.value().mode, eve::action::ActionSpatialAttachmentMode::FollowPositionOnly); + CHECK_EQ(binding.value().target, eve::action::ActionSpatialTarget::Target); + CHECK_EQ(binding.value().targetIndex, 2u); + CHECK_EQ(binding.value().bone, "hand_r"); + CHECK_EQ(binding.value().positionOffset.z, 3.5); + CHECK_EQ(binding.value().rotationOffsetDegrees.y, 90.0); + CHECK_EQ(binding.value().scale.y, 2.0); + + payload["scale"] = eve::Value::Array{1.0, 0.0, 1.0}; + auto rejected = eve::action::ActionSpatialBinding::fromPayload(payload); + CHECK(!rejected.ok()); + CHECK_EQ(rejected.status().diagnostics().front().path(), "scale"); +} + TEST_CASE("actionNotifyRegistry.validatesShapeAndRequiredPayload") { auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); From e1786ede6d5d8a5722047fcffe5636d3728575c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Sun, 13 Sep 2026 02:01:48 +0800 Subject: [PATCH 003/160] feat(action): run audio and vfx state blocks --- cmake/module_manifest/input_playback.cmake | 2 +- cmake/module_manifest/orchestration.cmake | 2 +- docs/dev/战斗系统与动作编辑器.md | 6 + src/modules/action/ActionNotifyRegistry.cpp | 10 ++ src/modules/action/ActionNotifyRegistry.h | 22 ++++ src/modules/audio/AudioCapabilities.cpp | 99 +++++++++++++++- .../particles/ParticlesCapabilities.cpp | 111 +++++++++++++++++- test/action_notify_registry.cpp | 4 +- test/audio.cpp | 35 ++++++ test/particles_effect_asset.cpp | 48 ++++++++ 10 files changed, 333 insertions(+), 6 deletions(-) diff --git a/cmake/module_manifest/input_playback.cmake b/cmake/module_manifest/input_playback.cmake index 4cb8368d..4e4b23a1 100644 --- a/cmake/module_manifest/input_playback.cmake +++ b/cmake/module_manifest/input_playback.cmake @@ -54,7 +54,7 @@ eve_declare_module(NAME touch LAYER 2 SCRIPT Touch SLOT touch THIRDPARTY sdl2 GROUP minimal 2d 3d web) eve_declare_module(NAME audio LAYER 2 SCRIPT Audio SLOT audio - DEPS platform_event sound + DEPS action platform_event sound OPTIONAL_DEPS scene THIRDPARTY openal GROUP 2d 3d) diff --git a/cmake/module_manifest/orchestration.cmake b/cmake/module_manifest/orchestration.cmake index 3dedcd70..ad6617be 100644 --- a/cmake/module_manifest/orchestration.cmake +++ b/cmake/module_manifest/orchestration.cmake @@ -51,7 +51,7 @@ eve_declare_module(NAME level_editing LAYER 5 DEPS editing GROUP 2d 3d web) eve_declare_module(NAME particles LAYER 5 SCRIPT Particles SLOT particles - DEPS animation data filesystem graphics ik + DEPS action animation data filesystem graphics ik THIRDPARTY poco GROUP 2d 3d) # Surface fluid simulation: particles constrained to mesh SDFs (flow down diff --git a/docs/dev/战斗系统与动作编辑器.md b/docs/dev/战斗系统与动作编辑器.md index c7de3f4d..2df27bf5 100644 --- a/docs/dev/战斗系统与动作编辑器.md +++ b/docs/dev/战斗系统与动作编辑器.md @@ -40,6 +40,12 @@ Audio、VFX 与 Prefab Spawn 共用 `ActionSpatialBinding` 解析 payload 中的 `rotationOffsetDegrees`、`scale` 是三个有限数值组成的数组。scale 必须逐轴大于零。该值只保存拥有型数据, 实体 handle 与骨骼名由具体表现模块在每次同步调用时解析,不把 Scene/Bone 裸指针跨帧带入 action 核心。 +`presentation:vfx-state` 已由 Particles provider 创建并独占真实 `ParticleEffect`,在 Enter 启动、Update 同步偏移、 +Exit/interrupt 停止并销毁;`presentation:audio-state` 由 Audio provider 独占解码后的 `SoundData` 与 `Source`, +并按相同边界播放和清理。provider 是可选模块能力:未创建对应模块时 descriptor 仍可编辑,但运行 dispatch +明确返回 handler `NotFound`。当前无 Scene 空间解析器的宿主只能使用 payload 的世界偏移;传入真实 source/target +handle 会返回 `Unsupported`,不会静默退化到世界原点。 + ## 持久化约定 编码器接受 v1-v3,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略, diff --git a/src/modules/action/ActionNotifyRegistry.cpp b/src/modules/action/ActionNotifyRegistry.cpp index ce407894..f0642b70 100644 --- a/src/modules/action/ActionNotifyRegistry.cpp +++ b/src/modules/action/ActionNotifyRegistry.cpp @@ -1,5 +1,7 @@ #include "action/ActionNotifyRegistry.h" +#include "common/Capability.h" + #include namespace eve::action { @@ -24,7 +26,9 @@ Result ActionNotifyRegistry::withBuiltins() { {"gameplay:event", "Gameplay Event", "Gameplay", ActionNotifyShape::Instant, {"tag"}}, {"combat:damage", "Apply Damage", "Combat", ActionNotifyShape::Instant, {"damageType", "amount"}}, {"presentation:vfx", "Spawn VFX", "Presentation", ActionNotifyShape::Instant, {"uri"}}, + {"presentation:vfx-state", "VFX State", "Presentation", ActionNotifyShape::State, {"uri"}}, {"presentation:audio", "Play Audio", "Presentation", ActionNotifyShape::Instant, {"uri"}}, + {"presentation:audio-state", "Audio State", "Presentation", ActionNotifyShape::State, {"uri"}}, {"presentation:camera", "Camera Cue", "Presentation", ActionNotifyShape::Instant, {"cue"}}, {"combat:hitbox-window", "Hitbox Window", "Combat", ActionNotifyShape::State, {"hitbox"}}, {"combat:invulnerability-window", "Invulnerability Window", "Combat", ActionNotifyShape::State, {}}, @@ -36,6 +40,12 @@ Result ActionNotifyRegistry::withBuiltins() { auto registered = registry.registerDescriptor(std::move(descriptor)); if (!registered) return Result::failure(registered.status()); } + Result providers = Result::success(Status::success(StatusCode::NoOp)); + cap::forEach([&](IActionNotifyProvider* provider) { + if (!providers) return; + providers = provider->install(registry); + }); + if (!providers) return Result::failure(providers.status()); return Result::success(std::move(registry)); } diff --git a/src/modules/action/ActionNotifyRegistry.h b/src/modules/action/ActionNotifyRegistry.h index f18d4201..b2235bd2 100644 --- a/src/modules/action/ActionNotifyRegistry.h +++ b/src/modules/action/ActionNotifyRegistry.h @@ -12,6 +12,8 @@ namespace eve::action { +class ActionNotifyRegistry; + /** @brief Declares whether a notify is instantaneous or an enter/exit state. */ enum class ActionNotifyShape : std::uint8_t { Instant, State }; @@ -78,6 +80,26 @@ public: } }; +/** + * @brief Optional module provider that installs handlers into a new registry. + * + * Providers are discovered through the capability listener registry while + * `withBuiltins()` is constructing its candidate. The callback is synchronous, + * owner-thread-only and must not retain the borrowed registry reference. + */ +class IActionNotifyProvider { +public: + static constexpr const char* capabilityName = "eve.action.notify-provider"; + virtual ~IActionNotifyProvider() = default; + + /** + * @brief Install provider-owned handler instances for known descriptors. + * @param registry Borrowed candidate registry valid only for this call. + * @return Applied/NoOp or a structured installation failure. + */ + [[nodiscard]] virtual Result install(ActionNotifyRegistry& registry) = 0; +}; + /** * @brief Canonical notify descriptor owner and runtime handler router. * diff --git a/src/modules/audio/AudioCapabilities.cpp b/src/modules/audio/AudioCapabilities.cpp index a260349a..dcc27a34 100644 --- a/src/modules/audio/AudioCapabilities.cpp +++ b/src/modules/audio/AudioCapabilities.cpp @@ -1,6 +1,14 @@ +#include "action/ActionNotifyRegistry.h" +#include "action/ActionSpatialBlock.h" +#include "audio/Audio.h" +#include "audio/Source.h" #include "common/AudioQuery.h" #include "common/Capability.h" -#include "audio/Audio.h" +#include "sound/Sound.h" +#include "sound/SoundData.h" + +#include +#include namespace eve::audio { namespace { @@ -21,11 +29,100 @@ public: } }; +using ActiveKey = std::pair; + +class AudioActionHandler final : public eve::action::IActionNotifyHandler { +public: + eve::Result handle(const eve::action::ActionTimelineEvent& event, + const eve::action::ActionNotifyContext& context) override { + const ActiveKey key{context.executionId, event.itemId.format()}; + if (event.kind == eve::action::ActionTimelineEventKind::StateExit) { + const auto found = active_.find(key); + if (found == active_.end()) + return eve::Result::success(eve::Status::success(eve::StatusCode::NoOp)); + found->second.source->stop(); + active_.erase(found); + return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); + } + if (event.kind != eve::action::ActionTimelineEventKind::StateEnter) + return fail(eve::DiagnosticCode::InvalidArgument, "audio state requires enter or exit", "event.kind"); + auto spatial = eve::action::ActionSpatialBinding::fromPayload(event.payload); + if (!spatial) return eve::Result::failure(spatial.status()); + if ((spatial.value().target == eve::action::ActionSpatialTarget::Source && context.source.has_value()) || + (spatial.value().target == eve::action::ActionSpatialTarget::Target && !context.targets.empty())) + return fail(eve::DiagnosticCode::Unsupported, + "entity or bone anchored audio requires a spatial resolver", "spatialTarget"); + const auto uri = event.payload.find("uri"); + if (uri == event.payload.end() || !uri->second.getIf()) + return fail(eve::DiagnosticCode::InvalidArgument, "audio URI must be text", "uri"); + auto* audio = eve::ModuleManager::getInstance