Part 41: Monsters part 3: Behaviors and attacking

Downloads

Source code
Executable for the map editor - exactly the same as in part 40 (Windows 32bits)
Executable for the game (Windows 32bits)

Before you try to compile the code go to the "Projects" tab on the left menu, select the "Run" settings for your kit,
and set the "Working directory" to the path of "editor\data" for the editor or "game\data" for the game.

Group behaviors

When you play the original game, if you encounter a group of monsters, you will see that it will have various
behaviors. It can either: In fact some monsters can also attack you with spells even if they are not right next to you, but we won't cover
that right now.

To implement this type of A.I. in a game we generally use a "finite state machine".
But don't be afraid, it's not as complex as is sounds.

In a finite state machine, a system can have several states. Here our monsters' group can be in 4 states - Wander,
Seek, Fight or Flee.
And under specific conditions, we can transition from one state to another.

In this part we will use only 2 states: Wandering and Fight.

Now for the transitions:

Here is an image that should be clearer:

Implementing the states

Previously we defined the monsters and monsters groups as structures inside the CMonsters class.
Now, as they will become more complex and have their own methods, it is simpler to define them as separate classes.

		//---------------------------------------------------------------------------------------------
		class CMonster
		{
		public:
			[...]

			EMonsterPos pos;
			EMonsterDir dir;
			int life;
			[...]
		};

		//---------------------------------------------------------------------------------------------
		class CMonsterGroup2
		{
		public:
			[...]

			CMonster        monsters[4];
			EMonsterDir     dir;
			[...]
		};
				
Note that I renamed SMonsterGroup to CMonsterGroup2 to avoid confusion with the CMonsterGroup class declared in
"map.h".

Now, as we said, each monster group is in a given state - either wander or fight.
So we will add a "state" variable to them:

		enum EMonsterState
		{
			eMonsterState_Wandering,
			eMonsterState_Fight
		};

		class CMonsterGroup2
		{
		public:
			[...]
			EMonsterState   state;
		};
				
To change the value of this state variable, we will need an update() function that will be called every frame.
This function will call either a wander() or a fight() function depending on the current state of the group.

		void CMonsterGroup2::update(CVec2 mapPos, uint8_t type)
		{
			[...]

			// handle group behavior for each state
			switch (state)
			{
				case eMonsterState_Wandering:
					wander(mapPos, type);
					break;

				case eMonsterState_Fight:
					fight(mapPos, type);
					break;
			}
		}
				
At the group level these functions don't do very much except handling the state transitions according to the
position of the player.

		//---------------------------------------------------------------------------------------------
		void CMonsterGroup2::wander(CVec2 mapPos, uint8_t type)
		{
			// transition to fight state ?
			CVec2   dist = player.pos - mapPos;
			bool    isNearPlayer = (dist.x == 0 && ABS(dist.y) == 1) || (dist.y == 0 && ABS(dist.x) == 1);

			if (isNearPlayer == true)
			{
				state = eMonsterState_Fight;
				[...]
			}
		}

		//---------------------------------------------------------------------------------------------
		void CMonsterGroup2::fight(CVec2 mapPos, uint8_t type)
		{
			// transition to wandering state ?
			CVec2   dist = player.pos - mapPos;
			bool    isNearPlayer = (dist.x == 0 && ABS(dist.y) == 1) || (dist.y == 0 && ABS(dist.x) == 1);

			if (isNearPlayer == false)
			{
				state = eMonsterState_Wandering;
			}
		}
				
In the update function of our group, we will also call an update() function for each monster inside this group.

		void CMonsterGroup2::update(CVec2 mapPos, uint8_t type)
		{
			// update all the monsters of the group
			for (int i = 0; i < 4; ++i)
				if (monsters[i].pos != eMonsterPosNone)
					monsters[i].update(*this, mapPos, type);

			[...]
		}
				

Monsters behaviors

Each monster will act differently depending on the state of the group.
So the update function of each monster will call either a wander() or a fight() function too.

		void CMonster::update(CMonsterGroup2& group, CVec2 mapPos, uint8_t type)
		{
			displayAttackTimer.update();

			switch (group.state)
			{
				case eMonsterState_Wandering:
					wander();
					break;

				case eMonsterState_Fight:
					fight(mapPos, type);
					break;
			}
		}
				
We said that we won't do anything in the wander state so the wander() function will be empty.
But in the fight state we want to launch the monster's attack animation randomly.
Now let's have a look at the values we will need.

Attack parameters

We will add 3 parameters to each monster in "monsters.xml":

		<monsters>
			[...]

			<!-- 01 Giant Scorpion -->
			<monster name = "MONSTER01">
				[...]
				<time_between_atk>200</time_between_atk>
				<atk_display_time>40</atk_display_time>
				<atk_sound>Attack_Scorpion.wav</atk_sound>
				[...]
			</monster>
				

To handle these times we will add timers to our monster:

		class CMonster
		{
		public:
			[...]
			CTimer  displayAttackTimer;
			CTimer  nextAttackTimer;
		};
				
In the original game, the time between 2 attacks was computed with a formula looking like this:

		void CMonster::initNextAttack(uint8_t type)
		{
			int minTime = monsters.monstersDatas[type].timeBetweenAttack;
			int nextTime = minTime + (rand() % 40) - 10;

			if (minTime > 150)
				nextTime += (rand() % 80) - 20;

			nextAttackTimer.set(nextTime, true);
		}
				
This function sets up one of our timers so we will call it at the moment we enter the "fight" state in the group.
And at the same time, we will turn the monster towards the player:

		void CMonsterGroup2::wander(CVec2 mapPos, uint8_t type)
		{
			// transition to fight state ?
			[...]

			if (isNearPlayer == true)
			{
				state = eMonsterState_Fight;

				// direction towards the player
				EMonsterDir dir;

				if (dist.x > 0)
					dir = eMonsterDirRight;
				else if (dist.x < 0)
					dir = eMonsterDirLeft;
				else if (dist.y > 0)
					dir = eMonsterDirDown;
				else
					dir = eMonsterDirUp;

				// initialize each monster of the group
				for (int i = 0; i < 4; ++i)
					if (monsters[i].pos != eMonsterPosNone)
					{
						monsters[i].dir = dir;
						monsters[i].initNextAttack(type);
					}
			}
		}
				
Now, in the fight function of the monster, we update this timer.
When it finishes, we play the attack sound, start the display timer and relaunch the "between attacks" timer.

		void CMonster::fight(CVec2 mapPos, uint8_t type)
		{
			if (nextAttackTimer.update() == true)
			{
				QString soundName = monsters.monstersDatas[type].attackSound;

				if (soundName.isEmpty() == false)
					sound->play(mapPos, soundName.toLocal8Bit().constData());

				int attackTime = monsters.monstersDatas[type].attackDisplayTime;
				displayAttackTimer.set(attackTime, true);
				initNextAttack(type);
			}
		}
				

Displaying the attack


We saw the attack sprites in the previous part.
Note that they only replace a "facing" image - we don't have attack sprites for the sides or for the back.
So, when we get the sprite for the monster in CMonsters::draw() we only have to test if the display timer is running
in the case of a facing monster:

		// sprite
		[...]

		if (tableDir == eMonsterDirDown)
		{
			// facing
			if (monster.displayAttackTimer.isRunning())
			{
				// attack
				spriteName = monstersDatas[type].attackImage;
				if (spriteName.isEmpty() == true)
					spriteName = monstersDatas[type].frontImage;
			}
			else
			{
				// normal
				spriteName = monstersDatas[type].frontImage;
			}
		}
		[...]