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.
|