§ root / a / polish-07

Polish 07 - Using Spells and Items Outside of Combat

Bitmap Text.

This is the seventh article in a series that takes the RPG from the “How to Make an RPG” project and adds a little more polish to one element.

In the previous polish phase we added a spell-browser to the in-game menu. In this article we’ll make that menu interactive.

As you play an RPG you discover spells and items that can heal and revive your characters. In the example game, at the end of the book, curative items can only be used during combat. This means if your character dies during combat, the only way to revive them is to make it to a town and rest at an inn. That’s not good!

After combat a character may be:

  • KO’ed
  • Down on mana
  • Down on HP
  • Suffering from a status effect

At this point the player should be able to use spells and items to heal the party!

Here’s the party damaged after battle:

A post battle party looking a little worse for wear.

How will the party the recover?

Note: This polish article got a bit out of hand. It’s big, too detailed and adds a lot!

Todo

Before adding a feature it’s a good idea to scope out what’s needed.

  1. Some way to mark spells and items as “Can be used on the map”
  2. Selection code to chose the wounded character(s).
  3. Spell and item effects need to be shared between the combat and menu. (Otherwise we’re duplicating code and that it’s going to get out of sync)
  4. Make sure mana checks are applied at the menu level. Spells aren’t free :)

Decision Points

There’s one major decision to make about this feature - which maps do we want to allow the player to use items and spells? The world map, town maps - how about dungeons?

I’ve decided the player can use spells on any map.

Spell Mark Up

How are spells defined in our game? Here’s a definition for the Fire spell.

["fire"] =
{
    name = "Fire",
    action = "element_spell",
    element = "fire",
    mp_cost = 8,
    base_damage = {3, 5},
    base_hit_chance = 1,
    time_points = 10,
    target =
    {
        selector = "WeakestEnemy",
        switch_sides = true,
        type = "One"
    },
    description = "Damages an enemy with elemental fire!"
},

We’re going to add a new optional field to the spell definitions. This field is called can_use_on_map and determines if the spell can be used on the map. This field defaults to false. In the case of the fire spell, shown above, we can add can_use_on_map = false or just leave it to assume it’s default value.

In book we didn’t introduce any healing spells appropriate for the map, so let’s add one! All spells are defined in the SpellDB table in SpellDB.lua.

["heal"] =
{
    name = "Heal",
    action = "hp_restore_spell",
    mp_cost = 8,
    base_heal = 100,
    base_hit_chance = 1,
    time_points = 10,
    can_use_on_map = true, -- <-- this is new!
    target =
    {
        selector = "MostHurtParty",
        switch_sides = true,
        type = "One"
    },
    description = "Heals some health of a single party member."
},

There are a lot of fields, so let me explain what they all do:

  • name - The name shown in the menus.
  • action - The name of the function that applies the spell effect. (We haven’t written this yet!)
  • mp_cost - How much the spell costs to cast.
  • base_hit_chance - A number 0 to 1 that determines the chance the spell hits. Here 1 means a 100% chance of hitting.
  • time_points - The length of time the spell takes to cast in “points”. Using an item might be a single point, so even if used later than spell, it would take precedence and happen first.
  • can_use_on_map - Our new field determining if we can use the spell on the map.
  • target - table that determines which characters can be targeted.

Restore Action

The action field of the heal spell is set to hp_restore_spell. We haven’t written this yet, so let’s look at the existing hp_restore used by items. This is defined in CombatActions.lua.

["hp_restore"] =
function(state, owner, targets, def)

    local restoreAmount = def.use.restore or 250
    local animEffect = gEntities.fx_restore_hp
    local restoreColor = Vector.Create(0, 1, 0, 1)

    for k, v in ipairs(targets) do

        local stats, character, entity = StatsCharEntity(state, v)

        local maxHP = stats:Get("hp_max")
        local nowHP = stats:Get("hp_now")

        if nowHP > 0 then
            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)
            nowHP = math.min(maxHP, nowHP + restoreAmount)
            stats:Set("hp_now", nowHP)
        end

        AddAnimEffect(state, entity, animEffect, 0.1)

    end
end

A pretty straight forward action, it applies the restore_amount of the item to each character it targets.

It’s worth noting: the application of the action is directly tied to the combat state. It updates the health but also adds special effects. We need to break up the action into the a general part and a combat specific part. The alternative is to create two actions hp_restore_combat and hp_restore_map but it’s a bad idea to duplicate this code. It means whenever we update the action code, we have to do it in two places and this means, at some point - it’s going to get out of sync!

We’ll worry about this later, let’s add hp_restore_spell.

["hp_restore_spell"] =
function(state, owner, targets, def)

    local restoreAmount = def.base_heal or 100
    restoreAmount = restoreAmount * owner.mLevel
    local animEffect = gEntities.fx_restore_hp
    local restoreColor = Vector.Create(0, 1, 0, 1)

    for k, v in ipairs(targets) do

        local stats, character, entity = StatsCharEntity(state, v)

        local maxHP = stats:Get("hp_max")
        local nowHP = stats:Get("hp_now")

        if nowHP > 0 then
            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)
            nowHP = math.min(maxHP, nowHP + restoreAmount)
            stats:Set("hp_now", nowHP)
        end

        AddAnimEffect(state, entity, animEffect, 0.1)

    end
end

We now have a hp_restore and a hp_restore_spell, these seem similar but they’re not. Items are generally consistent, they do the same thing again and again. Spells are less predictable, depend on the caster and have a certain randomness, so it’s makes sense to seperate these actions.

The hp_restore_spell has a crude formula that heals 100 points of HP per the level of the caster. This can be refined later but it’s good enough for our purposes!

Item Mark Up

Items are defined in a similar way to the spells. Here’s an entry from the ItemDB.

{
    name = "Heal Potion",
    type = "useable",
    description = "Heal a small amount of HP.",
    use =
    {
        action = "hp_restore",
        restore = 250,
        target =
        {
            selector = "MostHurtParty",
            switch_sides = true,
            type = "One"
        },
        hint = "Choose target to heal.",
        can_use_on_map = true,
    },
    price = 50,
},

We’ve added the can_use_on_map field to the use table and set it to true. We’ll also do this to the following items:

  • Life Salve
  • Mana Potion

That’s the markup done!

The Heal Spell

The Heal spell needs to be usable in combat before we get it working on the world map. For testing, let’s add the spell to the mage’s repertoire. Below you can see the spell inserted into to the mage definition, in the gPartyMemberDefs table.

gPartyMemberDefs =
{
    -- code omitted

    mage =
    {
        -- code omitted

    actionGrowth =
    {
        [1] =
        {
            ['magic'] = { 'bolt', 'heal' },
        },

Heal is a level one spell. Start a new game (it won’t automatically be awarded from a save game), enter combat and you can try it out!

Revisiting the Magic Menu

The player needs to know which spells can be cast on the world map. We’ll grey out any spell names that cannot be cast such as “Fire”. Spell names are drawn by a function called RenderSpell. Here’s the updated function:

function MagicMenuState:CanCast(spellDef)
    if spellDef == nil or not spellDef.can_use_on_map then
       return false
    end

    return self.mCharacter:CanCast(spellDef)
end

function MagicMenuState:RenderSpell(menu, renderer, x, y, item)

    local font = gGame.Font.default

    if item == nil then
       font:DrawText2d(renderer, x, y, "--")
    else
        local spell = SpellDB[item]
        local horzSpace = 96
        local color = Vector.Create(107/255, 107/255, 107/255, 1)

        if self:CanCast(spell) then
            color = Vector.Create(1, 1, 1, 1)
        end

        font:DrawText2d(renderer, x, y, spell.name, color)
        font:DrawText2d(renderer, x + horzSpace, y,
                        string.format("%d", spell.mp_cost), color)
    end
end

There are two tests to see if a spell name should appear in white:

  1. Does it have a true can_use_on_map flag?
  2. Does the mage have enough mana to cast the spell?

I added a new CanCast function to the Actor class, here’s the implementation in Actor.lua.

function Actor:CanCast(spellDef)
    return spellDef.mp_cost <= self.mStats:Get("mp_now")
end

The CombatState has also been updated to use this function. The CanCast test is better in the actor because later, if we want add a “silence” status effect, we can easily do it here and have it apply everywhere.

Selecting Targets in the Character Menu

Let’s have quick recap of what needs doing:

  1. Enter a selection state from the item and spell menu
  2. Upgrade the selector so we can select the entire party at once
  3. Work out how we handle the excution of spells or items from the menu

On choosing a spell to cast, we’ll switch to the FrontMenuState so the player can select the target(s). We’ll base the selection code on the CombatSelector from the CombatTargetState (in fact to tidy up the code a little, I’ve moved CombatSelector into it’s own file CombatSelector.lua).

Let’s create a new file called MenuActorSelector.lua and copy in the code below.

MenuActorSelector =
{
    FirstPartyMember = function(menu)
    end,

    FirstMagicUser = function(menu)
    end,

    MostHurtMember = function(menu)
    end,

    MostDrainedParty = function(menu) -- lowest mp
    end,

    DeadParty = function(menu) -- first dead party member
    end,
}

These are the kind of selections we want to be able to make. The names are chosen to match those in the CombatSelector.

Let’s make the FrontMenuState use the selector code and then we’ll fill in all the selection types we need. We’ll make item menu work first, as that’s simpler than the magic menu.

When we choose an item that can be used we want move to the FrontMenuState, then push a MenuTargetState on the top of the stack. The menu target state will control which targets are selected. In the MenuTargetState the player can then either:

  1. Chose a valid party member
  2. Cancel the selection

Whichever choice the player makes they will be returned to the item menu. This means the MenuTargetState needs to store which state it was launched from.

Preparing FrontMenuState

By making some small additions to the FrontMenuState we’ll able to control the party selection more easily. Let’s begin with a very simple function HideOptionsCursor, that hides the cursor. When we’re in selection mode, we don’t want any other cusors on screen confusing things. Here’s the code:

function FrontMenuState:HideOptionsCursor()
    self.mSelections:HideCursor()
end

To select characters we need to know where to render the selection marker and which party members can be selected. Next we’ll add a function to make this easier.

function FrontMenuState:GetPartyAsSelectionTargets()
    local targets = {}

    local x = self.mPartyMenu.mX
    local y = self.mPartyMenu.mY
    local cursorWidth = self.mPartyMenu:CursorWidth()

    for k, v in ipairs(self.mPartyMenu.mDataSource) do

        local indexFrom0 = k - 1

        table.insert(targets,
                     {
                        x = x + cursorWidth * 0.5,
                        y = y - (indexFrom0 * self.mPartyMenu.mSpacingY),
                        summary = v
                    })
    end
    return targets
end

GetPartyAsSelectionTargets returns a list of the character summaries for each member of the party, it also returns an X, Y position so we know where to place the cursor(s).

Finally we need to move some code. In FrontMenuState:Render there are two lines of code:

function FrontMenuState:Render(renderer)
    -- code omitted

    local partyX = self.mLayout:Left("party") - 16
    local partyY = self.mLayout:Top("party") - 45
    self.mPartyMenu:SetPosition(partyX, partyY)
    self.mPartyMenu:Render(renderer)
end

The partyX and partyY variables define where the summaries are displayed in the FrontMenuState. We need to know the value of these variables when the FrontMenuState is first created. Therefore we’ll cut them out of the Render function and paste them at the bottom of the Enter function. You can see their new home below.

function FrontMenuState:Enter()
    local partyX = self.mLayout:Left("party") - 16
    local partyY = self.mLayout:Top("party") - 45
    self.mPartyMenu:SetPosition(partyX, partyY)
end

Ok, on to new code!

MenuTargetState doesn’t exist so let’s create it.

MenuTargetType =
{
    One = "One",
    All = "All",
}

MenuTargetState = {}
MenuTargetState.__index = MenuTargetState
function MenuTargetState:Create()
    local this = {}

    setmetatable(this, self)
    return this
end

function MenuTargetState:Enter()
end

function MenuTargetState:Exit()
end

function MenuTargetState:Update(dt)
end

function MenuTargetState:Render(renderer)
end

As with the MenuActorSelector, it’s just a skeleton for now, we’ll fill in the details as they’re needed. Let’s start with the following flow:

[ItemMenuState] --use item--> [MenuTargetSelector]
[MenuTargetSelector] --cancel--> [ItemMenuState]

When the player choses an item, we need to know about it. Therefore let’s add a callback when an item is clicked. In the ItemMenuState, items are stored in a Selection menu, you can see the code below.

Selection:Create
{
    data = gGame.World.mItems,
    spacingX = 256,
    columns = 2,
    displayRows = 8,
    spacingY = 28,
    rows = 20,
    RenderItem = function(self, renderer, x, y, item)
        gGame.World:DrawItem(self, renderer, x, y, item)
    end
},

We need to add the OnSelection callback to this.

Selection:Create
{
    -- code omitted

    RenderItem = function(self, renderer, x, y, item)
        gGame.World:DrawItem(self, renderer, x, y, item)
    end,
    OnSelection = function(...) this:OnUseItem(...) end, <-- new
},

Then let’s create the new OnUseItem function.

function ItemMenuState:CanUseItem(itemDef)
    local useDef = itemDef.use or {}
    return useDef.can_use_on_map == true
end

function ItemMenuState:OnUseItem(index, item)
    local itemDef = ItemDB[item.id]
    if not self:CanUseItem(itemDef) then
        return
    end

    local targetState = MenuTargetState:Create
    {
        originState = self,
        stack = gGame.Stack,
        stateMachine = self.mStateMachine,
        targetType = MenuTargetType.One,
        selector = MenuActorSelector["FirstMagicUser"],
        OnCancel = function(target) print("Cancelled") end,
        OnSelect = function(target) print("Selected", target) end
    }
    gGame.Stack:Push(targetState)
end

In OnUseItem we create the target state with all the parameters it needs and push it on top of the global stack.

There and Back Again

If you run the code now and select an item, the game freezes because we’ve pushed an empty state on top of the stack! Let’s update the code so we’re taken to the FrontStateMenu, then we’ll add an ability to cancel and return to the ItemMenuState, once those work we can dig into the selector more deeply.

Here’s a updated MenuTargetState.

MenuTargetType =
{
    One = "One",
    All = "All",
}

MenuTargetState = {}
MenuTargetState.__index = MenuTargetState
function MenuTargetState:Create(params)

    params = params or {}

    local this =
    {
        mStack = params.stack,
        mStateMachine = params.stateMachine,
        mOriginalState = params.originState,
        mStartedInFrontMenu = (params.originId == "frontmenu"),

        mTargetType = params.targetType or MenuTargetType.One,
        mSelector = params.selector or MenuActorSelector["FirstPartyMember"],

        mFrontMenuState = nil,
        mMarker = Sprite.Create(),

        mTargets = nil,
        mActiveTargets = nil,

        OnSelect = params.OnSelect or function() end,
        OnCancel = params.OnCancel or function() end,
    }

    local markTexture = Texture.Find('cursor.png')
    this.mMarkerWidth = markTexture:GetWidth()
    this.mMarker:SetTexture(markTexture)

    setmetatable(this, self)
    return this
end

function MenuTargetState:Enter()

    -- Selection for previous state should be ignored!
    self.mIgnoreSpaceRelease = Keyboard.JustPressed(KEY_SPACE)

    if not self.mStartedInFrontMenu then
        self.mStateMachine:Change("frontmenu")
    end

    self.mFrontMenuState = self.mStateMachine:Current()
    self.mFrontMenuState:HideOptionsCursor()

    -- Need to get the list of targets with position
    -- Of the form { x = 0, y = 0, summary = Y }
    self.mTargets = self.mFrontMenuState:GetPartyAsSelectionTargets()

    -- Filter the target by the selector
    self.mActiveTargets = self.mSelector(self.mTargets)

    -- If the selector is of type one then we need to select a default target
end

function MenuTargetState:Exit()
end

function MenuTargetState:Update(dt)
end

function MenuTargetState:Render(renderer)
    for k, v in ipairs(self.mActiveTargets) do
        self.mMarker:SetPosition(v.x, v.y)
        renderer:DrawSprite(self.mMarker)
    end
end

function MenuTargetState:HandleInput()

    -- Released otherwise the keypress gets picked up in the state
    -- we transition to
    if Keyboard.JustReleased(KEY_BACKSPACE) or
       Keyboard.JustReleased(KEY_ESCAPE) then
        self.OnCancel()
        self:Back()
    end

    if Keyboard.JustReleased(KEY_SPACE) then

        if self.mIgnoreSpaceRelease then
            self.mIgnoreSpaceRelease = false
            return
        end

        self.OnSelect(self.mActiveTargets)
        self:Back()
    end
end

-- Back out of targetting
function MenuTargetState:Back()
    self.mStack:Pop() -- remove the targeting state

    -- If we're not already in the front menu, then restore previous menu
    if not self.mStartedInFrontMenu then
        self.mStateMachine.mCurrent = self.mOriginalState
    end
end

A bit of a code dump but there’s nothing too tricky going on here.

Well actually, some of the input handling is a little tricky: if you switch between states, a keypress can registered in both states, in the same frame which is annoying! I’ve added code to get around that but really it should be handled more gracefully.

Also note: When we return to the previous state, we don’t call Change. We want to skip all the Enter/Exit stuff and just return to the state as it was.

On entering the target state we get the list of all party members using GetPartyAsSelectionTargets. We filter the list using the mSelector. The selector is defined by the spell or item in use. A revive spell selector should only let you target KO’ed members for instance. (What if there are no KO’ed members? Well, that’s an edge case we handle poorly!)

The MenuTargetState draws a selector over each active target. When the player presses Space it calls it’s selection callback with the active target list. When they press Backspace it returns to the ItemMenuState. Try it out, it works pretty well.

There’s no code to handle targeting types yet, if we can select only one target, or if we can select multiple. That kind of thing. If there’s only one target that can be selected we need to be choose which one and so on.

Selection Types : Everybody

Let’s deal with the easiest selection type first - the entire party. For testing purposes we’ll add an new item the “Mega Heal Potion”. Here’s the def to add to the bottom of the ItemsDB table.

{
    name = "Mega Heal Potion",
    type = "useable",
    description = "Heal all party members for a small amount of HP.",
    use =
    {
        action = "hp_restore",
        restore = 250,
        target =
        {
            selector = "SideParty",
            switch_sides = true,
            type = "Side"
        },
        hint = "Choose target to heal.",
        can_use_on_map = true
    },
    price = 250,
},

It’s similar to the normal “Heal Potion” but

  • More expensive
  • Target is of type Side rather than One.
  • Selector is equal to SideParty instead of MostHurtParty.

We haven’t defined the combat selector for SideParty so let’s add it now.

CombatSelector =
{
    -- code omitted
    SideParty = function(state)
        return state.mActors["party"]
    end,
}

Now we can add a Mega Heal Potion to the party inventory and use it in combat.

Next let’s add the code to call when the player selects the target(s). To make this happen let’s update the OnUseItem function in the ItemMenuState.

function ItemMenuState:OnUseItem(index, item)
    local itemDef = ItemDB[item.id]
    if not self:CanUseItem(itemDef) then
        return
    end

    local selectId = itemDef.use.target.selector

    local targetState = MenuTargetState:Create
    {
        originId = "items",
        stack = gGame.Stack,
        stateMachine = self.mStateMachine,
        targetType = itemDef.use.target.type,
        selector = MenuActorSelector[selectId],
        OnCancel = function(target) print("Cancelled") end,
        OnSelect = function(target) print("Selected", target) end
    }
    gGame.Stack:Push(targetState)
end

The targetType and selector fields are now set by the item.

In MenuActorSelector let’s add a selector called SideParty too.

MenuActorSelector =
{
    -- code omitted
    SideParty = function(targets)
        return targets -- return everyone!
    end
}

Trying out the Mega Potion

To make testing this easier, let’s add a debug button in the main.lua file.

function update()

    -- code omitted

    if Keyboard.JustPressed(KEY_D) then
        for k, v in pairs(gGame.World.mParty.mMembers) do
            local stats = v.mStats
            stats:Set("hp_now", stats:Get("hp_now")*0.5)
        end
        local megaHealPotionId = 16
        gGame.World:AddItem(megaHealPotionId, 1)
    end

If we run the game and press D, it halves the health of all the party members and gives the player a mega potion.

Run the game now, press D, choose the mega potion and by default every member of the party will be targeted.

Hooking up Item Use

When a players choses the targets we get a callback to OnSelect. At the moment, OnSelect just prints a message. Let’s make it call OnItemTargetsSelected.

OnSelect = function(targets) self:OnItemTargetsSelected(itemDef, targets) end

And now let’s write that function.

function ItemMenuState:OnItemTargetsSelected(itemDef, targets)
    local action = itemDef.use.action
    CombatActions[action](self,
                       nil, -- the person using the item
                       targets,
                       itemDef,
                       "item")

    -- Remove the item from the inventory
    gGame.World:RemoveItem(itemDef.id)
end

The last parameter in the CombatActions indicates that we’re calling this action from the “item” menu.

Here’s the current hp_restore combat action we reference in the heal potion def.

["hp_restore"] =
function(state, owner, targets, def)
    local restoreAmount = def.use.restore or 250
    local animEffect = gEntities.fx_restore_hp
    local restoreColor = Vector.Create(0, 1, 0, 1)

    for k, v in ipairs(targets) do

        local stats, character, entity = StatsCharEntity(state, v)

        local maxHP = stats:Get("hp_max")
        local nowHP = stats:Get("hp_now")

        if nowHP > 0 then
            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)
            nowHP = math.min(maxHP, nowHP + restoreAmount)
            stats:Set("hp_now", nowHP)
        end

        AddAnimEffect(state, entity, animEffect, 0.1)

    end

end,

We need to update this action, so it works on the menu as well. We must seperate the “behind-the-scenes” logic and foreground special effects.

This means it’s going to get longer!

local function StatsForCombatState(targets)
    local statList = {}
    for k, v in ipairs(targets) do
        table.insert(statList, v.mStats)
    end
    return statList
end

local function StatsForMenuState(targets)
    local statList = {}
    for k, v in pairs(targets) do
        local stats = v.summary.mActor.mStats
        table.insert(statList, stats)
    end
    return statList
end

CombatActions =
{
    ["hp_restore"] =
    function(state, owner, targets, def, stateId)

        local extractStatFunction = StatsForCombatState
        if stateId == "item" then
            extractStatFunction = StatsForMenuState
        end

        local statList = extractStatFunction(targets)
        local restoreAmount = def.use.restore or 250
        for k, v in ipairs(statList) do
            local maxHP = v:Get("hp_max")
            local nowHP = v:Get("hp_now")
            if nowHP > 0 then
                nowHP = math.min(maxHP, nowHP + restoreAmount)
                v:Set("hp_now", nowHP)
            end
        end

        if stateId == "item" then
            return
        end

        --
        -- Combat Effects
        --

        local animEffect = gEntities.fx_restore_hp
        local restoreColor = Vector.Create(0, 1, 0, 1)

        for k, v in ipairs(targets) do

            local stats, character, entity = StatsCharEntity(state, v)

            if stats:Get("hp_now") > 0 then
                AddTextNumberEffect(state, entity, restoreAmount, restoreColor)
            end

            AddAnimEffect(state, entity, animEffect, 0.1)
        end

    end,

Feels like this code could tighter but it works for now.

You can run this code and it works. The mega-heal potion can be used in both combat and the menus. When used from the item menu, the effect of the item isn’t well communicated but that can be a future polish article!

Selection Types : One

Items that target all the party work, now let’s make single target items work too.

We don’t need to add a new item to test this, we already have three!

  1. Heal Potion
  2. Life Salve
  3. Mana Potion

The heal potion shares the same action as the “Mega Heal Potion”, so that will work. Life Salve and the Mana Potion need their actions updating. These updates are pretty lengthy so you can check them out over here. And we also need to update the selectors they use which can be seen here.

Moving the Selector

When choosing the target for an item, the MenuTargetState is on the top of the stack. Let’s update the HandleInput function to detect up and down keypresses and react to them.

function MenuTargetState:HandleInput()

    -- code omitted

    if Keyboard.JustPressed(KEY_UP) then
        self:Up()
    elseif Keyboard.JustPressed(KEY_DOWN) then
        self:Down()
    end
end

When selecting a single target we need to keep track of who’s selected. We’ll do that with an index value.

function MenuTargetState:FindIndex()

    for k, v in ipairs(self.mTargets) do
        if self.mActiveTargets[1] == v then
            return k
        end
    end

    print("Error: Failed to find index!")
    return 1

end

function MenuTargetState:Enter()

    -- code omitted

    if self.mTargetType == MenuTargetType.One then
        self.mIndex = self:FindIndex()
    end
end

When we enter the MenuTargetState and we’re only selecting one party member we need to find the index of the default party member. Then we can use this index to let the player move around the party menu.

Using the index let’s implement the Up, Down functions and let the player choose who to target.

function MenuTargetState:Up()
    if self.mTargetType ~= MenuTargetType.One then
        return
    end

    local newIndex = self.mIndex

    while newIndex > 0 do
        newIndex = newIndex - 1

        local targets = self.mSelector({self.mTargets[newIndex]})

        if next(targets) then
            self.mIndex = newIndex
            self.mActiveTargets = targets
            return
        end
    end
end

function MenuTargetState:Down()
    if self.mTargetType ~= MenuTargetType.One then
        return
    end

    local newIndex = self.mIndex

    while newIndex <= #self.mTargets do
        newIndex = newIndex + 1

        local targets = self.mSelector({self.mTargets[newIndex]})

        if next(targets) then
            self.mIndex = newIndex
            self.mActiveTargets = targets
            return
        end
    end
end

When we press Down we move the current index down from the current index until we find a new index that the selector accepts. Up works in the same way.

Using Magic Spells from the Menu

Items can now be used from the in-game menu. Let’s make the same true of the spells.

The selection code for the spells lives in the MagicMenuState in the mSpellMenu. First thing, let’s add a new selection callback. Here’s the updated mSpellMenu below.

self.mSpellMenu = Selection:Create
{
    data = character.mMagic,
    OnSelection = function(...) self:OnCastSpell(...) end, -- new!
    spacingX = 256,
    displayRows = 6,
    spacingY = 28,
    columns = 2,
    rows = 20,
    RenderItem = function(...) self:RenderSpell(...) end
}

The OnCastSpell function is called when the player chooses a spell from the menu. This includes greyed out spells, so we need to be careful to not cast those.

function MagicMenuState:OnCastSpell(index, spellId)

    local spellDef = SpellDB[spellId]

    if self:CanCast(spellDef) then
        local selectId = spellDef.target.selector
        local targetState = MenuTargetState:Create
        {
            originState = self,
            stack = gGame.Stack,
            stateMachine = self.mStateMachine,
            targetType = spellDef.target.type,
            selector = MenuActorSelector[selectId],
            OnCancel = function(target) print("Cancelled") end,
            OnSelect = function(targets) self:OnSpellTargetsSelected(spellDef, targets) end
        }
        gGame.Stack:Push(targetState)

    end
end

When a spell selects a target it calls OnSpellTargetsSelected, let’s write that next.

function MagicMenuState:OnSpellTargetsSelected(targets)
    self.mCharacter:ReduceManaForSpell(spellDef)
    local action = spellDef.action
    CombatActions[action](self.mState,
                       self.mCharacter,
                       targets,
                       spellDef,
                       "magic_menu")
end

We don’t test if we can cast the spell because we already did that before entering the MenuTargetState.

We add a new function called ReduceManaForSpell check it out below:

function Actor:ReduceManaForSpell(spellDef)
    local mp = self.mStats:Get("mp_now")
    local cost = spellDef.mp_cost
    local mp = math.max(mp - cost, 0)
    self.mStats:Set("mp_now", mp)
end

I’ve also updated CECastSpell to use this function too.

Finally we need to update the hp_restore_spell action, so it doesn’t try and add combat spell effects to the menu. Here’s the updated version in CombatActions.lua.

["hp_restore_spell"] =
function(state, owner, targets, def, stateId)

    local extractStatFunction = StatsForCombatState
    if stateId == "magic_menu" then
        extractStatFunction = StatsForMenuState
    end

    local restoreAmount = def.base_heal or 100
    restoreAmount = restoreAmount * owner.mLevel
    local statList = extractStatFunction(targets)

    for k, v in ipairs(statList) do
        local maxHP = v:Get("hp_max")
        local nowHP = v:Get("hp_now")
        if nowHP > 0 then
            nowHP = math.min(maxHP, nowHP + restoreAmount)
            v:Set("hp_now", nowHP)
        end
    end

    if stateId == "magic_menu" then
        return
    end

    local animEffect = gEntities.fx_restore_hp
    local restoreColor = Vector.Create(0, 1, 0, 1)

    for k, v in ipairs(targets) do

        local stats, character, entity = StatsCharEntity(state, v)

        local nowHP = stats:Get("hp_now")

        if nowHP > 0 then
            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)
        end

        AddAnimEffect(state, entity, animEffect, 0.1)
    end
end

I’m not happy with how fat these combat action functions are but they do the job until I can think of something nicer!

That’s all we need to do. We can now cast spells and use items from the in-game menu. This change was surprisingly big but definitely makes the game more playable!

Here’s a screenshot showing all characters being selected:

Improved selection on the RPG menu.

A little clean-up

The magic menu state uses the variable mCharacter, I’ve renamed that to mActor as that it references the Actor class not the Character class!

Summing Up

This has been a pretty big feature! It’s functional but it’s not very polished. A number of future articles will make using items on the map nicer for the user.

Here are few things that need addressing:

  1. There are now two ways to select party members in the front menu - there should only be one!
  2. The player doesn’t get enough feedback about the effects of items and spells on their targets
  3. When selecting a target there should be tool tip saying “Choose target” or similar.
  4. Perhaps lock off items and spells that don’t have a valid target (using the life-salve when no one is dead).

Source

There’s a github with an updated project here.

Related Articles