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:
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.
- Some way to mark spells and items as “Can be used on the map”
- Selection code to chose the wounded character(s).
- 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)
- 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:
- Does it have a true
can_use_on_map
flag? - 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:
- Enter a selection state from the item and spell menu
- Upgrade the selector so we can select the entire party at once
- 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:
- Chose a valid party member
- 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!
Menu Target State
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 thanOne
. - Selector is equal to
SideParty
instead ofMostHurtParty
.
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!
- Heal Potion
- Life Salve
- 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:
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:
- There are now two ways to select party members in the front menu - there should only be one!
- The player doesn’t get enough feedback about the effects of items and spells on their targets
- When selecting a target there should be tool tip saying “Choose target” or similar.
- 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.
Enjoyed this article? Share or link to it, to support the site and help fellow developers.