跳转至

15-粒子系统和角色特效动画实现

1 粒子系统

粒子系统由粒子对象和粒子发射器组成。粒子对象通常由动画、物理和声明周期等众多属性来描述,而粒子发射器则决定着粒子对象的生成方式,如粒子的发射频率、发射方向和初始速度等。

首先定义particle.h头文件

class Particle
{
public:
    Particle() = default;
    ~Particle() = default;
private:
    int timer = 0;          // 粒子动画播放定时器
    int lifespan = 0;       // 单帧粒子动画持续时长
    int idx_frame = 0;      // 当前正在播放的动画帧
    Vector2 position;       // 粒子的世界坐标位置
    bool valid = true;      // 粒子对象是否有效
    Atlas *atlas = nullptr; // 粒子动画所使用的图集
};

乍一看,这些定义与动画类十分相像。确实在我们现阶段的视线中,粒子对象可以看作是一种特化的动画对象,它与咱们先前角色和子弹的动画区别在于粒子在发射后,世界坐标的位置便固定了下来,在播放完自身的动画后,粒子的寿命便终止了,就可以从场景中被移除掉了。

然后我们添加一个带参的构造函数,来方便直接初始化对应的值。对应地,我们也提供了设置这些字段地set方法,来动态地修改其成员属性。

Particle(const Vector2& position, Atlas* atlas, int lifespan)
        : position(position), atlas(atlas), lifespan(lifespan) { }

void set_atlas(Atlas* new_atlas)
{
    atlas = new_atlas;
}

void set_position(const Vector2& new_position)
{
    position = new_position;
}

void set_lifespan(int ms)
{
    lifespan = ms;
}

对于valid字段,我们仅需要获取它的值,所以仅提供一个check_valid方法来检测当前粒子是否仍然存活

bool check_valid() const
{
    return valid;
}

随后是on_update方法,它的实现与animation类似,只不过当播放完所有帧之后,直接将valid设置为false即可

void on_update(int delta)
{
    timer += delta;

    if(timer >= lifespan)
    {
        timer = 0;
        idx_frame++;
        if(idx_frame >= atlas->get_size())
        {
            idx_frame = atlas->get_size() - 1;
            valid = false;
        }
    }
}

接下来我们编写on_draw方法来绘制粒子对象

void on_draw(const Camera& camera) const
{
    putimage_alpha(camera, (int)position.x, (int)position.y, atlas->get_image(idx_frame));
}

之后我们把已经完成的粒子类集成到我们的游戏系统了
我们来到player头文件中,引入particle头文件,随后在成员变量部分添加玩家跑动特效和死亡特效的粒子发射定时器。
在当前项目中,我们的粒子系统比较单一,只需要模拟角色尾迹的生成,所以对于粒子发射器,我们便可以使用先前已经完成的定时器来进行替代。

std::vector<Particle> particle_list;

Timer timer_run_effect_generation;      // 跑动特效粒子发射定时器
Timer timer_die_effect_generation;      //  死亡特效粒子发射定时器

随后到Player的构造函数部分,对跑动特效的定时器进行初始化.每次计时到了之后,就在玩家的脚底生成跑动特效的粒子。

timer_run_effect_generation.set_wait_time(75);
timer_run_effect_generation.set_callback([&]()
    {
      Vector2 particle_position;
      IMAGE *frame = atlas_run_effect.get_image(0);
      particle_position.x = position.x + (size.x - frame->getwidth()) / 2;
      particle_position.y = position.y + size.y - frame->getheight();
      particle_list.emplace_back(particle_position, &atlas_run_effect, 45);
    });

死亡特效的定时器与之相似,只是产生间隔和粒子的存活时间不同。
timer_die_effect_generation.set_wait_time(35);
timer_die_effect_generation.set_callback([&]()
    {
        Vector2 particle_position;
        IMAGE* frame = atlas_run_effect.get_image(0);
        particle_position.x = position.x + (size.x - frame->getwidth()) / 2;
        particle_position.y = position.y + size.y - frame->getheight();
        particle_list.emplace_back(particle_position, &atlas_run_effect, 150);
    });

我们来到Player::on_update()中,发现玩家的跑动逻辑是被封装到on_run()方法中的。
所以来到on_run内部,添加让跑动特效定时器重新恢复运行的代码。

virtual void on_run(float distance)
{
    if(is_attacking_ex) {
        return;
    }
    position.x += distance;
    timer_run_effect_generation.resume();
}

回到on_update方法中,当角色不再移动时,添加定时器的pause方法调用。来停止跑动尾迹粒子的生成。同时,也不要忘记添加该计时器的on_update方法调用。死亡状态下的定时器只需要在玩家生命值归零的时候进行更新就可以了
timer_run_effect_generation.pause();
...
timer_run_effect_generation.on_update(delta);
if(hp<=0)
{
    timer_die_effect_generation.on_update(delta);
}

在完成这些之后,我们还需要更新粒子列表中的对象。随后遍历粒子列表,依次调用它们的on_update方法。
particle_list.erase(std::remove_if(
    particle_list.begin(), particle_list.end(),
    [](const Particle &particle)
    {
        return !particle.check_valid();
    }),
    particle_list.end());
for(Particle& particle: particle_list)
{
    particle.on_update(delta);
}

下面我们来到on_draw方法内部。我们希望粒子绘制在玩家底层,所以在代码头部添加

for (const Particle& particle : particle_list) 
{
    particle.on_draw(camera);
}

运行程序就能看到角色奔跑时的尾迹特效了。

接下来我们实现角色跳跃和落地的特效。
我们来到Player类的动画定义部分,添加玩家跳跃和落地的特效动画,并定义了两个bool变量,分别用来描述这两个动画特效的可见性。随后还有定义两个位置变量,来描述特效的位置。
我们希望这些动画在产生后保留在原地,而不是跟随玩家移动,这与粒子特效有着共通之处。虽然我们也可以试着用粒子系统将这些特效进行大一统,但是目前阶段我们并没有在粒子生成器上大做文章,所以还是使用已有的动画来实现更为简明一些。

Animation animation_jump_effect;    // 跳跃特效动画
Animation animation_land_effect;    // 落地特效动画

bool is_jump_effect_visible = false;    // 跳跃动画是否可见
bool is_land_effect_visible = false;    // 落地动画是否可见

Vector2 position_jump_effect;       // 跳跃动画播放位置
Vector2 position_land_effect;       // 落地动画播放位置

完成这些内容后,我们便可以来到构造函数部分,添加跳跃动画和落地动画的初始化代码。

animation_jump_effect.set_atlas(&atlas_jump_effect);
animation_jump_effect.set_interval(25);
animation_jump_effect.set_loop(false);
animation_jump_effect.set_callback([&]()
    {
        is_jump_effect_visible = false;
    });

animation_land_effect.set_atlas(&atlas_land_effect);
animation_land_effect.set_interval(50);
animation_land_effect.set_loop(false);
animation_land_effect.set_callback([&]()
    {
        is_land_effect_visible = false;
    });

下一步便是编写触发的代码了,我们首先来到on_jump跳跃方法,添加部分代码
virtual void on_jump()
{
    if (velocity.y != 0) {
        return;
    }
    velocity.y += jump_velocity;
    is_jump_effect_visible = true;
    animation_jump_effect.reset();

    IMAGE *effect_frame = animation_jump_effect.get_frame();
    position_jump_effect.x = position.x + (size.x - effect_frame->getwidth()) / 2;
    position_jump_effect.y = position.y + size.y - effect_frame->getheight();
}

然后来到on_update方法中,就可以添加跳跃动画的更新代码了

animation_jump_effect.on_update(delta);

之后在on_draw方法中,根据bool变量决定是否绘制跳跃动画

if (is_jump_effect_visible) 
{
    animation_jump_effect.on_draw(camera, (int)position_jump_effect.x, (int)position_jump_effect.y);
}

现在,就剩下落地动画了。我们仿照on_jump方法设计一个on_land方法,来封装落地时的逻辑。
同样地,我们在其中先设置特效可见性为true,重置落地动画,并设置动画的位置。这部分代码与on_jump几乎相同

virtual void on_land()
{
    is_land_effect_visible = true;
    animation_land_effect.reset();

    IMAGE *effect_frame = animation_jump_effect.get_frame();
    position_land_effect.x = position.x + (size.x - effect_frame->getwidth()) / 2;
    position_land_effect.y = position.y + size.y - effect_frame->getheight();
}

到on_update方法内调用落地动画的更新方法

animation_land_effect.on_update(delta);

然后来到on_draw方法内,添加在落地动画可见时的渲染方法调用。
if (is_land_effect_visible)
{
    animation_land_effect.on_draw(camera, (int)position_land_effect.x, (int)position_land_effect.y);
}

不过下面的人物就有点不一样了。on_land()落地方法的触发逻辑应该如何处理呢?
在先前的物理模拟中,我们已经把所有的物理相关的逻辑都封装在了move_and_collide方法中。所以想要判断落地时刻,也需要从这里入手。

当前一帧角色的竖直速度不为零,当前帧的竖直速度为零时,我们便可以判断在这一时刻角色落到了平台之上。
所以我们在重力改变速度之前先记录之前的速度

float last_velocity_y = velocity.y;
//......重力改变速度
//......

// 如果与平台发生碰撞
if (last_velocity_y!=0) {
    on_land();
}

评论

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