Welcome to Goldrush

Welcome to part 5. In this part I'm going to be revealing some of the inner workings of my minigame, "Goldrush", as it contains a rich selection of Bukkit API calls, and some bug work-arounds too. From here onwards I'm going to assume you have a reasonable understanding of Java. If it's all new to you, and you've made it with us to part 5 then congratulations, but you may have some more reading to do! The Oracle Collections tutorial would be a useful for beginners at this stage.

Play the game!

Before you start reading about the inner workings of Goldrush you might want to have a go at the game so you know what I'm talking about. Here are the rules of the game in a nutshell.
  • The game is multiplayer, and the main objective is to get the highest score by collecting and depositing collectable items from the game arena
  • Collectable items are
    • Poisonous potato (minus 1 point)
    • Golden carrot (zero points)
    • Gold nugget (1 point)
    • Gold ingot (10 points)
    • Watch (30 points)
  • Deposit the items by left clicking on the deposit signs.
  • Picking up a poisonous potato will inflict a random potion of harm on you
  • Picking up a golden carrot will heal your health
  • Non-collectable items (ie not listed above) are not affected by clicking the deposit sign
  • Poisonous potatoes and golden carrots may not be dropped in moved around in your inventory
  • Poisonous potatoes in your hand may be thrown like eggs for the purposes of
    • Getting rid of them so they don't count against your score
    • Using them as weapons
  • PVP is allowed:
    • Being struck by a thrown potato inflicts a random potion of harm on you. Note that you do not collect the potato
    • Players will drop items on death. These may be stolen by other player
    • Death has no effect on score
    • Players immediately respawn at the arena's spawn on death
    • Armor, weapons and food are scattered randomly in amongst the collectable items

The game running on the server hub minecraft.chilliserver.com. Use /server goldrush to join the goldrush server. Click on the join signs to enter the lobby for a new game - at least two players are required to start the game.

Potions and poisons


Let's start with a simple extract of code from the game. Here are the potion effects:

private final PotionEffect poison = new PotionEffect (PotionEffectType.POISON, 60, 1);
private final PotionEffect hunger = new PotionEffect (PotionEffectType.HUNGER, 200, 4);
private final PotionEffect confusion = new PotionEffect (PotionEffectType.CONFUSION, 200, 1);
private final PotionEffect blindness = new PotionEffect (PotionEffectType.BLINDNESS, 200, 1);
private final PotionEffect wither = new PotionEffect (PotionEffectType.WITHER, 200, 1);
private final PotionEffect[] potionEffects = 
{
	poison, poison, poison, hunger, hunger, hunger, blindness, confusion, wither
};

Upon picking up a potato, a random potion effect is applied from the array potionEffects. See how poison and hunger appear 3 times, this gives them more weight in the random selection. This might seem like a waste of memory but remember this is an array of references, and a reference consumes only 4 bytes (32 bits) of memory. The above code is run in the game's constructor, so each potion effect is constructed only once for each instance of the game - it is not necessary to make a new PotionEffect each time we poison a player. Here's how the potions get applied:

@EventHandler
public void onPlayerPickupItemEvent (PlayerPickupItemEvent e)
{
	Player p = e.getPlayer();
		
	if (playerInfo.containsKey(p)) // is the player in the game (replace with your own test)
	{
		Material type = e.getItem().getItemStack().getType();
		if (type == Material.POISONOUS_POTATO)
		{
			int i = random.nextInt (potionEffects.length);
			p.addPotionEffect (potionEffects[i]);
		}
		else if (type == Material.GOLDEN_CARROT)
			p.addPotionEffect (heal);
	}	
}

playerInfo is a local Map containing information about the players in the game, such as their current score. The Player objects are keys to the map. If the player p is not in the game then playerInfo.containsKey(p) will evaluate to false. This is an important test - there may be players that are on the server who are not part of this game, and we do not want to process any events for those players. After that we simply test the type of material being collected, and if it is a poisonous potato apply a random potion from the aforementioned array. The reference called random in the above code refers to an instance of Random

Simulated death

Remember what happens when you die in minecraft:


In the context of a short-lived minigame, this screen is quite disruptive. Most minigames do one of two things upon player death
  • Respawn the player at a point where they can continue with the game
  • Respawn the player at a point where they can spectate the remainder of the game (in the event that death causes you to lose)
In either case, the death menu at the client side isn't particularly friendly. Furthermore the plugin would have to deal with the outcome of the player clicking either button, as they are effectively given the choice to continue with the game or log off the server. That's all perfectly easy to deal with, but I find a cleaner approach is to prevent the death event from occurring. Now it turns out that PlayerDeathEvent is not cancellable, so instead we need to trap the event that causes the player death. Here's how, in brief:

	@EventHandler
	public void onEntityDamageEvent (EntityDamageEvent e)
	{
		Entity entity = e.getEntity();
		if (entity instanceof Player)
		{
			Player p = (Player) entity;
			
			// Do a test here, to see if your player is subject to death cancellation

			if (p.getHealth() - e.getDamage() <= 0)
			{
				e.setCancelled (true);
				// Simulate the death (or do something else) here
			}

		}
	}

The above code should compile out of the box. This handler will trap every type of player death that I've tested, including falling through the void. Here is the full version that won't compile for you because it refers to other elements in the Goldrush code

	@EventHandler
	public void onEntityDamageEvent (EntityDamageEvent e)
	{
		Entity entity = e.getEntity();
		if (entity instanceof Player)
		{
			Player p = (Player) entity;
			
			if (playerInfo.containsKey (p))
			{
				if (p.getHealth() - e.getDamage() <= 0)
				{
					p.sendMessage ("You died!");
					playerDrops (p);
					PlayerSave.resetPlayer (p);
					p.teleport (spawn);
					e.setCancelled (true);
				}
			}
		}
	}

Here are the components of Goldrush that the above handler uses, and you would need to replace them with your own:
  • playerInfo as previously described, a Map which amongst other thing indicates if the player is in the game
  • spawn A local field of type Location indicating the spawn point of the current game
  • playerDrops A local method that deals with items in the player's inventory
  • PlayerSave.resetPlayer Sets the player back to post-spawn conditions ie
    • Empty inventory
    • Empty armour slots
    • Full health
    • Full hunger (meaning not hungry!)
    • Zero XP
The PlayerSave class is given in full at the end of this page.

Potato throwing contest


Why is that the Bukkit API contains Egg.java but not Potato.java? The answer lies in what these objects represent. Consider an egg that has just been laid (dropped) by a chicken. Dropped items are represented by a class called Item which is a subclass of Entity (Entity represents just about everything in the game that isn't a block). The Item in turn references an ItemStack which has a Material type of Material.Egg. Nowhere does the class Egg come into this. It turns out that Egg represents an egg which has been thrown, and this has nothing to do with items or itemstacks. Egg is a subclass of Projectile. Projectile does not have a subclass called Potato, so we have to make do with another type of projectile, and the one that looks most like a potato at high speed is an egg.

I did originally try an alternative approach to this. Drop a potato object as an Item and then give the item some velocity in the direction of throw. However, this doesn't work well at all. Dropped items have a strange interpretation of the laws of physics and have no concept of a collision event. Here's how the code ended up (using eggs instead)

@EventHandler
public void onPlayerInteractEvent (PlayerInteractEvent e)
{
	Player player = e.getPlayer();
	
	GoldRushPlayerInfo info = playerInfo.get(player);
	
	if (info != null) // is the player in the game...
	{
		Action action = e.getAction ();
		if (action == Action.LEFT_CLICK_BLOCK) 
		{
			// snip some left click handling. for potato throwing see else clause
		}
		else if ((action == Action.RIGHT_CLICK_AIR || action == Action.RIGHT_CLICK_BLOCK) 
					&& e.getMaterial() == Material.POISONOUS_POTATO)
		{
			Egg egg = player.launchProjectile(Egg.class);
			egg.setVelocity(egg.getVelocity ().multiply(1.5));
			thrownEggs.put (egg, player);
			Bukkit.getScheduler().scheduleSyncDelayedTask 
				(MiniGames.getInstance(), new PostPotatoThrowCallback (player));
		}
	}
}

In the above code we are using two methods of PlayerInteractEvent to determine whether the player should throw a potato. getAction allows us to determine that the action was a right click and getMaterial tells us what kind of object the player was holding in their hands. If it was a poisonous potato we launch an egg to simulate the potato being thrown. That bit should be self explanatory. We also need to modify the ItemStack in the player's hand to indicate one less potato, but now we come across a bug. It turns out that modifying a player's inventory in a right-click handler does not work reliably. (This is the reason why the deposit signs in the game currently only accept left-clicks.) However, there is a work around, and that is to farm off the inventory modification to a separate task, which I call the PostPotatoThrowCallback. This task is submitted to the Bukkit scheduler for running ASAP, which means it will be appended to the list of things to do in the current tick. So that you're not left wondering, here is the PostPotatoThrowCallback task:

	private class PostPotatoThrowCallback implements Runnable
	{
		final Player player;
		
		PostPotatoThrowCallback (Player player)
		{
			this.player = player;
		}
		@Override 
		public void run ()
		{
			PlayerInventory inv = player.getInventory();
			ItemStack stack = inv.getItemInHand ();
			if (stack != null)
				stack.setAmount (stack.getAmount() -1);
			inv.setItemInHand (stack);
		}
	}

The above class implements Runnable which is an interface used to indicate that a class contains a method called run(), that will be called by some sort of scheduler at some point, one or more times in the future. The scheduler in this case is the Bukkit scheduler, which for the most part schedules things synchronised to the game tick. Whilst runnables are commonly associated with multi-threading it should be noted that the above code will be called in the main server thread, the same thread as the event handler that scheduled it. More on multi-threading in a later episode.

Saving and restoring players' inventory etc


I previously mentioned a class called PlayerSave. This is concerned with whisking the player out of context and putting them into the minigame. We want to preserve a copy of their health, inventory, location etc before they join the minigame, and restore all that information afterwards. Someone part way through a game of Goldrush will actually have two PlayerSaves associated with them, one that is used for restoring their state when they return to the lobby, (the waiting area) and one that is used when they quit the game entirely. Note that this code does not save anything to disk, this has two important implications: firstly if the server crashes then the PlayerSave data will be lost (that's never happened yet, and wouldn't be a particuarly big problem on that server). Secondly, we need to make sure we apply the restores to all players in the event of server shutdown or reload. So an OP reloads the server, all players will just get the message "Goldrush closed!" and be returned to their pre-game state. Finally, if a player disconnects from the server, this is taken as the player having left the minigame, which also causes their pre-game state to be restored, ready for reconnect.

PlayerSave is used as follows
  • The constructor is called to save the state of the player into a new PlayerSave object
  • The restore method is used to restore the state
  • The static method resetPlayer sets the player's state to a post-spawn-like condition.
Here is the PlayerSave class in full.

import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;

class PlayerSave 
{

	private final Player player;

	private final Location returnLocation;

	private final float exhaustion;
	private final float saturation;
	private final int totalExperience;
	private final int expLevel;
	private final float exp;
	private final int foodLevel;
	private final int health;

	// Inventory
	private final ItemStack[] armor;
	private final ItemStack[] inventoryMain;
	
	public PlayerSave (Player p)
	{
		player = p;

		returnLocation = p.getLocation ();

		exhaustion = p.getExhaustion ();
		saturation = p.getSaturation ();
		totalExperience = p.getTotalExperience ();
		expLevel = p.getLevel();
		exp = p.getExp();
		foodLevel = p.getFoodLevel();
		health = p.getHealth();
		
		PlayerInventory inv = p.getInventory();
		armor = inv.getArmorContents();
		inventoryMain = inv.getContents(); 
	}

	public void restore ()
	{
		player.setExhaustion (exhaustion);
		player.setSaturation (saturation);
		player.setTotalExperience (totalExperience);
		player.setLevel (expLevel);
		player.setExp (exp);
		player.setFoodLevel (foodLevel);
		player.setHealth (health);
		
		PlayerInventory inv = player.getInventory();
		inv.setArmorContents (armor);
		inv.setContents (inventoryMain);
		
		player.teleport (returnLocation);
		
	}
	
	public static void resetPlayer (Player p)
	{
		PlayerInventory inv = p.getInventory();
		ItemStack emptyArmor[] = {null, null, null, null};
		inv.clear();
		inv.setArmorContents (emptyArmor);
		
		p.setHealth (20);
		p.setSaturation (20);
		p.setTotalExperience (0);
		p.setLevel (0);
		p.setExp (0);
		p.setExhaustion (0);
		p.setFoodLevel (20);
	}

}
Top