Entity Programming - Timer Entity Last edited 2 years ago2021-11-12 20:57:41 UTC

You are viewing an older revision of this wiki page. The current revision may be more detailed and up-to-date. Click here to see the current revision of this page.

Half-Life Programming

After you got introduced to basic entity programming on the last page, it's time to get your hands a little dirty.
On this page, you will learn how to write a custom entity which periodically triggers its targets. It will also serve as an example of some of the many caveats in Half-Life entity programming.

Planning

First, let's define the problem this entity is meant to solve. In order to trigger something every X seconds, a mapper would normally set up a number of entities, wasting precious edicts and CPU cycles. It could all be done with a single entity.

At minimum, this entity would have 2 keyvalues: targetname, so other entities could trigger it, target, so it can find an entity to trigger, and delay, to determine how often this entity will trigger its targets. The entity will start inactive (off), and will be toggled by other entities.

However, there is room for expansion: These questions will be answered later. First, let's implement the bare minimum for the entity.

Bare minimum for target_timer

Perhaps this is obvious, but the entity is a point entity, just like trigger_relay. We'll need to know this when writing the FGD.
We'll start by writing the entity class:
#include "extdll.h"
#include "util.h"
#include "cbase.h"

class CTargetTimer : public CBaseEntity
{
public:
    void Spawn() override;
    void KeyValue( KeyValueData* pkvd ) override;

    void Use( CBaseEntity* pActivator, CBaseEntity* pCaller, USE_TYPE useType, float value ) override;
    void EXPORT TimerThink();

    bool IsEnabled() const;

private:
    float m_flTimerDelay = 1.0f; // 1s of delay by default
};

LINK_ENTITY_TO_CLASS( target_timer, CTargetTimer );
For now, Spawn won't do much, only make sure that the entity starts off:
void CTargetTimer::Spawn()
{
    // Entity starts off by default
    SetThink( nullptr );
}
In KeyValue, we will try to parse the "delay" keyvalue. Keyvalues like "targetname" are handled automatically.
void CTargetTimer::KeyValue( KeyValueData* pkvd )
{
    // Keyvalue parsing works by comparing the current keyvalue's name
    // to the keyvalue name we are looking for, then we parse the value itself
    if ( FStrEq( pkvd->szKeyName, "delay" ) )
    {
        m_flTimerDelay = atof( pkvd->szValue );
        pkvd->fHandled = TRUE;
    }
    else
    {    // In case no matching keyvalues are found, we must pass it on
        // to the class we inherited from
        CBaseEntity::KeyValue( pkvd );
    }
}
Now that the boilerplate has been taken care of, you can start implementing the actual functionality.

Core functionality

When the timer is triggered by another entity, it will start ticking. If triggered again, it'll stop. This part of the logic should go into Use:
void CTargetTimer::Use( CBaseEntity* pActivator, CBaseEntity* pCaller, USE_TYPE useType, float value )
{
    if ( IsEnabled() )
    {
        SetThink( nullptr );
    }
    else
    {
        SetThink( &CTargetTimer::TimerThink );
    }

    pev->nextthink = gpGlobals->time + m_flTimerDelay;
}
Alternatively, you can shorten the SetThink calls to:
SetThink( IsEnabled() ? nullptr : &CTargetTimer::TimerThink );
Finally, you must define TimerThink and IsEnabled.
TimerThink is trivial, as it just triggers its targets and sets the next think time:
void CTargetTimer::TimerThink()
{
    SUB_UseTargets( this, USE_TOGGLE, 0.0f );

    pev->nextthink = gpGlobals->time + m_flTimerDelay;
}
SUB_UseTargets is used by all entities that can trigger other entities. It essentially loops through all entities in the map with the matching target name.

IsEnabled is even simpler:
bool CTargetTimer::IsEnabled() const
{
    // We could've used a BOOL variable to keep track of
    // the entity's status, but this is a more optimal way
    return m_pfnThink != nullptr;
}
Simply put, when the entity is inactive, m_pfnThink is nullptr. Otherwise, m_pfnThink points to TimerThink.

Don't forget to save-restore

Add these below KeyValue:
int Save( CSave& save );
int Restore( CRestore& restore );
static TYPEDESCRIPTION m_SaveData[];
And below LINK_ENTITY_TO_CLASS:
TYPEDESCRIPTION CTargetTimer::m_SaveData[] =
{
    DEFINE_FIELD( CTargetTimer, m_flTimerDelay, FIELD_FLOAT )
};

IMPLEMENT_SAVERESTORE( CTargetTimer, CBaseEntity );

Improvements

The current design has a few issues and lacking features. First, there is no "start on" flag, so the mapper will have to use trigger_auto or something else to make this timer tick from the start.

"Start on" spawnflag

This is quite simple to add. Normally in the HL SDK, spawnflags are declared like so:
#define SF_TIMER_START_ON 1
Alternatively, you can use constexpr, or static constexpr inside the class, but, to remain consistent with the SDK, we'll use a macro.
Spawnflags are stored in pev->spawnflags and it's trivial to check them:
if ( pev->spawnflags & SF_TIMER_START_ON )
With all that in mind, you will modify Spawn like so:
void CTargetTimer::Spawn()
{
    SetThink( nullptr );

    if ( pev->spawnflags & SF_TIMER_START_ON )
    {
        // This will simply turn on the entity, as
        // if it triggered itself
        Use( this, this, USE_TOGGLE, 0.0f );
    }
}

Activator business

Second, much more subtle and rare, there will be a crash in certain situations.

If a mapper decides to build a personal announcement system using game_text and target_timer, with the intent of displaying text to an individual player every 30 seconds, the game will crash. This is because of the way we're calling SUB_UseTargets. The first argument (this) is the activator:
SUB_UseTargets( this, USE_TOGGLE, 0.0f );
One way you can work around this issue is to "grab" the activator in Use, and store it somewhere. You will need to save-restore it as well:
// In the class
CBaseEntity* m_pActivator = nullptr;
...
// In Use
m_pActivator = pActivator;
...
// In the save-restore table
DEFINE_FIELD( CTargetTimer, m_pActivator, FIELD_CLASSPTR ),
Then modify TimerThink to use m_pActivator instead of this as the activator.

Random timing

It can be achieved in many different ways, but here are the two major ones: We will go with the latter, as it's simpler for the mapper to use. First, this will require a new keyvalue:
// In the class
float m_flRandomMax = 0.0f;
...
// In the save-restore table
DEFINE_FIELD( CTargetTimer, m_flRandomMax, FIELD_FLOAT ),
...
// In KeyValue
else if ( FStrEq( pkvd->szKeyName, "random" ) )
{
    m_flRandomMax = atof( pkvd->szValue );
    pkvd->fHandled = TRUE;
}
Finally, you will modify TimerThink to update pev->nextthink with this new variable taken into account:
pev->nextthink = gpGlobals->time + m_flTimerDelay + RANDOM_FLOAT( -m_flRandomMax, m_flRandomMax );

Warn the mapper

There are a couple of situations where you might want to warn the mapper: Let's take the first case into account, and remove the entity if it doesn't have a targetname. Modify Spawn like so:
void CTargetTimer::Spawn()
{
    SetThink( nullptr );

    if ( pev->spawnflags & SF_TIMER_START_ON )
    {
        // This will simply turn on the entity, as
        // if it triggered itself
        Use( this, this, USE_TOGGLE, 0.0f );
    }
    else if ( !pev->targetname )
    {
        ALERT( at_console, "target_timer with no name, deleting...\n" );
        UTIL_Remove( this );
    }
}
Notice how we're only checking for the missing targetname if the "start on" flag isn't set. If the "start on" flag is set, then the entity still does something, even without a targetname.

Writing the FGD

Now that we know all the different keyvalues our entity will need, we can start writing the FGD:
@PointClass base(Targetname, Target) = target_timer
[
    delay(string) : "Delay (in seconds)" : "1"
    random(string) : "Added random delay" : "0"
]
However, we're missing the spawnflags. Their syntax is a bit different:
spawnflags(Flags) =
[
    1 : "Spawnflag 1" : 0
    2 : "Spawnflag 2" : 1
    4 : "Spawnflag 3" : 1
    8 : "Spawnflag 4" : 0
]
According to this, spawnflags 2 and 3 (values 2 and 4) will be turned on by default. With this in mind, we'll add the "start on" spawnflag, so the final FGD entry will be:
@PointClass base(Targetname, Target) = target_timer
[
    delay(string) : "Delay (in seconds)" : "1"
    random(string) : "Added random delay" : "0"

    spawnflags(Flags) =
    [
        1 : "Start on" : 0
    ]
]
Also notice that we're using the Targetname and Target bases. They automatically add the respective keyvalues to the entry.

Conclusion

That's all for this one. Now you see how complex an otherwise simple entity can become, once you start taking into account certain edge cases and desired features.

Comments

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