L'Intelligence Artificielle

Écrit le 03/07/2003 par DukeNukem
Dernière mise à jour : 30/01/2006

Introduction

Half-life est un jeu réputé pour sa bonne Intelligence Artificielle. Cependant, elle n'est pas toujours facile à comprendre pour les développeurs de mods, surtout quand on débute... Ce tutorial a pour but de vous montrer comment fonctionne cette IA et comment la personnaliser pour un monstre..

Au commencement : MonsterInit()

Tout commence à partir du moment où vous appelez MonsterInit() à la fin de la fonction Spawn(). Cette fonction fini l'initialisation de certaines variables (faites attentions à celles que vous initialisez dans Spawn(), elle pourraient être modifiées par CBaseMonster::MonsterInit()) puis appelle (indirectement) la fonction CBaseMonster::StartMonster().

Cette dernière va initialiser la fonction « Think » de base : CBaseMonster::MonsterThink(). Cette fonction va être maintenant exécutée en boucle jusqu'à la mort de l'entité, et va appeler CBaseMonster::RunAI().

RunAI() exécutes ces différentes tâches (monsterstate.cpp) :

Ces fonctions vont être responsables de l'activation ou désactivation des bits de conditions (bits_COND_) du monstre.

Puis fait un appel à la fonction CBaseMonster::MaintainSchedule() (schedule.cpp). Regardons un peu cette fonction... beuuarrkk c'est tout caca. Y'a plein de « tasks » et de « schedules » tout partout ! Pas de panique, je vais expliquer...

Schedule_t et Task_t, les sources de l'IA

L'Intelligence Artificielle d'Half-life est principalement faite de deux choses : les Tasks et les Schedules. Ce sont eux qui donnent à l'IA cette grande flexibilité.

Chaque Task est un objet de type Task_t et chaque Schedules, un Schedule_t. Leurs définitions se trouvent dans schedule.h. Examinons de plus près ces 2 structures :

struct Task_t
{
    int  iTask;
    float flData;
};
struct Schedule_t
{
    Task_t  *pTasklist;
    int    cTasks; 
    int    iInterruptMask;
    int    iSoundMask;
    const  char *pName;
};

La définition et l'initialisation de tableaux de type Task_t et Schedule_t se fait par liste d'initialisation. On va prendre comme exemple le programme, « Combat Stand » qui se trouve dans defaulai.cpp :

//=========================================================
// CombatIdle Schedule
//=========================================================

Task_t tlCombatStand1[] =
{
    { TASK_STOP_MOVING,    0              },
    { TASK_SET_ACTIVITY,    (float)ACT_IDLE },
    { TASK_WAIT_INDEFINITE, (float)0        },
};

Schedule_t slCombatStand[] =
{
    {
        tlCombatStand1,
        ARRAYSIZE( tlCombatStand1 ),
        bits_COND_NEW_ENEMY        |
        bits_COND_ENEMY_DEAD      |
        bits_COND_LIGHT_DAMAGE    |
        bits_COND_HEAVY_DAMAGE    |
        bits_COND_CAN_ATTACK,
        0,
        "Combat Stand"
    },
};

Notez que les 2 tableaux portent un nom similaire, à l'exception de leur préfixe. Pour des raisons de simplicité, je vous conseille de faire de même, en utilisant le préfixe « tl » pour les objets Task_t et « sl » pour Schedule_t (notation hongroise personnalisée de valve?).

Passons aux explications de chaque variable membre de ces 2 structures :

Task_t

iTask est le nom d'une tâche (Task) à effectuer. Il en existe un certain nombre déjà, mais vous pouvez en créer de nouveau aussi (on verra ça un peu plus loin).
flData est le paramètre de iTask. Certains Tasks demandent des paramètres comme une durée, ou une distance. Mais ce n'est pas le cas partout.
Dans notre exemple, il y a trois Tasks dans le tableau tlCombatStand1[]. Seul le second Task possède un paramètre (les autres ont 0 à la place), mais on ne va pas ici s'attarder sur ce que font chaque Task, car il y en a beaucoup. Vous pourrez avoir une explication plus approfondie sur elles dans un autre tutorial.

Schedule_t

*pTasklist est la liste des Tasks du Schedule. On lui associe généralement un tableau de Task_t.
cTasks est le nombre d'éléments du tableau.
iInterruptMask est la valeur des bits des conditions qui interrompront l'action. On utilise pour ça les macros définie dans schedule.h commençant par bits_COND_.
iSoundMask est la valeur des bits des sons qui interrompront l'action également. Comme iInterruptMask, on utilise des macros commençant par bits_SOUND_ définies dans soundent.h.
*pName est le nom de l'action (char *).
Les tableaux de type Schedule_t sont en générale des tableaux à un seul élément. Je vais quand même commenter rapidement l'exemple slCombatStand[] pour vous l'exemple :

tlCombatStand1 est le nom du tableau de Task_t associé à slCombatStand1[].
ARRAYSIZE( tlCombatStand1 ) est une macro qui calcule le nombre d'élément du tableau de Task_t associé (le nombre de TASK quoi). Ici, c'est trois.
Les 3ème et 4ème paramètres sont donc décrits plus haut et représentent les conditions d'interruption pouvant être combinée par l'opérateur OU exclusif |.
"Combat Stand" le nom tout simple de l'action.

Tous ces Schedules et Tasks se trouvent pour celles par défaut, dans defaultai.cpp, pour celle qui sont spécifique à chaque monstre dans le fichier source .cpp du monstre et pour les monstres bavards, il y'en a certains dans talkmonster.cpp.

Il existe aussi des constantes SCHED_ à ne pas confondre avec les TASK_. Les TASK_ et SCHED_ sont énuméré dans schedule.h. Nous verrons dans un autre tutorial comment ajouter ses TASK_ et SCHED_ personnalisés à la liste sans toucher à ce fichier.

Voyons maintenant comment sont exécutés nos Schedules ! Deux fonctions s'occupent de ça : GetSchedule() et GetScheduleOfType() :

// ========================================================
// GetSchedule()
// ========================================================

Schedule_t *CBaseMonster::GetSchedule( void )
{
    // ...

    if( condition )
        return GetScheduleOfType( SCHED_COMBAT_STAND );

    // ...
}

J'ai simplifié à fond la fonction pour comprendre le fonctionnement car en réalité, la condition est plus complexe que ça (un switch sur m_MonsterState et d'autres paramètres blablabla... ce n'est plus le sujet.)

C'est donc là que ça commence. Si condition est vraie (TRUE), on retourne le Schedule_t correspondant à la constante SCHED_COMBAT_STAND en appelant une autre fonction : GetScheduleOfType() (dans defaultai.cpp) :

// ========================================================
// GetScheduleOfType()
// ========================================================

Schedule_t* CBaseMonster::GetScheduleOfType( int Type )
{
    switch( Type )
    {
        // ...

        case SCHED_COMBAT_STAND:
            return &slCombatStand[0];

        // ...
    }
}

La fonction va donc switcher la variable Type. Lorsque Type est SCHED_COMBAT_STAND, on sort de la fonction en retournant l'adresse du premier élément du tableau slCombatStand[]. Parfois, on retourne le Schedule_t directement :

    return slFail;

slFail, qui est un autre Schedule. Ici, pas de retour d'adresse. C'est le cas dans les IA personnalisées le plus souvent.

Toutes ces constante SCHED_ sont énumérés dans schedule.h.

Ensuite, les Task listées dans notre tableau Task_t sont exécutées :-) Il nous reste maintenant comment.

Dans notre tableaux Task_t, on a utilisé des constantes commençant par TASK_. Toutes ces constantes sont énumérée dans schedule.h sous le nom de SHARED_TASKS. Ces TASK_ exécutent les instructions nécessaires au Task demandé grâce à deux fonctions : StartTask() et RunTask() qui sont dans schedule.cpp.

StartTask() est appelée chaque fois qu'un Task est demandée, et RunTask() est appelé chaque fois que RunAI() est appelé, c'est à dire en boucle jusqu'à la mort du monstre.

// ========================================================
// StartTask()
// ========================================================

void CBaseMonster::StartTask( Task_t *pTask )
{
    switch( pTask->iTask )
    {
        // ...

        case TASK_STOP_MOVING:
        {
            if( m_IdealActivity == m_movementActivity )
                m_IdealActivity = GetStoppedActivity();

            RouteClear();
            TaskComplete();
            break;
        }

        case TASK_SET_ACTIVITY:
        {
            m_IdealActivity =(Activity)(int)pTask->flData;
            TaskComplete();
            break;
        }

        case TASK_WAIT_INDEFINITE:
        {
            // don't do anything.
            break;
        }

        // ...
    }
}

La fonction switch la valeur de iTask et exécutera les Tasks un par un à leur appel dans tlCombatStand. J'ai mis ici les trois Tasks utilisés par « Combat Stand ». C'est ici que tout le code à exécuter pour chaque Task est placé. En quelque sorte, tlCombatStand1[] est l'équivalent des trois case du switch ci-dessus. Mais pour éviter de récrire du même code à chaque fois, Valve a codé chaque action séparément et l'on peu ainsi faire des combinaisons de Tasks plus facilement, grâce à Task_t.

Il reste encore RunTask() qui se présente de la même manière que StartTask() :

// ========================================================
// RunTask()
// ========================================================

void CBaseMonster::RunTask( Task_t *pTask )
{
    switch( pTask->iTask )
    {
        // ...

        case TASK_WAIT_INDEFINITE:
        {
            // don't do anything.
            break;
        }

        // ...
    }
}

On retrouve seulement TASK_WAIT_INDEFINITE, car les 2 autres se terminent par l'appel de la fonction TaskComplete() dans StartTask(), qui prévient le jeu que le Task est terminé. Sans ça, le Task serait exécuté indéfiniment (comme pour TASK_WAIT_INDEFINITE). Il existe aussi la fonction inline TaskFail(), au cas ou il y aurait une erreur lors de l'exécution du Task.

Dans RunTask(), les Tasks sont exécutés en boucle jusqu'à l'appel de TaskComplete(), tandis que dans StartTask(), elles ne sont exécutées qu'une seule fois. On pourrait rajouter ici par exemple, une variable de contrôle qui stop le Task si elle est TRUE...

Pour arrêter un Task, il y'aussi la fonction TaskIsComplete(), ou bien l'instruction m_iTaskStatus = TASKSTATUS_COMPLETE;.

Vous pouvez savoir si un Task est en cours de route avec TaskIsRunning().

Voilà c'est ça les Tasks et les Schedules ! MaintainSchedule() s'occupe donc de gérer les Schedules, d'en ré-exécuter un nouveau dès qu'un autre est terminé, d'exécuter les Tasks du Schedule en cours, etc. Jetez-y un coup d'½il ;)

Personnalisation de l'IA

Nous allons à présent apprendre à créer une IA customisée pour un monstre, en créant de nouveaux Schedules et de nouveaux Tasks !

On doit d'abord définir nos constantes SCHED_ et TASK_. Comme je l'ai dis plus haut, les SCHED_ et TASK_ de l'IA par défaut sont définie dans schedule.h, mais il est hors de question de trafiquer ce fichier et d'y ajouter nos propres SCHED_ ou TASK_ !

À la fin de l'énumération des deux types de constantes, Valve a ajouté les constantes LAST_COMMON_SCHEDULE et LAST_COMMON_TASK qui vont nous être utile pour ajouter nos propres SCHED_ et TASK_ à la suite de l'enum, sans écraser les anciens. A la fin du fichier de votre monstre, ajoutez :

// -----------------------------------------
// AI Schedules Specific
// -----------------------------------------

enum
{
    SCHED_DOSOMETHING = LAST_COMMON_SCHEDULE + 1,
};

enum
{     
    TASK_CUSTOM_NEW = LAST_COMMON_TASK + 1,
};

Ainsi notre SCHED_CUSTOM_NEW et notre TASK_CUSTOM_NEW suivent logiquement la liste des autres SCHED_ et TASK_.

Passons maintenant à la définition du Schedule :

// -----------------------------------------
// DoSomething schedule
// -----------------------------------------

Task_t tlDoSomething[] =
{
    { TASK_STOP_MOVING,  0              },
    { TASK_SET_ACTIVITY, (float)ACT_IDLE },
    { TASK_CUSTOM_NEW,  (float)0        },
};

Schedule_t slDoSomething[] =
{
    {
        tlDoSomething,
        ARRAYSIZE( tlDoSomething ),
        bits_COND_LIGHT_DAMAGE |
        bits_COND_HEAVY_DAMAGE
        0,
        "do Something!"
    },
};

Ici, notre Schedule utilise notre nouveau Task : TASK_CUSTOM_NEW. Mais avant d'exécuter celui-ci, il en exécutera deux autres : l'un pour s'arrêter, l'autre pour jouer l'animation idle.

S'il reçoit des dégâts, le Schedule est interrompu (bits_COND_LIGHT_DAMAGE | bits_COND_HEAVY_DAMAGE) mais aucun bruit ne le fera s'arrêter.

Vous devez maintenant ajouter ces quelques lignes de code (en imaginant que la classe de votre monstre est CMyMonster) :

DEFINE_CUSTOM_SCHEDULES( CMyMonster )
{
    slDoSomething,
};

IMPLEMENT_CUSTOM_SCHEDULES( CMyMonster, CBaseMonster );

Dans DEFINE_CUSTOM_SCHEDULE, vous devrez spécifier en paramètre la classe à laquelle les Schedules personnalisés appartiennent, et dans la liste, tous les Schedules perso. Ici, on n'en a qu'un seul. Au passage, vous devrez aussi apporter quelques modifications dans la définition de la classe de votre monstre :

public:
    // IA spécifique
    Schedule_t *GetSchedule( void );
    Schedule_t *GetScheduleOfType( int Type );

    void    StartTask( Task_t* );
    void    RunTask( Task_t* );

    CUSTOM_SCHEDULES;

CUSTOM_SCHEDULES sert à prévenir qu'on va utiliser des nouveaux Schedules personnalisés. On surcharge également les fonctions GetSchedule(), GetScheduleOfType(), StartTask() et RunTask(). Maintenant retournez à la fin du fichier pour y ajouter les 4 définitions de fonction :

// -----------------------------------------
// GetSchedule()
// -----------------------------------------

Schedule_t *CMyMonster::GetSchedule( void )
{
    if( condition )
        return GetScheduleOfType( SCHED_DOSOMETHING );

    // on se rappuie sur l'IA par défaut de CBaseMonster...
    return CBaseMonster::GetSchedule();
}



// -----------------------------------------
// GetScheduleOfType()
// -----------------------------------------

Schedule_t *CMyMonster::GetScheduleOfType( int Type )
{
    switch( Type )
    {
        case SCHED_DOSOMETHING:
            return slDoSomething;
       
        default:
            // on se rappuie sur l'IA par défaut de CBaseMonster...
            return CBaseMonster::GetScheduleOfType( Type );
    }
}

N'oubliez pas de faire appel aux fonctions de base si aucune condition n'est remplie sinon, théoriquement, vous n'auriez pas accès à l'IA par défaut. En réalité vous avez un warning à la compilation et un beau crash arrivé dans le jeu. Alors les oubliez pas :p

Et enfin StartTask() et à RunTask(), simplement comme expliqué plus haut :

// -----------------------------------------
// StartTask()
// -----------------------------------------

void CMyMonster::StartTask( Task_t *pTask )
{
    switch( pTask->iTask )
    {
        case TASK_CUSTOM_NEW:
        {                     
            // do something...
            break;
        }

        default:
        {
            // on se rappuie sur l'IA par défaut de CBaseMonster...
            CBaseMonster::StartTask( pTask );
            break;
        }
    }
}



// -----------------------------------------
// RunTask()
// -----------------------------------------

void CMyMonster::RunTask( Task_t* pTask )
{
    switch( pTask->iTask )
    {
        case TASK_CUSTOM_NEW:
        {                     
            if( condition )
                TaskComplete();
            else
            {
                // do something...
            }

            break;
        }

        default:
        {
            // on se rappuie sur l'IA par défaut de CBaseMonster...
            CBaseMonster::RunTask( pTask );
            break;
        }
    }
}

Gérer les events du modèle

Il reste encore un truc, un peu à part : la fonction HandleAnimEvent().

Les modèles de monstres sont riches en script faisant exécuter certaines tâches spécifiques, comme jouer un son, afficher un muzzleflash ou jouer une séquence (grâce aux ACT_!). Et il est en effet possible de faire exécuter des bouts de code à partir des events du modèle.

Prenez le .qc du hgrunt par exemple. Voici une de ses lignes :

$sequence launchgrenade "launchgrenade" fps 30 ACT_RANGE_ATTACK2 1 { event 8 24 }

Cette ligne servira à compiler une animation sous le nom de « launchgrenade ». Elle sera exécutée lorsque le système d'IA traitera une ligne du genre { TASK_SET_ACTIVITY, (float)ACT_RANGE_ATTACK2 }. À la fin de cette ligne de script se trouve { event 8 24 }. Ce bout de script va être traité par la fonction HandleAnimEvent(). Cette dernière prend en paramètre un event (structure spéciale pour les events .mdl, pas les events mp->client attention!) et repose principalement sur un switch du numéro assigné à l'event. Ce numéro c'est le premier nombre de { event 8 24 } (donc ici : 8). Le second nombre est la frame à laquelle HandleAnimEvent( 8 ) devra être appelé.

La fonction se présente donc ainsi :

#define    HGRUNT_AE_GREN_LAUNCH  ( 8 )

// ...

// ========================================================
// HandleAnimEvent - catches the monster-specific messages
// that occur when tagged animation frames are played.
// ========================================================

void CHGrunt::HandleAnimEvent( MonsterEvent_t *pEvent )
{
    Vector  vecShootDir;
    Vector  vecShootOrigin;

    switch( pEvent->event )
    {
        // ...

        // { event 8 24 } - pEvent->event = 8, frame #24
        case HGRUNT_AE_GREN_LAUNCH:
        {
            EMIT_SOUND( ENT(pev), CHAN_WEAPON, "weapons/glauncher.wav", 0.8, ATTN_NORM );
            CGrenade::ShootContact( pev, GetGunPosition(), m_vecTossVelocity );
            m_fThrowGrenade = false;

            if( g_iSkillLevel == SKILL_HARD )
                m_flNextGrenadeCheck = gpGlobals->time + RANDOM_FLOAT( 2, 5 );
            else
                m_flNextGrenadeCheck = gpGlobals->time + 6;
        }
        break;

        // ...

        default:
            CSquadMonster::HandleAnimEvent( pEvent );
            break;
    }
}

Ainsi grâce à ces events, vous pouvez gérer beaucoup plus facilement vos actions et vos effets! Les events par défaut ont réservé les 1000, 2000, 3000 ou 5000. Donc pour des events personnalisés, utilisez des petits nombre (en commençant à 0 par exemple).

Conclusion

Voilà on arrive à la fin de cette introduction sur l'IA d'Half-life. J'espère que vous y avez compris quelque chose et que ça vous servira :)

On peut résumer en quelque sorte le cycle de l'IA par ce schéma :

http://www.game-lab.com/images/tuts/hl1_npc_ia/monster_think.png