-- ============================================================================ -- M-Minigames :: client -- -- The entire public API lives here. Design rules, in priority order: -- 1. The player is NEVER left with NUI focus. Every exit path releases it. -- 2. One game at a time. A second call returns false instead of stacking. -- 3. Blocking by default, because `if Start(...) then` is the whole point. -- -- Exports: -- Start(name, difficultyOrOpts [, callback]) -- StartAny({ 'a', 'b' }, difficultyOrOpts) -- StartForTool('drill', difficultyOrOpts) -- StartVerified(name, opts) -- IsActive() / Cancel() / GetGames() -- ============================================================================ local active = nil -- the single in-flight run, nil when idle local runId = 0 -- ------------------------------------------------------------------ -- -- PARAMETER RESOLUTION -- ------------------------------------------------------------------ -- ---'easy' | 'medium' | 'hard' | number | nil -> 0.0-1.0 local function toScalar(difficulty) if type(difficulty) == 'number' then return math.max(0.0, math.min(1.0, difficulty)) end if type(difficulty) == 'string' and Config.Difficulty[difficulty] then return Config.Difficulty[difficulty] end return Config.Difficulty[Config.DefaultDifficulty] or 0.55 end ---A plain number passes through; an { easy, hard } pair is interpolated. local function resolveValue(value, scalar) if type(value) == 'number' then return value end if type(value) == 'table' then local from = value[1] or value.easy local to = value[2] or value.hard if type(from) == 'number' and type(to) == 'number' then return from + (to - from) * scalar end end return value end local function resolveParams(name, scalar, opts) local tuning = Config.Games[name] local params = {} if tuning and tuning.params then for key, value in pairs(tuning.params) do params[key] = resolveValue(value, scalar) end end -- Explicit options always beat the tuning table. if opts.duration then params.timeLimit = opts.duration end if opts.rounds then params.rounds = opts.rounds end params.rounds = math.max(1, math.floor(params.rounds or 1)) return params end ---Second argument may be a difficulty or a full options table. local function toOptions(arg) if type(arg) == 'table' then return arg end if arg == nil then return {} end return { difficulty = arg } end -- ------------------------------------------------------------------ -- -- LIFECYCLE -- resolve() is the ONLY place a run ends, which is what guarantees that -- focus is released on every path: finish, cancel, timeout, death, -- NUI error and resource stop all funnel through it. -- ------------------------------------------------------------------ -- local function resolve(id, result) if not active or active.id ~= id then return end local current = active active = nil SetNuiFocus(false, false) SendNUIMessage({ action = 'stop' }) result = result or { success = false } result.time = result.time or (GetGameTimer() - current.startedAt) result.game = current.name result.token = current.token if Config.Debug then print(('[M-Minigames] %s -> success=%s accuracy=%s perfect=%s time=%sms') :format(current.name, tostring(result.success), tostring(result.accuracy or '-'), tostring(result.perfect or false), result.time)) end TriggerServerEvent('M-Minigames:report', { token = current.token, game = current.name, success = result.success == true, perfect = result.perfect == true, time = result.time, expected = current.expected }) current.promise:resolve(result) end ---Starts a game and blocks until it resolves. local function run(name, opts) -- Stacking two NUIs would trap the player, so the second caller loses. if active then return { success = false, reason = 'busy' } end if not Registry.Exists(name) then print(('[M-Minigames] ERROR: unknown minigame "%s".'):format(tostring(name))) return { success = false, reason = 'unknown' } end local scalar = toScalar(opts.difficulty) local params = resolveParams(name, scalar, opts) local expected = (params.timeLimit or 10000) * params.rounds local timeout = math.min(Config.Safety.hardTimeout, expected + Config.Safety.timeoutGrace) runId = runId + 1 local id = runId active = { id = id, name = name, promise = promise.new(), startedAt = GetGameTimer(), expected = expected, token = opts.verified and lib.callback.await('M-Minigames:issueToken', false, name) or nil } SetNuiFocus(true, true) SendNUIMessage({ action = 'start', game = name, opts = { id = id, difficulty = scalar, params = params, rounds = params.rounds, label = opts.label or L('game_' .. name), hint = opts.hint or L('hint_' .. name), icon = opts.icon or false, accent = opts.accent or Config.Theme.accent, canCancel = opts.canCancel ~= false, locale = { cancel = L('ui_cancel'), round = L('ui_round'), success = L('ui_success'), failed = L('ui_failed') } } }) -- The safety net: focus comes back even if the NUI dies or never reports. SetTimeout(timeout, function() resolve(id, { success = false, reason = 'timeout' }) end) return Citizen.Await(active.promise) end -- ------------------------------------------------------------------ -- -- NUI CALLBACKS -- ------------------------------------------------------------------ -- RegisterNUICallback('finish', function(data, cb) resolve(data.id, { success = data.success == true, accuracy = data.accuracy, perfect = data.perfect == true, time = data.time }) cb('ok') end) RegisterNUICallback('cancel', function(data, cb) resolve(data.id, { success = false, reason = 'cancelled' }) cb('ok') end) -- A JS exception reports here rather than stranding the player. RegisterNUICallback('error', function(data, cb) print(('[M-Minigames] NUI ERROR in "%s": %s'):format(tostring(data.game), tostring(data.message))) resolve(data.id, { success = false, reason = 'error' }) cb('ok') end) -- ------------------------------------------------------------------ -- -- EXPORTS -- ------------------------------------------------------------------ -- ---Start(name, difficultyOrOpts [, callback]) ---Blocking unless a callback is given. Returns a boolean, or the full ---result table when opts.detailed is set. local function Start(name, arg, cb) local opts = toOptions(arg) if cb then CreateThread(function() local result = run(name, opts) cb(opts.detailed and result or result.success) end) return end local result = run(name, opts) return opts.detailed and result or result.success end exports('Start', Start) exports('StartAny', function(names, arg, cb) if type(names) == 'string' then names = { names } end if type(names) ~= 'table' or #names == 0 then local opts = toOptions(arg) return opts.detailed and { success = false, reason = 'unknown' } or false end return Start(names[math.random(#names)], arg, cb) end) exports('StartForTool', function(tool, arg, cb) local pool = Config.ToolGames[tool] or Config.ToolGames['hands'] return Start(pool[math.random(#pool)], arg, cb) end) ---Returns the result table plus a one-time server token. The consuming ---server passes that token to VerifyToken() to confirm this player really ---started this game recently. Raises the bar; not absolute proof. exports('StartVerified', function(name, arg) local opts = toOptions(arg) opts.verified = true opts.detailed = true return run(name, opts) end) exports('IsActive', function() return active ~= nil end) exports('Cancel', function() if not active then return false end resolve(active.id, { success = false, reason = 'cancelled' }) return true end) exports('GetGames', function() return Registry.List() end) -- Event form for scripts that cannot use exports. RegisterNetEvent('M-Minigames:start', function(name, arg, callerResource) local result = run(name, toOptions(arg)) TriggerEvent('M-Minigames:result', callerResource, name, result) end) -- lib.callback wrapper: lib.callback.await('M-Minigames:play', playerId, name, difficulty) lib.callback.register('M-Minigames:play', function(name, arg) return run(name, toOptions(arg)) end) -- ------------------------------------------------------------------ -- -- SAFETY NETS -- ------------------------------------------------------------------ -- if Config.Safety.failOnDeath then CreateThread(function() while true do if active and IsEntityDead(PlayerPedId()) then resolve(active.id, { success = false, reason = 'died' }) end -- Polls quickly only while a game is up. Wait(active and 250 or 1000) end end) end AddEventHandler('onResourceStop', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end if active then resolve(active.id, { success = false, reason = 'stopped' }) end SetNuiFocus(false, false) end) -- ------------------------------------------------------------------ -- -- DEVELOPER COMMANDS -- ------------------------------------------------------------------ -- RegisterCommand('minigame', function(_, args) local name = args[1] if not name then print('[M-Minigames] Usage: /minigame [easy|medium|hard|0.0-1.0]') for _, game in ipairs(Registry.List()) do print(' ' .. game.name) end return end CreateThread(function() local result = run(name, { difficulty = tonumber(args[2]) or args[2] or Config.DefaultDifficulty, detailed = true }) lib.notify({ title = 'M-Minigames', description = ('%s: %s (%.0f%%, %sms)'):format( name, result.success and L('ui_success') or L('ui_failed'), (result.accuracy or 0) * 100, result.time or 0), type = result.success and 'success' or 'error' }) end) end, false) RegisterCommand('minigames', function() SetNuiFocus(true, true) SendNUIMessage({ action = 'gallery', games = Registry.List(), accent = Config.Theme.accent, locale = { title = L('ui_gallery_title'), play = L('ui_play'), close = L('ui_cancel') } }) end, false) RegisterNUICallback('galleryClose', function(_, cb) SetNuiFocus(false, false) cb('ok') end) RegisterNUICallback('galleryPlay', function(data, cb) cb('ok') CreateThread(function() SetNuiFocus(false, false) Wait(120) run(data.name, { difficulty = data.difficulty or Config.DefaultDifficulty, detailed = true }) end) end) -- ------------------------------------------------------------------ -- -- THEME INHERITANCE -- ------------------------------------------------------------------ -- AddEventHandler('onClientResourceStart', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end local host = Config.Theme.inheritFrom if host and GetResourceState(host) == 'started' then local ok, accent = pcall(function() return exports[host]:GetAccent() end) if ok and type(accent) == 'string' then Config.Theme.accent = accent end end end)