Zapping wands

Note

This chapter is very work in progress, beware! Don’t procede yet adventurer!

In this chapter we’ll add wands and a generalized targetting system for actions. We’ll add a Wand of Hurt to zap a creatures with.

Creating a base module

The load order for a given type within a module is random, so let’s start by creating a “base” module that our other content can depend on. Head to modules and create a new folder there called base. Now go ahead and add an actions and components folder.

Let’s head to modules/base/components and create a new component in zappable.lua. This will be the base component for our wand components. It implements a few utility functions for managing charges and checking if we have charges left to zap.

--- @class ZappableOptions
--- @field charges integer
--- @field cost integer

--- @class Zappable : Component
--- @overload fun(): Zappable
local Zappable = prism.Component:extend "Zappable"

function Zappable:__new(options)
    self.charges = options.charges
    self.cost = options.cost
end

function Zappable:canZap(cost)
    cost = cost or self.cost
    return self.charges >= cost
end

function Zappable:reduceCharges(cost)
    cost = cost or self.cost
    self.charges = self.charges - self.cost
end

return Zappable

Now let’s head to modules/basegame/actions and create a new action in zap.lua. We implement a Action:canPerform() that checks if the wand has enough charges to zap. Then we implement a perform action that reduces the wand’s charges.

local Log = prism.components.Log

local ZappableTarget = prism.targets.InventoryTarget(prism.components.Zappable)

--- @class Zap : Action
local Zap = prism.Action:extend "Zap"
Zap.abstract = true
Zap.targets = { ZappableTarget }
Zap.ZappableTarget = ZappableTarget

--- @param zappable Actor
function Zap:canPerform(level, zappable)
    return zappable:expect(prism.components.Zappable):canZap()
end

--- @param zappable Actor
function Zap:perform(level, zappable)
    zappable:expect(prism.components.Zappable):reduceCharges()
end

return Zap

Making a wand

Let’s head to /modules/game/components and create a new folder called zappable. Then let’s create a new file named hurtzappable.lua.

--- @class HurtZappableOptions : ZappableOptions
--- @field damage integer

--- @class HurtZappable : Zappable
--- @overload fun(options: HurtZappableOptions): HurtZappable
local HurtZappable = prism.components.Zappable:extend "HurtZappable"

--- @param options HurtZappableOptions
function HurtZappable:__new(options)
    prism.components.Zappable.__new(self, options)
    self.damage = options.damage
end

return HurtZappable

This is pretty much the base Zappable component, but we’ve added a damage amount to it. Now let’s head to modules/game/actors and create a new file named wandofhurt.lua.

prism.registerActor("WandofHurt", function()
   return prism.Actor.fromComponents {
       prism.components.Name("Wand of Hurt"),
       prism.components.Drawable {
           index = "/",
           color = prism.Color4.LIME
       },
       prism.components.HurtZappable {
           charges = 3,
           cost = 1,
           damage = 3,
       },
       prism.components.Item(),
       prism.components.Position()
   }
end)

We can edit our loot/chest.lua file to add wands to the drop pool. We have to switch from a single entry to a list of weighted entries, and we’ll give the wands half the weight of potions, making them drop 33% of the time.

--- @type DropTableOptions
return {
   entries = {
      {
         entry = "VitalityPotion",
         weight = 100,
      },
      {
         entry = "WandofHurt",
         weight = 50,
      },
   }
}

Great, now we’ve got to implement the zap. Head over to modules/game/actions and create a new folder called zaps. Inside create a new file called hurtzap.lua. We’re going to extend the base zap to add a tiny bit of behavior to it. We’ll add a target, anything with health, and try to deal damage to it.

local WandTarget = prism.targets.InventoryTarget(prism.components.HurtZappable)

local HurtTarget = prism.Target(prism.components.Health)
   :range(5)
   :sensed()

--- @class HurtZap : Zap
local HurtZap = prism.actions.Zap:extend "HurtZap"
HurtZap.name = "Zap"
HurtZap.abstract = false
HurtZap.targets = {
   HurtZappableTarget,
   HurtTarget
}

--- @param level Level
function HurtZap:perform(level, zappable, hurtable)
   prism.actions.Zap.perform(self, level, zappable)
   local zappableComponent = zappable:expect(prism.components.HurtZappable)
   local damage = prism.actions.Damage(hurtable, zappableComponent.damage)
   level:tryPerform(damage)

   local dealt = damage.dealt or 0

   local zapName = Name.lower(hurtable)
   local ownerName = Name.lower(self.owner)

   Log.addMessage(self.owner, "You zap the %s for %i damage!", zapName, dealt)
   Log.addMessage(hurtable, "The %s zaps you for %i damage!", ownerName, dealt)
   Log.addMessageSensed(
      level,
      self,
      "The %s kicks the %s for %i damage.",
      ownerName,
      zapName,
      dealt
   )
end

return HurtZap

Okay now if you go in game there’s a bit of an issue! You can’t actually zap anything with this wand yet, just drop it! We’ll have to modify the user interface to add some proper targetting to let us select who we’d like to zap.

Handling targets

Let’s head over to modules/base/gamestates and create a new GameState in targethandler.lua. We’ll accept a display, the base LevelState, a target list, and the current target we’re handling and initializes a few fields for convenience.

--- @class TargetHandler : GameState
--- @field display Display
--- @field levelState LevelState
--- @field validTargets any
--- @field curTarget any
--- @field target Target
--- @field level Level
--- @field targetList any[]
--- @overload fun(display: Display, levelState: LevelState, targetList: any[], target: Target): self
local TargetHandler = spectrum.GameState:extend("TargetHandler")

---@param display Display
---@param levelState LevelState
---@param targetList any[]
---@param target Target
function TargetHandler:__new(display, levelState, targetList, target)
   self.display = display
   self.levelState = levelState
   self.owner = self.levelState.decision.actor
   self.level = self.levelState.level
   self.targetList = targetList
   self.target = target
   self.index = nil
end
function TargetHandler:getValidTargets()
   error("Method 'getValidTargets' must be implemented in subclass")
end

function TargetHandler:init()
   self.validTargets = self:getValidTargets()
   if #self.validTargets == 0 then self.manager:pop("poprecursive") end
end

function TargetHandler:resume(previous, shouldPop)
   if shouldPop then
      self.manager:pop(shouldPop == "poprecursive" and shouldPop or nil)
   end

   self:init()
end

function TargetHandler:load()
   self:init()
end

return TargetHandler

The first method, getValidTargets() is defined in the base class because we use it in the following methods to see if we have a target and start popping states back to the inventory if we don’t.

The init function is called in both resume and load and primes the target handler with all of the valid targets, and pops back to the inventory if not. We pass “poprecursive” up the chain of states to indicate we should keep popping until we reach the inventory again.

Creating our concrete target handler

Let’s head over to modules/game/gamestates/ and create a new file called generaltargethandler.lua.

local controls = require "controls"
local Name = prism.components.Name

--- @class GeneralTargetHandler : TargetHandler
--- @field selectorPosition Vector2
local GeneralTargetHandler = spectrum.gamestates.TargetHandler:extend("GeneralTargetHandler")

We create a new target handler derived from the TargetHandler gamestate. Next we move on to getValidTargets, where we’ll query the level for valid targets to our action and collect them. We can query directly for actors using Query:target(), or if the target is Vector2 we validate against the entire map.

function GeneralTargetHandler:getValidTargets()
   local valid = {}

   for foundTarget in self.level:query():target(self.target, self.level, self.owner, self.targetList):iter() do
      table.insert(valid, foundTarget)
   end

   if self.target.type and self.target.type == prism.Vector2 then
      for x, y in self.level.map:each() do
         local vec = prism.Vector2(x, y)
         if self.target:validate(self.level, self.owner, vec, self.targetList) then
            table.insert(valid, vec)
         end
      end
   end

   return valid
end

We check if the current target is a Vector2 or an Actor and we’ll set the selectorPosition based on the current target that we chose arbitrarily.

function GeneralTargetHandler:setSelectorPosition()
   if prism.Vector2.is(self.curTarget) then
      self.selectorPosition = self.curTarget
   elseif self.curTarget then
      self.selectorPosition = self.curTarget:getPosition()
   end
end

Next we’ll redefine the init function to set the selector position.

function GeneralTargetHandler:init()
   self.super.init(self)
   self.curTarget = self.validTargets[1]
   self:setSelectorPosition()
end

Then we’ll implement a draw function that draws this state. Like our other states, we draw the base state and then draw on top of it. We’ll put a red “X” over our selected position, and next to it their name, if they are Entity.

function GeneralTargetHandler:draw()
   self.levelState:draw()

   self.display:clear()
   -- set the camera position on the display
   local x, y = self.selectorPosition:decompose()

   -- put a string to let the player know what's happening
   self.display:print(1, 1, "Select a target!")
   self.display:beginCamera()
   self.display:print(x, y, "X", prism.Color4.RED, prism.Color4.BLACK)

   -- if there's a target then we should draw its name!
   if prism.Entity:is(self.curTarget) then
      self.display:print(x + 1, y, Name.get(self.curTarget))
   end
   self.display:endCamera()
   self.display:draw()
end

Finally, we’ll handle input. Add the following controls in controls.lua.

tab            = "tab",
select         = "return"

Then we’ll check if the user hit the tab keybind, and if so we’ll use next to cycle through our valid targets table.

function GeneralTargetHandler:update(dt)
   controls:update()
   if controls.tab.pressed then
      local lastTarget = self.curTarget
      self.index, self.curTarget = next(self.validTargets, self.index)

      while
         (not self.index and #self.validTargets > 0) or
         (lastTarget == self.curTarget and #self.validTargets > 1)
      do
         self.index, self.curTarget = next(self.validTargets, self.index)
      end

      self:setSelectorPosition()
   end

Then if the user hits the select keybind we add this target to the overall target list we’re building and pop this instance of the target handler off of the gamestate stack.

if controls.select.pressed and self.curTarget then
   table.insert(self.targetList, self.curTarget)
   self.manager:pop()
end

If the user hits the return keybind we’ll pop this state and pass “pop” to indicate to the other states that we should pop all the way back to the inventory.

if controls.back.pressed then
   self.manager:pop("pop")
end

Next we’ll handle moving the selector. When the user hits a movement key we move the selector, check for a valid target on that tile, and if it exists we’ll set that as the current target.

   if controls.move.pressed then
      self.selectorPosition = self.selectorPosition + controls.move.vector
      self.curTarget = nil

      -- Check if the position is valid
      if self.target:validate(self.level, self.owner, self.selectorPosition, self.targetList) then
         self.curTarget = self.selectorPosition
      end

      -- Check if any actors at the position are valid
      local validTarget = self.level:query()
         :at(self.selectorPosition:decompose())
         :target(self.target, self.level, self.owner, self.targetList)
         :first()

      if validTarget then
         self.curTarget = validTarget
      end
   end
end

return GeneralTargetHandler

Modifying InventoryActionState

Okay with our target handler out of the way we’re going to have to make some changes to the InventoryActionState. Navigate to modules/game/gamestates/inventoryactionstate.lua. First we’re going to make a small change to the constructor.

Instead of validating if the action is valid with its only target being the item we’ll instead validate if its first target is the item. The actions table notably now holds prototypes instead of instances of actions.

function InventoryActionState:__new(display, decision, level, item)
   -- ...

   for _, Action in ipairs(self.decision.actor:getActions()) do
      if Action:validateTarget(1, level, self.decision.actor, item) and not Action:isAbstract() then
         table.insert(self.actions, Action)
      end
   end
end

Next we’ll make a small modification to draw. We’ll use the action’s Action:getName() method so our zaps display as “Zap” and not “HurtZap”.

for i, Action in ipairs(self.actions) do
   local letter = string.char(96 + i)
   local name = string.gsub(Action:getName(), "Action", "")
   self.display:print(1, 1 + i, string.format("[%s] %s", letter, name), nil, nil, nil, "right")
end

Now we’ll modify the update function. Instead of simply executing the action the user selects we’ll now check if the action is valid with just the item as the first target.

function InventoryActionState:update(dt)
   controls:update()
   for i, Action in ipairs(self.actions) do
      if spectrum.Input.key[string.char(i + 96)].pressed then
         if self.decision:setAction(Action(self.decision.actor, self.item), self.level) then
            self.manager:pop()
            return
         end

If it wasn’t valid, we’ll push instances of our GeneralTargetHandler in reverse order, so the second target (whatever is after the item) is on the top of the stack.

         self.selectedAction = Action
         self.targets = { self.item }
         for j = Action:getNumTargets(), 2, -1 do
            self.manager:push(
               spectrum.gamestates.GeneralTargetHandler(
                  self.display,
                  self.previousState,
                  self.targets,
                  Action:getTarget(j),
                  self.targets
               )
            )
         end
      end
   end

   if controls.inventory.pressed or controls.back.pressed then self.manager:pop() end
end

And to wrap things up we’ll change InventoryActionState’s resume. We’ll check if we’re handling targets for an action, and if we are we check if we succeeded. If we succeeded we set the action and then pop the state. If not we display a message to the user explaining why their action didn’t work.

function InventoryActionState:resume()
   if self.targets then
      local action = self.selectedAction(self.decision.actor, unpack(self.targets))
      local success, err = self.level:canPerform(action)
      if success then
         self.decision:setAction(action)
      else
         prism.components.Log.addMessage(self.decision.actor, err)
      end

      self.manager:pop()
   end
end

Note

unpack expands a table into separate values.

Wrapping it up

That one was a doozy, but we layed the ground work for making adding new ways to target really easy in the future! In the next section we’ll go over equipment, and modify InventoryActionState a little bit more to handle non-standard targets like inventory slots.