The server itself: repo now mirrors the machine it runs on, plus install.sh
The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.
Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):
resources/[rp]/ rp_db, rp_core, rp_session, rp_loading, rp_ui,
rp_selftest, rp_dbtest
bin/ supervise.sh (keep-alive + console FIFO), rcon, init script
etc/schema.sql accounts, characters, transactions, inventory, vehicles
assets/fonts/ the bundled subsets
docs/SERVER.md how it is put together, resource by resource
install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.
No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80c1d75d2f
commit
71856b15e9
@@ -0,0 +1,188 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Scripted cameras for the pre-spawn screens.
|
||||
--
|
||||
-- One camera object is reused throughout. Everything is driven from a single
|
||||
-- render thread that eases the live values towards target values, so a change
|
||||
-- requested by the UI never snaps.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Cam = {}
|
||||
|
||||
local cam = nil
|
||||
local mode = 'off' -- 'off' | 'shots' | 'orbit' | 'fly'
|
||||
local renderThread = false
|
||||
|
||||
-- live and target orbit values
|
||||
local orbit = {
|
||||
angle = 180.0, targetAngle = 180.0,
|
||||
radius = 1.6, targetRadius = 1.6,
|
||||
height = 0.65, targetHeight = 0.65,
|
||||
focus = nil, -- entity to orbit
|
||||
pitch = 0.0, targetPitch = 0.0,
|
||||
}
|
||||
|
||||
local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 }
|
||||
local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil }
|
||||
|
||||
local function ensureCam()
|
||||
if cam and DoesCamExist(cam) then return cam end
|
||||
cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
return cam
|
||||
end
|
||||
|
||||
local function lerp(a, b, t) return a + (b - a) * t end
|
||||
|
||||
local function vlerp(a, b, t)
|
||||
return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t))
|
||||
end
|
||||
|
||||
-- smoothstep, so fly-throughs start and end at rest
|
||||
local function ease(t)
|
||||
t = math.max(0.0, math.min(1.0, t))
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
end
|
||||
|
||||
local function startRenderThread()
|
||||
if renderThread then return end
|
||||
renderThread = true
|
||||
|
||||
CreateThread(function()
|
||||
while mode ~= 'off' do
|
||||
local c = ensureCam()
|
||||
|
||||
if mode == 'shots' then
|
||||
local shot = shots.list[shots.index]
|
||||
if shot then
|
||||
local t = (GetGameTimer() - shots.startedAt) / shots.duration
|
||||
if t >= 1.0 then
|
||||
shots.index = (shots.index % #shots.list) + 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
shot = shots.list[shots.index]
|
||||
t = 0.0
|
||||
end
|
||||
-- a slow linear dolly reads as a helicopter, not a spline
|
||||
local pos = vlerp(shot.from, shot.to, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z)
|
||||
end
|
||||
|
||||
elseif mode == 'orbit' then
|
||||
orbit.angle = lerp(orbit.angle, orbit.targetAngle, 0.14)
|
||||
orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10)
|
||||
orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10)
|
||||
orbit.pitch = lerp(orbit.pitch, orbit.targetPitch, 0.10)
|
||||
|
||||
local ent = orbit.focus
|
||||
if ent and DoesEntityExist(ent) then
|
||||
local base = GetEntityCoords(ent)
|
||||
local rad = math.rad(orbit.angle)
|
||||
local px = base.x + math.sin(rad) * orbit.radius
|
||||
local py = base.y + math.cos(rad) * orbit.radius
|
||||
local pz = base.z + orbit.height
|
||||
SetCamCoord(c, px, py, pz)
|
||||
PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch)
|
||||
end
|
||||
|
||||
elseif mode == 'fly' then
|
||||
local t = ease((GetGameTimer() - fly.startedAt) / fly.duration)
|
||||
local pos = vlerp(fly.from, fly.to, t)
|
||||
local look = vlerp(fly.lookFrom, fly.lookTo, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, look.x, look.y, look.z)
|
||||
if t >= 1.0 then
|
||||
mode = 'idle'
|
||||
if fly.done then
|
||||
local fn = fly.done
|
||||
fly.done = nil
|
||||
fn()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Wait(0)
|
||||
end
|
||||
renderThread = false
|
||||
end)
|
||||
end
|
||||
|
||||
--- Slow establishing shots over the city, cycled forever.
|
||||
function Cam.Shots(list)
|
||||
shots.list = list
|
||||
shots.index = 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
mode = 'shots'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 42.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Orbit an entity. `snap` places the camera immediately instead of easing in.
|
||||
function Cam.Orbit(entity, radius, height, angle, snap)
|
||||
orbit.focus = entity
|
||||
orbit.targetRadius = radius or orbit.targetRadius
|
||||
orbit.targetHeight = height or orbit.targetHeight
|
||||
if angle then orbit.targetAngle = angle end
|
||||
if snap then
|
||||
orbit.radius = orbit.targetRadius
|
||||
orbit.height = orbit.targetHeight
|
||||
orbit.angle = orbit.targetAngle
|
||||
end
|
||||
mode = 'orbit'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 34.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Framing presets used by the creator's section tabs.
|
||||
function Cam.OrbitFrame(radius, height, pitch)
|
||||
orbit.targetRadius = radius
|
||||
orbit.targetHeight = height
|
||||
orbit.targetPitch = pitch or 0.0
|
||||
end
|
||||
|
||||
function Cam.Nudge(deltaDegrees)
|
||||
orbit.targetAngle = orbit.targetAngle + deltaDegrees
|
||||
end
|
||||
|
||||
function Cam.SetAngle(deg)
|
||||
orbit.targetAngle = deg
|
||||
end
|
||||
|
||||
function Cam.Angle() return orbit.targetAngle end
|
||||
|
||||
--- Fly from wherever we are to a point looking at a target, then call `done`.
|
||||
function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done)
|
||||
fly.from = fromPos
|
||||
fly.to = toPos
|
||||
fly.lookFrom = fromLook
|
||||
fly.lookTo = toLook
|
||||
fly.duration = duration or 3000
|
||||
fly.startedAt = GetGameTimer()
|
||||
fly.done = done
|
||||
mode = 'fly'
|
||||
ensureCam()
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
function Cam.SetFov(fov)
|
||||
if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end
|
||||
end
|
||||
|
||||
function Cam.Position()
|
||||
if cam and DoesCamExist(cam) then return GetCamCoord(cam) end
|
||||
return GetEntityCoords(PlayerPedId())
|
||||
end
|
||||
|
||||
--- Hand control back to the gameplay camera. With a duration the engine
|
||||
--- interpolates between the two, which hides the cut entirely.
|
||||
function Cam.Release(duration)
|
||||
mode = 'off'
|
||||
duration = duration or 0
|
||||
RenderScriptCams(false, duration > 0, duration, true, true)
|
||||
if cam and DoesCamExist(cam) then
|
||||
DestroyCam(cam, true)
|
||||
end
|
||||
cam = nil
|
||||
end
|
||||
@@ -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)
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Building a freemode ped from an appearance table.
|
||||
--
|
||||
-- The same code paints the creator preview and the live player, so what you
|
||||
-- saw while editing is exactly what walks into the world.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
PedBuild = {}
|
||||
|
||||
local NO_OVERLAY = 255 -- the native's "none" value; our model stores -1
|
||||
|
||||
local function loadModel(name)
|
||||
local hash = joaat(name)
|
||||
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local waited = 0
|
||||
while not HasModelLoaded(hash) and waited < 10000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if not HasModelLoaded(hash) then return nil end
|
||||
return hash
|
||||
end
|
||||
|
||||
PedBuild.LoadModel = loadModel
|
||||
|
||||
--- Apply an appearance table to an existing freemode ped.
|
||||
function PedBuild.Apply(ped, a)
|
||||
if not ped or not DoesEntityExist(ped) or type(a) ~= 'table' then return end
|
||||
|
||||
SetPedHeadBlendData(
|
||||
ped,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.shapeMix + 0.0, a.parents.skinMix + 0.0, 0.0,
|
||||
false
|
||||
)
|
||||
|
||||
for _, f in ipairs(Appearance.FEATURES) do
|
||||
local v = a.features[tostring(f.id)]
|
||||
if v then SetPedFaceFeature(ped, f.id, v + 0.0) end
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
local ov = a.overlays[o.key]
|
||||
if ov then
|
||||
local index = (ov.index == nil or ov.index < 0) and NO_OVERLAY or ov.index
|
||||
SetPedHeadOverlay(ped, o.id, index, ov.opacity + 0.0)
|
||||
if o.tint and index ~= NO_OVERLAY then
|
||||
-- tint type 1 = hair palette, 2 = make-up palette
|
||||
SetPedHeadOverlayColor(ped, o.id, o.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SetPedComponentVariation(ped, 2, a.hair.style, 0, 0)
|
||||
SetPedHairColor(ped, a.hair.colour, a.hair.highlight)
|
||||
SetPedEyeColor(ped, a.eyeColour)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local cc = a.components[c.key]
|
||||
if cc then SetPedComponentVariation(ped, c.id, cc.drawable, cc.texture, 0) end
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local pp = a.props[p.key]
|
||||
if pp then
|
||||
if not pp.drawable or pp.drawable < 0 then
|
||||
ClearPedProp(ped, p.id)
|
||||
else
|
||||
SetPedPropIndex(ped, p.id, pp.drawable, pp.texture, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- How many variations this particular model actually has, so the creator's
|
||||
--- sliders stop at real values instead of guessed ones.
|
||||
function PedBuild.Limits(ped)
|
||||
local limits = { components = {}, props = {}, overlays = {}, hair = 0 }
|
||||
if not ped or not DoesEntityExist(ped) then return limits end
|
||||
|
||||
limits.hair = math.max(0, GetNumberOfPedDrawableVariations(ped, 2) - 1)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local drawables = math.max(0, GetNumberOfPedDrawableVariations(ped, c.id) - 1)
|
||||
local current = GetPedDrawableVariation(ped, c.id)
|
||||
limits.components[c.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedTextureVariations(ped, c.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local drawables = math.max(-1, GetNumberOfPedPropDrawableVariations(ped, p.id) - 1)
|
||||
local current = GetPedPropIndex(ped, p.id)
|
||||
limits.props[p.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedPropTextureVariations(ped, p.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
limits.overlays[o.key] = o.max
|
||||
end
|
||||
|
||||
return limits
|
||||
end
|
||||
|
||||
--- Create a standalone preview ped (never networked).
|
||||
function PedBuild.CreatePreview(appearance, coords, heading)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return nil end
|
||||
|
||||
local ped = CreatePed(2, hash, coords.x, coords.y, coords.z, heading + 0.0, false, false)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
SetEntityInvincible(ped, true)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetBlockingOfNonTemporaryEvents(ped, true)
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
SetPedCanRagdoll(ped, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return ped
|
||||
end
|
||||
|
||||
--- Turn the local player into this character.
|
||||
function PedBuild.ApplyToPlayer(appearance)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return false end
|
||||
|
||||
SetPlayerModel(PlayerId(), hash)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
-- freemode peds start with no head blend applied until this settles a frame
|
||||
Wait(50)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return true
|
||||
end
|
||||
|
||||
--- A calm idle so the preview does not stand like a mannequin.
|
||||
function PedBuild.Idle(ped)
|
||||
local dict = 'anim@heists@heist_corona@team_idles@female_a'
|
||||
RequestAnimDict(dict)
|
||||
local waited = 0
|
||||
while not HasAnimDictLoaded(dict) and waited < 3000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if HasAnimDictLoaded(dict) then
|
||||
TaskPlayAnim(ped, dict, 'idle', 2.0, 2.0, -1, 1, 0, false, false, false)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user