Shots Fired
Now that we can move our player, let’s actually have some fun, shall we?
Objectives
- Create projectiles that get updated in position over time
- Prevent projectiles from going through obstacles/animals/players
- Replace the animal/player with some resource/gold on death
The FireProjectileSystem
is also a request-based system, but it is unique in that it queues a request to another system internally.
type FireProjectileRequest struct {
Direction helper.Direction `json:"direction"`
PlayerId int `json:"playerId"`
}
var FireProjectileSystem = server.CreateSystemFromRequestHandler(func(ctx *server.TransactionCtx[FireProjectileRequest]) {
req := ctx.Req.Data
w := ctx.W
direction := req.Direction
initialPosition, found := locationOfPlayer(w, req.PlayerId)
if !found {
return
}
projectileID := data.Projectile.Add(w, data.ProjectileSchema{ // Explanation 1
Position: initialPosition,
})
tickNumber := ctx.GameCtx.GameTick.TickNumber + constants.BulletSpeed
server.QueueTxFromInternal[UpdateProjectileRequest](w, tickNumber, server.NewKeystoneTx(UpdateProjectileRequest{ // Explanation 2
Direction: direction,
ProjectileID: projectileID,
PlayerID: req.PlayerId,
}, nil), "")
}, VerifyWalletAndIdentity[FireProjectileRequest]())
func locationOfPlayer(w state.IWorld, playerId int) (state.Pos, bool) {
playerEntity := data.Player.Filter(w, data.PlayerSchema{PlayerId: playerId}, []string{"PlayerId"})
if len(playerEntity) == 0 {
return state.Pos{}, false
}
player := data.Player.Get(w, playerEntity[0])
return player.Position, true
}
type UpdateProjectileRequest struct {
Direction helper.Direction
ProjectileID int
PlayerID int
}
var UpdateProjectileSystem = server.CreateSystemFromRequestHandler(func(ctx *server.TransactionCtx[UpdateProjectileRequest]) {
w := ctx.W
req := ctx.Req.Data
// get projectile's position
projectile := data.Projectile.Get(w, req.ProjectileID)
nextPosition := helper.TargetTile(projectile.Position, req.Direction)
// check collisions
collision := updateWorldForCollision(w, nextPosition)
if collision {
// if collided, remove the projectile
data.Projectile.RemoveEntity(w, req.ProjectileID)
} else {
// update the position of the projectile
projectile.Position = nextPosition
data.Projectile.Set(w, req.ProjectileID, projectile)
// queue the next projectile update
tickNumber := ctx.GameCtx.GameTick.TickNumber + constants.BulletSpeed
server.QueueTxFromInternal[UpdateProjectileRequest](w, tickNumber, server.NewKeystoneTx(UpdateProjectileRequest{
Direction: req.Direction,
ProjectileID: req.ProjectileID,
PlayerID: req.PlayerID,
}, nil), "")
}
})
We get the position of the player and add a projectile in that location. We start the projectile from the position of a player since we can be sure that the projectile will be fine to start there.
direction := req.Direction
initialPosition, found := locationOfPlayer(w, req.PlayerId)
if !found {
return
}
projectileID := data.Projectile.Add(w, data.ProjectileSchema{ // Explanation 1
Position: initialPosition,
})
We queue a job to the UpdateProjectileSystem
in constants.BulletSpeed
more ticks.
tickNumber := ctx.GameCtx.GameTick.TickNumber + constants.BulletSpeed
server.QueueTxFromInternal[UpdateProjectileRequest](w, tickNumber, server.NewKeystoneTx(UpdateProjectileRequest{ // Explanation 2
Direction: direction,
ProjectileID: projectileID,
PlayerID: req.PlayerId,
}, nil), "")
}, VerifyWalletAndIdentity[FireProjectileRequest]())
In the UpdateProjectileSystem
, we check for collisions using Filter
and update the position of the projectile one more step in its intended direction if possible.
collision := updateWorldForCollision(w, nextPosition) // uses filters for animals/players
if collision {
// if collided, remove the projectile
data.Projectile.RemoveEntity(w, req.ProjectileID)
} else {
// update the position of the projectile
projectile.Position = nextPosition
data.Projectile.Set(w, req.ProjectileID, projectile)
// queue the next projectile update
tickNumber := ctx.GameCtx.GameTick.TickNumber + constants.BulletSpeed
server.QueueTxFromInternal[UpdateProjectileRequest](w, tickNumber, server.NewKeystoneTx(UpdateProjectileRequest{
Direction: req.Direction,
ProjectileID: req.ProjectileID,
PlayerID: req.PlayerID,
}, nil), "")
}
-
On collision, we apply the effects of the collision to the map, including the dropping of resources, or just the removal of the projectile entity.
players := playersAtLocation(w, position) if len(players) != 0 { collision = true for _, player := range players { data.Player.RemoveEntity(w, player) } data.Resource.Add(w, data.ResourceSchema{ Position: position, Amount: constants.PlayerGold, }) }
It is usually best for design in general to have one system be responsible for one thing, whether that’s creating the projectile or updating its location. Although it is a bit weird to start a projectile in the same position as the player, it is a tradeoff we were willing to make.
Note: Be careful about queuing jobs for the future, because if the system doesn't tick on the exact tick you scheduled it on, then you are going to miss the update.
When you are doing something like this:
tickNumberToScheduleJobFor := ctx.GameCtx.GameTick.TickNumber + [TickIncrement]
Two tips (TickInterval
refers to the system you are queuing a job to) :
- Make sure that your
TickIncrement
is a multiple of theTickInterval
/TickRate
- Make sure that the
TickInterval
%TickRate
is 0. We round down if the expected tick is a decimal, and it’s nice to avoid changing intervals when scheduling jobs.