Monsters Programming - Talkative Monsters (part 1) Last edited 6 months ago2023-10-22 09:56:14 UTC

Half-Life Programming

Now that you know how to create basic and squad monsters, it's time to look at another kind, the friendly talkative ones.

For the context of this chapter, we are going to create a variant of the Black Ops Assassin which we will call "Friendly Assassin".

We are not going to make a 100% copy of the original Assassin tho. For the sake of simplicity, we will remove the jumping/flying mechanic and replace the grenade attack by the ability to shoot healing darts at friendlies to heal them if certain conditions are met. And of course, like the name implies, she will be friendly to players, Barneys and scientists and hostile towards aliens, H.E.C.U., Black Ops and so on. The player will be able to ask her to follow and such (unless provoked or in pre-disaster scenario).

Assets

To distinguish our new Friendly Assassin from the existing one, we will not use the same model but a different one. Since this is a programming book and not a modeling/texturing one, you can either use the assets provided below or if you have the skills and/or courage to learn modeling/texturing/animating/compiling and so on, you could make your own assets. For the rest of this book page, it is assumed you use the assets provided.
Almost the same model as the enemy one but blue (like Barney and Otis)Almost the same model as the enemy one but blue (like Barney and Otis)
Download the test mod that contains the Friendly Assassin assets (models and sounds) and a test map:

Monsters Programming - Talkative monster template

The twhl_talk_monsters_tutorial folder goes in your Half-Life installation directory. Restart Steam if you do not see the test mod in your library.
Credits
Model, texture and QC edits by Shepard. Voice lines by JennWolf for Barniel in Azure Sheep mod. Valve for everything else.
Visually, the only differences you will see is the removal of grenades near the pouches and the black suit being replaced by a blue one (to fit with the Black Mesa Security staff like Barney and eventually Otis).

But what's more important are the technical differences between the existing Human Assassin and our new one:

Programming the basic Friendly Assassin

Like before, you should program the monster (in our case: Friendly Assassin) in a "basic fashion". In other words, start programming her using CBaseMonster as parent class instead of CTalkMonster. Then, add her healing darts and the logic behind it and finish her code by reading the next page to "upgrade" her into a CTalkMonster with some behavior similar to Barney.
Disclaimer
The code of the Friendly Assassin as a basic monster with her healing darts is provided at the end of this section.

You might be tempted to scroll down, copy/paste the code and skip to the "talkative" part right away, but please, do notice you have an opportunity to train yourself into monsters programming by giving a try at this exercise.

Don't worry if your code isn't 100% identical to the one provided. If you manage to adapt the rest of this page to your own code, that's good too.
So you want to try programming the basic Friendly Assassin and her healing darts on your own? That's nice!

Here are some tips: do not hesitate to read (again) the previous pages if you need to and go back here. Likewise, looking at the existing Human Assassin (hassassin.cpp) code for reference contains some information that will surely be of interest when programming our friendly version.

Here are some interesting notes about the Human Assassin's code: At this point, you should have a Friendly Assassin that is able to attack enemies (if you can lure them to her or spawn them right in front of her). She won't talk, follow you or get out of the way if you "push" her yet but we'll take care of that later.

Next, you will need to program the healing dart itself and the logic behind it.

The healing dart can be a copy of the crossbow bolt but modified to not deal damage and heal instead (bool CBaseEntity::TakeHealth(float flHealth, int bitsDamageType)). The choice of editing the existing crossbow bolt code or duplicate it is yours to make (read this page for more details). Don't forget that crossbow bolts explodes in multiplayer if shot without using the crossbow's scope, so make sure that behavior does not happen for the healing darts.

To determine which ally to heal, we'll make a function dedicated to searching a nearby ally eligible for healing while checking for a set of conditions like waiting for a cooldown to be over, searching within a certain radius, confirming the ally relationship and checking if the health is below a certain threshold.

If we do have that ally, then we "remember" him/her by storing it in an EHANDLE m_pHealTarget in the Friendly Assassin's class (don't forget to update the save/restore table!).

Then we can use Schedule_t* GetSchedule() to execute that search function early as possible regardless of the state (alert, combat, idle...) And if we have our ally (m_pHealTarget != nullptr), we can return the SCHED_RANGE_ATTACK2 schedule that will play ACT_RANGE_ATTACK2 activity and thus play our duplicated shooting animation.

The last thing left to do is to program the shoot healing dart animation event itself. It can be a "copy/paste" of the existing 9mm one but changed to target our ally instead of the enemy and shoot the healing dart instead of a 9mm bullet. Looking at how the crossbow spawn and set the bolt is a nice reference to get the healing dart right.

If you managed to program the Friendly Assassin as intended, good job! If you failed, don't let yourself down and keep trying, making mistakes is the path to learn and gain knowledge.

Here's the code if you need it. First, the header file:
#pragma once

// Animation event IDs
constexpr int FASSASSIN_AE_SHOOT = 1;
constexpr int FASSASSIN_AE_SHOOT_HEALING_DART = 2;

class CFAssassin : public CBaseMonster
{
public:
    void Spawn() override;
    void Precache() override;

    int Classify() override;
    int ISoundMask() override;
    void SetYawSpeed() override;

    bool CheckRangeAttack1(float flDot, float flDist) override;

    void HandleAnimEvent(MonsterEvent_t* pEvent) override;

    Schedule_t* GetSchedule() override;
    Schedule_t* GetScheduleOfType(int Type) override;

    void Killed(entvars_t* pevAttacker, int iGib) override;

    bool Save(CSave& save) override;
    bool Restore(CRestore& restore) override;
    static TYPEDESCRIPTION m_SaveData[];

    CUSTOM_SCHEDULES;
private:
    // Search nearby for a friendly monster to heal and returns said monster if it exists. Otherwise, "nullptr" is returned
    CBaseEntity* CheckHealTarget();

    float m_flDiviation; // Accuracy code taken from Human Assassin
    float m_flLastShot;

    EHANDLE m_pHealTarget; // Target to heal
    float m_flLastHealTime; // Healing cooldown

    int m_iShell; // Brass shell
};

// The healing dart, a modified copy of the crossbow bolt
class CHealingDart : public CBaseEntity
{
public:
    static CHealingDart* DartCreate();

    void Spawn() override;
    void Precache() override;

    int Classify() override;

    void EXPORT DartTouch(CBaseEntity* pOther);
    void EXPORT BubbleThink();
};
And here's the source file:
#include "extdll.h"
#include "util.h"
#include "cbase.h"

#include "monsters.h"
#include "soundent.h"
#include "weapons.h"

#include "fassassin.h"

// Tasks and schedules
Task_t tlFAssassinHunt[] =
    {
        {TASK_GET_PATH_TO_ENEMY, 0.0f},
        {TASK_RUN_PATH, 0.0f},
        {TASK_WAIT_FOR_MOVEMENT, 0.0f},
};

Schedule_t slFAssassinHunt[] =
    {
        {tlFAssassinHunt,
            ARRAYSIZE(tlFAssassinHunt),
            bits_COND_NEW_ENEMY |
                bits_COND_CAN_RANGE_ATTACK1 |
                bits_COND_HEAR_SOUND,

            bits_SOUND_DANGER,
            "FAssassinHunt"},
};

Task_t tlFAssassinTakeCoverFromBestSound[] =
    {
        {TASK_STOP_MOVING, 0.0f},
        {TASK_FIND_COVER_FROM_BEST_SOUND, 0.0f},
        {TASK_RUN_PATH, 0.0f},
        {TASK_WAIT_FOR_MOVEMENT, 0.0f},
        {TASK_REMEMBER, (float)bits_MEMORY_INCOVER},
        {TASK_TURN_LEFT, 179.0f},
};

Schedule_t slFAssassinTakeCoverFromBestSound[] =
    {
        {tlFAssassinTakeCoverFromBestSound,
            ARRAYSIZE(tlFAssassinTakeCoverFromBestSound),
            bits_COND_NEW_ENEMY,
            0,
            "FAssassinTakeCoverFromBestSound"},
};

Task_t tlFAssassinTakeCoverFromEnemy[] =
    {
        {TASK_STOP_MOVING, 0.0f},
        {TASK_WAIT, 0.2f},
        {TASK_SET_FAIL_SCHEDULE, (float)SCHED_RANGE_ATTACK1},
        {TASK_FIND_COVER_FROM_ENEMY, 0.0f},
        {TASK_RUN_PATH, 0.0f},
        {TASK_WAIT_FOR_MOVEMENT, 0.0f},
        {TASK_REMEMBER, (float)bits_MEMORY_INCOVER},
        {TASK_FACE_ENEMY, 0.0f},
};

Schedule_t slFAssassinTakeCoverFromEnemy[] =
    {
        {tlFAssassinTakeCoverFromEnemy,
            ARRAYSIZE(tlFAssassinTakeCoverFromEnemy),
            bits_COND_NEW_ENEMY |
                bits_COND_HEAR_SOUND,

            bits_SOUND_DANGER,
            "FAssassinTakeCoverFromEnemy"},
};

Task_t tlFAssassinTakeCoverFromEnemy2[] =
    {
        {TASK_STOP_MOVING, 0.0f},
        {TASK_WAIT, 0.2f},
        {TASK_FACE_ENEMY, 0.0f},
        {TASK_RANGE_ATTACK1, 0.0f},
        {TASK_FIND_FAR_NODE_COVER_FROM_ENEMY, 384.0f},
        {TASK_RUN_PATH, 0.0f},
        {TASK_WAIT_FOR_MOVEMENT, 0.0f},
        {TASK_REMEMBER, (float)bits_MEMORY_INCOVER},
        {TASK_FACE_ENEMY, 0.0f},
};

Schedule_t slFAssassinTakeCoverFromEnemy2[] =
    {
        {tlFAssassinTakeCoverFromEnemy2,
            ARRAYSIZE(tlFAssassinTakeCoverFromEnemy2),
            bits_COND_NEW_ENEMY |
                bits_COND_HEAR_SOUND,

            bits_SOUND_DANGER,
            "FAssassinTakeCoverFromEnemy2"},
};

DEFINE_CUSTOM_SCHEDULES(CFAssassin) {
    slFAssassinHunt,
    slFAssassinTakeCoverFromBestSound,
    slFAssassinTakeCoverFromEnemy,
    slFAssassinTakeCoverFromEnemy2,
};

IMPLEMENT_CUSTOM_SCHEDULES(CFAssassin, CBaseMonster);

// Save/restore table
TYPEDESCRIPTION CFAssassin::m_SaveData[] =
    {
        DEFINE_FIELD(CFAssassin, m_flDiviation, FIELD_FLOAT),
        DEFINE_FIELD(CFAssassin, m_flLastShot, FIELD_TIME),

        DEFINE_FIELD(CFAssassin, m_pHealTarget, FIELD_EHANDLE),
        DEFINE_FIELD(CFAssassin, m_flLastHealTime, FIELD_TIME),
};

IMPLEMENT_SAVERESTORE(CFAssassin, CBaseMonster);

LINK_ENTITY_TO_CLASS(monster_fassassin, CFAssassin);

void CFAssassin::Spawn()
{
    Precache();
    SET_MODEL(ENT(pev), "models/fassassin.mdl");

    UTIL_SetSize(pev, VEC_HUMAN_HULL_MIN, VEC_HUMAN_HULL_MAX);
    pev->solid = SOLID_SLIDEBOX;
    pev->movetype = MOVETYPE_STEP;
    m_bloodColor = BLOOD_COLOR_RED;
    pev->health = gSkillData.hassassinHealth;

    m_flFieldOfView = VIEW_FIELD_WIDE;
    m_MonsterState = MONSTERSTATE_NONE;
    m_afCapability = bits_CAP_HEAR | bits_CAP_DOORS_GROUP | bits_CAP_TURN_HEAD | bits_CAP_RANGE_ATTACK1 | bits_CAP_RANGE_ATTACK2;

    // This is used by various methods like "ShootEnemy" to determine precisely the origin of the shots
    m_HackedGunPos = Vector(0.0f, 24.0f, 48.0f);

    MonsterInit();
}

void CFAssassin::Precache()
{
    PRECACHE_MODEL("models/fassassin.mdl");

    PRECACHE_SOUND("weapons/pl_gun1.wav");
    PRECACHE_SOUND("weapons/pl_gun2.wav");

    m_iShell = PRECACHE_MODEL("models/shell.mdl");

    UTIL_PrecacheOther("healing_dart");
}

int CFAssassin::Classify()
{
    return CLASS_PLAYER_ALLY;
}

int CFAssassin::ISoundMask()
{
    return bits_SOUND_WORLD |
        bits_SOUND_COMBAT |
        bits_SOUND_CARCASS |
        bits_SOUND_MEAT |
        bits_SOUND_GARBAGE |
        bits_SOUND_DANGER |
        bits_SOUND_PLAYER;
}

void CFAssassin::SetYawSpeed()
{
    pev->yaw_speed = 360.0f;
}

bool CFAssassin::CheckRangeAttack1(float flDot, float flDist)
{
    if (HasConditions(bits_COND_ENEMY_OCCLUDED) || flDist <= 64.0f || flDist > 2048.0f)
        return false;

    TraceResult tr;
    Vector vecSrc = GetGunPosition();
    UTIL_TraceLine(vecSrc, m_hEnemy->BodyTarget(vecSrc), dont_ignore_monsters, ENT(pev), &tr);
    return tr.flFraction == 1.0f || tr.pHit == m_hEnemy->edict();
}

void CFAssassin::HandleAnimEvent(MonsterEvent_t* pEvent)
{
    switch (pEvent->event)
    {
    case FASSASSIN_AE_SHOOT:
    {
        Vector vecOrigin = GetGunPosition();
        Vector vecDir = ShootAtEnemy(vecOrigin);

        // Accuracy code taken from Human Assassin
        if (m_flLastShot + 2.0f < gpGlobals->time)
        {
            m_flDiviation = 0.10f;
        }
        else
        {
            m_flDiviation -= 0.01f;
            if (m_flDiviation < 0.02f)
                m_flDiviation = 0.02f;
        }
        m_flLastShot = gpGlobals->time;

        UTIL_MakeVectors(pev->angles);

        Vector vecShellVelocity = gpGlobals->v_right * RANDOM_FLOAT(40.0f, 90.0f) + gpGlobals->v_up * RANDOM_FLOAT(75.0f, 200.0f) + gpGlobals->v_forward * RANDOM_FLOAT(-40.0f, 40.0f);
        EjectBrass(pev->origin + gpGlobals->v_up * 32.0f + gpGlobals->v_forward * 12.0f, vecShellVelocity, pev->angles.y, m_iShell, TE_BOUNCE_SHELL);
        FireBullets(1, vecOrigin, vecDir, Vector(m_flDiviation, m_flDiviation, m_flDiviation), 2048.0f, BULLET_MONSTER_9MM);

        switch (RANDOM_LONG(0, 1))
        {
        case 0:
            EMIT_SOUND(ENT(pev), CHAN_WEAPON, "weapons/pl_gun1.wav", RANDOM_FLOAT(0.6f, 0.8f), ATTN_NORM);
            break;
        case 1:
            EMIT_SOUND(ENT(pev), CHAN_WEAPON, "weapons/pl_gun2.wav", RANDOM_FLOAT(0.6f, 0.8f), ATTN_NORM);
            break;
        }

        pev->effects |= EF_MUZZLEFLASH;

        Vector angDir = UTIL_VecToAngles(vecDir);
        SetBlending(0, angDir.x);
    }
    break;
    case FASSASSIN_AE_SHOOT_HEALING_DART:
    {
        Vector vecOrigin = GetGunPosition();
        Vector vecDir = gpGlobals->v_forward;
        if (m_pHealTarget)
        {
            MakeIdealYaw(m_pHealTarget->pev->origin);
            ChangeYaw(pev->yaw_speed);
            vecDir = ((m_pHealTarget->BodyTarget(vecOrigin) - m_pHealTarget->pev->origin) + m_pHealTarget->pev->origin - vecOrigin).Normalize();
        }

        // Spawn the healing dart (same as the crossbow code)
        CHealingDart* pDart = CHealingDart::DartCreate();
        pDart->pev->origin = vecOrigin;
        pDart->pev->angles = UTIL_VecToAngles(vecDir);
        pDart->pev->owner = edict();
        pDart->pev->velocity = vecDir * 2048.0f;
        pDart->pev->speed = 2048.0f;
        pDart->pev->avelocity.z = 10.0f;

        // Same sounds as shooting with the 9mm but with a higher pitch to make the difference
        switch (RANDOM_LONG(0, 1))
        {
        case 0:
            EMIT_SOUND_DYN(ENT(pev), CHAN_WEAPON, "weapons/pl_gun1.wav", VOL_NORM, ATTN_NORM, 0, 150);
            break;
        case 1:
            EMIT_SOUND_DYN(ENT(pev), CHAN_WEAPON, "weapons/pl_gun2.wav", VOL_NORM, ATTN_NORM, 0, 150);
            break;
        }

        pev->effects |= EF_MUZZLEFLASH;

        Vector angDir = UTIL_VecToAngles(vecDir);
        SetBlending(0, angDir.x);
        m_flLastHealTime = gpGlobals->time + 10.0f; // Apply cooldown
        m_pHealTarget = nullptr; // Forget target
    }
    break;
    default:
        CBaseMonster::HandleAnimEvent(pEvent);
    }
}

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);
    }

    // Unless we are taking cover from a dangerous sound (i.e. grenade), check ASAP if we can heal a friend regardless of state (idle/alert/combat...)
    m_pHealTarget = CheckHealTarget();
    if (m_pHealTarget)
        return GetScheduleOfType(SCHED_RANGE_ATTACK2);

    if (m_MonsterState != MONSTERSTATE_COMBAT || HasConditions(bits_COND_ENEMY_DEAD))
        return CBaseMonster::GetSchedule();

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

    if (HasConditions(bits_COND_SEE_ENEMY))
        return GetScheduleOfType(SCHED_COMBAT_FACE);

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

    return GetScheduleOfType(SCHED_ALERT_STAND);
}

Schedule_t* CFAssassin::GetScheduleOfType(int Type)
{
    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;
    }

    return CBaseMonster::GetScheduleOfType(Type);
}

void CFAssassin::Killed(entvars_t* pevAttacker, int iGib)
{
    // Drop the gun
    if (pev->body <= 0)
    {
        pev->body = 1;

        Vector vecOrigin;
        Vector vecAngles;
        GetAttachment(0, vecOrigin, vecAngles);
        DropItem("weapon_9mmhandgun", vecOrigin, vecAngles);
    }

    CBaseMonster::Killed(pevAttacker, iGib);
}

CBaseEntity* CFAssassin::CheckHealTarget()
{
    // Cooldown is not over yet
    if (m_flLastHealTime > gpGlobals->time)
        return nullptr;

    // Find all entities nearby
    CBaseEntity* pTarget = nullptr;
    while ((pTarget = UTIL_FindEntityInSphere(pTarget, pev->origin, 256.0f)) != nullptr)
    {
        // Check if it's a monster (including player) that is alive, an ally and not moving too fast (otherwise the healing dart goes somewhere else)
        if (pTarget->MyMonsterPointer() == nullptr || !pTarget->IsAlive() || IRelationship(pTarget) != R_AL || pTarget->pev->velocity.Length() > 64.0f)
            continue;

        // For players, heal them if they have half of their health
        if (pTarget->IsPlayer() && pTarget->pev->health <= pTarget->pev->max_health / 2.0f)
            return pTarget;

        // For the rest (friendly monsters), heal them if they have their below below a threshold
        if (!pTarget->IsPlayer() && pTarget->pev->health <= gSkillData.scientistHeal)
            return pTarget;
    }
    // Found no one eligible for healing
    return nullptr;
}

LINK_ENTITY_TO_CLASS(healing_dart, CHealingDart);

CHealingDart* CHealingDart::DartCreate()
{
    CHealingDart* pDart = GetClassPtr((CHealingDart*)nullptr);
    pDart->pev->classname = MAKE_STRING("healing_dart");
    pDart->Spawn();
    return pDart;
}

void CHealingDart::Spawn()
{
    Precache();
    pev->movetype = MOVETYPE_FLY;
    pev->solid = SOLID_BBOX;

    pev->gravity = 0.5f;

    SET_MODEL(ENT(pev), "models/crossbow_bolt.mdl"); // Could use a different model here if you have one and you wish

    UTIL_SetOrigin(pev, pev->origin);
    UTIL_SetSize(pev, Vector(0.0f, 0.0f, 0.0f), Vector(0.0f, 0.0f, 0.0f));

    SetTouch(&CHealingDart::DartTouch);
    SetThink(&CHealingDart::BubbleThink);
    pev->nextthink = gpGlobals->time + 0.2f;
}

void CHealingDart::Precache()
{
    PRECACHE_MODEL("models/crossbow_bolt.mdl");

    PRECACHE_SOUND("weapons/xbow_hitbod1.wav");
    PRECACHE_SOUND("weapons/xbow_hitbod2.wav");
    PRECACHE_SOUND("weapons/xbow_hit1.wav");
}

int CHealingDart::Classify()
{
    return CLASS_NONE;
}

void CHealingDart::DartTouch(CBaseEntity* pOther)
{
    SetTouch(nullptr);
    SetThink(nullptr);

    if (0 != pOther->pev->takedamage)
    {
        TraceResult tr = UTIL_GetGlobalTrace();
        entvars_t* pevOwner = VARS(pev->owner);

        // Check if the target is alive first, we don't want to heal a dying or dead monster
        if (pOther->IsAlive())
            pOther->TakeHealth(gSkillData.scientistHeal, DMG_GENERIC);

        pev->velocity = Vector(0.0f, 0.0f, 0.0f);
        switch (RANDOM_LONG(0, 1))
        {
        case 0:
            EMIT_SOUND(ENT(pev), CHAN_BODY, "weapons/xbow_hitbod1.wav", VOL_NORM, ATTN_NORM);
            break;
        case 1:
            EMIT_SOUND(ENT(pev), CHAN_BODY, "weapons/xbow_hitbod2.wav", VOL_NORM, ATTN_NORM);
            break;
        }

        Killed(pev, GIB_NEVER);
    }
    else
    {
        EMIT_SOUND_DYN(ENT(pev), CHAN_BODY, "weapons/xbow_hit1.wav", RANDOM_FLOAT(0.95f, 1.0f), ATTN_NORM, 0, 98 + RANDOM_LONG(0, 7));

        SetThink(&CHealingDart::SUB_Remove);
        pev->nextthink = gpGlobals->time;

        if (FClassnameIs(pOther->pev, "worldspawn"))
        {
            Vector vecDir = pev->velocity.Normalize();
            UTIL_SetOrigin(pev, pev->origin - vecDir * 12.0f);
            pev->angles = UTIL_VecToAngles(vecDir);
            pev->solid = SOLID_NOT;
            pev->movetype = MOVETYPE_FLY;
            pev->velocity = Vector(0.0f, 0.0f, 0.0f);
            pev->avelocity.z = 0.0f;
            pev->angles.z = RANDOM_LONG(0.0f, 360.0f);
            pev->nextthink = gpGlobals->time + 10.0f;
        }

        if (UTIL_PointContents(pev->origin) != CONTENTS_WATER)
            UTIL_Sparks(pev->origin);
    }
}

void CHealingDart::BubbleThink()
{
    pev->nextthink = gpGlobals->time + 0.1f;
    if (pev->waterlevel == 0)
        return;

    UTIL_BubbleTrail(pev->origin - pev->velocity * 0.1f, pev->origin, 1);
}

Comments

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