TUTORIAL 17 - Scoreboard
fragrate by HypoThermia
Fed up of joining a game part the way through and being unable to
win it? Want to show that you're up there with the best... or that
you could have won that match?
Lets take a look at the scoreboard and see how we can get it
showing your frags per minute. Along the way we'll find out about
timing and a little on how the server keeps all the clients updated.
1. Working out the fragrate
Start by having a look at cg_scoreboard.c in the
cgame directory. It contains all the code used to draw the
scoreboard (surprise!), and compared to other source files is quite
straightforward to understand. After all we have a very clear
picture of how a scoreboard works - don't we?
The first function that we see is CG_DrawClientScore. This
simply draws a line of information that the client knows about the
player. Things like the model picture, frags, ping, time connected,
and name. It's made a little more complicated because version 1.15
introduced a second, smaller, scoreboard for a larger number of
players.
We have the information we need here to construct a frag rate per
minute: the number of frags and the time connected
(score->time). Unfortunately this is in minutes: it
doesn't change quickly enough to be useful. Let's track back to
where this information is set and see what we can do.
CG_DrawClientScore is called from
CG_TeamScoreboard, and we can see that players are grouped
into "teams": TEAM_RED, TEAM_BLUE, TEAM_FREE and TEAM_SPECTATOR. It
also appears that the list is already sorted for us, and there's
nothing in cg_scoreboard.c doing that for us. The stats are
all stored in the array cg.scores[] so we need to find where
this is modified.
Found it yet? A search shows that it's only modified in
cg_servercmds.c, in response to the server sending out a
scores command. All commands sent by the server are processed
in this file: so all the sorting and ordering must be done in the
server. This makes sense: the server carries the master records and
arbitrates the game.
Moving over to the code in the game directory we see that
the scores command is sent out by
DeathmatchScoreboardMessage() in g_cmds.c. This file
handles commands sent to the server by the client (one of which is a
request to update the scores stored by the client). This information
is only sent out if there's a scoreboard to update. Com_sprintf (entry, sizeof(entry),
" %i %i %i %i %i %i", level.sortedClients[i],
cl->ps.persistant[PERS_SCORE], ping, (level.time - cl->pers.enterTime)/60000,
scoreFlags, g_entities[level.sortedClients[i]].s.powerups);
The scores are sent out as a long string of numbers. We can see
that the 4th number in each group of six is the time connected to
the server in minutes. The "divide by 60000" occurs because the
standard unit of time in Quake3 is the millisecond - the conversion
to minutes is made at this point.
2. The changes we need to make
We could adjust DeathmatchScoreboardMessage() to send out
a calculated frag rate as well, but there's a more efficient way.
Since the number of frags and the time connected are already sent,
we'll just adjust these instead. If we change the time connected to
seconds we can then get a fragrate that updates with an accuracy
every second (at best).
We'll store the fragrate in the client as an integer: the actual
fragrate per minute multiplied up by 100. I've also imposed an
(arbitrary) minimum 10 seconds for caclulating the fragrate. This
keeps any "logon and frag" luck to a minimum.
The scoreboard will have to be re-sorted by fragrate: done in the
client because this is a client preference. This preference will be
stored in a system variable.
We're almost there. One more issue we need to concern ourselves
with: the scoreboard can be updated during the intermission (when
someone disconnects for example), so we don't want our time
information to keep on changing during this period.
After a little digging around we find that there's a variable
already set up to help us: level.intermissiontime. It's set
to zero when the server is playing a level, and marks the time (in
milliseconds) at which the intermission started. It helps the server
time the duration of the intermission - and we can make use of it
too.
Let's get our hands dirty.
3. Coding the changes
First of all we'll make the only server modification that's
required (g_cmds.c) so the connect time is sent in seconds
(remembering that the unit of time is the millisecond in Quake3).
The use of level.intermissiontime prevents our fragrate times
from ticking down while we're in the intermission. /*
==================
DeathmatchScoreboardMessage
==================
*/
void DeathmatchScoreboardMessage( gentity_t *ent ) {
char entry[1024];
char string[1400];
int stringlength;
int i, j;
gclient_t *cl;
int numSorted;
int scoreFlags;
int playtime;
// send the latest information on all clients
string[0] = 0;
stringlength = 0;
scoreFlags = 0;
// don't send more than 32 scores (FIXME?)
numSorted = level.numConnectedClients;
if ( numSorted > 32 ) {
numSorted = 32;
}
for (i=0 ; i < numSorted ; i++) {
int ping;
cl = &level.clients[level.sortedClients[i]];
if ( cl->pers.connected == CON_CONNECTING ) {
ping = -1;
} else {
ping = cl->ps.ping < 999 ? cl->ps.ping : 999;
}
// HypoThermia: get the correct time (rate shouldn't change
// during intermission)
playtime = level.time;
if (level.intermissiontime)
playtime = level.intermissiontime;
// Hypothermia: send over time in seconds instead of minutes
Com_sprintf (entry, sizeof(entry),
" %i %i %i %i %i %i", level.sortedClients[i],
cl->ps.persistant[PERS_SCORE], ping, (playtime - cl->pers.enterTime)/1000,
scoreFlags, g_entities[level.sortedClients[i]].s.powerups);
j = strlen(entry);
if (stringlength + j > 1024)
break;
strcpy (string + stringlength, entry);
stringlength += j;
}
trap_SendServerCommand( ent-g_entities, va("scores %i %i %i%s", i,
level.teamScores[TEAM_RED], level.teamScores[TEAM_BLUE],
string ) );
}
From now on it's client side stuff. We need a new variable in the
score_t struct to store the fragrate (cg_local.h):
typedef struct {
int client;
int score;
int ping;
int time;
int scoreFlags;
int fragrate;
} score_t;
Next we're updating cg_servercmds.c so it handles the
changed connect time. This is also a good place to calculate the
fragrate. Remember that the time is now in seconds and that fragrate
stores 100 times the number of frags per minute.
This is in CG_ParseScores(): cg.scores[i].time = atoi( CG_Argv( i * 6 + 7 ) );
cg.scores[i].scoreFlags = atoi( CG_Argv( i * 6 + 8 ) );
powerups = atoi( CG_Argv( i * 6 + 9 ) );
// HypoThermia: fragrate based on minimum of 10 seconds
if (cg.scores[i].time < 10)
cg.scores[i].fragrate = 600 * cg.scores[i].score;
else
cg.scores[i].fragrate = (6000 * cg.scores[i].score) / cg.scores[i].time;
// HypoThermia: correct time value back to minutes
cg.scores[i].time /= 60;
if ( cg.scores[i].client < 0 || cg.scores[i].client >= MAX_CLIENTS ) {
cg.scores[i].client = 0;
}
With all the information in place we now need to adapt the
scoreboard so it'll display the fragrate. First of all, though,
we'll add a system variable that stores the type of scoreboard we've
chosen to display: Back in cg_local.h we add a reference to a
global variable: extern vmCvar_t cg_blood;
extern vmCvar_t cg_predictItems;
extern vmCvar_t cg_deferPlayers;
extern vmCvar_t cg_fragRateScoreboard;
and the static declaration of this variable into
cg_main.c: vmCvar_t cg_deferPlayers;
vmCvar_t cg_drawTeamOverlay;
vmCvar_t cg_teamOverlayUserinfo;
vmCvar_t cg_fragRateScoreboard;
and finally link it into the list of client commands (still in
cg_main.c). Notice that we don't need to do any more: we
automatically get TAB completion in the console, and the variable is
saved from session to session by using the flag CVAR_ARCHIVE. { &cg_drawTeamOverlay, "cg_drawTeamOverlay", "0", CVAR_ARCHIVE },
{ &cg_teamOverlayUserinfo, "teamoverlay", "0", CVAR_ROM | CVAR_USERINFO },
{ &cg_stats, "cg_stats", "0", 0 },
{ &cg_fragRateScoreboard, "cg_fragRateScoreboard", "0", CVAR_ARCHIVE },
This variable has a default value of 0. When set to 1 the
fragrate will be displayed in the scoreboard.
Finally we get to make the changes to the scoreboard display. We
need to convert the fragrate back into a "floating point" display
format, and to sort the scoreboard into the correct order.
First the display of the scoreboard. Notice that the changes to
the "connecting" and "SPECT" formating strings realign the names
properly. We also have to make sure we don't overstep the formatting
limits, and handle the negative numbers properly. We also have to
handle the case where the decimal part is less that ten, otherwise
we'd get things like 5.9 instead of 5.09.
I don't think anyone is going to reach 99.99 frags per minute so
we cap it there, nor are they going to get a suicide rate below
about 10 per minute.
These changes are in cg_scoreboard.c in
CG_DrawClientScore(). // draw the score line
if ( score->ping == -1 ) {
Com_sprintf(string, sizeof(string),
" connecting %s", ci->name);
} else if ( ci->team == TEAM_SPECTATOR ) {
Com_sprintf(string, sizeof(string),
" SPECT%4i %4i %s", score->ping, score->time, ci->name);
} else if (cg_fragRateScoreboard.integer) {
// HypoThermia: display fractional fragrate
int whole,frac;
char* fmt; // format string used
if (score->fragrate < 0)
{
frac = ( -score->fragrate) % 100;
whole = -( -score->fragrate - frac) / 100;
}
else if (score->fragrate < 9999)
{
frac = score->fragrate % 100;
whole = score->fragrate / 100;
}
else
{
whole = 99;
frac = 99;
}
if (frac < 10)
fmt = "%2i.0%1i %4i %4i %s";
else
fmt = "%2i.%2i %4i %4i %s";
Com_sprintf(string, sizeof(string),
fmt, whole, frac, score->ping, score->time, ci->name);
}
else {
Com_sprintf(string, sizeof(string),
"%5i %4i %4i %s", score->score, score->ping, score->time, ci->name);
}
and a small modification to the highlight showing your
score so it doesn't leave a text overhang on the left: } else {
hcolor[0] = 0.7;
hcolor[1] = 0.7;
hcolor[2] = 0.7;
}
hcolor[3] = fade * 0.7;
CG_FillRect( SB_SCORELINE_X, y,
640 - SB_SCORELINE_X - BIGCHAR_WIDTH, BIGCHAR_HEIGHT+1, hcolor );
Last of all we need to sort the scores. As the results are
already sorted by total frags, we need to re-sort it for fragrate.
I've only done this for standard deathmatch (TEAM_FREE), it doesn't
really make much sense for other game types. The changes go into
CG_TeamScoreboard() as this constructs each type of team for
the scoreboard.
Instead of checking each item on the list and displaying only the
correct ones (as the original CG_TeamScoreboard() did), we
construct a list of items to be displayed. This list is sorted if
needed, and then displayed. The variable count has been
removed, it returns the number of lines drawn, and has been
superceded by indexcount.
Although there is a sort being performed each frame the
scoreboard is drawn, it's a quick one (rarely more than 16 items),
and only moves pointers around in an array. /*
=================
CG_TeamScoreboard
=================
*/
static int CG_TeamScoreboard( int y, team_t team, float fade, int maxClients, int lineHeight ) {
int i, j;
score_t *score;
float color[4];
clientInfo_t *ci;
score_t * scorelist[MAX_CLIENTS];
int indexcount;
color[0] = color[1] = color[2] = 1.0;
color[3] = fade;
// HypoThermia: build an indexed array into given team type
indexcount = 0;
for ( i = 0; i < cg.numScores && indexcount < maxClients; i++ )
{
score = &cg.scores[i];
if ( team != cgs.clientinfo[ score->client ].team )
continue;
scorelist[indexcount] = score;
indexcount++;
}
// HypoThermia: sort the score by frag rate for FREE players only
// use a quick and dirty sort because we're moving pointers around
if ( team == TEAM_FREE && cg_fragRateScoreboard.integer)
{
for ( i = 0; i < indexcount - 1; i++ )
for ( j = i + 1; j < indexcount; j++ )
if (scorelist[j]->fragrate > scorelist[i]->fragrate)
{
score_t *t;
t = scorelist[i];
scorelist[i] = scorelist[j];
scorelist[j] = t;
}
}
for ( i = 0 ; i < indexcount ; i++ ) {
CG_DrawClientScore( y + lineHeight * i, scorelist[i], color, fade, lineHeight == SB_NORMAL_HEIGHT );
}
return indexcount;
}
That's it! Compile the changes and try out the new scoreboard.
4. Following on
There are a number of changes that you might want to make.
Displaying the fragrate and total number of frags is easily done,
but the original scoreboard is too wide to do this. You'll have to
drop back to permanently using the scoreboard for large numbers of
players because of its smaller font.
Of more interest is a game that relies only on fragrate to
determine the winner. This requires more server side modifications,
and the trick of storing 100 times the actual fragrate in an integer
may be useful.
Finally you might want a fragrate on your HUD. This is a little
more involved as this tutorial uses information that is only sent
when the scoreboard is displayed.
|