tutorial_steps.luau
Luau, 154 lines from Game design overview
--!nonstrict
-- Generic server-side tutorial step machine.
-- Rules it enforces: actions done early count, goals are absolute, steps can
-- go backwards, rewards pay once, rejoin resumes, skip is allowed.
-- Wire `Tutorial.record(player, action)` into your game services, and call
-- `Tutorial.tick(player)` about once per second. `Hooks` is yours to fill.
local Tutorial = {}
export type State = {
step: number, -- index into STEPS, or #STEPS + 1 when done
done: boolean,
did: { [string]: boolean }, -- every action ever seen, whatever the step
paid: { [string]: boolean }, -- reward already paid for this step id
shownAt: number?,
}
-- Game-specific hooks (replace with your own services)
local Hooks = {
getState = function(_player: Player): State? return nil end,
count = function(_player: Player, _what: string): number return 0 end,
battleRunning = function(_player: Player): boolean return false end,
cash = function(_player: Player): number return 0 end,
grant = function(_player: Player, _reward: { [string]: number }) end,
send = function(_player: Player, _view: { [string]: any }) end,
analytics = function(_player: Player, _stepId: string, _seconds: number) end,
}
Tutorial.Hooks = Hooks
-- Each step: id, card text (8 words max), done(), optional back(), reward.
local STEPS = {
{ id = "collect", text = "Walk over your farm to collect",
done = function(p, st) return st.did.collect end,
reward = { cash = 50 } },
{ id = "sell", text = "Sell your wheat for cash",
done = function(p, st) return st.did.sell end,
reward = { cash = 100 } },
{ id = "train", text = "Train 3 soldiers", goal = 8,
-- absolute goal, so training early counts
done = function(p, st) return Hooks.count(p, "army") >= 8 end,
counter = function(p) return ("Soldiers %d / 8"):format(Hooks.count(p, "army")) end,
reward = { cash = 150 } },
{ id = "attack", text = "Attack your neighbour!",
done = function(p, st) return st.did.tutorialWarStarted end,
back = function(p, st) -- army too small? go back to train
if Hooks.count(p, "army") < 8 then return "train" end
return nil
end },
{ id = "win", text = "Watch your army win!",
done = function(p, st) return st.did.tutorialWarWon end,
back = function(p, st) -- no battle and not won: lost or left
if not Hooks.battleRunning(p) and not st.did.tutorialWarWon then
st.did.tutorialWarStarted = false
return (Hooks.count(p, "army") < 8) and "train" or "attack"
end
return nil
end,
reward = { cash = 200, gems = 2 } },
{ id = "upgrade", text = "Upgrade your farm",
done = function(p, st) return st.did.upgrade end,
onShow = function(p, st) -- guarantee affordability
local need = 100 - Hooks.cash(p)
if need > 0 then Hooks.grant(p, { cash = need }) end
end,
reward = { cash = 100, gems = 3 } },
}
local INDEX: { [string]: number } = {}
for i, s in ipairs(STEPS) do INDEX[s.id] = i end
local function view(p: Player, st: State)
if st.done then
Hooks.send(p, { done = true })
return
end
local s = STEPS[st.step]
Hooks.send(p, {
step = st.step, total = #STEPS, id = s.id, text = s.text,
counter = s.counter and s.counter(p) or nil,
})
end
local function show(p: Player, st: State, i: number)
st.step = i
st.shownAt = os.clock()
local s = STEPS[i]
if s and s.onShow then s.onShow(p, st) end
view(p, st)
end
local function finishStep(p: Player, st: State)
local s = STEPS[st.step]
if s.reward and not st.paid[s.id] then
st.paid[s.id] = true
Hooks.grant(p, s.reward)
end
Hooks.analytics(p, s.id, os.clock() - (st.shownAt or os.clock()))
if st.step >= #STEPS then
st.done = true
view(p, st)
else
show(p, st, st.step + 1)
end
end
-- Call from game code for every relevant action, at any time.
function Tutorial.record(p: Player, action: string)
local st = Hooks.getState(p)
if st then st.did[action] = true end
end
-- Call about once per second per player.
function Tutorial.tick(p: Player)
local st = Hooks.getState(p)
if not st or st.done then return end
local s = STEPS[st.step]
if s.back then
local to = s.back(p, st)
if to then
show(p, st, INDEX[to])
return
end
end
if s.done(p, st) then
-- pre-satisfied steps still show for ~1 s so the player sees the tick
if os.clock() - (st.shownAt or 0) >= 1 then finishStep(p, st) end
elseif s.counter then
view(p, st)
end
end
-- Call on join with the loaded save: resume, re-checked by the next tick.
function Tutorial.playerLoaded(p: Player)
local st = Hooks.getState(p)
if not st then return end
if st.done then return end
st.step = math.clamp(st.step, 1, #STEPS)
show(p, st, st.step)
end
function Tutorial.skip(p: Player)
local st = Hooks.getState(p)
if not st or st.done then return end
st.done = true
view(p, st)
end
-- True while tutorial-only rules apply to THIS target (scope locks to it).
function Tutorial.isTutorialTarget(st: State, targetId: string, tutorialTargetId: string): boolean
return not st.done and targetId == tutorialTargetId
end
return Tutorial