Join the RoClicks Discord
RoClicks

tutorial_steps.luau

Luau, 154 lines from Game design overview

Download tutorial_steps.luau
--!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