跳转至

16-胜负检定与结算动效

1 胜负检定

我们首先思考一下游戏都有哪些胜负条件。显而易见的是游戏会在某一方生命值为的0时候结束。
但是既然我们提供了平台和重力模拟,那么当一方玩家坠落到平台之下的深渊中时,游戏也会判定另一方玩家获胜。
也就是说如果我们统一使用生命值这个概念来决定游戏胜负,那么坠落到窗口世界外的玩家就要将其剩余生命值清空。所以我们来到Player类中,为玩家提供set_hp的生命值设置方法。

void set_hp(int val)
{
    hp = val;
}

然后来到GameScene::on_update中,先分别获取玩家1和玩家2的位置,随后判断二者是否已经掉落到窗口下方的深渊中,在满足条件时将角色的生命值归零。
当任意一位玩家的生命值小于或等于0时,弹出窗口提示游戏结束,并强制退出程序

const Vector2 &position_player_1 = player_1->get_position();
const Vector2 &position_player_2 = player_2->get_position();
if (position_player_1.y >= getheight()) {
    player_1->set_hp(0);
}
if (position_player_2.y >= getheight()) {
    player_2->set_hp(0);
}
if (player_1->get_hp() <= 0 || player_2->get_hp() <= 0) {
    MessageBox(GetHWnd(), _T("游戏结束"), _T("测试"), MB_OK);
    exit(0);
}

之后,运行程序,操纵玩家跳出平台落到窗口外或操纵另一个玩家进行攻击直到生命值归零,游戏结束的逻辑都能正常执行。

接下来的人物是让游戏结束的效果更丰富一点。
我们之前忽略了一个问题,那就是我们一直在播放舒缓的菜单背景音乐,我们要为游戏局内场景添加更加激烈的背景音乐。
我们先在SelectorScene::on_exit()中,添加停止菜单背景音乐的代码。

mciSendString(_T("stop bgm_menu"), NULL, 0, NULL);

随后来到GameScene::on_enter,添加开始循环播放游戏局内背景音乐的代码

mciSendString(_T("play bgm_game repeat from 0"), NULL, 0, NULL);

2 界面动效

接下来,在GameScene中添加一个变量来标记游戏是否结束

bool is_game_over = false;  //游戏是否结束

在GameScene::on_update部分进一步完善游戏结束的逻辑,将弹出窗口信息修改为下面这样

if (player_1->get_hp() <= 0 || player_2->get_hp() <= 0) {
    if (!is_game_over) 
    {
        mciSendString(_T("stop bgm_game"), NULL, 0, NULL);
        mciSendString(_T("play ui_win from 0"), NULL, 0, NULL);
    }

    is_game_over = true;
}

接下来我们开始添加游戏结束画面的动效,首先引入相关的图片资源

extern IMAGE img_1P_winner;
extern IMAGE img_2P_winner;
extern IMAGE img_winner_bar;

并在私有成员部分添加下面这几个变量用来描述它们的位置
POINT pos_img_winner_bar = {0};         //结算动效背景位置
POINT pos_img_winner_text = {0};        //结算动效文本位置
int pos_x_img_winner_bar_dst = 0;       //结算动效背景移动的目标位置
int pos_x_img_winner_text_dst = 0;      //结算动效文本移动的目标位置

结算时的标题动画由两部分组成,分别是底层的背景图和上层的文本图片。当游戏结束时,移动背景较快的背景图先行一步滑入画面中。同时对应获胜玩家的文本图片也紧随其后进入到场景中。二者在屏幕中心的目标位置停留一小段时间后,再依次从屏幕的另一侧退出游戏画面。
所以,我们还需要两个定时器来控制化动动效的进度。以及一个bool变量用于标记动效是否开始滑动

Timer timer_winner_slide_in;            //结算动效滑入定时器
Timer timer_winner_slide_out;           //结算动效滑出定时器
bool is_slide_out_started = false;      //结算动效是否开始滑动

另外,记得定义两个表示速度的常量
private:
    const float speed_winner_bar = 3.0f;
    const float speed_winner_text = 1.5f;

我们来到GameScene::on_enter方法中,对这些变量进行设置,并计算得到了标题的两层图片居中的目标位置

pos_img_winner_bar.x = -img_winner_bar.getwidth();
pos_img_winner_bar.y = (getheight() - img_winner_bar.getheight()) / 2;
pos_x_img_winner_bar_dst = (getwidth() - img_winner_bar.getwidth()) / 2;

pos_img_winner_text.x = pos_img_winner_bar.x;
pos_img_winner_text.y = (getheight() - img_1P_winner.getheight()) / 2;
pos_x_img_winner_text_dst = (getwidth() - img_1P_winner.getwidth()) / 2;

之后对两个定时器进行初始化。滑入定时器在2.5秒后,将滑出的状态设置为true开始执行标题退出的逻辑。而滑出定时器在1s后,将场景切换为主菜单场景。

timer_winner_slide_in.restart();
timer_winner_slide_in.set_wait_time(2500);
timer_winner_slide_in.set_one_shot(true);
timer_winner_slide_in.set_callback([&]()
    {
        is_slide_out_started = true;
    });

timer_winner_slide_out.restart();
timer_winner_slide_out.set_wait_time(1000);
timer_winner_slide_out.set_one_shot(true);
timer_winner_slide_out.set_callback([&]()
    {
        scene_manager.switch_to(SceneManager::SceneType::Menu);
    });

在on_update的最后,我们可以在游戏结束时,累加结算标题两层图片的水平坐标位置。并且根据当前是否滑出的标志,执行不同的逻辑。

  • 当没有滑出时,说明正在执行滑入和停顿展示的逻辑。那么我们只需要累加控制滑入动画的定时器,并让图片位置不要越过目标位置即可。
  • 而当需要滑出时,便不断更新控制滑出的定时器
if(is_game_over)
{
    pos_img_winner_bar.x += (int)(speed_winner_bar * delta);
    pos_img_winner_text.x += (int)(speed_winner_text * delta);

    if(!is_slide_out_started)
    {
        timer_winner_slide_in.on_update(delta);
        if(pos_img_winner_bar.x = pos_x_img_winner_bar_dst) 
            pos_img_winner_bar.x = pos_x_img_winner_bar_dst;
        if (pos_img_winner_text.x > pos_x_img_winner_text_dst)
            pos_img_winner_text.x = pos_x_img_winner_text_dst;

    } else {
        timer_winner_slide_out.on_update(delta);
    }
}

之后来到on_draw方法中,我们可以修改状态栏的绘制代码,让它只有在游戏没有结束时才被渲染。

if (is_game_over) {
    putimage_alpha(pos_img_winner_bar.x, pos_img_winner_bar.y, &img_winner_bar);
    putimage_alpha(pos_img_winner_text.x, pos_img_winner_text.y,
        player_1->get_hp() > 0 ? &img_1P_winner : &img_2P_winner);
} else {
    status_bar_1P.on_draw();
    status_bar_2P.on_draw();
}

最后不要忘记在on_exit中禁用debug模式,清空子弹列表并重置摄像机位置

void on_exit() {
    delete player_1;
    player_1 = nullptr;
    delete player_2;
    player_2 = nullptr;

    is_debug = false;

    bullet_list.clear();
    main_camera.reset();
}

3 初始角色朝向问题

另外,我们还注意到一个现象,在游戏一开始两个玩家都是面朝向右的,我们想让它们在一开始面朝彼此。
所以在Player类中,给构造函数添加一个带有默认值的参数,并在一开始根据这个参数选择对应的动画

Player(bool facing_right = true) : is_facing_right(facing_right) {
current_animation = is_facing_right ? &animation_idle_right : &animation_idle_left;
//......
}

在两个角色子类中,我们也需要添加这个参数,并将它传递给父类的参数

PeashooterPlayer(bool facing_right = true) : Player(facing_right) {
//......
}

SunflowerPlayer(bool facing_right = true) : Player(facing_right) {
//......
}

那么,在SelectorScene中创建两个玩家角色时,就要将2P玩家的构造函数传入对应的参数了,让它朝向左侧

switch (player_type_2) 
{
case PlayerType::Peashooter:
    player_2 = new PeashooterPlayer(false);
    img_player_2_avatar = &img_avatar_peashooter;
    break;
case PlayerType::Sunflower:
    player_2 = new SunflowerPlayer(false);
    img_player_2_avatar = &img_avatar_sunflower;
    break;
}

4 游戏开始时的角色提示

我们想要在游戏一开始时,使用醒目的光标显示两个玩家的位置。
我们先来到Player头文件,引入光标图片资源

extern IMAGE img_1P_cursor;
extern IMAGE img_2P_cursor;

随后来到Player类的成员变量部分,添加下面的变量

bool is_cursor_visible = true;          // 玩家光标指示器是否显示
Timer timer_cursor_visibiblity;         // 玩家光标指示器可见性定时器

来到构造函数中对定时器进行设置,在2.5秒后将可见性设置为false
timer_cursor_visibiblity.set_wait_time(2500);
timer_cursor_visibiblity.set_one_shot(true);
timer_cursor_visibiblity.set_callback([&]()
    {
        is_cursor_visible = false;
    });

随后来到on_update方法中更新这个定时器
timer_cursor_visibiblity.on_update(delta);

在on_draw方法中,我们便可以在光标可见时,进行绘制
if (is_cursor_visible) 
{
    switch(id)
    {
    case PlayerID::P1:
        putimage_alpha(camera, (int)(position.x + (size.x - img_1P_cursor.getwidth()) / 2),
                       (int)(position.y - img_1P_cursor.getheight()), &img_1P_cursor);
        break;
    case PlayerID::P2:
        putimage_alpha(camera, (int)(position.x + (size.x - img_2P_cursor.getwidth()) / 2),
                       (int)(position.y - img_2P_cursor.getheight()), &img_2P_cursor);
        break;
    }
}

5 角色击飞动画

我们首先来到Player类成员部分,添加记录受击方向的变量

Vector2 last_hurt_direction;            // 最后以此受击方向

随后来到move_and_collide部分,在玩家受击生命值减少的时候,使用子弹的位置和玩家当前的位置作差,更新最后一次受击的方向。而当生命值减少到零之后,我们便可以根据最后一次受击的方向向量,设置其起飞的水平和竖直速度,达到斜上抛运动的效果。

last_hurt_direction = bullet->get_position() - position;
if (hp<=0)
{
    velocity.x = last_hurt_direction.x < 0 ? 0.35f : -0.35f;
    velocity.y = -1.0f;
}

我们不希望玩家在死亡击飞的过程中,与场景中的物体发生碰撞。所以我们把下面这个if语句添加在move_and_collide方法中的碰撞检测之前。如果角色死亡则直接返回,不进行碰撞检测。

if (hp <= 0) {
    return;
}

6 角色死亡动画

我们希望玩家在死亡击飞时,播放对应的死亡动画
我们在Player成员变量添加死亡动画

Animation animation_die_left;       // 朝向左的死亡动画
Animation animation_die_right;      // 朝向右的死亡动画

来到on_update方法内部,当生命值归零时,设置当前动画为死亡动画
if(hp <= 0) {
    current_animation = is_facing_right ? &animation_die_right : &animation_die_left;
}

最后,我们就要到两个角色子类中对动画进行初始化了,主要设置动画使用的图集和帧间隔

extern Atlas atlas_peashooter_die_left;
extern Atlas atlas_peashooter_die_right;

animation_die_left.set_atlas(&atlas_peashooter_die_left);
animation_die_right.set_atlas(&atlas_peashooter_die_right);
animation_die_left.set_interval(150);
animation_die_right.set_interval(150);
animation_die_left.set_loop(false);
animation_die_right.set_loop(false);

龙日葵角色类中也与之类似
extern Atlas atlas_sunflower_die_left;
extern Atlas atlas_sunflower_die_right;

animation_die_left.set_atlas(&atlas_sunflower_die_left);
animation_die_right.set_atlas(&atlas_sunflower_die_right);
animation_die_left.set_interval(150);
animation_die_right.set_interval(150);
animation_die_left.set_loop(false);
animation_die_right.set_loop(false);

运行程序可以看到,死亡动画能够正确播放

我发现当第一句某个玩家掉到平台之外,之后在开始第二局的时候,会直接显示另一个玩家获胜。
排查代码发现,原因是Player类的成员变量velocity没有初始化。
原本Vector2的构造函数使用由关键字default指定,导致它的x和y值是不确定的。
于是我把Vector2的构造函数改为

Vector2(): x(0.0f), y(0.0f) { }

之后,bug成功解决

评论

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