Monsters Programming - "Core loop", senses and enemy acquisition Last edited 10 months ago2024-01-08 18:44:27 UTC

Half-Life Programming

If you remember what has been said in the first page, the RunAI() method which is kinda like a "core loop" had several responsibilities involving senses, enemy acquisition and other things.

In this page, we will take a deeper look into said method with those responsibilities as well as some other things not mentioned in the linked page.
A condition you need to be aware of for senses and enemy acquisition
In order for a monster to start looking, listening (and smelling) and doing enemy acquisition, the monster must have a different state (m_MonsterState) than "barnacled" (MONSTERSTATE_PRONE), dead (MONSTERSTATE_DEAD) and none (MONSTERSTATE_NONE).

The other stuff mentioned in this page does not require that condition to be true unless stated otherwise.

Idle sounds

If we look at the RunAI() method, the first thing we can see is that idle sounds (calling IdleSound()) are performed on a 1% chance (RANDOM_LONG( 0, 99 ) == 0) if the monster is not gagged (SF_MONSTER_GAG spawnflag set by level designers) and is idling (m_MonsterState == MONSTERSTATE_IDLE) or in an alert state (m_MonsterState == MONSTERSTATE_ALERT).

Look

After idle sounds, RunAI() calls the Look( m_flDistLook ) method. m_flDistLook is a member variable responsible to define how far the monster can look, it has a default value of 2048 units which is set in the MonsterInit() method. If you want to override this value, it's best to set it after the call to said method when the monster is spawning (unless the monster does not call MonsterInit() at all like the leech for example).

When the method is called, the monster clear any condition flag from the last frame involving enemy and sight (bits_COND_SEE_ENEMY, bits_COND_SEE_NEMESIS and so on). If the monster has the "prisoner" spawnflag (SF_MONSTER_PRISONER), then the "looking" part is considered to be done and we move on to listening (and smelling) right away.

The next step consist of retrieving all clients and monsters (entities with the FL_CLIENT or FL_MONSTER flag) in a box around the monster the size of m_flDistLook thanks to CBaseEntity **UTIL_EntitiesInBox( CBaseEntity **pList, int listMax, const Vector &mins, const Vector &maxs, int flagMask ). Likely for performance reasons, the limit of "collected" entities in the box is 100.

For each client/monster in said box, in order to be taken into account it must: If any of these checks above is false, then we check the next client/monster in the list.
Definition of "visible" in this context
An entity is visible if all the following conditions are met:
  • The target does not have the FL_NOTARGET flag (for players, this is the notarget cheat).
  • Target must not be underwater and looker on ground or vice-versa.
  • A line between the looker and target's eyes is not obstructed (except monsters and glass material).
The position of the eyes (Vector EyePosition()) is by default the origin of the entity (pev->origin). For monsters, CBaseMonster overrides this to "the entity origin and the view offset (pev->view_ofs) added together". The view offset must be familiar to you because you initialize its value in the Spawn() method of the monster.

Keep in mind that there are two best practices to keep with "eye position" for monsters: the first one is not a programming task, it consist of setting the right position in the QC file when compiling the model (MDL) using the $eyeposition <X> <Y> <Z> statement. The second one is a programming task and is to make sure that both values in the QC/MDL and code are identical to prevent issues.
Otherwise, there is a check if the sighted entity is the player (bool IsPlayer() returns true) and in the monster's view cone. This is to handle the "wait until seen" spawnflag (SF_MONSTER_WAIT_TILL_SEEN) that level designers can set on monsters. If the monster did not saw the player that frame, we move to the next client/monster in the list. Otherwise, the spawnflag bit is cleared and the bits_COND_SEE_CLIENT is set (mostly used by scripting entities like scripted_sequence).

A link between the monster and the sighted entity is stored in their m_pLink member variable (linked list approach), this will be used in enemy acquisition later.

If the "wait until seen" spawnflag was never set or was handled (the monster saw the player), then we check if the sighted entity is the current enemy (m_hEnemy). If the answer is yes, the bits_COND_SEE_ENEMY condition flag is set.

Finally, an additional condition flag is set based on the relationship with the sighted entity (except allies). For example: a scientist will set the bits_COND_SEE_FEAR condition flag when seeing human grunts.
Noteworthy mention of condition flags clearance between two senses
Between "looking" and "listening (and smelling)", do note that all conditions flags that the monster should ignore (int IgnoreConditions()) are cleared.
That method (and eventually bool FIsVisible( pEntity )) would be a nice spot for those who would like to implement "stealth" in their mod. Especially when Valve left a convenient int Illumination() method to query how much an entity is in the light or darkness.

Listen (and smell)

Looking at the Listen() method, we can see that listening (and by extension smelling) is done by resetting the condition flags about it (bits_COND_HEAR_SOUND, bits_COND_SMELL and bits_COND_SMELL_FOOD).

A local variable named iMySounds retrieve what the monster can hear and smell about (the result of calling int ISoundMask()). If the monster has a schedule (m_pSchedule != nullptr), then the sound mask(s) of said schedule are added.

You'll notice that conditions are cleared again and monsters have a "hearing sensitivity". Only the tentacle has a sensitivity of 2 while the others have a sensitivity of 1.

An iteration upon sounds and smells is done, for each sound that the monster care and is within it's hearing range (sound's range in units multiplied by hearing sensitivity), we check if it's a sound or smell and set the right conditions flags. Also, the type of sound (combat, danger...) is stored in m_afSoundTypes so other parts of the code can use it.

Something worth mentioning, Valve added the ability to override the position of a monster's ears (virtual Vector EarPosition()). By default, this is a copy/paste of the monster's eyes position code. None of the Half-Life monsters does override the position of ears.

This method is a good spot if you are programming a "stealth" oriented mod where enemies would need their AI to react to suspicious and dangerous sounds (and eventually smells). Suspicious as in "who's making that noise over there" and dangerous as in "I heard that, show yourself!"

Enemy acquisition

The first part is done in bool GetEnemy(). First, it check if any condition flag involving seeing a nemesis, hated or disliked monster is set.

If any of these condition flag is set, then the best visible enemy (CBaseEntity *BestVisibleEnemy()) is determined.
What is a "best visible enemy"?
Monsters will prioritize enemies selection based on two factors: relationship and distance. The list of enemies eligible for that check are those who were "linked" in the Look() method.

If the relationship is more "hostile" than the previous one, it gets picked up. For example: human grunts will prioritize Barneys and players over scientists.

If the relationship is on the same level, then the closest enemy has priority over the previous one.
If the monster is running a schedule, had an actual enemy and the best visible enemy is not the actual one (pNewEnemy != m_hEnemy && pNewEnemy != nullptr), then two things could happen: A reminder that monsters have an actual enemy (m_hEnemy) and a list of "old enemies" up to MAX_OLD_ENEMIES (4) that works like a "stack" (hence why the PushEnemy( CBaseEntity *pEnemy, Vector &vecLastKnownPos ) and bool PopEnemy() methods).

If the monster had no actual enemy but picked up an old one while performing a schedule with the "new enemy" interruptible mask, then the "new enemy" condition flag is set.

The method returns true or false depending if the monster has an actual enemy or not. In order for the second part bool CheckEnemy() to happen, the result of the first part must be true.

Assuming the latter, the method will set or clear the condition flag that the "enemy has been occluded" (bits_COND_ENEMY_OCCLUDED) based on it's visibility.

Another check is made to see if the enemy is alive or dead (bool IsAlive()). Assuming the enemy is still alive, information about the enemy's position and distance are gathered.

If the enemy is visible, a test is made to set/clear the bits_COND_ENEMY_FACING_ME condition flag.

Otherwise, if the enemy can't be seen and occluded but the distance is less than 256 units, then the monster will magically know where the enemy is. Mods that wish to implement "stealth gameplay" should probably remove or edit this behavior (blackjack/backstabbing anyone?)

Likewise, another check if the distance between the monster and it's enemy is made, if it's higher or equals than m_flDistTooFar, then the bits_COND_ENEMY_TOOFAR condition flag is set. Otherwise, it's cleared.
About that "distance too far" thingy
m_flDistTooFar works like m_flDistLook, the default value is set to the same location (MonsterInit()) and overriding it works the same.
If the monster is allowed to check attacks (a call of bool FCanCheckAttacks() which by default check the condition flags "can see the enemy" and "enemy not too far"), then it will check the attacks (call to CheckAttacks()). Checking attacks basically involves checking if the monster if capable of doing a kind of attack (primary/secondary melee/ranged), call the appropriate check method which is usually overriden by monsters and if the result is true, the appropriate condition flag is set. Example: if bool CheckRangeAttack1( float flDot, float flDist ) returns true then the bits_COND_CAN_RANGE_ATTACK1 condition flag is set (assuming m_afCapability & bits_CAP_RANGE_ATTACK1 is true).

Finally, if the monster is instructed to move towards the enemy (m_movementGoal == MOVEGOAL_ENEMY), has a route of type "goal" or "enemy" (bits_MF_IS_GOAL | bits_MF_TO_ENEMY) but the enemy's last known position's (m_vecEnemyLKP) distance is higher than 80 units to said route, then the entire route is refreshed (FRefreshRoute()). Don't worry if you don't understand this part right away, navigation will be covered in another page when we'll get there.

Other things

If the sense and enemy acquisition condition mentioned in the introduction is true, then a call to CheckAmmo() is made. Only the human grunts (and our Armored Man from the basic/squad monster tutorial) override it so they can check if m_cAmmoLoaded is equals or less than 0 and set the condition flag that they need to reload if that's the case. You should know what happens next by now.

From here, the mentioned sense and enemy acquisition condition no longer matter.

A call to FCheckAITrigger() is made, this method is very interesting for level designers because it is responsible for handling the "trigger condition" and "trigger target" properties they set on the monsters. If you don't know what "trigger conditions" are, those are basically "when the monster is dead", "when the monster has half of his health", "when the monster sees the player" and "trigger target" is the name of the entity to call/fire when said "trigger condition" is true.

Next, a call to PrescheduleThink() is made, this is an optional spot to do things before the entire schedules/tasks management happen.

After that, you should be familiar with what MaintainSchedule() does. If not, you should consider (re-)reading the page about schedules and tasks.

Comments

You must log in to post a comment. You can login or register a new account.