-- --------------------------------------------------------------------------- -- rp_ui - the pre-spawn experience. -- -- Owns the stage machine (auth -> characters -> creator -> spawn -> live), -- the NUI bridge, and the transition into the world. The server decides -- everything that matters; this file only asks and then animates the answer. -- --------------------------------------------------------------------------- local stage = 'boot' local account = nil local characters = {} local draft = nil -- appearance currently being edited local previewPed = nil local spawnData = nil -- payload from char:select local chosenChar = nil local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac -- --------------------------------------------------------------------------- -- helpers -- --------------------------------------------------------------------------- local function ui(payload) SendNUIMessage(payload) end local function focusUI(on) SetNuiFocus(on, on) SetNuiFocusKeepInput(false) end local function holdPlayer() local ped = PlayerPedId() SetEntityVisible(ped, false, false) SetEntityCollision(ped, false, false) FreezeEntityPosition(ped, true) SetEntityInvincible(ped, true) SetPlayerControl(PlayerId(), false, 0) SetPlayerInvincible(PlayerId(), true) DisplayHud(false) DisplayRadar(false) ClearPedTasksImmediately(ped) end local function releasePlayer() local ped = PlayerPedId() SetEntityVisible(ped, true, false) SetEntityCollision(ped, true, true) FreezeEntityPosition(ped, false) SetEntityInvincible(ped, false) SetPlayerControl(PlayerId(), true, 0) SetPlayerInvincible(PlayerId(), false) DisplayHud(true) DisplayRadar(true) end --- Dusk, so the sodium palette of the interface matches the world behind it. local function setSceneMood(on) if on then NetworkOverrideClockTime(20, 40, 0) SetWeatherTypeNowPersist('EXTRASUNNY') else NetworkClearClockTimeOverride() ClearOverrideWeatherType() SetWeatherTypeNow('EXTRASUNNY') end end --- Keep the world streamed around a point while no player ped is there. local function focusOn(pos) SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0) RequestCollisionAtCoord(pos.x, pos.y, pos.z) end local function destroyPreview() if previewPed and DoesEntityExist(previewPed) then DeleteEntity(previewPed) end previewPed = nil end local function setStage(next, data) stage = next ui({ action = 'stage', stage = next, data = data }) end -- --------------------------------------------------------------------------- -- boot -- --------------------------------------------------------------------------- CreateThread(function() while not NetworkIsSessionStarted() do Wait(50) end holdPlayer() setSceneMood(true) local first = Config.CinematicShots[1] focusOn(first.from) SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false) Cam.Shots(Config.CinematicShots) -- give the world a moment to stream in behind the loading screen SendLoadingScreenMessage(json.encode({ eventName = 'rp:server', name = Config.ServerName, status = 'Connected', })) SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules })) local waited = 0 while waited < 6000 do SendLoadingScreenMessage(json.encode({ eventName = 'rp:progress', fraction = 0.85 + (waited / 6000) * 0.15, step = 'Streaming the city', })) Wait(250) waited = waited + 250 end SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' })) Wait(1500) ShutdownLoadingScreenNui() focusUI(true) setStage('auth', { serverName = Config.ServerName, serverTag = Config.ServerTag, maxChars = Config.MaxChars, }) end) -- --------------------------------------------------------------------------- -- authentication -- --------------------------------------------------------------------------- RegisterNUICallback('auth:submit', function(data, cb) CreateThread(function() local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login' local res, err = Core.Callback('rp_session', mode, { username = data.username, password = data.password, }) if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end account = { username = res.username, role = res.role } characters = res.characters or {} cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars }) end) end) -- --------------------------------------------------------------------------- -- character select -- --------------------------------------------------------------------------- --- Stand the highlighted character up so the list is not just text. RegisterNUICallback('char:preview', function(data, cb) CreateThread(function() destroyPreview() local appearance for _, c in ipairs(characters) do if c.id == data.id then appearance = c.appearance end end if not appearance then return cb({ ok = false }) end appearance = Appearance.Sanitise(appearance) focusOn(Config.CreatorScene.ped) previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w) if previewPed then PedBuild.Idle(previewPed) Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true) Cam.OrbitFrame(2.4, 0.25, 0.35) end cb({ ok = previewPed ~= nil }) end) end) RegisterNUICallback('char:delete', function(data, cb) CreateThread(function() local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id }) if not res then return cb({ ok = false, error = err }) end local list = Core.Callback('rp_session', 'char:list') characters = (list and list.characters) or {} destroyPreview() Cam.Shots(Config.CinematicShots) cb({ ok = true, characters = characters }) end) end) RegisterNUICallback('char:select', function(data, cb) CreateThread(function() local res, err = Core.Callback('rp_session', 'char:select', { id = data.id }) if not res then return cb({ ok = false, error = err }) end spawnData = res chosenChar = res.character destroyPreview() -- become the character now, off-screen, so the fly-down reveals them if res.appearance then PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance)) holdPlayer() end cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character }) end) end) -- --------------------------------------------------------------------------- -- character creation -- --------------------------------------------------------------------------- --- Read the game's own colour tables so the swatches in the UI are the real --- colours, not an approximation that drifts from what the ped ends up wearing. local paletteCache = nil local function palettes() if paletteCache then return paletteCache end local hair, makeup = {}, {} for i = 0, 63 do local ok, r, g, b = pcall(GetPedHairRgbColor, i) hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 } local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i) makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 } end paletteCache = { hair = hair, makeup = makeup } return paletteCache end --- The creator UI is built from this schema rather than a hardcoded copy, so --- adding a slider is a one-line change in shared/appearance.lua. local function pushDraft(cb) local payload = { ok = true, appearance = draft, limits = PedBuild.Limits(previewPed), palettes = palettes(), schema = { features = Appearance.FEATURES, overlays = Appearance.OVERLAYS, components = Appearance.COMPONENTS, props = Appearance.PROPS, }, } if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end end local function rebuildPreview() destroyPreview() focusOn(Config.CreatorScene.ped) previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w) if previewPed then PedBuild.Idle(previewPed) Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true) end end RegisterNUICallback('creator:start', function(data, cb) CreateThread(function() draft = Appearance.Default(data and data.gender or 'm') rebuildPreview() pushDraft(cb) end) end) RegisterNUICallback('creator:gender', function(data, cb) CreateThread(function() draft = Appearance.Default(data.gender == 'f' and 'f' or 'm') rebuildPreview() pushDraft(cb) end) end) RegisterNUICallback('creator:parent', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end local key = data.key if key == 'father' or key == 'mother' then draft.parents[key] = math.floor(tonumber(data.value) or 0) elseif key == 'shapeMix' or key == 'skinMix' then draft.parents[key] = tonumber(data.value) or 0.5 end SetPedHeadBlendData(previewPed, draft.parents.father, draft.parents.mother, 0, draft.parents.father, draft.parents.mother, 0, draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false) cb({ ok = true }) end) RegisterNUICallback('creator:feature', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end local id = math.floor(tonumber(data.id) or 0) local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0)) draft.features[tostring(id)] = v SetPedFaceFeature(previewPed, id, v + 0.0) cb({ ok = true }) end) RegisterNUICallback('creator:overlay', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end local slot for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end if not slot then return cb({ ok = false }) end local ov = draft.overlays[data.key] if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end local index = (ov.index == nil or ov.index < 0) and 255 or ov.index SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0) if slot.tint and index ~= 255 then SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour) end cb({ ok = true }) end) RegisterNUICallback('creator:hair', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0) SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight) cb({ ok = true }) end) RegisterNUICallback('creator:eyes', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end draft.eyeColour = math.floor(tonumber(data.value) or 0) SetPedEyeColor(previewPed, draft.eyeColour) cb({ ok = true }) end) RegisterNUICallback('creator:component', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end local comp for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end if not comp then return cb({ ok = false }) end local cc = draft.components[data.key] if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end -- a new drawable usually has a different number of textures local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1) if cc.texture > maxTex then cc.texture = 0 end SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0) cb({ ok = true, texture = cc.texture, maxTexture = maxTex }) end) RegisterNUICallback('creator:prop', function(data, cb) if not draft or not previewPed then return cb({ ok = false }) end local prop for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end if not prop then return cb({ ok = false }) end local pp = draft.props[data.key] if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end if pp.drawable < 0 then ClearPedProp(previewPed, prop.id) cb({ ok = true, texture = 0, maxTexture = 0 }) else local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1) if pp.texture > maxTex then pp.texture = 0 end SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true) cb({ ok = true, texture = pp.texture, maxTexture = maxTex }) end end) --- Camera framing per creator section. local FRAMES = { heritage = { radius = 1.05, height = 0.66, pitch = 0.02 }, face = { radius = 0.85, height = 0.68, pitch = 0.0 }, hair = { radius = 1.05, height = 0.70, pitch = 0.0 }, body = { radius = 2.60, height = 0.20, pitch = 0.45 }, clothing = { radius = 2.30, height = 0.28, pitch = 0.40 }, identity = { radius = 1.90, height = 0.45, pitch = 0.25 }, } RegisterNUICallback('creator:frame', function(data, cb) local f = FRAMES[data.section] or FRAMES.face Cam.OrbitFrame(f.radius, f.height, f.pitch) cb({ ok = true }) end) RegisterNUICallback('creator:rotate', function(data, cb) Cam.Nudge(tonumber(data.delta) or 0.0) cb({ ok = true }) end) RegisterNUICallback('creator:submit', function(data, cb) CreateThread(function() local res, err = Core.Callback('rp_session', 'char:create', { firstName = data.firstName, lastName = data.lastName, dob = data.dob, gender = draft and draft.model or 'm', backstory = data.backstory, appearance = draft, }) if not res then return cb({ ok = false, error = err }) end local list = Core.Callback('rp_session', 'char:list') characters = (list and list.characters) or {} destroyPreview() draft = nil cb({ ok = true, characters = characters }) end) end) RegisterNUICallback('creator:cancel', function(_, cb) destroyPreview() draft = nil Cam.Shots(Config.CinematicShots) cb({ ok = true }) end) -- --------------------------------------------------------------------------- -- spawn -- --------------------------------------------------------------------------- RegisterNUICallback('spawn:preview', function(data, cb) CreateThread(function() if not spawnData then return cb({ ok = false }) end for _, sp in ipairs(spawnData.spawns) do if sp.id == data.id then local from = Cam.Position() focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z)) Cam.FlyTo( from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z), vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z), vector3(sp.coords.x, sp.coords.y, sp.coords.z), 2200 ) break end end cb({ ok = true }) end) end) RegisterNUICallback('spawn:confirm', function(data, cb) CreateThread(function() local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id }) if not res then return cb({ ok = false, error = err }) end cb({ ok = true }) focusUI(false) ui({ action = 'stage', stage = 'flying' }) local c = res.coords local ped = PlayerPedId() -- stream the ground before anyone stands on it focusOn(vector3(c.x, c.y, c.z)) RequestCollisionAtCoord(c.x, c.y, c.z) local waited = 0 while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do RequestCollisionAtCoord(c.x, c.y, c.z) Wait(50) waited = waited + 50 end SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false) SetEntityHeading(ped, c.h or 0.0) local from = Cam.Position() Cam.FlyTo( from, vector3(c.x, c.y, c.z), vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2), 3400, function() SetEntityVisible(ped, true, false) releasePlayer() setSceneMood(false) ClearFocus() Cam.Release(1400) ui({ action = 'stage', stage = 'live' }) TriggerEvent('rp:ui:spawned', chosenChar) end ) end) end) -- --------------------------------------------------------------------------- -- misc -- --------------------------------------------------------------------------- RegisterNUICallback('ui:log', function(data, cb) print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message))) cb({ ok = true }) end) AddEventHandler('onResourceStop', function(res) if res ~= GetCurrentResourceName() then return end destroyPreview() Cam.Release(0) focusUI(false) releasePlayer() setSceneMood(false) ClearFocus() end)