跳转至

04-有限状态机与横版游戏角色类设计

1 状态机

1.1 概念

在本阶段项目中,尤其是再引入具备AI行为的BOSS敌对角色时,其行为和状态阶段比较复杂。如果把所有逻辑都堆在on_update中,就过于臃肿了。
所以我们不得不开始设计点更优雅的架构。来将不同阶段的岱庙划分到不同的区域中,有条理地进行处理。
这时,就要搬出游戏开发技术中的一个热度很高的经典设计了——有限状态机。
它的英文名叫FSM,Finite State Machine。


Pasted image 20260310214643.png

状态机再运行时,同一时刻只会有一个状态节点被激活。
当我们设计一个状态机时,本质是再设计其中各个状态节点的内容。

想要设计一个状态机节点,我们需要从下面几个方面入手。

  • 条件(或称之为“事件”):当满足何种情况时,状态机才会进入到当前状态节点中。
  • 动作:即当前状态下要做什么。
  • 变换:即当前状态退出的时机。

Pasted image 20260310215111.png

1.2 StateNode类

我们新建state_node.h头文件,在其中声明StateNode类

class StateNode
{
public:
    StateNode() = default;
    ~StateNode() = default;

    virtual void on_enter() { }
    virtual void on_update(float delta) { }
    virtual void on_exit() { }
};

以后玩家和敌人的状态机只需要继承这个类,并重写对应的方法。

1.3 StateMachine类

对于状态节点的管理器,我们便可以创建state_machine.h头文件,并定义状态机类。

class StateMachine
{
public:
    StateMachine();
    ~StateMachine();

    void on_update(float delta);

    void set_entry(const std::string &id);
    void switch_to(const std::string &id);
    void register_state(const std::string &id, StateNode *state_node);

private:
    bool need_init = true;
    StateNode *current_state = nullptr;
    std::unordered_map<std::string, StateNode *> state_pool;
};

  • 为了更方便地对状态节点进行管理,我们和先前一样借助哈希表实现了状态池。我们可以通过字符串形式地状态名,注册新的状态或切换状态机地当前状态。
  • need_init标记了当前状态机是否已经被初始化过。也就是入口节点状态是否已经执行过on_enter逻辑。
  • current_state指针记录着当前激活的状态节点。以方便在on_update的过程中调用其更新逻辑。
  • set_entery和switch_to分别用来设置初始状态,和切换状态
  • register_state则用来向状态机中注册新的状态

对应到state_machine.cpp源代码文件中,

StateMachine::StateMachine() = default;

StateMachine::~StateMachine() = default;

void StateMachine::on_update(float delta)
{
    if(!current_state)
        return;

    if (need_init)
    {
        current_state->on_enter();
        need_init = false;
    }

    current_state->on_update(delta);
}

void StateMachine::set_entry(const std::string& id)
{
    current_state = state_pool[id];
}

void StateMachine::switch_to(const std::string& id)
{
    if (current_state)
        current_state->on_exit();
    current_state = state_pool[id];
    if (current_state) 
        current_state->on_enter();
}

void StateMachine::register_state(const std::string& id, StateNode* state_node)
{
    state_pool[id] = state_node;
}

2 Character角色类实现

2.1 声明头文件

无论是玩家还是敌人,我们希望它们都能继承一个游戏角色基类。
所以先定义charater头文件,

class Character
{
public:
    Character();
    ~Character();

protected:
    struct AnimationGroup
    {
        Animation left;
        Animation right;
    };

protected:
    const float FLOOR_Y = 620;                 // 地板的竖直方向坐标
    const float GRAVITY = 980 * 2;             // 重力大小

protected:
    int hp = 10;                               // 角色生命值
    Vector2 position;                          // 角色脚底位置
    Vector2 velocity;                          // 角色速度
    float logic_height = 0;                    // 角色的逻辑高度
    bool is_facing_left = true;                // 当前角色是否朝向左
    StateMachine state_machine;                // 角色逻辑状态机
    bool enable_gravity = true;                // 启用重力模拟
    bool is_invulnerable = false;              // 当前是否无敌
    Timer timer_invulnerable_blink;            // 无敌闪烁效果计时器
    Timer timer_invulnerable_statue;           // 无敌状态计时器
    bool is_blink_invulnerable = false;        // 当前是否处于闪烁的不可见帧
    CollisionBox *hit_box = nullptr;           // 攻击碰撞箱
    CollisionBox *hurt_box = nullptr;          // 受击碰撞箱
    AnimationGroup *current_animation = nullptr;            // 当前角色动画
    std::unordered_map<std::string, AnimationGroup> animation_pool; // 角色动画池
};

之后,需要定义下面这些方法,方便在外部获取或修改Character中刚刚定义的protected字段
public:
    Character();
    ~Character();

    void decrease_hp();

    int get_hp() const
    {
        return hp;
    }

    void set_position(const Vector2& position)
    {
        this->position = position;
    }

    const Vector2& get_position() const
    {
        return position;
    }

    void set_velocity(const Vector2& velocity)
    {
        this->velocity = velocity;
    }

    const Vector2& get_velocity() const
    {
        return velocity;
    }

    Vector2 get_logic_center() const
    {
        return Vector2(position.x, position.y - logic_height / 2);
    }

    void set_gravity_enabled(bool flag)
    {
        enable_gravity = flag;
    }

    CollisionBox* get_hit_box()
    {
        return hit_box;
    }

    CollisionBox* get_hurt_box()
    {
        return hurt_box;
    }

    bool is_on_floor() const
    {
        return position.y >= FLOOR_Y;
    }

    float get_gloor_y() const
    {
        return FLOOR_Y;
    }

    void make_invulnerable()
    {
        is_invulnerable = true;
        timer_invulnerable_status.restart();
    }

    virtual void on_input(const ExMessage &msg);
    virtual void on_update(float delta);
    virtual void on_render();

    virtual void on_hurt();

    void Character::switch_state(const std::string &id);
    void Character::set_animation(const std::string &id);

代码编写的思路始终是从游戏的实际功能需求出发,过度设计和过度封装是工程推进的大忌;在试错和学习的过程中,一定不要太多纠结细枝末节、无关紧要的细节而固步自封。

2.2 实现关键方法

下面便可以创建对应的character.cpp源文件,引入所需的头文件,开始编写先前声明的方法实现了。
首先是构造函数部分.设置闪烁状态持续1.0秒,闪烁帧每0.075秒切换一次。

Character::Character()
{
    hit_box = CollisionManager::instance()->create_collision_box();
    hurt_box = CollisionManager::instance()->create_collision_box();

    timer_invulnerable_status.set_wait_time(1.0f);
    timer_invulnerable_status.set_one_shot(true);
    timer_invulnerable_status.set_callback([&]()
        { is_invulnerable = false; });

    timer_invulnerable_blink.set_wait_time(0.075f);
    timer_invulnerable_blink.set_one_shot(false);
    timer_invulnerable_blink.set_callback([&]()
        { is_blink_invulnerable = !is_blink_invulnerable; });
}

对应在析构函数中,销毁碰撞箱

Character::~Character()
{
    CollisionManager::instance()->destroy_collision_box(hit_box);
    CollisionManager::instance()->destroy_collision_box(hurt_box);
}

然后是decreas_hp方法

void Character::decrease_hp()
{
    if (is_invulnerable)
        return;

    hp -= 1;
    if (hp > 0)
        make_invulnerable();
    on_hurt();
}

之后在on_update方法中,我们依次实现下述逻辑

  • 更新状态机
  • 重力逻辑(更新速度和位置)
  • 对位置进行限制
  • 设置受击碰撞箱的位置
  • 更新闪烁计时器
  • 根据朝向更新动画,并设置其位置
    void Character::on_update(float delta)
    {
        state_machine.on_update(delta);
    
        if (hp<=0) {
            velocity.x = 0;
        }
        if (enable_gravity) {
            velocity.y += GRAVITY * delta;
        }
    
        position += velocity * delta;
    
        if(position.y >= FLOOR_Y) {
            position.y = FLOOR_Y;
            velocity.y = 0;
        }
    
        if (position.x <= 0) position.x=0;
        if (position.x >= getwidth()) position.x = (float)(getwidth());
    
        hurt_box->set_position(get_logic_center());
    
        timer_invulnerable_status.on_update(delta);
    
        if(is_invulnerable) timer_invulnerable_blink.on_update(delta);
    
        if (!current_animation) return;
    
        Animation &animation = (is_facing_left ? current_animation->left : current_animation->right);
        animation.on_update(delta);
        animation.set_position(position);
    }
    

在on_render方法中, 如果没有设置当前动画或者处于闪烁状态的不可见帧,则直接返回。

void Character::on_render()
{
    if (!current_animation || (is_invulnerable && is_blink_invisible)) return;

    (is_facing_left ? current_animation->left : current_animation->right).on_render();
}

on_hurt方法主要负责在受击播放不同效果的受击音效。因此在基类中我们留空即可。
void Character::on_hurt() { }

对于切换状态和设置动画的方法实现,只需要操作状态机和动画池即可。

void Character::switch_state(const std::string& id)
{
    state_machine.switch_to(id);
}

void Character::set_animation(const std::string& id)
{
    current_animation = &animation_pool[id];
    current_animation->left.reset();
    current_animation->right.reset();
}

评论

如果你已登录 GitHub,就可以直接在这里评论。