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:
2026-08-06 19:27:48 +00:00
co-authored by Claude Fable 5
parent cb0649016c
commit 80c1d75d2f
41 changed files with 6801 additions and 0 deletions
@@ -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