This article is part of a series.
Prev: «How To Make a Roguelike: #7 Stationary Monsters Next: How To Make a Roguelike: #9 A Multi-level Dungeon »
We already have monsters in our game but hitting them just results in an instagib. Now we’re going to improve this by adding real combat to our game!
Our Fight Club
How combat works is a central question in most games having it. This tutorial is not about game design though so we’ll go with a very simple model: the damage amount is a random number from 1
to the attacker’s attack value minus the defenders defense value. We know the drill, so let’s create an Attribute
first which will hold our CombatStats
:
package com.example.cavesofzircon.attributes
import org.hexworks.amethyst.api.base.BaseAttribute
import org.hexworks.cobalt.databinding.api.extension.createPropertyFrom
import org.hexworks.cobalt.databinding.api.property.Property
data class CombatStats(
val maxHpProperty: Property<Int>, // 1
val hpProperty: Property<Int> = createPropertyFrom(maxHpProperty.value), // 2
val attackValueProperty: Property<Int>,
val defenseValueProperty: Property<Int>
) : BaseAttribute() { // 3
val maxHp: Int by maxHpProperty.asDelegate() // 4
var hp: Int by hpProperty.asDelegate() // 5
val attackValue: Int by attackValueProperty.asDelegate()
val defenseValue: Int by defenseValueProperty.asDelegate()
companion object {
fun create(maxHp: Int, hp: Int = maxHp, attackValue: Int, defenseValue: Int) = // 6
CombatStats(
maxHpProperty = createPropertyFrom(maxHp),
hpProperty = createPropertyFrom(hp),
attackValueProperty = createPropertyFrom(attackValue),
defenseValueProperty = createPropertyFrom(defenseValue)
)
}
}
Wait, what is this
Property
thing? AProperty
wraps a value and augments it with features like data binding and the ability to listen to its changes. We’ll take a look at these features soon enough!
Here we
- Take the maximum health as a property
- And we set the initial health to be equal to it
- We implement
Attribute
as usual asDelegate
needs some explanation: it uses theby
keyword to delegate fetching the value of the above fields (maxHp
,hp
,attackValue
anddefenseValue
) to the respective properties. I’ve written about the topic of delegation here if you’re interested. The TL;DR is thatProperty
has agetValue
and asetValue
function so it can be used to represent a field likemaxHp
hp
is avar
because we’re going to change it when damage is taken- It is quite common to have factory methods on classes like this. We need it here because by using it
we keep the logic which is necessary for creating
CombatStats
within the class so its users will just have to supply the necessary parameters and nothing else
Now let’s create a new interface which will be used by our Combatant
s:
package com.example.cavesofzircon.attributes.types
import com.example.cavesofzircon.attributes.CombatStats
import com.example.cavesofzircon.extensions.GameEntity
import org.hexworks.amethyst.api.entity.EntityType
interface Combatant : EntityType // 1
val GameEntity<Combatant>.combatStats: CombatStats // 2
get() = findAttribute(CombatStats::class).get()
So why did we do this?
- First, this interface extends
EntityType
so we can use it to mark anyEntity
we create later on as aCombatant
which lets us write - the extension property
combatStats
which just fetches theCombatStats
of aGameEntity
which has theEntityType
Combatant
.
This code above is useful because our code handling combat can be much more expressive. Instead of having to do entity.findAttribute(CombatStats::Class).get()
every time we want to access CombatStats
we can just do entity.combatStats
which is more readable. We’ll see how this works soon but until then let’s make our player and the fungus a Combatant
:
object Player : BaseEntityType(
name = "player"
), Combatant
object Fungus : BaseEntityType(
name = "fungus"
), Combatant
As we’ve previously added Attack
let’s see how that will change to incorporate our new Combatant
type:
// add these to Attack
import com.example.cavesofzircon.attributes.types.Combatant
data class Attack(
override val context: GameContext,
override val source: GameEntity<Combatant>,
override val target: GameEntity<Combatant>
) : EntityAction<Combatant, Combatant>
Oh wait! Attack
will only work with entities which are Combatant
s, so we can use our extension property!
So now we have to take a look at how our Attackable
Facet
will change. First let’s think about what would happen if we just add this Facet
to all our entities which can get into combat…yep, they would attack each other, not just the player so first let’s create an extension property on AnyGameEntity
so it is easy for us to determine the EntityType
:
// add this to EntityExtensions.kt
import com.example.cavesofzircon.attributes.types.Player
val AnyGameEntity.isPlayer: Boolean
get() = this.type == Player
Another useful thing our Combatant
s can report about themselves is whether they are ready to be destroyed (eg: their health is equal to or below zero). This can also be added to all Combatant
entities:
// put this in EntityExtensions.kt
import com.example.cavesofzircon.attributes.types.combatStats
import com.example.cavesofzircon.attributes.types.Combatant
fun GameEntity<Combatant>.hasNoHealthLeft(): Boolean = combatStats.hp <= 0
One final thing to add before we modify Attackable
is a new message, Destroy
which “asks” an Entity
do destroy itself if the combat system determines that it is time to go:
Why are we separating destruction? The reason is that there are a bunch of use cases when this is useful. One of them is having an invulnerability potion or an indestructible perk which will prevent an
Entity
from being destroyed even in the most dire circumstances (like reaching zero hp). More on this later!
package com.example.cavesofzircon.messages
import com.example.cavesofzircon.extensions.GameEntity
import com.example.cavesofzircon.extensions.GameMessage
import com.example.cavesofzircon.world.GameContext
import org.hexworks.amethyst.api.entity.EntityType
data class Destroy(
override val context: GameContext,
override val source: GameEntity<EntityType>, // 1
val target: GameEntity<EntityType>, // 2
val cause: String = "natural causes."
) : GameMessage // 3
source
is the destroyertarget
is theEntity
being destroyed- we also supply a
cause
which tells us why theEntity
is being destroyed.
Note that we’re not dealing with
Combatant
s here! The reason is that it is possible to destroy something without combat. Think about having a spell likeAnnihilate
which will just erase something from existence.
With the new things in place Attackable
will look like this:
package com.example.cavesofzircon.systems
import com.example.cavesofzircon.attributes.types.combatStats
import com.example.cavesofzircon.extensions.hasNoHealthLeft
import com.example.cavesofzircon.extensions.isPlayer
import com.example.cavesofzircon.messages.Attack
import com.example.cavesofzircon.messages.Destroy
import com.example.cavesofzircon.world.GameContext
import org.hexworks.amethyst.api.Consumed
import org.hexworks.amethyst.api.Pass
import org.hexworks.amethyst.api.Response
import org.hexworks.amethyst.api.base.BaseFacet
import kotlin.math.max
object Attackable : BaseFacet<GameContext, Attack>(Attack::class) {
override suspend fun receive(message: Attack): Response {
val (context, attacker, target) = message
return if (attacker.isPlayer || target.isPlayer) { // 1
val damage = max(0, attacker.combatStats.attackValue - target.combatStats.defenseValue) // 2
val finalDamage = (Math.random() * damage).toInt() + 1 // 3
target.combatStats.hp -= finalDamage // 4
if (target.hasNoHealthLeft()) { // 5
target.sendMessage(
Destroy( // 6
context = context,
source = attacker,
target = target,
cause = "a blow to the head"
)
)
}
Consumed // 7
} else Pass
}
}
This is how it works:
- First we make sure that either the
attacker
or thetarget
is a player so monsters won’t kill each other - We calculate the
damage
. We can do this without fetchingAttribute
s manually becausesource
andtarget
inAttack
areCombatant
s. - We add
1
todamage
to calculate thefinalDamage
to make sure that there are no super-strong entities we have no hope of damaging - When the
finalDamage
is determined we just decrement thehp
of thetarget
- And when it has no health left
- We try to
Destroy
it - Then we return
Consumed
orPass
if no player was involved
Now let’s see the CombatStats
we’re gonna give to the player and the fungus:
// modify EntityFactory.kt with these
import com.example.cavesofzircon.attributes.CombatStats
// add this to the player entity's attributes
CombatStats.create(
maxHp = 100,
attackValue = 10,
defenseValue = 5
)
// and this to the fungus entity's attributes
CombatStats.create(
maxHp = 10,
attackValue = 0,
defenseValue = 0
)
So our player has 100
max hp, 10
attack and 5
defense value. Our fungus can’t attack so it only has hp, which is 10
. Let’s keep it simple for now.
Now if we start the game and attack something…then nothing happens. This is because no one handles Destroy
yet! Let’s add Destructible
:
package com.example.cavesofzircon.systems
import com.example.cavesofzircon.messages.Destroy
import com.example.cavesofzircon.world.GameContext
import org.hexworks.amethyst.api.Consumed
import org.hexworks.amethyst.api.Response
import org.hexworks.amethyst.api.base.BaseFacet
object Destructible : BaseFacet<GameContext, Destroy>(Destroy::class) {
override suspend fun receive(message: Destroy): Response {
val (context, _, target) = message
context.world.removeEntity(target)
return Consumed
}
}
and make fungus have it
// modify EntityFactory.kt with these
import com.example.cavesofzircon.systems.Destructible
// add this to the fungus entity:
facets(Attackable, Destructible)
Now if we start up our game we can see that the fungi are dying a bit more slowly:
Logging combat messages
Fighting fungi is pure fun but we don’t really know what happens when we bump them. Let’s start using the LogArea
which we’ve added to our PlayView
. There is a question though: how do we have access to it from our entities? We could put it into the GameContext
but then it will start to grow until it becomes a god object having a reference to everything. We can be smarter than that by using Zircon’s EventBus
instead. This is how it is going to work:
- We create an event object which holds our game messages
- We start listening to that event in our
PlayView
- We send that message whenever something important happens in our game
Let’s start by adding an Event
:
package com.example.cavesofzircon.events
import org.hexworks.cobalt.events.api.Event
data class GameLogEvent(
val text: String,
override val emitter: Any
) : Event
Using the
EventBus
supplied by Zircon is a good idea when we want to bridge the gap between the game world handled by Amethyst and the UI itself which is managed by Zircon. Note thatemitter
here will represent the object that sent the message. This is necessary for theEventBus
to prevent infinite event loops.
Now let’s add a global function with which we can send game log events:
// put this in a file called Functions.kt
package com.example.cavesofzircon.functions
import com.example.cavesofzircon.events.GameLogEvent
import org.hexworks.zircon.internal.Zircon
fun logGameEvent(text: String, emitter: Any) { // 1
Zircon.eventBus.publish(GameLogEvent(text, emitter)) // 2
}
Here we
- Create a global function which can be accessed from anywhere
- And send a
GameLogEvent
using Zircon’sEventBus
with the giventext
Now let’s augment Attackable
:
import com.example.cavesofzircon.functions.logGameEvent
target.combatStats.hp -= finalDamage
logGameEvent("The $attacker hits the $target for $finalDamage!", Attackable)
and Destructible
with some funky log messages:
// modify Attackable with these
import com.example.cavesofzircon.functions.logGameEvent
object Destructible : BaseFacet<GameContext, Destroy>(Destroy::class) {
override suspend fun receive(message: Destroy): Response {
val (context, _, target, cause) = message
context.world.removeEntity(target)
logGameEvent("$target dies after receiving $cause.", Destructible)
return Consumed
}
}
Now we just have to start listening to GameLogEvent
in our PlayView
to make this work:
import org.hexworks.cobalt.events.api.KeepSubscription
import org.hexworks.zircon.internal.Zircon
import org.hexworks.cobalt.events.api.subscribeTo
import com.example.cavesofzircon.events.GameLogEvent
// add this to the end of the init block
Zircon.eventBus.subscribeTo<GameLogEvent> { (text) -> // 1
logArea.addParagraph( // 2
paragraph = text,
withNewLine = false, // 3
withTypingEffectSpeedInMs = 10 // 4
)
KeepSubscription // 5
}
Note that for this to work you might have to add this to
build.gradle.kts
. It’s because by default the JVM target is 1.6 and some advanced features won’t work with that. Don’t forget to also click “Reload all Gradle projects” on the Gradle tab as well:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val compileKotlin: KotlinCompile by tasks
compileKotlin.kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString()
Here we:
subscribe
to theGameLogEvent
message.- We add a paragraph to our
LogArea
- Without adding a new line
- But with some funky typing effect!
- We also have to make sure that the listener isn’t unsubscribed after receiving an event.
If you want to do that just return
DisposeSubscription
instead ofKeepSubscription
.
Let’s see it in action:
Well, that’s really funky!
Conclusion
In this article we added real combat with combat stats and also learned how to use extension properties and generic type parameters to our advantage. We also started using Zircon’s eventing capabilities to greatly simplify how we interact with the UI to display messages!
In the next article we’re going to create some stairs we can use to climb further down to the depths of our dungeon!
Until then go forth and kode on!
The code of this article can be found in commit #8.
Do you have questions? Ask us on our Discord Server.
If you like what we do and want to support us consider becoming a Patron.