diff --git a/Game/include/character/CharacterObject.h b/Game/include/character/CharacterObject.h index 713cdbe..4863be6 100644 --- a/Game/include/character/CharacterObject.h +++ b/Game/include/character/CharacterObject.h @@ -44,11 +44,15 @@ public: const CharacterWorldPosition& GetWorldPosition() const { return motor_.position; } const CharacterMotor& GetMotor() const { return motor_; } CharacterMotor& GetMotorMutable() { return motor_; } + CharacterCommandBuffer& GetCommandBufferMutable() { return commandBuffer_; } float GetLastDeltaTime() const { return lastDeltaTime_; } const CharacterActionDefinition* FindAction(const std::string& actionId) const; std::string ResolveAnimationTag(const std::string& preferred, const std::string& fallback) const; void PlayAnimationTag(const std::string& actionTag); + void RunStateHook(const std::string& phase, + CharacterStateId stateId, + const CharacterActionDefinition* action); void SetFacing(int direction); const character::CharacterConfig* GetConfig() const { diff --git a/Game/include/character/CharacterStateMachine.h b/Game/include/character/CharacterStateMachine.h index 15f3909..23b7ca0 100644 --- a/Game/include/character/CharacterStateMachine.h +++ b/Game/include/character/CharacterStateMachine.h @@ -1,20 +1,28 @@ #pragma once #include "character/CharacterActionLibrary.h" +#include "character/CharacterDataLoader.h" +#include "character/states/CharacterStateBase.h" +#include +#include namespace frostbite2D { class CharacterObject; -/// 角色主状态机。输入先变成意图,再由状态机决定当前大状态和动作推进。 class CharacterStateMachine { public: + using StateRegistry = std::unordered_map>; + + CharacterStateMachine() = default; + + void Configure(const character::CharacterConfig& config); void Reset(); void Update(CharacterObject& owner, CharacterCommandBuffer& commandBuffer, const CharacterIntent& intent, float deltaTime); - void ForceHurt(const CharacterActionDefinition* hurtAction); + void ForceHurt(CharacterObject& owner, const CharacterActionDefinition* hurtAction); CharacterStateId GetState() const { return currentState_; } const std::string& GetCurrentActionId() const { return currentActionId_; } @@ -27,24 +35,21 @@ public: bool IsMovementLocked() const; bool CanTurn() const; + void RegisterState(std::unique_ptr stateNode); + void ClearRegisteredStates(); + ICharacterStateNode* FindStateNode(CharacterStateId stateId) const; + private: void ChangeState(CharacterObject& owner, CharacterStateId nextState); void EnterAction(CharacterObject& owner, const CharacterActionDefinition* action); - void UpdateGroundState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent); - void UpdateAirState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent); - void UpdateActionState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent, - float deltaTime); - void UpdateHurtState(CharacterObject& owner, float deltaTime); + void InvokeStateHook(CharacterObject& owner, + const CharacterActionDefinition* action, + const char* phase) const; bool TryStartAction(CharacterObject& owner, CharacterCommandBuffer& commandBuffer, CharacterCommandType commandType, const std::string& actionId); + bool TryStartRegisteredAction(CharacterStateContext& context); void ApplyFrameEvents(CharacterObject& owner, int previousFrame, int currentFrame); CharacterStateId currentState_ = CharacterStateId::Idle; @@ -54,6 +59,9 @@ private: float actionFrameProgress_ = 0.0f; int actionFrame_ = 0; float landingTimer_ = 0.0f; + StateRegistry stateRegistry_; + + friend class CharacterStateBase; }; } // namespace frostbite2D diff --git a/Game/include/character/states/CharacterStateBase.h b/Game/include/character/states/CharacterStateBase.h new file mode 100644 index 0000000..00d5272 --- /dev/null +++ b/Game/include/character/states/CharacterStateBase.h @@ -0,0 +1,94 @@ +#pragma once + +#include "character/CharacterActionTypes.h" +#include + +namespace frostbite2D { + +class CharacterObject; +class CharacterStateMachine; +struct CharacterActionDefinition; + +struct CharacterStateContext { + CharacterObject& owner; + CharacterCommandBuffer& commandBuffer; + const CharacterIntent& intent; + float deltaTime = 0.0f; +}; + +class ICharacterStateNode { +public: + virtual ~ICharacterStateNode() = default; + + virtual CharacterStateId GetId() const = 0; + virtual void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)machine; + (void)context; + (void)previousState; + } + virtual void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) = 0; + virtual void OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) { + (void)machine; + (void)context; + (void)nextState; + } +}; + +class ICharacterActionStateNode { +public: + virtual ~ICharacterActionStateNode() = default; + virtual bool TryEnter(CharacterStateMachine& machine, + CharacterStateContext& context) = 0; +}; + +class CharacterStateBase : public ICharacterStateNode { +public: + explicit CharacterStateBase(CharacterStateId stateId); + ~CharacterStateBase() override = default; + + CharacterStateId GetId() const override; + +protected: + void ChangeState(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) const; + void PlayActionById(CharacterObject& owner, + const std::string& actionId, + const std::string& fallbackTag = "rest") const; + void InvokeHook(CharacterStateMachine& machine, + CharacterObject& owner, + const CharacterActionDefinition* action, + const char* phase) const; + bool TryStartAction(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterCommandType commandType, + const std::string& actionId) const; + bool TryStartRegisteredAction(CharacterStateMachine& machine, + CharacterStateContext& context) const; + void ApplyFrameEvents(CharacterStateMachine& machine, + CharacterObject& owner, + int previousFrame, + int currentFrame) const; + + const CharacterActionDefinition* GetCurrentAction(const CharacterStateMachine& machine) const; + const std::string& GetCurrentActionId(const CharacterStateMachine& machine) const; + void SetCurrentAction(CharacterStateMachine& machine, + const CharacterActionDefinition* action, + const std::string& actionId) const; + void ClearCurrentAction(CharacterStateMachine& machine) const; + + float& StateTime(CharacterStateMachine& machine) const; + float& ActionFrameProgress(CharacterStateMachine& machine) const; + int& ActionFrame(CharacterStateMachine& machine) const; + float& LandingTimer(CharacterStateMachine& machine) const; + +private: + CharacterStateId stateId_ = CharacterStateId::Idle; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/CharacterStateRegistry.h b/Game/include/character/states/CharacterStateRegistry.h new file mode 100644 index 0000000..26fb47d --- /dev/null +++ b/Game/include/character/states/CharacterStateRegistry.h @@ -0,0 +1,13 @@ +#pragma once + +#include "character/CharacterDataLoader.h" + +namespace frostbite2D { + +class CharacterStateMachine; + +void RegisterCommonCharacterStates(CharacterStateMachine& machine); +void RegisterJobCharacterStates(CharacterStateMachine& machine, + const character::CharacterConfig& config); + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/DeadState.h b/Game/include/character/states/common/DeadState.h new file mode 100644 index 0000000..a644046 --- /dev/null +++ b/Game/include/character/states/common/DeadState.h @@ -0,0 +1,15 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class DeadState : public CharacterStateBase { +public: + DeadState(); + + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/FallState.h b/Game/include/character/states/common/FallState.h new file mode 100644 index 0000000..4935ce1 --- /dev/null +++ b/Game/include/character/states/common/FallState.h @@ -0,0 +1,18 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class FallState : public CharacterStateBase { +public: + FallState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/HurtState.h b/Game/include/character/states/common/HurtState.h new file mode 100644 index 0000000..af69201 --- /dev/null +++ b/Game/include/character/states/common/HurtState.h @@ -0,0 +1,21 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class HurtState : public CharacterStateBase { +public: + HurtState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; + void OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/IdleState.h b/Game/include/character/states/common/IdleState.h new file mode 100644 index 0000000..1c32e20 --- /dev/null +++ b/Game/include/character/states/common/IdleState.h @@ -0,0 +1,18 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class IdleState : public CharacterStateBase { +public: + IdleState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/JumpState.h b/Game/include/character/states/common/JumpState.h new file mode 100644 index 0000000..f7741f0 --- /dev/null +++ b/Game/include/character/states/common/JumpState.h @@ -0,0 +1,18 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class JumpState : public CharacterStateBase { +public: + JumpState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/LandingState.h b/Game/include/character/states/common/LandingState.h new file mode 100644 index 0000000..14f77d3 --- /dev/null +++ b/Game/include/character/states/common/LandingState.h @@ -0,0 +1,18 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class LandingState : public CharacterStateBase { +public: + LandingState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/common/MoveState.h b/Game/include/character/states/common/MoveState.h new file mode 100644 index 0000000..866a869 --- /dev/null +++ b/Game/include/character/states/common/MoveState.h @@ -0,0 +1,18 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class MoveState : public CharacterStateBase { +public: + MoveState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; +}; + +} // namespace frostbite2D diff --git a/Game/include/character/states/jobs/swordman/SwordmanAttackState.h b/Game/include/character/states/jobs/swordman/SwordmanAttackState.h new file mode 100644 index 0000000..443fe5c --- /dev/null +++ b/Game/include/character/states/jobs/swordman/SwordmanAttackState.h @@ -0,0 +1,23 @@ +#pragma once + +#include "character/states/CharacterStateBase.h" + +namespace frostbite2D { + +class SwordmanAttackState : public CharacterStateBase, public ICharacterActionStateNode { +public: + SwordmanAttackState(); + + bool TryEnter(CharacterStateMachine& machine, + CharacterStateContext& context) override; + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; + void OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) override; +}; + +} // namespace frostbite2D diff --git a/Game/src/character/CharacterObject.cpp b/Game/src/character/CharacterObject.cpp index 578879b..95cc95b 100644 --- a/Game/src/character/CharacterObject.cpp +++ b/Game/src/character/CharacterObject.cpp @@ -28,6 +28,7 @@ bool CharacterObject::Construction(int jobId) { growType_ = -1; direction_ = 1; config_ = *config; + stateMachine_.Configure(*config_); equipmentManager_.Init(config_->baseJobConfig); if (auto actionLibrary = loadCharacterActionLibrary(*config_)) { actionLibrary_ = *actionLibrary; @@ -104,9 +105,7 @@ void CharacterObject::ApplyHit(const HitContext& hit) { if (hit.launchZVelocity > 0.0f) { motor_.grounded = false; } - stateMachine_.ForceHurt(hurtAction); - PlayAnimationTag(hurtAction ? hurtAction->animationTag - : ResolveAnimationTag("hurt_light", "rest")); + stateMachine_.ForceHurt(*this, hurtAction); } void CharacterObject::OnUpdate(float deltaTime) { @@ -172,6 +171,15 @@ void CharacterObject::PlayAnimationTag(const std::string& actionTag) { SetAction(actionTag); } +void CharacterObject::RunStateHook(const std::string& phase, + CharacterStateId stateId, + const CharacterActionDefinition* action) { + (void)phase; + (void)stateId; + (void)action; + // 预留给后续脚本系统;当前默认由 C++ 状态节点接管生命周期逻辑。 +} + void CharacterObject::SetFacing(int direction) { motor_.facing = direction >= 0 ? 1 : -1; SetDirection(motor_.facing); diff --git a/Game/src/character/CharacterStateMachine.cpp b/Game/src/character/CharacterStateMachine.cpp index 51e640e..7bcd91a 100644 --- a/Game/src/character/CharacterStateMachine.cpp +++ b/Game/src/character/CharacterStateMachine.cpp @@ -1,33 +1,16 @@ #include "character/CharacterStateMachine.h" #include "character/CharacterObject.h" -#include +#include "character/states/CharacterStateRegistry.h" namespace frostbite2D { -namespace { -constexpr float kLogicFramesPerSecond = 60.0f; -constexpr float kLandingDuration = 0.08f; -constexpr float kMovementActionScale = 0.35f; - -bool hasMoveIntent(const CharacterIntent& intent) { - return std::abs(intent.moveX) > 0.01f || std::abs(intent.moveY) > 0.01f; +void CharacterStateMachine::Configure(const character::CharacterConfig& config) { + ClearRegisteredStates(); + RegisterCommonCharacterStates(*this); + RegisterJobCharacterStates(*this, config); + Reset(); } -void playActionById(CharacterObject& owner, - const std::string& actionId, - const std::string& fallbackTag = "rest") { - if (const auto* action = owner.FindAction(actionId)) { - if (!action->animationTag.empty()) { - owner.PlayAnimationTag(action->animationTag); - return; - } - } - - owner.PlayAnimationTag(owner.ResolveAnimationTag(actionId, fallbackTag)); -} - -} // namespace - void CharacterStateMachine::Reset() { currentState_ = CharacterStateId::Idle; currentActionId_.clear(); @@ -44,35 +27,30 @@ void CharacterStateMachine::Update(CharacterObject& owner, float deltaTime) { stateTime_ += deltaTime; - switch (currentState_) { - case CharacterStateId::Idle: - case CharacterStateId::Move: - case CharacterStateId::Landing: - UpdateGroundState(owner, commandBuffer, intent); - break; - case CharacterStateId::Jump: - case CharacterStateId::Fall: - UpdateAirState(owner, commandBuffer, intent); - break; - case CharacterStateId::Action: - UpdateActionState(owner, commandBuffer, intent, deltaTime); - break; - case CharacterStateId::Hurt: - UpdateHurtState(owner, deltaTime); - break; - case CharacterStateId::Dead: - owner.GetMotorMutable().StopGroundMovement(); - break; + CharacterStateContext context{owner, commandBuffer, intent, deltaTime}; + if (auto* stateNode = FindStateNode(currentState_)) { + stateNode->OnUpdate(*this, context); } } -void CharacterStateMachine::ForceHurt(const CharacterActionDefinition* hurtAction) { - currentState_ = CharacterStateId::Hurt; +void CharacterStateMachine::ForceHurt(CharacterObject& owner, + const CharacterActionDefinition* hurtAction) { + const CharacterActionDefinition* previousAction = currentAction_; currentAction_ = hurtAction; currentActionId_ = hurtAction ? hurtAction->actionId : std::string("hurt_light"); - stateTime_ = 0.0f; - actionFrameProgress_ = 0.0f; - actionFrame_ = 0; + if (currentState_ == CharacterStateId::Hurt) { + InvokeStateHook(owner, previousAction, "on_exit"); + stateTime_ = 0.0f; + actionFrameProgress_ = 0.0f; + actionFrame_ = 0; + CharacterStateContext context{owner, owner.GetCommandBufferMutable(), owner.GetCurrentIntent(), + owner.GetLastDeltaTime()}; + if (auto* hurtNode = FindStateNode(CharacterStateId::Hurt)) { + hurtNode->OnEnter(*this, context, CharacterStateId::Hurt); + } + return; + } + ChangeState(owner, CharacterStateId::Hurt); } bool CharacterStateMachine::CanAcceptGroundActions() const { @@ -98,12 +76,39 @@ bool CharacterStateMachine::CanTurn() const { && !currentAction_->canTurn); } +void CharacterStateMachine::RegisterState(std::unique_ptr stateNode) { + if (!stateNode) { + return; + } + stateRegistry_[stateNode->GetId()] = std::move(stateNode); +} + +void CharacterStateMachine::ClearRegisteredStates() { + stateRegistry_.clear(); +} + +ICharacterStateNode* CharacterStateMachine::FindStateNode(CharacterStateId stateId) const { + auto it = stateRegistry_.find(stateId); + if (it == stateRegistry_.end()) { + return nullptr; + } + return it->second.get(); +} + void CharacterStateMachine::ChangeState(CharacterObject& owner, CharacterStateId nextState) { if (currentState_ == nextState) { return; } + CharacterStateId previousState = currentState_; + CharacterStateContext context{owner, owner.GetCommandBufferMutable(), owner.GetCurrentIntent(), + owner.GetLastDeltaTime()}; + + if (auto* previousNode = FindStateNode(previousState)) { + previousNode->OnExit(*this, context, nextState); + } + currentState_ = nextState; stateTime_ = 0.0f; if (nextState != CharacterStateId::Action && nextState != CharacterStateId::Hurt) { @@ -111,189 +116,38 @@ void CharacterStateMachine::ChangeState(CharacterObject& owner, currentActionId_.clear(); } - switch (nextState) { - case CharacterStateId::Idle: - playActionById(owner, "idle"); - break; - case CharacterStateId::Move: - playActionById(owner, "move"); - break; - case CharacterStateId::Jump: - playActionById(owner, "jump"); - break; - case CharacterStateId::Fall: - playActionById(owner, "fall"); - break; - case CharacterStateId::Landing: - landingTimer_ = 0.0f; - playActionById(owner, "landing"); - break; - case CharacterStateId::Action: - case CharacterStateId::Hurt: - case CharacterStateId::Dead: - break; + if (auto* nextNode = FindStateNode(nextState)) { + nextNode->OnEnter(*this, context, previousState); } } void CharacterStateMachine::EnterAction(CharacterObject& owner, const CharacterActionDefinition* action) { - // 普攻和技能都走通用 Action 状态,差异由动作定义驱动。 + const CharacterActionDefinition* previousAction = currentAction_; currentAction_ = action; currentActionId_ = action ? action->actionId : std::string(); - currentState_ = CharacterStateId::Action; - stateTime_ = 0.0f; - actionFrameProgress_ = 0.0f; - actionFrame_ = 0; - if (action) { - owner.PlayAnimationTag(action->animationTag); + if (currentState_ == CharacterStateId::Action) { + InvokeStateHook(owner, previousAction, "on_exit"); + stateTime_ = 0.0f; + actionFrameProgress_ = 0.0f; + actionFrame_ = 0; + CharacterStateContext context{owner, owner.GetCommandBufferMutable(), owner.GetCurrentIntent(), + owner.GetLastDeltaTime()}; + if (auto* actionNode = FindStateNode(CharacterStateId::Action)) { + actionNode->OnEnter(*this, context, CharacterStateId::Action); + } + return; } + ChangeState(owner, CharacterStateId::Action); } -void CharacterStateMachine::UpdateGroundState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent) { - auto& motor = owner.GetMotorMutable(); - if (!motor.grounded) { - ChangeState(owner, CharacterStateId::Fall); +void CharacterStateMachine::InvokeStateHook(CharacterObject& owner, + const CharacterActionDefinition* action, + const char* phase) const { + if (!action || !phase || phase[0] == '\0') { return; } - - if (currentState_ == CharacterStateId::Landing) { - landingTimer_ += owner.GetLastDeltaTime(); - motor.StopGroundMovement(); - if (landingTimer_ < kLandingDuration) { - return; - } - } - - if (TryStartAction(owner, commandBuffer, CharacterCommandType::Skill1Pressed, - "skill_1")) { - return; - } - if (TryStartAction(owner, commandBuffer, CharacterCommandType::AttackPressed, - "attack_1")) { - return; - } - if (commandBuffer.HasBuffered(CharacterCommandType::JumpPressed)) { - commandBuffer.Consume(CharacterCommandType::JumpPressed); - motor.Jump(); - ChangeState(owner, CharacterStateId::Jump); - return; - } - - motor.ApplyGroundInput(intent.moveX, intent.moveY); - owner.SetFacing(motor.facing); - if (hasMoveIntent(intent)) { - ChangeState(owner, CharacterStateId::Move); - } else { - motor.StopGroundMovement(); - ChangeState(owner, CharacterStateId::Idle); - } -} - -void CharacterStateMachine::UpdateAirState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent) { - auto& motor = owner.GetMotorMutable(); - motor.ApplyGroundInput(intent.moveX, intent.moveY, 0.75f); - owner.SetFacing(motor.facing); - - if (motor.grounded) { - ChangeState(owner, CharacterStateId::Landing); - return; - } - - ChangeState(owner, motor.verticalVelocity > 0.0f ? CharacterStateId::Jump - : CharacterStateId::Fall); - - if (commandBuffer.HasBuffered(CharacterCommandType::AttackPressed)) { - commandBuffer.Consume(CharacterCommandType::AttackPressed); - } - if (commandBuffer.HasBuffered(CharacterCommandType::Skill1Pressed)) { - commandBuffer.Consume(CharacterCommandType::Skill1Pressed); - } -} - -void CharacterStateMachine::UpdateActionState(CharacterObject& owner, - CharacterCommandBuffer& commandBuffer, - const CharacterIntent& intent, - float deltaTime) { - auto& motor = owner.GetMotorMutable(); - if (!currentAction_) { - ChangeState(owner, CharacterStateId::Idle); - return; - } - - if (currentAction_->canMove) { - float moveScale = currentAction_->moveSpeedScale > 0.0f - ? currentAction_->moveSpeedScale - : kMovementActionScale; - motor.ApplyGroundInput(intent.moveX, intent.moveY, moveScale); - } else { - motor.StopGroundMovement(); - } - if (currentAction_->canTurn) { - owner.SetFacing(motor.facing); - } - - actionFrameProgress_ += deltaTime * kLogicFramesPerSecond; - int previousFrame = actionFrame_; - actionFrame_ = std::max(0, static_cast(actionFrameProgress_)); - ApplyFrameEvents(owner, previousFrame + 1, actionFrame_); - - for (const auto& rule : currentAction_->cancelRules) { - // 取消窗口由动作定义提供,状态机只负责在允许的帧段里消费缓存输入。 - if (actionFrame_ < rule.beginFrame || actionFrame_ > rule.endFrame) { - continue; - } - if (rule.requireGrounded && !motor.grounded) { - continue; - } - if (rule.requireAirborne && motor.grounded) { - continue; - } - - if (rule.targetAction == "attack_2" - && commandBuffer.HasBuffered(CharacterCommandType::AttackPressed)) { - if (TryStartAction(owner, commandBuffer, CharacterCommandType::AttackPressed, - rule.targetAction)) { - return; - } - } - } - - if (currentAction_->totalFrames > 0 && actionFrame_ >= currentAction_->totalFrames) { - currentAction_ = nullptr; - currentActionId_.clear(); - if (!motor.grounded) { - ChangeState(owner, CharacterStateId::Fall); - } else if (hasMoveIntent(intent)) { - ChangeState(owner, CharacterStateId::Move); - } else { - ChangeState(owner, CharacterStateId::Idle); - } - } -} - -void CharacterStateMachine::UpdateHurtState(CharacterObject& owner, float deltaTime) { - (void)deltaTime; - auto& motor = owner.GetMotorMutable(); - motor.StopGroundMovement(); - if (!currentAction_) { - currentAction_ = owner.FindAction("hurt_light"); - } - - actionFrameProgress_ += owner.GetLastDeltaTime() * kLogicFramesPerSecond; - actionFrame_ = std::max(0, static_cast(actionFrameProgress_)); - if (currentAction_ && actionFrame_ >= currentAction_->totalFrames) { - currentAction_ = nullptr; - currentActionId_.clear(); - if (motor.grounded) { - ChangeState(owner, CharacterStateId::Idle); - } else { - ChangeState(owner, CharacterStateId::Fall); - } - } + owner.RunStateHook(phase, currentState_, action); } bool CharacterStateMachine::TryStartAction(CharacterObject& owner, @@ -314,6 +168,15 @@ bool CharacterStateMachine::TryStartAction(CharacterObject& owner, return true; } +bool CharacterStateMachine::TryStartRegisteredAction(CharacterStateContext& context) { + auto* stateNode = FindStateNode(CharacterStateId::Action); + auto* actionNode = dynamic_cast(stateNode); + if (!actionNode) { + return false; + } + return actionNode->TryEnter(*this, context); +} + void CharacterStateMachine::ApplyFrameEvents(CharacterObject& owner, int previousFrame, int currentFrame) { @@ -323,7 +186,6 @@ void CharacterStateMachine::ApplyFrameEvents(CharacterObject& owner, auto& motor = owner.GetMotorMutable(); for (const auto& event : currentAction_->frameEvents) { - // 帧事件把“动作进行到第几帧时要做什么”从硬编码里抽出来。 if (event.frame < previousFrame || event.frame > currentFrame) { continue; } diff --git a/Game/src/character/states/CharacterStateBase.cpp b/Game/src/character/states/CharacterStateBase.cpp new file mode 100644 index 0000000..95a1f86 --- /dev/null +++ b/Game/src/character/states/CharacterStateBase.cpp @@ -0,0 +1,98 @@ +#include "character/states/CharacterStateBase.h" +#include "character/CharacterObject.h" +#include "character/CharacterStateMachine.h" + +namespace frostbite2D { + +CharacterStateBase::CharacterStateBase(CharacterStateId stateId) + : stateId_(stateId) { +} + +CharacterStateId CharacterStateBase::GetId() const { + return stateId_; +} + +void CharacterStateBase::ChangeState(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) const { + machine.ChangeState(context.owner, nextState); +} + +void CharacterStateBase::PlayActionById(CharacterObject& owner, + const std::string& actionId, + const std::string& fallbackTag) const { + if (const auto* action = owner.FindAction(actionId)) { + if (!action->animationTag.empty()) { + owner.PlayAnimationTag(action->animationTag); + return; + } + } + + owner.PlayAnimationTag(owner.ResolveAnimationTag(actionId, fallbackTag)); +} + +void CharacterStateBase::InvokeHook(CharacterStateMachine& machine, + CharacterObject& owner, + const CharacterActionDefinition* action, + const char* phase) const { + machine.InvokeStateHook(owner, action, phase); +} + +bool CharacterStateBase::TryStartAction(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterCommandType commandType, + const std::string& actionId) const { + return machine.TryStartAction(context.owner, context.commandBuffer, commandType, actionId); +} + +bool CharacterStateBase::TryStartRegisteredAction(CharacterStateMachine& machine, + CharacterStateContext& context) const { + return machine.TryStartRegisteredAction(context); +} + +void CharacterStateBase::ApplyFrameEvents(CharacterStateMachine& machine, + CharacterObject& owner, + int previousFrame, + int currentFrame) const { + machine.ApplyFrameEvents(owner, previousFrame, currentFrame); +} + +const CharacterActionDefinition* CharacterStateBase::GetCurrentAction( + const CharacterStateMachine& machine) const { + return machine.currentAction_; +} + +const std::string& CharacterStateBase::GetCurrentActionId( + const CharacterStateMachine& machine) const { + return machine.currentActionId_; +} + +void CharacterStateBase::SetCurrentAction(CharacterStateMachine& machine, + const CharacterActionDefinition* action, + const std::string& actionId) const { + machine.currentAction_ = action; + machine.currentActionId_ = actionId; +} + +void CharacterStateBase::ClearCurrentAction(CharacterStateMachine& machine) const { + machine.currentAction_ = nullptr; + machine.currentActionId_.clear(); +} + +float& CharacterStateBase::StateTime(CharacterStateMachine& machine) const { + return machine.stateTime_; +} + +float& CharacterStateBase::ActionFrameProgress(CharacterStateMachine& machine) const { + return machine.actionFrameProgress_; +} + +int& CharacterStateBase::ActionFrame(CharacterStateMachine& machine) const { + return machine.actionFrame_; +} + +float& CharacterStateBase::LandingTimer(CharacterStateMachine& machine) const { + return machine.landingTimer_; +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/CharacterStateRegistry.cpp b/Game/src/character/states/CharacterStateRegistry.cpp new file mode 100644 index 0000000..6c51ca9 --- /dev/null +++ b/Game/src/character/states/CharacterStateRegistry.cpp @@ -0,0 +1,32 @@ +#include "character/states/CharacterStateRegistry.h" +#include "character/CharacterStateMachine.h" +#include "character/states/common/DeadState.h" +#include "character/states/common/FallState.h" +#include "character/states/common/HurtState.h" +#include "character/states/common/IdleState.h" +#include "character/states/common/JumpState.h" +#include "character/states/common/LandingState.h" +#include "character/states/common/MoveState.h" +#include "character/states/jobs/swordman/SwordmanAttackState.h" +#include + +namespace frostbite2D { + +void RegisterCommonCharacterStates(CharacterStateMachine& machine) { + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); + machine.RegisterState(std::make_unique()); +} + +void RegisterJobCharacterStates(CharacterStateMachine& machine, + const character::CharacterConfig& config) { + if (config.jobTag == "swordman") { + machine.RegisterState(std::make_unique()); + } +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/DeadState.cpp b/Game/src/character/states/common/DeadState.cpp new file mode 100644 index 0000000..2b491a4 --- /dev/null +++ b/Game/src/character/states/common/DeadState.cpp @@ -0,0 +1,16 @@ +#include "character/states/common/DeadState.h" +#include "character/CharacterObject.h" + +namespace frostbite2D { + +DeadState::DeadState() + : CharacterStateBase(CharacterStateId::Dead) { +} + +void DeadState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + (void)machine; + context.owner.GetMotorMutable().StopGroundMovement(); +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/FallState.cpp b/Game/src/character/states/common/FallState.cpp new file mode 100644 index 0000000..923499b --- /dev/null +++ b/Game/src/character/states/common/FallState.cpp @@ -0,0 +1,43 @@ +#include "character/states/common/FallState.h" +#include "character/CharacterObject.h" + +namespace frostbite2D { + +FallState::FallState() + : CharacterStateBase(CharacterStateId::Fall) { +} + +void FallState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + PlayActionById(context.owner, "fall"); + InvokeHook(machine, context.owner, context.owner.FindAction("fall"), "on_enter"); +} + +void FallState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY, 0.75f); + owner.SetFacing(motor.facing); + + if (motor.grounded) { + ChangeState(machine, context, CharacterStateId::Landing); + return; + } + + if (motor.verticalVelocity > 0.0f) { + ChangeState(machine, context, CharacterStateId::Jump); + return; + } + + if (context.commandBuffer.HasBuffered(CharacterCommandType::AttackPressed)) { + context.commandBuffer.Consume(CharacterCommandType::AttackPressed); + } + if (context.commandBuffer.HasBuffered(CharacterCommandType::Skill1Pressed)) { + context.commandBuffer.Consume(CharacterCommandType::Skill1Pressed); + } +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/HurtState.cpp b/Game/src/character/states/common/HurtState.cpp new file mode 100644 index 0000000..dccaf0e --- /dev/null +++ b/Game/src/character/states/common/HurtState.cpp @@ -0,0 +1,77 @@ +#include "character/states/common/HurtState.h" +#include "character/CharacterObject.h" +#include +#include + +namespace frostbite2D { +namespace { + +CharacterStateId ResolveInterruptedState(const CharacterStateContext& context) { + auto& motor = context.owner.GetMotorMutable(); + if (!motor.grounded) { + return CharacterStateId::Fall; + } + return (std::abs(context.intent.moveX) > 0.01f || std::abs(context.intent.moveY) > 0.01f) + ? CharacterStateId::Move + : CharacterStateId::Idle; +} + +} // namespace + +HurtState::HurtState() + : CharacterStateBase(CharacterStateId::Hurt) { +} + +void HurtState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + ActionFrameProgress(machine) = 0.0f; + ActionFrame(machine) = 0; + const CharacterActionDefinition* action = GetCurrentAction(machine); + if (!action) { + action = context.owner.FindAction("hurt_light"); + SetCurrentAction(machine, action, action ? action->actionId : "hurt_light"); + } + if (action) { + context.owner.PlayAnimationTag(action->animationTag); + InvokeHook(machine, context.owner, action, "on_enter"); + } +} + +void HurtState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + motor.StopGroundMovement(); + + const CharacterActionDefinition* action = GetCurrentAction(machine); + if (!action) { + action = owner.FindAction("hurt_light"); + SetCurrentAction(machine, action, action ? action->actionId : "hurt_light"); + } + if (!action) { + context.owner.SetFacing(motor.facing); + ChangeState(machine, context, ResolveInterruptedState(context)); + return; + } + + InvokeHook(machine, owner, action, "on_update"); + + ActionFrameProgress(machine) += context.deltaTime * 60.0f; + ActionFrame(machine) = std::max(0, static_cast(ActionFrameProgress(machine))); + if (action->totalFrames > 0 && ActionFrame(machine) >= action->totalFrames) { + ClearCurrentAction(machine); + context.owner.SetFacing(motor.facing); + ChangeState(machine, context, ResolveInterruptedState(context)); + } +} + +void HurtState::OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) { + (void)nextState; + InvokeHook(machine, context.owner, GetCurrentAction(machine), "on_exit"); +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/IdleState.cpp b/Game/src/character/states/common/IdleState.cpp new file mode 100644 index 0000000..4d7461e --- /dev/null +++ b/Game/src/character/states/common/IdleState.cpp @@ -0,0 +1,55 @@ +#include "character/states/common/IdleState.h" +#include "character/CharacterObject.h" +#include + +namespace frostbite2D { +namespace { + +bool HasMoveIntent(const CharacterIntent& intent) { + return std::abs(intent.moveX) > 0.01f || std::abs(intent.moveY) > 0.01f; +} + +} // namespace + +IdleState::IdleState() + : CharacterStateBase(CharacterStateId::Idle) { +} + +void IdleState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + PlayActionById(context.owner, "idle"); + InvokeHook(machine, context.owner, context.owner.FindAction("idle"), "on_enter"); +} + +void IdleState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + if (!motor.grounded) { + ChangeState(machine, context, CharacterStateId::Fall); + return; + } + + if (TryStartRegisteredAction(machine, context)) { + return; + } + if (context.commandBuffer.HasBuffered(CharacterCommandType::JumpPressed)) { + context.commandBuffer.Consume(CharacterCommandType::JumpPressed); + motor.Jump(); + ChangeState(machine, context, CharacterStateId::Jump); + return; + } + + if (HasMoveIntent(context.intent)) { + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY); + owner.SetFacing(motor.facing); + ChangeState(machine, context, CharacterStateId::Move); + return; + } + + motor.StopGroundMovement(); +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/JumpState.cpp b/Game/src/character/states/common/JumpState.cpp new file mode 100644 index 0000000..32b180f --- /dev/null +++ b/Game/src/character/states/common/JumpState.cpp @@ -0,0 +1,43 @@ +#include "character/states/common/JumpState.h" +#include "character/CharacterObject.h" + +namespace frostbite2D { + +JumpState::JumpState() + : CharacterStateBase(CharacterStateId::Jump) { +} + +void JumpState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + PlayActionById(context.owner, "jump"); + InvokeHook(machine, context.owner, context.owner.FindAction("jump"), "on_enter"); +} + +void JumpState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY, 0.75f); + owner.SetFacing(motor.facing); + + if (motor.grounded) { + ChangeState(machine, context, CharacterStateId::Landing); + return; + } + + if (motor.verticalVelocity <= 0.0f) { + ChangeState(machine, context, CharacterStateId::Fall); + return; + } + + if (context.commandBuffer.HasBuffered(CharacterCommandType::AttackPressed)) { + context.commandBuffer.Consume(CharacterCommandType::AttackPressed); + } + if (context.commandBuffer.HasBuffered(CharacterCommandType::Skill1Pressed)) { + context.commandBuffer.Consume(CharacterCommandType::Skill1Pressed); + } +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/LandingState.cpp b/Game/src/character/states/common/LandingState.cpp new file mode 100644 index 0000000..43b54e4 --- /dev/null +++ b/Game/src/character/states/common/LandingState.cpp @@ -0,0 +1,64 @@ +#include "character/states/common/LandingState.h" +#include "character/CharacterObject.h" +#include + +namespace frostbite2D { +namespace { + +constexpr float kLandingDuration = 0.08f; + +bool HasMoveIntent(const CharacterIntent& intent) { + return std::abs(intent.moveX) > 0.01f || std::abs(intent.moveY) > 0.01f; +} + +} // namespace + +LandingState::LandingState() + : CharacterStateBase(CharacterStateId::Landing) { +} + +void LandingState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + LandingTimer(machine) = 0.0f; + PlayActionById(context.owner, "landing"); + InvokeHook(machine, context.owner, context.owner.FindAction("landing"), "on_enter"); +} + +void LandingState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + if (!motor.grounded) { + ChangeState(machine, context, CharacterStateId::Fall); + return; + } + + LandingTimer(machine) += context.deltaTime; + motor.StopGroundMovement(); + if (LandingTimer(machine) < kLandingDuration) { + return; + } + + if (TryStartRegisteredAction(machine, context)) { + return; + } + if (context.commandBuffer.HasBuffered(CharacterCommandType::JumpPressed)) { + context.commandBuffer.Consume(CharacterCommandType::JumpPressed); + motor.Jump(); + ChangeState(machine, context, CharacterStateId::Jump); + return; + } + + if (HasMoveIntent(context.intent)) { + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY); + owner.SetFacing(motor.facing); + ChangeState(machine, context, CharacterStateId::Move); + return; + } + + ChangeState(machine, context, CharacterStateId::Idle); +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/common/MoveState.cpp b/Game/src/character/states/common/MoveState.cpp new file mode 100644 index 0000000..fc3ee96 --- /dev/null +++ b/Game/src/character/states/common/MoveState.cpp @@ -0,0 +1,53 @@ +#include "character/states/common/MoveState.h" +#include "character/CharacterObject.h" +#include + +namespace frostbite2D { +namespace { + +bool HasMoveIntent(const CharacterIntent& intent) { + return std::abs(intent.moveX) > 0.01f || std::abs(intent.moveY) > 0.01f; +} + +} // namespace + +MoveState::MoveState() + : CharacterStateBase(CharacterStateId::Move) { +} + +void MoveState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + PlayActionById(context.owner, "move"); + InvokeHook(machine, context.owner, context.owner.FindAction("move"), "on_enter"); +} + +void MoveState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + if (!motor.grounded) { + ChangeState(machine, context, CharacterStateId::Fall); + return; + } + + if (TryStartRegisteredAction(machine, context)) { + return; + } + if (context.commandBuffer.HasBuffered(CharacterCommandType::JumpPressed)) { + context.commandBuffer.Consume(CharacterCommandType::JumpPressed); + motor.Jump(); + ChangeState(machine, context, CharacterStateId::Jump); + return; + } + + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY); + owner.SetFacing(motor.facing); + if (!HasMoveIntent(context.intent)) { + motor.StopGroundMovement(); + ChangeState(machine, context, CharacterStateId::Idle); + } +} + +} // namespace frostbite2D diff --git a/Game/src/character/states/jobs/swordman/SwordmanAttackState.cpp b/Game/src/character/states/jobs/swordman/SwordmanAttackState.cpp new file mode 100644 index 0000000..4141ce8 --- /dev/null +++ b/Game/src/character/states/jobs/swordman/SwordmanAttackState.cpp @@ -0,0 +1,110 @@ +#include "character/states/jobs/swordman/SwordmanAttackState.h" +#include "character/CharacterObject.h" +#include +#include + +namespace frostbite2D { +namespace { + +constexpr float kLogicFramesPerSecond = 60.0f; +constexpr float kMovementActionScale = 0.35f; + +bool HasMoveIntent(const CharacterIntent& intent) { + return std::abs(intent.moveX) > 0.01f || std::abs(intent.moveY) > 0.01f; +} + +CharacterStateId ResolveGroundState(const CharacterIntent& intent) { + return HasMoveIntent(intent) ? CharacterStateId::Move : CharacterStateId::Idle; +} + +} // namespace + +SwordmanAttackState::SwordmanAttackState() + : CharacterStateBase(CharacterStateId::Action) { +} + +bool SwordmanAttackState::TryEnter(CharacterStateMachine& machine, + CharacterStateContext& context) { + if (TryStartAction(machine, context, CharacterCommandType::Skill1Pressed, "skill_1")) { + return true; + } + return TryStartAction(machine, context, CharacterCommandType::AttackPressed, "attack_1"); +} + +void SwordmanAttackState::OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) { + (void)previousState; + ActionFrameProgress(machine) = 0.0f; + ActionFrame(machine) = 0; + const CharacterActionDefinition* action = GetCurrentAction(machine); + if (action) { + context.owner.PlayAnimationTag(action->animationTag); + InvokeHook(machine, context.owner, action, "on_enter"); + } +} + +void SwordmanAttackState::OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) { + auto& owner = context.owner; + auto& motor = owner.GetMotorMutable(); + const CharacterActionDefinition* action = GetCurrentAction(machine); + if (!action) { + ChangeState(machine, context, ResolveGroundState(context.intent)); + return; + } + + InvokeHook(machine, owner, action, "on_update"); + + if (action->canMove) { + float moveScale = action->moveSpeedScale > 0.0f ? action->moveSpeedScale : kMovementActionScale; + motor.ApplyGroundInput(context.intent.moveX, context.intent.moveY, moveScale); + } else { + motor.StopGroundMovement(); + } + if (action->canTurn) { + owner.SetFacing(motor.facing); + } + + ActionFrameProgress(machine) += context.deltaTime * kLogicFramesPerSecond; + int previousFrame = ActionFrame(machine); + ActionFrame(machine) = std::max(0, static_cast(ActionFrameProgress(machine))); + ApplyFrameEvents(machine, owner, previousFrame + 1, ActionFrame(machine)); + + for (const auto& rule : action->cancelRules) { + if (ActionFrame(machine) < rule.beginFrame || ActionFrame(machine) > rule.endFrame) { + continue; + } + if (rule.requireGrounded && !motor.grounded) { + continue; + } + if (rule.requireAirborne && motor.grounded) { + continue; + } + + if (rule.targetAction == "attack_2" + && context.commandBuffer.HasBuffered(CharacterCommandType::AttackPressed)) { + if (TryStartAction(machine, context, CharacterCommandType::AttackPressed, rule.targetAction)) { + return; + } + } + } + + if (action->totalFrames > 0 && ActionFrame(machine) >= action->totalFrames) { + ClearCurrentAction(machine); + if (!motor.grounded) { + ChangeState(machine, context, CharacterStateId::Fall); + } else { + ChangeState(machine, context, ResolveGroundState(context.intent)); + } + } +} + +void SwordmanAttackState::OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) { + (void)nextState; + InvokeHook(machine, context.owner, GetCurrentAction(machine), "on_exit"); +} + +} // namespace frostbite2D diff --git a/docs/角色状态机开发说明.md b/docs/角色状态机开发说明.md new file mode 100644 index 0000000..9ba95a6 --- /dev/null +++ b/docs/角色状态机开发说明.md @@ -0,0 +1,694 @@ +# 角色状态机开发说明 + +## 1. 这套架构是做什么的 + +这一套角色系统的目标,是把“输入处理”、“状态切换”、“动作定义”、“动画表现”和“位移推进”拆开,避免所有逻辑都堆在一个状态机函数里。 + +当前架构分成五层: + +1. 输入层:接收键盘、手柄等事件 +2. 命令层:把输入整理成统一命令和意图 +3. 状态层:决定角色此刻处于什么状态 +4. 动作层:从 `.act` / `.lst` 读取动作参数 +5. 表现层:根据动作定义播放动画,并由 `CharacterMotor` 推进坐标 + +这意味着: + +- 状态类负责“逻辑决策” +- `.act` 负责“动作配置和时序参数” +- 动画系统负责“资源表现” +- `CharacterMotor` 负责“x/y/z 逻辑坐标推进” + +不要再把动作时长、动画标签、可否转向、可否移动这类信息硬编码回状态机里。 + +## 2. 整体驱动流程 + +角色每帧的主流程如下: + +```text +输入事件 + -> CharacterInputRouter + -> CharacterCommandBuffer + -> CharacterIntent + -> CharacterStateMachine::Update() + -> 当前状态类 OnUpdate() + -> CharacterMotor::Update() + -> CharacterObject::SetWorldPosition() + -> CharacterObject::PlayAnimationTag() +``` + +可以按下面的理解来记: + +- `CharacterInputRouter` 负责把键盘、手柄输入统一成命令 +- `CharacterCommandBuffer` 负责缓冲按钮和移动轴 +- `CharacterIntent` 是这一帧角色“想做什么” +- `CharacterStateMachine` 负责找到当前状态类并调用它 +- 状态类决定要不要切状态、要不要吃掉输入、要不要启动动作 +- `CharacterMotor` 负责真正推进世界坐标 +- `CharacterObject` 负责把逻辑位置投影到屏幕,并驱动动画表现 + +## 3. 坐标与表现 + +角色逻辑坐标是三维:`x / y / z` + +- `x`:横向 +- `y`:地面纵深 +- `z`:高度 + +当前渲染不是直接画三维,而是把逻辑坐标投影到屏幕: + +- 屏幕坐标约等于 `Vec2(x, y - z)` +- 同层遮挡排序仍然主要看地面 `y` + +因此: + +- 看起来像 2D +- 但跳跃、击飞、下落这些逻辑,实际依赖 `z` 和 `verticalVelocity` + +相关核心数据在 `CharacterActionTypes.h`: + +- `CharacterWorldPosition` +- `CharacterMotor` +- `CharacterIntent` +- `CharacterCommandBuffer` +- `CharacterStateId` + +## 4. 目录结构与职责 + +当前状态系统相关代码主要在这些位置: + +```text +Game/include/character/ +Game/src/character/ +Game/include/character/states/ +Game/src/character/states/ +``` + +### 4.1 核心文件 + +- `CharacterObject` + - 角色主对象 + - 负责构建、更新、动画播放、受击入口 +- `CharacterStateMachine` + - 负责注册状态、切换状态、分发更新、推进动作帧事件 +- `CharacterStateBase` + - 所有状态类的基类 + - 提供公共 helper +- `CharacterStateRegistry` + - 负责把通用状态和职业状态注册进状态机 + +### 4.2 状态目录分层 + +```text +states/ + common/ + IdleState + MoveState + JumpState + FallState + LandingState + HurtState + DeadState + jobs/ + swordman/ + SwordmanAttackState +``` + +约定如下: + +- `common/`:所有职业都能复用的状态 +- `jobs//`:职业专属状态 +- 当前除攻击外,其余基础状态都在 `common/` +- 剑士攻击逻辑在 `jobs/swordman/` + +## 5. 状态机本体现在负责什么 + +`CharacterStateMachine` 现在是一个“调度器”,不是“所有逻辑的堆放点”。 + +它主要负责: + +- `Configure()`:按职业注册状态 +- `Reset()`:重置运行时状态 +- `Update()`:找到当前状态类并调用 `OnUpdate` +- `ChangeState()`:做状态切换,并触发 `OnExit -> OnEnter` +- `TryStartAction()`:按动作 id 启动动作 +- `ApplyFrameEvents()`:根据 `.act` 定义执行帧事件 + +它不应该再负责: + +- 直接写死所有状态的业务逻辑 +- 直接写死 idle/move/jump/attack 的完整流程 +- 直接把职业特有攻击逻辑塞在一个大 switch 里 + +## 6. 状态类生命周期 + +每个状态类都继承 `CharacterStateBase`,支持以下生命周期: + +### 6.1 `OnEnter` + +进入状态时调用,常见用途: + +- 重置计时器 +- 设置动作帧游标 +- 播放对应动画 +- 调用进入 hook + +### 6.2 `OnUpdate` + +每帧调用,常见用途: + +- 检查输入 +- 检查 grounded / velocity / 受击等条件 +- 切换状态 +- 推进动作帧 +- 执行取消规则 +- 消费输入命令 + +### 6.3 `OnExit` + +离开状态时调用,常见用途: + +- 调用退出 hook +- 做简单清理 + +### 6.4 `TryEnter` + +这个不是所有状态都有。 + +只有“承担动作入口判定职责”的职业动作状态才需要实现 `ICharacterActionStateNode`,例如当前的: + +- `SwordmanAttackState` + +它的作用是: + +- 在通用状态里,不直接写死“按攻击键就进哪个动作” +- 而是统一调用 `TryStartRegisteredAction()` +- 再由职业动作状态自己的 `TryEnter()` 决定是 `attack_1`、`skill_1` 还是别的动作 + +这样职业差异就解耦开了。 + +## 7. CharacterStateBase 提供了什么 + +`CharacterStateBase` 是给所有状态类复用的基类,常用 helper 包括: + +- `ChangeState()` + - 切换到另一个状态 +- `PlayActionById()` + - 通过逻辑动作 id 查动作定义并播放动画 +- `InvokeHook()` + - 触发状态 hook,当前先走 C++ 预留接口 +- `TryStartAction()` + - 按命令类型和动作 id 尝试启动动作 +- `TryStartRegisteredAction()` + - 让职业动作状态决定是否启动动作 +- `ApplyFrameEvents()` + - 执行动作的帧事件 +- `GetCurrentAction()` / `SetCurrentAction()` / `ClearCurrentAction()` + - 读写当前动作定义 +- `StateTime()` / `ActionFrameProgress()` / `ActionFrame()` / `LandingTimer()` + - 访问状态机内部运行时变量 + +开发新状态时,优先复用这些 helper,不要重复去碰状态机内部字段。 + +## 8. 当前已有状态怎么分工 + +### 8.1 通用状态 + +- `IdleState` + - 地面待机 + - 检查移动、跳跃、职业动作入口 +- `MoveState` + - 地面移动 + - 检查停止移动、跳跃、职业动作入口 +- `JumpState` + - 上升阶段 + - 空中可控移动,升到顶后转 `Fall` +- `FallState` + - 下落阶段 + - 落地后转 `Landing` +- `LandingState` + - 落地收招阶段 + - 用短 landing timer 过渡,再回 `Idle` 或 `Move` +- `HurtState` + - 受击状态 + - 推进受击动作帧,到结束后回地面或空中状态 +- `DeadState` + - 死亡状态 + - 当前只停止地面移动 + +### 8.2 剑士职业状态 + +- `SwordmanAttackState` + - 当前负责剑士攻击与技能入口 + - 在 `TryEnter()` 决定是否启动 `attack_1` 或 `skill_1` + - 在 `OnUpdate()` 中推进动作帧、处理 cancel rule、结束后回 `Idle / Move / Fall` + +注意: + +- `Action` 是一个逻辑状态 id +- 但它的具体实现是职业可替换的 +- 现在剑士把 `Action` 的实现接管到了 `SwordmanAttackState` + +## 9. 动作和动画到底怎么关联 + +这是开发时最容易混的地方。 + +### 9.1 两层名字 + +当前系统里有两层名字: + +1. 逻辑动作名:例如 `idle`、`move`、`attack_1` +2. 动画标签名:例如 `rest`、`move`、`attack1` + +### 9.2 读取来源 + +逻辑动作定义从 `script.pvf` 中读取,路径关系是: + +```text +character//action_logic/action_list.lst + -> 指向多个 .act + -> 每个 .act 提供一个动作定义 +``` + +例如: + +```text +0 `swordman/action_logic/idle.act` +1 `swordman/action_logic/move.act` +2 `swordman/action_logic/attack_1.act` +``` + +### 9.3 `.act` 里关注哪些字段 + +当前最关键的是: + +- `action id` +- `animation tag` +- `total frames` +- `loop` +- `can move` +- `can turn` +- `can be interrupted` + +其中: + +- `action id` 是玩法逻辑识别名 +- `animation tag` 是动画系统真正去播放的标签 + +所以状态类通常不直接写动画名,而是: + +- 先用逻辑动作 id 找动作定义 +- 再从动作定义里拿 `animationTag` +- 最后调用 `PlayAnimationTag()` + +### 9.4 一个例子 + +例如 `attack_1.act` 里: + +```text +[action id] + `attack_1` + +[animation tag] + `attack1` +``` + +那状态系统理解为: + +- 逻辑上现在执行的是 `attack_1` +- 表现上要播的动画是 `attack1` + +## 10. `.lst` 与 `.act` 的当前约定 + +### 10.1 `action_list.lst` 格式 + +当前只支持新格式: + +```text +#PVF_File +0 `swordman/action_logic/idle.act` +1 `swordman/action_logic/move.act` +2 `swordman/action_logic/jump.act` +``` + +注意: + +- 第一列是索引 +- 第二列是相对职业目录的 `.act` 路径 +- 项目里当前只按“索引 / 路径”对来解析 + +### 10.2 `.act` 写法注意 + +字符串建议写成: + +```text +`attack_1` +``` + +不要依赖源码层再做“去反引号”的兼容逻辑。 + +另外布尔项在 PVF 中不要写 C++ 风格的裸 `true` / `false`。要用当前 PVF 能稳定落地的格式,例如项目现有约定支持的 0/1 或能被脚本解析的文本形式。 + +## 11. 当前动作帧是怎么推进的 + +动作状态下,核心流程是: + +1. `TryStartAction()` 启动某个逻辑动作 +2. 状态机记录 `currentAction_` 和 `currentActionId_` +3. 进入 `Action` 状态的 `OnEnter()` +4. `OnUpdate()` 每帧用 `deltaTime * 60` 推进逻辑帧 +5. 根据动作定义应用帧事件和取消规则 +6. 到达 `totalFrames` 后回收动作并切回后续状态 + +当前帧事件入口在: + +- `CharacterStateMachine::ApplyFrameEvents()` + +当前已支持的关键帧事件类型包括: + +- `SetVelocityXY` +- `SetVelocityZ` +- `FinishAction` + +后面如果要加命中框、输入开放窗、特效事件,也优先沿着帧事件扩,而不是继续把时序写死到状态类里。 + +## 12. 如何新增一个通用状态 + +下面以“新增 DashState”为例。 + +### 步骤 1:新建状态类 + +在: + +```text +Game/include/character/states/common/DashState.h +Game/src/character/states/common/DashState.cpp +``` + +类定义继承: + +```cpp +class DashState : public CharacterStateBase { +public: + DashState(); + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override; + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override; + void OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) override; +}; +``` + +### 步骤 2:构造函数绑定状态 id + +```cpp +DashState::DashState() + : CharacterStateBase(CharacterStateId::Dash) { +} +``` + +如果新增的是全新状态 id,需要先扩展 `CharacterStateId` 枚举。 + +### 步骤 3:实现状态逻辑 + +常规做法: + +- `OnEnter()`:重置计时器、播 dash 动画 +- `OnUpdate()`:推进位移,检查结束条件,决定切回哪个状态 +- `OnExit()`:收尾或发 hook + +### 步骤 4:注册状态 + +在 `CharacterStateRegistry` 中注册: + +- 通用状态放 `RegisterCommonCharacterStates()` +- 职业状态放 `RegisterJobCharacterStates()` + +### 步骤 5:补切换入口 + +仅注册状态还不够,还要在合适的已有状态里决定“什么时候能进这个状态”。 + +例如: + +- 在 `IdleState::OnUpdate()` 检查 dash 输入 +- 在 `MoveState::OnUpdate()` 检查 dash 输入 +- 或在职业动作状态中允许某些动作 cancel 到 dash + +### 步骤 6:准备动作资源 + +如果 dash 有专属动作,要补: + +- `action_list.lst` +- `dash.act` +- 对应动画标签资源 + +## 13. 如何新增一个职业动作状态 + +如果是职业差异比较大的状态,建议放到: + +```text +states/jobs// +``` + +### 13.1 什么时候应该做成职业状态 + +满足以下情况时建议独立做: + +- 输入入口不同 +- 动作链不同 +- 取消规则不同 +- 动作帧事件不同 +- 和其他职业共享价值不高 + +### 13.2 如果它承担动作入口 + +就让它同时继承: + +- `CharacterStateBase` +- `ICharacterActionStateNode` + +然后实现: + +- `TryEnter()`:决定什么输入启动什么动作 +- `OnEnter()`:进入动作表现 +- `OnUpdate()`:推进帧、处理 cancel、判断结束 +- `OnExit()`:退出收尾 + +### 13.3 参考当前剑士攻击状态 + +可以直接参考: + +- `SwordmanAttackState::TryEnter()` +- `SwordmanAttackState::OnUpdate()` + +它已经演示了: + +- 用 `Skill1Pressed` 启动 `skill_1` +- 用 `AttackPressed` 启动 `attack_1` +- 用 cancel rule 在窗口内切到 `attack_2` + +## 14. 新增一个动作资源要做什么 + +如果只是给已有状态补一个新动作,通常要做这几件事: + +1. 在 `action_list.lst` 中增加映射 +2. 新建对应 `.act` +3. 在 `.act` 中写好 `action id` 和 `animation tag` +4. 确保动画资源里真的有这个 tag +5. 在状态逻辑里通过逻辑动作 id 去触发它 + +最小示例: + +```text +#PVF_File +0 `swordman/action_logic/idle.act` +1 `swordman/action_logic/move.act` +2 `swordman/action_logic/dash.act` +``` + +对应 `dash.act` 示例: + +```text +#PVF_File + +[action id] + `dash` + +[animation tag] + `dash` + +[total frames] + 12 + +[loop] + 0 + +[can move] + 1 + +[can turn] + 0 + +[can be interrupted] + 0 +``` + +## 15. 如何理解当前输入接入 + +当前输入来源不只键盘,主要有: + +- 键盘 +- 手柄轴 +- 手柄按键 + +统一入口都收敛到 `CharacterInputRouter`,它负责把平台输入转成统一命令。 + +所以状态层不要关心: + +- 这是键盘来的 +- 还是手柄来的 + +状态层只关心: + +- 有没有 `JumpPressed` +- 有没有 `AttackPressed` +- 当前 `moveX / moveY` 是多少 + +这就是输入解耦的关键。 + +## 16. 当前脚本扩展点在哪里 + +`CharacterObject::RunStateHook()` 是当前预留给脚本系统的入口。 + +状态在以下时机会调用它: + +- `on_enter` +- `on_update` +- `on_exit` + +目前它还是空实现,意思是: + +- 当前实际逻辑仍由 C++ 状态类负责 +- 但未来可以把一部分进入、更新、退出逻辑下沉到脚本 + +如果后面要接脚本,建议保持下面的边界: + +- C++ 继续控制状态切换主流程和底层安全逻辑 +- 脚本负责更灵活的状态表现、特效、派生行为、部分判定 + +不要一开始就把所有底层切换权完全放给脚本,否则调试成本会很高。 + +## 17. 常见问题排查 + +### 17.1 按键了角色没反应 + +优先查: + +1. 输入事件有没有进 `CharacterInputRouter` +2. 有没有生成对应命令进入 `CharacterCommandBuffer` +3. 当前状态的 `OnUpdate()` 有没有检查这个命令 +4. 命令有没有被过早消费 +5. 对应动作 id 有没有加载成功 + +### 17.2 状态切了但动画不对 + +优先查: + +1. `.act` 的 `animation tag` 是否正确 +2. 动画资源里是否存在这个 tag +3. 当前状态是否通过逻辑动作 id 正确拿到了动作定义 +4. 是否被 fallback 动画顶掉了 + +### 17.3 动作资源没读到 + +优先查: + +1. `action_list.lst` 路径格式是否正确 +2. `.act` 路径是否相对于 `character//` +3. `jobTag` 是否和实际职业目录一致 +4. PVF 中是否真的导入了这些文件 + +### 17.4 朝左时时装和人物不契合 + +这个问题通常优先查: + +- Ani 锚点 +- 左右翻转实现 +- 各部件的挂点/偏移 + +它一般不是状态机逻辑问题。 + +## 18. 推荐开发约定 + +为了后面大家都能维护,建议遵守下面几条: + +- 一个状态一个类 +- 通用状态优先放 `common/` +- 职业差异大的状态放 `jobs//` +- 动作参数优先写 `.act` +- 状态类只写决策,不写死资源细节 +- 新功能先找有没有通用 helper 能复用 +- 新状态一定补入口,不要只注册不切换 + +## 19. 一个最小状态模板 + +下面是一个最小可抄的状态模板: + +```cpp +class ExampleState : public CharacterStateBase { +public: + ExampleState() + : CharacterStateBase(CharacterStateId::Idle) { + } + + void OnEnter(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId previousState) override { + (void)previousState; + PlayActionById(context.owner, "idle"); + InvokeHook(machine, context.owner, context.owner.FindAction("idle"), "on_enter"); + } + + void OnUpdate(CharacterStateMachine& machine, + CharacterStateContext& context) override { + auto& motor = context.owner.GetMotorMutable(); + if (!motor.grounded) { + ChangeState(machine, context, CharacterStateId::Fall); + return; + } + } + + void OnExit(CharacterStateMachine& machine, + CharacterStateContext& context, + CharacterStateId nextState) override { + (void)nextState; + InvokeHook(machine, context.owner, context.owner.FindAction("idle"), "on_exit"); + } +}; +``` + +## 20. 新同事接入时建议先读什么 + +建议按这个顺序看: + +1. `CharacterObject::OnUpdate()` +2. `CharacterStateMachine` +3. `CharacterStateBase` +4. `CharacterStateRegistry` +5. `states/common/` 下的基础状态 +6. `states/jobs/swordman/SwordmanAttackState` +7. `CharacterActionTypes.h` +8. 对应职业的 `action_list.lst` 和 `.act` + +这样能最快建立“输入 -> 状态 -> 动作 -> 动画 -> 位移”的完整认知。 + +--- + +如果后面架构继续扩展,建议再补两份专题文档: + +- 一份讲 `.act` / 帧事件 / cancel rule 设计 +- 一份讲脚本如何接入状态生命周期