跳转至

06-武士零状态节点与子弹时间实现

我们的游戏项目中,玩家可以按下鼠标右键进入子弹时间状态,来让包括敌人行动在内的整个世界运行速度放缓。
不过在此之前,让我们先填充玩家角色的状态机节点细节。

1 状态节点

1.1 整体结构

我们先纵览一下武士零角色的所有状态节点和跳转关系。
在本项目的设计中,武士零的形动由7个状态构成。这与我们提供的动画素材基本上是一一对应的关系。
Pasted image 20260311172157.png
实际编写时,我们只需要重点关注节点的跳出逻辑即可。

1.2 声明状态类

对于攻击、翻滚等退出条件受时间影响的节点,我们都添加了Timer定时器字段的定义。使用定时器对象控制这些状态的跳转逻辑。

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

    void on_enter() override;
    void on_update(float delta) override;
    void on_exit() override;

private:
    Timer timer;

private:
    void update_hit_box_position();
};

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

    void on_enter() override;
    void on_update(float delta) override;

private:
    Timer timer;
};

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

    void on_enter() override;
    void on_update(float delta) override;
};

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

    void on_enter() override;
    void on_update(float delta) override;
};

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

    void on_enter() override;
    void on_update(float delta) override;
};

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

    void on_enter() override;
    void on_update(float delta) override;
    void on_exit() override;

private:
    Timer timer;
};

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

    void on_enter() override;
    void on_update(float delta) override;
    void on_exit() override;
};

2 角色管理器

我们的状态节点要控制玩家对象,那么玩家实例应该存放在何处呢?
我们当然可以添加一个角色管理器,来处理所有继承自Character基类的游戏角色。

所以创建character_manager.h头文件
角色管理器同样是单例类,内部存储了玩家和敌人对象实例指针,同时提供了对外的get访问接口,对于角色的操作消息输入、更新和渲染逻辑。我们也是添加了对应的input、update和render方法。

class CharacterManager
{
public:
    static CharacterManager *instance();

    Character *get_enemy()
    {
        return enemy;
    }

    Character* get_player()
    {
        return player;
    }

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

private:
    static CharacterManager *manager;

    Character *enemy = nullptr;
    Character *player = nullptr;

private:
    CharacterManager();
    ~CharacterManager();
};

对应到character_manager.cpp文件中

CharacterManager *CharacterManager::manager = nullptr;

CharacterManager *CharacterManager::instance()
{
    if(!manager)
        manager = new CharacterManager();
    return manager;
}

void CharacterManager::on_input(const ExMessage& msg)
{
    player->on_input(msg);
}

void CharacterManager::on_update(float delta)
{
    player->on_update(delta);
}

void CharacterManager::on_render()
{
    player->on_render();
}

CharacterManager::CharacterManager()
{
    player = new Player();
}

CharacterManager::~CharacterManager()
{
    delete enemy;
    delete player;
}

3 实现状态类方法

现在,我们就可以创建player_state_nodes.cpp源代码文件
首先实现PlayerAttackState
在构造函数中初始化了控制状态的定时器,在0.3秒后退出攻击状态。这时需要同步设置玩家角色的攻击状态标志。

在on_enter方法中,也就是玩家进入到攻击状态时,首先设置当前动画为attack,随后设置攻击碰撞箱启用,并设置角色的攻击状态标志。随后更新碰撞箱的位置,随后调用角色的on_attack方法,并重置计时器。
在攻击时,我们也需要从已经加载的攻击音效中,随机挑选一个进行播放。

PlayerAttackState::PlayerAttackState()
{
    timer.set_wait_time(0.3f);
    timer.set_one_shot(true);
    timer.set_callback([&]()
        { 
            Player *player = (Player *)CharacterManager::instance()->get_player();
            player->set_attacking(false); 
        });
}

void PlayerAttackState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("attack");

    Player *player = (Player *)CharacterManager::instance()->get_player();
    player->get_hit_box()->set_enable(true);
    player->set_attacking(true);
    update_hit_box_position();
    player->on_attack();
    timer.restart();

    switch(range_random(1,3))
    {
    case 1:
        play_audio(_T("player_attack_1"), false);
        break;
    case 2:
        play_audio(_T("player_attack_2"), false);
        break;
    case 3:
        play_audio(_T("player_attack_3"), false);
        break;
    }
}

range_random是自定义的用来获取范围随机数的函数。
我们来到util.h头文件中
inline int range_random(int min_num, int max_num)   // 在[min_num, max_num]范围内返回一个随机整数
{
    return min_num + rand() % (max_num - min_num + 1);
}

之后回到player_state_nodes.cpp中,继续编写攻击状态更新和退出的逻辑。
在攻击状态持续进行的过程中,除去更新定时器和攻击碰撞箱外,还需要处理状态跳出的逻辑,当角色生命值归零时跳转到死亡状态。当角色攻击状态结束时,再根据当前是否处于地板上,以及是否存在水平移动,将状态切换为闲置或者奔跑的状态中。
在攻击状态的退出方法中,我们与先前的on_enter前后呼应,仅用了玩家的碰撞箱。

void PlayerAttackState::on_update(float delta)
{
    timer.on_update(delta);
    update_hit_box_position();

    Player* player = (Player *)CharacterManager::instance()->get_player();

    if (player->get_hp() <=0) {
        player->switch_state("dead");
    } else if (!player->get_attacking()) {
        if (player->get_velocity().y > 0) {
            player->switch_state("fall");
        } else if (player->get_move_axis() == 0) {
            player->switch_state("idle");
        } else if (player->is_on_floor() && player->get_move_axis() != 0) {
            player->switch_state("run");
        }
    }
}

void PlayerAttackState::on_exit()
{
    Player* player = (Player *)CharacterManager::instance()->get_player();
    player->get_hit_box()->set_enable(false);
    player->set_attacking(false);
}

而在更新碰撞箱位置的方法中,我们根据角色的逻辑中心位置和攻击方向枚举,将碰撞箱再对应的方向上偏移一小段距离,而在另一条坐标轴上保持居中。

void PlayerAttackState::update_hit_box_position()
{
    Player* player = (Player *)CharacterManager::instance()->get_player();
    Vector2 pos_center = player->get_logic_center();
    CollisionBox *hit_box = player->get_hit_box();
    const Vector2& size_hit_box = hit_box->get_size();
    Vector2 pos_hit_box;

    switch(player->get_attack_dir())
    {
    case Player::AttackDir::Up:
        pos_hit_box = {pos_center.x, pos_center.y - size_hit_box.y / 2};
        break;
    case Player::AttackDir::Down:
        pos_hit_box = {pos_center.x, pos_center.y + size_hit_box.y / 2};
        break;
    case Player::AttackDir::Left:
        pos_hit_box = {pos_center.x - size_hit_box.x / 2, pos_center.y};
        break;
    case Player::AttackDir::Right:
        pos_hit_box = {pos_center.x + size_hit_box.x / 2, pos_center.y};
        break;
    }
    hit_box->set_position(pos_hit_box);
}

死亡状态的实现比较简单,我们无需编写跳出逻辑。由于玩家角色是存在死亡动画的,我们希望状态进入后一段时间再结束游戏。

PlayerDeadState::PlayerDeadState()
{
    timer.set_wait_time(2.0f);
    timer.set_one_shot(true);
    timer.set_callback([&]()
        { 
            MessageBox(GetHWnd(), _T("不对……\n这样不行。"), _T("挑战失败!"), MB_OK);
            exit(0);
        });
}

void PlayerDeadState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("dead");
    play_audio(_T("player_dead"), false);
}

void PlayerDeadState::on_update(float delta)
{
    timer.on_update(delta);
}

玩家坠落的状态,相关逻辑比较简单。唯一的重点是,检查角色是否已经落到地板上了,将状态跳转到idle现置状态,并调用玩家角色的on_land方法,播放落地音效。

void PlayerFallState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("fall");
}

void PlayerFallState::on_update(float delta)
{
    Player* player = (Player *)CharacterManager::instance()->get_player();

    if(player->get_hp() <=0) {
        player->switch_state("dead");
    } else if (player->is_on_floor()) {
        player->switch_state("idle");
        player->on_land();
        play_audio(_T("player_land"), false);
    } else if (player->can_attack()) {
        player->switch_state("attack");
    }
}

玩家闲置状态作为玩家的默认状态,我们在处理状态跳转时,如果不是强关联的状态,则可以先设置为闲置状态进行过渡。

void PlayerIdleState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("fidle");
}

void PlayerIdleState::on_update(float delta)
{
    Player* player = (Player *)CharacterManager::instance()->get_player();

    if (player->get_hp() <= 0) {
        player->switch_state("dead");
    } else if (player->can_attack()) {
        player->switch_state("attack");
    } else if (player->get_velocity().y > 0) {
        player->switch_state("fall");
    } else if (player->can_jump()) {
        player->switch_state("jump");
    } else if (player->can_roll()) {
        player->switch_state("roll");
    } else if (player->is_on_floor() && player->get_move_axis() != 0) {
        player->switch_state("run");
    } 
}

跳跃状态

void PlayerJumpState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("jump");

    Player* player = (Player *)CharacterManager::instance()->get_player();
    player->on_jump();
}

void PlayerJumpState::on_update(float delta)
{
    Player* player = (Player *)CharacterManager::instance()->get_player();

    if(player->get_hp() <= 0) {
        player->switch_state("dead");
    } else if (player->get_velocity().y > 0) {
        player->switch_state("fall");
    } else if (player->can_attack()) {
        player->switch_state("attack");
    }
}

翻滚状态和攻击状态类似。由于翻滚期间玩家角色是无敌的,所以在翻滚状态开始时关闭了玩家的受击碰撞箱,在退出时再重新启用。
此外,由于无敌的效果,翻滚状态是唯一一个不需要跳转到死亡状态的节点。

PlayerRollState::PlayerRollState()
{
    timer.set_wait_time(0.35f);
    timer.set_one_shot(true);
    timer.set_callback([&]()
        { 
            Player *player = (Player *)CharacterManager::instance()->get_player();
            player->set_rolling(false); 
        });
}

void PlayerRollState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("roll");

    Player *player = (Player *)CharacterManager::instance()->get_player();
    player->get_hurt_box()->set_enable(false);
    player->set_rolling(true);
    player->on_roll();
    timer.restart();

    play_audio(_T("player_roll"), false);
}

void PlayerRollState::on_update(float delta)
{
    timer.on_update(delta);

    Player* player = (Player *)CharacterManager::instance()->get_player();

    if (!player->get_rolling()) {
        if (player->get_move_axis() != 0) {
            player->switch_state("run");
        } else if (player->can_jump()) {
            player->switch_state("jump");
        } else {
            player->switch_state("idle");
        }
    }
}

void PlayerRollState::on_exit()
{
    Player* player = (Player *)CharacterManager::instance()->get_player();
    player->get_hurt_box()->set_enable(true);
}

最后的奔跑状态。由于奔跑音效是循环播放的,所以需要在on_enter状态进入时开始播放,在on_exit状态退出时结束。

void PlayerRunState::on_enter()
{
    CharacterManager::instance()->get_player()->set_animation("run");
    play_audio(_T("player_run"), true);
}

void PlayerRunState::on_update(float delta)
{
    Player* player = (Player *)CharacterManager::instance()->get_player();

    if (player->get_hp() <= 0) {
        player->switch_state("dead");
    } else if (player->get_move_axis() == 0) {
        player->switch_state("idle");
    } else if (player->can_jump()) {
        player->switch_state("jump");
    } else if (player->can_attack()) {
        player->switch_state("attack");
    } else if (player->can_roll()) {
        player->switch_state("roll");
    }
}

void PlayerRunState::on_exit()
{
    stop_audio(_T("player_run"));
}

到此,所有的状态都已经编写完了。

之后我们来到player.cpp源代码文件中,引入玩家状态节点头文件. 之后来到构造函数的最后,补全我们先前预留的状态机初始化代码。

state_machine.register_state("attack", new PlayerAttackState());
state_machine.register_state("dead", new PlayerDeadState());
state_machine.register_state("fall", new PlayerFallState());
state_machine.register_state("idle", new PlayerIdleState());
state_machine.register_state("jump", new PlayerJumpState());
state_machine.register_state("roll", new PlayerRollState());
state_machine.register_state("run", new PlayerRunState());

state_machine.set_entry("idle");

最后我们来到main.cpp中,引入碰撞管理器和角色管理器的头文件
然后来到main函数的主循环部分,添加对已有的各个管理器系统的调用.
对程序架构精心设计的好处就在这里,我们只需要在主函数中使用极少量且含义明确的代码,就能完成对整个游戏逻辑的执行。

while(!is_quit) 
{
    while(peekmessage(&msg))
    {
        // 处理消息
        CharacterManager::instance()->on_input(msg);
    }

    steady_clock::time_point frame_start = steady_clock::now();
    duration<float> delta = duration<float>(frame_start - last_tick);

    // 处理更新
    CharacterManager::instance()->on_update(delta.count());
    CollisionManager::instance()->process_collide();

    setbkcolor(RGB(0, 0, 0));
    cleardevice();

    // 处理绘图
    draw_background();
    CharacterManager::instance()->on_render();
    CollisionManager::instance()->on_debug_render();

    FlushBatchDraw();

    last_tick = frame_start;
    nanoseconds sleep_duration = frame_duration - (steady_clock::now() - frame_start);
    if (sleep_duration > nanoseconds(0))
        std::this_thread::sleep_for(sleep_duration);
}

4 调试

运行程序,在一开始发现角色没有落到平台上,而是落到了平台下面某个位置。由此推测是渲染出错了。检查发现是Animation::on_render()中计算坐标时把减号错写成了加号,下面是正确代码

void on_render()
{
    const Frame &frame = frame_list[idx_frame];

    Rect rect_dst;
    rect_dst.x = (int)position.x - frame.rect_src.w / 2;
    rect_dst.y = (anchor_mode == AnchorMode::Centered)
        ? (int)position.y - frame.rect_src.h / 2 : (int)position.y - frame.rect_src.h;
    rect_dst.w = frame.rect_src.w, rect_dst.h = frame.rect_src.h;

    putimage_ex(frame.image, &rect_dst, &frame.rect_src);
}

还有一个问题是按下鼠标发动攻击时,会直接卡死。
在调试模式下运行,按下鼠标,程序直接退出,终端有下面的报错信息

C:/msys64/mingw64/include/c++/15.2.0/bits/stl_vector.h:1263: std::vector<_Tp, _Alloc>::reference std::vector<_Tp, _Alloc>::operator[](size_type) [with _Tp = Animation::Frame; _Alloc = std::allocator<Animation::Frame>; reference = Animation::Frame&; size_type = long long unsigned int]: Assertion '__n < this->size()' failed.

由此可以看出是Animation::on_render中下面这一行越界了。
const Frame &frame = frame_list[idx_frame];

之后我把这一行改成
const Frame &frame = frame_list.at(idx_frame);

运行程序,可以看到更详细的报错信息
terminate called after throwing an instance of 'std::out_of_range'
  what():  vector::_M_range_check: __n (which is 0) >= this->size() (which is 0)

这说明越界时,vector的长度为0,这可能是animation的帧序列没有正确加载,检查发现错误发生在player.cpp中构造函数中的这一行,之前不小心把animation_attack_right写成了animation_attack_left,导致animation_attack_right中没有正确加载到帧序列
animation_attack_right.add_frame(ResourcesManager::instance()->find_image("player_attack_right"), 5);

最后一个问题是角色落地和翻滚时会有很短时间的卡顿,不过似乎并不明显

5 子弹时间

我们现有的游戏框架下,整个世界的运行,无论是动画的更新还是各类游戏对象的运动。我们都是利用

on_update(delta);

根据传入的帧更新时间进行处理。那么要实现整个世界慢下来的效果,就只需要在最上层主循环计算帧更新时间时,对这个事件进行缩放即可,使用这种思路不仅可以实现子弹时间的效果,还可以实现时间加速。

5.1 子弹时间管理器

于是,我们可以定义bullet_timer_manager.h头文件
我们定义了一个枚举,用来标记当前正在进入子弹时间还是正在退出子弹时间。
因为在进入子弹时间过程中,时间的速度是逐渐变慢的,退出子弹时间也是逐渐退出。所以我们需要这个状态标志来记录这个持续的状态。
在子弹时间状态下,我们让除去玩家之外的所有画面都暗下来。我们在post_process方法中实现这部分功能。
progress变量是一个取值范围为0~1的浮点数。记录进入子弹事件的进入,0表示当前没有子弹时间效果,1表示完全进入子弹时间状态。
我们定义了一些常量分别用来表示进入和退出的速度,以及完全进入子弹时间后帧更新时间的缩放和画面色彩改变比例。

class BulletTimerManager
{
public:
    enum class Status
    {
        Entering,
        Exiting
    };

public:
    static BulletTimerManager *instance();

    void post_process();
    void set_status(Status status);

    float on_update(float delta);

private:
    static BulletTimerManager *manager;

private:
    float progress = 0;
    Status status = Status::Exiting;
    const float SPEED_PROGRESS = 2.0f;
    const float DST_DELTA_FACTOR = 0.35f;
    const float DST_COLOR_FACTOR = 0.35f;
};

private:
    BulletTimerManager();
    ~BulletTimerManager();

我们定义了一个lerp函数来实现插值的效果.
float lerp(float start, float end, float t)
{
    return (1 - t) * start + t * end;
}

之后来到新建的bullet_time_manager.cpp

BulletTimeManager *BulletTimeManager::manager = nullptr;

BulletTimeManager *BulletTimeManager::instance()
{
    if (!manager)
        manager = new BulletTimeManager();
    return manager;
}

void BulletTimeManager::post_process()
{
    DWORD *buffer = GetImageBuffer();
    int w = getwidth(), h = getheight();
    for (int y = 0; y < h; y++)
    {
        for (int x = 0; x < w; x++) 
        {
            int idx = y * w + x;
            DWORD color = buffer[idx];
            BYTE r = (BYTE)(GetBValue(color) * lerp(1.0f, DST_COLOR_FACTOR, progress));
            BYTE g = (BYTE)(GetGValue(color) * lerp(1.0f, DST_COLOR_FACTOR, progress));
            BYTE b = (BYTE)(GetRValue(color) * lerp(1.0f, DST_COLOR_FACTOR, progress));
            buffer[idx] = BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) << 24);
        }
    }
}

on_update方法比较特殊,我们返回一个float值,这个值是根据当前子弹时间状态进度,计算得到的缩放后帧更新时间。
也就是说子弹时间管理器更像是一个“拦截器”的概念,它拦截了主循环中帧更新的现实时间,进行缩放处理后对外返回一个处理后的时间。
而游戏世界中所有物体的更新都依照这个时间进行处理。那么就可以时间我们的预期效果。
void BulletTimeManager::set_status(Status status)
{
    this->status = status;
}

float BulletTimeManager::on_update(float delta)
{
    float delta_progress = SPEED_PROGRESS * delta;
    progress += delta_progress * (status == Status::Entering ? 1 : -1);

    if (progress < 0)
        progress = 0;
    if (progress > 1.0f)
        progress = 1.0f;

    return delta * lerp(1.0f, DST_DELTA_FACTOR, progress);
}

BulletTimeManager::BulletTimeManager() = default;
BulletTimeManager::~BulletTimeManager() = default;

5.2 接入功能

我们来到player.cpp中
在on_input最后进行修改
我们在鼠标右键按下时播放时间暂停的音效,并设置子弹时间管理器的状态为进入。右键抬起时停止播放音效并设置状态为退出。

case WM_RBUTTONDOWN:
    play_audio(_T("bullet_time"), false);
    BulletTimeManager::instance()->set_status(BulletTimeManager::Status::Entering);
    break;
case WM_RBUTTONUP:
    stop_audio(_T("bullet_time"));
    BulletTimeManager::instance()->set_status(BulletTimeManager::Status::Exiting);
    break;

随后来到character_manager.cpp
在渲染玩家之前调用子弹时间管理器的后处理方法,这样就可以让除去玩家外的所有内容,都可以受到变暗效果的影响

void CharacterManager::on_render()
{
    BulletTimeManager::instance()->post_process();
    player->on_render();
}

最后在main.cpp中
来到帧循环中,修改帧更新时间相关的计算代码,将子弹管理器缩放后的时间,作为角色管理器更新所需的时间。

steady_clock::time_point frame_start = steady_clock::now();
duration<float> delta = duration<float>(frame_start - last_tick);

float scaled_delta = BulletTimeManager::instance()->on_update(delta.count());
CharacterManager::instance()->on_update(scaled_delta);
CollisionManager::instance()->process_collide();

6 调试


Pasted image 20260312112320.png


Pasted image 20260312112427.png

在视频评论区看到了解决落地和翻滚卡顿的方法。那就是在main函数中循环播放bgm。

现在还有一个问题是,在跑动过程中按下触控板右键不管用。
测试发现使用鼠标右键没有问题,所以应该是触控板的问题,而非程序问题。

评论

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