Attention : cet article a été écrit pour le SDK 2.2 d'Half-Life 1 ! Il se peut qu'il ne soit pas entièrement compatible avec des versions antérieures ou supérieures du Kit.

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 executée en boucle jusqu'à la mort de l'entitée, et va appeler CBaseMonster::RunAI().

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

Ces fonctions vont être responsables de l'activation ou désactivation des bits de conditions (bits_COND_XXX) 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é.

Les Tasks (une tâche, un travail en français) sont des actions spécifiques exécutées par le monstre comme recharger son arme, jouer une animation ou lancer une grenade.
Les Schedules (un programme) eux sont des listes de Task exécutés dans un ordre précis dans le but de créer des actions plus larges comme se déplacer à un endroit précis, faire un signe de la main puis tirer ou se mettre à l'abris pour recharger.

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 deux 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"
    },
  };

Si votre item est un objet stockable en quantité (comme par exemple des clefs...), vous pouvez utiliser la variable m_rgItems[] en accédant à l'objet avec l'identifiant de votre item et en l'incrémentant :

BOOL
MyTouch (CBasePlayer *pPlayer)
{
  pPlayer->m_rgItems[ITEM_MONITEM] += 1;
  return TRUE;
}

Notez que les deux 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 deux structures :

Task_t :

Dans notre exemple, il y a trois Tasks dans le tableau tlCombatStand1[]. Seule le second Task possède un paramètre (les autres ont zéro à 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 :

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[] : tlCombatStand1 est le nom du tableau de Task_t associé à slCombatStand1[]. ARRAYSIZE (tlCombatStand1) est une macros qui calcule le nombre d'élément du tableau passé en paramètres (le nombre de Tasks, quoi). Ici, c'est trois. Les 3ème et 4ème paramètres sont donc décris plus haut et représentent les conditions d'interruption pouvant être combinées 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_XXX à ne pas confondre avec les TASK_XXX. Les TASK_XXX et SCHED_XXX sont énumérés dans schedule.h. Nous verrons dans un autre tutorial comment ajouter ses TASK_XXX et SCHED_XXX 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() :

// --------------------------------------------------------------------------
// CBaseMonster::GetSchedule
// --------------------------------------------------------------------------

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

  if (condition)
    return GetScheduleOfType (SCHED_COMBAT_STAND);

  // ...
}

J'ai simplifié à fond la fonction pour comprendre le fonctionnement. En réalité, la condition est plus complexe que ça. C'est donc la 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) :

// --------------------------------------------------------------------------
// CBaseMonster::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 vaut 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_XXX sont énumérés dans schedule.h. Ensuite, les Tasks listées dans notre tableau Task_t sont exécutées. Il nous reste maintenant à voir comment.

Dans notre tableau Task_t, on a utilisé des constantes commençant par TASK_XXX. Toutes ces constantes sont énumérées dans schedule.h sous le nom de SHARED_TASKS. Ces TASK_XXX 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 de monstre.

// --------------------------------------------------------------------------
// CBasEMonster::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 Tasks 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() :

// --------------------------------------------------------------------------
// CBaseMonster::RunTast
// --------------------------------------------------------------------------

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

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

    // ...
    }
}

On ne retrouve seulement que TASK_WAIT_INDEFINITE, car les deux autres se terminent par l'appel de la fonction TaskComplete() dans StartTask(), qui prévient le jeu que le Task est terminée. Sans ça, le Task serait exécuté indéfiniment (comme pour TASK_WAIT_INDEFINITE). Il existe aussi la fonction 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(), elle ne sont exécutées qu'une seule fois. On pourrait rajouter ici par exemple, une variable de contrôle qui stop le Tasks si elle est vraie.

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é-executer un nouveau dès qu'un autre est terminé, d'executer les Tasks du Schedule en cours, etc... Jetez-y un coups d'oeil ;)

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_XXX et TASK_XXX. Comme je l'ai dis plus haut, les SCHED_XXX et TASK_XXX 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_XXX ou TASK_XXX !

À 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 utiles pour ajouter nos propre SCHED_XXX et TASK_XXX à la suite de l'enumération, sans écraser les anciens. À la fin du fichier de votre monstre .cpp, 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_XXX et TASK_XXX. 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égats, 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 supposant 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 :

class CMyMonster : public CBaseMonster
{
  // ...

public:
  // IA spécifique
  Schedule_t *GetSchedule ();
  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 Schedule personnalisés. On surcharge également les fonctions GetSchedule(), GetScheduleOfType(), StartTask() et RunTask(). Maintenant retournez à la fin du fichier pour y ajouter les quatre définitions de fonction :

// --------------------------------------------------------------------------
// CMyMonster::GetSchedule
// --------------------------------------------------------------------------

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

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

// --------------------------------------------------------------------------
// CMyMonster::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 ne les oubliez pas. Et enfin StartTask() et à RunTask(), simplement comme expliqué plus haut :

// --------------------------------------------------------------------------
// CMyMonster::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;
      }
    }
}

// --------------------------------------------------------------------------
// CMyMonster::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 scripts faisant exécuter certaines tâches spécifiques, comme jouer un son, afficher un muzzleflash ou jouer une séquence (grace aux ACT_XXX). Et il est en effet possible de faire exécuter des bouts de code à partir des events du modèle.

Prenez le fichier .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 des fichiers .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 ainsi :

#define HGRUNT_AE_GREN_LAUNCH  8

// ...

// --------------------------------------------------------------------------
// CHGrunt::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 action 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 :

Schema MonsterThink()
Creative Commons Logo Contrat Creative Commons

Cet article est mis à disposition sous un contrat Creative Commons (licence CC-BY-ND).