PVG
32. Turn Order — Program Video Games

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:

  1. Clear the turn order.
  2. Collect the entity handles, favouring the player.
  3. Clone the turn counters to prevent the simulation changing entity counters.
  4. Set up a safeguard to prevent infinite loops: MAX_TICKS.
  5. Iterate until 16 turns have been predicted or MAX_TICKS reached.
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_THRESHOLD is defined at the top of battle.odin as 10000.

Computation of the turn order is instigated in two locations:

  1. At the end of battle_start.
  2. At the end of battle_apply_action.

battle_apply_action has various changes:

  1. Added error logging in case of having no actor. This is not a crash.
  2. Turn list is removed from, and an error is logged if the acting entity is not the first in the turn order.
  3. SKIP is an action which skips the turn at the cost of 70% of a turn.
  4. ITEM does nothing yet, but costs 80% of a turn.
  5. ATTACK costs 100% of a turn.
  6. Since entities may die, menus must be reconstructed (entities with 0 hp cannot be targets).
  7. Turn order is recomputed for this reason.
  8. 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
        }
    }
}

Party must be added to the Entity_Flag enum.

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

  1. A key is computed using the id parameter and hash.fnv64.
  2. A container element is created to hold the column - the column will be scrolled.
  3. Inside the column is the body() call.
  4. A calculation takes place to determine the scroll offset and scrollbar size.
  5. The fui.Status is used to handle events such as dragging, pressing, hovered, or scrolling while hovered.
  6. fui.set_scroll is 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}