Monsters Programming - Talkative Monsters (part 2) Last edited 2 months ago2024-08-07 09:00:36 UTC

Half-Life Programming

Now that we have the basics setup for our Friendly Assassin, it's time to dive into the subject you've been waiting for.

Converting to a talkative monster and minor details

The first step of "upgrading" our Friendly Assassin to a talkative monster is to change her base class from CBaseMonster to CTalkMonster, so do this and don't forget to include the talkmonster.h header file.

Let's take care of tiny details first before moving on to the big things. You need to update the const char* CTalkMonster::m_szFriends[TLK_CFRIENDS] array at the beginning of talkmonster.cpp (don't forget to update the array's size as well) to add the classname of our Friendly Assassin. This is required by CTalkMonster in order to have the other friendly monsters (Barney, scientists...) be aware of our Friendly Assassin for certain behaviors (talk to each other, alert each other in case of danger, warn the player in case of friendly fire).

Scientists in Half-Life have some kind of "priority logic" where they will always prefer talking to other standing scientists, then sitting scientists and Barneys, in that order. In scientist.cpp and more specifically the CScientist::TalkInit() method, you need to add our Friendly Assassin alongside the other friendly monsters.

Speech

Now that the tiny details are covered, we'll take care of speech first. Do note that speech is mostly handled by the "sentence system". If you have no idea what this is, you should read the Wiki article about it here.

Most of the work to do is in the Precache() method where you can add this:
PRECACHE_SOUND("fassassin/fa_die1.wav");
PRECACHE_SOUND("fassassin/fa_pain1.wav");

CTalkMonster::TalkInit();

m_szGrp[TLK_ANSWER] = "FA_ANSWER";
m_szGrp[TLK_QUESTION] = "FA_QUESTION";
m_szGrp[TLK_IDLE] = "FA_IDLE";
m_szGrp[TLK_STARE] = "FA_STARE";
m_szGrp[TLK_USE] = "FA_OK";
m_szGrp[TLK_UNUSE] = "FA_WAIT";
m_szGrp[TLK_STOP] = "FA_STOP";

m_szGrp[TLK_NOSHOOT] = "FA_SCARED";
m_szGrp[TLK_HELLO] = "FA_HELLO";

m_szGrp[TLK_PLHURT1] = "!FA_CUREA";
m_szGrp[TLK_PLHURT2] = "!FA_CUREB";
m_szGrp[TLK_PLHURT3] = "!FA_CUREC";

m_szGrp[TLK_PHELLO] = nullptr;
m_szGrp[TLK_PIDLE] = nullptr;
m_szGrp[TLK_PQUESTION] = "FA_PQUEST";

m_szGrp[TLK_SMELL] = "FA_SMELL";

m_szGrp[TLK_WOUND] = "FA_WOUND";
m_szGrp[TLK_MORTAL] = "FA_MORTAL";

m_voicePitch = PITCH_NORM;

CTalkMonster::Precache();
The pain and death sounds are "normal" sounds, this is explained a bit later as to why, not everything needs to be tied to a sentence.

You will probably ask what all these TLK_ values mean, these are values from the TALKGROUPNAMES (talkmonster.h) enumeration which represent a context of the conversation. Values are:
Enumeration name Value Context
TLK_ANSWER 0 Answering a question that a friend asked (idle chit-chat).
TLK_QUESTION 1 Asking a question to a friend (idle chit-chat).
TLK_IDLE 2 Idle chit-chat (usually to the player when there is no friend nearby).
TLK_STARE 3 React to a client (bot or player) staring at me for some time.
TLK_USE 4 Client is asking me to follow me and I accept it.
TLK_UNUSE 4 Client is asking me to stay at my position and I accept it.
TLK_STOP 5 I can't follow the client anymore (usually blocked path) and I must stop.
TLK_NOSHOOT 6 Client is shooting my friend (friendly fire) and I'm asking him/her/them to stop.
TLK_HELLO 7 Greeting the client (after seeing him/her/them for the first time).
TLK_PHELLO 8 Same as previous one but in "pre-disaster" (before resonance cascade) scenario.
TLK_PIDLE 8 Same as TLK_IDLE but in "pre-disaster" scenario.
TLK_PQUESTION 9 Same as TLK_QUESTION but in "pre-disaster" scenario.
TLK_PLHURT1 10 I want to chit-chat in this idle situation but the client I'm following has less than 50% of his/her/their health.
TLK_PLHURT2 11 Same as previous one but with the client's health being less than 25%.
TLK_PLHURT3 12 Same as previous one but with the client's health being less than 12,5%.
TLK_SMELL 13 I smell something foul.
TLK_WOUND 14 I want to chit-chat in this idle situation but my health is below 75%.
TLK_MORTAL 15 Same as previous one but my health is below 50%.
TLK_CGROUPS 16 Not used by monsters but must this value must always be the last one since it acts as the enumeration's count (used to define const char* CTalkMonster::m_szGrp[TLK_CGROUPS] array size).
The purpose of CTalkMonster::TalkInit() is to reset the "global talking timer" (float CTalkMonster::g_talkWaitTime) common to all talkative monsters. The purpose of said timer is to prevent conversations from "overlapping" and being interrupted, one quirk with that method is that it also sets the voice pitch (m_voicePitch) to 100 (PITCH_NORM). For monsters that have different voice pitches based on certain criterias (like the scientists having a different voice pitch depending on their head), that can be problematic in certain scenarios. That's why it's a good practice to set m_voicePitch at the end of the Precache() method.
A side note about voice pitch, scientists and a bug
Scientists have a bug where the voice pitch can be incorrect when loading a map for the first time.

Their voice pitch is configured in CScientist::TalkInit(). The call stack is basically that method <- Precache() <- Spawn(). The problem is that the scientist's body gets randomized after assigning that voice pitch.

The bug fixes itself in-game after you save and load a save file because Precache() is called again but not Spawn(). And since the head is already known at this point, the proper voice pitch is assigned.

If you want to fix this oversight, you need to go to CScientist::Spawn() and move the code that select the scientist's head before Precache() is called.

Here's a bonus, scientists with the "Slick" head has a higher pitch similar to "Glasses/Einstein" one due to a miscalculation in CScientist::TalkInit(). See that switch (pev->body % 3) line? Replace the "3" by NUM_SCIENTIST_HEADS.

Thanks to FreeSlave for the original findings, explanation and solution which you can read here
m_szGrp is an array of sentence groups where the index correspond to a context like player staring at the monster, asking him to follow/stay here, asking/answering a question before the incident ("pre-disaster") and after it ("post-disaster").

CTalkMonster::Precache() precache and sets the custom "use/unuse" sentences if asked by the level designer. Even if you are not using that feature, it's a good idea to include that line because the "core" of said feature is part of CTalkMonster.

Now to demonstrate how you would use these sentences, we can override the alert, death and pain sounds methods like this:
void CFAssassin::AlertSound()
{
    if (m_hEnemy == nullptr || !FOkToSpeak())
        return;

    PlaySentence("FA_ATTACK", RANDOM_FLOAT(2.8f, 3.2f), VOL_NORM, ATTN_IDLE);
}

void CFAssassin::DeathSound()
{
    EMIT_SOUND_DYN(ENT(pev), CHAN_VOICE, "fassassin/fa_die1.wav", VOL_NORM, ATTN_NORM, 0, GetVoicePitch());
}

void CFAssassin::PainSound()
{
    if (gpGlobals->time < m_flPainTime)
        return;

    m_flPainTime = gpGlobals->time + RANDOM_FLOAT(0.5f, 0.75f);
    EMIT_SOUND_DYN(ENT(pev), CHAN_VOICE, "fassassin/fa_pain1.wav", VOL_NORM, ATTN_NORM, 0, GetVoicePitch());
}
In AlertSound(), you might have noticed the usage of bool CTalkMonster::FOkToSpeak(). This method is responsible of checking if the monsters can speak by checking if various criterias are met such as: Some of these conditions clashes with death and pain sounds and that's why sentences are not used for them.

CTalkMonster::PlaySentence(const char* pszSentence, float duration, float volume, float attenuation) is the method that play sentences. While the parameters are self-explanatory, there is a special case for pszSentence. In sentences.txt, to define multiple lines for the same context, you suffix the name with an incremental number (FA_WOUND0, FA_WOUND1 for example). For pszSentence, if the first character is an exclamation mark (!), you can play a specific sentence directly and bypass the randomization (!FA_WOUND1 will always play the sentence FA_WOUND1). If you do not put that exclamation mark, then a random line for said context will be picked up.

It's also note-worthy that this method does not check the "global talk timer" but do update it. This is done to ensure that "story based" voice lines (scripted_sentence) or important dialog like alerting the player to an enemy's presence is played regardless if someone was already talking or not.

In Half-Life, friendly monsters can say "hello", "I smell/hear something", "I'm wounded" and some other similar lines. For these cases, in order to prevent these lines from being played too frequently, the "said bit" for the context is stored in their m_bitsSaid attribute. See the similarity with conditions/memory bits? These bits are cleared later (except for the "hello" one) when friendly monsters can repeat that kind of dialogue.

What does this mean for CTalkMonster::PlaySentence(const char* pszSentence, float duration, float volume, float attenuation)? When playing a sentence through that method, the "hello said bit" is set even if the friendly monster never said "hello" in the first place and/or the sentence to play is not a "hello" one.

GetVoicePitch() as you might expect returns the value of m_voicePitch but do note that there is a tiny bit of randomization added as well (in reality, it's m_voicePitch + RANDOM_LONG(0, 3)).

For the pain sound, we add an attribute m_flPainTime to our Friendly Assassin class to prevent the pain sounds being played too frequently.

There are two other interesting methods to know about their existence: CTalkMonster::TrySmellTalk() (which will be used later) in order to make the monster smell for scents and try to talk about it and CTalkMonster::ShutUpFriends() to silence everyone besides the current monster. This is used by the concurrent spawnflag in scripted_sentence and the animation events 1005 (play a sentence) and 1009 (25% chance to play a sentence).

If you used the same sound masks (int ISoundMask()) as the Human Assassin, you might want to use the same as Barney's in order to keep things consistent between all friendly monsters.

Follow the player and stay here orders

Time to handle the "follow me/stay here" part. You will need to override int ObjectCaps() for our Friendly Assassin and return the result of CTalkMonster::ObjectCaps() | FCAP_IMPULSE_USE so that the USE key works. Then you can simply add SetUse(&CFAssassin::FollowerUse) in the Spawn() method to use the code from CTalkMonster.

Let's talk about void CTalkMonster::FollowerUse(CBaseEntity* pActivator, CBaseEntity* pCaller, USE_TYPE useType, float value) a bit. For starters, it won't do anything if you are "using" the monster during a script (like scripted_sequence).

Only the player can "use" friendly monsters, everything else is ignored. If the "pre-disaster" flag is set, then the CTalkMonster::DeclineFollowing() method is executed. It might be a good idea to override this method in our Friendly Assassin class to play the FA_POK sentence there.

If we're not in a "pre-disaster" scenario, a call to bool CTalkMonster::CanFollow() is made to check if she can follow the player. It returns false if in a script state, dead or bool CTalkMonster::IsFollowing() returns true, it returns false otherwise.

If she can follow the player, a final check is made to validate she has not been provoked by the player first (friendly fire). If that's the case, nothing happens besides a message in the console otherwise, CTalkMonster::StartFollowing(CBaseEntity* pLeader) is called with the player being the leader.

In other cases, CTalkMonster::StopFollowing(bool clearSchedule) is called with clearSchedule being true.

This won't be enough tho, you need to have some tasks, schedules and some changes in schedules management to complete the follow part. To make our lives easier, we'll borrow some code from Barney:
Task_t tlFAssassinFaceTarget[] =
    {
        {TASK_SET_ACTIVITY, (float)ACT_IDLE},
        {TASK_FACE_TARGET, 0.0f},
        {TASK_SET_ACTIVITY, (float)ACT_IDLE},
        {TASK_SET_SCHEDULE, (float)SCHED_TARGET_CHASE},
};

Schedule_t slFAssassinFaceTarget[] =
    {
        {tlFAssassinFaceTarget,
            ARRAYSIZE(tlFAssassinFaceTarget),
            bits_COND_CLIENT_PUSH |
                bits_COND_NEW_ENEMY |
                bits_COND_LIGHT_DAMAGE |
                bits_COND_HEAVY_DAMAGE |
                bits_COND_HEAR_SOUND |
                bits_COND_PROVOKED,
            bits_SOUND_DANGER,
            "FAssassinFaceTarget"},
};

Task_t tlFAssassinFollow[] =
    {
        {TASK_MOVE_TO_TARGET_RANGE, 128.0f},
        {TASK_SET_SCHEDULE, (float)SCHED_TARGET_FACE},
};

Schedule_t slFAssassinFollow[] =
    {
        {tlFAssassinFollow,
            ARRAYSIZE(tlFAssassinFollow),
            bits_COND_NEW_ENEMY |
                bits_COND_LIGHT_DAMAGE |
                bits_COND_HEAVY_DAMAGE |
                bits_COND_HEAR_SOUND |
                bits_COND_PROVOKED,
            bits_SOUND_DANGER,
            "FAssassinFollow"},
};
Don't forget to update the custom schedules table. Then we can update Schedule_t *GetSchedule() and Schedule_t *GetScheduleOfType(int Type) like this:
Schedule_t* CFAssassin::GetSchedule()
{
    if (HasConditions(bits_COND_HEAR_SOUND))
    {
        CSound* pSound = PBestSound();
        if (pSound && (pSound->m_iType & bits_SOUND_DANGER) != 0)
            return GetScheduleOfType(SCHED_TAKE_COVER_FROM_BEST_SOUND);
    }

    switch (m_MonsterState)
    {
    case MONSTERSTATE_COMBAT:
    {
        // Check ASAP if we can heal a friend right now
        m_pHealTarget = CheckHealTarget();
        if (m_pHealTarget)
            return GetScheduleOfType(SCHED_RANGE_ATTACK2);

        if (HasConditions(bits_COND_ENEMY_DEAD))
            return CTalkMonster::GetSchedule();

        if (HasConditions(bits_COND_HEAVY_DAMAGE))
            return GetScheduleOfType(SCHED_TAKE_COVER_FROM_ENEMY);

        if (HasConditions(bits_COND_CAN_RANGE_ATTACK1))
            return GetScheduleOfType(SCHED_RANGE_ATTACK1);

        if (HasConditions(bits_COND_SEE_ENEMY))
            return GetScheduleOfType(SCHED_COMBAT_FACE);
    }
    break;
    case MONSTERSTATE_ALERT:
    case MONSTERSTATE_IDLE:
    {
        // Check ASAP if we can heal a friend right now
        m_pHealTarget = CheckHealTarget();
        if (m_pHealTarget)
            return GetScheduleOfType(SCHED_RANGE_ATTACK2);

        // No enemy and ordered to follow the player
        if (m_hEnemy == nullptr && IsFollowing())
        {
            // If the player died, stop following him
            if (!m_hTargetEnt->IsAlive())
            {
                StopFollowing(false);
                break;
            }
            else
            {
                // Player is pushing me (probably because I'm blocking his path), run the "move away and follow" schedule
                if (HasConditions(bits_COND_CLIENT_PUSH))
                    return GetScheduleOfType(SCHED_MOVE_AWAY_FOLLOW);

                // Just face the player to know where he's going
                return GetScheduleOfType(SCHED_TARGET_FACE);
            }
        }

        // Player is pushing me (probably because I'm blocking his path), run the "move away" schedule
        if (HasConditions(bits_COND_CLIENT_PUSH))
            return GetScheduleOfType(SCHED_MOVE_AWAY);

        // Smell and try to talk about it if there's a scent
        TrySmellTalk();
        return GetScheduleOfType(SCHED_ALERT_STAND);
    }
    break;
    }

    return CTalkMonster::GetSchedule();
}

Schedule_t* CFAssassin::GetScheduleOfType(int Type)
{
    Schedule_t* pSched;

    switch (Type)
    {
    case SCHED_TAKE_COVER_FROM_ENEMY:
        return pev->health > 30 ? slFAssassinTakeCoverFromEnemy : slFAssassinTakeCoverFromEnemy2;
    case SCHED_TAKE_COVER_FROM_BEST_SOUND:
        return slFAssassinTakeCoverFromBestSound;
    case SCHED_CHASE_ENEMY:
        return slFAssassinHunt;
    case SCHED_TARGET_CHASE: // Part of the follow code, make sure to actually follow the player
        return slFAssassinFollow;
    case SCHED_TARGET_FACE: // Use our version of "face target" instead of the default one
        pSched = CTalkMonster::GetScheduleOfType(Type);

        if (pSched == slIdleStand)
            return slFAssassinFaceTarget;
        else
            return pSched;
        break;
    }

    return CTalkMonster::GetScheduleOfType(Type);
}
Do notice the importance (and duplication) of the code that order the friendly assassin to check and eventually heal an ally. If the code has been placed outside of the switch, there would have been risks where said code could run in "unexpected scenarios" such as "being barnacled" (MONSTERSTATE_PRONE), running a scripted sequence (MONSTERSTATE_SCRIPT) or even dead (MONSTERSTATE_DEAD).

Provoke

In Half-Life, friendly fire (intended or multiple cross-fire situations) will provoke the friendly monster. This means it will stop follow the player if it was and shoot him until he's dead.

If the player kill the friendly monster, all scientists will run from fear and everyone else that have weapons (Barney and now our Friendly Assassin) will react and open fire.

The latter is already handled when we converted our Friendly Assassin from CBaseMonster to CTalkMonster, you can check the CTalkMonster::Killed() method to see how it's done.

However, we need to take into account the first part where it's an intended friendly fire or a cross-fire situation. To do that, we need to override the bool CBaseEntity::TakeDamage(entvars_t* pevInflictor, entvars_t* pevAttacker, float flDamage, int bitsDamageType) method like this:
bool CFAssassin::TakeDamage(entvars_t* pevInflictor, entvars_t* pevAttacker, float flDamage, int bitsDamageType)
{
    bool bReturn = CTalkMonster::TakeDamage(pevInflictor, pevAttacker, flDamage, bitsDamageType);
    if (!IsAlive() || pev->deadflag == DEAD_DYING)
        return bReturn;

    if (m_MonsterState == MONSTERSTATE_PRONE || (pevAttacker->flags & FL_CLIENT) == 0)
        return bReturn;

    if (m_hEnemy == nullptr)
    {
        if ((m_afMemory & bits_MEMORY_SUSPICIOUS) != 0 || IsFacing(pevAttacker, pev->origin))
        {
            PlaySentence("FA_MAD", 4.0f, VOL_NORM, ATTN_NORM);
            Remember(bits_MEMORY_PROVOKED);
            StopFollowing(true);
        }
        else
        {
            PlaySentence("FA_SHOT", 4.0f, VOL_NORM, ATTN_NORM);
            Remember(bits_MEMORY_SUSPICIOUS);
        }
    }
    else if (!(m_hEnemy->IsPlayer()) && pev->deadflag == DEAD_NO)
    {
        PlaySentence("FA_SHOT", 4.0f, VOL_NORM, ATTN_NORM);
    }

    return bReturn;
}
First, we call the parent bool CTalkDamage::TakeDamage(pevInflictor, pevAttacker, flDamage, bitsDamageType) and store it's result. CTalkMonster override is responsible for having nearby friendlies asking the player to stop before calling the CBaseMonster version to handle the damage, pain sound, death and so on.

If the Friendly Assassin is already dead or dying, no need to go further. Likewise, if she's being grabbed by a Barnacle (m_MonsterState == MONSTERSTATE_PRONE) or the damage come from a non-player, then there is nothing extra to do.

If she has no enemy, two scenarios can happen: 1) if she was already suspicious or the player was facing her, then we play the "mad" sentence, stop follow the player and turn on the "provoke" memory. The default code handle the rest (relationship, targeting...)

If the attacker was not a player and she is still alive, we play an "I'm shot" sentence. In all cases, we return the result of the parent calls.

Full code of this page is available here: here

Interesting stuff

Here are some interesting elements when reading CTalkMonster code in talkmonster.cpp:
Note about talkative monsters and enemies
CTalkMonster is primarily designed for friendly monsters and caution must be exercised if they're used on enemies (making an enemy Barney for example).

Changing the classification won't be enough, you still have to take care of some behavior such as "talking" (unless you want the enemy to say "hello" to the player), "following" and "actual allies being provoked" when you kill the enemy (AlertFriends, EnumFriends, m_szFriends...)

In this scenario, ask yourself if it wouldn't be simpler and efficient to make a basic or squad monster instead.

Comments

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