Polish 07 - Using Spells and Items Outside of Combat

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

LUA
1["fire"] =2{3    name = "Fire",4    action = "element_spell",5    element = "fire",6    mp_cost = 8,7    base_damage = {3, 5},8    base_hit_chance = 1,9    time_points = 10,10    target =11    {12        selector = "WeakestEnemy",13        switch_sides = true,14        type = "One"15    },16    description = "Damages an enemy with elemental fire!"17},

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.

LUA
1["heal"] =2{3    name = "Heal",4    action = "hp_restore_spell",5    mp_cost = 8,6    base_heal = 100,7    base_hit_chance = 1,8    time_points = 10,9    can_use_on_map = true, -- <-- this is new!10    target =11    {12        selector = "MostHurtParty",13        switch_sides = true,14        type = "One"15    },16    description = "Heals some health of a single party member."17},

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.

LUA
1["hp_restore"] =2function(state, owner, targets, def)34    local restoreAmount = def.use.restore or 2505    local animEffect = gEntities.fx_restore_hp6    local restoreColor = Vector.Create(0, 1, 0, 1)78    for k, v in ipairs(targets) do910        local stats, character, entity = StatsCharEntity(state, v)1112        local maxHP = stats:Get("hp_max")13        local nowHP = stats:Get("hp_now")1415        if nowHP > 0 then16            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)17            nowHP = math.min(maxHP, nowHP + restoreAmount)18            stats:Set("hp_now", nowHP)19        end2021        AddAnimEffect(state, entity, animEffect, 0.1)2223    end24end

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.

LUA
1["hp_restore_spell"] =2function(state, owner, targets, def)34    local restoreAmount = def.base_heal or 1005    restoreAmount = restoreAmount * owner.mLevel6    local animEffect = gEntities.fx_restore_hp7    local restoreColor = Vector.Create(0, 1, 0, 1)89    for k, v in ipairs(targets) do1011        local stats, character, entity = StatsCharEntity(state, v)1213        local maxHP = stats:Get("hp_max")14        local nowHP = stats:Get("hp_now")1516        if nowHP > 0 then17            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)18            nowHP = math.min(maxHP, nowHP + restoreAmount)19            stats:Set("hp_now", nowHP)20        end2122        AddAnimEffect(state, entity, animEffect, 0.1)2324    end25end

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.

LUA
1{2    name = "Heal Potion",3    type = "useable",4    description = "Heal a small amount of HP.",5    use =6    {7        action = "hp_restore",8        restore = 250,9        target =10        {11            selector = "MostHurtParty",12            switch_sides = true,13            type = "One"14        },15        hint = "Choose target to heal.",16        can_use_on_map = true,17    },18    price = 50,19},

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.

LUA
1gPartyMemberDefs =2{3    -- code omitted45    mage =6    {7        -- code omitted89    actionGrowth =10    {11        [1] =12        {13            ['magic'] = { 'bolt', 'heal' },14        },

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:

LUA
1function MagicMenuState:CanCast(spellDef)2    if spellDef == nil or not spellDef.can_use_on_map then3       return false4    end56    return self.mCharacter:CanCast(spellDef)7end89function MagicMenuState:RenderSpell(menu, renderer, x, y, item)1011    local font = gGame.Font.default1213    if item == nil then14       font:DrawText2d(renderer, x, y, "--")15    else16        local spell = SpellDB[item]17        local horzSpace = 9618        local color = Vector.Create(107/255, 107/255, 107/255, 1)1920        if self:CanCast(spell) then21            color = Vector.Create(1, 1, 1, 1)22        end2324        font:DrawText2d(renderer, x, y, spell.name, color)25        font:DrawText2d(renderer, x + horzSpace, y,26                        string.format("%d", spell.mp_cost), color)27    end28end

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.

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

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.

LUA
1MenuActorSelector =2{3    FirstPartyMember = function(menu)4    end,56    FirstMagicUser = function(menu)7    end,89    MostHurtMember = function(menu)10    end,1112    MostDrainedParty = function(menu) -- lowest mp13    end,1415    DeadParty = function(menu) -- first dead party member16    end,17}

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:

LUA
1function FrontMenuState:HideOptionsCursor()2    self.mSelections:HideCursor()3end

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.

LUA
1function FrontMenuState:GetPartyAsSelectionTargets()2    local targets = {}34    local x = self.mPartyMenu.mX5    local y = self.mPartyMenu.mY6    local cursorWidth = self.mPartyMenu:CursorWidth()78    for k, v in ipairs(self.mPartyMenu.mDataSource) do910        local indexFrom0 = k - 11112        table.insert(targets,13                     {14                        x = x + cursorWidth * 0.5,15                        y = y - (indexFrom0 * self.mPartyMenu.mSpacingY),16                        summary = v17                    })18    end19    return targets20end

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:

LUA
1function FrontMenuState:Render(renderer)2    -- code omitted34    local partyX = self.mLayout:Left("party") - 165    local partyY = self.mLayout:Top("party") - 456    self.mPartyMenu:SetPosition(partyX, partyY)7    self.mPartyMenu:Render(renderer)8end

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.

LUA
1function FrontMenuState:Enter()2    local partyX = self.mLayout:Left("party") - 163    local partyY = self.mLayout:Top("party") - 454    self.mPartyMenu:SetPosition(partyX, partyY)5end

Ok, on to new code!

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

LUA
1MenuTargetType =2{3    One = "One",4    All = "All",5}67MenuTargetState = {}8MenuTargetState.__index = MenuTargetState9function MenuTargetState:Create()10    local this = {}1112    setmetatable(this, self)13    return this14end1516function MenuTargetState:Enter()17end1819function MenuTargetState:Exit()20end2122function MenuTargetState:Update(dt)23end2425function MenuTargetState:Render(renderer)26end

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:

LUA
1[ItemMenuState] --use item--> [MenuTargetSelector]2[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.

LUA
1Selection:Create2{3    data = gGame.World.mItems,4    spacingX = 256,5    columns = 2,6    displayRows = 8,7    spacingY = 28,8    rows = 20,9    RenderItem = function(self, renderer, x, y, item)10        gGame.World:DrawItem(self, renderer, x, y, item)11    end12},

We need to add the OnSelection callback to this.

LUA
1Selection:Create2{3    -- code omitted45    RenderItem = function(self, renderer, x, y, item)6        gGame.World:DrawItem(self, renderer, x, y, item)7    end,8    OnSelection = function(...) this:OnUseItem(...) end, <-- new9},

Then let’s create the new OnUseItem function.

LUA
1function ItemMenuState:CanUseItem(itemDef)2    local useDef = itemDef.use or {}3    return useDef.can_use_on_map == true4end56function ItemMenuState:OnUseItem(index, item)7    local itemDef = ItemDB[item.id]8    if not self:CanUseItem(itemDef) then9        return10    end1112    local targetState = MenuTargetState:Create13    {14        originState = self,15        stack = gGame.Stack,16        stateMachine = self.mStateMachine,17        targetType = MenuTargetType.One,18        selector = MenuActorSelector["FirstMagicUser"],19        OnCancel = function(target) print("Cancelled") end,20        OnSelect = function(target) print("Selected", target) end21    }22    gGame.Stack:Push(targetState)23end

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.

LUA
1MenuTargetType =2{3    One = "One",4    All = "All",5}67MenuTargetState = {}8MenuTargetState.__index = MenuTargetState9function MenuTargetState:Create(params)1011    params = params or {}1213    local this =14    {15        mStack = params.stack,16        mStateMachine = params.stateMachine,17        mOriginalState = params.originState,18        mStartedInFrontMenu = (params.originId == "frontmenu"),1920        mTargetType = params.targetType or MenuTargetType.One,21        mSelector = params.selector or MenuActorSelector["FirstPartyMember"],2223        mFrontMenuState = nil,24        mMarker = Sprite.Create(),2526        mTargets = nil,27        mActiveTargets = nil,2829        OnSelect = params.OnSelect or function() end,30        OnCancel = params.OnCancel or function() end,31    }3233    local markTexture = Texture.Find('cursor.png')34    this.mMarkerWidth = markTexture:GetWidth()35    this.mMarker:SetTexture(markTexture)3637    setmetatable(this, self)38    return this39end4041function MenuTargetState:Enter()4243    -- Selection for previous state should be ignored!44    self.mIgnoreSpaceRelease = Keyboard.JustPressed(KEY_SPACE)4546    if not self.mStartedInFrontMenu then47        self.mStateMachine:Change("frontmenu")48    end4950    self.mFrontMenuState = self.mStateMachine:Current()51    self.mFrontMenuState:HideOptionsCursor()5253    -- Need to get the list of targets with position54    -- Of the form { x = 0, y = 0, summary = Y }55    self.mTargets = self.mFrontMenuState:GetPartyAsSelectionTargets()5657    -- Filter the target by the selector58    self.mActiveTargets = self.mSelector(self.mTargets)5960    -- If the selector is of type one then we need to select a default target61end6263function MenuTargetState:Exit()64end6566function MenuTargetState:Update(dt)67end6869function MenuTargetState:Render(renderer)70    for k, v in ipairs(self.mActiveTargets) do71        self.mMarker:SetPosition(v.x, v.y)72        renderer:DrawSprite(self.mMarker)73    end74end7576function MenuTargetState:HandleInput()7778    -- Released otherwise the keypress gets picked up in the state79    -- we transition to80    if Keyboard.JustReleased(KEY_BACKSPACE) or81       Keyboard.JustReleased(KEY_ESCAPE) then82        self.OnCancel()83        self:Back()84    end8586    if Keyboard.JustReleased(KEY_SPACE) then8788        if self.mIgnoreSpaceRelease then89            self.mIgnoreSpaceRelease = false90            return91        end9293        self.OnSelect(self.mActiveTargets)94        self:Back()95    end96end9798-- Back out of targetting99function MenuTargetState:Back()100    self.mStack:Pop() -- remove the targeting state101102    -- If we're not already in the front menu, then restore previous menu103    if not self.mStartedInFrontMenu then104        self.mStateMachine.mCurrent = self.mOriginalState105    end106end

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.

LUA
1{2    name = "Mega Heal Potion",3    type = "useable",4    description = "Heal all party members for a small amount of HP.",5    use =6    {7        action = "hp_restore",8        restore = 250,9        target =10        {11            selector = "SideParty",12            switch_sides = true,13            type = "Side"14        },15        hint = "Choose target to heal.",16        can_use_on_map = true17    },18    price = 250,19},

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.

LUA
1CombatSelector =2{3    -- code omitted4    SideParty = function(state)5        return state.mActors["party"]6    end,7}

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.

LUA
1function ItemMenuState:OnUseItem(index, item)2    local itemDef = ItemDB[item.id]3    if not self:CanUseItem(itemDef) then4        return5    end67    local selectId = itemDef.use.target.selector89    local targetState = MenuTargetState:Create10    {11        originId = "items",12        stack = gGame.Stack,13        stateMachine = self.mStateMachine,14        targetType = itemDef.use.target.type,15        selector = MenuActorSelector[selectId],16        OnCancel = function(target) print("Cancelled") end,17        OnSelect = function(target) print("Selected", target) end18    }19    gGame.Stack:Push(targetState)20end

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

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

LUA
1MenuActorSelector =2{3    -- code omitted4    SideParty = function(targets)5        return targets -- return everyone!6    end7}

Trying out the Mega Potion

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

LUA
1function update()23    -- code omitted45    if Keyboard.JustPressed(KEY_D) then6        for k, v in pairs(gGame.World.mParty.mMembers) do7            local stats = v.mStats8            stats:Set("hp_now", stats:Get("hp_now")*0.5)9        end10        local megaHealPotionId = 1611        gGame.World:AddItem(megaHealPotionId, 1)12    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.

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

And now let’s write that function.

LUA
1function ItemMenuState:OnItemTargetsSelected(itemDef, targets)2    local action = itemDef.use.action3    CombatActions[action](self,4                       nil, -- the person using the item5                       targets,6                       itemDef,7                       "item")89    -- Remove the item from the inventory10    gGame.World:RemoveItem(itemDef.id)11end

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.

LUA
1["hp_restore"] =2function(state, owner, targets, def)3    local restoreAmount = def.use.restore or 2504    local animEffect = gEntities.fx_restore_hp5    local restoreColor = Vector.Create(0, 1, 0, 1)67    for k, v in ipairs(targets) do89        local stats, character, entity = StatsCharEntity(state, v)1011        local maxHP = stats:Get("hp_max")12        local nowHP = stats:Get("hp_now")1314        if nowHP > 0 then15            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)16            nowHP = math.min(maxHP, nowHP + restoreAmount)17            stats:Set("hp_now", nowHP)18        end1920        AddAnimEffect(state, entity, animEffect, 0.1)2122    end2324end,

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!

LUA
1local function StatsForCombatState(targets)2    local statList = {}3    for k, v in ipairs(targets) do4        table.insert(statList, v.mStats)5    end6    return statList7end89local function StatsForMenuState(targets)10    local statList = {}11    for k, v in pairs(targets) do12        local stats = v.summary.mActor.mStats13        table.insert(statList, stats)14    end15    return statList16end1718CombatActions =19{20    ["hp_restore"] =21    function(state, owner, targets, def, stateId)2223        local extractStatFunction = StatsForCombatState24        if stateId == "item" then25            extractStatFunction = StatsForMenuState26        end2728        local statList = extractStatFunction(targets)29        local restoreAmount = def.use.restore or 25030        for k, v in ipairs(statList) do31            local maxHP = v:Get("hp_max")32            local nowHP = v:Get("hp_now")33            if nowHP > 0 then34                nowHP = math.min(maxHP, nowHP + restoreAmount)35                v:Set("hp_now", nowHP)36            end37        end3839        if stateId == "item" then40            return41        end4243        --44        -- Combat Effects45        --4647        local animEffect = gEntities.fx_restore_hp48        local restoreColor = Vector.Create(0, 1, 0, 1)4950        for k, v in ipairs(targets) do5152            local stats, character, entity = StatsCharEntity(state, v)5354            if stats:Get("hp_now") > 0 then55                AddTextNumberEffect(state, entity, restoreAmount, restoreColor)56            end5758            AddAnimEffect(state, entity, animEffect, 0.1)59        end6061    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.

LUA
1function MenuTargetState:HandleInput()23    -- code omitted45    if Keyboard.JustPressed(KEY_UP) then6        self:Up()7    elseif Keyboard.JustPressed(KEY_DOWN) then8        self:Down()9    end10end

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

LUA
1function MenuTargetState:FindIndex()23    for k, v in ipairs(self.mTargets) do4        if self.mActiveTargets[1] == v then5            return k6        end7    end89    print("Error: Failed to find index!")10    return 11112end1314function MenuTargetState:Enter()1516    -- code omitted1718    if self.mTargetType == MenuTargetType.One then19        self.mIndex = self:FindIndex()20    end21end

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.

LUA
1function MenuTargetState:Up()2    if self.mTargetType ~= MenuTargetType.One then3        return4    end56    local newIndex = self.mIndex78    while newIndex > 0 do9        newIndex = newIndex - 11011        local targets = self.mSelector({self.mTargets[newIndex]})1213        if next(targets) then14            self.mIndex = newIndex15            self.mActiveTargets = targets16            return17        end18    end19end2021function MenuTargetState:Down()22    if self.mTargetType ~= MenuTargetType.One then23        return24    end2526    local newIndex = self.mIndex2728    while newIndex <= #self.mTargets do29        newIndex = newIndex + 13031        local targets = self.mSelector({self.mTargets[newIndex]})3233        if next(targets) then34            self.mIndex = newIndex35            self.mActiveTargets = targets36            return37        end38    end39end

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.

LUA
1self.mSpellMenu = Selection:Create2{3    data = character.mMagic,4    OnSelection = function(...) self:OnCastSpell(...) end, -- new!5    spacingX = 256,6    displayRows = 6,7    spacingY = 28,8    columns = 2,9    rows = 20,10    RenderItem = function(...) self:RenderSpell(...) end11}

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.

LUA
1function MagicMenuState:OnCastSpell(index, spellId)23    local spellDef = SpellDB[spellId]45    if self:CanCast(spellDef) then6        local selectId = spellDef.target.selector7        local targetState = MenuTargetState:Create8        {9            originState = self,10            stack = gGame.Stack,11            stateMachine = self.mStateMachine,12            targetType = spellDef.target.type,13            selector = MenuActorSelector[selectId],14            OnCancel = function(target) print("Cancelled") end,15            OnSelect = function(targets) self:OnSpellTargetsSelected(spellDef, targets) end16        }17        gGame.Stack:Push(targetState)1819    end20end

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

LUA
1function MagicMenuState:OnSpellTargetsSelected(targets)2    self.mCharacter:ReduceManaForSpell(spellDef)3    local action = spellDef.action4    CombatActions[action](self.mState,5                       self.mCharacter,6                       targets,7                       spellDef,8                       "magic_menu")9end

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:

LUA
1function Actor:ReduceManaForSpell(spellDef)2    local mp = self.mStats:Get("mp_now")3    local cost = spellDef.mp_cost4    local mp = math.max(mp - cost, 0)5    self.mStats:Set("mp_now", mp)6end

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.

LUA
1["hp_restore_spell"] =2function(state, owner, targets, def, stateId)34    local extractStatFunction = StatsForCombatState5    if stateId == "magic_menu" then6        extractStatFunction = StatsForMenuState7    end89    local restoreAmount = def.base_heal or 10010    restoreAmount = restoreAmount * owner.mLevel11    local statList = extractStatFunction(targets)1213    for k, v in ipairs(statList) do14        local maxHP = v:Get("hp_max")15        local nowHP = v:Get("hp_now")16        if nowHP > 0 then17            nowHP = math.min(maxHP, nowHP + restoreAmount)18            v:Set("hp_now", nowHP)19        end20    end2122    if stateId == "magic_menu" then23        return24    end2526    local animEffect = gEntities.fx_restore_hp27    local restoreColor = Vector.Create(0, 1, 0, 1)2829    for k, v in ipairs(targets) do3031        local stats, character, entity = StatsCharEntity(state, v)3233        local nowHP = stats:Get("hp_now")3435        if nowHP > 0 then36            AddTextNumberEffect(state, entity, restoreAmount, restoreColor)37        end3839        AddAnimEffect(state, entity, animEffect, 0.1)40    end41end

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