32. Turn Order
In lesson 31, we produced a battle system that allows for winning and losing.
In this lesson, we will add variable turn order based on the entity's speed stat. We will fix some bugs and make some API changes to increase our programming QoL (Quality of Life).
Variable Turn Order
To facilitate variable turn order, we require a number which may vary across entities - we'll add spd to the Entity_Stats type.
Entity_Stats :: struct {
// ... other fields ...
spd: Stat,
}
The way this system works is based on Final Fantasy X's Conditional Turn-Based system, with some changes. Turn order depends on the magnitude of the speed stat, and moves cost variable amounts.
Imagine an analogue clock with 8 second-hands - one for each battle entity. The speed stat is the seconds per second (per tick) that an entity's hand moves. As the hand reaches 60, an action may be performed, and the hand rewinds by a cycle, keeping the remainder. If the hand reaches 62, it will be set to 2, etc.
We simulate this clock until 16 total turns are predicted, which are shown in the Turn Order UI.
To store these turns, we'll add a field to Battle_State:
Battle_State :: struct {
// ... other fields ...
turn_order: [dynamic]Entity_Handle,
// Also add/change these (remnants existed from lesson 31 - unimplemented)
menu_stack: mem.Stack,
menu_allocator: mem.Allocator,
}
The Stack Allocator may be any allocator type: Arena, Linear, etc.
We must amend the Entity type to store their counter (the second-hand).
Entity :: struct {
// ... other fields ...
turn_counter: int,
}
Here is the algorithm to compute the turn order:
- Clear the turn order.
- Collect the entity handles, favouring the player.
- Clone the turn counters to prevent the simulation changing entity counters.
- Set up a safeguard to prevent infinite loops:
MAX_TICKS. - Iterate until 16 turns have been predicted or
MAX_TICKSreached.
battle_compute_turn_order :: proc(bs: ^Battle_State) {
clear(&bs.turn_order)
// Construct entity handle list
// Player gets priority
entity_handles := make([dynamic]Entity_Handle, context.temp_allocator)
for player_entity_id in bs.player_entity_ids {
if player_entity_id == nil do continue
append(&entity_handles, player_entity_id.?)
}
for enemy_entity_id in bs.enemy_entity_ids {
if enemy_entity_id == nil do continue
append(&entity_handles, enemy_entity_id.?)
}
turn_counters := make([]int, len(entity_handles))
for entity_handle, i in entity_handles {
entity := entity_get(bs.battle_scene, entity_handle)
turn_counters[i] = entity.turn_counter
}
tick := 0
MAX_TICKS :: 100000
for len(bs.turn_order) < 16 && tick < MAX_TICKS {
for entity_handle, i in entity_handles {
entity := entity_get(bs.battle_scene, entity_handle)
if entity.hp.curr <= 0 do continue
turn_counters[i] += entity.spd.curr
if turn_counters[i] >= TURN_THRESHOLD {
append(&bs.turn_order, entity_handle)
turn_counters[i] -= TURN_THRESHOLD
}
}
tick += 1
}
}
TURN_THRESHOLDis defined at the top ofbattle.odinas10000.
Computation of the turn order is instigated in two locations:
- At the end of
battle_start. - At the end of
battle_apply_action.
battle_apply_action has various changes:
- Added error logging in case of having no actor. This is not a crash.
- Turn list is removed from, and an error is logged if the acting entity is not the first in the turn order.
SKIPis an action which skips the turn at the cost of 70% of a turn.ITEMdoes nothing yet, but costs 80% of a turn.ATTACKcosts 100% of a turn.- Since entities may die, menus must be reconstructed (entities with 0 hp cannot be targets).
- Turn order is recomputed for this reason.
- Determine whether the player or enemy has a turn now.
battle_apply_action :: proc(bs: ^Battle_State, a: Battle_Action) {
actor := entity_get(bs.battle_scene, a.actor)
if actor == nil {
log.error("Action applied with no actor. Is this intended?")
return
}
// Remove turn from list
if len(bs.turn_order) > 0 {
entity_handle := bs.turn_order[0]
if entity_handle == a.actor {
pop_front(&bs.turn_order)
} else {
first_entity := entity_get(bs.battle_scene, entity_handle)
actor_entity := entity_get(bs.battle_scene, a.actor)
log.error(
"Action performed by entity that isn't first in the turn orrder. Is this intended?",
"First entity in turn_order:",
first_entity.display_name,
"Actor:",
actor_entity.display_name,
)
}
}
switch a.type {
case .NONE:
case .ITEM:
// Item costs 90% of an attack
actor.turn_counter -= TURN_THRESHOLD * 0.9
case .SKIP:
// Skip costs 70% of an attack
actor.turn_counter -= TURN_THRESHOLD * 0.7
case .ATTACK:
actor.turn_counter -= TURN_THRESHOLD
target := entity_get(bs.battle_scene, a.target)
if actor == nil || target == nil do return
dmg := actor.atk.curr
target.hp.curr = max(0, target.hp.curr - dmg)
log.infof("%s Attacked %s for %d.", actor.display_name, target.display_name, dmg)
}
battle_construct_menus(bs)
battle_compute_turn_order(bs)
if len(bs.turn_order) > 0 {
next_turn_entity_handle := bs.turn_order[0]
next_turn_entity := entity_get(bs.battle_scene, next_turn_entity_handle)
if .Party in next_turn_entity.flags {
bs.turn_state = .PLAYER_TURN
} else {
bs.turn_state = .ENEMY_TURN
}
}
}
Partymust be added to theEntity_Flagenum.
Speaking of which, menus are now constructed from various places and require a procedure:
battle_construct_menus :: proc(bs: ^Battle_State) {
mem.stack_free_all(&bs.menu_stack)
allocator := bs.menu_allocator
atk := new(Battle_Menu_Item, allocator)
atk.text = "Attack"
atk.action_type = .ATTACK
itm := new(Battle_Menu_Item, allocator)
itm.text = "Item"
itm.action_type = .ITEM
atk.next = itm
skp := new(Battle_Menu_Item, allocator)
skp.text = "Skip"
skp.action_type = .SKIP
itm.next = skp
// Enemies
prev: ^Battle_Menu_Item
#reverse for id in bs.enemy_entity_ids {
if id == nil do continue
e := entity_get(bs.battle_scene, id.?)
if e.hp.curr == 0 do continue
entry := new(Battle_Menu_Item, allocator)
entry.text = e.name
entry.next = prev
entry.target = id
prev = entry
}
atk.submenu = prev
// Mock items
item_names := []string{"Potion", "Cure Tablet", "Popsicle"}
prev = nil
#reverse for name, i in item_names {
entry := new(Battle_Menu_Item, allocator)
entry.text = name
entry.next = prev
entry.data = i // item id
prev = entry
}
itm.submenu = prev
bs.menu_start = atk
}
battle_construct_menus is called at the end of battle_init and the end of battle_apply_action.
battle_update must be updated to use the turn order:
First, add return statements after battle_finish is called.
battle_finish(bs, false)
return
After if !any_enemy_unit_alive { ..., add the following code:
This code replaces the enemy "AI" which attacked player entity 0 on each turn.
if bs.turn_state != .PLAYER_TURN && len(bs.turn_order) > 0 {
next_turn_entity_handle := bs.turn_order[0]
next_turn_entity := entity_get(bs.battle_scene, next_turn_entity_handle)
targets := make([dynamic]Entity_Handle, context.temp_allocator)
for maybe_entity_handle in bs.player_entity_ids {
if maybe_entity_handle == nil do continue
entity_handle := maybe_entity_handle.?
// If dead, can't be target
entity := entity_get(bs.battle_scene, entity_handle)
if entity.hp.curr <= 0 do continue
append(&targets, entity_handle)
}
index := rand.int_max(len(targets))
target_handle := targets[index]
action := Battle_Action {
type = .ATTACK,
actor = next_turn_entity.handle,
target = target_handle,
}
battle_apply_action(bs, action)
}
Defeat
DEFEAT has been added in order to facilitate testing.
In battle_finish:
if victory {
bs.turn_state = .VICTORY
} else {
bs.turn_state = .DEFEAT
}
In battle_update:
// When SPACE is pressed
if bs.turn_state == .VICTORY || bs.turn_state == .DEFEAT {
battle_exit(bs)
}
UI
This lesson's archive contains an update version of FUI, required to add scrolling functionality.
Please make sure to overwrite fui.odin with this version.
The user must keep track of scroll state, so we add these fields to Input_State:
Input_State :: struct {
// ... other fields ...
scrollbar_dragging: map[u64]bool,
scrollbar_grab_y: map[u64]f32,
is_dragging_scrollbar: bool,
scroll_history: [dynamic]f32,
}
The scroll container code requires some explanation.
In order to reference an element across frames, a key: u64 may be provided to the config.
This key is used in the scrollbar state maps as the key.
Using the key ensures the element may be referenced even when the order/count of elements changes.
The ui_scrollable_col procedure will not fit on this page, please see the attached files. However, here are the key details:
Signature: proc(config: ^fui.Config, id: string, body: proc()) -> fui.Status
- A key is computed using the
idparameter andhash.fnv64. - A container element is created to hold the column - the column will be scrolled.
- Inside the column is the
body()call. - A calculation takes place to determine the scroll offset and scrollbar size.
- The
fui.Statusis used to handle events such as dragging, pressing, hovered, or scrolling while hovered. fui.set_scrollis used to update the scroll state for the column.
Since DEFEAT has been added, we must account for that in ui_update, and add a line to facilitate scrolling:
// ...
defer gs.ui.draw_cmds = fui.end()
fui.set_scroll_speed(24) // New line
// ...
if gs.battle.turn_state == .VICTORY {
ui_battle_victory()
} else if gs.battle.turn_state == .DEFEAT { // New clause
ui_battle_defeat()
} else {
ui_battle_active()
}
Replace the "TURN ORDER" UI stub column with this code:
fui.col(&{size = {200, 0}}, proc() {
fui.text("Turn Order")
ui_scrollable_col(&{color = BLUE}, "turn_order", proc() {
gs := cast(^Game_State)context.user_ptr
for entity_handle in gs.battle.turn_order {
entity := entity_get(gs.battle.battle_scene, entity_handle)
fui.row_box(&{padding = 4, flags = {.FIT_Y}}); {
fui.text(
fmt.tprintf(
"%s (%d)",
entity.display_name,
entity.turn_counter, // Temporary debug display
),
)
}; fui.pop()
}
})
})
In ui_battle_active, the actor was being set to the player entity 0 every time. Now, set it to the first entity in gs.battle.turn_order:
action.actor = gs.battle.turn_order[0]
And, handle the SKIP action:
case .SKIP:
action.type = .SKIP
battle_apply_action(&gs.battle, action)
QoL
Passing gs.entity_definitions to various sub-systems adds annoying friction for no benefit.
We will refactor the entity_def_by_id procedure to the following:
entity_def_by_id :: proc(id: int) -> ^Entity_Definition {
gs := cast(^Game_State)context.user_ptr
if len(gs.entity_definitions) <= id do return nil
return &gs.entity_definitions[id]
}
You may consider returning a struct to ensure the caller cannot write data into the pointer.
Escape is a key that I press when exiting menus in games. I could not help myself from exiting the game during battles by hitting Escape.
I have changed the exit combination to ctrl-q, and here are the changes:
Game_API :: struct {
// ... other fields ...
should_close: proc(game_state_memory: rawptr) -> bool,
}
main :: proc() {
// ...
rl.InitWindow(/*...*/)
rl.SetExitKey(.KEY_NULL) // Disable Exit Key
// ...
// Change loop condition to:
for !game_api.should_close(game_state_memory) {
// ...
}
}
// game.odin:
Game_State :: struct {
// ... other fields ...
should_close: bool,
}
@(export)
should_close :: proc(gs: ^Game_State) -> bool {
return gs.should_close
}
game_post_window_create :: proc(gs: ^Game_State) {
// ...
// New Event
events_subscribe(&gs.events, .Quit, proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
gs.should_close = true
})
}
// input.odin:
is_ctrl := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)
if is_ctrl && rl.IsKeyDown(.Q) {
emit(Event{type = .Quit})
}
// I added SPACE and ESCAPE as keys to navigate battle menus:
is.selecting = rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.SPACE)
is.canceling = rl.IsKeyPressed(.B) || rl.IsKeyPressed(.ESCAPE)
Bugfix
1 entity could spawn on battle start, with this fix we can have 4:
// In player_update
// remove: payload.enemy_entity_def_ids[0] = entity.def_id
def := entity_def_by_id(entity.def_id)
payload.enemy_entity_def_ids = def.battle_entities
Update the code in battle_start to show entities spaced out, add .Party flag to player entities, and add A, B, C, or D to display_name for enemies:
// Enemies
// ...
e.position = {-8 + f32(i) * 0.7, 0, 2 - f32(i) * 1.5}
e.display_name = fmt.aprintf(
"%s (%r)",
e.display_name,
rune(i + 'A'),
allocator = allocator,
)
// Party Members
// ...
e.position = {8 + f32(i) * 0.7, 0, 2 + f32(i) * 1.5}
e.flags += {.Party}