Adding an experience system

From Valve Developer Community
Jump to navigation Jump to search
English (en)中文 (zh)Translate (Translate)

Introduction

This tutorial will cover adding a basic experience (XP) system to your mod. It will assume you are modding HL2MP but can be easily modified to work with the "from scratch" or singleplayer SDK, by simply changing the player files used. Although it's quite long and involves editing many different files, each step is relatively simple and tries to be as clear as possible. This tutorial is aimed at beginner to moderately skilled modders.

Basic setup

We will start by adding some variables and defining the key functions for level and experience.

Server-side variables

Begin by adding two variables to the player class: one for current XP, and one for the current level.

Open hl2mp_player.h, and add these two lines to the end of private: section of the CHL2MPPlayer class, around line 163:

CNetworkVar( int, m_iExp );
CNetworkVar( int, m_iLevel );

These variables will need functions to control and use them. Define these in the public: section of the same class (CHL2MPPlayer), around line 138.

int GetXP() { return m_iExp; }
void AddXP( int add = 1)
{
	m_iExp += add;
	CheckLevel();
}

int GetLevel() { return m_iLevel; }
void CheckLevel();
void LevelUp();
 
void ResetXP()
{
	m_iExp = 0;
	m_iLevel = 1;
	LevelUp(); // Calling LevelUp() will reset max health, etc.
}

We will need to set default values, so open hl2mp_player.cpp, and look for this constructor:

CHL2MP_Player::CHL2MP_Player() : m_PlayerAnimState( this )

In this function, add:

	m_iExp = 0;
	m_iLevel = 1;
	LevelUp(); // Sets default values

We will create bodies for CheckLevel() and LevelUp() shortly. It seems likely that XP may affect things related to prediction, such as player movement speed, and we will also want to be able to display it on their screen, so we will need to be able to access the player's level on the client.

Client-side variables

Open up c_hl2mp_player.h, as we're going to put the variables and accessors in there also. In a private: section:

int m_iExp, m_iLevel;

And in a public: section:

int GetXP() { return m_iExp; }
int GetLevel() { return m_iLevel; }

We don't need to be able to change these variables from the client, as they will be updated by the server.

Linking client and server

In order to have the client m_iExp and m_iLevel keep the same values as they have on the server, we will need to add them to the player's network table. Near the top of hl2mp_player.cpp, you should find the send table:

IMPLEMENT_SERVERCLASS_ST( CHL2MP_Player, DT_HL2MP_Player )
	SendPropBlahBlah( ... )
	SendPropYaddaYadda( ... )
END_SEND_TABLE()

At the top of this send table (the line immediately after IMPLEMENT_SERVERCLASS_ST), add our own variables like so:

	SendPropInt( SENDINFO( m_iExp ) ),
	SendPropInt( SENDINFO( m_iLevel ) ),

Now we need to modify the client's receive table to read these.

Near the top of c_hl2mp_player.cpp, you'll find the matching receive table:

IMPLEMENT_CLIENTCLASS_DT( C_HL2MP_Player, DT_HL2MP_Player, CHL2MP_Player )

At the top of this, receive our variables like so:

RecvPropInt( RECVINFO( m_iExp ) ),
RecvPropInt( RECVINFO( m_iLevel ) ),

And that's them fully networked! Straightforward enough, right?

Leveling up

That won't yet fully compile, because we still have to add function bodies for CheckLevel() and LevelUp(). In order to do so, we will need a way to decide when a player is ready to level up. We're going to assume only 5 levels, for now, you can of course add many more, and adjust the XP requirements as you see fit. We'll also assume that the player starts out as level 1, and not 0!

Level XP limits

Somewhere in hl2mp_player.cpp, add the CheckLevel() function, and be sure to add all these defines in front of it:

const int XP_FOR_LEVELS[] = { 5, 12, 22, 35 };

// We have gained XP; decide if we should level up, and do it if needed
// Note: the XP limits here could be in an array, but that would be less clear
void CHL2MP_Player::CheckLevel()
{
	int CurrentLevel = GetLevel(); // Find the player's level

	if ( GetXP() >= XP_FOR_LEVELS[CurrentLevel - 1] ) // Check if the player's XP exceeds that which he needs to level up
	{
		m_iLevel ++; // Actually increment player's level
		LevelUp();   // and then adjust their settings (speed, health, damage) to reflect the change

		ClientPrint( this, HUD_PRINTTALK, UTIL_VarArgs( "You have reached level %i\n", GetLevel() ) ); // Write it on their screen

		UTIL_ClientPrintAll( HUD_PRINTCONSOLE, UTIL_VarArgs( "%s has reached level %i\n", GetPlayerName(), GetLevel() ) ); // Write it in everyone's console
	}
}

Note.pngNote:


As we currently have no level-related features (different health, speed, etc), LevelUp() will be empty for now. Add it underneath CheckLevel():

void CHL2MP_Player::LevelUp()
{
}

Giving XP for kills

This should be quite straightforward. Go to hl2mp_gamerules.cpp, and find the PlayerKilled( ... ) function. Add a little bit onto the end:

	BaseClass::PlayerKilled( pVictim, info );

	CBaseEntity *pInflictor = info.GetInflictor();
	CBaseEntity *pKiller = info.GetAttacker();
	CHL2MP_Player *pScorer = ToHL2MPPlayer( GetDeathScorer( pKiller, pInflictor ) );
	if ( pScorer && pKiller == pInflictor && pKiller != pVictim && PlayerRelationship( pKiller, pVictim ) == GR_NOTTEAMMATE )
		pScorer->AddXP( 1 );
#endif
}

Displaying the player's level

A player should be able to see at a glance what level they are. A VGUI HUD panel would be good for this! Create a new .cpp file in the client.dll project, called hud_level.cpp. Ideally (for neatness) it should go in the .../HL2MP/UI folder, but as long as it's in the client project, it should work just the same.

What we want here is just a panel that displays a number; the game includes several of these already, so we'll just copy and paste one, and change it where necessary. Open up hud_battery.cpp, select and copy the entire file contents. Paste it into your new hud_level.cpp, and then close hud_battery.cpp just so you don't change the wrong file.

In hud_level.cpp, have Visual Studio do a Find and Replace, changing all instances of CHudBattery to CHudLevel.

Now there are a few lines that need to be trimmed:

Remove lines 41 - 46:

void MsgFunc_Battery( bf_read &msg );
bool ShouldDraw();

private:
	int		m_iBat;	
	int		m_iNewBat;

And now remove the new line 44:

DECLARE_HUD_MESSAGE( CHudLevel, Battery );

On the line 48 constructor, change HudSuit to HudLevel, like so:

CHudLevel::CHudLevel( const char *pElementName ) : BaseClass( NULL, "HudLevel" ), CHudElement( pElementName )

Remove lines 58, 60 and 61 from Init(), leaving only the call to Reset():

	HOOK_HUD_MESSAGE( CHudLevel, Battery);
	Reset();
	m_iBat		= INIT_BAT;
	m_iNewBat   = 0;

Replace the Reset() function itself with this:

void CHudLevel::Reset( void )
{
	wchar_t *tempString = vgui::localize()->Find( "#Hud_Level" );

	if ( tempString )
	{
		SetLabelText( tempString );
	}
	else
	{
		SetLabelText( L"LEVEL" );
	}
}

Checking the string exists before displaying prevents a crash that previously had occurred when loading the main menu.


Note.pngNote:vgui::localize() may not be in your namespace. This is the case if you are using Orange Box code. To fix this problem use g_pVGuiLocalize instead.


Next, delete the entire ShouldDraw() function.

We're not going to bother with animating this panel, as that's beyond the scope of this tutorial, so delete all the contents of OnThink() and replace it with just:

void CHudLevel::OnThink( void )
{
	C_HL2MP_Player *pPlayer = C_HL2MP_Player::GetLocalHL2MPPlayer();
	if ( pPlayer )
		SetDisplayValue( pPlayer->GetLevel() );
}

Next, delete the MsgFunc_Battery function, and as a last change, add one line to the list of includes at the top of the file:

#include "c_hl2mp_player.h"

That's all the code done for our panel, but we need to define in the script files where and how it's displayed on the HUD.

Firstly, add the following keyvalue to resource/<YourModName>_english.txt:

"Hud_Level"		"Level"

That gives an easy way to set the panel text, based upon your mod user's language.

Note.pngNote:<YourModName>_english.txt does not come automatically with the mod source code. Either create this file yourself or alter a copy from HL2DM's VPK files.

Now open HudLayout.res, in the scripts folder. Add onto the end:

	HudLevel
	{
		"fieldName"		"HudLevel"
		"xpos"	"300"
		"ypos"	"432"
		"wide"	"96"
		"tall"  "36"
		"visible" "1"
		"enabled" "1"

		"PaintBackgroundType"	"2"
		
		"text_xpos" "8"
		"text_ypos" "20"
		"digit_xpos" "50"
		"digit_ypos" "2"
	}

You'll want to adjust those values to suit your own taste in HUD layout, trial and error is the way to go. And realize that all positions are based on a 640x480 screen resolution, even if that isn't the resolution you're playing at! See VGUI Documentation#Schemes for more information about this step.

Showing other players' level

It would be a nice feature to show a player's level when you move the mouse over them, so that instead of saying "Enemy: Winston" it will say "Enemy: Winston (level 2)"

Open <YourModName>_english.txt from your mod's resource folder, and replace the following three lines:

	"Playerid_sameteam"		"Friend: %s1 Health: %s2"
	"Playerid_diffteam"		"Enemy: %s1"
	"Playerid_noteam"		"%s1 Health:%s2"

With:

	"Playerid_sameteam"		"Friend: %s1 Health: %s2 (level %s3)"
	"Playerid_diffteam"		"Enemy: %s1 (level %s2)"
	"Playerid_noteam"		"%s1 Health:%s2 (level %s3)"

Now go to hl2mp_hud_target_id.cpp, which is the file that controls drawing other player's names when you look at them. At around line 142 you'll see these:

C_BasePlayer *pPlayer = static_cast<C_BasePlayer *>( cl_entitylist->GetEnt( iEntIndex ) );
C_BasePlayer *pLocalPlayer = C_BasePlayer::GetLocalPlayer();

Replace them with these

C_HL2MP_Player *pPlayer = ToHL2MPPlayer( cl_entitylist->GetEnt( iEntIndex ) );
C_HL2MP_Player *pLocalPlayer = C_HL2MP_Player::GetLocalHL2MPPlayer();

Add at line 148: (new line highlighted)

wchar_t wszPlayerName[ MAX_PLAYER_NAME_LENGTH ];
wchar_t wszHealthText[ 10 ];
wchar_t wszLevelText[ 10 ];
bool bShowHealth = false;
bool bShowPlayerName = false;

And then at line 177

			if ( bShowHealth )
			{
				_snwprintf( wszHealthText, ARRAYSIZE(wszHealthText) - 1, L"%.0f%%",  ((float)pPlayer->GetHealth() / (float)pPlayer->GetMaxHealth() ) );
				wszHealthText[ ARRAYSIZE(wszHealthText)-1 ] = '\0';
			}
			_snwprintf( wszLevelText, ARRAYSIZE(wszLevelText) - 1, L"%i",  pPlayer->GetLevel() );
			wszLevelText[ ARRAYSIZE(wszLevelText) - 1 ] = '\0';
	}

Now, from line 182, replace the entire if ( printFormatString ) statement with:

Note.pngNote: If you are using Orange Box code make sure to replace vgui::localize() with g_pVGuiLocalize just as before.
		if ( printFormatString )
		{
			if ( bShowPlayerName && bShowHealth )
			{
				vgui::localize()->ConstructString( sIDString, sizeof(sIDString), vgui::localize()->Find(printFormatString), 3, wszPlayerName, wszHealthText, wszLevelText );
			}
			else if ( bShowPlayerName )
			{
				vgui::localize()->ConstructString( sIDString, sizeof(sIDString), vgui::localize()->Find(printFormatString), 2, wszPlayerName, wszLevelText );
			}
			else if ( bShowHealth )
			{
				vgui::localize()->ConstructString( sIDString, sizeof(sIDString), vgui::localize()->Find(printFormatString), 2, wszHealthText, wszLevelText );
			}
			else
			{
				vgui::localize()->ConstructString( sIDString, sizeof(sIDString), vgui::localize()->Find(printFormatString), 1, wszLevelText );
			}
		}

Experience effects

Now, at last, the interesting part. Different things for different levels! Use these effects as a basis for developing your own, unique, level differences for your mod.

Health increase

Put the following into your LevelUp() function (in hl2mp_player.cpp):

	int currentLevel = GetLevel();
	SetHealthMax( 100 + ( ( currentLevel - 1 ) * 10) );
	m_iHealth = m_iMaxHealth = m_iHealthMax;

We will need to declare the SetHealthMax function (somewhere public: in hl2mp_player.h)

void SetHealthMax( int h ) { m_iHealthMax = h; }

This variable, m_iHealthMax, should be defined in a private: section in this file (still hl2mp_player.h), like so:

int m_iHealthMax;

You needn't worry about setting a default value, as the constructor's call to LevelUp() will take care of that.

Now to apply this health max each time they spawn, too. In hl2mp_player.cpp, find the CHL2MP_Player::Spawn function, and add onto the end:

m_iHealth = m_iMaxHealth = m_iHealthMax;

Yes, there's already a variable called m_iMaxHealth, and we're making one called m_iHealthMax rather than using that. Yes, that's quite poor practice, but it's by far the simplest way!

Speed increase

We'll do this one a bit differently. The player's maximum allowed speed is changed every time they press or release the sprint button, so adding a new variable would be fiddly. Instead, we'll just adjust the SetMaxSpeed( ... ) function.

In both hl2mp_player.h and c_hl2mp_player.h (this is needed for prediction), declare:

virtual void	SetMaxSpeed( float flMaxSpeed );

This lets us override the default function.

Now open up hl2mp_player_shared.cpp, and put the function there. By putting it in this file, the same code will be executed on the client and the server, ensuring that we don't have any prediction problems.

void CHL2MP_Player::SetMaxSpeed( float flMaxSpeed )
{
	BaseClass::SetMaxSpeed( flMaxSpeed + 10.0f * ( GetLevel() - 1.0f ) );
}

That should add on 10 units per second onto the player's movement speed, for each level they have (beyond level 1). -1.0f is there so that level 1 players get no speed increase; their increase is 0.

Max Armor increase

In hl2mp_player.h in the HL2MP_Player class, somewhere in the public: section add:

int	m_iMaxArmor;
int	m_iOldMaxArmor;
void	SetMaxArmorValue( int iMaxArmorValue ) { m_iMaxArmor = iMaxArmorValue; };
void	SetOldMaxArmorValue( int iOldMaxArmorValue ) { m_iOldMaxArmor = iOldMaxArmorValue; };

In the constructor of the player or the Spawn function add:

SetOldMaxArmorValue( 100 + GetLevel() * 4 );

This will initialize m_iOldMaxArmor to the player's starting armor value for their level.

In hl2mp_player.cpp, inside the LevelUp() function, add:

SetMaxArmorValue( 100 + GetLevel() * 4 );

if ( m_iMaxArmor > m_iOldMaxArmor )
{
	IncrementArmorValue( m_iMaxArmor - m_iOldMaxArmor, m_iMaxArmor );
}

if ( m_iArmor > m_iMaxArmor )
{
	if ( m_iOldMaxArmor != m_iMaxArmor )
	{
		m_iArmor = m_iMaxArmor;
		SetOldMaxArmorValue( m_iMaxArmor );
	}
}

if ( m_iOldMaxArmor != m_iMaxArmor )
{
	SetOldMaxArmorValue( m_iMaxArmor );
}

This will implement an armor upgrade that won't set the armor back to the max value on every upgrade. Instead, when the player earns an armor upgrade, they will have the value of the upgrade added to their armor value (i.e. if they have 50 armor out of 100, and get an upgrade to 150 max armor, they are now at 100 armor, instead of 150).


Todo: Implement overrides to allow armor pickups to reach the new max armor value.

Conclusion

This tutorial has covered the implementation of a very basic experience system. The user is encouraged to develop their own extensions, to affect things like maximum stamina, weapon damage, and armor. Feel free to add any such extensions to this tutorial!