31. Combat Resolution and Party
By the end of this lesson, you'll have a working party system and Battle UI, with the option to Attack or use an Item (does nothing yet).
You'll also have a rudimentary working battle victory screen.
The concepts we'll need to cover to achieve this are varied, but can be grouped together into Party, Battle, and UI.
To achieve a working UI, I have developed a small UI layout library which is included as a dependency.
The building of the UI layout library is outside the scope of this course. We will treat it as a 3rd party dependency like Raylib.
Party
The Party system has two components: Party Roster and Party Members.
The Roster is the list of all possible party members in the game. I have supplied a file at data/party_roster.json with 6 members.
Party Members are the instantiation of these Roster entries and represent the party at a point in time during a play-through.
Party Members may be enabled or disabled based on game state. For example, if a character is kidnapped, we don't want them to be available to use in the active party.
Party Members may gain experience and have their stats updated, hence using an Entity_Definition is not sufficient.
I have included only two party members' battle-only Entity_Definition, and hence their battle entities can be found in data/ara_battle.json and data/denning_battle.json.
To make combat work, I have added 3 basic stats to our entities:
Stat :: struct {
curr, max: int,
}
Entity_Stats :: struct {
hp, atk, def: Stat,
}
Entity :: struct {
// ... existing fields ...
name: string,
display_name: string,
using stats: Entity_Stats,
}
name and display_name have been added for two reasons. name for map lookup and display_name so that the UI can show a nice name without worrking about the map key.
Entity_Definition also needs the stats, but only the maximum values:
Entity_Definition :: struct {
// ... existing fields ...
hp, atk, def: int,
}
When instantiating the Entity with entity_create_with_def:
e.name = def.name
e.layer = def.layer
e.hp = {def.hp, def.hp}
e.atk = {def.atk, def.atk}
e.def = {def.def, def.def}
layer is an existing field that currently only changes the colour of the wireframe spheres in battle, but later will be used for checking triggers or collisions, etc.
We were not parsing layer, and so I have added custom parsing in game_post_window_create.
Where we parse entity definitions:
if layer, exists := root.(json.Object)["_layer"].(json.String); exists {
v, ok := reflect.enum_from_name(Layer, layer)
if !ok {
log.panicf("Error parsing Entity Definition Layer: %s", layer)
}
entity_def.layer = v
}
While we are editing game_post_create_window, add this to the bottom:
gs.party_roster = load_party_roster()
gs.unlocked_party_members = make([]bool, len(gs.party_roster))
gs.current_party_members[0] = 0 // FIXME: Load from save file
gs.current_party_members[1] = 3 // FIXME: Load from save file
gs.current_party_members[2] = -1 // FIXME: Load from save file
// TODO: Save-file checking
gs.unlocked_party_members[0] = true // FIXME: This is a mock
gs.unlocked_party_members[3] = true // FIXME: This is a mock
for i in 0 ..< len(gs.party_roster) {
// Check save file for whether this party member is unlocked
// gs.unlocked_party_members = get_unlocked_party_members_from_save_file()
// If yes, use their saved experience points and other data
if gs.unlocked_party_members[i] {
// TODO
// gs.party_roster[i].stats = saved_party_member_stats[i]
}
}
Some of the code is commented out or mocked, though the intention should be clear.
We use the Roster to fill in the base data, then combine that with the data from the save file to get up-to-date party members and their current unlocked status.
And, of course, we must update our Game_State with the relevant fields:
Game_State :: struct {
// ... existing fields ...
current_party_members: [3]int
unlocked_party_members: []bool,
party_roster: []Party_Member,
// For use later with the UI - defined in ui.odin
ui: UI_State,
}
Here is the new party.odin file in full:
package main
import "core:encoding/json"
import "core:log"
import "core:os"
Party_Member :: struct {
name: string,
battle_entity_def_key: string,
field_entity_def_key: string,
using stats: Entity_Stats,
}
load_party_roster :: proc(allocator := context.allocator) -> (roster: []Party_Member) {
data, data_ok := os.read_entire_file("data/party_roster.json")
if !data_ok {
log.panicf("Failed to read data/party_roster.json - does the file exist?")
}
root, parse_err := json.parse(data, .MJSON, true, context.temp_allocator)
count := 0
#partial switch v in root {
case json.Array:
count = len(v)
}
if count == 0 do log.panicf("Could not find any party members in data/party_roster.json. Make sure the json structure is an Array")
roster = make([]Party_Member, count, allocator)
unmarshal_err := json.unmarshal(data, &roster, .MJSON, allocator)
if unmarshal_err != nil {
log.panicf("Failed to parse data/party_roster.json: %s", unmarshal_err)
}
return
}
Battle Setup
For the first iteration of a working battle system, we will have a simple turn-based setup where the player takes a turn and then the enemy takes a turn.
To model out the battle state, we required the following types:
Battle_Menu_State :: struct {
cursor: [8]^Battle_Menu_Item,
depth: int,
}
Battle_Menu_Item :: struct {
text: string,
data: int,
action_type: Battle_Action_Type,
target: Maybe(Entity_Handle),
next: ^Battle_Menu_Item,
submenu: ^Battle_Menu_Item,
}
Battle_Action :: struct {
type: Battle_Action_Type,
actor: Entity_Handle,
target: Entity_Handle,
}
Battle_Action_Type :: enum {
NONE,
ATTACK,
ITEM,
}
Battle_Turn_State :: enum {
PLAYER_TURN,
ENEMY_TURN,
VICTORY,
}
And the Battle_State struct itself has some new fields:
Battle_State :: struct {
// ... existing fields ...
player_entity_ids: [3]Maybe(Entity_Handle),
enemy_entity_ids: [3]Maybe(Entity_Handle),
menu_state: Battle_Menu_State,
menu_start: ^Battle_Menu_Item,
turn_state: Battle_Turn_State,
}
What we want to do now is flip back and forth between the player and enemy turns, while keeping things as simple as possible, and using these new entity slots.
In battle_start, near the top, set:
bs.turn_state = .PLAYER_TURN
We are going to swap the players to the RHS of the screen, so update the loop in which we spawn the enemies:
for maybe_def_id, i in enemy_def_ids {
// ...
e.position = {-8, 0, 2}
// ...
bs.enemy_entity_ids[i] = e.handle
We'll also set the enemy entity IDs now.
Where we spawned the Warrior, now we spawn the actual party members. Replace the block with:
// Spawn party members
gs := cast(^Game_State)context.user_ptr
for idx, i in gs.current_party_members {
if idx == -1 do continue
def_key := gs.party_roster[idx].battle_entity_def_key
def, def_ok := entity_def_by_name(entity_defs, def_key)
if !def_ok {
log.panicf("Failed to find the %s entity def!", def_key)
}
h := entity_create_with_def(bs.battle_scene, def)
e := entity_get(bs.battle_scene, h)
e.position = {8 + f32(i), 0, 2 + f32(i) * 1.5}
bs.player_entity_ids[i] = h
}
UI
Most of the UI code is fhe UI layout library usage and it's all for the battle scene.
As such, I'll outline how to use the library here and supply the full ui.odin (game code) and fui.odin (third party) code in the zip file.
The full
ui.odinfile is heavily indented due to the nature of how the library works - so, it's impractical to embed into a Skool code block.
The UI library works by the user declaring either a Column or a Row.
Each element takes a Config struct which gives it certain properties.
For example:
RED :: Vec4{255, 0, 0, 255}
GREEN :: Vec4{0, 255, 0, 255}
BLUE :: Vec4{0, 0, 255, 255}
// Create a column with red background
// The 2nd parameter is the body procedure and any elements created in that scope will have the parent element. In this case: the RED Column.
// The column will have 3 rows, all with a blue background.
// The 2nd row will have a column inside of it with a green background.
fui.col(&{color = RED, gap = 10}, proc() {
fui.row(&{color = BLUE})
fui.row(&{color = BLUE, padding = 10}, proc() {
fui.col(&{color = GREEN})
})
fui.row(&{color = BLUE})
})
Due to Odin not having closures, in order to access variables in the lower scopes, two procedures have been added:
fui.col_box
fui.row_box
They act similarly, but rather than passing in a 2nd parameter, they push themselves to the current parent of the UI. In order to close the scope, the user must call fui.pop().
I tend to use this configuration to stop myself getting confused:
fui.col_box(); {
fui.row_box(); {
fui.row_box(); {
}; fui.pop()
}; fui.pop()
}; fui.pop()
It's a bit messy, but prevents having to count instances of _box to see which scope is active.
The other thing to understand about the UI library is that all calls must be made each update between fui.begin() and fui.end(), like so:
fui.begin(
gs.ui.instance,
gs.input.cursor,
gs.input.move,
gs.input.scroll,
gs.input.clicking,
gs.input.holding,
gs.input.up_pressed,
gs.input.down_pressed,
gs.input.left_pressed,
gs.input.right_pressed,
gs.input.selecting,
)
defer gs.ui.draw_cmds = fui.end() // Collect draw commands at end of scope
// QoL procedure to set a style for all following `fui.text` calls.
fui.set_text_config({color = Vec4{255, 255, 255, 255}, size = 18, font = 0})
The begin procedure requires a bunch of input state. As such, we must update the input_update procedure and Input_State:
Input_State :: struct {
// ... existing fields ...
up_pressed, down_pressed, left_pressed, right_pressed: bool,
cursor: Vec2,
move: Vec2,
scroll: f32,
clicking: bool,
holding: bool,
// Gamepad or Keyboard
selecting: bool,
canceling: bool,
}
In input_update:
// ...
if rl.IsMouseButtonDown(.LEFT) do is.holding = true
if rl.IsMouseButtonPressed(.LEFT) do is.clicking = true
if rl.IsKeyPressed(.ENTER) do is.selecting = true
if rl.IsKeyPressed(.B) do is.canceling = true
is.cursor = rl.GetMousePosition()
is.move = rl.GetMouseDelta()
is.scroll = rl.GetMouseWheelMove()
is.up_pressed = rl.IsKeyPressed(.UP)
is.down_pressed = rl.IsKeyPressed(.DOWN)
is.left_pressed = rl.IsKeyPressed(.LEFT)
is.right_pressed = rl.IsKeyPressed(.RIGHT)
Finally, we must call the ui_init, ui_update, and ui_render procedures.
In game_post_window_create, right after events_init is called:
ui_init(&gs.ui, gs.window_width, gs.window_height)
In game_update, right after events_update is called:
ui_update(gs)
In game_render, right after rl.EndMode3D():
ui_render(&gs.ui)
The rendering code for this library is user-supplied, so I encourage you to take a look at ui.odin ui_render and see how it works. It's a command buffer structure with a switch statement - quite straightforward.
Putting It All Together
Much of the Battle UI's behavior is contained within ui.odin, so let me explain how it works.
We used a stack-based battle menu system so that arbitrary menus can be created and navigated with ease.
The menu has a cursor position at each depth, with a maximum of 8-deep menus (highly improbable! Most games have 2-3 deep battle command menus).
To navigate the menu, the player can press up/down, hit enter to confirm and B to go back/cancel.
The entire battle_update procedure is below:
battle_update :: proc(bs: ^Battle_State) {
if rl.IsKeyPressed(.SPACE) {
if bs.turn_state == .VICTORY do battle_exit(bs)
}
if rl.IsKeyPressed(.B) {
if bs.menu_state.depth > 0 {
bs.menu_state.cursor[bs.menu_state.depth] = nil
bs.menu_state.depth -= 1
}
}
// Enemy always uses Attack for now
if bs.turn_state != .PLAYER_TURN {
// TODO: Turn Order
actor := bs.enemy_entity_ids[0].?
target := bs.player_entity_ids[0].?
action := Battle_Action {
type = .ATTACK,
actor = actor,
target = target,
}
battle_apply_action(bs, action)
}
any_player_unit_alive := false
for h in bs.player_entity_ids {
if h == nil do continue
e := entity_get(bs.battle_scene, h.?)
if e.hp.curr > 0 {
any_player_unit_alive = true
}
}
if !any_player_unit_alive {
battle_finish(bs, false)
}
any_enemy_unit_alive := false
for h in bs.enemy_entity_ids {
if h == nil do continue
e := entity_get(bs.battle_scene, h.?)
if e.hp.curr > 0 {
any_enemy_unit_alive = true
}
}
if !any_enemy_unit_alive {
battle_finish(bs, true)
}
}
The procedures used are below:
battle_finish :: proc(bs: ^Battle_State, victory := true) {
if victory {
bs.turn_state = .VICTORY
}
// log.info("Battle Finished. Victory?", victory)
// battle_exit(bs)
// Game over?
}
battle_exit :: proc(bs: ^Battle_State) {
emit({type = .Battle_Exit})
}
battle_apply_action :: proc(bs: ^Battle_State, a: Battle_Action) {
switch a.type {
case .NONE:
case .ITEM:
case .ATTACK:
attacker := entity_get(bs.battle_scene, a.actor)
target := entity_get(bs.battle_scene, a.target)
if attacker == nil || target == nil do return
dmg := attacker.atk.curr
target.hp.curr = max(0, target.hp.curr - dmg)
}
if bs.turn_state == .PLAYER_TURN {
bs.turn_state = .ENEMY_TURN
} else {
bs.turn_state = .PLAYER_TURN
}
}
What makes this all work with the UI is this part of the UI code:
// ...
if pick, pick_ok := ui_battle_menu(
&gs.battle.menu_state,
gs.battle.menu_start,
0,
); pick_ok {
cmd := gs.battle.menu_state.cursor[0]
if cmd == nil do return
// Now we know we chose a Command and not a Submenu Item
// ... get the action ...
battle_apply_action(&gs.battle, action)
// ...
}
When a Menu Item is selected, if the Menu Item has no pointer to a Sub Menu, then it must be an action.
The type (which is already above), reproduced:
Battle_Menu_Item :: struct {
text: string, // What's shown on the UI
data: int, // May refer to an item ID or spell ID or something
action_type: Battle_Action_Type, // Only used if submenu is nil
target: Maybe(Entity_Handle), // May hold the target entity handle
next: ^Battle_Menu_Item, // Next item in the list
submenu: ^Battle_Menu_Item, // If nil, is action
}
In the ui.odin ui_update procedure, we simply:
// ... after fui begin et al ...
if !gs.battle.is_active do return
if gs.battle.turn_state == .VICTORY {
ui_battle_victory()
} else {
ui_battle_active()
}
Optional Changes
In game_render, change the rl.DrawSphereWires call to this:
color := rl.WHITE
if e.layer == .NPC {
color = rl.YELLOW
} else if e.layer == .Player {
color = rl.GREEN
}
rl.DrawSphereWires(e.position, e.collider_radius, 4, 8, color)
Now the enemies will be yellow and the party members will be green.
Conclusion
In this lesson we have added a functional user interface and command based battle system.
If you have any questions, please ask them in the Discord.