Another state machine demo

Showcase your libraries, tools and other projects that help your fellow love users.
Post Reply
RNavega
Party member
Posts: 456
Joined: Sun Aug 16, 2020 1:28 pm

Another state machine demo

Post by RNavega »

A demo of using finite state machines to do things like sequential cinematic steps.
If you think you can improve this / make it more organized, I'd love to see your suggestions.

The main idea is from the discussion in this thread here:
viewtopic.php?p=261838#p261838

main.lua (no assets needed)

Code: Select all

-- Experiment on sequential cinematic steps using finite state
-- machines, like for a "turn-based game".
-- RN 2025
-- License: Public Domain
--
-- From this thread:
-- https://love2d.org/forums/viewtopic.php?t=96303

io.stdout:setvbuf('no')
math.randomseed(222)

-- From LuaJIT:
-- https://luajit.org/extensions.html#table_clear
require('table.clear')

local unit_a = {x = 100, y = 200, color = {0.9, 0.27, 0.1}}
local unit_b = {x = 500, y = 500, color = {0.13, 0.71, 1.0}}
local unit_c = {x = 350, y = 200, color = {0.98, 0.6, 0}}

local all_missiles = {total = 0}

local steps_list     = {index = 0}
local cinematic_animations = {total = 0, active = 0}

local small_font = love.graphics.getFont()
local big_font   = love.graphics.newFont(30)
local GRAPHICS_WIDTH, GRAPHICS_HEIGHT = love.graphics.getDimensions()

local game_fsm,
      gameplay_fsm


local function animation_move_unit(dt, animation)
    local data = animation.data
    data.time = data.time + dt
    if data.time >= data.duration then
        data.time = data.duration
        animation.is_active = false
        -- Don't return right now, let it update one last time.
    end
    local t = data.time / data.duration
    data.unit.x = data.old_x + t * data.delta_x
    data.unit.y = data.old_y + t * data.delta_y
end


local function add_new_animation(update_func, animation_data)
    local new_animation = {func = update_func,
                           data = animation_data,
                           is_active = true}
    table.insert(cinematic_animations, new_animation)
    cinematic_animations.total  = cinematic_animations.total + 1
    cinematic_animations.active = cinematic_animations.active + 1
end


local function move_unit_randomly(data)
    local new_x = math.random() * GRAPHICS_WIDTH
    local new_y = math.random() * GRAPHICS_HEIGHT
    local animation_data = {unit = data,
                            time = 0.0,
                            duration = math.random() + 0.5,
                            old_x = data.x,
                            old_y = data.y,
                            delta_x = new_x - data.x,
                            delta_y = new_y - data.y}
    add_new_animation(animation_move_unit, animation_data)
end


local function fire_missiles_from_unit(data)
    local DISTANCE = 30
    local source_x = data.source.x + (math.random() - 0.5) * DISTANCE
    local source_y = data.source.y + (math.random() - 0.5) * DISTANCE
    for index = 1, data.count do
        all_missiles.total = all_missiles.total + 1
        local missile = {x = source_x, y = source_y, index = all_missiles.total}
        table.insert(all_missiles, missile)
        local target_x, target_y
        if data.target then
            target_x = data.target.x + 25 -- To center the "missile".
            target_y = data.target.y + 25
        else
            target_x = math.random() * GRAPHICS_WIDTH
            target_y = math.random() * GRAPHICS_HEIGHT
        end
        local animation_data = {unit=missile,
                                time = 0.0,
                                duration = math.random() + 0.5,
                                old_x = source_x,
                                old_y = source_y,
                                delta_x = target_x - source_x,
                                delta_y = target_y - source_y}
        add_new_animation(animation_move_unit, animation_data)
        missile.animation_data = animation_data
    end
end


local function clear_missiles()
    table.clear(all_missiles)
    all_missiles.total = 0
end


local function clear_cinematic_animations()
    table.clear(cinematic_animations)
    cinematic_animations.total  = 0
    cinematic_animations.active = 0
end


local function clear_steps_list()
    table.clear(steps_list)
    steps_list.index = 0
end


local function add_cinematic_steps()
    -- The cinematic steps list is supposed to be filled based on the player & CPU
    -- actions during the turn, but for simplicity it's pre-filled in here.
    clear_steps_list()
    table.insert(
        steps_list,
        {name = 'Move "A" somewhere', data = unit_a,
         func = function(data)
                    clear_missiles()
                    move_unit_randomly(data)
                end}
    )
    table.insert(
        steps_list,
        {name = '"C" fires 3 missiles', data = {source = unit_c, count = 3},
         func = function(data)
                    clear_missiles()
                    fire_missiles_from_unit(data)
                end}
    )
    table.insert(
        steps_list,
        {name = 'Move "B" somewhere + fire missile at "C"',
         data = {source = unit_b, target = unit_c, count = 1},
         func = function(data)
                    clear_missiles()
                    move_unit_randomly(data.source)
                    fire_missiles_from_unit(data)
                end}
    )
end


-- Proceeds to the next cinematic step, returning 'false' in case
-- the list has finished or 'true' if there was a valid step to advance to.
local function take_cinematic_step()
    steps_list.index = steps_list.index + 1
    if steps_list.index > #steps_list then
        steps_list.index = 0
        return false
    else
        local current_step = steps_list[steps_list.index]
        current_step.func(current_step.data)
        return true
    end
end


-- To be called in love.update().
local function update_all_animations(dt)
    if cinematic_animations.active == 0 then
        return
    end
    for index = 1, cinematic_animations.total do
        local animation = cinematic_animations[index]
        if animation.is_active then
            animation.func(dt, animation)
            if not animation.is_active then
                cinematic_animations.active = cinematic_animations.active - 1
                if cinematic_animations.active == 0 then
                    clear_cinematic_animations()
                    break
                end
            end
        end
    end
end


-- Some helper code to create finite state machines.

local function do_nothing()
end


local function fsm_enter_helper(self, next_state)
    -- For convenience and bug-proofing, allow the 'enter' function of states
    -- to optionally return another state ID, making the FSM redirect to that
    -- state right after that 'enter' function finishes.
    -- This is useful in some cases like when you have a simple state that only
    -- needs to run its 'enter' function and switch afterwards, or when the state
    -- checks some conditions in its 'enter' function and notices that the FSM
    -- should change to some other state.
    -- If nil is returned from 'enter' then the state continues on.
    local next_state_id = next_state:enter()
    if next_state_id then
        next_state = self[next_state_id]
        if next_state then
            fsm_enter_helper(self, next_state)
        else
            error('Unknown state ID from enter redirection: ' .. tostring(next_state_id), 2)
        end
    else
        self.current_state = next_state
    end
end


local function fsm_add_state(self,
                             id,
                             state_enter_func,
                             state_update_func,
                             state_exit_func)
    local state = {
        -- Variables.
        id = id,
        fsm = self,
        -- Functions.
        enter  = state_enter_func or do_nothing,
        update = state_update_func or do_nothing,
        exit   = state_exit_func or do_nothing
    }
    self[id] = state
    -- Return the new state table in case the caller wants to store more
    -- stuff inside, like other variables and functions etc.
    return state
end


local function fsm_change_state(self, next_state_id)
    if self.current_state then
        self.current_state:exit()
    end
    local next_state = self[next_state_id]
    if next_state then
        fsm_enter_helper(self, next_state)
    else
        error('Invalid new state ID', 2)
    end
end


local function fsm_update(self, dt)
    -- Similar redirection strategy as in 'fsm_enter_helper': allow the
    -- 'update' function of states to optionally return the ID of another
    -- state to switch to, or return nil for it to continue.
    -- When an ID is returned, the current state is exited before the
    -- FSM changes to the new state.
    local next_state_id = self.current_state:update(dt)
    if next_state_id then
        local next_state = self[next_state_id]
        if next_state then
            self.current_state:exit()
            fsm_enter_helper(self, next_state)
            -- Should the new state be updated right now?
            --fsm_update(self, dt)
        else
            error('Unknown state ID from update redirection: ' .. tostring(next_state_id), 2)
        end
    end
end


local function create_fsm(ids)
    local fsm = {
        -- Variables.
        ids = ids,
        current_state = nil,
        -- Functions.
        add_state    = fsm_add_state,
        change_state = fsm_change_state,
        update       = fsm_update,
    }
    return fsm
end


-- Major game FSM. This controls the game states, like menu screen,
-- options screen, gameplay etc.
game_fsm = create_fsm({
    -- State names as keys.
    TITLE_SCREEN   = 1,
    OPTIONS_SCREEN = 2,
    LOADING_SCREEN = 3,
    GAMEPLAY       = 4
})


local function game_states_gameplay_enter(self)
    -- Initializing things for the GAMEPLAY state.
    -- (...)
    -- Using a secondary FSM to manage the states of gameplay itself.
    -- Start it in the TURN state.
    -- In an actual game, this could be a "SHOW_LEVEL_INTRO" state.
    gameplay_fsm:change_state(gameplay_fsm.ids.TURN)
end


local function game_states_gameplay_update(self, dt)
    -- Things to update on this frame for the GAMEPLAY game state.
    --
    -- Execute the list of internal game events that were triggered
    -- on the previous frame, like playing sounds, creating new
    -- things, animating things etc.
    update_all_animations(dt)

    -- Update the secondary FSM for managing the gameplay states.
    gameplay_fsm:update(dt)
end


game_fsm:add_state(game_fsm.ids.GAMEPLAY,
                   -- Enter function.
                   game_states_gameplay_enter,
                   -- Update function,
                   game_states_gameplay_update,
                   -- Exit function.
                   nil)


-- A secondary FSM for keeping track of gameplay states.
gameplay_fsm = create_fsm({
    TURN                = 1,
    FINISH_TURN         = 2,
    CINEMATIC_RUN_STEPS = 3,
})


local function gameplay_states_turn_update(self, dt)
    if love.keyboard.isDown('space') then
        return self.fsm.ids.FINISH_TURN
    end
end


local function gameplay_states_finish_turn_enter(self)
    -- Used to initialize the "after turn cinematic".
    -- While this could be done using the 'exit' function of the TURN state, I think
    -- it adds more possibilities to do it in its own state, a state just for
    -- finishing the turn, one that runs its 'enter' function and redirects
    -- to another state immediately.
    add_cinematic_steps()
    -- Redirect to another state.
    return self.fsm.ids.CINEMATIC_RUN_STEPS
end


local function gameplay_states_cinematic_run_steps_enter(self)
    -- Use the 'enter' function of this state to consume the next cinematic
    -- step, if any. Otherwise, go back to the TURN (or to some "FINISH_CINEMATIC"
    -- intermediary state or whatever).
    if not take_cinematic_step() then
        return self.fsm.ids.TURN
    end
    return nil
end


local function gameplay_states_cinematic_run_steps_update(self, dt)
    -- In case all animations (or in an actual game, just the animations that
    -- are "tagged" as part of the cinematic, and not other unrelated
    -- animations) have finished, then switch to this same state again.
    -- This makes this state exit and enter again, consuming another cinematic step.
    if cinematic_animations.active == 0 then
        return self.fsm.ids.CINEMATIC_RUN_STEPS
    end
    return nil
end


gameplay_fsm:add_state(gameplay_fsm.ids.TURN,
                       nil,
                       gameplay_states_turn_update,
                       nil)
gameplay_fsm:add_state(gameplay_fsm.ids.FINISH_TURN,
                       gameplay_states_finish_turn_enter,
                       nil,
                       nil)
gameplay_fsm:add_state(gameplay_fsm.ids.CINEMATIC_RUN_STEPS,
                       gameplay_states_cinematic_run_steps_enter,
                       gameplay_states_cinematic_run_steps_update,
                       nil)


function love.load()
    love.graphics.setFont(big_font)
    -- Starting the game FSM directly with the GAMEPLAY state, but in an
    -- actual game it should be TITLE_SCREEN state or something like
    -- that.
    game_fsm:change_state(game_fsm.ids.GAMEPLAY)
end


function love.update(dt)
    game_fsm:update(dt)
end


function love.draw()
    local current_step_name
    if steps_list.index > 0 and gameplay_fsm.current_state.id ~= gameplay_fsm.ids.TURN then
        current_step_name = steps_list[steps_list.index].name
    else
        current_step_name = 'IDLE (press Space to restart)'
    end
    love.graphics.print(('Step %i / %s'):format(steps_list.index, current_step_name), 10, 10)

    love.graphics.setColor(unpack(unit_a.color))
    love.graphics.rectangle('fill', unit_a.x, unit_a.y, 70, 70)
    love.graphics.setColor(unpack(unit_b.color))
    love.graphics.rectangle('fill', unit_b.x, unit_b.y, 70, 70)
    love.graphics.setColor(unpack(unit_c.color))
    love.graphics.rectangle('fill', unit_c.x, unit_c.y, 70, 70)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('A', unit_a.x + 20, unit_a.y + 15)
    love.graphics.print('B', unit_b.x + 20, unit_b.y + 15)
    love.graphics.print('C', unit_c.x + 20, unit_c.y + 15)

    for missile_index = 1, all_missiles.total do
        local missile = all_missiles[missile_index]
        if missile then
            love.graphics.setColor(1.0, 0.9, 0)
            love.graphics.circle('fill', missile.x, missile.y, 20)
            love.graphics.setColor(1, 1, 1)
            local ad = missile.animation_data
            if ad then
                love.graphics.circle('line', ad.old_x + ad.delta_x, ad.old_y + ad.delta_y, 20)
            end
        end
    end
end


local function system_keypressed(key)
    -- Handle priority key combos.
    if key == 'escape' then
        love.event.quit()
        return true
    end
    return false
end


function love.keypressed(key)
    if not system_keypressed(key) then
        -- Send the input event to the UI manager.
        -- (...)
    end
end
PS this other thread has someone else's demo on the same topic, just for reference:
viewtopic.php?p=258236#p258236
Last edited by RNavega on Tue Feb 11, 2025 12:52 am, edited 1 time in total.
RNavega
Party member
Posts: 456
Joined: Sun Aug 16, 2020 1:28 pm

Re: Another state machine demo

Post by RNavega »

Some notes that I took for myself, I learned a lot:

- States in a F.S.M. shouldn't directly do presentation things like drawing on screen and playing sounds. Other systems should do that.

- To keep things simple and help separate responsibilities, the states can change some shared data (bools, numbers, strings, tables), and can check for the conditions for switching to a different state, but they should have to ask by means of a 'message' or 'event' that other systems of the game engine do something. This forces important things to happen outside the control of states, like the playing of a sound, the creation of an object etc. This indirection helps with debugging, as you'll have systems that centralize things being done (like an events processing system, an assets system, a renderer system, a UI system etc).

- The other systems can consult the shared data that's modified by the states, and use it for their own purpose. So the drawing system might check what sprites are visible and draw them, and the visibility of those sprites might be modified (that is, requested to be modified) by some state in a F.S.M.

- Whenever possible, prefer to add more states to an existing F.S.M. than creating a whole new F.S.M. as the former leads to simpler designs: if you have one F.S.M with 'n' states and you create another F.S.M. with 'm' states, now you have n x m state combinations to worry about.
If instead you added those new 'm' states to the first F.S.M. you'd only have n + m states (less unique situations than with n x m).
Adding a new F.S.M. is sometimes the only way to do some sophisticated thing that you're trying to do, but a lot of the time any new behaviors or complexity that you need can be done via adding new states to some preexisting F.S.M.

- Instead of doing state switching via calling the "change state" function of the F.S.M. it's better to handle switching by returning a state ID from either the 'enter' or 'update' functions of states. This reduces bugs as you'll explicitly have to return from the function that's running before the state is switched. There won't be a chance of you switching states from the inside of a function of another state, and having to worry about conflicting data changes etc as the program changed to some state but is still inside the function of another state.
See function 'fsm_enter_helper' to see how this ID returning was implemented.

- No matter how much you try to reduce and simplify all your F.S.M. and their states, there are some behaviors which are just complex and can't be simplified further. You might be thinking that you're using a lot of states to do something, but in the end it might be the only efficient way of doing that thing because it just happens to be something complex (like a cutscene / cinematic for example), and that's okay.
User avatar
dusoft
Party member
Posts: 765
Joined: Fri Nov 08, 2013 12:07 am
Location: Europe usually
Contact:

Re: Another state machine demo

Post by dusoft »

RNavega wrote: Tue Feb 11, 2025 12:19 am - No matter how much you try to reduce and simplify all your F.S.M. and their states, there are some behaviors which are just complex and can't be simplified further. You might be thinking that you're using a lot of states to do something, but in the end it might be the only efficient way of doing that thing because it just happens to be something complex (like a cutscene / cinematic for example), and that's okay.
This. I have been concerned about number of states in my game, but it's better to have everything organized separately (intro, main menu, mission info, gameplay, success/failure, help, settings, outro etc.) than a large one-piece with many functions.
RNavega
Party member
Posts: 456
Joined: Sun Aug 16, 2020 1:28 pm

Re: Another state machine demo

Post by RNavega »

dusoft wrote: Tue Feb 11, 2025 6:07 am This. I have been concerned about number of states in my game, but it's better to have everything organized separately (intro, main menu, mission info, gameplay, success/failure, help, settings, outro etc.) than a large one-piece with many functions.
That's exactly right. Another way to look at it is: there's a fixed number of cables ("complexity") required for what you're trying to do, the FSMs are just helping you organize them.

temp.jpg
temp.jpg (858.54 KiB) Viewed 2111 times
Post Reply

Who is online

Users browsing this forum: Ahrefs [Bot] and 2 guests