Écrit par David Henry, le 27 juillet 2002
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.
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.
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) :
Look()
).Listen()
).GetEnemy()
).CheckAmmo()
).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...
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 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 :
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[]
. 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.
*pTasklist
est la liste des Tasks du Schedule. On lui associe généralement
un tableau de type 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[]
:
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 ;)
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; } } }
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).
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 :
Cet article est mis à disposition sous un contrat Creative Commons (licence CC-BY-ND).