Adding level transitions to multiplayer

From Valve Developer Community

Warning.png Warning: Level transitions don't work anymore with the new, 06 August 2006 SDK-code.

Normally, when changing to an other level, everything of the player is reset. Health, weapons, score. This is not the case in HL2SP where you take your health and weapons to the next level. For multiplayer this is sometimes interesting when you have a series of levels.

Table of contents

Theory

In single-player the level is changed when the player hits a trigger_changelevel. The same trigger is used in multiplayer. However SP has an extra entity, namely info_landmark. The engine saves information about every entity near the info_landmarkentity, and when the next level is loaded, it restores it. This built-in save restore function works good in single-player but has issues in multiplayer.

The problem is, the code assumes there's only one player. So if a player would reach the landmark, the level would change and there would be only one player close to the landmark. This is a gameplay-problem.

However, the code can be edited to only change the level when multiple people trigger (close to the landmark) and/or move any players who are outside the landmark to the landmark. This last solution may give problems, because there might not be enough space in the room when we teleport outside players to the landmark. This can be fixed by teleporting the player again, but this time to a spawn point.

The code

While we are changing the code anyway, why not add some new features?

multiplay_gamerules.cpp

Add the following after the includes:

ConVar	mp_transition_players_percent( "mp_transition_players_percent",
					  "50",
					  FCVAR_NOTIFY|FCVAR_REPLICATED,
					  "How many players in percent are needed for a level transition?" );
#ifndef CLIENT_DLL
ConVar sv_transitions( "sv_transitions", "1", FCVAR_NOTIFY|FCVAR_GAMEDLL, "Enable transitions" );
#endif

Two new ConVars are added providing more functionality. As seen from the first ConVar, it will let only multiple players trigger a level change.

triggers.cpp

Add multiplay_gamerules.h to the includes and look for the function CChangeLevel::ChangeLevelNow and replace the entire function with the following:

extern ConVar mp_transition_players_percent;
extern ConVar sv_transitions;
void CChangeLevel::ChangeLevelNow( CBaseEntity *pActivator )
{
	CBaseEntity	*pLandmark;
	levellist_t	levels[16];

	CBasePlayer *pPlayer = (pActivator && pActivator->IsPlayer()) ? ToBasePlayer( pActivator ) : NULL;
	if ( !pPlayer )
		return;

	pPlayer->m_bTransition = true;

	if ( mp_transition_players_percent.GetInt() > 0 )
	{
		int totalPlayers = 0;
		int transitionPlayers = 0;
		for( int i = 1; i <= gpGlobals->maxClients; i++)
		{
			CBasePlayer* pPlayer = UTIL_PlayerByIndex( i );
			if ( pPlayer && pPlayer->IsAlive() )
			{
				totalPlayers++;
				if ( pPlayer->m_bTransition )
					transitionPlayers++;
			}
		}

		if ( ( (int) (transitionPlayers / totalPlayers * 100) ) < mp_transition_players_percent.GetInt() )
		{
			Msg("Transitions: Not enough players to trigger level change\n");
			return;
		}
	}

	// This object will get removed in the call to engine->ChangeLevel, copy the params into "safe" memory
	Q_strncpy(st_szNextMap, m_szMapName, sizeof(st_szNextMap));

	if ( !sv_transitions.GetBool() )
		engine->ChangeLevel( st_szNextMap, NULL );

	// Some people are firing these multiple times in a frame, disable
	if ( m_bTouched )
		return;

	m_bTouched = true;

	int transitionState = InTransitionVolume(pPlayer, m_szLandmarkName);
	if ( transitionState == TRANSITION_VOLUME_SCREENED_OUT )
	{
		DevMsg( 2, "Player isn't in the transition volume %s, aborting\n", m_szLandmarkName );
		return;
	}

	// look for a landmark entity		
	pLandmark = FindLandmark( m_szLandmarkName );

	if ( !pLandmark )
		return;

	//LEVEL TRANSITIONS
	// no transition volumes, check PVS of landmark
	if ( transitionState == TRANSITION_VOLUME_NOT_FOUND )
	{
		byte pvs[MAX_MAP_CLUSTERS/8];
		int clusterIndex = engine->GetClusterForOrigin( pLandmark->GetAbsOrigin() );
		engine->GetPVSForCluster( clusterIndex, sizeof(pvs), pvs );

		// DM: Iterate through all players and find those not in the landmark
		for( int i = 1; i <= gpGlobals->maxClients; i++ )
		{
			CBasePlayer *pl = UTIL_PlayerByIndex( i );
			if ( pPlayer && pl && pl->entindex() != pPlayer->entindex() && pl->IsAlive() )
			{
				Vector vectorSurroundMins, vectorSurroundMaxs;
				pl->CollisionProp()->WorldSpaceSurroundingBounds( &vectorSurroundMins, &vectorSurroundMaxs );
				bool playerInPVS = engine->CheckBoxInPVS( vectorSurroundMins, vectorSurroundMaxs, pvs, sizeof( pvs ) );
			
				// DM: Oke, player is not in the PVS of landmark... teleport him to the landmark
				if ( !playerInPVS )
				{
					pl->JumptoPosition( pPlayer->GetAbsOrigin(), pPlayer->GetAbsAngles() );
					pl->m_bTransitionTeleported = true;
				}
			}
		}

		if ( pPlayer )
		{
			Vector vecSurroundMins, vecSurroundMaxs;
			pPlayer->CollisionProp()->WorldSpaceSurroundingBounds( &vecSurroundMins, &vecSurroundMaxs );
			bool playerInPVS = engine->CheckBoxInPVS( vecSurroundMins, vecSurroundMaxs, pvs, sizeof( pvs ) );

			if ( !playerInPVS )
			{
				Warning( "Player isn't in the landmark's (%s) PVS, aborting\n", m_szLandmarkName );
				return;
			}
		}
	}

	g_iDebuggingTransition = 0;
	st_szNextSpot[0] = 0;	// Init landmark to NULL
	Q_strncpy(st_szNextSpot, m_szLandmarkName,sizeof(st_szNextSpot));

	m_hActivator = pActivator;

	m_OnChangeLevel.FireOutput(pActivator, this);

	NotifyEntitiesOutOfTransition();


////	Msg( "Level touches %d levels\n", ChangeList( levels, 16 ) );
	if ( g_debug_transitions.GetInt() )
	{
		Msg( "CHANGE LEVEL: %s %s\n", st_szNextMap, st_szNextSpot );
	}

	// If we're debugging, don't actually change level
	if ( g_debug_transitions.GetInt() == 0 )
	{
		engine->ChangeLevel( st_szNextMap, st_szNextSpot );
	}
	else
	{
		// Build a change list so we can see what would be transitioning
		CSaveRestoreData *pSaveData = SaveInit( 0 );
		if ( pSaveData )
		{
			g_pGameSaveRestoreBlockSet->PreSave( pSaveData );
			pSaveData->levelInfo.connectionCount = BuildChangeList( pSaveData->levelInfo.levelList, MAX_LEVEL_CONNECTIONS );
			g_pGameSaveRestoreBlockSet->PostSave();
		}

		SetTouch( NULL );
	}
}

This code is pretty simple. It checks how many people have triggered the level change and how many have not. If the percent is the same as or more than mp_transition_players_percent, then it will execute the rest of the function.

Also, when sv_transitions is off, it will do a normal level change, like in multiplayer, without saving anything.

After that, it checks which players are outside the landmark. Dead players need to respawn anyway so why bother transitioning them. m_bTransitionTeleported is also set because the teleported player needs to be teleported again. All players outside the landmark are teleported to the one player who triggered the level change, and will be stuck at the same place.

player.h

The variables also need to be added to CBasePlayer under public.

	bool m_bTransition;
	bool m_bTransitionTeleported;

player.cpp

Add to the function CBasePlayer::CBasePlayer.

	m_bTransition = false;
	m_bTransitionTeleported = false;

Note, there is an important code change in BEGIN_DATADESC( CBasePlayer ). As you can see at the comment, every variable defined in the datadesc is saved and restored by the engine. The two new booleans need to be saved or else the player's spawn function is called, which results in resetting the player, even though the engine saved the equipment/health and other information about the player.

To do this, add:

	DEFINE_FIELD( m_bTransition, FIELD_BOOLEAN ),
	DEFINE_FIELD( m_bTransitionTeleported, FIELD_BOOLEAN ),

Also, comment out the following line:

DEFINE_FIELD( m_fLastPlayerTalkTime, FIELD_FLOAT ),

The engine uses m_fLastPlayerTalkTime to prevent players spamming the chat. However, when changing to another map, gpGlobals->curtime gets reset to 0 and is for a long time lesser then m_fLastPlayerTalkTime. If we don't save it, it gets reset and players are able to chat.

hl2mp_player.cpp

Find CHL2MP_Player::Spawn and add the following to the top:

if ( m_bTransition )
	{
		if ( m_bTransitionTeleported )
			g_pGameRules->GetPlayerSpawnSpot( this );

		m_bTransition = false;
		m_bTransitionTeleported = false;;

		return;
	}

Quite simple, if the player was teleported, move the player to the closest spawn point. There's also the return because the rest of the function will reset health, give new weapons, etc.

Gameplay issues

Please note that adding level transitions may also create issues with gameplay. Thanks to mp_transition_players_percent, it's possible to change the level only when multiple players trigger it. The level should be closed, you don't want players walking out of the map. Also, it should be clear that the end of the map is reached or else the player will look for other entrances and/or go back, which will cause the level to never change.



This page is also available in: French (français)