Monsters Programming - Schedules and Tasks Last edited 1 month ago2024-11-05 16:38:09 UTC

Half-Life Programming

In this page, we'll talk about schedules and tasks which are two "main" components of Half-Life's AI. If you want to program custom monsters with custom behavior in the future, that's something you should really pay close attention to.

Concepts of schedules and tasks

If you go back to looking at the code of CBaseMonster::RunAI(), you can see there is a call to CBaseMonster::MaintainSchedule(). If you look at this method, you will see things about schedules, tasks, completion and failure.

A task is a set of specific actions executed by the monster like playing a sequence/animation, reload, throw a grenade and so on. A schedule is a list (array) of tasks executed by the monster to achieve a bigger goal such as moving to a precise location, wave a hand, shoot and take cover to reload...

The Task_t structure in the code represent a task, here is its definition:
struct Task_t
{
    int iTask;    // The ID of this task (TASK_WAIT for example)
    float flData; // Additional data that this task may use (time to wait for TASK_WAIT for example)
};
Same thing with Schedule_t for a schedule:
struct Schedule_t
{
    Task_t* pTasklist;  // Array of tasks for this schedule
    int cTasks;         // The number of tasks (always use "ARRAYSIZE" of the value you use in "pTasklist" to be safe)
    int iInterruptMask; // Condition bit(s) allowed to interrupt this schedule if needed
    int iSoundMask;     // Sound bit(s) allowed to interrupt this schedule if needed
    const char *pName;  // Name of the schedule shown in the console when testing/debugging
};
Let us take a deeper look at how declared and defined a task and a schedule are by looking at how a monster make a small flinch:
Task_t tlSmallFlinch[] =
{
    { TASK_REMEMBER,     (float)bits_MEMORY_FLINCHED }, // Remember in my memory that I flinched
    { TASK_STOP_MOVING,  0                           }, // Stop moving
    { TASK_SMALL_FLINCH, 0                           }, // Make a small flinch
};

Schedule_t slSmallFlinch[] =
{
    {
        tlSmallFlinch,            // This schedule use the "tlSmallFlinch" tasks array
        ARRAYSIZE(tlSmallFlinch), // "ARRAYSIZE" will return 3 here and tell this schedule has 3 tasks
        0,                        // No condition bit can interrupt this schedule
        0,                        // No sound bit can interrupt this schedule
        "Small Flinch"            // This schedule is named "Small Flinch"
    },
};
An array of tasks just for a single one?
One thing that you might have noticed is that the schedules are always defined as arrays even if there is only one task in there.

There are two exceptions to this rule: one is in the Houndeye's code (a "disabled" feature to be precise) and the other is in the "common talkative monsters" (Barney, scientist) code. If you are curious, see if you can find them.
How the monster is aware that it should make a small flinch? This is where Schedule_t* CBaseMonster::GetSchedule() intervenes:
Schedule_t* CBaseMonster::GetSchedule()
{
    switch (m_MonsterState)
    {

    // [...]

    case MONSTERSTATE_COMBAT:
        {
            // [...]

            else if (HasConditions(bits_COND_LIGHT_DAMAGE) && !HasMemory(bits_MEMORY_FLINCHED))
            {
                return GetScheduleOfType(SCHED_SMALL_FLINCH);
            }

            // [...]

            break;
        }
        default:
        {
            ALERT(at_aiconsole, "Invalid State for GetSchedule!\n");
            break;
        }
    }

    return &slError[0];
}
In the above example, if the monster is in combat, has the "took light damage" condition bit enabled and does not remember in it's memory "having already flinched", then he will try to perform a schedule tied to SCHED_SMALL_FLINCH.

Now let's look at Schedule_t* CBaseMonster::GetScheduleOfType(int Type) to see how the schedule is actually executed:
Schedule_t* CBaseMonster::GetScheduleOfType(int Type)
{
    switch (Type)
    {

    // [...]

    case SCHED_SMALL_FLINCH:
        {
            return &slSmallFlinch[0];
        }
    default:
        {
            ALERT(at_console, "GetScheduleOfType()\nNo CASE for Schedule Type %d!\n", Type);

            return &slIdleStand[0];
            break;
        }
    }

    return nullptr;
}
You can notice that SCHED_ constants are used to identify schedules and tie them to a Schedule_t.

How about tasks? The CBaseMonster::StartTask(Task_t* pTask) method is called whenever a task is requested to be performed. Let us look at task startup for small flinch:
void CBaseMonster::StartTask(Task_t* pTask)
{
    switch (pTask->iTask)
    {

    // [...]

    case TASK_REMEMBER:
        {
            Remember((int)pTask->flData);
            TaskComplete();
            break;
        }

    // [...]

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

            RouteClear();
            TaskComplete();
            break;
        }

    // [...]

    case TASK_SMALL_FLINCH:
        {
            m_IdealActivity = GetSmallFlinchActivity();
            break;
        }

    // [...]

    default:
        {
            ALERT(at_aiconsole, "No StartTask entry for %d\n", (SHARED_TASKS)pTask->iTask);
            break;
        }
    }
}
There are two interesting things to note here: How does TASK_SMALL_FLINCH notifies the AI that it has finished? This is the job of CBaseMonster::RunTask(Task_t* pTask) which is called by CBaseMonster::MaintainSchedule() through CBaseMonter::RunAI(). A reminder that CBaseMonster::RunAI() is called every "thinking tick" until the monster dies.
void CBaseMonster::RunTask(Task_t* pTask)
{
    switch (pTask->iTask)
    {

    // [...]

    case TASK_SMALL_FLINCH:
        {
            if (m_fSequenceFinished)
            {
                TaskComplete();
            }
        }
        break;

    // [...]

    }
}
You can see here that the task will be marked as completed successfully when the sequence/animation has finished playing. We can also determine that the duration of the flinch is determined by the animation/sequence itself.

As you might have already guessed, there are scenarios where a monster cannot complete a task successfully as intended (a monster trying to follow a target but that same target is too far and/or in a place impossible to reach). In that case, the task is marked as failed through CBaseMonster::TaskFail().

There are handy methods about tasks available such as int CBaseMonster::TaskIsComplete() and int CBaseMonster::TaskIsRunning(). And you guessed it already, monsters can have their own tasks and schedules as well. You can find most of the common tasks in the defaultai.cpp file.

For the tasks that involves "cover": searches are made within a 764 units radius unless float CBaseMonster::CoverRadius() is overridden. There is also "normal cover" (bool CBaseMonster::FindCover(Vector vecThreat, Vector vecViewOffset, float flMinDist, float flMaxDist)) and "lateral cover" (bool CBaseMonster::FindLateralCover(const Vector& vecThreat, const Vector& vecViewOffset)), the difference between them is that the first uses the node graph (which we will study later) and the other try to find cover directly to the left or right of the monster.
Monster specific tasks, schedules and IDs
When defining new tasks that are specific to a monster, it is very important that the first task ID is LAST_COMMON_TASK + 1 if your monster is based on CBaseMonster or CSquadMonster. If you are using CTalkMonster, use LAST_TALKMONSTER_TASK + 1 instead.

If you do not do that, you will override the common tasks that are used by all monsters.

The same applies to schedules with LAST_COMMON_SCHEDULE + 1 or LAST_TALKMONSTER_SCHEDULE + 1.
One more thing, when being "restored" (loading a saved game for example), the monsters have their current schedule and task being "reset".

Schedules inner management

This section of this page will detail how schedules and by extension: tasks (as well as monster's state) are managed. While this part is not necessary to read and understand if you are programming "copies" of existing monsters, it still provide valuable information if you are going to program custom schedules and tasks or just for the sake of curiosity and education.

As mentioned earlier, tasks can be completed successfully or failed either instantly or after a period. To achieve this, Valve used a "state" approach where m_iTaskStatus can be one of the following:
#define TASKSTATUS_NEW              0 // Just started
#define TASKSTATUS_RUNNING          1 // Running task & movement
#define TASKSTATUS_RUNNING_MOVEMENT 2 // Just running movement
#define TASKSTATUS_RUNNING_TASK     3 // Just running task
#define TASKSTATUS_COMPLETE         4 // Completed, get next task
If you go back in the CBaseMonster::MaintainSchedule() method, we can see inside that loop with a scary "undone: this is just here so infinite loops are impossible" comment that if there is an actual schedule and the current task is complete, then a call to CBaseMonster::NextScheduledTask() advance to the next task (which increase an index and set m_iTaskStatus to TASKSTATUS_NEW) in the current schedule or mark the latter as "done" if there are no more tasks.

Next, a schedule validation is performed. The validation can be summed up to "is there an active schedule" and "is that schedule not interrupted due to a task failure or because it is simply done". If you build the server binary in debug configuration, the schedule was interrupted due to a task failure and there is no "failed schedule" (m_failSchedule) set, then you will see sparks above the monster's head and the Schedule: <name> Failed console message.

A "failed schedule" basically defines what schedule should the monster perform if the current one failed. Most of the time, the default SCHED_FAILED is used but tasks can define a "custom" one through the usage of TASK_SET_FAIL_SCHEDULE. Note that some monsters don't use this task and will "override" case SCHED_FAILED in Schedule_t* CBaseMonster::GetScheduleOfType(int Type) (the alien controller does that).

Assuming that validation is a failure or the monster's state (idle, alert, combat, barnacle'd, dying, script...) need to be changed. there is a call to MONSTERSTATE CBaseMonster::GetIdealState() if various conditions are met (enemy died during combat or schedule is interrupted by non-completion or schedule has completed and requested a state change). MONSTERSTATE CBaseMonster::GetIdealState() is responsible for checking various conditions bits and apply the right state as needed.

If the current task failed and there is no need to change the monster's state. Then the monster switches to either it's "failed schedule" (if defined) or the default one. Otherwise, the state is changed to the ideal one.

If there is a new task, then the state changes to "running" and call CBaseMonster::StartTask(). The activity is changed to the ideal one if needed and if the task is not complete and not a new one, we get out of that loop mentioned previously.

After the loop, if there is a "running" task, CBaseMonster::RunTask() is called. The ideal activity is set again for an edge case where two animations need to blend if CBaseMonster::RunTask() changes it.

Here's a diagram to recap everything said above:
User posted image

Comments

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