Home Let's Build an RPG! Deconstructing B/X D&D Adventure Generator! B/X D&D Character Generator Various Java Contact
 

First PagePrevious PageBack to overview

A Simple Combat Loop

Design

It's finally time! Let's get some fightin' on!!!

We'll implement a Combat utility class, which is really just a quick hack to make it do something neat. We'll replace it with something better much later on, when we have an event-driven dungeon simulator designed and built. But it will do for now!

It will work something like this:

  • Upon instantiation of the Combat object, the constructor is given two groups of critters, one group for each side of the combat.
  • Each round, we call a method on the Combat object which:
    • Rolls individual initiative for each combatant, and sorts all of the combatants into initiative order.
    • Loops through the sorted list of combatants and:
      • Skips the combatant if they were killed earlier in the round. Otherwise,
      • Roll to-hit for the attacker, and apply damage if the hit is successful.
      • If the defender is killed, then the defender is removed from the combat groups.
  • At the end of each round, we can call some methods on the Combat object to see if anyone is left alive on one side or the other, and thereby determine if the combat is over, and which side won.

Implementation

Adding Hit Points to Critters

The first thing we have to do is add some hit points statistics to critters.

Critter.java

public interface Critter 
{ 
// ... <snip> ... 
 
	/** 
	 * Gets the critter's maximum hit points. 
	 *  
	 * @return the critter's maximum hit points 
	 */ 
	public int getMaximumHp(); 
 
	/** 
	 * Gets the critter's current hit points. 
	 *  
	 * @return the critter's current hit points 
	 */ 
	public int getCurrentHP(); 
 
	/** 
	 * Adjust critter's current hit points by the specified amount. Negative 
	 * values cause damage, while positive values cause healing. Implementations 
	 * of this method must ensure that the critter's current hit points never 
	 * exceed the critter's maximum hit points, and that the critter's current 
	 * hit points are never less than zero (dead). 
	 *  
	 * @param adjustment 
	 *            the amount to adjust the critters hit points; negative for 
	 *            damage or positive for healing 
	 */ 
	public void adjustCurrentHP( int adjustment ); 
}

PlayerCharacter.java

public class PlayerCharacter implements Critter 
{ 
// ... <snip> ... 
 
	/** The character's maximum hit-points. */ 
	private int hpMax; 
 
	/** The character's current hit-points. */ 
	private int hpCurrent; 
 
// ... <snip> ... 
 
	/** 
	 * Rolls a new character. Stats are straight 3d6 down the line. Name, 
	 * gender, and alignment are set to the specified values. If any of those 
	 * values are null, then that value will be rolled randomly. 
	 * 
	 * @param name the name 
	 * @param gender the gender 
	 * @param alignment the alignment 
	 */ 
	public PlayerCharacter( String name, Gender gender, Alignment alignment ) 
	{ 
// ... <snip> ... 
 
		// Roll for hit points.  For now, we'll just assume that everyone uses 
		// a d8 for hit points, but we'll change that once we add some character 
		// classes other than the barbarian. 
		hpCurrent = hpMax = Dice.d( 1, 8, getStatModifier( Stat.CONSTITUTION ) ); 
	} 
 
// ... <snip> ... 
 
	/* (non-Javadoc) 
	 * @see net.dizzydragon.rustybox.Critter#getMaximumHp() 
	 */ 
	@Override 
	public int getMaximumHp() 
	{ 
		return hpMax; 
	} 
 
	/* (non-Javadoc) 
	 * @see net.dizzydragon.rustybox.Critter#getCurrentHP() 
	 */ 
	@Override 
	public int getCurrentHP() 
	{ 
		return hpCurrent; 
	} 
 
	/* (non-Javadoc) 
	 * @see net.dizzydragon.rustybox.Critter#adjustCurrentHP(int) 
	 */ 
	@Override 
	public void adjustCurrentHP( int adjustment ) 
	{ 
		hpCurrent += adjustment; 
		// Enforce that 0 <= hpCurrent <= hpMax. 
		if( hpCurrent < 0 ) 
			hpCurrent = 0; 
		else if( hpCurrent > hpMax ) 
			hpCurrent = hpMax; 
	} 
}

Implementing the Combat Loop!

The code is fairly straightforward and commented, so here is a wall o' text! Hold onto your hat!!

FIXME We forgot to comment rollInitiative()……

package net.dizzydragon.rustybox; 
 
import java.util.ArrayList; 
import java.util.Arrays; 
import java.util.Collection; 
import java.util.HashMap; 
import java.util.HashSet; 
 
import net.dizzydragon.rustybox.Critter.Stat; 
import net.dizzydragon.rustybox.simpleio.SimpleIO; 
import net.dizzydragon.rustybox.util.Dice; 
 
/** 
 * A temporary utility class to run combats between two groups of critters. 
 */ 
public class Combat 
{ 
	/** 
	 * An enumeration of the different combat messages. 
	 */ 
	public static enum CombatMessage 
	{ 
		ROLL_INITIATIVE,  
		ROLL_ATTACK,  
		ATTACK_HIT,  
		ATTACK_MISSED,  
		DIE 
	} 
 
	/** The combat messages, in ordinal order of the CombatMessage enum. */ 
	final private ActionString[]	combatMessages	=  
		{  
			new ActionString( "$Name$0 rolls %d for initiative." ),  
			new ActionString( "$Name$0 attacks $name$1!" ),  
			new ActionString( "$Name$0 hits $name$1 for %d points!" ),  
			new ActionString( "$Name$0 misses!" ),  
			new ActionString( "$Name$1 is dead!" )  
		}; 
 
	/** The two groups of combatants. */ 
	final private HashSet<Critter>	group1			= new HashSet<Critter>(); 
	final private HashSet<Critter>	group2			= new HashSet<Critter>(); 
 
	/** 
	 * Instantiates a new combat object, wherein the two given groups will fight 
	 * each other. 
	 *  
	 * @param group1 
	 *            one of the groups of critters who will be fighting 
	 * @param group2 
	 *            the other group of critters who will be fighting 
	 */ 
	public Combat( Collection<Critter> group1, Collection<Critter> group2 ) 
	{ 
		this.group1.addAll( group1 ); 
		this.group2.addAll( group2 ); 
	} 
 
	/** 
	 * Instantiates a new combat object, taking two critters, mano-a-mano. 
	 *  
	 * @param critter1 
	 *            one of the critters who will be fighting 
	 * @param critter2 
	 *            the other critter who will be fighting 
	 */ 
	public Combat( Critter critter1, Critter critter2 ) 
	{ 
		this.group1.add( critter1 ); 
		this.group2.add( critter2 ); 
	} 
 
	/** 
	 * Change the text of a combat message to the given ActionString format 
	 * string. Actor #0 is generally the attacker, while actor #1 is generally 
	 * the defender. 
	 *  
	 * @param message 
	 *            the message to change 
	 * @param fmt 
	 *            an ActionString format string to change it to. 
	 */ 
	public void setCombatMessage( CombatMessage message, String fmt ) 
	{ 
		combatMessages[message.ordinal()] = new ActionString( fmt ); 
	} 
 
	/** 
	 * Get the #1 combatant group. 
	 *  
	 * @return an array containing the combatants in group1 who are still alive. 
	 */ 
	public Critter[] getGroup1() 
	{ 
		return group1.toArray( new Critter[0] ); 
	} 
 
	/** 
	 * Get the #2 combatant group. 
	 *  
	 * @return an array containing the combatants in group2 who are still alive. 
	 */ 
	public Critter[] getGroup2() 
	{ 
		return group2.toArray( new Critter[0] ); 
	} 
 
	/** 
	 * Run combat round. 
	 *  
	 * @param io 
	 *            the SimpleIO to use to print combat messages 
	 */ 
	public void runCombatRound( SimpleIO io ) 
	{ 
		// Roll initiative and loop through all attackers in initiative order. 
		for( Critter attacker : rollInitiative( io ) ) 
		{ 
			io.println(); 
			// Get an array of opposing critters for the attacker to attack. 
			ArrayList<Critter> defenders = null; 
			if( !group1.contains( attacker ) ) 
				defenders = new ArrayList<Critter>( group1 ); 
			else if( !group2.contains( attacker ) ) 
				defenders = new ArrayList<Critter>( group2 ); 
			// This will only happen if the critter got into both lists 
			// somehow... In this case, we'll just skip them! 
			if( defenders == null || defenders.size() < 1 ) 
				continue; 
			// Choose a random alive defender out of the array of opposing 
			// critters. 
			Critter defender = null; 
			do 
			{ 
				defender = defenders.remove( Dice.d( defenders.size() ) - 1 ); 
				if( defender.getCurrentHP() <= 0 ) 
					defender = null; 
			} while( !defenders.isEmpty() && defender == null ); 
			// Roll an attack against the defender (but only if the attacker is 
			// still alive)! 
			if( attacker.getCurrentHP() > 0 ) 
				doStrike( io, attacker, defender ); 
		} 
	} 
 
	/** 
	 * Roll initiative. 
	 *  
	 * @param io 
	 *            the SimpleIO to use to print combat messages 
	 * @return a list of all critters still in the combat, in initiative order 
	 */ 
	private ArrayList<Critter> rollInitiative( SimpleIO io ) 
	{ 
		final HashMap<Integer,HashSet<Critter>> crittersByInitiativeRoll = new HashMap<Integer,HashSet<Critter>>(); 
		final ArrayList<Critter> allCombatants = new ArrayList<Critter>(); 
		allCombatants.addAll( group1 ); 
		allCombatants.addAll( group2 ); 
		for( Critter critter : allCombatants ) 
		{ 
			int initiativeRoll = Dice.d( 6 ) + critter.getStatModifier( Stat.DEXTERITY ); 
			HashSet<Critter> critterSet = crittersByInitiativeRoll.get( initiativeRoll ); 
			if( critterSet == null ) 
			{ 
				critterSet = new HashSet<Critter>(); 
				crittersByInitiativeRoll.put( initiativeRoll, critterSet ); 
			} 
			critterSet.add( critter ); 
		} 
		final Integer[] initiativeRolls = crittersByInitiativeRoll.keySet().toArray( new Integer[0] ); 
		Arrays.sort( initiativeRolls ); 
		final ArrayList<Critter> results = new ArrayList<Critter>(); 
		for( int i = initiativeRolls.length - 1; i >= 0; i-- ) 
		{ 
			Integer initiativeRoll = initiativeRolls[i]; 
			ArrayList<Critter> critters = new ArrayList<Critter>( crittersByInitiativeRoll.get( initiativeRoll ) ); 
			while( !critters.isEmpty() ) 
			{ 
				Critter critter = critters.remove( Dice.d( critters.size() ) - 1 ); 
				if( critter.getCurrentHP() > 0 ) 
				{ 
					io.println( String.format( combatMessages[CombatMessage.ROLL_INITIATIVE.ordinal()].resolve( null, critter ), initiativeRoll ) ); 
					results.add( critter ); 
				} 
			} 
		} 
		return results; 
	} 
 
	/** 
	 * Resolve a strike. 
	 *  
	 * @param io 
	 *            the SimpleIO to use to print combat messages 
	 * @param attacker 
	 *            the attacking critter 
	 * @param defender 
	 *            the defending critter 
	 */ 
	private void doStrike( SimpleIO io, Critter attacker, Critter defender ) 
	{ 
		// Print the swing message. 
		io.println( combatMessages[CombatMessage.ROLL_ATTACK.ordinal()].resolve( null, attacker, defender ) ); 
		// Roll to-hit. 
		int toHit = Dice.d( 1, 20, attacker.getStatModifier( Stat.STRENGTH ) ); 
		// If THACO - Defender AC > the attacker's to-hit roll, then the attack 
		// is a miss. 
		if( 19 - 5 > toHit ) 
		{ 
			// Print the miss message. 
			io.println( combatMessages[CombatMessage.ATTACK_MISSED.ordinal()].resolve( null, attacker, defender ) ); 
		} 
		else 
		{ 
			// It was a hit! Roll some damage. 
			int damage = Dice.d( 4 ); // Reduced from d8 so there are fewer 
										// one-hit-wonders! 
			// Print the hit message. 
			io.println( String.format( combatMessages[CombatMessage.ATTACK_HIT.ordinal()].resolve( null, attacker, defender ), damage ) ); 
			// Apply the damage to the defender. 
			defender.adjustCurrentHP( -damage ); 
			// If it was enough to kill the defender, print the death message 
			// and remove them from the combat groups. 
			if( defender.getCurrentHP() <= 0 ) 
			{ 
				group1.remove( defender ); 
				group2.remove( defender ); 
				io.println(); 
				io.println( combatMessages[CombatMessage.DIE.ordinal()].resolve( null, attacker, defender ) ); 
			} 
		} 
	} 
}

Test It!

Back to Test.java! We'll add some flavor text and change some combat messages to make things fit better with the scene we are trying to present!

package test; 
 
import net.dizzydragon.rustybox.ActionString; 
import net.dizzydragon.rustybox.Combat; 
import net.dizzydragon.rustybox.Combat.CombatMessage; 
import net.dizzydragon.rustybox.Critter; 
import net.dizzydragon.rustybox.PlayerCharacter; 
import net.dizzydragon.rustybox.Critter.Alignment; 
import net.dizzydragon.rustybox.simpleio.SimpleIO; 
import net.dizzydragon.rustybox.simpleio.SimpleRunnable; 
import net.dizzydragon.rustybox.tables.TableNamesAdventureSites; 
import net.dizzydragon.rustybox.util.Dice; 
 
public class Test extends SimpleRunnable 
{ 
	private static final long	serialVersionUID	= 1L; 
 
	public static void main( String[] args ) 
	{ 
		runLocal( new Test() ); 
	} 
 
	@Override 
	public void run( SimpleIO io ) 
	{ 
		// The battle is between a good barbarian and an bad barbarian. 
		final Critter goodCritter = new PlayerCharacter( null, null, Alignment.LAWFUL ); 
		final Critter badCritter = new PlayerCharacter( null, null, Alignment.CHAOTIC ); 
 
		// Instantiate a Combat object for the two critters. 
		Combat combat = new Combat( goodCritter, badCritter ); 
 
		// Modify some of the combat messages to make them a little more 
		// appropriate for the scene we are describing. 
		combat.setCombatMessage( CombatMessage.ROLL_ATTACK, "$Name$0 swings at $name$1!" ); 
		combat.setCombatMessage( CombatMessage.ATTACK_HIT, "$Name$0 strikes $name$1 for %d points!" ); 
		combat.setCombatMessage( CombatMessage.ATTACK_MISSED, "$Name$1 adroitly avoids the blow!" ); 
		combat.setCombatMessage( CombatMessage.DIE, "$Name$1 crumples to the ground, $hisHerIts$1 last life's blood spreading through the snow beneath $himHerIt$1!" ); 
 
		// And it happens at this adventure site! 
		final String adventureSiteName = new TableNamesAdventureSites().random(); 
 
		// We ought to be declaring all of these ActionString as final static 
		// fields, but when the program runs in an applet, we have to be able 
		// to generate new characters and adventure site each time through. 
 
		final ActionString		goodIntro		= new ActionString( 
				"Once upon a time in the far away northern reaches of the frozen wilderness kingdom of Winterlund, " + 
						"the kind and goodly barbarian $name$0 stumbled upon the "+ 
						adventureSiteName + 
						" during $hisHerIts$0 quest to right wrongs and help the " + 
						"helpless and all of that stuff that goodly and kind " + 
						"barbarians do.\n" + 
						"\n" + 
						"Upon the threshold of the " + 
						adventureSiteName + 
						" stood the foul and evil barbarian $name$1!  $Name$1 " + 
						"let out a bellow of rage at the sight of $hisHerIts$1 nemesis, and " + 
						"drew $hisHerIts$1 sword, charging across the packed snow " + 
						"towards $himHerIt$0!\n" + 
						"\n" + 
						"$Name$0 cast off $hisHerIts$0 fur cloak and readied $hisHerIts$0 " + 
						"axe!  The gods watched with mild amusement, for the pure white " + 
						"snow was soon to be stained red with blood!" 
				); 
 
		final ActionString		badIntro		= new ActionString( 
				"Once upon a time in the far away northern reaches of the frozen wilderness kingdom of Winterlund, " + 
						"the foul and evil barbarian $name$1 stumbled upon the " + 
						adventureSiteName + 
						" during $hisHerIts$1 quest to commit wrongs and pillage the " + 
						"helpless and all of that stuff that foul and evil " + 
						"barbarians do.\n" + 
						"\n" + 
						"Upon the threshold of " + 
						adventureSiteName + 
						" stood the kindly and good barbarian $name$0!  $Name$1 " + 
						"let out a bellow of rage at the sight of $hisHerIts$1 nemesis, and " + 
						"drew $hisHerIts$1 sword, charging across the packed snow " + 
						"towards $himHerIt$0!\n" + 
						"\n" + 
						"$Name$0 cast off $hisHerIts$0 fur cloak and readied $hisHerIts$0 " + 
						"axe, preparing to meet $name$1's onslaught!  The gods watched with " + 
						"mild amusement, for the pure white snow was soon to be stained red " + 
						"with blood!" 
				); 
 
		final ActionString		goodClosing		= new ActionString( 
				"$Name$0 mops $hisHerIts$0 brow and intones a short prayer to " + 
						"the Lady of Light over the body of $hisHerIts$0 fallen " + 
						"nemesis!  " + 
						"Then $heSheIt$0 turns on $hisHerIts$0 heel and enters the dark yawning " + 
						"depths of the " + 
						adventureSiteName + 
						", in search of Loot and XPs!" 
				); 
 
		final ActionString		badClosing		= new ActionString( 
				"$Name$1 cackles evily and steals $name$0's blue suede boots!  " + 
						"Then $heSheIt$1 turns on $hisHerIts$1 heel and enters the dark yawning " + 
						"depths of the " + 
						adventureSiteName + 
						", in search of Loot and XPs!" 
				); 
 
		// We print the introduction of the story from the point of view of 
		// one of the barbarians. Which one it is is chosen randomly. 
		if( Dice.d( 2 ) == 1 ) 
		{ 
			// This is the point of view of the good barbarian. 
			io.println( goodIntro.resolve( null, goodCritter, badCritter ) ); 
		} 
		else 
		{ 
			// This is the point of view of the bad barbarian. 
			io.println( badIntro.resolve( null, goodCritter, badCritter ) ); 
		} 
		pause( io ); 
 
		// This variable tracks the number of rounds that have elapsed. It is 
		// incremented each time we go through the combat loop. 
		int round = 1; 
		// And here's the combat loop. It loops over and over, incrementing the 
		// round number each time, until one of the combatants is dead. 
		for( ; combat.getGroup1().length > 0 && combat.getGroup2().length > 0 ; round++ ) 
		{ 
			// Print the round number. 
			io.clear(); 
			io.println( "Round " + round + "!" ); 
			io.println(); 
			// Resolve the combat round. 
			combat.runCombatRound( io ); 
			// If nobody died, then we pause so the user can read the output, 
			// before looping back to the next round. 
			pause( io ); 
		} 
		io.clear(); 
		// Silly thing to print if somebody was one-shotted! 
		if( round <= 2 ) 
		{ 
			io.println(); 
			io.println( "That didn't take long!" ); 
		} 
		// If the good barbarian was killed, when we print one thing. But if 
		// the bad barbarian was killed instead, then we print something else! 
		if( goodCritter.getCurrentHP() <= 0 ) 
			io.println( badClosing.resolve( null, goodCritter, badCritter ) ); 
		else 
			io.println( goodClosing.resolve( null, goodCritter, badCritter ) ); 
		// The end! 
		io.println(); 
		io.println( "The End." ); 
	} 
 
	/** 
	 * Pause the "game" until the user presses return. 
	 */ 
	private void pause( SimpleIO io ) 
	{ 
		io.println( "\n(Press Enter to continue!)" ); 
		io.getInputLine(); 
		io.println(); 
	} 
}

What's Next?

In the next chapters, we'll start delving into implementing some monsters and random encounter tables!

Once we have some monsters, we'll build a quick “Dungeon On Rails” that a player can delve into and fight some monsters! Maybe we'll even implement some sort of high-scores table, showing which characters have obtained the most experience before dying!

Source Code

The source code for this chapter can be found in the Subversion Repository.

If you are interested in changing/modifying the code, you can download it into your Eclipse workspace using Subclipse as detailed here: TUTORIAL: Using Subclipse.

First PagePrevious PageBack to overview

rustybox/book/010_a_simple_combat_loop.txt · Last modified: 2012/09/28 09:13 by leaf

Copyright © 2009, 2012-2013 by L. Adamson, unless otherwise stated.
If you see something here that you like, and find it useful or learn something, please consider making a quick, easy, and secure donation via PayPal.
Your support is what keeps this whole thing going!