Welcome back! I'm really excited to show you what I have been working on this time around: actual gameplay mechanics! But first I'll run you through a what we talked about in the last installement of this blog series.

Last time, we explored a couple of implementations for collision systems, with and without acceleration structure. While it spatial structure are a net positive in terms of performance, I found it was a bit more complex to synchronise both the physics and Flecs context. Instead I settled on a storing collisions in a singleton component which contained collision records. This was the better performing "pure" ECS and non-accelerated implementation. If you are interested in reading more, I highly suggest you look at that blog post as I go into design implications of each method explored (see here). This was the ground work of what I will show in this post.

For this blog I had some pretty ambitious plans to create a few attacks: Melee, ranged and magic. However we'll have to settle for projectile attacks only. That's not to minimise the work done though, since the attacks are highly customisable.

In Vampire survivors I believe the play can have up to six weapons at a time performing an attack. I want attacks setup in a way that there is one "spawner" and one "spawnee". The spawner will hold the reference to the prefab it want's to instantiate, and then tell that spawnee what to do. To achieve this we will need to create an Attack component that will hold the prefab's name, as well as the target's tag; we wouldn't want a player to attack itself, only the wanted target. And then we should specify some kind of fire rate, we'll express that as a Cooldown so it's applicable to other situations later on. Finally since we are working with projectiles, we will add a Projectile tag.

For the spawnee, we will need to give it a damage value, a velocity, some visuals, and a collider. We also have to make sure that the projectiles don't live forever off the screen otherwise they could cause performance issues, we can create a new component that specifies the maximum amount of time an entity can live: DestroyAfterTime.

m_world.entity("dagger attack").child_of(player)
    .add<gameplay::Projectile>()
    .set<core::Attack>({"dagger projectile", "enemy"})
    .set<gameplay::Cooldown>({1.0f, 1})
    .add<gameplay::CooldownCompleted>()
    .set<core::Speed>({150.f});

m_world.prefab("dagger projectile")
    .add<gameplay::Projectile>()
    .set<core::Damage>({2})
    .set<physics::Velocity2D>({0, 0})
    .set<core::DestroyAfterTime>({5})
    .set<rendering::Renderable>({
        LoadTexture("../resources/dagger.png"), // 8x8
        {0, 0}, // offset
        3.f, // scale
        WHITE // tint
    })
    .set<physics::Collider>({
        24, // radius
        false, // correct_position?
        physics::CollisionFilter::player, // collision layer
        physics::player_filter // collision filter
    });

Now that our entities are built, we need to create the systems that go with them. I won't bother explaining the destroy after time systems since it's pretty simple. We'll implement three new systems in the gameplay module, one to update the cooldown, one observer that resets the cooldown, and one to fire the projectiles.

// gameplay/component.h

struct Cooldown {
    float value;
    float elapsed_time;
};

struct CooldownCompleted {};

struct Projectile {};

// gameplay/gameplay_module.h

world.system<Cooldown>("Update Cooldown")
	.without<CooldownCompleted>()
	.each([](flecs::iter &it, size_t i, Cooldown &cooldown) {
		cooldown.elapsed_time += it.delta_time();
		if (cooldown.elapsed_time > cooldown.value)
			it.entity(i).add<CooldownCompleted>();
});

world.observer("Restart Cooldown")
	.with<CooldownCompleted>()
	.event(flecs::OnRemove)
	.each([](flecs::entity e) {
		if (e.has<Cooldown>())
			e.set<Cooldown>({e.get<Cooldown>()->value, 0.0f});
});
world.system<core::Position2D, core::Attack, core::Speed>("Fire Projectile")
    .with<Projectile>()
    .with<CooldownCompleted>()
    .write<CooldownCompleted>()
    .term_at(0).parent()
    .each([world, target_type_query](flecs::entity e, core::Position2D &pos, core::Attack &attack,
                                 core::Speed &speed) {
        float shortest_distance_sqr = 1000000;
        core::Position2D target_pos = pos;
        target_type_query.each([&](flecs::entity o, core::Tag &t, core::Position2D &o_pos) {
            if (attack.target_tag != t.name) return;
            float d = Vector2DistanceSqr(pos.value, o_pos.value);
            if (d > shortest_distance_sqr) return;
            shortest_distance_sqr = d;
            target_pos = o_pos;
        });

        if (target_pos.value == pos.value) return;

        float rot = Vector2Angle(Vector2{0, 1}, pos.value - target_pos.value) * RAD2DEG;

        auto prefab = world.lookup(attack.attack_prefab_name.c_str());


        world.entity().is_a(prefab).child_of(e)
            .set<core::Position2D>({pos.value})
            .set<rendering::Rotation>({ rot })
            .set<core::Speed>({speed.value})
            .set<physics::Velocity2D>({
                Vector2Normalize(target_pos.value - pos.value) * speed.value 
            });

        e.remove<CooldownCompleted>();
});
world.system("no pierce or chain")
    .with<Projectile>()
    .without<Pierce>().without<Chain>()
    .with<physics::CollidedWith>(flecs::Wildcard)
    .kind(flecs::PostUpdate)
    .immediate()
    .each([](flecs::iter &it, size_t i) {
	it.entity(i).add<core::DestroyAfterFrame>();
});

Our firing system will wait until it has a CooldownCompleted component, then performs the logic, and removes it after. And just like that we can shoot projectiles.

That cool and all, but what's better than one projectile? Many projectiles! Well create a new component, and update the fire projectile system to include an optional component.

struct MultiProj {
    int projectile_count;
    float spread_angle;
    float max_spread;
    float min_spread;
};

// gameplay/gameplay_module.cpp

world.system<core::Position2D, core::Attack, core::Speed, MultiProj *>("Fire Projectile")
...

    float rot = Vector2Angle(Vector2{0, 1}, pos.value - target_pos.value) * RAD2DEG;

    int proj_count = multi_proj ? multi_proj->projectile_count : 1;
    float spread_angle = multi_proj ? multi_proj->spread_angle : 0.0f;

    float offset = proj_count % 2 == 0 ? spread_angle / proj_count / 2 : 0;


    auto prefab = world.lookup(attack.attack_prefab_name.c_str());

    for (int i = -proj_count / 2; i < (proj_count + 1) / 2; i++) {
        world.entity().is_a(prefab).child_of(e)
        .set<core::Position2D>({pos.value + Vector2{0, 0} * i})
        .set<rendering::Rotation>({
        	rot + ((i * (spread_angle / proj_count) + offset))
        })
        .set<core::Speed>({speed.value})
        .set<physics::Velocity2D>({
        	Vector2Rotate(Vector2Normalize(target_pos.value - pos.value) * speed.value,
            (i * (spread_angle / proj_count) + offset) * DEG2RAD)
            });
	}

MORE !!!

Ahh, better! But I'd like to have more mechanics. I played a lot of ARPGs in the likes of Diablo II and Path of Exile which feature projectile modifiers such as piercing, chaining, and splitting. To make sure that the projectiles don't interact twice with the same collision, well keep track of the entity ids.

struct Pierce {
	int pierce_count;
	std::unordered_set<int> hits;
};

struct Chain {
	int chain_count;
	std::unordered_set<int> hits;
};

struct Split {
	std::unordered_set<int> hits;
};

First for Pierce

world.system<Pierce>("apply pierce mod")
    .with<physics::CollidedWith>(flecs::Wildcard)
    .kind(flecs::PostUpdate)
    .immediate()
    .each([](flecs::iter &it, size_t i, Pierce &pierce) {
        flecs::entity other = it.pair(1).second();
        if (pierce.hits.contains(other.id())) {
            it.entity(i).remove<physics::CollidedWith>(other);
            return;
	}
        pierce.hits.insert(other.id());
        pierce.pierce_count -= 1;
        if (pierce.pierce_count < 0) {
        	it.entity(i).add<core::DestroyAfterFrame>();
	}
});

Chain

world.system<Chain, physics::Velocity2D, core::Position2D, rendering::Rotation, core::Attack>("apply chain mod")
                .with<physics::CollidedWith>(flecs::Wildcard)
                .kind(flecs::PostUpdate)
                .immediate()
                .each([target_type_query](flecs::iter &it, size_t i, Chain &chain, physics::Velocity2D &vel,
                                          core::Position2D &pos, rendering::Rotation &rot, core::Attack &attack) {
                    flecs::entity other = it.pair(5).second();

                    if (chain.hits.contains(other.id())) {
                        it.entity(i).remove<physics::CollidedWith>(other);
                        return;
                    }
                    chain.hits.insert(other.id());
                    chain.chain_count -= 1;

                    float shortest_distance_sqr = 1000000;
                    core::Position2D target_pos = pos;
                    target_type_query.each([&](flecs::entity o, core::Tag &t, core::Position2D &o_pos) {
                        if (!chain.hits.contains(o.id()) && attack.target_tag == t.name && other.id() != o.id()) {
                            float d = Vector2DistanceSqr(pos.value, o_pos.value);
                            if (d < shortest_distance_sqr) {
                                shortest_distance_sqr = d;
                                target_pos = o_pos;
                            }
                        }
                    });

                    if (target_pos.value == pos.value) return;

                    float rad = Vector2Angle(Vector2{0, 1}, pos.value - target_pos.value);
                    rot.angle = rad * RAD2DEG;
                    vel.value = Vector2Normalize(target_pos.value - pos.value) * Vector2Length(vel.value);

                    if (chain.chain_count < 0) {
                        it.entity(i).add<core::DestroyAfterFrame>();
                    }
                });

Split

world.system<Split, physics::Velocity2D, core::Position2D, rendering::Rotation, core::Attack>("apply split mod")
                .with<physics::CollidedWith>(flecs::Wildcard)
                .kind(flecs::PostUpdate)
                .immediate()
                .each([world](flecs::iter &it, size_t i, Split &split, physics::Velocity2D &vel, core::Position2D &pos,
                              rendering::Rotation &rot, core::Attack &attack) {
                    flecs::entity other = it.pair(5).second();

                    if (split.hits.contains(other.id())) {
                        it.entity(i).remove<physics::CollidedWith>(other);
                        return;
                    }
                    split.hits.insert(other.id());

                    Vector2 left = Vector2Rotate(vel.value, -90 * DEG2RAD);
                    Vector2 right = Vector2Rotate(vel.value, 90 * DEG2RAD);

                    auto prefab = world.lookup(attack.attack_prefab_name.c_str());
                    world.entity().is_a(prefab).child_of(it.entity(i).parent())
                            .set<core::Position2D>(pos)
                            .set<rendering::Rotation>({
                                rot.angle - 90.0f
                            })
                            .set<physics::Velocity2D>({
                                {left}
                            }).remove<Split>().remove<Chain>().remove<Pierce>();

                    world.entity().is_a(prefab).child_of(it.entity(i).parent())
                            .set<core::Position2D>(pos)
                            .set<rendering::Rotation>({
                                rot.angle + 90.0f
                            })
                            .set<physics::Velocity2D>({
                                {right}
                            }).remove<Split>().remove<Chain>().remove<Pierce>();
                });

I made the decision to remove all modifiers when a projectile is split because things would get a little too crazy, and the split modifier would be a lot stronger than the others.

What's great about ECS, and composition in general, is that all of those systems will run independently of each other. Concretely what this means is that I can have a piercing and splitting projectile, or a chaining and splitting projectile, but I don't have to hard code the logic for every type of combination of components!

Now lets push this a bit further (Sorry for the lag, it's due to the screen recording).

You probably noticed the menubar at the top. I added this little tool to speed up my iterations and debug the game as I go. When we work on the upgrade system later in this game's development I will explain how the menubar system works since it will be closely related.

So we managed to create some really nice combination of effectors on our projectiles which should make for some nice gameplay later down the line. What's also nice is that we designed the effectors to work on any kind of projectile that currently exist or that we add in the future (I hope :)).

That is it for this time. There are a few avenues we could take from here, notably some melee attacks, like the whip in vampire survivors, or a proper environment, and I can also tell that we will need some refactoring in the code base, the system declaration is taking a lot of space. Thanks for your time. As per usual, the source code is available on Github and you get your hands wet with the latest build on Itch.io