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
viewtopic.php?p=258236#p258236