refactor(character): 重构角色动作与动画系统

- 移除自动回退动作生成逻辑,改为严格检查动作定义
- 增加动作资源缺失时的详细错误报告机制
- 统一输入事件处理接口,优化角色对象生命周期管理
- 改进动画标签管理,移除隐式回退逻辑
- 增强状态机对无效动作的处理能力
This commit is contained in:
2026-04-04 05:53:07 +08:00
parent f64180ebed
commit 1200cf0181
13 changed files with 380 additions and 202 deletions

View File

@@ -8,15 +8,15 @@
namespace frostbite2D { namespace frostbite2D {
/// 管理角色动作定义。优先从 PVF 动作脚本读取,缺失时回退到内置默认动作。 /// 管理角色动作定义。优先从 PVF 动作脚本读取,不再自动生成回退动作。
class CharacterActionLibrary { class CharacterActionLibrary {
public: public:
bool LoadForConfig(const character::CharacterConfig& config); bool LoadForConfig(const character::CharacterConfig& config);
const CharacterActionDefinition* FindAction(const std::string& actionId) const; const CharacterActionDefinition* FindAction(const std::string& actionId) const;
const CharacterActionDefinition* GetDefaultAction() const; const CharacterActionDefinition* GetDefaultAction() const;
std::string DescribeActionIds() const;
private: private:
void BuildFallbackActions(const character::CharacterConfig& config);
bool TryLoadPvfActionScripts(const character::CharacterConfig& config); bool TryLoadPvfActionScripts(const character::CharacterConfig& config);
std::map<std::string, CharacterActionDefinition> actions_; std::map<std::string, CharacterActionDefinition> actions_;

View File

@@ -21,12 +21,13 @@ public:
const character::CharacterConfig& config, const character::CharacterConfig& config,
const CharacterEquipmentManager& equipmentManager); const CharacterEquipmentManager& equipmentManager);
void SetAction(const std::string& actionName); bool SetAction(const std::string& actionName);
void SetDirection(int direction); void SetDirection(int direction);
const std::string& GetCurrentAction() const { return currentActionTag_; } const std::string& GetCurrentAction() const { return currentActionTag_; }
bool HasAction(const std::string& actionName) const { bool HasAction(const std::string& actionName) const {
return actionAnimations_.count(actionName) > 0; return actionAnimations_.count(actionName) > 0;
} }
std::string DescribeAvailableActions() const;
private: private:
static std::string FormatImgPath(std::string path, Animation::ReplaceData data); static std::string FormatImgPath(std::string path, Animation::ReplaceData data);

View File

@@ -10,7 +10,7 @@ namespace frostbite2D {
class CharacterInputRouter { class CharacterInputRouter {
public: public:
bool OnKeyDown(const KeyEvent& event); bool OnKeyDown(const KeyEvent& event);
bool OnKeyUp(KeyCode keyCode); bool OnKeyUp(const KeyUpEvent& event);
bool OnJoystickAxis(const JoystickAxisEvent& event); bool OnJoystickAxis(const JoystickAxisEvent& event);
bool OnJoystickButtonDown(const JoystickButtonDownEvent& event); bool OnJoystickButtonDown(const JoystickButtonDownEvent& event);
bool OnJoystickButtonUp(const JoystickButtonUpEvent& event); bool OnJoystickButtonUp(const JoystickButtonUpEvent& event);

View File

@@ -7,6 +7,7 @@
#include "character/CharacterInputRouter.h" #include "character/CharacterInputRouter.h"
#include "character/CharacterStateMachine.h" #include "character/CharacterStateMachine.h"
#include <frostbite2D/2d/actor.h> #include <frostbite2D/2d/actor.h>
#include <frostbite2D/event/event.h>
#include <frostbite2D/event/joystick_event.h> #include <frostbite2D/event/joystick_event.h>
#include <frostbite2D/event/key_event.h> #include <frostbite2D/event/key_event.h>
#include <optional> #include <optional>
@@ -14,26 +15,76 @@
namespace frostbite2D { namespace frostbite2D {
/// @brief 角色系统的主聚合对象。
///
/// 这个类可以理解为“角色外壳”:
/// - 输入事件先进入 `OnEvent()`,再由 `CharacterInputRouter` 转成统一命令
/// - `OnUpdate()` 把命令整理成意图,交给 `CharacterStateMachine` 决策
/// - `CharacterMotor` 负责推进逻辑世界坐标
/// - 最后通过 `Actor` 和 `CharacterAnimation` 把结果表现到屏幕上
///
/// 所以阅读这个类时,可以重点抓住两条主线:
/// 1. 数据如何从输入流到状态机
/// 2. 状态机如何把逻辑结果投影成最终动画和位置
class CharacterObject : public Actor { class CharacterObject : public Actor {
public: public:
CharacterObject() = default; CharacterObject() = default;
~CharacterObject() override = default; ~CharacterObject() override = default;
/// @brief 二段初始化角色。
/// @param jobId 职业 id用来加载职业配置、动作定义和动画资源。
/// @return 初始化成功返回 true。
bool Construction(int jobId); bool Construction(int jobId);
// ---------------------------------------------------------------------------
// 表现与外部驱动入口
// ---------------------------------------------------------------------------
/// @brief 直接切换当前播放的资源动作标签。
///
/// 这里操作的是“动画表现层名字”,不一定等于逻辑动作 id。
void SetAction(const std::string& actionName); void SetAction(const std::string& actionName);
/// @brief 设置朝向,并同步到动画镜像。
/// @param direction 大于等于 0 视为朝右,小于 0 视为朝左。
void SetDirection(int direction); void SetDirection(int direction);
/// @brief 只改地面平面上的 x/y再重新投影到屏幕。
void SetCharacterPosition(const Vec2& pos); void SetCharacterPosition(const Vec2& pos);
/// @brief 写入逻辑世界坐标,并把它转换成屏幕坐标和遮挡顺序。
void SetWorldPosition(const CharacterWorldPosition& pos); void SetWorldPosition(const CharacterWorldPosition& pos);
/// @brief 向输入缓冲区手动压入一条命令。
///
/// 一般用于 AI、脚本或测试代码模拟输入而不是直接发 SDL 事件。
void PushCommand(const CharacterCommand& command); void PushCommand(const CharacterCommand& command);
/// @brief 处理一次受击请求。
///
/// 这里会先检查无敌、霸体、当前动作是否允许被打断,再决定是否强制切到受击状态。
void ApplyHit(const HitContext& hit); void ApplyHit(const HitContext& hit);
/// @brief 开关输入处理。
///
/// 关闭后角色仍会继续更新状态和运动,只是不再消费外部输入事件。
void SetInputEnabled(bool enabled) { inputEnabled_ = enabled; }
bool IsInputEnabled() const { return inputEnabled_; }
/// @brief 角色每帧主循环。
///
/// 顺序固定为:命令缓冲推进 -> 采样实时输入 -> 生成意图 -> 状态机更新
/// -> motor 推进 -> 投影到 Actor 坐标。
void OnUpdate(float deltaTime) override; void OnUpdate(float deltaTime) override;
bool OnKeyDown(const KeyEvent& event) override;
bool OnKeyUp(const KeyEvent& event) override; /// @brief 统一输入入口。
bool OnJoystickAxis(const JoystickEvent& event) override; ///
bool OnJoystickButtonDown(const JoystickEvent& event) override; /// `Actor` 底层会把不同 SDL 事件派发到这里;本类再把它们转交给 `CharacterInputRouter`。
bool OnJoystickButtonUp(const JoystickEvent& event) override; bool OnEvent(const Event& event) override;
// ---------------------------------------------------------------------------
// 运行时查询接口
// ---------------------------------------------------------------------------
int GetJobId() const { return jobId_; } int GetJobId() const { return jobId_; }
int GetGrowType() const { return growType_; } int GetGrowType() const { return growType_; }
@@ -46,13 +97,37 @@ public:
CharacterMotor& GetMotorMutable() { return motor_; } CharacterMotor& GetMotorMutable() { return motor_; }
CharacterCommandBuffer& GetCommandBufferMutable() { return commandBuffer_; } CharacterCommandBuffer& GetCommandBufferMutable() { return commandBuffer_; }
float GetLastDeltaTime() const { return lastDeltaTime_; } float GetLastDeltaTime() const { return lastDeltaTime_; }
/// @brief 按逻辑动作 id 查询动作定义。
const CharacterActionDefinition* FindAction(const std::string& actionId) const; const CharacterActionDefinition* FindAction(const std::string& actionId) const;
std::string ResolveAnimationTag(const std::string& preferred,
const std::string& fallback) const; /// @brief 严格查询逻辑动作;不存在时会输出详细错误并请求退出程序。
const CharacterActionDefinition* RequireAction(const std::string& actionId,
const char* phase) const;
/// @brief 按动作定义播放动画;缺少 animationTag 或资源不存在时会输出详细错误并退出。
bool PlayActionDefinition(const CharacterActionDefinition& action, const char* phase);
/// @brief 播放某个动画标签。
///
/// 当前只是对 `SetAction()` 的语义包装,方便状态机表达“播放动作标签”。
void PlayAnimationTag(const std::string& actionTag); void PlayAnimationTag(const std::string& actionTag);
/// @brief 输出角色动作/动画的致命错误,并请求应用退出。
void ReportFatalCharacterError(const char* phase,
const char* reason,
const std::string& requestedActionId = std::string(),
const std::string& requestedAnimationTag = std::string())
const;
/// @brief 触发状态生命周期钩子。
///
/// 当前为空实现,预留给后续脚本系统接管 `on_enter/on_update/on_exit`。
void RunStateHook(const std::string& phase, void RunStateHook(const std::string& phase,
CharacterStateId stateId, CharacterStateId stateId,
const CharacterActionDefinition* action); const CharacterActionDefinition* action);
/// @brief 同步逻辑朝向到 motor 与动画。
void SetFacing(int direction); void SetFacing(int direction);
const character::CharacterConfig* GetConfig() const { const character::CharacterConfig* GetConfig() const {
@@ -61,21 +136,58 @@ public:
const CharacterEquipmentManager& GetEquipmentManager() const { return equipmentManager_; } const CharacterEquipmentManager& GetEquipmentManager() const { return equipmentManager_; }
private: private:
bool SetActionStrict(const std::string& actionName,
const char* phase,
const std::string& requestedActionId);
std::string DescribeConfiguredAnimationTags() const;
/// 职业 id用于加载职业配置、动作表和资源。
int jobId_ = -1; int jobId_ = -1;
/// 转职/成长阶段,当前项目里先保留这个槽位。
int growType_ = -1; int growType_ = -1;
/// 表现层朝向1 表示右,-1 表示左。
int direction_ = 1; int direction_ = 1;
/// 当前正在播放的资源动作标签,不一定等于逻辑 actionId。
std::string currentAction_; std::string currentAction_;
/// 角色静态配置,包含职业、动画路径等加载结果。
std::optional<character::CharacterConfig> config_; std::optional<character::CharacterConfig> config_;
/// 负责拼装装备部件,决定分层 Avatar 的表现资源。
CharacterEquipmentManager equipmentManager_; CharacterEquipmentManager equipmentManager_;
/// 动作定义表:把逻辑 actionId 映射到帧数据、取消规则、动画标签等。
CharacterActionLibrary actionLibrary_; CharacterActionLibrary actionLibrary_;
/// 输入适配层,把键盘/手柄事件收敛成统一命令。
CharacterInputRouter inputRouter_; CharacterInputRouter inputRouter_;
/// 输入缓冲区,支持预输入和连段窗口。
CharacterCommandBuffer commandBuffer_; CharacterCommandBuffer commandBuffer_;
/// 当前帧从命令缓冲整理出的“角色意图”。
CharacterIntent currentIntent_; CharacterIntent currentIntent_;
/// 状态调度器,负责状态切换、动作进入和帧事件推进。
CharacterStateMachine stateMachine_; CharacterStateMachine stateMachine_;
/// 纯运动数据:位置、速度、落地状态和朝向都在这里维护。
CharacterMotor motor_; CharacterMotor motor_;
/// 战斗附加状态,如霸体、无敌、控制锁定。
CharacterStatus status_; CharacterStatus status_;
/// 真正负责播放角色分层动画的 Actor 子节点。
RefPtr<CharacterAnimation> animationManager_ = nullptr; RefPtr<CharacterAnimation> animationManager_ = nullptr;
/// 缓存上一帧 deltaTime方便状态和脚本系统查询。
float lastDeltaTime_ = 0.0f; float lastDeltaTime_ = 0.0f;
/// 是否允许从事件系统接收输入AI/剧情接管时可临时关闭。
bool inputEnabled_ = true;
}; };
} // namespace frostbite2D } // namespace frostbite2D

View File

@@ -57,9 +57,7 @@ protected:
void ChangeState(CharacterStateMachine& machine, void ChangeState(CharacterStateMachine& machine,
CharacterStateContext& context, CharacterStateContext& context,
CharacterStateId nextState) const; CharacterStateId nextState) const;
void PlayActionById(CharacterObject& owner, void PlayActionById(CharacterObject& owner, const std::string& actionId) const;
const std::string& actionId,
const std::string& fallbackTag = "rest") const;
void InvokeHook(CharacterStateMachine& machine, void InvokeHook(CharacterStateMachine& machine,
CharacterObject& owner, CharacterObject& owner,
const CharacterActionDefinition* action, const CharacterActionDefinition* action,

View File

@@ -4,6 +4,7 @@
#include <frostbite2D/resource/script_parser.h> #include <frostbite2D/resource/script_parser.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <sstream>
#include <vector> #include <vector>
namespace frostbite2D { namespace frostbite2D {
@@ -227,9 +228,6 @@ void CharacterCommandBuffer::Clear() {
bool CharacterActionLibrary::LoadForConfig(const character::CharacterConfig& config) { bool CharacterActionLibrary::LoadForConfig(const character::CharacterConfig& config) {
actions_.clear(); actions_.clear();
// 先填一套最小可运行的默认动作,保证资源或 PVF 缺失时角色仍能工作。
BuildFallbackActions(config);
// 再尝试用 PVF 动作逻辑覆盖默认值。
TryLoadPvfActionScripts(config); TryLoadPvfActionScripts(config);
return !actions_.empty(); return !actions_.empty();
} }
@@ -247,95 +245,22 @@ const CharacterActionDefinition* CharacterActionLibrary::GetDefaultAction() cons
return FindAction("idle"); return FindAction("idle");
} }
void CharacterActionLibrary::BuildFallbackActions( std::string CharacterActionLibrary::DescribeActionIds() const {
const character::CharacterConfig& config) { if (actions_.empty()) {
auto animationTagOrDefault = [&config](const std::string& actionId, return "<none>";
const std::string& fallback) {
if (config.animationPath.count(actionId) > 0) {
return actionId;
} }
if (config.animationPath.count(fallback) > 0) {
return fallback; std::ostringstream stream;
bool first = true;
for (const auto& [actionId, definition] : actions_) {
(void)definition;
if (!first) {
stream << ", ";
} }
if (!config.animationPath.empty()) { stream << actionId;
return config.animationPath.begin()->first; first = false;
} }
return fallback; return stream.str();
};
CharacterActionDefinition idle;
idle.actionId = "idle";
idle.animationTag = animationTagOrDefault("rest", "rest");
idle.totalFrames = 1;
idle.loop = true;
idle.canMove = true;
addOrReplaceAction(actions_, idle);
CharacterActionDefinition move;
move.actionId = "move";
move.animationTag = animationTagOrDefault("run", animationTagOrDefault("walk", "rest"));
move.totalFrames = 1;
move.loop = true;
move.canMove = true;
addOrReplaceAction(actions_, move);
CharacterActionDefinition jump;
jump.actionId = "jump";
jump.animationTag = animationTagOrDefault("jump", "rest");
jump.totalFrames = 18;
jump.canTurn = true;
addOrReplaceAction(actions_, jump);
CharacterActionDefinition fall;
fall.actionId = "fall";
fall.animationTag = animationTagOrDefault("jump", "rest");
fall.totalFrames = 1;
fall.loop = true;
fall.canTurn = true;
addOrReplaceAction(actions_, fall);
CharacterActionDefinition landing;
landing.actionId = "landing";
landing.animationTag = animationTagOrDefault("rest", "rest");
landing.totalFrames = 6;
addOrReplaceAction(actions_, landing);
CharacterActionDefinition attack1;
attack1.actionId = "attack_1";
attack1.animationTag = animationTagOrDefault("attack1", animationTagOrDefault("attack", "rest"));
attack1.totalFrames = 24;
attack1.canMove = false;
attack1.canTurn = false;
attack1.canBeInterrupted = true;
attack1.cancelRules.push_back({"attack_2", 12, 22, true, false});
addOrReplaceAction(actions_, attack1);
CharacterActionDefinition attack2;
attack2.actionId = "attack_2";
attack2.animationTag = animationTagOrDefault("attack2", attack1.animationTag);
attack2.totalFrames = 28;
attack2.canMove = false;
attack2.canTurn = false;
attack2.canBeInterrupted = true;
addOrReplaceAction(actions_, attack2);
CharacterActionDefinition skill1;
skill1.actionId = "skill_1";
skill1.animationTag = animationTagOrDefault("skill1", attack1.animationTag);
skill1.totalFrames = 36;
skill1.canMove = false;
skill1.canTurn = false;
skill1.canBeInterrupted = false;
addOrReplaceAction(actions_, skill1);
CharacterActionDefinition hurt;
hurt.actionId = "hurt_light";
hurt.animationTag = animationTagOrDefault("damage", animationTagOrDefault("hurt", "rest"));
hurt.totalFrames = 18;
hurt.canMove = false;
hurt.canTurn = false;
hurt.canBeInterrupted = true;
addOrReplaceAction(actions_, hurt);
} }
bool CharacterActionLibrary::TryLoadPvfActionScripts( bool CharacterActionLibrary::TryLoadPvfActionScripts(
@@ -413,12 +338,6 @@ bool CharacterActionLibrary::TryLoadPvfActionScripts(
} }
} }
if (definition.animationTag.empty()) {
// 如果动作脚本没有显式指定动画标签,则尝试用同名动作映射。
if (config.animationPath.count(actionId) > 0) {
definition.animationTag = actionId;
}
}
addOrReplaceAction(actions_, std::move(definition)); addOrReplaceAction(actions_, std::move(definition));
} }

View File

@@ -3,6 +3,7 @@
#include <SDL2/SDL.h> #include <SDL2/SDL.h>
#include <array> #include <array>
#include <cstdio> #include <cstdio>
#include <sstream>
namespace frostbite2D { namespace frostbite2D {
namespace { namespace {
@@ -51,12 +52,6 @@ bool CharacterAnimation::Init(CharacterObject* parent,
return false; return false;
} }
if (actionAnimations_.count("rest") > 0) {
SetAction("rest");
} else {
SetAction(actionAnimations_.begin()->first);
}
return true; return true;
} }
@@ -129,13 +124,37 @@ void CharacterAnimation::CreateAnimationBySlot(
} }
} }
void CharacterAnimation::SetAction(const std::string& actionName) { std::string CharacterAnimation::DescribeAvailableActions() const {
if (actionAnimations_.empty()) {
return "<none>";
}
std::ostringstream stream;
bool first = true;
for (const auto& [actionName, animations] : actionAnimations_) {
(void)animations;
if (!first) {
stream << ", ";
}
stream << actionName;
first = false;
}
return stream.str();
}
bool CharacterAnimation::SetAction(const std::string& actionName) {
auto nextIt = actionAnimations_.find(actionName); auto nextIt = actionAnimations_.find(actionName);
if (nextIt == actionAnimations_.end()) { if (nextIt == actionAnimations_.end()) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, if (parent_) {
"CharacterAnimation: action %s missing, keep %s", actionName.c_str(), parent_->ReportFatalCharacterError("CharacterAnimation::SetAction",
currentActionTag_.c_str()); "requested animation tag is not loaded",
return; std::string(), actionName);
} else {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
"CharacterAnimation: action %s missing and parent is null",
actionName.c_str());
}
return false;
} }
if (!currentActionTag_.empty()) { if (!currentActionTag_.empty()) {
@@ -154,6 +173,7 @@ void CharacterAnimation::SetAction(const std::string& actionName) {
} }
currentActionTag_ = actionName; currentActionTag_ = actionName;
SetDirection(direction_); SetDirection(direction_);
return true;
} }
void CharacterAnimation::SetDirection(int direction) { void CharacterAnimation::SetDirection(int direction) {

View File

@@ -46,8 +46,8 @@ bool CharacterInputRouter::OnKeyDown(const KeyEvent& event) {
} }
} }
bool CharacterInputRouter::OnKeyUp(KeyCode keyCode) { bool CharacterInputRouter::OnKeyUp(const KeyUpEvent& event) {
switch (keyCode) { switch (event.getKeyCode()) {
case KeyCode::a: case KeyCode::a:
case KeyCode::Left: case KeyCode::Left:
left_ = false; left_ = false;

View File

@@ -1,9 +1,42 @@
#include "character/CharacterObject.h" #include "character/CharacterObject.h"
#include <SDL2/SDL.h> #include <SDL2/SDL.h>
#include <frostbite2D/core/application.h>
#include <sstream>
namespace frostbite2D { namespace frostbite2D {
namespace {
const char* StateIdToString(CharacterStateId stateId) {
switch (stateId) {
case CharacterStateId::Idle:
return "Idle";
case CharacterStateId::Move:
return "Move";
case CharacterStateId::Jump:
return "Jump";
case CharacterStateId::Fall:
return "Fall";
case CharacterStateId::Landing:
return "Landing";
case CharacterStateId::Action:
return "Action";
case CharacterStateId::Hurt:
return "Hurt";
case CharacterStateId::Dead:
return "Dead";
default:
return "<unknown>";
}
}
const char* NonEmptyOrPlaceholder(const std::string& value, const char* placeholder) {
return value.empty() ? placeholder : value.c_str();
}
} // namespace
bool CharacterObject::Construction(int jobId) { bool CharacterObject::Construction(int jobId) {
// Reset all runtime state before rebuilding the character from config.
EnableEventReceive(); EnableEventReceive();
RemoveAllChildren(); RemoveAllChildren();
animationManager_ = nullptr; animationManager_ = nullptr;
@@ -16,6 +49,7 @@ bool CharacterObject::Construction(int jobId) {
motor_ = CharacterMotor(); motor_ = CharacterMotor();
status_ = CharacterStatus(); status_ = CharacterStatus();
lastDeltaTime_ = 0.0f; lastDeltaTime_ = 0.0f;
inputEnabled_ = true;
auto config = character::loadCharacterConfig(jobId); auto config = character::loadCharacterConfig(jobId);
if (!config) { if (!config) {
@@ -36,17 +70,19 @@ bool CharacterObject::Construction(int jobId) {
animationManager_ = MakePtr<CharacterAnimation>(); animationManager_ = MakePtr<CharacterAnimation>();
if (!animationManager_->Init(this, *config_, equipmentManager_)) { if (!animationManager_->Init(this, *config_, equipmentManager_)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, ReportFatalCharacterError("CharacterObject::Construction",
"CharacterObject: no layered avatar animations available, character will stay empty"); "no usable animation tags were loaded");
return false;
} }
AddChild(animationManager_); AddChild(animationManager_);
if (const auto* idleAction = actionLibrary_.GetDefaultAction()) { const CharacterActionDefinition* idleAction = RequireAction(
PlayAnimationTag(idleAction->animationTag); "idle", "CharacterObject::Construction");
} else if (config_->animationPath.count("rest") > 0) { if (!idleAction) {
SetAction("rest"); return false;
} else if (!config_->animationPath.empty()) { }
SetAction(config_->animationPath.begin()->first); if (!PlayActionDefinition(*idleAction, "CharacterObject::Construction")) {
return false;
} }
SetWorldPosition({}); SetWorldPosition({});
@@ -55,10 +91,35 @@ bool CharacterObject::Construction(int jobId) {
} }
void CharacterObject::SetAction(const std::string& actionName) { void CharacterObject::SetAction(const std::string& actionName) {
currentAction_ = actionName; SetActionStrict(actionName, "CharacterObject::SetAction", std::string());
if (animationManager_) {
animationManager_->SetAction(actionName);
} }
bool CharacterObject::SetActionStrict(const std::string& actionName,
const char* phase,
const std::string& requestedActionId) {
if (actionName.empty()) {
ReportFatalCharacterError(phase, "requested animation tag is empty",
requestedActionId, actionName);
return false;
}
if (!animationManager_) {
ReportFatalCharacterError(phase, "animation manager is not initialized",
requestedActionId, actionName);
return false;
}
if (!animationManager_->HasAction(actionName)) {
ReportFatalCharacterError(phase, "requested animation tag is not loaded",
requestedActionId, actionName);
return false;
}
if (!animationManager_->SetAction(actionName)) {
ReportFatalCharacterError(phase, "failed to activate requested animation tag",
requestedActionId, actionName);
return false;
}
currentAction_ = actionName;
return true;
} }
void CharacterObject::SetDirection(int direction) { void CharacterObject::SetDirection(int direction) {
@@ -75,9 +136,7 @@ void CharacterObject::SetCharacterPosition(const Vec2& pos) {
void CharacterObject::SetWorldPosition(const CharacterWorldPosition& pos) { void CharacterObject::SetWorldPosition(const CharacterWorldPosition& pos) {
motor_.position = pos; motor_.position = pos;
// 逻辑世界坐标是 x/y/z真正渲染时再投影到屏幕坐标。
SetPosition(pos.ToScreenPosition()); SetPosition(pos.ToScreenPosition());
// 地面 y 继续决定同层对象前后遮挡顺序。
SetZOrder(static_cast<int>(pos.y)); SetZOrder(static_cast<int>(pos.y));
} }
@@ -96,9 +155,10 @@ void CharacterObject::ApplyHit(const HitContext& hit) {
return; return;
} }
const CharacterActionDefinition* hurtAction = FindAction(hit.hurtAction); const CharacterActionDefinition* hurtAction = RequireAction(
hit.hurtAction, "CharacterObject::ApplyHit");
if (!hurtAction) { if (!hurtAction) {
hurtAction = FindAction("hurt_light"); return;
} }
motor_.verticalVelocity = hit.launchZVelocity; motor_.verticalVelocity = hit.launchZVelocity;
@@ -109,8 +169,7 @@ void CharacterObject::ApplyHit(const HitContext& hit) {
} }
void CharacterObject::OnUpdate(float deltaTime) { void CharacterObject::OnUpdate(float deltaTime) {
// 角色主循环顺序固定为: // Fixed update order keeps input, state transitions, and motion deterministic.
// 输入采样 -> 缓冲推进 -> 意图生成 -> 状态机决策 -> motor 推进 -> 投影到屏幕。
lastDeltaTime_ = deltaTime; lastDeltaTime_ = deltaTime;
commandBuffer_.Advance(deltaTime); commandBuffer_.Advance(deltaTime);
inputRouter_.EmitCommands(commandBuffer_); inputRouter_.EmitCommands(commandBuffer_);
@@ -121,29 +180,32 @@ void CharacterObject::OnUpdate(float deltaTime) {
SetFacing(motor_.facing); SetFacing(motor_.facing);
} }
bool CharacterObject::OnKeyDown(const KeyEvent& event) { bool CharacterObject::OnEvent(const Event& event) {
if (event.isRepeat()) { if (!IsEventReceiveEnabled() || !inputEnabled_) {
return false; return false;
} }
return inputRouter_.OnKeyDown(event);
}
bool CharacterObject::OnKeyUp(const KeyEvent& event) { switch (event.getType()) {
return inputRouter_.OnKeyUp(event.getKeyCode()); case EventType::KeyDown: {
const auto& keyEvent = static_cast<const KeyEvent&>(event);
if (keyEvent.isRepeat()) {
return false;
} }
return inputRouter_.OnKeyDown(keyEvent);
bool CharacterObject::OnJoystickAxis(const JoystickEvent& event) { }
case EventType::KeyUp:
return inputRouter_.OnKeyUp(static_cast<const KeyUpEvent&>(event));
case EventType::JoystickAxis:
return inputRouter_.OnJoystickAxis(static_cast<const JoystickAxisEvent&>(event)); return inputRouter_.OnJoystickAxis(static_cast<const JoystickAxisEvent&>(event));
} case EventType::JoystickButtonDown:
bool CharacterObject::OnJoystickButtonDown(const JoystickEvent& event) {
return inputRouter_.OnJoystickButtonDown( return inputRouter_.OnJoystickButtonDown(
static_cast<const JoystickButtonDownEvent&>(event)); static_cast<const JoystickButtonDownEvent&>(event));
} case EventType::JoystickButtonUp:
bool CharacterObject::OnJoystickButtonUp(const JoystickEvent& event) {
return inputRouter_.OnJoystickButtonUp( return inputRouter_.OnJoystickButtonUp(
static_cast<const JoystickButtonUpEvent&>(event)); static_cast<const JoystickButtonUpEvent&>(event));
default:
return false;
}
} }
const CharacterActionDefinition* CharacterObject::FindAction( const CharacterActionDefinition* CharacterObject::FindAction(
@@ -151,24 +213,78 @@ const CharacterActionDefinition* CharacterObject::FindAction(
return actionLibrary_.FindAction(actionId); return actionLibrary_.FindAction(actionId);
} }
std::string CharacterObject::ResolveAnimationTag( const CharacterActionDefinition* CharacterObject::RequireAction(
const std::string& preferred, const std::string& actionId,
const std::string& fallback) const { const char* phase) const {
// 逻辑动作与资源动作是两层名字:优先选动作定义里的标签,不存在再回退。 const CharacterActionDefinition* action = FindAction(actionId);
if (animationManager_ && animationManager_->HasAction(preferred)) { if (!action) {
return preferred; ReportFatalCharacterError(phase, "requested logical action does not exist", actionId);
return nullptr;
} }
if (animationManager_ && animationManager_->HasAction(fallback)) { return action;
return fallback;
} }
if (config_ && !config_->animationPath.empty()) {
return config_->animationPath.begin()->first; bool CharacterObject::PlayActionDefinition(const CharacterActionDefinition& action,
const char* phase) {
if (action.animationTag.empty()) {
ReportFatalCharacterError(phase, "logical action has empty animationTag",
action.actionId, action.animationTag);
return false;
} }
return preferred; return SetActionStrict(action.animationTag, phase, action.actionId);
} }
void CharacterObject::PlayAnimationTag(const std::string& actionTag) { void CharacterObject::PlayAnimationTag(const std::string& actionTag) {
SetAction(actionTag); SetActionStrict(actionTag, "CharacterObject::PlayAnimationTag", std::string());
}
std::string CharacterObject::DescribeConfiguredAnimationTags() const {
if (!config_ || config_->animationPath.empty()) {
return "<none>";
}
std::ostringstream stream;
bool first = true;
for (const auto& [actionTag, path] : config_->animationPath) {
(void)path;
if (!first) {
stream << ", ";
}
stream << actionTag;
first = false;
}
return stream.str();
}
void CharacterObject::ReportFatalCharacterError(
const char* phase,
const char* reason,
const std::string& requestedActionId,
const std::string& requestedAnimationTag) const {
std::ostringstream stream;
stream << "CharacterObject fatal error: "
<< (reason && reason[0] != '\0' ? reason : "unknown error") << '\n'
<< " phase: " << (phase && phase[0] != '\0' ? phase : "<unknown>") << '\n'
<< " jobId: " << jobId_ << '\n'
<< " jobTag: "
<< (config_ ? NonEmptyOrPlaceholder(config_->jobTag, "<empty>") : "<config not loaded>")
<< '\n'
<< " state: " << StateIdToString(stateMachine_.GetState()) << '\n'
<< " currentActionId: "
<< NonEmptyOrPlaceholder(stateMachine_.GetCurrentActionId(), "<none>") << '\n'
<< " currentAnimationTag: " << NonEmptyOrPlaceholder(currentAction_, "<none>") << '\n'
<< " requestedActionId: "
<< NonEmptyOrPlaceholder(requestedActionId, "<none>") << '\n'
<< " requestedAnimationTag: "
<< NonEmptyOrPlaceholder(requestedAnimationTag, "<empty>") << '\n'
<< " loadedActionIds: " << actionLibrary_.DescribeActionIds() << '\n'
<< " configuredAnimationTags: " << DescribeConfiguredAnimationTags() << '\n'
<< " loadedAnimationTags: "
<< (animationManager_ ? animationManager_->DescribeAvailableActions()
: std::string("<animation manager not initialized>"));
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", stream.str().c_str());
Application::get().quit();
} }
void CharacterObject::RunStateHook(const std::string& phase, void CharacterObject::RunStateHook(const std::string& phase,
@@ -177,7 +293,6 @@ void CharacterObject::RunStateHook(const std::string& phase,
(void)phase; (void)phase;
(void)stateId; (void)stateId;
(void)action; (void)action;
// 预留给后续脚本系统;当前默认由 C++ 状态节点接管生命周期逻辑。
} }
void CharacterObject::SetFacing(int direction) { void CharacterObject::SetFacing(int direction) {

View File

@@ -35,9 +35,15 @@ void CharacterStateMachine::Update(CharacterObject& owner,
void CharacterStateMachine::ForceHurt(CharacterObject& owner, void CharacterStateMachine::ForceHurt(CharacterObject& owner,
const CharacterActionDefinition* hurtAction) { const CharacterActionDefinition* hurtAction) {
if (!hurtAction) {
owner.ReportFatalCharacterError("CharacterStateMachine::ForceHurt",
"hurt action definition is null");
return;
}
const CharacterActionDefinition* previousAction = currentAction_; const CharacterActionDefinition* previousAction = currentAction_;
currentAction_ = hurtAction; currentAction_ = hurtAction;
currentActionId_ = hurtAction ? hurtAction->actionId : std::string("hurt_light"); currentActionId_ = hurtAction->actionId;
if (currentState_ == CharacterStateId::Hurt) { if (currentState_ == CharacterStateId::Hurt) {
InvokeStateHook(owner, previousAction, "on_exit"); InvokeStateHook(owner, previousAction, "on_exit");
stateTime_ = 0.0f; stateTime_ = 0.0f;
@@ -123,9 +129,15 @@ void CharacterStateMachine::ChangeState(CharacterObject& owner,
void CharacterStateMachine::EnterAction(CharacterObject& owner, void CharacterStateMachine::EnterAction(CharacterObject& owner,
const CharacterActionDefinition* action) { const CharacterActionDefinition* action) {
if (!action) {
owner.ReportFatalCharacterError("CharacterStateMachine::EnterAction",
"action definition is null");
return;
}
const CharacterActionDefinition* previousAction = currentAction_; const CharacterActionDefinition* previousAction = currentAction_;
currentAction_ = action; currentAction_ = action;
currentActionId_ = action ? action->actionId : std::string(); currentActionId_ = action->actionId;
if (currentState_ == CharacterStateId::Action) { if (currentState_ == CharacterStateId::Action) {
InvokeStateHook(owner, previousAction, "on_exit"); InvokeStateHook(owner, previousAction, "on_exit");
stateTime_ = 0.0f; stateTime_ = 0.0f;
@@ -158,7 +170,8 @@ bool CharacterStateMachine::TryStartAction(CharacterObject& owner,
return false; return false;
} }
const CharacterActionDefinition* action = owner.FindAction(actionId); const CharacterActionDefinition* action = owner.RequireAction(
actionId, "CharacterStateMachine::TryStartAction");
commandBuffer.Consume(commandType); commandBuffer.Consume(commandType);
if (!action) { if (!action) {
return false; return false;

View File

@@ -19,16 +19,12 @@ void CharacterStateBase::ChangeState(CharacterStateMachine& machine,
} }
void CharacterStateBase::PlayActionById(CharacterObject& owner, void CharacterStateBase::PlayActionById(CharacterObject& owner,
const std::string& actionId, const std::string& actionId) const {
const std::string& fallbackTag) const { const auto* action = owner.RequireAction(actionId, "CharacterStateBase::PlayActionById");
if (const auto* action = owner.FindAction(actionId)) { if (!action) {
if (!action->animationTag.empty()) {
owner.PlayAnimationTag(action->animationTag);
return; return;
} }
} owner.PlayActionDefinition(*action, "CharacterStateBase::PlayActionById");
owner.PlayAnimationTag(owner.ResolveAnimationTag(actionId, fallbackTag));
} }
void CharacterStateBase::InvokeHook(CharacterStateMachine& machine, void CharacterStateBase::InvokeHook(CharacterStateMachine& machine,

View File

@@ -30,14 +30,16 @@ void HurtState::OnEnter(CharacterStateMachine& machine,
ActionFrame(machine) = 0; ActionFrame(machine) = 0;
const CharacterActionDefinition* action = GetCurrentAction(machine); const CharacterActionDefinition* action = GetCurrentAction(machine);
if (!action) { if (!action) {
action = context.owner.FindAction("hurt_light"); context.owner.ReportFatalCharacterError("HurtState::OnEnter",
SetCurrentAction(machine, action, action ? action->actionId : "hurt_light"); "hurt state entered without current action");
return;
}
if (!context.owner.PlayActionDefinition(*action, "HurtState::OnEnter")) {
return;
} }
if (action) {
context.owner.PlayAnimationTag(action->animationTag);
InvokeHook(machine, context.owner, action, "on_enter"); InvokeHook(machine, context.owner, action, "on_enter");
} }
}
void HurtState::OnUpdate(CharacterStateMachine& machine, void HurtState::OnUpdate(CharacterStateMachine& machine,
CharacterStateContext& context) { CharacterStateContext& context) {
@@ -47,12 +49,8 @@ void HurtState::OnUpdate(CharacterStateMachine& machine,
const CharacterActionDefinition* action = GetCurrentAction(machine); const CharacterActionDefinition* action = GetCurrentAction(machine);
if (!action) { if (!action) {
action = owner.FindAction("hurt_light"); owner.ReportFatalCharacterError("HurtState::OnUpdate",
SetCurrentAction(machine, action, action ? action->actionId : "hurt_light"); "hurt state updated without current action");
}
if (!action) {
context.owner.SetFacing(motor.facing);
ChangeState(machine, context, ResolveInterruptedState(context));
return; return;
} }

View File

@@ -38,10 +38,16 @@ void SwordmanAttackState::OnEnter(CharacterStateMachine& machine,
ActionFrameProgress(machine) = 0.0f; ActionFrameProgress(machine) = 0.0f;
ActionFrame(machine) = 0; ActionFrame(machine) = 0;
const CharacterActionDefinition* action = GetCurrentAction(machine); const CharacterActionDefinition* action = GetCurrentAction(machine);
if (action) { if (!action) {
context.owner.PlayAnimationTag(action->animationTag); context.owner.ReportFatalCharacterError("SwordmanAttackState::OnEnter",
InvokeHook(machine, context.owner, action, "on_enter"); "action state entered without current action");
return;
} }
if (!context.owner.PlayActionDefinition(*action, "SwordmanAttackState::OnEnter")) {
return;
}
InvokeHook(machine, context.owner, action, "on_enter");
} }
void SwordmanAttackState::OnUpdate(CharacterStateMachine& machine, void SwordmanAttackState::OnUpdate(CharacterStateMachine& machine,