Roguelike, step 8: projectiles

Previous tutorial – Download files – Next tutorial

You want to do what? Where? – Getting all touchy feely – Death from a distance – Flight time – Jagged little lines 

You want to do what? Where?

This is what our game looks like now.  Our player can direct the hero to wander around and bump into monsters, hoping to kill them with her sword.

old

This is where we want to be with the hero using her bow to shoot an arrow at a goblin.

new

So we need to give the player the power to select which weapon to use and to click directly on the screen to fire an arrow.  To make this happen, the first thing we’re going to need is icons.  By icons we mean buttons.  And by buttons, I mean two images, one to show the icon and another to highlight the icon.   So, after a quick visit to our favorite gaming icons website and some playing around in our favorite paint program, let’s import something texturepack-icons-144px.png into our program.

texturepack-icons-144px

As you can see, I decided to redo the compass buttons too.  Instead of having individual files for each image, I decided to put all the icons together in some image file.  We can load our Texture once and when peel off parts we need for each button with TextureRegion.new().  So let’s revisit MainScreen.lua and get our new icon images on the screen.

 local iconTexture = Texture.new("images/texturepack-icons-144px.png", true)
 --these variables are for the buttons that respond to user input
 local compass = Sprite.new()
 self.north = {}
 self.south = {}
 self.west = {}
 self.east = {}
 self.center = {}
 local actions = Sprite.new()
 self.look = {}
 self.move = {}
 self.melee = {}
 self.ranged = {}
 
 local up = Bitmap.new(TextureRegion.new(iconTexture, 0, 144, 144, 144))
 local down = Bitmap.new(TextureRegion.new(iconTexture, 144, 144, 144, 144))
 self.north = Button.new(up, down)
 self.north:setPosition(1482, 662) 
 compass:addChild(self.north)

Before we created four compass buttons, now we’re going to get a total of nine buttons on the screen.  To react to the buttons, the compass logic will be the same in Game:init().  The only addition is a center button that will directly call turnOver() so the hero can finish their turn without moving.

 --respond to the compass directions
 self.main.north:addEventListener("click", function() self:checkMove(0, -1) end)
 self.main.south:addEventListener("click", function() self:checkMove(0, 1) end)
 self.main.west:addEventListener("click", function() self:checkMove(-1, 0) end)
 self.main.east:addEventListener("click", function() self:checkMove(1, 0) end)
 self.main.center:addEventListener("click", function() self:turnOver() end)

The action buttons we want to connect.  That is, we’ll indicate which action is chosen by update its VisualState and turning off the others when the button is clicked.

 --set the default action to move
 self.active = "move"
 self.main.move:updateVisualState(true)
 --respond to the action buttons
 self.main.look:addEventListener("click", function() 
   self.active = "look"
   self.main.look:updateVisualState(true)
   self.main.move:updateVisualState(false)
   self.main.ranged:updateVisualState(false)
   self.main.melee:updateVisualState(false)
 end)
 self.main.move:addEventListener("click", function() 
   self.active = "move"
   self.main.move:updateVisualState(true)
   self.main.look:updateVisualState(false)
   self.main.ranged:updateVisualState(false)
   self.main.melee:updateVisualState(false)
 end)
 self.main.melee:addEventListener("click", function() 
   self.active = "attack"
   self.hero.weapon = self.hero.weapons[1]
   self.main.melee:updateVisualState(true)
   self.main.move:updateVisualState(false)
   self.main.look:updateVisualState(false)
   self.main.ranged:updateVisualState(false)
 end)
 self.main.ranged:addEventListener("click", function() 
   self.active = "attack"
   self.hero.weapon = self.hero.weapons[2]
   self.main.ranged:updateVisualState(true)
   self.main.move:updateVisualState(false)
   self.main.look:updateVisualState(false)
   self.main.melee:updateVisualState(false)
 end)

This is pretty straightforward stuff.  We keep track of which button is active with the new self.active variable.  Then we use self.active in Game:checkMove() to respond accordingly.

function Game:checkMove(dx, dy)

  -- first we need the tile info for where the hero wants to move
  local entry, layer, tile = self.world:getTileInfo(self.hero.x + dx, self.hero.y + dy)
  -- respond accordingly
  if self.active == "attack" then 
    if layer == LAYER_MONSTERS then
      self:attackMonster(self.hero.x + dx, self.hero.y + dy)
      self:turnOver()
    else
      self.msg:add("Not a monster", MSG_DESCRIPTION)
    end
  elseif self.active == "look" then 
    self.msg:add("A " .. tile.name, MSG_DESCRIPTION) 
  elseif self.active == "move" then
    if layer == LAYER_MONSTERS then
      -- allow them to bump and melee attack when moving
      self.active = "attack"
      self.hero.weapon = self.hero.weapons[1]
      self.main.melee:updateVisualState(true)
      self.main.move:updateVisualState(false)
      self.main.look:updateVisualState(false)
      self.main.ranged:updateVisualState(false)
      self:attackMonster(self.hero.x + dx, self.hero.y + dy)
      self:turnOver()
    elseif layer == LAYER_ENVIRONMENT then
      -- check for blocked tiles
      if tile.blocked then
        self.msg:add("A " .. tile.name, MSG_DESCRIPTION) 
      else
        self.world:moveHero(self.hero, dx, dy)
        self.sounds:play("hero-steps")
        self:turnOver()
      end
    else
      self.world:moveHero(self.hero, dx, dy)
      self.sounds:play("hero-steps")
      self:turnOver()
    end
  end
end

It isn’t quite as simple as “do this” for each button because there are some default behavior we want our function to do such as attack monsters when it’s obvious they should be attacked.  But otherwise, it’s fairly easy to integrate self.active into Game:checkMove().

The problem is ranged attacks are no better than melee attacks.  At this point,  the only way to shoot an arrow at something (or look at it or swing a sword at it) is to walk up to it and bump into it.  We need a way to respond to touches directly on the map.

Getting all touchy feely

We’ve actually thought about this before.  In the Start, Victory and Death scenes, we added an EventListener for touches.  Let’s do that in our main Game:init() function as well.

 --respond to touch events
 self:addEventListener(Event.MOUSE_UP, self.onMouseUp, self)

This addEventListener() command will call Game:onMouseUp().  onMouseUp() will be a pre-function for Game:checkMove().  After all, checkMove() already does everything we want our program to do:  have the hero look, move or attack.  Now, we just need that functionality when we touch the map.  MOUSE_UP events generate event.x and event.y variables which we can use to limit the responses the function makes.

function Game:onMouseUp(event)

  --only check visible parts of the map
  local x = self.hero.x + math.ceil(event.x / TILE_WIDTH) - 6
  local y = self.hero.y + math.ceil(event.y / TILE_HEIGHT) - 6
  local key, layer = 0, 0
  local visibleMapTouched = (event.x < FG_X and x >= 0 and x <= LAYER_COLUMNS and 
                             y >= 0 and y <= LAYER_ROWS)
  if visibleMapTouched then
    --find the tile that was touched
    local key, layer, tile = self.world:getTileInfo(x, y)

The first thing onMouseUp() does is only respond to touches on the map.  Once we know it’s a tile that’s touched, we get the tile’s information.  The rest of the function responds using the self.active variable.  For “look” actions, just output what the tile is; for “attack” actions, only attack monster tiles; for move actions, figure out the best step to take.

  if self.active == "look" then 
    self.msg:add("A " .. tile.name, MSG_DESCRIPTION) 
  elseif self.active == "attack" then
    --calculate the reach, check if it's the monster layer and 
    --make sure it wasn't the hero who was clicked
    local inReach = math.abs(self.hero.x - x) <= self.hero.weapon.reach and 
                    math.abs(self.hero.y - y) <= self.hero.weapon.reach and
                    layer == LAYER_MONSTERS and not (self.hero.x == x and self.hero.y == y)
    if inReach then
      self:checkMove(x - self.hero.x, y - self.hero.y)
    elseif (not tile.tactics == "player") and layer == LAYER_MONSTERS then
      self.msg:add(tile.name .. "is out of range", MSG_DESCRIPTION) 
    end
  elseif self.active == "move" then
    --if a move action is selected, move the hero towards the tile 
    local deltaX = math.abs(self.hero.x - x)
    local deltaY = math.abs(self.hero.y - y)
    local dx, dy = 0, 0
    --calculate the optimal dx, dy 
    if deltaX > deltaY then
      if x > self.hero.x then dx, dy = 1, 0 else dx, dy = -1, 0 end
    else
      if y > self.hero.y then dx, dy = 0, 1 else dx, dy = 0, -1 end
    end
    self:checkMove(dx, dy)
  end
end

Like I said, it’s a pre-function for checkMove().  It only calls checkMove with an “attack” action if it’s a valid attack and starts a checkMove() “move” by first translating the touch into a single step dx, dy.

Death from a distance

Now when the player touches the screen onMouseUp() calls checkMove() and checkMove takes over with a call to basicAttack() or world:moveHero().  This is good.  We actually have ranged attack functionality.  Unfortunately, we don’t actually launch an arrow and we end up shooting through walls.  I propose a separate rangedAttack() function that will go over the math behind the line of flight of an arrow and will actually launch the arrow.

Within basicAttack(), we can check if the hero has a ranged weapon and if they do, call a rangedAttack() function that will keep track of everything.  Game:rangedAttack() is going to put an arrow sprite on the stage after checking if anything is in the way with a call to World:lineOfCover().

We’ll describe lineOfCover() in detail shortly, but for now, know that it checks all the stuff in the way and modifies the attack roll and it returnd the location of a tile that blocks the line of flight if there is one.  There are three possible results

  1. a miss that hits a wall or pillar
  2. a miss that just misses
  3. a hit

Result number one will be a flight cut show while the latter two will have the same flight; the blocked flight will be cut short.  Either way, a Projectile class is called with the weapon, attack and defender information.  Projectile.new() puts a sprite that is put on the stage and generates a “animated finished” event when it reaches its target.  The rest of Game:rangedAttack() is simply an EventListener that finishes all the attack logic similar to basicAttack().

function Game:rangedAttack(weapon, attacker, defender)

  local p = nil
  local cover, blockedX, blockedY = self.world:lineOfCover(attacker.x, attacker.y, defender.x, defender.y)
  if blockedX then 
    -- launch a projectile that hits something along the way
    p = Projectile.new(weapon.projectile, attacker.x - self.hero.x, attacker.y - self.hero.y,
                       blockedX - self.hero.x, blockedY - self.hero.y)
  else
    -- launch a projectile towards the hero
    p = Projectile.new(weapon.projectile, attacker.x - self.hero.x, attacker.y - self.hero.y,
    defender.x - self.hero.x, defender.y - self.hero.y) 
  end
  self:addChild(p)
  p:addEventListener("animation finished", function()   
    if blockedX then 
      local key, layer, tile = self.world:getTileKey(blockedX, blockedY)
      self.sounds:play(weapon.projectile .. "-miss") 
      if attacker.tactics == "player" then
        self.msg:add("You hit the ", tile.name)
      end
    else
      --roll for the attack using the weapon and all modifiers
      local roll, crit = self:roll(weapon.modifier + cover)
      if defender.defense[weapon.defense] > roll then
        self.sounds:play(weapon.projectile .. "-miss") 
        self.msg:add(weapon.missSound, MSG_ATTACK)
      else
        self.sounds:play(weapon.projectile .. "-hit")
        self:rollDamage(weapon, attacker, defender, crit)
      end
    end
  end)
end

The Projectile class is the key here.  Let’s figure out how exactly that one works.

Flight time

In a Projectiles.lua file, Projectile is a class that returns a sprite and creates an Enter_Frame event to monitor it.  Then it generates a Removed_From_Stage event to report when it’s done.

function Projectile:init(pName, fromX, fromY, toX, toY)
  -- create a Bitmap 
  local info = manual:getEntry("projectiles", pName) 
  local p = Bitmap.new(info.image)
  p:setAnchorPoint(0.5, 0.5)
  --calculate the screen start and target variables
  local startX = (6 + fromX - 0.5) * TILE_WIDTH 
  local startY = (6 + fromY - 0.5) * TILE_HEIGHT 
  self.targetX = (6 + toX - 0.5) * TILE_WIDTH 
  self.targetY = (6 + toY - 0.5) * TILE_HEIGHT 
  --set initial position and speed
  self:setPosition(startX, startY)
  self.speedX = (toX - fromX) * info.speed
  self.speedY = (toY - fromY) * info.speed
  self:addChild(p)
  self:addEventListener(Event.ENTER_FRAME, self.onEnterFrame, self) 
  self:addEventListener(Event.REMOVED_FROM_STAGE, self.onRemovedFromStage, self)
end

It also create self.speedX and self.speedY variables which are used in an onEnterFrame() function to move the sprite across the screen.

function Projectile:onEnterFrame()
  local x, y = self:getPosition()
  -- if the projectile hits the target, remove it 
  if math.abs(x - self.targetX) < 20 and math.abs(y - self.targetY) < 20 then 
    self:removeFromParent() 
  else
    -- set a new position
    x = x + self.speedX
    y = y + self.speedY
    self:setPosition(x, y)
  end
end

The final, important bit is generatating an “animated finish” event that Game:rangedAttack() can respond to.

function Projectile:onRemovedFromStage(event)
  self:dispatchEvent(Event.new("animation finished")) 
  self:removeEventListener(Event.ENTER_FRAME, self.onEnterFrame, self)
end

With that, we know have a class that will get a projectile flying across the screen and let us know when it’s done.

Jagged little lines

Getting back to this little line in Game:rangedAttack() that appeared before we started shooting arrows:

local cover, blockedX, blockedY = self.world:lineOfCover(attacker.x, attacker.y, defender.x, defender.y)

lineOfCover() let us know the cover modifier and if any tile was totally blocked.  While Projectile.new() smoothly animated an arrow going across the screen, how do we know which tiles it flew over?  Here’s a picture that shows the problem:

bresenham

How many tiles does our flight path cross?  As with many things in computer science, this has been looked at many different ways.  One of the simplest solutions is Bresenham’s.  Look it up on the internet.  It’s a thing of beauty which I implement as World:line().  World:line() returns a table of x, y coordinates that our arrow flies over.

With line() at the ready, we can now write World:lineOfCover().  The plan is to go through the list of tiles that line() generates.  For each, check the tile.cover value for all the environment and light tiles that our arrow crosses through.  Here are the values for the environment in constants.lua.

 ["environment"] = {
 [1] = {name = "wall", blocked = true, cover = -6},
 [2] = {name = "wall", blocked = true, cover = -6},
 [3] = {name = "pillar", blocked = true, cover = -2},
 [4] = {name = "pillar", blocked = true, cover = -4},
 [5] = {name = "dirt hole", blocked = false, cover = 0},
 [6] = {name = "well", blocked = false, cover = -2}},

When the total cover for all the tiles under -5, then the line is considered blocked.  Of course, feel free to implement your own cover logic.

function WorldMap:lineOfCover(fromX, fromY, toX, toY)
  local totalCover = 0
  local blockedX, blockedY = nil, nil
  local line = self:line(fromX, fromY, toX, toY)
  --remove the starting x, y
  table.remove(line, 1)
  for key, coordinates in pairs(line) do
    x, y = coordinates[1], coordinates[2]
    --figure out the cover for what's in the way
    local key, layer, tile = self:getTileInfo(x, y, LAYER_ENVIRONMENT)
    if key ~= 0 then 
      totalCover = totalCover + tile.cover 
    end
    --account for lighting too
    local key, layer, tile = self:getTileInfo(x, y, LAYER_LIGHT) 
    totalCover = totalCover + tile.cover 
    if totalCover < -5 or tile.cover < -5 then
      blockedX, blockedY = x, y
    end
  end
  return totalCover, blockedX, blockedY
end

And with that, our game is complete again.  onMouseUp() calls checkMove() which calls basicAttack() which calls rangedAttack() which detours through World:lineOfCover() and World:line() and returns to call Projectile.new().

Summary

There you have it.  We now have a functional little Roguelike.   Do you realize we started out with just two lua files:  main.lua and button.lua back in Step 1?  After implementing everything we’ve done, we now have 17 lua files worth of who-knows-how-many-lines-of code.   And it’s filled with all sorts of classes, texturepacks, sound files and the lot, but I also think it’s easy to grok.

Take this code and make it your own.  The game content itself is simple enough to extract, replace, rebuild, or modify that it can easily turn into all manner of Roguelikes.  Lua and Gideros Mobile can make it really straightforward.  One of the best parts is you can deploy to almost any screen with the same language and framework.  Whether your goal is to spend a week or a few years, the genre encourages all kinds of games, from the desktop campaign that is ADOM to ten minutes on your phone with Hoplite.

I hope you realize your own Roguelike one day.  In the meantime, many happy evenings programming by moonlight!