06-武士零状态节点与子弹时间实现
我们的游戏项目中,玩家可以按下鼠标右键进入子弹时间状态,来让包括敌人行动在内的整个世界运行速度放缓。
不过在此之前,让我们先填充玩家角色的状态机节点细节。
1 状态节点
1.1 整体结构
我们先纵览一下武士零角色的所有状态节点和跳转关系。
在本项目的设计中,武士零的形动由7个状态构成。这与我们提供的动画素材基本上是一一对应的关系。

实际编写时,我们只需要重点关注节点的跳出逻辑即可。
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 调试


在视频评论区看到了解决落地和翻滚卡顿的方法。那就是在main函数中循环播放bgm。
现在还有一个问题是,在跑动过程中按下触控板右键不管用。
测试发现使用鼠标右键没有问题,所以应该是触控板的问题,而非程序问题。
评论
如果你已登录 GitHub,就可以直接在这里评论。