Creating a Roundtimer: Difference between revisions

From Valve Developer Community
Jump to navigation Jump to search
m (pov thingue)
mNo edit summary
Line 3: Line 3:
----
----


I'm trying to not making a simple copy&paste-tutorial, meaning I'm not explaining every line which needs to be touched.  
I'm trying not to making a simple copy&paste-tutorial, meaning I'm not explaining every line which needs to be touched.  
Nevertheless this should be straight forward if you know your C++.
Nevertheless this should be straight forward if you know your C++.
==The Concept==
==The Concept==

Revision as of 07:30, 2 March 2008

Broom icon.png
This article or section should be converted to third person to conform to wiki standards.

[originally from sourcewiki.org]


I'm trying not to making a simple copy&paste-tutorial, meaning I'm not explaining every line which needs to be touched. Nevertheless this should be straight forward if you know your C++.

The Concept

The goal is to build a roundtimer (actually 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. I'm not writing how to reset a round. This shouldn't be very hard, I guess it's basically deleting all entities and recreate them (maybe take a look at MapEntity_ParseAllEntities in mapentities.cpp).

The communication is done via CNetworkVar and I strongy recommend to read Networking Entities in the Source SDK Documentation. There are 2 variables needed, one for the starttime and one for the duration of the timer. It's also possible to use only one variable and store starttime 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

Let's 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

Looks like the skeleton is already there only waiting to be filled with life :)

I call the two needed variables

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

and define them (of course) in the header-file of CSDKGameRules:

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

Be sure to 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()

Finally let's initialize at least m_fStart in the constructor CSDKGameRules():

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

But to get the communication work there's still one thing missing. The CSDKGameRulesProxy you saw above in the skeleton is not initiated. I thought this would be done by some voodoo-macro-magic as written in the sdk-docs ("...Proxy functions are installed when declaring entity properties in SendTables or ReceiveTables..."), but it isn't. Putting

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 sence 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. Substracting 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 it's 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.