I'm currently rewriting Mazebert in Java with the goal to add multiplayer. In order to do this, I need to decouple all game logic code from client code. The game logic is then a separate module and runs a deterministic simulation in its own thread.
While being quite the opposite of what game programming was for me all these years - stuffing everything into the scene graph of the used rendering framework - it brings a lot of opportunities in terms of programming. One of them is the ability to easily unit test all game logic. When porting the nature towers of the game, I've written unit tests for almost every tower, and it's been super fun. I was even able to understand and fix some problems of the old game:
- Frog poison was doing less than the promised 100% DoT
- Kills from frog DoT did not drop cards
- Huli's aura says all femal towers in range, but did not scale with range changing items
- Balu's cuddle did not scale with attack speed modifier
For example, here's the unit test code for Holgar the Horrible:
class VikingTest extends SimTest {
RandomPluginTrainer randomPluginTrainer = new RandomPluginTrainer();
DamageSystemTrainer damageSystemTrainer;
Wizard wizard;
Viking viking;
Creep creep;
@BeforeEach
void setUp() {
simulationListeners = new SimulationListeners();
randomPlugin = randomPluginTrainer;
unitGateway = new UnitGateway();
projectileGateway = new ProjectileGateway();
damageSystem = damageSystemTrainer = new DamageSystemTrainer(simulationListeners);
lootSystem = new LootSystemTrainer();
commandExecutor = new CommandExecutor();
commandExecutor.init();
wizard = new Wizard();
unitGateway.addUnit(wizard);
viking = new Viking();
viking.setWizard(wizard);
unitGateway.addUnit(viking);
creep = new Creep();
unitGateway.addUnit(creep);
}
@Test
void attack_canHitSameCreepWithTwoAxesAtOnce() {
whenVikingAttacks();
assertThat(creep.getHealth()).isEqualTo(80);
}
@Test
void attack_canHitTwoCreeps() {
Creep anotherCreep = new Creep();
unitGateway.addUnit(anotherCreep);
whenVikingAttacks();
assertThat(creep.getHealth()).isEqualTo(90);
assertThat(anotherCreep.getHealth()).isEqualTo(90);
}
@Test
void kill_canDropMead_drop() {
randomPluginTrainer.givenFloatAbs(0.0f);
damageSystemTrainer.givenConstantDamage(1000);
whenVikingAttacks();
assertThat(wizard.potionStash.get(PotionType.Mead).getAmount()).isEqualTo(1);
}
@Test
void kill_canDropMead_noDrop() {
randomPluginTrainer.givenFloatAbs(0.9f);
damageSystemTrainer.givenConstantDamage(1000);
whenVikingAttacks();
assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
}
@Test
void kill_canDropMead_badDropChanceOfCreep_noDrop() {
randomPluginTrainer.givenFloatAbs(0.0f);
damageSystemTrainer.givenConstantDamage(1000);
creep.setDropChance(0);
whenVikingAttacks();
assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
}
@Test
void drinksMead() {
givenMeadPotionIsDrank();
givenMeadPotionIsDrank();
givenMeadPotionIsDrank();
CustomTowerBonus bonus = new CustomTowerBonus();
viking.populateCustomTowerBonus(bonus);
assertThat(bonus.title).isEqualTo("Mead:");
assertThat(bonus.value).isEqualTo("3");
}
@Test
void drinksNoMead() {
CustomTowerBonus bonus = new CustomTowerBonus();
viking.populateCustomTowerBonus(bonus);
assertThat(bonus.title).isEqualTo("Mead:");
assertThat(bonus.value).isEqualTo("0");
}
@Test
void drinksMead_hasEffects() {
givenMeadPotionIsDrank();
assertThat(viking.getChanceToMiss()).isEqualTo(0.01f);
givenMeadPotionIsDrank();
assertThat(viking.getChanceToMiss()).isEqualTo(0.02f);
}
private void givenMeadPotionIsDrank() {
wizard.potionStash.add(PotionType.Mead);
DrinkPotionCommand command = new DrinkPotionCommand();
command.potionType = PotionType.Mead;
command.playerId = wizard.getPlayerId();
commandExecutor.executeVoid(command);
}
private void whenVikingAttacks() {
viking.simulate(3.0f);
projectileGateway.simulate(1.0f);
projectileGateway.simulate(1.0f);
}
}
And here is the production code of the tower:
public strictfp class Viking extends Tower {
public Viking() {
setBaseCooldown(3.0f);
setBaseRange(2.0f);
setAttackType(AttackType.Fal);
setStrength(0.89f);
setDamageSpread(0.16f);
setGender(Gender.Male);
setElement(Element.Nature);
addAbility(new ProjectileDamageAbility(ProjectileViewType.Axe, 6));
addAbility(new VikingMultishot());
addAbility(new VikingMead());
}
@Override
protected float getGoldCostFactor() {
return 1.1f;
}
@Override
public String getName() {
return "Holgar the Horrible";
}
@Override
public String getDescription() {
return "Holgar is the terror of the seven seas with a slight alcohol problem.";
}
@Override
public String getAuthor() {
return "Holger Herbricht";
}
@Override
public Rarity getRarity() {
return Rarity.Rare;
}
@Override
public int getItemLevel() {
return 50;
}
@Override
public String getSinceVersion() {
return "0.3";
}
@Override
public String getModelId() {
return "viking";
}
@Override
public int getImageOffsetOnCardY() {
return 8;
}
@Override
public void populateCustomTowerBonus(CustomTowerBonus bonus) {
bonus.title = "Mead:";
bonus.value = "" + getConsumedMeadCount();
}
private int getConsumedMeadCount() {
MeadAbility meadPotionAbility = getAbility(MeadAbility.class);
if (meadPotionAbility == null) {
return 0;
} else {
return meadPotionAbility.getStackCount();
}
}
}
With the ability to attack two targets:
public strictfp class VikingMultishot extends AttackAbility {
public VikingMultishot() {
super(2, true);
}
@Override
public boolean isVisibleToUser() {
return true;
}
@Override
public String getTitle() {
return "Double Throw";
}
@Override
public String getDescription() {
return "Holgar throws two axes at once. He likes the sound of two cracking skulls.";
}
@Override
public String getIconFile() {
return "0026_axeattack2_512";
}
}
And the ability to find mead:
public strictfp class VikingMead extends Ability<Tower> implements OnKillListener {
private static final float CHANCE = 0.01f;
private final LootSystem lootSystem = Sim.context().lootSystem;
@Override
protected void initialize(Tower unit) {
super.initialize(unit);
unit.onKill.add(this);
}
@Override
protected void dispose(Tower unit) {
unit.onKill.remove(this);
super.dispose(unit);
}
@Override
public void onKill(Creep target) {
if (getUnit().isAbilityTriggered(CHANCE * StrictMath.min(1, target.getDropChance()))) {
Wizard wizard = getUnit().getWizard();
lootSystem.dropCard(wizard, target, wizard.potionStash, PotionType.Mead);
}
}
@Override
public boolean isVisibleToUser() {
return true;
}
@Override
public String getTitle() {
return "Drink for Victory";
}
@Override
public String getDescription() {
return "Whenever Holgar kills a creep, there is a " + format.percent(CHANCE) + "% chance he finds a bottle of mead.";
}
@Override
public String getIconFile() {
return "9005_MeadPotion";
}
}