The first goal we want to achieve is getting the player into the game correctly. A single player game normally would have only one starting point, which is defined in the map by an info_player_deathmatch or info_player_start entity. The player should enter the game at this point when starting the first level, when changing levels or when respawning after dying.
As descibed in the Interfacing to the UI section, we use the spmap console command to load a map and start the server. The code changes we must make are designed to place the player at the correct starting point with the proper health, weapons, ammo and items. In this tutorial the player will be starting a new game with full health but no weapons, ammo or items. When respawning at the start point after death the player will have the health, weapons, ammo and items he had when he originally started the level. When changing levels the player will retain everything he had at the end of the previous level.
Conditional Defines
In order not to butcher the source code we will be using conditional defines to add and remove code to create the single player game. The define we will use will be named SINGLEPLAYER and will be defined in the q_shared.h file. It can also be defined in the project file if so desired.
In q_shared.h about line 32 add :
#define SINGLEPLAYER
Saving and Loading Persistant Attributes
We must save the player's health, weapons, ammo and items whenever he finishes a level so they can be reloaded when starting (or restarting) the next level. Because the server resets all variable data when loading a map this information must be saved to a file which is reloaded when we need the data again. The changes for saving and loading this persistant data are :
In g_local.h about line 715 add :
#define FOFS(x) ((int)&(((gentity_t *)0)->x))
#ifdef SINGLEPLAYER
//
// persistant data load/save
//
#define CFOFS(x) ((int)&(((gclient_t *)0)->x))
qboolean G_SavePersistant(char *nextmap);
void G_LoadPersistant(void);
#endif
In g_client.c at the end of the file add :
#ifdef SINGLEPLAYER
//
// persistant data is carried across level changes
//
typedef struct {
int ofs;
int len;
} persField_t;
static persField_t gentityPersFields[] = {
{FOFS(health), sizeof(int)},
{0, 0}
};
static persField_t gclientPersFields[] = {
{CFOFS(ps.weapon), sizeof(int)},
{CFOFS(ps.ammo[0]), sizeof(int) * MAX_WEAPONS},
{CFOFS(ps.persistant[0]), sizeof(int) * MAX_PERSISTANT},
{CFOFS(ps.stats[0]), sizeof(int) * MAX_STATS},
{CFOFS(ps.powerups[0]), sizeof(int) * MAX_POWERUPS},
{0, 0}
};
/*===============
PersWriteClient
===============*/
void PersWriteClient(fileHandle_t f, gclient_t *cl)
{
persField_t *field;
// save the fields
for (field=gclientPersFields ; field->len ; field++)
{ // write the block
trap_FS_Write ( (void *)((byte *)cl + field->ofs), field->len, f);
}
}
/*===============
PersWriteEntity
===============*/
void PersWriteEntity(fileHandle_t f, gentity_t *ent)
{
persField_t *field;
// save the fields
for (field=gentityPersFields ; field->len ; field++)
{ // write the block
trap_FS_Write ( (void *)((byte *)ent + field->ofs), field->len, f);
}
}
/*===============
G_SavePersistant
returns qtrue if successful
NOTE: only saves the local player's data
===============*/
qboolean G_SavePersistant(char *nextmap)
{
char filename[MAX_QPATH];
fileHandle_t f;
int persid;
// open the file
Com_sprintf( filename, MAX_QPATH, "save\\current.psw" );
if (trap_FS_FOpenFile( filename, &f, FS_WRITE ) < 0) {
G_Error( "G_SavePersistant: cannot open '%s' for saving\n", filename );
}
// write the mapname
if(nextmap==NULL)
trap_FS_Write ("NULLMAP", MAX_QPATH, f);
else
trap_FS_Write (nextmap, MAX_QPATH, f);
// save out the pers id
persid = trap_Milliseconds() + (rand() & 0xffff);
trap_FS_Write (&persid, sizeof(persid), f);
trap_Cvar_Set("persid", va("%i", persid) );
// write out the entity structure
PersWriteEntity (f, &g_entities[0]);
// write out the client structure
PersWriteClient (f, &level.clients[0]);
trap_FS_FCloseFile( f );
return qtrue;
}
/*===============
PersReadClient
===============*/
void PersReadClient (fileHandle_t f, gclient_t *cl)
{
persField_t *field;
// read the fields
for (field=gclientPersFields ; field->len ; field++)
{ // read the block
trap_FS_Read ( (void *)((byte *)cl + field->ofs), field->len, f);
}
}
/*===============
PersReadEntity
===============*/
void PersReadEntity (fileHandle_t f, gentity_t *cl)
{
persField_t *field;
// read the fields
for (field=gentityPersFields ; field->len ; field++)
{ // read the block
trap_FS_Read ( (void *)((byte *)cl + field->ofs), field->len, f);
}
}
/*===============
G_LoadPersistant
===============*/
void G_LoadPersistant(void)
{
fileHandle_t f;
char *filename;
char mapstr[MAX_QPATH];
char map[MAX_QPATH];
int persid;
filename = "save\\current.psw";
// open the file
if (trap_FS_FOpenFile( filename, &f, FS_READ ) < 0) {
// not here, we shall assume they didn't want one
return;
}
// read the mapname, if it's not the same, then ignore the file
trap_FS_Read (mapstr, MAX_QPATH, f);
trap_Cvar_VariableStringBuffer( "mapname", map, sizeof(map) );
if (Q_stricmp( map, mapstr ) != 0) {
trap_FS_FCloseFile( f );
return;
}
// check the pers id
trap_FS_Read (&persid, sizeof(persid), f);
if (persid != trap_Cvar_VariableIntegerValue("persid")) {
trap_FS_FCloseFile( f );
return;
}
// read the entity structure
PersReadEntity (f, &g_entities[0]);
// read the client structure
PersReadClient (f, &level.clients[0]);
trap_FS_FCloseFile( f );
}
#endif
This gives us two functions, one to save the persistant data and the other to reload it. When saving the data, using the G_SavePersistant function, we must tell it the name of the level where this data is valid to reload. When changing levels this would be the name of the next level, since we will be reloading the data at the start of that level. The persid cvar is used to make sure the data is truely valid for the level we are loading it in.
Forcing the Single Player Game Type
Even though we set the g_gametype cvar to 2 in the UI to indicate to the server that this is a single player game, this cvar may be reset to something else when the server is restarted at level change or respawn after dying. To make sure this doesn't happen we need to force it to stay at the single player value all the time. The following change does that :
In g_main.c in function G_RegisterCvars about line 355 add :
if (remapped) {
G_RemapTeamShaders();
}
#ifdef SINGLEPLAYER
trap_Cvar_Set( "g_gametype", "2" );
#else
// check some things
if ( g_gametype.integer < 0 || g_gametype.integer >= GT_MAX_GAME_TYPE ) {
G_Printf( "g_gametype %i is out of range, defaulting to 0\n", g_gametype.integer );
trap_Cvar_Set( "g_gametype", "0" );
}
#endif
level.warmupModificationCount = g_warmup.modificationCount;
Joining the Game
We are now ready to have the player join the game. In regular Quake 3 the teleportation effect is shown when the player enters the game but we don't want that to happen in a single player game. He is also give certain weapons, which we also don't want, and his health is set to count down to the proper amount, again which we don't want. The following changes will fix these problems as well as allow the loading of any saved persistant attributes if the situation requires it.
In g_client.c in function respawn about line 499 add :
void respawn( gentity_t *ent ) {
#ifndef SINGLEPLAYER
gentity_t *tent;
#endif
CopyToBodyQue (ent);
ClientSpawn(ent);
#ifndef SINGLEPLAYER // remove effect when respawning
// add a teleportation effect
tent = G_TempEntity( ent->client->ps.origin, EV_PLAYER_TELEPORT_IN );
tent->s.clientNum = ent->s.clientNum;
#endif
}
In g_client.c in function ClientConnect about line 951 add :
G_ReadSessionData( client );
#ifndef SINGLEPLAYER // stop Bots from entering game
if( isBot ) {
ent->r.svFlags |= SVF_BOT;
ent->inuse = qtrue;
if( !G_BotConnect( clientNum, !firstTime ) ) {
return "BotConnectfailed";
}
}
#endif
// get and distribute relevent paramters
G_LogPrintf( "ClientConnect: %i\n", clientNum );
ClientUserinfoChanged( clientNum );
#ifndef SINGLEPLAYER // no connection messages
// don't do the "xxx connected" messages if they were caried over from previous level
if ( firstTime ) {
trap_SendServerCommand( -1, va("print \"%s" S_COLOR_WHITE " connected\n\"", client->pers.netname) );
}
if ( g_gametype.integer >= GT_TEAM &&
client->sess.sessionTeam != TEAM_SPECTATOR ) {
BroadcastTeamChange( client, -1 );
}
#endif
// count current clients and rank for scoreboard
CalculateRanks();
In g_client.c in function ClientBegin about line 996 add :
void ClientBegin( int clientNum ) {
gentity_t *ent;
gclient_t *client;
#ifndef SINGLEPLAYER
gentity_t *tent;
#endif
int flags;
In g_client.c in function ClientBegin about line 1030 add :
ClientSpawn( ent );
#ifndef SINGLEPLAYER // remove teleport effect and enter message
if ( client->sess.sessionTeam != TEAM_SPECTATOR ) {
// send event
tent = G_TempEntity( ent->client->ps.origin, EV_PLAYER_TELEPORT_IN );
tent->s.clientNum = ent->s.clientNum;
if ( g_gametype.integer != GT_TOURNAMENT ) {
trap_SendServerCommand( -1, va("print \"%s" S_COLOR_WHITE " entered the game\n\"", client->pers.netname) );
}
}
G_LogPrintf( "ClientBegin: %i\n", clientNum );
// count current clients and rank for scoreboard
CalculateRanks();
#endif
}
In g_client.c in function ClientSpawn about line 1070 add :
int eventSequence;
char userinfo[MAX_INFO_STRING];
#ifdef SINGLEPLAYER
int save_loading;
#endif
index = ent - g_entities;
client = ent->client;
In g_client.c in function ClientSpawn about line 1185 add :
client->ps.clientNum = index;
#ifndef SINGLEPLAYER // give player no weapons to start with
client->ps.stats[STAT_WEAPONS] = ( 1 << WP_MACHINEGUN );
if ( g_gametype.integer == GT_TEAM ) {
client->ps.ammo[WP_MACHINEGUN] = 50;
} else {
client->ps.ammo[WP_MACHINEGUN] = 100;
}
client->ps.stats[STAT_WEAPONS] |= ( 1 << WP_GAUNTLET );
client->ps.ammo[WP_GAUNTLET] = -1;
#else
client->ps.stats[STAT_WEAPONS] = 0;
#endif
client->ps.ammo[WP_GRAPPLING_HOOK] = -1;
// health will count down towards max_health
#ifndef SINGLEPLAYER // no health countdown
ent->health = client->ps.stats[STAT_HEALTH] = client->ps.stats[STAT_MAX_HEALTH] + 25;
#else
ent->health = client->ps.stats[STAT_HEALTH] = client->ps.stats[STAT_MAX_HEALTH];
#endif
G_SetOrigin( ent, spawn_origin );
In g_client.c in function ClientSpawn about line 1218 add :
G_KillBox( ent );
trap_LinkEntity (ent);
// force the base weapon up
#ifndef SINGLEPLAYER // no ready weapon at start
client->ps.weapon = WP_MACHINEGUN;
client->ps.weaponstate = WEAPON_READY;
#else
client->ps.weapon = WP_NONE;
client->ps.weaponstate = WEAPON_READY;
#endif
}
In g_client.c in function ClientSpawn about line 1248 add :
// select the highest weapon number available, after any
// spawn given items have fired
#ifndef SINGLEPLAYER // don't select any weapon after spawning
client->ps.weapon = 1;
for ( i = WP_NUM_WEAPONS - 1 ; i > 0 ; i-- ) {
if ( client->ps.stats[STAT_WEAPONS] & ( 1 << i ) ) {
client->ps.weapon = i;
break;
}
}
#endif
}
#ifdef SINGLEPLAYER // load persistant data after level change or respawn
//
// Load persistant data here
//
save_loading = trap_Cvar_VariableIntegerValue( "Save_Loading" );
if(save_loading == 2 || save_loading == 3) // level change or restart
{
G_LoadPersistant(); // get data
trap_Cvar_Set("Save_Loading","0"); // set to no loading
}
#endif
// run a client frame to drop exactly to the floor,
// initialize animations and other things
client->ps.commandTime = level.time - 100;
In g_client.c in function ClientDisconnect about line 1309 add :
// cleanup if we are kicking a bot that
// hasn't spawned yet
#ifndef SINGLEPLAYER // ignore bot stuff
G_RemoveQueuedBotBegin( clientNum );
#endif
ent = g_entities + clientNum;
In g_client.c in function ClientDisconnect about line 1364 add :
trap_SetConfigstring( CS_PLAYERS + clientNum, "");
CalculateRanks();
#ifndef SINGLEPLAYER // ignore bots
if ( ent->r.svFlags & SVF_BOT ) {
BotAIShutdownClient( clientNum, qfalse );
}
#endif
}
Fixing Up Some Other Areas
There are a few other areas that require some minor fixups in the source to handle a single player game. These changes are :
In g_active.c in function ClientThink_real about line 864 add :
memset (&pm, 0, sizeof(pm));
#ifndef SINGLEPLAYER // disable Gauntlet attack
// check for the hit-scan gauntlet, don't let the action
// go through as an attack unless it actually hits something
if ( client->ps.weapon == WP_GAUNTLET && !( ucmd->buttons & BUTTON_TALK ) &&
( ucmd->buttons & BUTTON_ATTACK ) && client->ps.weaponTime <= 0 ) {
pm.gauntletHit = CheckGauntletAttack( ent );
}
#endif
if ( ent->flags & FL_FORCE_GESTURE ) {
In g_active.c in function ClientThink_real about line 979 add :
VectorCopy( ent->client->ps.origin, ent->r.currentOrigin );
#ifndef SINGLEPLAYER // ignore Bots
//test for solid areas in the AAS file
BotTestAAS(ent->r.currentOrigin);
#endif
// touch other objects
ClientImpacts( ent, &pm );
In g_main.c in function G_RunFrame about line 1808 add :
#ifndef SINGLEPLAYER
// see if it is time to do a tournement restart
CheckTournament();
// see if it is time to end the level
CheckExitRules();
// update to team status?
CheckTeamStatus();
// cancel vote if timed out
CheckVote();
// check team votes
CheckTeamVote( TEAM_RED );
CheckTeamVote( TEAM_BLUE );
#endif
// for tracking changes
CheckCvars();