Part 1 final build: Los Santos RP written on camera by the agent
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>
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
Reference in New Issue
Block a user