跳转至

05-武士零玩家角色类详细实现

1 Player类声明

首先我们实现Player类,新建player.h

1.1 成员变量

玩家可以通过按S键或下方向键来想着面朝的方向翻滚,期间处于无敌状态
我们利用两个计时器,并配合相应的bool变量来控制翻滚和攻击的cd。
之后利用一系列bool变量来标志各个操作按键是否按下。
然后定义了攻击的动画,攻击的方向,和当前动画指针。
最后是jump和land的动画,以及指示它的bool变量。

class Player : public Character
{
public:
    enum class AttackDir
    {
        Up, Down, Left, Right
    };

private:
    Timer timer_roll_cd;
    bool is_rolling = false;
    bool is_roll_cd_comp = true;

    Timer timer_attack_cd;
    bool is_attacking = false;
    bool is_attack_cd_comp = true;

    bool is_left_key_down = false;
    bool is_right_key_down = false;
    bool is_jump_key_down = false;
    bool is_roll_key_down = false;
    bool is_attack_key_down = false;

    Animation animation_slash_up;
    Animation animation_slash_down;
    Animation animation_slash_left;
    Animation animation_slash_right;
    AttackDir attack_dir = AttackDir::Right;
    Animation *current_slash_animation = nullptr;

    bool is_jump_vfx_visible = false;
    Animation animation_jump_vfx;

    bool is_land_vfx_visible = false;
    Animation animation_land_vfx;
};

对于玩家角色所需的常量,我们需要定义翻滚和攻击的CD,以及角色运动的速度。

private:
    const float CD_ROLL = 0.75f;
    const float CD_ATTACK = 0.5f;
    const float SPEED_RUN = 300.0f;
    const float SPEED_JUMP = 780.0f;
    const float SPEED_ROLL = 800.0f;

对于公共方法的设计,我们可以从两个思路着手。继承自基类需要重写的方法,以及玩家角色自身所特有的为了方便外部控制的扩展方法。

1.2 基本方法声明

我们在重写方法时,都添加了override关键字,这就确保了在编译时检查重写方法的一致性。避免因为函数签名不同而错过了调用重写的逻辑。

public:
    Player();
    ~Player();

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

    void on_hurt() override;

1.3 状态接口

而Player类所特有的方法,尤其是翻滚的一组,我们可以设置和获取当前的翻滚状态。并且提供了一个can_roll的接口,来检查当前是否可以跳转到翻滚状态。可以翻滚的条件是翻滚的cd结束、当前没有处于翻滚状态中并且翻滚的按键被按下。

void set_rolling(bool flag)
{
    is_rolling = flag;
}

bool get_rolling() const
{
    return is_rolling;
}

bool can_roll() const
{
    return is_roll_cd_comp && !is_rolling && is_roll_key_down;
}

攻击状态也是有相同的接口,设置和检查状态,以及对当前是否可以进入到攻击状态的检查。
我们封装了这些意义明确的方法,来方便后续在具体的状态机节点中直接进行调用。
虽然这些方法内容的逻辑并不复杂,但是如果我们直接将这些状态变量暴露给状态机节点,一方面破化了面向对象的封装性原则,另一方面也会导致状态机内的代码语义更加混乱。
同时多种条件组合的编写也增加了产生人为失误的机会。

void set_attacking(bool flag)
{
    is_attacking = flag;
}

bool get_attacking() const
{
    return is_attacking;
}

bool can_attack() const
{
    return is_attack_cd_comp && !is_attacking && is_attack_key_down;
}

对于跳跃这个动作,我们也封装了can_jump方法。只有当角色处于地面上且跳跃按键按下时才可以跳跃。
而对应状态的set-get方法就不是很有必要了,因为重力模拟的下落会结束跳跃坠落的动作。而is_on_floor方法已经在Character基类中定义过了。

bool can_jump() const
{
    return is_on_floor() && is_jump_key_down;
}

除去关于角色状态的这部分方法之外,我们还提供了两个简单的接口,获取角色移动方向和获取攻击方向枚举。
角色移动方向的接口返回一个整数,我们把右左方向键的状态相减,-1表示角色在向左移动,1表示角色在向右移动,0表示角色没有移动。

int get_move_axis() const
{
    return is_right_key_down - is_left_key_down;
}

AttackDir get_attack_dir() const 
{
    return attack_dir;
}


Pasted image 20260311105003.png

1.4 特殊方法声明

进入到对应行动状态后,执行具体的代码逻辑。我们也添加了这样几个方法定义。来执行具体的修改角色速度,特效动画播控等功能。

void on_jump();
void on_land();
void on_roll();
void on_attack();

在类声明部分的最后,我们追加了一个私有的工具方法。用来传入鼠标点击的坐标,计算转化为攻击的方向。这部分同样先只在这里编写声明,

private:
    void update_attack_dir(int x, int y);

2 Player类实现

2.1 初始化

之后在player.cpp源代码文件中,我们编写Player的构造方法
我们将hit_box的layer_dst设置为Enemy,将hurt_box的layer_src设置为Player。这与Qt中信号(signal)与槽(slot)的概念很像了。
我们也可以将其归纳于“观察者模式”下,碰撞箱发生碰撞就像是一个信号,在出发后激活对应的行为,也就是生命值减少的逻辑。
虽然我们目前阶段还没有抽象出更复杂的自定义消息机制,应用于整体的游戏框架。但这种设计模式,已经是可以被逐渐理解和应用的了。

Player::Player()
{
    is_facing_left = false;
    position = {250, 200};
    logic_height = 120;

    hit_box->set_size({150, 150});
    hurt_box->set_size({40, 40});

    hit_box->set_layer_src(CollisionLayer::None);
    hit_box->set_layer_dst(CollisionLayer::Enemy);

    hurt_box->set_layer_src(CollisionLayer::Player);
    hurt_box->set_layer_dst(CollisionLayer::None);

    hit_box->set_enable(false);
    hurt_box->set_on_collide([&]()
        { decrease_hp(); });
}

然后便是翻滚闪避冷却和攻击冷却计时器的初始化了
timer_roll_cd.set_wait_time(CD_ROLL);
timer_roll_cd.set_one_shot(true);
timer_roll_cd.set_callback([&]()
    { is_roll_cd_comp = true; });

timer_attack_cd.set_wait_time(CD_ATTACK);
timer_attack_cd.set_one_shot(true);
timer_attack_cd.set_callback([&]()
    { is_attack_cd_comp = true; });

接下来,便是动画对象的初始化了
首先需要创建名为attack的动画组对象。

AnimationGroup& animation_attack = animation_pool["attack"];

Animation &animation_attack_left = animation_attack.left;
animation_attack_left.set_interval(0.05f);
animation_attack_left.set_loop(false);
animation_attack_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_attack_left.add_frame(ResourcesManager::instance()->find_image("player_attack_left"), 5);

Animation &animation_attack_right = animation_attack.right;
animation_attack_right.set_interval(0.05f);
animation_attack_right.set_loop(false);
animation_attack_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_attack_right.add_frame(ResourcesManager::instance()->find_image("player_attack_right"), 5);

其他代码也是这样

AnimationGroup& animation_dead = animation_pool["dead"];

Animation& animation_dead_left = animation_dead.left;
animation_dead_left.set_interval(0.1f);
animation_dead_left.set_loop(false);
animation_dead_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_dead_left.add_frame(ResourcesManager::instance()->find_image("player_dead_left"), 6);

Animation& animation_dead_right = animation_dead.right;
animation_dead_right.set_interval(0.1f);
animation_dead_right.set_loop(false);
animation_dead_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_dead_right.add_frame(ResourcesManager::instance()->find_image("player_dead_right"), 6);


AnimationGroup& animation_fall = animation_pool["fall"];

Animation& animation_fall_left = animation_fall.left;
animation_fall_left.set_interval(0.15f);
animation_fall_left.set_loop(true);
animation_fall_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_fall_left.add_frame(ResourcesManager::instance()->find_image("player_fall_left"), 5);

Animation& animation_fall_right = animation_fall.right;
animation_fall_right.set_interval(0.15f);
animation_fall_right.set_loop(true);
animation_fall_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_fall_right.add_frame(ResourcesManager::instance()->find_image("player_fall_right"), 5);

AnimationGroup& animation_idle = animation_pool["idle"];

Animation& animation_idle_left = animation_idle.left;
animation_idle_left.set_interval(0.15f);
animation_idle_left.set_loop(true);
animation_idle_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_idle_left.add_frame(ResourcesManager::instance()->find_image("player_idle_left"), 5);

Animation& animation_idle_right = animation_idle.right;
animation_idle_right.set_interval(0.15f);
animation_idle_right.set_loop(true);
animation_idle_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_idle_right.add_frame(ResourcesManager::instance()->find_image("player_idle_right"), 5);


AnimationGroup& animation_jump = animation_pool["jump"];

Animation& animation_jump_left = animation_jump.left;
animation_jump_left.set_interval(0.15f);
animation_jump_left.set_loop(false);
animation_jump_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_jump_left.add_frame(ResourcesManager::instance()->find_image("player_jump_left"), 5);

Animation& animation_jump_right = animation_jump.right;
animation_jump_right.set_interval(0.15f);
animation_jump_right.set_loop(false);
animation_jump_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_jump_right.add_frame(ResourcesManager::instance()->find_image("player_jump_right"), 5);


AnimationGroup& animation_roll = animation_pool["roll"];

Animation& animation_roll_left = animation_roll.left;
animation_roll_left.set_interval(0.05f);
animation_roll_left.set_loop(false);
animation_roll_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_roll_left.add_frame(ResourcesManager::instance()->find_image("player_roll_left"), 7);

Animation& animation_roll_right = animation_roll.right;
animation_roll_right.set_interval(0.05f);
animation_roll_right.set_loop(false);
animation_roll_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_roll_right.add_frame(ResourcesManager::instance()->find_image("player_roll_right"), 7);


AnimationGroup& animation_run = animation_pool["run"];

Animation& animation_run_left = animation_run.left;
animation_run_left.set_interval(0.075f);
animation_run_left.set_loop(true);
animation_run_left.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_run_left.add_frame(ResourcesManager::instance()->find_image("player_run_left"), 10);

Animation& animation_run_right = animation_run.right;
animation_run_right.set_interval(0.075f);
animation_run_right.set_loop(true);
animation_run_right.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_run_right.add_frame(ResourcesManager::instance()->find_image("player_run_right"), 10);

紧接着便是武士零角色特有的特效动画对象初始化了

animation_slash_up.set_interval(0.07f);
animation_slash_up.set_loop(false);
animation_slash_up.set_anchor_mode(Animation::AnchorMode::Centered);
animation_slash_up.add_frame(ResourcesManager::instance()->find_image("player_vfx_attack_up"), 5);

animation_slash_down.set_interval(0.07f);
animation_slash_down.set_loop(false);
animation_slash_down.set_anchor_mode(Animation::AnchorMode::Centered);
animation_slash_down.add_frame(ResourcesManager::instance()->find_image("player_vfx_attack_down"), 5);

animation_slash_left.set_interval(0.07f);
animation_slash_left.set_loop(false);
animation_slash_left.set_anchor_mode(Animation::AnchorMode::Centered);
animation_slash_left.add_frame(ResourcesManager::instance()->find_image("player_vfx_attack_left"), 5);

animation_slash_right.set_interval(0.07f);
animation_slash_right.set_loop(false);
animation_slash_right.set_anchor_mode(Animation::AnchorMode::Centered);
animation_slash_right.add_frame(ResourcesManager::instance()->find_image("player_vfx_attack_right"), 5);

animation_jump_vfx.set_interval(0.05f);
animation_jump_vfx.set_loop(false);
animation_jump_vfx.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_jump_vfx.add_frame(ResourcesManager::instance()->find_image("player_vfx_jump"), 5);
animation_jump_vfx.set_on_finished([&]() { is_jump_vfx_visible = false; });

animation_land_vfx.set_interval(0.1f);
animation_land_vfx.set_loop(false);
animation_land_vfx.set_anchor_mode(Animation::AnchorMode::BottomCentered);
animation_land_vfx.add_frame(ResourcesManager::instance()->find_image("player_vfx_land"), 2);
animation_land_vfx.set_on_finished([&]() { is_land_vfx_visible = false; });

最后我们需要对状态机进行初始化, 这部分内容需要等待我们完成详细的节点逻辑后再进行编写

{
    // TODO: 状态机初始化
}

2.2 析构函数

对于析构函数,我们保持默认即可。对于碰撞箱对象,我们在基类中创建,自然也要在基类中销毁。

Player::~Player() = default;

2.3 on_input方法

在on_input方法中

void Player::on_input(const ExMessage& msg)
{
    if (hp<=0) return;

    switch(msg.message)
    {
    case WM_KEYDOWN:
        switch (msg.vkcode)
        {
        case 'A':
        case VK_LEFT:
            is_left_key_down = true;
            break;
        case 'D':
        case VK_RIGHT:
            is_right_key_down = true;
            break;
        case 'W':
        case VK_UP:
        case VK_SPACE:
            is_jump_key_down = true;
            break;
        case 'S':
        case VK_DOWN:
            is_roll_key_down = true;
            break;
        }
        break;
    case WM_KEYUP:
        switch (msg.vkcode)
        {
        case 'A':
        case VK_LEFT:
            is_left_key_down = false;
            break;
        case 'D':
        case VK_RIGHT:
            is_right_key_down = false;
            break;
        case 'W':
        case VK_UP:
        case VK_SPACE:
            is_jump_key_down = false;
            break;
        case 'S':
        case VK_DOWN:
            is_roll_key_down = false;
            break;
        }
        break;
    case WM_LBUTTONDOWN:
        is_attack_key_down = true;
        update_attack_dir(msg.x, msg.y);
        break;
    case WM_LBUTTONUP:
        is_attack_key_down = false;
        break;
    case WM_RBUTTONDOWN:
        // TODO: 进入子弹事件
        break;
    case WM_RBUTTONUP:
        // TODO: 退出子弹事件
        break;
    }
}

2.4 on_update方法

在Player的on_update帧更新方法中.

  • 我们首先对角色的水平方向移动进行了处理,水平方向速度其实就是标准化的移动方向乘以移动速度大小。
  • 那么当速度方向不为0时,我们便需要同时更新is_facing_left的朝向。
  • 然后是冷却定时器和特效动画的更新调用
  • 当角色处于攻击状态中时,我们便需要设置攻击特效动画的位置始终跟随玩家角色
  • 最后,调用基类的on_update方法
    void Player::on_update(float delta)
    {
        if (hp>0 && !is_rolling) {
            velocity.x = get_move_axis() * SPEED_RUN;
        }
    
        if (get_move_axis()!=0) {
            is_facing_left = (get_move_axis() < 0);
        }
    
        timer_roll_cd.on_update(delta);
        timer_attack_cd.on_update(delta);
    
        animation_jump_vfx.on_update(delta);
        animation_land_vfx.on_update(delta);
    
        if(is_attacking)
        {
            current_slash_animation->set_position(get_logic_center());
            current_slash_animation->on_update(delta);
        }
    
        Character::on_update(delta);
    }
    

2.5 on_render方法

在on_render渲染方法中,我们根据渲染顺序,依次执行起跳落地特效、玩家角色动画和攻击特效动画的渲染。

void Player::on_render()
{
    if(is_jump_vfx_visible) {
        animation_jump_vfx.on_render();
    }
    if(is_land_vfx_visible) {
        animation_land_vfx.on_render();
    }

    Character::on_render();

    if(is_attacking) {
        current_slash_animation->on_render();
    }
}

2.6 特殊方法

在on_hurt中我们只需要通过先前已经封装好的音频播放函数,播放对应的音效就可以了。

void Player::on_hurt()
{
    play_audio(_T("player _hurt"), false);
}

起跳和落地的逻辑十分简单。我们只需要设置特效的可见性和位置,并重置特效动画让它从头开始播放就好了。
唯一不同的时,在起跳时我们还需要给角色一个竖直像上的速度。

void Player::on_jump()
{
    velocity.y = -SPEED_JUMP;
    is_jump_vfx_visible = true;
    animation_jump_vfx.set_position(position);
    animation_jump_vfx.reset();
}

void Player::on_land()
{
    is_land_vfx_visible = true;
    animation_land_vfx.set_position(position);
    animation_land_vfx.reset();
}

在翻滚方法中,除去重置冷却事件定时器和翻滚动画状态的代码外,我们还需要根据角色当前的朝向,设置其水平方向的移动速度,

void Player::on_roll()
{
    timer_roll_cd.restart();
    is_roll_cd_comp = false;
    velocity.x = is_facing_left ? -SPEED_ROLL : SPEED_ROLL;
}

在on_attack攻击逻辑中,我们首先重置计时器,之后更具不同的攻击朝向枚举,选择不同的特效动画,最后不要忘记设置攻击动画的初始位置在角色的逻辑中心处。

void Player::on_attack()
{
    timer_attack_cd.restart();
    is_attack_cd_comp = false;
    switch (attack_dir)
    {
    case Player::AttackDir::Up:
        current_slash_animation = &animation_slash_up;
        break;
    case Player::AttackDir::Down:
        current_slash_animation = &animation_slash_down;
        break;
    case Player::AttackDir::Left:
        current_slash_animation = &animation_slash_left;
        break;
    case Player::AttackDir::Right:
        current_slash_animation = &animation_slash_right;
        break;
    }
    current_slash_animation->set_position(get_logic_center());
    current_slash_animation->reset();
}

最后便是计算角色攻击方向的方法了,
首先使用反三角函数计算得到鼠标点击位置和角色位置夹角的弧度

void Player::update_attack_dir(int x, int y)
{
    static const float PI = 3.1415926534f;
    float angle = std::atan2(y - position.y, x - position.x);

    if (angle >= -PI / 4 && angle < PI / 4) {
        attack_dir = AttackDir::Right;
    } else if (angle >= PI / 4 && angle < 3 * PI / 4) {
        attack_dir = AttackDir::Down;
    } else if ((angle >= 3 * PI / 4 && angle <= PI) || (angle >= -PI && angle < -3 * PI / 4)) {
        attack_dir = AttackDir::Left;
    } else {
        attack_dir = AttackDir::Up;
    }
}

评论

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