There are cases when some piece of game logic is triggered by multiple different events. Consider Quake 3 Arena. There are several ways to select a weapon:
If Q3 was built following the ECS pattern and if Systems were small, then these three preconditions of weapon selection would probably be handled by different Systems. And it feels natural to decouple the part which switches events and extract it to another System. Normally Systems operate on Components, so first thing that comes to mind is to create a Component like “IntentWeaponChange” and put data there, then process it in a System. But this component does not really represent state, it’s just for an event. Feels like a workaround. And instead of a workaround of this kind, Cotta provides mechanism of Effects.
Effect is a notification about something that happened. Effect is processed in the same tick it was created, provided the System handling it is invoked after the System that fired it.
Let’s modify the example from the Quick start to demonstrate possible usage of Effects. We will add shooting to that game. We need:
First we one more parameter: isShooting
data class ShowcasePlayerInput(
val walkingDirection: Byte,
val isShooting: Boolean
) : PlayerInput
And handle button press:
class ShowcaseCottaClientGdxInput : CottaClientGdxInput {
private val storage = Storage()
override fun accumulate() {
with(storage) {
// ...
shootPressed = shootPressed || Gdx.input.isKeyPressed(Input.Keys.SPACE)
}
}
override fun input(): ShowcasePlayerInput {
return ShowcasePlayerInput(
walkingDirection = /* ... */
isShooting = storage.shootPressed
).also { clear() }
}
private fun clear() {
// ...
storage.shootPressed = false
}
private class Storage {
// ...
var shootPressed: Boolean = false
}
}
Since we use ControllableComponent
there as a Component which Systems use, we
also add isShooting
to it:
interface ControllableComponent : MutableComponent<ControllableComponent> {
var direction: Byte
var isShooting: Boolean
val playerId: PlayerId
}
And we need to modify input processing to pass this new Boolean
from input to
the component:
class ShowcaseInputProcessing : InputProcessing {
override fun processPlayerInput(
playerId: PlayerId,
input: PlayerInput,
entities: Entities,
effectBus: EffectBus
) {
// ...
controllable.isShooting = input.isShooting
}
// ...
}
This is not exactly necessary to demonstrate Effects, but it makes sense from the gameplay perspective, and it doesn’t hurt to once again touch components to get more used to them.
Since we are making our little square shoot, we need to know where to shoot. We will introduce orientation to the square, so that when the shoot button is pressed, it shoots at the direction the square is facing.
interface PositionComponent : MutableComponent<PositionComponent> {
@Interpolated var x: Float
@Interpolated var y: Float
var orientation: Byte
}
object Orientation {
const val UP: Byte = 0
const val RIGHT: Byte = 1
const val DOWN: Byte = 2
const val LEFT: Byte = 3
}
Now try gradle clean build
and see compilation errors in the
ShowcasePlayersHandler
class. Since we have modified PositionComponent
and
ControllableComponent
, their creation functions were regenerated, and now they
require new parameters:
override fun onEnterGame(playerId: PlayerId, entities: Entities) {
entities.create(ownedBy = Entity.OwnedBy.Player(playerId)).apply {
addComponent(createControllableComponent(
direction = WalkingDirections.IDLE,
isShooting = false,
playerId = playerId
))
addComponent(createPositionComponent(
x = 120f,
y = 120f,
orientation = Orientation.RIGHT
))
}
}
Let’s define the effect that we will use to shoot. Akin to Components, Effects
are defined as interfaces. They must implement CottaEffect
and be immutable.
And, just like Components, they have to be defined in the same or nested
package as the class implementing CottaGame
annotated with @Game
.
interface ShootEffect : CottaEffect {
val shooterId: EntityId
}
The idea is to fire this effect from one system and handle it in another. That another system will retrieve the shooter’s position and orientation and create a bullet accordingly.
So, the system that fires the effect (again, execute gradle clean build
to
generate the createShootEffect
function):
class ShootingSystem : EntityProcessingSystem {
override fun process(e: Entity, ctx: EntityProcessingContext) {
if (e.hasComponent(ControllableComponent::class) &&
e.hasComponent(PositionComponent::class)
) {
val controllable = e.getComponent(ControllableComponent::class)
if (controllable.isShooting) {
ctx.fire(createShootEffect(e.id))
}
}
}
}
Note that we check the existence of PositionComponent
even though we don’t use
it here. That’s because we can’t shoot without knowing the shooter’s position.
And now the system that handles the effect:
class ShootEffectConsumer : EffectsConsumerSystem<ShootEffect> {
override val effectType: Class<ShootEffect> = ShootEffect::class.java
override fun handle(e: ShootEffect, ctx: EffectProcessingContext) {
val shooter = ctx.entities().get(e.shooterId) ?: return
val position = shooter.getComponent(PositionComponent::class)
val bullet = ctx.createEntity()
bullet.addComponent(createPositionComponent(position.x, position.y, position.orientation))
}
}
And we need to mention them in the class implementing CottaGame
:
@Game
class ShowcaseGame : CottaGame {
// ...
override val systems: List<CottaSystem> = listOf(
MovementSystem(),
ShootingSystem(),
ShootEffectConsumer()
)
}
Let’s take a moment to gradle clean build
, then gradle server:run
and
gradle lwjgl3:run
to see what we have. Now after we press the space button,
new entity is created in the same position. Since we draw all entities as
squares regardless of their components, “bullets” are also just squares. And
they don’t move, because we haven’t implemented their movement yet. You can find
the code for this checkpoint here.
But for the basic demonstration of Effects, this is enough.
The remaining part of this document is just a straightforward exercise to make
bullets move and to just get hands dirty in code once agan, feel free to skip,
you can just checkout the next revision
of cotta-showcase
.
So what we have to do is add a VelocityComponent and move bullets.
interface VelocityComponent : MutableComponent<VelocityComponent> {
var x: Float
var y: Float
}
Since we already have MovementSystem, it makes sense to extract the movement
control logic to a separate system. We will call it MovementControlSystem
. And
what the original MovementSystem
will do is to move entities using their
VelocityComponent
.
@Predicted
class MovementControlSystem : EntityProcessingSystem {
override fun process(e: Entity, ctx: EntityProcessingContext) {
if (e.hasComponent(ControllableComponent::class) &&
e.hasComponent(VelocityComponent::class) &&
e.hasComponent(PositionComponent::class)
) {
val controllable = e.getComponent(ControllableComponent::class)
val velocity = e.getComponent(VelocityComponent::class)
velocity.x = when (controllable.direction) {
WalkingDirections.LEFT -> -300f
WalkingDirections.RIGHT -> 300f
else -> 0f
}
velocity.y = when (controllable.direction) {
WalkingDirections.UP -> 300f
WalkingDirections.DOWN -> -300f
else -> 0f
}
val position = e.getComponent(PositionComponent::class)
position.orientation = when (controllable.direction) {
WalkingDirections.UP -> Orientation.UP
WalkingDirections.RIGHT -> Orientation.RIGHT
WalkingDirections.DOWN -> Orientation.DOWN
WalkingDirections.LEFT -> Orientation.LEFT
else -> position.orientation
}
}
}
}
@Predicted
class MovementSystem : EntityProcessingSystem {
override fun process(e: Entity, ctx: EntityProcessingContext) {
if (e.hasComponent(VelocityComponent::class) &&
e.hasComponent(PositionComponent::class)
) {
val velocity = e.getComponent(VelocityComponent::class)
val position = e.getComponent(PositionComponent::class)
position.x += velocity.x * ctx.clock().delta()
position.y += velocity.y * ctx.clock().delta()
}
}
}
And since we’re now using VelocityComponent
, we need to add it to both players
and bullets:
class ShowcasePlayersHandler : PlayersHandler {
override fun onEnterGame(playerId: PlayerId, entities: Entities) {
entities.create(ownedBy = Entity.OwnedBy.Player(playerId)).apply {
// ...
addComponent(createVelocityComponent(
x = 0f,
y = 0f
))
}
}
// ...
}
class ShootEffectConsumer : EffectsConsumerSystem<ShootEffect> {
override val effectType: Class<ShootEffect> = ShootEffect::class.java
override fun handle(e: ShootEffect, ctx: EffectProcessingContext) {
val shooter = ctx.entities().get(e.shooterId) ?: return
val position = shooter.getComponent(PositionComponent::class)
val bullet = ctx.createEntity()
bullet.addComponent(createPositionComponent(position.x, position.y, position.orientation))
val velX = when (position.orientation) {
Orientation.RIGHT -> 800f
Orientation.LEFT -> -800f
else -> 0f
}
val velY = when (position.orientation) {
Orientation.UP -> 800f
Orientation.DOWN -> -800f
else -> 0f
}
bullet.addComponent(createVelocityComponent(velX, velY))
}
}
This is enough. Again, gradle build
, gradle server:run
, gradle lwjgl3:run
and see bullets moving.