rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
509 lines
17 KiB
Lua
509 lines
17 KiB
Lua
-- ---------------------------------------------------------------------------
|
|
-- 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)
|