Creating a Roundtimer

From Valve Developer Community
Jump to navigation Jump to search
Broom icon.png
This article or section should be converted to third person to conform to wiki standards.

The Concept

The aim of this tutorial is to build a roundtimer (it could be any kind of timer, but the most obvious purpose is timing rounds) which is displayed on the client(s) and counts backward to zero every second from any given start-duration set on the server. This tutorial doesn't explain how to reset the roun on the end of the timer. This shouldn't be very hard, (take a look at MapEntity_ParseAllEntities in mapentities.cpp).

The communication is done via CNetworkVar It is recommended to read about Networking Entities in the Source SDK Documentation. There are 2 variables needed, one for the start time and one for the duration of the timer. It's also possible to use only one variable and store start time on the client whenever a new duration is set, but considering the timer isn't set very often, one can live with the wasted bandwith of transfering the starttime, too. It could also be an issue of accuracy, because if the timer is started without transfering the starttime, it may be out of sync with the server.

Since the roundtimer is a gamestate, it's best to put it into the gamerules. The HUD on the clientside will ask the gamerules how much time remains and shows the time accordingly.

Implementing the roundtimer-logic

Take a look at game_shared\sdk\sdk_gamerules.cpp:

BEGIN_NETWORK_TABLE_NOBASE( CSDKGameRules, DT_SDKGameRules )
END_NETWORK_TABLE()

LINK_ENTITY_TO_CLASS( sdk_gamerules, CSDKGameRulesProxy );
IMPLEMENT_NETWORKCLASS_ALIASED( SDKGameRulesProxy, DT_SDKGameRulesProxy )

#ifdef CLIENT_DLL
  void RecvProxy_SDKGameRules( const RecvProp *pProp, void **pOut, void *pData, int objectID )
  {
    CSDKGameRules *pRules = SDKGameRules();
    Assert( pRules );
    *pOut = pRules;
  }

  BEGIN_RECV_TABLE( CSDKGameRulesProxy, DT_SDKGameRulesProxy )
    RecvPropDataTable( "sdk_gamerules_data", 0, 0, &REFERENCE_RECV_TABLE( DT_SDKGameRules ),
        RecvProxy_SDKGameRules )
  END_RECV_TABLE()
#else
  void *SendProxy_SDKGameRules( const SendProp *pProp, const void *pStructBase,
      const void *pData, CSendProxyRecipients *pRecipients, int objectID )
  {
    CSDKGameRules *pRules = SDKGameRules();
    Assert( pRules );
    pRecipients->SetAllRecipients();
    return pRules;
  }

  BEGIN_SEND_TABLE( CSDKGameRulesProxy, DT_SDKGameRulesProxy )
    SendPropDataTable( "sdk_gamerules_data", 0, &REFERENCE_SEND_TABLE( DT_SDKGameRules ),
        SendProxy_SDKGameRules )
  END_SEND_TABLE()
#endif

The skeleton of the code is already there, but needs some additional code:

The two needed variables

  • m_fStartTime - The time at which the timer starts
  • m_iDuration - The duration of the timer in seconds

and declare them in the header file as members of CSDKGameRules:

  CNetworkVar( float, m_fStart);
  CNetworkVar( int, m_iDuration);

Put them in the part which is used by the server and client version of the class. That means neither in a #ifdef CLIENT_DLL-block nor in an corresponding #else-block.

Now the engine needs to know how to transmit the variables, so fill the skeleton:

BEGIN_NETWORK_TABLE_NOBASE( CSDKGameRules, DT_SDKGameRules )
#ifdef CLIENT_DLL
  RecvPropFloat( RECVINFO( m_fStart) ),
  RecvPropInt( RECVINFO( m_iDuration) ),
#else
  SendPropFloat( SENDINFO( m_fStart) ),
  SendPropInt( SENDINFO( m_iDuration) ),
#endif
END_NETWORK_TABLE()

Now we initialize m_fStart in the constructor CSDKGameRules():

m_fStart=-1; // <0 means no timer

But to get the communication work there's still more needed, the CSDKGameRulesProxy you saw above in the skeleton is not initiated. This should already be done according to the documentation, but it isn't:

CreateEntityByName( "sdk_gamerules" );

in the constructor does the trick (works because of LINK_ENTITY_TO_CLASS( sdk_gamerules, CSDKGameRulesProxy ); ).

The interface

Now we create 3 helper-functions, to make working with the timer easy.

The most important one returns the remaining time of the timer: GetRoundtimerRemain(). This is used by the HUD and therefore needs to be clientside, but it makes sense to have it also on the server, so put it into the shared part of the gamerules. If you didn't figure it out yet by the names of the variables, there's no actual timer which is counting down. There's only the starttime we calculate the elapsed time with. Subtracting from the duration gives the remaining time.

int CSDKGameRules::GetRoundtimerRemain() {
  return m_fStart<0 ? -1 : m_iDuration-int(gpGlobals->curtime-m_fStart);
}

To init the timer, we use this little fella, nothing special to say about him, only that it should be placed in the server-only-part of the gamerules:

void CSDKGameRules::StartRoundtimer(int iDuration) {
  m_iDuration=iDuration;
  m_fStart=gpGlobals->curtime;
}

The last function is a console-command, which allows us to test the timer easily, also put in the server-only-part:

                                                                         
CON_COMMAND(gs_roundtime, "set the roundtimer (in seconds)")
{
  // do nothing if there is not exactly one argument
  if ( engine->Cmd_Argc() != 2 )
    return;

  // get the gamerules
  CSDKGameRules *pRules = SDKGameRules();

  // no gamerules, no fun
  if (!pRules)
    return;
  // evaluate the argument to an integer and start the timer with it
  pRules->StartRoundtimer(atoi(engine->Cmd_Argv( 1 )));
}

The HUD

Fixing the CHudBaseTimer

There's a basic hud-element for displaying time delivered with the package, but it's...erm...well, it's almost working ;)

Look in hud_basetimer.cpp at the function PaintTime.

void CHudBaseTimer::PaintTime(HFont font, int xpos, int ypos, int mins, int secs)
{
  surface()->DrawSetTextFont(font);
  wchar_t unicode[6];
  swprintf(unicode, L"%d:%.2d", mins, secs);
  
  surface()->DrawSetTextPos(xpos, ypos);
  for (wchar_t *ch = unicode; *ch != 0; ch++)
  {
    surface()->DrawUnicodeChar(*ch);
  }
}

First I don't like the idea of setting minutes and seconds independently, so I decided to change this function, even though it may be overwritten by upcoming updates to the sdk. Instead of displaying minutes and seconds accordingly to the two variables, I calculate the total seconds first:

int iSeconds=mins*60+secs;

The second glitch is kind of funny (at least for me, because it took me some time to understand the problem). Instead of the colon in e.g. 13:49 it shows a weapon-icon. Of course the reason is the font, since the code isn't that complicated to be wrong ;) Take a look at halflife2.ttf (if you didn't extract it already from the gcf, now is the time...) Halflife2 ttf.gif

Oops, no colon at all. I won't edit fonts (lack of knowledge and motivation) and so I choose the nice litte standing man with the cross above his head as seperator. It's corresponding letter is 'M'.

  swprintf(unicode, L"%02dM%02d", iSeconds/60, iSeconds%60);

Defining the look'n'feel

The layout of all HUD-elements is defined in the file scripts/HudLayout.res (as with a lot of resources, if it's not there, you need to extract it from the cache).

Add the definition for the roundtimer:

HudRoundtimer
{
  "fieldName"   "HudRoundtimer"
  "xpos"  "0"
  "ypos"  "100"
  "wide"  "69"
  "tall"  "27"
  "visible" "0"
  "enabled" "0"
  "PaintBackgroundType" "2"
  "digit_xpos" "2"
  "digit_ypos" "-4"
}

The first 7 definitions (and last 2) are self-explanatory. "PaintBackgroundType" can be

  • 0 - rectangle
  • 1 - rectangle with "bitten" left top corner
  • 2 - rectangle with rounded corners


This would be enough to show a basic HUD-element, but let's be honest; we want to make it something special ;) Therefore open scripts/HudAnimations.txt and add the following events:

// initializes the timer, when it's restarted
event RoundtimerInit
{
  Animate HudRoundtimer FgColor    "255 255 0 127" Linear  0.0  0.0
  Animate HudRoundtimer Blur       "0"             Linear  0.0  0.0
  Animate HudRoundtimer Position   "0 100"         Linear  0.0  0.0
}
// change the textcolor to red
event RoundtimerBelow20
{
  Animate HudRoundtimer FgColor    "255 30 30 127" Linear  0.0  1.0
}
// moving down
event RoundtimerBelow5
{
  Animate HudRoundtimer Position    "0 200"        Linear  0.0  5.0
}
// pules one time
event RoundtimerPulse
{
  Animate HudRoundtimer Blur        "5"            Linear  0.0  0.1
  Animate HudRoundtimer Blur        "0"            Deaccel 0.1  0.5
}

Those are the 4 events we're using to improve the look of the timer, RoundtimerBelow5 is only there for illustration that even the position can be changed by events - don't try this at home!

A short explanation, of what's happening here: The syntax of Animate is

Animate <hudelement> <property to change> <goal-value for the change> <kind of transition> <starttime (every event starts at 0.0)> <duration>.

  • The init-event sets the textcolor, blur-effekt and position to the initial values in "no time".
  • RoundtimerBelow20 changes the textcolor to RGBA "255 30 30 127" in 1 second.
  • RoundtimerBelow5 moves the element 100 pixel down in 5 seconds. That means, it arrives when the timer reaches zero.
  • RoundtimerPulse starts a blur-effect and "deblurs" in 0.5 seconds

Making CHudRoundtimer

The roundtimer-class is basically a ripoff of CHudHealth, so copy the file cl_dll/sdk/sdk_hud_health.cpp to e.g. cl_dll/sdk/sdk_hud_roundtimer.cpp and add it to the project. Instead of CHudNumericDisplay inherit from the fixed CHudBaseTimer (changing the include, class-definition, constructor, blah...) and most important set the name in the CHudBaseTimer-constructor to "HudRoundtimer", otherwise there'd be no connection to the layout and events defined before.

We need two variables:

  • float m_fStart; // The last transmitted start-time, to check, if the timer has restarted
  • int m_iRemain; // The remaining time at the last visual update, to optimize updates

All there's left to do is one function, which does it all (and should be commented enough):

void CHudRoundtimer::OnThink()
{
  CSDKGameRules *pRules = SDKGameRules();
  if (!pRules)
    return;
  int iRemain=pRules->GetRoundtimerRemain();

  // There was no timer and still is no timer or there's no new time to display.
  if (( iRemain<0 && m_iRemain<0) || ( iRemain == m_iRemain))
    return;

  // If we're here, there's was a timer before, but if's it's not there anymore, we need to hide it.
  if (iRemain<0) {
    SetPaintEnabled(false);
    SetPaintBackgroundEnabled(false);
    m_iRemain=-1;
    return;
  }

  // There was no timer before or the timer has been restarted.
  if ((m_fStart!=pRules->m_fStart) || m_iRemain<0) {
    // start the init-event to reset the changing properties
    g_pClientMode->GetViewportAnimationController()->StartAnimationSequence("RoundtimerInit");
    SetPaintEnabled(true);
    SetPaintBackgroundEnabled(true);
    // save starttime to detect a timer-restart
    m_fStart=pRules->m_fStart;
  }

  // When the timer reaches 20, change the color to red.
  if (m_iRemain==20)
    g_pClientMode->GetViewportAnimationController()->StartAnimationSequence("RoundtimerBelow20");
  else
  // For every time below 10 make it pulse.
  if (iRemain<10)
    g_pClientMode->GetViewportAnimationController()->StartAnimationSequence("RoundtimerPulse");

  // Move it down for the last 5 seconds. 
  if (iRemain==5)
    g_pClientMode->GetViewportAnimationController()->StartAnimationSequence("RoundtimerBelow5");
  m_iRemain=iRemain;
  SetSeconds(m_iRemain);
}

I tried the pulsing with an event-loop (like in HealthLoop), but it didn't run synchron for 10 seconds.

You can test the timer with "gs_roundtime 25" (e.g. bind x "gs_roundtime 25"). If you play with the events and layout, I recommend to bind "hud_reloadscheme", then you won't need to restart the game everytime you change something.