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 which you can zap a creature with to hurt them, and a Wand of Swapping which you can use to swap positions with another actor.

Creating a base module

We’re going to be creating a base module where we can put components, actions, and actors we want to reference in the main module so they’re available at load time.

Head to modules and create a new folder there called basegame. Now go ahead and add an actions and components folder.

Let’s head to modules/basegame/components and create a new file zappable.lua.

--- @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

This component is going to be the base Zappable component all of our wand components derive from. It implements a few utility functions for managing charges and checking if we have charges left to zap.

Now let’s head to modules/basegame/actions and create a new file zap.lua.

local Log = prism.components.Log

local ZappableTarget = prism.InventoryTarget()
    :inInventory()
    :with(prism.components.Zappable)

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

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

--- @param level Level
--- @param zappable Actor
function Zap:perform(level, zappable)
    local zappableComponent = zappable:expect(prism.components.Zappable):reduceCharges()
    Log.addMessage(self.owner, "You zap the wand!")
end

return Zap

Now we implement a zap action. We implement a canPerform that checks if the wand has enough charges to zap. Then we implement a perform action that reduces the wand’s charges and logs a message.

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{
        char = "/"
    },
    prism.components.HurtZappable{
        charges = 3,
        cost = 1,
        damage = 3,
    },
    prism.components.Item(),
}
end)

Great, now we’ve got to implement the zap. Head over to modules/game/actions and create a new file folder called zaps. Inside create a new file called hurtzap.lua.

local HurtZappableTarget = prism.InventoryTarget(prism.components.HurtZappable)
    :inInventory()

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

--- @class HurtZap : Zap
local HurtZap = prism.actions.Zap:extend "HurtZap"
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)
    level:tryPerform(prism.actions.Damage(hurtable, zappableComponent.damage))
end

return HurtZap

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.

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 gamestates and create a new folder called targethandlers. Inside let’s create a new file called targethandler.lua.

Let’s walk through step by step. We’re going to create a new base class we’ll use for all our target handlers.

--- @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

This accepts a display, the base levelstate, a target list, and the current target we’re handling and initializes a few fields for convenience.

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 == "poprecursive" then self.manager:pop("poprecursive") return end
    if shouldPop then self.manager:pop() return 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 gamestates/targethandlers and create a new file called generaltargethandler.lua.

local keybindings = require "keybindingschema"
local Name = prism.components.Name
local TargetHandler = require "gamestates.targethandlers.targethandler"

--- @class GeneralTargetHandler : TargetHandler
--- @field selectorPosition Vector2
local GeneralTargetHandler = 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.

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 not (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 Vector 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()
    TargetHandler.init(self)
    self.curTarget = self.validTargets[1]
    self:setSelectorPosition()
end

Then we’ll implement a draw function that draws this state. You’ll recognize a lot of this code it’s very similar to the code found in GameLevelState. The main difference between this and the drawing code in GameLevelState is that we’ll center the camera on the selector’s position.

function GeneralTargetHandler:draw()
    local cameraPos = self.selectorPosition

    self.display:clear()
    -- set the camera position on the display
    local ox, oy = self.display:getCenterOffset(cameraPos:decompose())
    self.display:setCamera(ox, oy)

    -- draw the level
    local primary, secondary = self.levelState:getSenses()
    self.display:putSenses(primary, secondary)

    -- put a string to let the player know what's happening
    self.display:putString(1, 1, "Select a target!")
    self.display:putString(self.selectorPosition.x + ox, self.selectorPosition.y + oy, "X", prism.Color4.RED)

    -- if there's a target then we should draw it's name!
    if self.curTarget then
        local x, y = cameraPos:decompose()
        self.display:putString(x + ox + 1, y + oy, Name.get(self.curTarget))
    end
    self.display:draw()
end

Now finally we’ll handle keypresses. Let’s walk through it. First let’s define a map and the keypressed function.

local keybindOffsets = {
    ["move up"] = prism.Vector2.UP,
    ["move left"] = prism.Vector2.LEFT,
    ["move down"] = prism.Vector2.DOWN,
    ["move right"] = prism.Vector2.RIGHT,
    ["move up-left"] = prism.Vector2.UP_LEFT,
    ["move up-right"] = prism.Vector2.UP_RIGHT,
    ["move down-left"] = prism.Vector2.DOWN_LEFT,
    ["move down-right"] = prism.Vector2.DOWN_RIGHT,
}

function GeneralTargetHandler:keypressed(key)
    local action = keybindings:keypressed(key)

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

if action == "tab" 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 action == "select" 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 “poprecursive” to indicate to the other states that we should pop all the way back to the inventory.

if action == "return" 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 keybindOffsets[action] then
        self.selectorPosition = self.selectorPosition + keybindOffsets[action]
        self.curTarget = nil

        if self.target:validate(self.level, self.owner, self.selectorPosition, self.targetList) then
            self.curTarget = self.selectorPosition
        end

        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 /gamestates/inventoryactionstate.lua. First we’re going to make a small change to the constructor.

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

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

    self.actions = {}

    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 name field and fallback to the className if it doesn’t exist. This is so our zaps display as “Zap” and not “HurtZap”.

function InventoryActionState:draw()
    self.previousState:draw()
    self.display:clear()
    self.display:putString(1, 1, Name.get(self.item), nil, nil, 2, "right")

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

    self.display:draw()
end

Now we’ll modify the keypressed function. Instead of simply executing the action the user selects we’ll know check if the action is valid with just the item as the first target, and if not we’ll push GeneralTargetHandler states to handle the rest of the targets.

function InventoryActionState:keypressed(key)
    for i, Action in ipairs(self.actions) do
        if key == string.char(i + 96) then
            self.decision:trySetAction(Action(self.decision.actor, self.item), self.level)

            if self.decision:validateResponse() then
                self.manager:pop()
                return
            end

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

    local binding = keybindings:keypressed(key)
    if binding == "inventory" or binding == "return" then self.manager:pop() end
end

And to wrap things up we’ll change InventoryActionState’s resume. When it resumes we’ll check if we’re handling targets for an action, adn 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

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.