How HRPG implemented RPG Leveling for GZDOOM

Ask about ACS, DECORATE, ZScript, or any other scripting questions here!

Moderator: GZDoom Developers

Forum rules
Before asking on how to use a ZDoom feature, read the ZDoom wiki first. If you still don't understand how to use a feature, then ask here.

Please bear in mind that the people helping you do not automatically know how much you know. You may be asked to upload your project file to look at. Don't be afraid to ask questions about what things mean, but also please be patient with the people trying to help you. (And helpers, please be patient with the person you're trying to help!)
Post Reply
peewee_RotA
Posts: 407
Joined: Fri Feb 07, 2014 6:45 am

How HRPG implemented RPG Leveling for GZDOOM

Post by peewee_RotA »

One of the first hurdles for me when picking up zscript was finding the right "entrypoint". It seems like most folks are making a new map and placing new classes within the map. But for adding RPG elements to the existing Heretic and the existing levels, it required changing the base classes for monsters and changing the base classes for players. There may be better ways to implement this, but the real thing I wanted to share was that getting started piece.

Start with the MapInfo
I was the most confused by this. Mapinfo is not info for each specific level. It's pretty much general properties for your wad. The mod, map, etc. that you are working on is going to get all of its entrypoints from here, as well as some additional data on the game in general.

For HRPG I used the mapinfo.txt to reference a brand new file, similar to how it is done in the gzdoom pk3.

Code: Select all

include "mapinfo/hrpg.txt"
This is basically telling the game to use a file in the folder mapinfo with the name hrpg.txt. The actual file is basically a big copy of the heretic.txt file in the gzdoom pk3. If you haven't already, now is a good time to use slade to explore this file. It is in your gzdoom install directory. I recommend making a copy and only ever messing with the copy of the file.

For the actual changes to the hrpg.txtfile, there are a few sections I'll highlight, but will not copy the whole thing here.

For my player class to have rpg elements, I've made a few player classes. Because there are more than 1, a class selection menu automatically opens like in Hexen. All of these classes inherit from HRpgPlayer.

Code: Select all

playerclasses = "HRpgHereticPlayer", "HRpgHeathenPlayer", "HRpgBlasphemerPlayer"
In order to display my experience points and levels, I had to create a new status bar. That is defined here.

Code: Select all

	statusbarclass = "HRpgStatusBar"
"Replace" your Monster Classes
This is where HRPG makes each monster give Experience. Any thing that already spawns into a map in ZScript can be "replaced" by using the replace keyword. What this does is spawn your new version instead of the old version when the map loads. This trick gives you an entry point for overriding existing things. In this case, monsters.

The first thing I did was create an ExpSquishBag base class for monsters to inherit from. This means that it can override a few important virtual methods and any monster based on this base class will work like an experience giving bag of squishiness.

Here we will override the Take Special Damage method. I use the actual damage dealt because it helps with coop games. Everyone who scores a hit gets instant HP. It really beats all the lame logic to award coop XP, and really cool games like Amulets and Armor do it this way.

Code: Select all

const MAXXPHIT = 75;
//...
	override int TakeSpecialDamage(Actor inflictor, Actor source, int damage, Name damagetype)
	{
		int xp = damage;
		if (xp > MAXXPHIT)
			xp = MAXXPHIT;

		let hrpgPlayer = HRpgPlayer(source); //check if the source of the damage derives from HRpgPlayer. This means any of the classes that inherit from it can get XP.
		if (hrpgPlayer)
		{
			hrpgPlayer.GiveXP(xp); //call the methods on the player directly
			
			//...
		}
		
		return Super.TakeSpecialDamage(inflictor, source, damage, damagetype);
	}
Now I create new versions of each monster. We'll just look at the Mummyas an example.

Code: Select all

class HRpgMummy : ExpSquishbag replaces Mummy
{
	Default
	{
		Health 80;
		Radius 22;
		Height 62;
		Mass 75;
		Speed 12;
		Painchance 128;
		Monster;
//...
What we do here is make a new HRpgMummy that inherits from ExpSquishbag. That means that any overrides in ExpSquishbag should run here. This also contains the keyword "replaces" which means that anywhere that a class of "Mummy" was supposed to spawn, it will now spawn my new HRpgMummy class instead. That's how we get an "entrypoint" into the monster. We are cleverly replacing them at spawn with this really nice feature of ZScript.

I do this for all of the monster classes except dsparil. There is no need to gain levels in the very last boss fight.

Note: This method requires that we replace every monster to get this bonus. It is not easily compatible with existing map packs and plugins. So if you want to try a different approach, then that may be better for your mod. One common suggestion is to add an inventory item to the player, and another is the do special damage override on the player object or the weapons, instead of the monster.

Create the Player Classes
I have HRpgPlayer and 3 player classes that inherit from it. You don't have to have the extra player classes. You could just make HRpgPlayer your only playable character and it would still work.

Code: Select all

const XPMULTI = 1000;
const HEALTHBASE = 100;
const STATNUM = 4;

class HRpgPlayer : HereticPlayer //This does not have to "replace" because it doesn't spawn in the map. We use the mapinfo to set the class
{
	//Create the leveling variables
	int expLevel; 
	int exp;
	int expNext;
	//Statistics
	int brt; //Brutality (strength)
	int trk; //Trickerty (Dexterity)
	int crp; //Corruption (Intelligence)

	//Expose them as properties
	property ExpLevel : expLevel;
	property Exp : exp;
	property ExpNext : expNext;
	property Brt : brt;
	property Trk : trk;
	property Crp : crp;
	
	Default
	{
		//Defaults
		HRpgPlayer.ExpLevel 1;
		HRpgPlayer.Exp 0;
		HRpgPlayer.ExpNext XPMULTI;
		Player.MaxHealth HEALTHBASE;
		Health HEALTHBASE;
		//...
	}

	States
	{
		//...
	}
	
	//Methods for calculating statistics bonuses to attacks
	double GetScaledMod(int stat)
	//...	
	int GetModDamage(int damage, int stat, int scaled)
	//...		
	int GetDamageForMelee(int damage)
	//...	
	int GetDamageForWeapon(int damage)
	//...		
	int GetDamageForMagic(int damage)
	//...
	void SetProjectileDamage(Actor proj, int stat)
	//...		
	void SetProjectileDamageForMelee(Actor proj)
	//...	
	void SetProjectileDamageForWeapon(Actor proj)
	//...	
	void SetProjectileDamageForMagic(Actor proj)
	//...	
	
	int CalcXPNeeded() //XP needed is current level times 1000
	{
		return ExpLevel * XPMULTI;
	}
	
	void GiveXP (int expEarned) //Method used to add XP to player. Called form monster's take special damage
	{
		Exp += expEarned;
		
		while (Exp >= ExpNext) //Handles multiple level gains if massive XP is added
		{
			GainLevel();
		}
	}
	
	//Leveling
	void DoLevelGainBlend()//Make screen flash
	{
		let blendColor = Color(122,	122, 122, 122);
		A_SetBlend(blendColor, 0.8, 40);
		
		string lvlMsg = String.Format("You are now level %d", ExpLevel);
		A_Print(lvlMsg);
	}
	
	//Gain a level
	void GainLevel()
	{
		if (Exp < ExpNext)
			return;

		ExpLevel++;
		Exp = Exp - ExpNext;
		ExpNext = CalcXpNeeded();
		
		//Distribute points randomly, giving weight to highest stats
		int statPoints = STATNUM;
		while (statPoints > 0)
		{
			int statStack = Brt + Trk + Crp;
			
			double rand = random(1, statStack);
			if (rand <= Brt)
			{
				Brt += 1;
			}
			else if (rand <= Brt + Trk)
			{
				Trk += 1;
			}
			else
			{
				Crp += 1;
			}
			statPoints--;
		}
		
		//BasicStatIncrease to call overrides in classes
		BasicStatIncrease();

		//health increases by random up to Brutality, min 5 (weighted for low end of flat scale)
		int healthBonus = random(1, Brt);
		if (healthBonus < 5)
			healthbonus = 5;

		int newHealth = MaxHealth + healthBonus;
		MaxHealth = newHealth;
		if (Health < MaxHealth)
			A_SetHealth(MaxHealth);
			
		DoLevelGainBlend();
	}
}
This gives the Player the ability to be given XP, and level when it reaches next. It also handles random stat distribution and massive XP increases that do multiple levels. XP per hit is currently capped at 75, but if you ever give boss bonuses or anything that is important.

Display that new stuff
Adding the new status bar HrpgStatusBar is pretty complicated, but mine is a copy of Heretics with minor changes.

Code: Select all

HUDFont mSmallFont;
//...
	override void Init()
	{
		//...
		fnt = "SMALLFONT";
		mSmallFont = HUDFont.Create(fnt, fnt.GetCharWidth("0"), Mono_CellLeft);
		//...
	}
//...
	override void Draw (int state, double TicFrac)
	{
		Super.Draw (state, TicFrac);

		if (state == HUD_StatusBar)
		{
			BeginStatusBar();
			DrawMainBar (TicFrac);
			
			DrawExpStuff(0);
		}
		else if (state == HUD_Fullscreen)
		{
			BeginHUD();
			DrawFullScreenStuff ();
			
			DrawExpStuff(1);
		}
	}
	//...
	protected void DrawExpStuff (int isFullscreen)
	{
		let xPos = 0;
		let yPos = 126;
		let yStep = 8;
		
		let xPosStats = 320;
		
		if (isFullscreen)
		{
			xPos = 8;
			yPos = 346;
			xPosStats = 740;
		}

		let hrpgPlayer = HRpgPlayer(CPlayer.mo);
		if (!hrpgPlayer)
			return;

		let text1 = String.Format("Level: %s", FormatNumber(hrpgPlayer.ExpLevel, 0));
		let text2 = String.Format("XP: %s / %s", FormatNumber(hrpgPlayer.Exp, 0), FormatNumber(hrpgPlayer.ExpNext, 0));
				
		//Exp
		DrawString(mSmallFont, text1, (xPos, yPos), DI_TEXT_ALIGN_LEFT);
		DrawString(mSmallFont, text2, (xPos, yPos + yStep), DI_TEXT_ALIGN_LEFT);
		
		let statText1 = String.Format("Brutality: %s", FormatNumber(hrpgPlayer.Brt, 0));
		let statText2 = String.Format("Trickery: %s", FormatNumber(hrpgPlayer.Trk, 0));
		let statText3 = String.Format("Corruption: %s", FormatNumber(hrpgPlayer.Crp, 0));

		//Stats
		DrawString(mSmallFont, statText1, (xPosStats, yPos - yStep), DI_TEXT_ALIGN_RIGHT);
		DrawString(mSmallFont, statText2, (xPosStats, yPos), DI_TEXT_ALIGN_RIGHT);
		DrawString(mSmallFont, statText3, (xPosStats, yPos + yStep), DI_TEXT_ALIGN_RIGHT);
		
		let bPlayer = HRpgBlasphemerPlayer(CPlayer.mo);
		if (bPlayer && isFullscreen)
		{
			let text3 = String.Format("Mana: %s / %s", FormatNumber(bPlayer.Mana / MANA_SCALE_MOD, 0), FormatNumber(bPlayer.MaxMana / MANA_SCALE_MOD, 0));
			DrawString(mSmallFont, text3, (xPos, yPos - yStep), DI_TEXT_ALIGN_LEFT);
		}
	}
For the most part we are just adding a method to draw the EXP text, and doing so with some special fonts and formatting. Heretic has some limited fonts so we do have to create a new small font.

And that's pretty much it for code changes. The next part is referencing those code files.

Referencing your new files
Now that you have all these new classes, ZScript has to know that they exist. That is done by adding them to the zscript.zs file.

Code: Select all

version "4.3.0"

#include "hrpgscripts/HRpgPlayer.zs"
//...
#include "hrpgscripts/HRpgStatusBar.zs"
//...
#include "hrpgscripts/ExpSquishbag.zs"
//...
#include "hrpgscripts/HRpgMummy.zs"
User avatar
jdredalert
Posts: 1681
Joined: Sat Jul 13, 2013 10:13 pm
Contact:

Re: How HRPG implemented RPG Leveling for GZDOOM

Post by jdredalert »

Thank you for sharing this info! This might be useful in the future.
peewee_RotA
Posts: 407
Joined: Fri Feb 07, 2014 6:45 am

Re: How HRPG implemented RPG Leveling for GZDOOM

Post by peewee_RotA »

Made an approach that uses inventory:

First I created ExpSquishItem.zs so that it could use the ModifyDamage method to detect when damage is being done. This virtual function is complex, but basically if it is "not passive" then the owner is dealing damage.

Code: Select all

class ExpSquishItem : Powerup
{
	Default
	{
		+INVENTORY.UNDROPPABLE
        +INVENTORY.UNTOSSABLE
        +INVENTORY.AUTOACTIVATE
        +INVENTORY.PERSISTENTPOWER
        +INVENTORY.UNCLEARABLE

        Powerup.Duration 0x7FFFFFFF;
	}

	//===========================================================================
	//
	// ModifyDamage
	//
	//===========================================================================

	override void ModifyDamage(int damage, Name damageType, out int newdamage, bool passive, Actor inflictor, Actor source, int flags)
	{
		if (!passive && damage > 0 && Owner && Owner.Player && Owner.Player.mo)
        {
            let xrpgPlayer = XRpgPlayer(Owner.Player.mo);
            if (xrpgPlayer)
            {
                xrpgPlayer.DoXPHit(source, damage, damageType);
            }
        }
	}
}
Then I created a new base player very similar to the last one.

XRpgPlayer.zs

Code: Select all

const MAXXPHIT = 75;
const XPMULTI = 1000;
const HEALTHBASE = 100;
const STATNUM = 4;

class XRpgPlayer : PlayerPawn
{
	int expLevel;
	int exp;
	int expNext;
	int strength;
	int dexterity;
	int magic;
	property ExpLevel : expLevel;
	property Exp : exp;
	property ExpNext : expNext;
	property Strength : strength;
	property Dexterity : dexterity;
	property Magic : magic;

	Default
	{
		XRpgPlayer.ExpLevel 1;
		XRpgPlayer.Exp 0;
		XRpgPlayer.ExpNext XPMULTI;
		Player.MaxHealth HEALTHBASE;
		Health HEALTHBASE;

		Player.StartItem "ExpSquishItem";//Gives the player the Experience Squish Bag item.
	}
//...
    void DoXPHit(Actor xpSource, int damage, name damagetype)
	{
        if (damage <= 0)
            return;
        
        if (!xpSource)
            return;

        if (!xpSource.bISMONSTER)
            return;

        int xp = damage;
        if (xp > MAXXPHIT)
            xp = MAXXPHIT;

        GiveXP(xp);
	}
}
All of the leveling and damage scaling code is essentially the same. The main thing is that the give XP does some checks to make sure the thing being damaged is a monster and that there was more than 0 damage done. Some examples of things this prevents from accidentally giving XP are shootable windows, shootable barrels, and monsters that can be invincible, such as like the Centaur's shield.

I've been applying this over Hexen because I figured it was a good blank slate to start on. One note is that for Hexen, adding an item in default to the base class doesn't seem to work. I had to add Player.StartItem "ExpSquishItem"; to the default sections of the fighter, cleric, and mage classes directly as well.
Post Reply

Return to “Scripting”