Writing an ECS Game Engine, Difficulty with Effects

Started by
8 comments, last by Kylotan 6 years, 8 months ago

I am trying to implement status effects in my game but I have not quite been able to get the hang of it. Do you have to make a large number of classes for different kinds of status effects? Or is it possible to do it with just 1, or a small number of classes?

Advertisement

I would have a small number of classes , the hierarchy somewhat like this:

Effect (contains duration and name , maybe if the target has to be friendly, etc)

   - StatEffect ( contains a value and an enum(i.e strength/movement/etc))

   - IntervalEffect (contains a effect value , damage type(enum or class) , frequency)

   - DamageEffect ( contains a damage amount , damage type(enum or class)

 and etc.

Why use a StatEffect? Why not use a bunch of different classes, each independent of one another?

Well it helps with a data oriented model. It depends on what your stat effects are. I'm thinking more about in the rpg sense. So for instance, you have a stat that increases your strength by 100 , or your movement speed by 50 , etc. With your method, you would have to create a different class for each of these, but if you do a single base class you can do things like : 

http://www.gdcvault.com/play/1024580/Modify-Everything-Data-Driven-Dynamic

A simple data driven example would be like this:


enum class EStatType : uint8
{
 MaxHealth,
 Stamina,
 Strength,
 MovementSpeed
};

class CStatEffect : public CEffect
{
public:

   void Apply(Character* C)
   {
     switch(m_StatType)
     {
       ...
     }
  }

void Remove(Character* C)
{ 
   switch(m_StatType)
   {
     ....
  }
}

void Serialize(IArchive& Ar)
{
  Ar.Serialize("StatType",m_StatType);
  Ar.Serialize("Value", m_Value);
} 
 
private:
   EStatType  m_StatType;
   int        m_Value;

};

and then you can have data definition files like : 



"Max Health Effect" := 
  "StatType" := "MaxHealth",
  "Value" := 500
}

using Json or XML or etc.

My game is vaguely ECS-ish in places. The way I handle this is to make each status effect its own kind of "component" that gets attached to characters. The "components" are just blobs of state that get processed homogeneously.  In some cases, a "status effect component" is as simple as a handle to a character in a vector. I just go through the status effect lists every frame updating the characters as necessary. As status effects are added to the characters, each character gets its own entry in those arrays.

There is no inheritance or polymorphism necessary and everything is batch-processed. I've found it helpful for some purposes to have a bitmask on the character indicating which status effects are active, but the actual status effect processing is done in the status effect systems, not by the characters.

This is an attempt to be "data-oriented" (as opposed to merely "data-driven", which is what AxeGuyWithAnAxe is describing). Data for the status effects either comes from weapon or character definitions or, if status effect data is going to be uniform across all characters, separate files with per-status effect data.

Oberon, that sounds really neat, are you for showing your source code to the outside world?


// header
struct StatusKnockback
{
	void clear();
	void finish_knockback(TypedHandle characterHandle);
	void record(
		TypedHandle sourceCharacterHandle,
		TypedHandle targetCharacterHandle,
		float duration);

	void apply_all_knockback(
		float dt,
		CharacterSystem& characters,
		SpriteObjectSystem& sprites,
		MovementSystem& movement);

private:
	struct Datum
	{
		TypedHandle sourceCharacterHandle;
		TypedHandle targetCharacterHandle;
		float timeRemaining;
	};

	std::vector<Datum> Records;
};

// cpp file
// the other methods are pretty self explanitory, so I'll just show the implementation of apply_all_knockback
void StatusKnockback::apply_all_knockback(
	float dt,
	CharacterSystem& characters,
	SpriteObjectSystem& sprites,
	MovementSystem& movement)
{
	VectorAlgorithm::consume_if(Records, [&](Datum& record)
	{
		// only apply knockback if the source character is still alive to push
		if (const CharacterState* sourceCharacter = 
			characters.try_and_get_datum(record.sourceCharacterHandle))
		{
			if (CharacterState* targetCharacter = 
				characters.try_and_get_mutable_datum(
					record.targetCharacterHandle))
			{ 
				sf::Vector2f move = sourceCharacter->compute_facing_vector();
				float scale = KNOCKBACK_SPEED_SCALAR * 2.0f * 
					record.timeRemaining / KNOCKBACK_DURATION_SCALAR;

				movement.cancel_movement_for(record.targetCharacterHandle);
				movement.queue_involuntary_move(
					record.targetCharacterHandle,
					move.x * scale,
					move.y * scale);
			
				targetCharacter->disable_all_actions();
			targetCharacter->set_status_flag(CharacterState::Status::Knockback);
				targetCharacter->override_definition_for_status(
					sprites, 
					StatusEffectDefinition::Name::Knockback);

				float nextKnockback = record.timeRemaining - dt;
				if (nextKnockback > 0.0f)
				{
					record.timeRemaining = nextKnockback;
					return false;
				}
			}
		}
		return true;
	});
}

This could of course be somewhat improved (eg. by not requiring that the source character still be alive, and only storing the direction the knockback is going in), but the gist of it is this:

StatusKnockback::apply_all_knockback is called once per frame when all the other status effects are updated. "Knockback" is the result of an attack where one character "pushes" another back a little ways.

VectorAlgorithm::consume_if takes an "updater" function and applies it to each element and then removes every element whose updater decided that it shouldn't be in the vector anymore. So we update each datum in the vector and remove the ones that have expired.

In case you've never seen it before: "datum" is the singular of "data."

wut

The code example works from the perspective of the Knockback effect rather than the character, so what happens is broadly:

  • each effect is stored inside StatusKnockback::Records
  • every update, the system goes through each of the stored effects and processes it
  • processing an effect involves looking up the 2 characters involved, applying all the changes to the target character, and tracking how much time is left
  • finally, the whole thing is wrapped in a tricky little structure that removes the effect if it returns true, so you don't have to loop over everything as a separate 'remove expired effects' step.

In terms of your original questions, this is basically a class per effect type, and the code example above is for the Knockback effect.

 

When I make status effect systems I like to brainstorm all the different types of effect and usually you can spot the commonalities that allow you to make just a small number of classes, or even just one. But how to do that depends entirely on the kinds of effects you need to implement.

 

This topic is closed to new replies.

Advertisement