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:
Claude Opus 5
2026-08-12 23:23:33 +00:00
co-authored by Claude Opus 5
parent 80c1d75d2f
commit 71856b15e9
66 changed files with 2030 additions and 3814 deletions
+24
View File
@@ -0,0 +1,24 @@
-- ---------------------------------------------------------------------------
-- Client mirror of this player's character state.
-- Read-only: the server pushes it, nothing here writes back.
-- ---------------------------------------------------------------------------
Core = Core or {}
local character = nil
RegisterNetEvent('rp:core:character', function(data)
character = data
TriggerEvent('rp:core:characterChanged', character)
end)
--- Current character, or nil while the player is still at the auth screens.
function Core.Character()
return character
end
function Core.HasCharacter()
return character ~= nil
end
exports('getCharacter', Core.Character)
+32
View File
@@ -0,0 +1,32 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_core'
description 'Framework core: config, validation, player registry, request bridge'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'shared/config.lua',
'shared/util.lua',
'shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'server/crypto.js',
'server/player.lua',
}
client_scripts {
'client/state.lua',
}
-- Consumed by other resources via '@rp_core/...'
files {
'shared/config.lua',
'shared/util.lua',
'shared/appearance.lua',
'lib/callbacks_client.lua',
'lib/callbacks_server.lua',
}
@@ -0,0 +1,46 @@
-- ---------------------------------------------------------------------------
-- Client half of the request/response bridge.
-- Include with: client_scripts { '@rp_core/lib/callbacks_client.lua', ... }
--
-- Usage (inside a thread):
-- local result, err = Core.Callback('rp_session', 'auth:login', { ... })
--
-- Reply events are namespaced by the *calling* resource, so two resources
-- can never resolve each other's tokens.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local pending = {}
local seq = 0
RegisterNetEvent('rp:cb:res:' .. RES, function(token, result, err)
local p = pending[token]
if not p then return end -- already timed out
pending[token] = nil
p:resolve({ result = result, err = err })
end)
--- Blocking request to a server callback. Returns result, err.
--- Always returns; a lost reply surfaces as an error rather than a hang.
function Core.Callback(targetResource, name, payload, timeoutMs)
seq = seq + 1
local token = seq
local p = promise.new()
pending[token] = p
TriggerServerEvent('rp:cb:' .. targetResource, RES, name, token, payload)
CreateThread(function()
Wait(timeoutMs or 15000)
local waiting = pending[token]
if waiting then
pending[token] = nil
waiting:resolve({ result = nil, err = 'no response from ' .. targetResource .. '/' .. name })
end
end)
local r = Citizen.Await(p)
return r.result, r.err
end
@@ -0,0 +1,78 @@
-- ---------------------------------------------------------------------------
-- Server half of the request/response bridge.
-- Include with: server_scripts { '@rp_core/lib/callbacks_server.lua', ... }
--
-- Core.RegisterCallback('auth:login', function(src, payload)
-- return result, err -- err ~= nil is reported to the client
-- end)
--
-- Handlers run in their own thread so they may block on the database.
-- Every handler is rate limited per player; a client that floods is ignored
-- rather than allowed to queue unbounded database work.
-- ---------------------------------------------------------------------------
Core = Core or {}
local RES = GetCurrentResourceName()
local handlers = {}
local buckets = {} -- [src] = { tokens = n, last = ms }
local BUCKET_MAX = 15 -- burst
local BUCKET_RATE = 5 -- refilled per second
local function allow(src)
local now = GetGameTimer()
local b = buckets[src]
if not b then
b = { tokens = BUCKET_MAX, last = now }
buckets[src] = b
end
local elapsed = (now - b.last) / 1000
b.last = now
b.tokens = math.min(BUCKET_MAX, b.tokens + elapsed * BUCKET_RATE)
if b.tokens < 1 then return false end
b.tokens = b.tokens - 1
return true
end
AddEventHandler('playerDropped', function()
buckets[source] = nil
end)
function Core.RegisterCallback(name, fn)
if handlers[name] then
print(('^3[rp_core]^7 callback %q registered twice in %s'):format(name, RES))
end
handlers[name] = fn
end
RegisterNetEvent('rp:cb:' .. RES, function(fromResource, name, token, payload)
local src = source
-- A client controls fromResource/token, so they are only ever echoed back to
-- that same client. They are never used to look anything up on the server.
if type(fromResource) ~= 'string' or type(name) ~= 'string' then return end
local reply = function(result, err)
TriggerClientEvent('rp:cb:res:' .. fromResource, src, token, result, err)
end
if not allow(src) then
return reply(nil, 'slow down')
end
local fn = handlers[name]
if not fn then
print(('^3[rp_core]^7 %s: unknown callback %q from %d'):format(RES, name, src))
return reply(nil, 'unknown request')
end
CreateThread(function()
local ok, result, err = pcall(fn, src, payload)
if not ok then
print(('^1[rp_core]^7 callback %q errored: %s'):format(name, tostring(result)))
return reply(nil, 'internal error')
end
reply(result, err)
end)
end)
+62
View File
@@ -0,0 +1,62 @@
// ---------------------------------------------------------------------------
// Password hashing for rp_auth.
//
// scrypt with per-password random salt. The cost parameters are stored inside
// the hash string, so they can be raised later without invalidating existing
// accounts - verify() always uses the parameters the hash was made with.
//
// Format: scrypt$N$r$p$<salt b64>$<derived key b64>
// ---------------------------------------------------------------------------
const crypto = require('crypto');
const N = 16384; // CPU/memory cost
const R = 8; // block size
const P = 1; // parallelisation
const KEYLEN = 64;
const MAXMEM = 96 * 1024 * 1024; // scrypt needs ~128*N*r bytes = 16MB here
function derive(password, salt, n, r, p, cb) {
crypto.scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: MAXMEM }, cb);
}
global.exports('hashPassword', (password, cb) => {
if (typeof password !== 'string' || password.length === 0 || password.length > 200) {
return cb(null, 'invalid password');
}
const salt = crypto.randomBytes(16);
derive(password, salt, N, R, P, (err, dk) => {
if (err) return cb(null, err.message);
cb(`scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${dk.toString('base64')}`, null);
});
});
global.exports('verifyPassword', (password, stored, cb) => {
if (typeof password !== 'string' || typeof stored !== 'string') return cb(false, null);
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return cb(false, 'unrecognised hash format');
const n = parseInt(parts[1], 10);
const r = parseInt(parts[2], 10);
const p = parseInt(parts[3], 10);
if (!n || !r || !p) return cb(false, 'corrupt hash parameters');
let salt, expected;
try {
salt = Buffer.from(parts[4], 'base64');
expected = Buffer.from(parts[5], 'base64');
} catch (e) {
return cb(false, 'corrupt hash encoding');
}
derive(password, salt, n, r, p, (err, dk) => {
if (err) return cb(false, err.message);
// constant time: a wrong password must not be distinguishable by timing
const ok = dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
cb(ok, null);
});
});
// Opaque tokens (character session handles, drop ids, ...)
global.exports('randomToken', (bytes) => crypto.randomBytes(Math.min(Math.max(bytes || 16, 8), 64)).toString('hex'));
+264
View File
@@ -0,0 +1,264 @@
-- ---------------------------------------------------------------------------
-- Authoritative player registry.
--
-- While a player is online their state lives in this table and nowhere else.
-- Balance changes are applied in memory *synchronously* (no yield between
-- read and write, so two concurrent handlers cannot both spend the same
-- money) and then mirrored to MariaDB asynchronously alongside a row in
-- `transactions`, which is the audit trail.
-- ---------------------------------------------------------------------------
Core = Core or {}
local players = {} -- [source] = player table
local byChar = {} -- [character id] = source
-- ---------------------------------------------------------------------------
-- Lookup
-- ---------------------------------------------------------------------------
function Core.Get(src) return players[src] end
function Core.GetByChar(charId) return players[byChar[charId] or -1] end
function Core.All() return players end
local function requireChar(src)
local p = players[src]
if not p or not p.char then return nil end
return p
end
-- ---------------------------------------------------------------------------
-- Lifecycle
-- ---------------------------------------------------------------------------
--- Called by rp_session once credentials have been accepted.
function Core.AttachAccount(src, account)
players[src] = {
source = src,
license = account.license,
account = { id = account.id, username = account.username, role = account.role },
char = nil,
spawned = false,
joinedAt = os.time(),
}
return players[src]
end
--- Called once a character has been chosen. `row` is the DB row.
function Core.AttachCharacter(src, row)
local p = players[src]
if not p then return nil end
p.char = {
id = row.id,
firstName = row.first_name,
lastName = row.last_name,
dob = row.dob,
gender = row.gender,
backstory = row.backstory,
cash = math.floor(tonumber(row.cash) or 0),
bank = math.floor(tonumber(row.bank) or 0),
health = tonumber(row.health) or 200,
armour = tonumber(row.armour) or 0,
job = row.job or 'unemployed',
jobGrade = tonumber(row.job_grade) or 0,
playtime = tonumber(row.playtime) or 0,
appearance = row.appearance and json.decode(row.appearance) or nil,
position = row.position and json.decode(row.position) or nil,
needs = row.needs and json.decode(row.needs) or { hunger = 100, thirst = 100, stress = 0 },
}
p.sessionStart = os.time()
byChar[row.id] = src
Core.PushCharacter(src)
Core.PublishState(src)
return p.char
end
function Core.Detach(src)
local p = players[src]
if not p then return end
if p.char then byChar[p.char.id] = nil end
players[src] = nil
end
-- ---------------------------------------------------------------------------
-- Synchronisation
-- ---------------------------------------------------------------------------
--- Private state: only the owning client receives this.
function Core.PushCharacter(src)
local p = requireChar(src)
if not p then return end
TriggerClientEvent('rp:core:character', src, p.char)
end
--- Public state: what every other client is allowed to know about this player.
function Core.PublishState(src)
local p = requireChar(src)
if not p then return end
local st = Player(src).state
st:set('rp:name', p.char.firstName .. ' ' .. p.char.lastName, true)
st:set('rp:job', p.char.job, true)
st:set('rp:charId', p.char.id, true)
end
-- ---------------------------------------------------------------------------
-- Money
-- ---------------------------------------------------------------------------
local VALID_ACCOUNTS = { cash = true, bank = true }
local function journal(charId, kind, delta, balanceAfter, reason)
DB.TransactionAsync({
{
query = ('UPDATE characters SET %s = ? WHERE id = ?'):format(kind),
values = { balanceAfter, charId },
},
{
query = 'INSERT INTO transactions (character_id, account_kind, delta, balance_after, reason) VALUES (?, ?, ?, ?, ?)',
values = { charId, kind, delta, balanceAfter, reason },
},
}, function(ok, err)
if not ok then
print(('^1[rp_core]^7 failed to journal %s %+d for character %d: %s')
:format(kind, delta, charId, tostring(err)))
end
end)
end
--- Returns true on success. Amounts are always positive integers.
function Core.AddMoney(src, kind, amount, reason)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return false end
amount = math.floor(tonumber(amount) or 0)
if amount <= 0 then return false end
p.char[kind] = p.char[kind] + amount -- no yield: cannot interleave
local after = p.char[kind]
Core.PushCharacter(src)
journal(p.char.id, kind, amount, after, reason or 'unspecified')
return true
end
--- Returns false (and changes nothing) when the player cannot afford it.
function Core.RemoveMoney(src, kind, amount, reason)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return false end
amount = math.floor(tonumber(amount) or 0)
if amount <= 0 then return false end
if p.char[kind] < amount then return false end
p.char[kind] = p.char[kind] - amount
local after = p.char[kind]
Core.PushCharacter(src)
journal(p.char.id, kind, -amount, after, reason or 'unspecified')
return true
end
function Core.GetMoney(src, kind)
local p = requireChar(src)
if not p or not VALID_ACCOUNTS[kind] then return 0 end
return p.char[kind]
end
-- ---------------------------------------------------------------------------
-- Persistence
-- ---------------------------------------------------------------------------
--- Reads position and health from the server's own copy of the entity rather
--- than asking the client, so a modified client cannot lie about either.
local function snapshot(p)
local ped = GetPlayerPed(p.source)
if ped and ped ~= 0 and DoesEntityExist(ped) then
local c = GetEntityCoords(ped)
local h = GetEntityHeading(ped)
if c and c.x and not (c.x == 0.0 and c.y == 0.0) then
p.char.position = { x = c.x, y = c.y, z = c.z, h = h }
end
local hp = GetEntityHealth(ped)
if hp and hp > 0 then p.char.health = hp end
end
if p.sessionStart then
local now = os.time()
p.char.playtime = p.char.playtime + (now - p.sessionStart)
p.sessionStart = now
end
end
function Core.Save(src, reason)
local p = requireChar(src)
if not p or not p.spawned then return end
snapshot(p)
local c = p.char
DB.UpdateAsync([[
UPDATE characters
SET cash = ?, bank = ?, health = ?, armour = ?, job = ?, job_grade = ?,
position = ?, needs = ?, appearance = ?, playtime = ?, last_played_at = NOW()
WHERE id = ?
]], {
c.cash, c.bank, c.health, c.armour, c.job, c.jobGrade,
json.encode(c.position or {}), json.encode(c.needs or {}),
c.appearance and json.encode(c.appearance) or DB.NULL,
c.playtime, c.id,
}, function(_, err)
if err then
print(('^1[rp_core]^7 save failed for character %d (%s): %s'):format(c.id, tostring(reason), err))
end
end)
end
function Core.SaveAll(reason)
local n = 0
for src in pairs(players) do
if players[src].char and players[src].spawned then
Core.Save(src, reason)
n = n + 1
end
end
return n
end
CreateThread(function()
local interval = (Config.Session.autosaveSeconds or 300) * 1000
while true do
Wait(interval)
local n = Core.SaveAll('autosave')
if n > 0 then print(('^5[rp_core]^7 autosaved %d character(s)'):format(n)) end
end
end)
AddEventHandler('playerDropped', function(reason)
local src = source
Core.Save(src, 'disconnect: ' .. tostring(reason))
Core.Detach(src)
end)
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
local n = Core.SaveAll('resource stop')
print(('^5[rp_core]^7 saving %d character(s) on shutdown'):format(n))
end)
-- ---------------------------------------------------------------------------
-- Exports for the other resources. None of these yield.
-- ---------------------------------------------------------------------------
exports('getPlayer', function(src) return players[src] end)
exports('getCharacter', function(src) local p = players[src]; return p and p.char or nil end)
exports('attachAccount', Core.AttachAccount)
exports('attachCharacter',Core.AttachCharacter)
exports('detach', Core.Detach)
exports('addMoney', Core.AddMoney)
exports('removeMoney', Core.RemoveMoney)
exports('getMoney', Core.GetMoney)
exports('save', Core.Save)
exports('pushCharacter', Core.PushCharacter)
exports('publishState', Core.PublishState)
exports('setSpawned', function(src, v)
local p = players[src]
if p then p.spawned = v and true or false end
end)
@@ -0,0 +1,185 @@
-- ---------------------------------------------------------------------------
-- Freemode ped appearance model.
--
-- The client builds a ped from this table and the server stores it verbatim,
-- so it is sanitised here - once - and both sides use the same code. Anything
-- the client sends that is not described below is dropped.
-- ---------------------------------------------------------------------------
Appearance = {}
Appearance.MODELS = {
m = 'mp_m_freemode_01',
f = 'mp_f_freemode_01',
}
-- SetPedHeadBlendData: 46 heritage faces per parent.
Appearance.PARENT_MAX = 45
-- SetPedFaceFeature indices, in the order the creator shows them.
Appearance.FEATURES = {
{ id = 0, label = 'Nose width' },
{ id = 1, label = 'Nose height' },
{ id = 2, label = 'Nose length' },
{ id = 3, label = 'Nose bridge' },
{ id = 4, label = 'Nose tip' },
{ id = 5, label = 'Nose shift' },
{ id = 6, label = 'Brow height' },
{ id = 7, label = 'Brow depth' },
{ id = 8, label = 'Cheekbone height' },
{ id = 9, label = 'Cheekbone width' },
{ id = 10, label = 'Cheek width' },
{ id = 11, label = 'Eye opening' },
{ id = 12, label = 'Lip thickness' },
{ id = 13, label = 'Jaw width' },
{ id = 14, label = 'Jaw length' },
{ id = 15, label = 'Chin height' },
{ id = 16, label = 'Chin length' },
{ id = 17, label = 'Chin width' },
{ id = 18, label = 'Chin dimple' },
{ id = 19, label = 'Neck thickness' },
}
-- SetPedHeadOverlay slots that the creator exposes.
Appearance.OVERLAYS = {
{ id = 2, key = 'eyebrows', label = 'Eyebrows', max = 33, tint = 'hair' },
{ id = 1, key = 'beard', label = 'Facial hair', max = 28, tint = 'hair' },
{ id = 0, key = 'blemishes', label = 'Blemishes', max = 23, tint = false },
{ id = 3, key = 'ageing', label = 'Ageing', max = 14, tint = false },
{ id = 6, key = 'complexion',label = 'Complexion', max = 11, tint = false },
{ id = 7, key = 'sundamage', label = 'Sun damage', max = 10, tint = false },
{ id = 9, key = 'freckles', label = 'Freckles', max = 17, tint = false },
{ id = 5, key = 'blush', label = 'Blush', max = 6, tint = 'makeup' },
{ id = 8, key = 'lipstick', label = 'Lipstick', max = 9, tint = 'makeup' },
{ id = 4, key = 'makeup', label = 'Make-up', max = 74, tint = 'makeup' },
}
-- Clothing components the creator exposes (0-11 exist; these are the useful ones).
Appearance.COMPONENTS = {
{ id = 11, key = 'jacket', label = 'Top' },
{ id = 8, key = 'undershirt',label = 'Undershirt' },
{ id = 4, key = 'legs', label = 'Legs' },
{ id = 6, key = 'shoes', label = 'Shoes' },
{ id = 3, key = 'torso', label = 'Arms' },
{ id = 1, key = 'mask', label = 'Mask' },
{ id = 9, key = 'vest', label = 'Vest' },
{ id = 7, key = 'accessory', label = 'Accessory' },
{ id = 10, key = 'decal', label = 'Decal' },
{ id = 5, key = 'bag', label = 'Bag' },
}
Appearance.PROPS = {
{ id = 0, key = 'hat', label = 'Hat' },
{ id = 1, key = 'glasses', label = 'Glasses' },
{ id = 2, key = 'ear', label = 'Earrings' },
}
local function num(v, lo, hi, fallback)
v = tonumber(v)
if not v or v ~= v then return fallback end -- also rejects NaN
if v < lo then return lo end
if v > hi then return hi end
return v
end
local function int(v, lo, hi, fallback)
return math.floor(num(v, lo, hi, fallback) + 0.0)
end
function Appearance.Default(gender)
local a = {
model = (gender == 'f') and 'f' or 'm',
parents = { father = 0, mother = 21, shapeMix = 0.5, skinMix = 0.5 },
features = {},
overlays = {},
hair = { style = 0, colour = 0, highlight = 0 },
eyeColour = 0,
components = {},
props = {},
}
for _, f in ipairs(Appearance.FEATURES) do a.features[tostring(f.id)] = 0.0 end
for _, o in ipairs(Appearance.OVERLAYS) do
a.overlays[o.key] = { index = -1, opacity = 1.0, colour = 0 }
end
for _, c in ipairs(Appearance.COMPONENTS) do
a.components[c.key] = { drawable = 0, texture = 0 }
end
for _, p in ipairs(Appearance.PROPS) do
a.props[p.key] = { drawable = -1, texture = 0 }
end
-- a plain default outfit rather than the naked base ped
a.components.jacket = { drawable = 15, texture = 0 }
a.components.undershirt = { drawable = 15, texture = 0 }
a.components.torso = { drawable = 15, texture = 0 }
a.components.legs = { drawable = 21, texture = 0 }
a.components.shoes = { drawable = 34, texture = 0 }
return a
end
--- Returns a clean appearance table built only from known keys.
function Appearance.Sanitise(input)
local gender = (type(input) == 'table' and input.model == 'f') and 'f' or 'm'
local out = Appearance.Default(gender)
if type(input) ~= 'table' then return out end
if type(input.parents) == 'table' then
out.parents.father = int(input.parents.father, 0, Appearance.PARENT_MAX, 0)
out.parents.mother = int(input.parents.mother, 0, Appearance.PARENT_MAX, 21)
out.parents.shapeMix = num(input.parents.shapeMix, 0.0, 1.0, 0.5)
out.parents.skinMix = num(input.parents.skinMix, 0.0, 1.0, 0.5)
end
if type(input.features) == 'table' then
for _, f in ipairs(Appearance.FEATURES) do
local k = tostring(f.id)
out.features[k] = num(input.features[k], -1.0, 1.0, 0.0)
end
end
if type(input.overlays) == 'table' then
for _, o in ipairs(Appearance.OVERLAYS) do
local given = input.overlays[o.key]
if type(given) == 'table' then
out.overlays[o.key] = {
index = int(given.index, -1, o.max, -1),
opacity = num(given.opacity, 0.0, 1.0, 1.0),
colour = int(given.colour, 0, 63, 0),
}
end
end
end
if type(input.hair) == 'table' then
out.hair.style = int(input.hair.style, 0, 80, 0)
out.hair.colour = int(input.hair.colour, 0, 63, 0)
out.hair.highlight = int(input.hair.highlight, 0, 63, 0)
end
out.eyeColour = int(input.eyeColour, 0, 31, 0)
if type(input.components) == 'table' then
for _, c in ipairs(Appearance.COMPONENTS) do
local given = input.components[c.key]
if type(given) == 'table' then
out.components[c.key] = {
drawable = int(given.drawable, 0, 511, 0),
texture = int(given.texture, 0, 63, 0),
}
end
end
end
if type(input.props) == 'table' then
for _, p in ipairs(Appearance.PROPS) do
local given = input.props[p.key]
if type(given) == 'table' then
out.props[p.key] = {
drawable = int(given.drawable, -1, 255, -1),
texture = int(given.texture, 0, 63, 0),
}
end
end
end
return out
end
+99
View File
@@ -0,0 +1,99 @@
-- ---------------------------------------------------------------------------
-- Shared configuration. Loaded on both server and client.
-- ---------------------------------------------------------------------------
Config = {}
Config.ServerName = 'LOS SANTOS'
Config.ServerTag = 'ROLEPLAY'
Config.MaxChars = 3
-- Shown on the loading screen while the world streams in.
Config.Rules = {
{ title = 'Stay in character', body = 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
{ title = 'Value your life', body = 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
{ title = 'No random deathmatch', body = 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
{ title = 'Do not power game', body = 'Give people a fair chance to react. Winning is not the point of a scene.' },
{ title = 'Leave the scene alive', body = 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
}
-- Spawn choices offered after character select. Coordinates are real map
-- locations; `cam` is where the establishing shot sits before it flies down.
Config.SpawnPoints = {
{
id = 'legion',
label = 'Legion Square',
area = 'Downtown',
blurb = 'Concrete, noise and opportunity. Everything starts here eventually.',
coords = vector4(195.12, -933.75, 30.69, 145.0),
cam = { pos = vector3(214.0, -880.0, 90.0), look = vector3(195.0, -933.0, 32.0) },
map = { x = 0.512, y = 0.548 },
},
{
id = 'vespucci',
label = 'Vespucci Beach',
area = 'West coast',
blurb = 'Sand, boardwalk hustlers and the smell of two-stroke engines.',
coords = vector4(-1223.45, -1490.32, 4.38, 125.0),
cam = { pos = vector3(-1250.0, -1440.0, 60.0), look = vector3(-1223.0, -1490.0, 6.0) },
map = { x = 0.318, y = 0.700 },
},
{
id = 'sandy',
label = 'Sandy Shores',
area = 'Blaine County',
blurb = 'Dust, trailers and people who would rather not be found.',
coords = vector4(1961.29, 3740.68, 32.34, 300.0),
cam = { pos = vector3(1995.0, 3700.0, 80.0), look = vector3(1961.0, 3740.0, 34.0) },
map = { x = 0.760, y = 0.245 },
},
{
id = 'paleto',
label = 'Paleto Bay',
area = 'North coast',
blurb = 'A town small enough that everyone will know your name by Friday.',
coords = vector4(-108.71, 6467.32, 31.63, 225.0),
cam = { pos = vector3(-160.0, 6420.0, 80.0), look = vector3(-108.0, 6467.0, 33.0) },
map = { x = 0.470, y = 0.075 },
},
{
id = 'mirror',
label = 'Mirror Park',
area = 'East Vinewood',
blurb = 'Quiet streets, loud neighbours, and rent you cannot quite afford.',
coords = vector4(1050.12, -720.45, 57.05, 90.0),
cam = { pos = vector3(1100.0, -680.0, 100.0), look = vector3(1050.0, -720.0, 58.0) },
map = { x = 0.640, y = 0.505 },
},
}
-- Establishing shots used behind the login / character screens. Each entry is
-- a slow dolly from `from` to `to` while looking at `look`.
Config.CinematicShots = {
{ from = vector3(-1900.0, -600.0, 130.0), to = vector3(-1700.0, -500.0, 120.0), look = vector3(-1450.0, -400.0, 60.0) },
{ from = vector3(-75.0, -818.0, 320.0), to = vector3(30.0, -700.0, 290.0), look = vector3(-200.0, -1000.0, 60.0) },
{ from = vector3(1200.0, -1600.0, 160.0),to = vector3(1000.0, -1450.0, 150.0), look = vector3(500.0, -1200.0, 60.0) },
{ from = vector3(-1550.0, -1450.0, 90.0),to = vector3(-1400.0, -1550.0, 80.0), look = vector3(-1100.0, -1700.0, 20.0) },
{ from = vector3(730.0, 1200.0, 400.0), to = vector3(640.0, 1100.0, 380.0), look = vector3(300.0, 800.0, 200.0) },
}
-- Where a character stands while being edited or previewed. Flat, empty apron
-- at the airport: always streamed, no props to clip through, and dark enough
-- at the overridden clock time to light the ped cleanly.
Config.CreatorScene = {
ped = vector4(-1037.20, -2738.60, 20.17, 328.0),
}
Config.Money = {
startingCash = 500,
startingBank = 2500,
}
Config.Session = {
-- how long a client may sit at the auth screen before it is dropped
authTimeoutMs = 300000,
-- failed logins allowed per identity within the window
maxAttempts = 6,
attemptWindowS = 900,
autosaveSeconds = 300,
}
+111
View File
@@ -0,0 +1,111 @@
-- ---------------------------------------------------------------------------
-- Small shared helpers. Kept deliberately thin.
-- ---------------------------------------------------------------------------
Util = {}
function Util.trim(s)
if type(s) ~= 'string' then return '' end
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
function Util.clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
function Util.round(v, places)
local m = 10 ^ (places or 0)
return math.floor(v * m + 0.5) / m
end
--- Deep copy; cycles are not expected in config/state tables.
function Util.copy(t)
if type(t) ~= 'table' then return t end
local out = {}
for k, v in pairs(t) do out[k] = Util.copy(v) end
return out
end
function Util.count(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
--- Group separated money, e.g. 1234567 -> "1,234,567"
function Util.money(n)
local s = tostring(math.floor(math.abs(n or 0)))
local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
out = out:gsub('^,', '')
return (n or 0) < 0 and ('-' .. out) or out
end
--- Names are stored capitalised regardless of how they were typed.
function Util.properName(s)
s = Util.trim(s):lower()
return (s:gsub("([^%s'-]+)", function(word)
return word:sub(1, 1):upper() .. word:sub(2)
end))
end
--- Validation shared by client (live feedback) and server (enforcement), so
--- the two can never disagree about what is acceptable.
Rules = {}
Rules.username = {
min = 3, max = 20,
pattern = '^[%w_]+$',
hint = '3-20 characters, letters, numbers and underscore only',
}
Rules.password = {
min = 8, max = 72,
hint = 'at least 8 characters, with a letter and a number',
}
Rules.name = {
min = 2, max = 20,
pattern = "^[%a][%a'%-]*$",
hint = "letters, apostrophes and hyphens",
}
function Rules.checkUsername(v)
v = Util.trim(v or '')
if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end
if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end
if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end
return true
end
function Rules.checkPassword(v)
v = v or ''
if #v < Rules.password.min then return false, 'At least 8 characters' end
if #v > Rules.password.max then return false, 'Too long' end
if not v:match('%a') then return false, 'Needs at least one letter' end
if not v:match('%d') then return false, 'Needs at least one number' end
return true
end
function Rules.checkName(v)
v = Util.trim(v or '')
if #v < Rules.name.min then return false, 'Too short' end
if #v > Rules.name.max then return false, 'Too long' end
if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end
return true
end
--- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age.
function Rules.checkDob(v)
v = Util.trim(v or '')
local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
if not y then return false, 'Use YYYY-MM-DD' end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if m < 1 or m > 12 then return false, 'Month must be 01-12' end
local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end
if d < 1 or d > mdays[m] then return false, 'That day does not exist' end
if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end
return true
end
+15
View File
@@ -0,0 +1,15 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_db'
description 'MariaDB client speaking the MySQL wire protocol directly - no external packages'
author 'Los Santos RP'
version '1.0.0'
server_scripts {
'server/mysql.js',
}
files {
'lib/db.lua',
}
+87
View File
@@ -0,0 +1,87 @@
-- ---------------------------------------------------------------------------
-- DB - Lua front end for rp_db.
--
-- Loaded *into* the consuming resource with:
-- server_scripts { '@rp_db/lib/db.lua', 'server/whatever.lua' }
--
-- so that Citizen.Await suspends the caller's own coroutine instead of trying
-- to yield across a cross-resource export call.
--
-- Every function comes in two flavours:
-- DB.Query(sql, params) -> rows, err (blocking, inside a thread)
-- DB.QueryAsync(sql, params, cb) -> cb(rows, err) (callback, anywhere)
--
-- Values are always passed as `?` parameters. Never build SQL by concatenation.
-- Use DB.NULL where you need a literal SQL NULL (Lua nil cannot survive a table).
-- ---------------------------------------------------------------------------
DB = {}
DB.NULL = 'RP_NULL'
local backend = exports.rp_db
local function async(method)
return function(sql, params, cb)
backend[method](backend, sql, params or {}, cb)
end
end
local function blocking(method)
return function(sql, params)
local p = promise.new()
backend[method](backend, sql, params or {}, function(result, err)
-- wrapped: a promise resolved with nil would never wake the coroutine
p:resolve({ result = result, err = err })
end)
local r = Citizen.Await(p)
if r.err then return nil, r.err end
return r.result
end
end
--- Returns all rows as an array of tables (empty table when nothing matched).
DB.Query = blocking('query')
--- Returns the first row as a table, or nil.
DB.Single = blocking('single')
--- Returns the first column of the first row, or nil.
DB.Scalar = blocking('scalar')
--- Returns the AUTO_INCREMENT id produced by an INSERT.
DB.Insert = blocking('insert')
--- Returns the number of rows changed by an UPDATE/DELETE.
DB.Update = blocking('update')
DB.QueryAsync = async('query')
DB.SingleAsync = async('single')
DB.ScalarAsync = async('scalar')
DB.InsertAsync = async('insert')
DB.UpdateAsync = async('update')
--- All statements commit together or none of them do.
--- statements: { { query = 'UPDATE ..', values = { 1, 2 } }, ... }
function DB.Transaction(statements)
local p = promise.new()
backend:transaction(statements, function(ok, err)
p:resolve({ ok = ok, err = err })
end)
local r = Citizen.Await(p)
if not r.ok then return false, r.err end
return true
end
function DB.TransactionAsync(statements, cb)
backend:transaction(statements, cb)
end
--- Blocks until the connection pool has at least one live connection.
--- Returns false if the database never came up within `timeout` ms.
function DB.WaitReady(timeout)
local waited, step = 0, 100
timeout = timeout or 30000
while not backend:isReady() do
if waited >= timeout then return false end
Citizen.Wait(step)
waited = waited + step
end
return true
end
+746
View File
@@ -0,0 +1,746 @@
// ---------------------------------------------------------------------------
// rp_db - a MySQL/MariaDB client written for this server.
//
// Speaks the MySQL client/server protocol directly over a TCP socket: framing,
// handshake, mysql_native_password / caching_sha2_password (fast path), and
// the text result-set protocol. No external packages - only node built-ins.
//
// Deliberate choices:
// * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection
// ("'; DROP TABLE ..") is rejected by the server even if escaping failed.
// * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to
// read a local file.
// * sql_mode is pinned at connect time, which also guarantees backslash
// escaping semantics that format() relies on.
// ---------------------------------------------------------------------------
const net = require('net');
const crypto = require('crypto');
// -- capability flags --------------------------------------------------------
const CAP = {
LONG_PASSWORD: 0x00000001,
FOUND_ROWS: 0x00000002,
LONG_FLAG: 0x00000004,
CONNECT_WITH_DB: 0x00000008,
LOCAL_FILES: 0x00000080,
PROTOCOL_41: 0x00000200,
TRANSACTIONS: 0x00002000,
SECURE_CONNECTION: 0x00008000,
MULTI_STATEMENTS: 0x00010000,
MULTI_RESULTS: 0x00020000,
PLUGIN_AUTH: 0x00080000,
CONNECT_ATTRS: 0x00100000,
PLUGIN_AUTH_LENENC: 0x00200000,
};
const CLIENT_CAPS =
CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 |
CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC;
const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e };
// MySQL column type codes we care about when coercing text-protocol values.
const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR
const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL
// ---------------------------------------------------------------------------
// Buffer helpers
// ---------------------------------------------------------------------------
class Reader {
constructor(buf) { this.b = buf; this.o = 0; }
u8() { return this.b[this.o++]; }
u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; }
skip(n) { this.o += n; return this; }
bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; }
nulStr() {
let i = this.b.indexOf(0, this.o);
if (i < 0) i = this.b.length;
const s = this.b.toString('utf8', this.o, i);
this.o = i + 1;
return s;
}
// length-encoded integer; returns null for the 0xfb NULL marker
lenenc() {
const f = this.b[this.o++];
if (f < 0xfb) return f;
if (f === 0xfb) return null;
if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; }
const v = this.b.readBigUInt64LE(this.o); this.o += 8;
return Number(v);
}
lenencStr() {
const n = this.lenenc();
if (n === null) return null;
return this.bytes(n).toString('utf8');
}
remaining() { return this.b.length - this.o; }
}
class Writer {
constructor() { this.parts = []; }
u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; }
u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; }
u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; }
zeros(n) { this.parts.push(Buffer.alloc(n)); return this; }
raw(b) { this.parts.push(b); return this; }
nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); }
lenencBuf(b) {
if (b.length < 0xfb) this.u8(b.length);
else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); }
else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); }
return this.raw(b);
}
done() { return Buffer.concat(this.parts); }
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
const sha1 = (d) => crypto.createHash('sha1').update(d).digest();
const sha256 = (d) => crypto.createHash('sha256').update(d).digest();
function xorBuf(a, b) {
const out = Buffer.alloc(a.length);
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length];
return out;
}
// SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) )
function authNative(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha1(Buffer.from(password, 'utf8'));
const s2 = sha1(s1);
return xorBuf(s1, sha1(Buffer.concat([scramble, s2])));
}
// SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only]
function authCachingSha2(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha256(Buffer.from(password, 'utf8'));
const s2 = sha256(s1);
return xorBuf(s1, sha256(Buffer.concat([s2, scramble])));
}
function authResponse(plugin, password, scramble) {
switch (plugin) {
case 'mysql_native_password': return authNative(password, scramble);
case 'caching_sha2_password': return authCachingSha2(password, scramble);
case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]);
default: return null; // unsupported -> connection fails loudly
}
}
// ---------------------------------------------------------------------------
// Value escaping. Every query goes through format(); values are never
// concatenated into SQL anywhere else in this codebase.
// ---------------------------------------------------------------------------
const ESCAPE_MAP = {
'\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n',
'\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\',
};
function escapeString(s) {
return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'";
}
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function escapeValue(v) {
if (v === null || v === undefined) return 'NULL';
switch (typeof v) {
case 'boolean': return v ? '1' : '0';
case 'number':
if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number');
return String(v);
case 'bigint': return String(v);
case 'string': return escapeString(v);
case 'object':
if (v instanceof Date) {
return escapeString(
`${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` +
`${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}`
);
}
if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex'));
if (Array.isArray(v)) return v.map(escapeValue).join(', ');
return escapeString(JSON.stringify(v)); // plain objects -> JSON columns
default:
throw new Error('cannot bind value of type ' + typeof v);
}
}
// Replace `?` placeholders, skipping any that appear inside string/identifier
// literals or comments so that user data containing '?' can never shift binding.
function format(sql, params) {
if (!params || (Array.isArray(params) && params.length === 0)) return sql;
const list = Array.isArray(params) ? params : [params];
let out = '';
let pi = 0;
let i = 0;
const n = sql.length;
while (i < n) {
const c = sql[i];
if (c === "'" || c === '"' || c === '`') {
const quote = c;
out += c; i++;
while (i < n) {
if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; }
if (sql[i] === quote) {
if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote
out += quote; i++; break;
}
out += sql[i]; i++;
}
continue;
}
if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '/' && sql[i + 1] === '*') {
out += '/*'; i += 2;
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++];
out += '*/'; i += 2; continue;
}
if (c === '?') {
if (pi >= list.length) throw new Error('not enough parameters for query');
out += escapeValue(list[pi++]);
i++; continue;
}
out += c; i++;
}
if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`);
return out;
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
const MAX_PAYLOAD = 0xffffff;
class Connection {
constructor(opts, id) {
this.opts = opts;
this.id = id;
this.socket = null;
this.buf = Buffer.alloc(0);
this.seq = 0;
this.state = 'offline';
this.busy = false;
this.pinned = false; // reserved by a transaction; pump() must skip it
this.pending = null; // {resolve, reject, sql}
this.rs = null; // in-flight result set
this.multi = null; // reassembly buffer for >16MB payloads
this.serverCaps = 0;
this.onIdle = null;
this.retryDelay = 1000;
}
log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); }
err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); }
connect() {
if (this.state !== 'offline') return;
this.state = 'connecting';
this.buf = Buffer.alloc(0);
this.seq = 0;
const sock = net.createConnection({ host: this.opts.host, port: this.opts.port });
this.socket = sock;
sock.setNoDelay(true);
sock.on('data', (d) => {
this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d;
this.drainPackets();
});
sock.on('error', (e) => this.fail(e.message));
sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); });
}
fail(reason) {
const wasReady = this.state === 'ready' || this.state === 'query';
this.state = 'offline';
if (this.socket) { this.socket.destroy(); this.socket = null; }
if (this.pending) {
const p = this.pending; this.pending = null; this.busy = false;
p.reject(new Error(reason));
}
this.busy = false;
this.pinned = false; // a dropped connection must not stay reserved
this.rs = null;
if (wasReady) this.err(`lost connection: ${reason}`);
else this.err(`connect failed: ${reason}`);
// exponential backoff, capped
setTimeout(() => this.connect(), this.retryDelay);
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
}
// Split the stream into protocol packets, reassembling 16MB-split payloads.
drainPackets() {
for (;;) {
if (this.buf.length < 4) return;
const len = this.buf.readUIntLE(0, 3);
if (this.buf.length < 4 + len) return;
const seq = this.buf[3];
const payload = this.buf.subarray(4, 4 + len);
this.buf = this.buf.subarray(4 + len);
this.seq = (seq + 1) & 0xff;
if (len === MAX_PAYLOAD) { // more to come
this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload;
continue;
}
let full = payload;
if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; }
try { this.onPacket(full); }
catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; }
}
}
send(payload) {
if (!this.socket) return;
const chunks = [];
let off = 0;
do {
const slice = payload.subarray(off, off + MAX_PAYLOAD);
const head = Buffer.alloc(4);
head.writeUIntLE(slice.length, 0, 3);
head[3] = this.seq & 0xff;
this.seq = (this.seq + 1) & 0xff;
chunks.push(head, slice);
off += slice.length;
} while (off < payload.length);
this.socket.write(Buffer.concat(chunks));
}
sendCommand(cmd, text) {
this.seq = 0; // every command starts a new sequence
const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0);
this.send(Buffer.concat([Buffer.from([cmd]), body]));
}
onPacket(p) {
switch (this.state) {
case 'connecting': return this.onHandshake(p);
case 'auth': return this.onAuthResult(p);
case 'init': return this.onInitResult(p);
case 'query': return this.onQueryPacket(p);
default:
this.err(`unexpected packet in state ${this.state}`);
}
}
// -- handshake -------------------------------------------------------------
onHandshake(p) {
const r = new Reader(p);
const protocol = r.u8();
if (protocol === 0xff) return this.fail(this.readError(p).message);
if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`);
this.serverVersion = r.nulStr();
r.u32(); // connection id
const scramble1 = r.bytes(8);
r.skip(1); // filler
let caps = r.u16();
let scramble2 = Buffer.alloc(0);
let plugin = 'mysql_native_password';
if (r.remaining() > 0) {
r.u8(); // charset
r.u16(); // status flags
caps |= r.u16() << 16;
const authDataLen = r.u8();
r.skip(10);
if (caps & CAP.SECURE_CONNECTION) {
const n = Math.max(13, authDataLen - 8);
scramble2 = r.bytes(n);
// trailing NUL is part of the padding, not the scramble
if (scramble2.length && scramble2[scramble2.length - 1] === 0) {
scramble2 = scramble2.subarray(0, scramble2.length - 1);
}
}
if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr();
}
this.serverCaps = caps >>> 0;
this.scramble = Buffer.concat([scramble1, scramble2]);
const resp = authResponse(plugin, this.opts.password, this.scramble);
if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`);
const w = new Writer();
w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23);
w.nulStr(this.opts.user);
w.lenencBuf(resp);
w.nulStr(this.opts.database);
w.nulStr(plugin);
this.authPlugin = plugin;
this.state = 'auth';
this.send(w.done());
}
onAuthResult(p) {
const marker = p[0];
if (marker === 0x00) return this.afterAuth();
if (marker === 0xff) return this.fail(this.readError(p).message);
if (marker === 0xfe) { // AuthSwitchRequest
const r = new Reader(p); r.u8();
const plugin = r.nulStr();
let scramble = r.bytes(r.remaining());
if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1);
const resp = authResponse(plugin, this.opts.password, scramble);
if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`);
this.scramble = scramble;
this.authPlugin = plugin;
return this.send(resp);
}
if (marker === 0x01) { // caching_sha2 extra auth data
const status = p[1];
if (status === 0x03) return; // fast auth ok -> OK packet follows
if (status === 0x04) {
return this.fail(
'caching_sha2_password requires a full authentication exchange (TLS or the ' +
'server public key), which this driver does not implement. Give the database ' +
"user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password."
);
}
return this.fail(`unexpected auth continuation 0x${status.toString(16)}`);
}
this.fail(`unexpected auth response 0x${marker.toString(16)}`);
}
// Pin session settings, then announce readiness.
afterAuth() {
this.retryDelay = 1000;
this.state = 'init';
this.sendCommand(
COM.QUERY,
"SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " +
"time_zone='+00:00', autocommit=1"
);
}
onInitResult(p) {
if (p[0] === 0xff) return this.fail(this.readError(p).message);
this.state = 'ready';
this.busy = false;
this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`);
if (this.onIdle) this.onIdle(this);
}
// -- errors ----------------------------------------------------------------
readError(p) {
const r = new Reader(p);
r.u8();
const code = r.u16();
let sqlState = '';
if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); }
const message = p.subarray(r.o).toString('utf8');
const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`);
e.errno = code; e.sqlState = sqlState;
return e;
}
readOk(p) {
const r = new Reader(p);
r.u8();
const affectedRows = r.lenenc() || 0;
const insertId = r.lenenc() || 0;
return { affectedRows, insertId };
}
isEof(p) { return p[0] === 0xfe && p.length < 9; }
// -- queries ---------------------------------------------------------------
query(sql) {
return new Promise((resolve, reject) => {
if (this.state !== 'ready') return reject(new Error('connection not ready'));
this.busy = true;
this.state = 'query';
this.pending = { resolve, reject, sql };
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.QUERY, sql);
});
}
finish(value, error) {
const p = this.pending;
this.pending = null;
this.rs = null;
this.state = 'ready';
this.busy = false;
if (p) { error ? p.reject(error) : p.resolve(value); }
if (this.onIdle) this.onIdle(this);
}
onQueryPacket(p) {
const rs = this.rs;
if (rs.stage === 'head') {
if (p[0] === 0x00) return this.finish(this.readOk(p));
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled'));
const r = new Reader(p);
rs.colCount = r.lenenc();
rs.stage = 'columns';
return;
}
if (rs.stage === 'columns') {
if (this.isEof(p)) { rs.stage = 'rows'; return; }
const r = new Reader(p);
r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table
const name = r.lenencStr();
r.lenencStr(); // org_name
r.lenenc(); // length of fixed fields
r.u16(); // charset
r.u32(); // column length
const type = r.u8();
rs.columns.push({ name, type });
return;
}
// stage === 'rows'
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (this.isEof(p)) return this.finish(rs.rows);
const r = new Reader(p);
const row = {};
for (let i = 0; i < rs.colCount; i++) {
const col = rs.columns[i];
const raw = r.lenencStr();
row[col.name] = raw === null ? null : coerce(raw, col.type);
}
rs.rows.push(row);
}
ping() {
if (this.state !== 'ready') return;
this.busy = true;
this.state = 'query';
this.pending = {
resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING',
};
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.PING, '');
}
}
// Text protocol hands us strings; give Lua sensible types back.
function coerce(raw, type) {
if (T_INT.has(type)) {
const n = Number(raw);
return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings
}
if (T_FLOAT.has(type)) {
const n = Number(raw);
return Number.isNaN(n) ? raw : n;
}
return raw; // strings, dates, JSON, blobs
}
// ---------------------------------------------------------------------------
// Pool
// ---------------------------------------------------------------------------
class Pool {
constructor(opts) {
this.opts = opts;
this.queue = [];
this.conns = [];
for (let i = 0; i < opts.size; i++) {
const c = new Connection(opts, i + 1);
c.onIdle = () => this.pump();
this.conns.push(c);
}
}
start() {
this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120));
setInterval(() => {
for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping();
}, 30000);
}
get ready() { return this.conns.some((c) => c.state === 'ready'); }
free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); }
pump() {
while (this.queue.length) {
const conn = this.free();
if (!conn) return;
const job = this.queue.shift();
clearTimeout(job.timer);
if (job.settled) continue;
job.settled = true;
conn.query(job.sql).then(job.resolve, job.reject);
}
}
exec(sql) {
return new Promise((resolve, reject) => {
const job = { sql, resolve, reject, settled: false };
// Each job owns its own deadline, so a query still fails loudly when the
// database is down and no connection ever becomes free to trigger pump().
job.timer = setTimeout(() => {
if (job.settled) return;
job.settled = true;
const i = this.queue.indexOf(job);
if (i >= 0) this.queue.splice(i, 1);
reject(new Error('timed out waiting for a database connection'));
}, this.opts.queueTimeout);
this.queue.push(job);
this.pump();
});
}
// Pins one connection for the whole transaction so no other query can
// interleave between START TRANSACTION and COMMIT.
async transaction(statements) {
const held = await this.acquire();
try {
await held.query('START TRANSACTION');
for (const st of statements) await held.query(st);
await held.query('COMMIT');
return true;
} catch (e) {
try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ }
throw e;
} finally {
held.release();
}
}
acquire() {
return new Promise((resolve, reject) => {
const started = Date.now();
const attempt = () => {
const conn = this.free();
if (conn) {
conn.pinned = true;
return resolve({
query: (sql) => conn.query(sql),
release: () => { conn.pinned = false; this.pump(); },
});
}
if (Date.now() - started > this.opts.queueTimeout) {
return reject(new Error('timed out waiting for a database connection'));
}
setTimeout(attempt, 25);
};
attempt();
});
}
}
// ---------------------------------------------------------------------------
// FiveM bridge - exposes the pool to the Lua resources as exports.
// ---------------------------------------------------------------------------
// Lua cannot put nil inside a table without creating a hole, so callers pass
// this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL.
const NULL_SENTINEL = 'RP_NULL';
function unsentinel(v) { return v === NULL_SENTINEL ? null : v; }
function normalizeParams(p) {
if (p === null || p === undefined) return [];
if (Array.isArray(p)) return p.map(unsentinel);
if (typeof p === 'object') {
const keys = Object.keys(p);
if (keys.length === 0) return []; // Lua {} arrives as an empty map
if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table
return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k]));
}
return [p]; // a real object -> JSON column
}
return [unsentinel(p)];
}
const pool = new Pool({
host: GetConvar('rp_db_host', '127.0.0.1'),
port: parseInt(GetConvar('rp_db_port', '3306'), 10),
user: GetConvar('rp_db_user', 'rp'),
password: GetConvar('rp_db_pass', ''),
database: GetConvar('rp_db_name', 'rp'),
size: parseInt(GetConvar('rp_db_pool', '4'), 10),
queueTimeout: 20000,
});
// Run a query and hand the result to a Lua callback as (result, err).
// The SQL *template* is logged on failure, never the formatted text, so bound
// values (password hashes and the like) stay out of the console.
function run(sql, params, cb, shape) {
const done = (result, err) => {
if (err) {
console.log(`^1[rp_db]^7 query failed: ${err}`);
console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`);
}
if (typeof cb === 'function') {
try { cb(result, err || null); }
catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); }
}
};
let text;
try { text = format(sql, normalizeParams(params)); }
catch (e) { return done(null, e.message); }
pool.exec(text).then(
(res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } },
(e) => done(null, e.message)
);
}
const rowsOf = (res) => (Array.isArray(res) ? res : []);
global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r));
global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null));
global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => {
const row = rowsOf(r)[0];
if (!row) return null;
const k = Object.keys(row)[0];
return k === undefined ? null : row[k];
}));
global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0));
global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0));
// statements: array of {query = ..., values = {...}} tables from Lua
global.exports('transaction', (statements, cb) => {
let list;
try {
const arr = Array.isArray(statements) ? statements : Object.values(statements || {});
list = arr.map((s) => format(s.query, normalizeParams(s.values)));
} catch (e) {
console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
return;
}
pool.transaction(list).then(
() => { if (typeof cb === 'function') cb(true, null); },
(e) => {
console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
}
);
});
global.exports('isReady', () => pool.ready);
global.exports('nullSentinel', () => NULL_SENTINEL);
pool.start();
+11
View File
@@ -0,0 +1,11 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_dbtest'
description 'Self-test for the rp_db driver. Not started in production.'
version '1.0.0'
server_scripts {
'@rp_db/lib/db.lua',
'server/test.lua',
}
+122
View File
@@ -0,0 +1,122 @@
-- Self-test for the hand-written MySQL driver. Run with: ensure rp_dbtest
local passed, failed = 0, 0
local function check(name, ok, detail)
if ok then
passed = passed + 1
print(('^2 PASS^7 %s'):format(name))
else
failed = failed + 1
print(('^1 FAIL^7 %s^1 %s^7'):format(name, detail or ''))
end
end
CreateThread(function()
print('^5[dbtest]^7 waiting for the pool...')
if not DB.WaitReady(30000) then
print('^1[dbtest] database never became ready^7')
return
end
-- 1. round trip through the text protocol -------------------------------
local two = DB.Scalar('SELECT 1 + 1')
check('scalar arithmetic', two == 2, ('got %s (%s)'):format(tostring(two), type(two)))
local row = DB.Single('SELECT ? AS s, ? AS n, ? AS f', { 'hello', 42, 1.5 })
check('typed columns', row and row.s == 'hello' and row.n == 42 and row.f == 1.5,
row and json.encode(row) or 'nil')
-- 2. hostile strings must survive verbatim ------------------------------
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
local nasty = [[O'Brien \ "quoted" ]] .. '\n\t' .. [[ 100% ` ; -- /* */ ?]]
local id = DB.Insert(
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
{ 'ztest_a', nasty })
check('insert returns id', type(id) == 'number' and id > 0, tostring(id))
local back = DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id })
check('hostile string round trip', back == nasty,
('stored %q'):format(tostring(back)))
-- 3. a parameter can never become SQL -----------------------------------
local inject = "'); DROP TABLE accounts; -- "
DB.Insert('INSERT INTO accounts (username, password_hash) VALUES (?, ?)',
{ 'ztest_b', inject })
local stillThere = DB.Scalar(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'accounts'")
check('injection is inert', stillThere == 1, 'accounts table missing!')
local storedInject = DB.Scalar("SELECT password_hash FROM accounts WHERE username = 'ztest_b'")
check('injection stored literally', storedInject == inject, tostring(storedInject))
-- 4. question marks inside literals must not shift binding ---------------
local q = DB.Single("SELECT 'what? really?' AS lit, ? AS bound", { 'bound-value' })
check('literal ? not treated as placeholder',
q and q.lit == 'what? really?' and q.bound == 'bound-value',
q and json.encode(q) or 'nil')
-- 5. NULL handling -------------------------------------------------------
DB.Update('UPDATE accounts SET last_ip = ? WHERE id = ?', { DB.NULL, id })
local isNull = DB.Scalar('SELECT last_ip IS NULL FROM accounts WHERE id = ?', { id })
check('DB.NULL writes a real NULL', isNull == 1, tostring(isNull))
local nilRow = DB.Single('SELECT last_ip FROM accounts WHERE id = ?', { id })
check('NULL reads back as nil', nilRow ~= nil and nilRow.last_ip == nil,
nilRow and json.encode(nilRow) or 'nil')
-- 6. unicode -------------------------------------------------------------
local uni = 'Ünïcødé — 日本語 — 🚓'
DB.Update('UPDATE accounts SET password_hash = ? WHERE id = ?', { uni, id })
check('utf8mb4 round trip', DB.Scalar('SELECT password_hash FROM accounts WHERE id = ?', { id }) == uni)
-- 7. empty result sets ---------------------------------------------------
local none = DB.Query('SELECT * FROM accounts WHERE id = ?', { -1 })
check('empty select returns empty table', type(none) == 'table' and #none == 0)
check('missing single returns nil', DB.Single('SELECT * FROM accounts WHERE id = ?', { -1 }) == nil)
-- 8. transactions --------------------------------------------------------
local ok = DB.Transaction({
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'admin', id } },
{ query = 'UPDATE accounts SET banned = ? WHERE id = ?', values = { 1, id } },
})
local after = DB.Single('SELECT role, banned FROM accounts WHERE id = ?', { id })
check('transaction commits', ok and after.role == 'admin' and after.banned == 1,
json.encode(after or {}))
local bad, err = DB.Transaction({
{ query = 'UPDATE accounts SET role = ? WHERE id = ?', values = { 'mod', id } },
{ query = 'UPDATE accounts SET nonexistent_column = 1 WHERE id = ?', values = { id } },
})
local rolled = DB.Scalar('SELECT role FROM accounts WHERE id = ?', { id })
check('failed transaction rolls back', bad == false and rolled == 'admin',
('role is now %s'):format(tostring(rolled)))
-- 9. errors surface instead of failing silently --------------------------
local res, qerr = DB.Query('SELECT * FROM table_that_does_not_exist')
check('bad query returns an error', res == nil and type(qerr) == 'string' and #qerr > 0,
tostring(qerr))
-- 10. wrong parameter count is caught, not guessed -----------------------
local _, cerr = DB.Query('SELECT ?, ?', { 1 })
check('parameter count mismatch reported', cerr ~= nil, tostring(cerr))
-- 11. a large payload exercises multi-packet framing in both directions --
local big = string.rep('x', 700000)
check('700KB payload round trip', DB.Scalar('SELECT ? AS big', { big }) == big)
-- 12. concurrency: many queries in flight across the pool ----------------
local done, results = 0, {}
for i = 1, 40 do
DB.ScalarAsync('SELECT ?', { i }, function(v) done = done + 1; results[i] = v end)
end
local waited = 0
while done < 40 and waited < 10000 do Wait(50); waited = waited + 50 end
local allGood = done == 40
for i = 1, 40 do if results[i] ~= i then allGood = false end end
check('40 concurrent queries all answered correctly', allGood, ('done=%d'):format(done))
DB.Update("DELETE FROM accounts WHERE username LIKE 'ztest%'")
print(('^5[dbtest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
if failed == 0 then print('^2[dbtest] driver OK^7') else print('^1[dbtest] DRIVER HAS PROBLEMS^7') end
end)
+27
View File
@@ -0,0 +1,27 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_loading'
description 'Loading screen: procedural night-city flyover, real load progress, synthesised ambience'
author 'Los Santos RP'
version '1.0.0'
loadscreen 'html/index.html'
-- The screen stays up after the game has finished loading so the client can
-- hand over to the in-game camera without a black frame in between.
loadscreen_manual_shutdown 'yes'
-- Needed for the volume fader to be usable.
loadscreen_cursor 'yes'
files {
'html/index.html',
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
+325
View File
@@ -0,0 +1,325 @@
/* ---------------------------------------------------------------------------
Loading screen.
Layout: the city runs full bleed; the record sits in a rail on the left,
held there by a gradient scrim rather than a panel, so the two never feel
like separate windows.
--------------------------------------------------------------------------- */
#sky, .grain, .scrim, .vignette {
position: fixed;
inset: 0;
pointer-events: none;
}
#sky { width: 100%; height: 100%; display: block; }
.scrim {
background: linear-gradient(
90deg,
rgba(8, 9, 11, 0.97) 0%,
rgba(8, 9, 11, 0.93) 34%,
rgba(8, 9, 11, 0.72) 58%,
rgba(8, 9, 11, 0.15) 82%,
rgba(8, 9, 11, 0) 100%
);
}
.vignette {
background: radial-gradient(120% 90% at 62% 45%, transparent 40%, rgba(0, 0, 0, 0.55) 100%);
}
/* film grain, drawn once as an SVG turbulence tile */
.grain {
opacity: 0.16;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grainshift 640ms steps(2) infinite;
}
@keyframes grainshift {
0% { transform: translate(0, 0); }
50% { transform: translate(-3px, 2px); }
100% { transform: translate(2px, -2px); }
}
/* --- rail ---------------------------------------------------------------- */
.rail {
position: relative;
z-index: 2;
width: var(--sheet);
height: 100%;
padding: var(--gut-y) var(--gut-x);
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: clamp(20px, 2.4vh, 34px);
}
/* staged entrance; each element declares its own delay */
.reveal {
opacity: 0;
transform: translateY(10px);
animation: rise 900ms var(--ease-out) forwards;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
to { opacity: 1; transform: none; }
}
/* --- file header --------------------------------------------------------- */
.file {
border-top: 1px solid rgba(217,178,60,.42);
padding-top: 14px;
}
.file .serial {
margin-top: 7px;
color: rgba(233,226,204,.62);
}
.file .serial span { color: var(--canary); }
/* --- wordmark ------------------------------------------------------------ */
.brand { align-self: end; }
.wordmark {
font-size: clamp(46px, 5.2vw, 82px);
font-weight: 800;
line-height: 0.86;
letter-spacing: 0.3em;
text-indent: 0.3em; /* keeps the wide tracking optically centred */
opacity: 0;
animation: settle 1500ms var(--ease-out) 180ms forwards;
}
/* the name tightens into place, like a stamp being pressed */
@keyframes settle {
0% { opacity: 0; letter-spacing: 0.3em; text-indent: 0.3em; filter: blur(6px); }
60% { opacity: 1; }
100% { opacity: 1; letter-spacing: -0.028em; text-indent: 0; filter: blur(0); }
}
.tagrow {
display: flex;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.tagline {
font-family: 'Plex Mono', monospace;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.42em;
color: var(--canary);
text-transform: uppercase;
}
.dash {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(217,178,60,.42), transparent);
}
/* --- notices ------------------------------------------------------------- */
.notices { align-self: center; }
.notice-head {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid rgba(233,226,204,.14);
}
.notice-index { color: rgba(233,226,204,.40); }
.notice { padding-top: 18px; min-height: 132px; }
.notice-title {
font-size: 21px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 8px;
}
.notice-body {
font-size: 14.5px;
color: rgba(233,226,204,.62);
max-width: 40ch;
}
.notice.swap .notice-title,
.notice.swap .notice-body { animation: none; }
.notice-title, .notice-body {
animation: noticein 620ms var(--ease-out) both;
}
.notice-body { animation-delay: 70ms; }
@keyframes noticein {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: none; }
}
/* --- progress ------------------------------------------------------------ */
.load { align-self: end; }
.loadhead {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 11px;
gap: 16px;
}
.step {
color: rgba(233,226,204,.62);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pct {
font-family: 'Plex Mono', monospace;
font-weight: 500;
font-size: 28px;
line-height: 1;
color: var(--stock-hi);
font-variant-numeric: tabular-nums;
}
.pct i {
font-style: normal;
font-size: 13px;
color: rgba(233,226,204,.40);
margin-left: 2px;
}
.track {
position: relative;
height: 2px;
background: rgba(233,226,204,.14);
overflow: hidden;
}
.bar {
display: block;
height: 100%;
width: 0%;
background: var(--canary);
transition: width 420ms var(--ease);
}
/* a light runs ahead of the fill so the bar reads as active even when the
game stalls on a long step */
.bar::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 64px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 159, 69, 0.85));
animation: crawl 1900ms linear infinite;
}
@keyframes crawl {
0% { transform: translateX(-64px); opacity: 0; }
35% { opacity: 1; }
100% { transform: translateX(0); opacity: 0; }
}
/* --- volume fader -------------------------------------------------------- */
.fader {
display: flex;
align-items: center;
gap: 14px;
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid rgba(233,226,204,.07);
}
.mute {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid rgba(233,226,204,.14);
color: rgba(233,226,204,.62);
cursor: pointer;
transition: color 180ms var(--ease), border-color 180ms var(--ease), transform 120ms var(--ease);
}
.mute:hover { color: var(--canary); border-color: rgba(217,178,60,.42); }
.mute:active { transform: translateY(1px); }
.mute.off #wave { opacity: 0.18; }
.mute.off { color: rgba(233,226,204,.40); }
input[type='range'] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 22px;
background: transparent;
cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
height: 2px;
background: rgba(233,226,204,.14);
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 3px;
height: 15px;
margin-top: -6.5px;
background: var(--canary);
border: 0;
border-radius: 0;
transition: height 160ms var(--ease), margin-top 160ms var(--ease);
}
input[type='range']:hover::-webkit-slider-thumb,
input[type='range']:focus-visible::-webkit-slider-thumb {
height: 21px;
margin-top: -9.5px;
}
.volval { width: 3ch; text-align: right; color: rgba(233,226,204,.40); }
.audiohint {
margin-top: 10px;
color: var(--canary);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.45; } }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Once the world is ready the rail steps aside and reveals the live camera.
The page background has to go transparent too, otherwise the fade reveals
nothing but our own backdrop colour. */
body.handover, body.handover .rail { background: transparent !important; }
body.handover .scrim { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover #sky { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .grain { transition: opacity 1400ms var(--ease); opacity: 0; }
body.handover .rail { transition: opacity 900ms var(--ease), transform 1200ms var(--ease); opacity: 0; transform: translateY(-12px); }
/* ---------------------------------------------------------------------------
The loading screen is the only screen with no paper on it, so the three
voices have to be restated in their light-on-dark values.
--------------------------------------------------------------------------- */
.eyebrow { color: rgba(233,226,204,.42); }
.data { color: rgba(233,226,204,.60); }
body { color: var(--stock-hi); }
/* The posted notice is a real notice: a slip of the same stock the rest of the
product is printed on, taped to the window of a city that is still loading. */
.notice {
background: var(--stock);
color: var(--ink);
padding: 15px 17px 16px;
box-shadow: var(--lift-1);
border-left: 3px solid var(--canary);
}
.notice-head { border-bottom: 1px solid var(--rule); padding-bottom: 7px; margin-bottom: 9px; }
.notice-index { color: var(--ink-3); }
.notice-title { color: var(--ink); }
.notice-body { color: var(--ink-2); }
+567
View File
@@ -0,0 +1,567 @@
/* ---------------------------------------------------------------------------
Loading screen behaviour.
Three jobs:
1. draw the city - a procedural night skyline flown over on canvas
2. report progress - bound to the load events the game actually emits
3. play ambience - synthesised in the browser, nothing is downloaded
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
/* =========================================================================
1. The city
========================================================================= */
/* Small deterministic PRNG so the skyline is stable across a resize. */
function rng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const SKY = {
canvas: $('sky'),
ctx: null,
layers: [],
w: 0,
h: 0,
dpr: 1,
t0: performance.now(),
};
/* Depth layers, far to near. Every layer is darker than the hazy sky behind
it, which is what makes a silhouette read at all; the far ones are lifted
towards the sky colour to stand in for atmosphere. `base` is the ground line
as a fraction of the viewport, so near towers rise right through the frame.
The difference in scroll rate is what reads as flight. */
const LAYER_SPEC = [
{ depth: 0.00, speed: 4, minW: 28, maxW: 66, minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 },
{ depth: 0.25, speed: 11, minW: 36, maxW: 90, minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 },
{ depth: 0.50, speed: 24, minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 },
{ depth: 0.75, speed: 50, minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 },
{ depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 },
];
function buildLayer(spec, index, w, h) {
const cv = document.createElement('canvas');
cv.width = Math.ceil(w * 2);
cv.height = h;
const c = cv.getContext('2d');
const rand = rng(9173 + index * 7717);
const base = h * spec.base;
const towers = [];
let x = -60;
while (x < cv.width + 60) {
const bw = spec.minW + rand() * (spec.maxW - spec.minW);
const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH));
const top = base - bh;
c.fillStyle = spec.tone;
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top));
/* a hairline of sodium bounce along the roof edge */
if (rand() < 0.55) {
c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`;
c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1);
}
/* windows */
const cell = 6 + spec.depth * 8;
const pad = Math.max(2, cell * 0.34);
for (let wy = top + pad * 2; wy < base - pad; wy += cell) {
for (let wx = x + pad; wx < x + bw - pad; wx += cell) {
if (rand() > spec.lit) continue;
const warm = rand();
/* mostly sodium/tungsten, a few cold offices; distant windows are
dimmed so they read as depth rather than as static */
const col = warm < 0.72
? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})`
: `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`;
c.fillStyle = col;
c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42));
}
}
/* tall towers get an aviation light */
if (bh > h * 0.5 && rand() < 0.5) {
towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 });
}
x += bw + 2 + rand() * 16;
}
return { cv, spec, towers, base };
}
function resizeSky() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
SKY.dpr = dpr;
SKY.w = window.innerWidth;
SKY.h = window.innerHeight;
SKY.canvas.width = Math.floor(SKY.w * dpr);
SKY.canvas.height = Math.floor(SKY.h * dpr);
SKY.ctx = SKY.canvas.getContext('2d');
SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h));
}
function drawSky(now) {
const { ctx, w, h } = SKY;
if (!ctx) return;
const t = (now - SKY.t0) / 1000;
/* Sky: light pollution rather than stars. It has to stay brighter than the
buildings all the way down, otherwise there is no silhouette to see. */
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0.00, '#080b14');
g.addColorStop(0.34, '#121627');
g.addColorStop(0.60, '#23202f');
g.addColorStop(0.78, '#412c27');
g.addColorStop(0.90, '#6b4527');
g.addColorStop(1.00, '#8a5726');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
/* the haze dome over downtown */
const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85);
halo.addColorStop(0, 'rgba(255,159,69,0.34)');
halo.addColorStop(0.45, 'rgba(255,140,60,0.12)');
halo.addColorStop(1, 'rgba(255,140,60,0)');
ctx.fillStyle = halo;
ctx.fillRect(0, 0, w, h);
/* a slow descent: everything drifts down and grows a touch over time */
const descend = Math.sin(t * 0.06) * 10 + t * 0.9;
for (const layer of SKY.layers) {
const { cv, spec } = layer;
const span = cv.width / 2;
let ox = -((t * spec.speed) % span);
const oy = descend * (0.25 + spec.depth * 0.9);
ctx.globalAlpha = 1;
ctx.drawImage(cv, ox, oy, cv.width, h);
/* aviation lights blink on their own phase */
for (const tw of layer.towers) {
const blink = Math.sin(t * 1.5 + tw.phase);
if (blink < 0.72) continue;
const px = tw.x + ox;
const py = tw.y + oy - h * 0.06;
ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`;
ctx.beginPath();
ctx.arc(px, py, 1.6, 0, Math.PI * 2);
ctx.fill();
if (px > span) {
ctx.beginPath();
ctx.arc(px - span, py, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
/* Aerial perspective: a warm veil laid over everything drawn so far, so
each nearer layer sits in front of progressively more atmosphere. */
if (spec.haze > 0) {
ctx.fillStyle = `rgba(104,72,50,${spec.haze})`;
ctx.fillRect(0, 0, w, h);
}
}
ctx.globalAlpha = 1;
}
/* =========================================================================
2. Progress, bound to the game's own load events
========================================================================= */
const Progress = {
shown: 0,
target: 0,
step: 'Waiting for the game',
set(fraction, step) {
if (typeof fraction === 'number' && isFinite(fraction)) {
/* never let it walk backwards - it reads as a fault */
this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction)));
}
if (step) this.step = step;
},
tick() {
/* ease towards the real figure so long steps still feel alive */
this.shown += (this.target - this.shown) * 0.08;
const pct = Math.min(100, Math.round(this.shown * 100));
$('bar').style.width = (this.shown * 100).toFixed(2) + '%';
$('pct').firstChild.nodeValue = String(pct);
if ($('step').textContent !== this.step) $('step').textContent = this.step;
},
};
/* Turns the game's internal init-function names into something a player can read. */
const STEP_NAMES = {
MAP: 'Loading the map',
BEFORE_MAP_LOADED: 'Preparing the world',
AFTER_MAP_LOADED: 'Placing the world',
SESSION_INIT: 'Joining the session',
INIT_BEFORE_MAP_LOADED: 'Starting up',
INIT_AFTER_MAP_LOADED: 'Finishing up',
INIT_SESSION: 'Joining the session',
};
window.addEventListener('message', (event) => {
const d = event.data || {};
switch (d.eventName) {
case 'loadProgress':
Progress.set(d.loadFraction);
break;
case 'startInitFunction':
Progress.set(null, STEP_NAMES[d.type] || 'Starting up');
break;
case 'initFunctionInvoking':
if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) {
Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`);
}
break;
case 'startDataFileEntries':
Progress.set(null, `Streaming ${d.count} asset packs`);
break;
case 'performMapLoadFunction':
Progress.set(null, 'Building the map');
break;
case 'startWarning':
case 'onLogLine':
if (d.message) Progress.set(null, String(d.message).slice(0, 70));
break;
/* --- our own messages, sent from rp_ui once the client is running --- */
case 'rp:rules':
if (Array.isArray(d.rules) && d.rules.length) {
Notices.load(d.rules);
}
break;
case 'rp:server':
if (d.name) document.querySelector('.wordmark').textContent = d.name;
if (d.serial) $('serial').textContent = d.serial;
if (d.status) $('hostcount').textContent = d.status;
break;
case 'rp:progress':
Progress.set(d.fraction, d.step);
break;
/* the world is ready: dissolve into the live camera behind us */
case 'rp:handover':
Progress.set(1, 'Ready');
document.body.classList.add('handover');
break;
}
});
/* =========================================================================
3. Posted notices
========================================================================= */
const Notices = {
items: [
{ title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
{ title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
{ title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
{ title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' },
{ title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
],
i: 0,
timer: null,
load(items) {
this.items = items;
this.i = 0;
this.render();
this.schedule();
},
render() {
const item = this.items[this.i % this.items.length];
const title = $('noticeTitle');
const body = $('noticeBody');
/* restart the entry animation by reflowing the nodes */
title.style.animation = 'none';
body.style.animation = 'none';
void title.offsetWidth;
title.style.animation = '';
body.style.animation = '';
title.textContent = item.title;
body.textContent = item.body;
$('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`;
},
schedule() {
clearInterval(this.timer);
this.timer = setInterval(() => {
this.i += 1;
this.render();
}, 7600);
},
};
/* =========================================================================
4. Ambience - synthesised, never downloaded
========================================================================= */
class Ambience {
constructor() {
this.ctx = null;
this.master = null;
this.volume = 0.45;
this.muted = false;
this.started = false;
}
/* A short noise burst with exponential decay makes a serviceable hall. */
makeImpulse(seconds, decay) {
const rate = this.ctx.sampleRate;
const len = Math.floor(rate * seconds);
const buf = this.ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const data = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buf;
}
noiseBuffer(seconds) {
const rate = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, rate * seconds, rate);
const d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < d.length; i++) {
/* brown-ish noise: closer to distant traffic than white hiss */
const white = Math.random() * 2 - 1;
last = (last + 0.02 * white) / 1.02;
d[i] = last * 3.2;
}
return buf;
}
start() {
if (this.started) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor();
this.started = true;
const ctx = this.ctx;
this.master = ctx.createGain();
this.master.gain.value = 0;
this.master.connect(ctx.destination);
const verb = ctx.createConvolver();
verb.buffer = this.makeImpulse(4.2, 2.6);
const verbGain = ctx.createGain();
verbGain.gain.value = 0.55;
verb.connect(verbGain).connect(this.master);
const dry = ctx.createGain();
dry.gain.value = 0.75;
dry.connect(this.master);
const bus = ctx.createGain();
bus.connect(dry);
bus.connect(verb);
/* --- the pad: a minor triad that never quite resolves --------------- */
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
lp.Q.value = 0.7;
lp.connect(bus);
const lfo = ctx.createOscillator();
lfo.frequency.value = 0.031;
const lfoAmt = ctx.createGain();
lfoAmt.gain.value = 210;
lfo.connect(lfoAmt).connect(lp.frequency);
lfo.start();
[55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => {
const osc = ctx.createOscillator();
osc.type = i % 2 ? 'sine' : 'triangle';
osc.frequency.value = freq;
osc.detune.value = (i - 2) * 4;
const g = ctx.createGain();
g.gain.value = 0.16 / (1 + i * 0.35);
/* each voice breathes on its own slow cycle */
const breath = ctx.createOscillator();
breath.frequency.value = 0.017 + i * 0.009;
const breathAmt = ctx.createGain();
breathAmt.gain.value = g.gain.value * 0.6;
breath.connect(breathAmt).connect(g.gain);
breath.start();
osc.connect(g).connect(lp);
osc.start();
});
/* --- distant traffic ------------------------------------------------ */
const noise = ctx.createBufferSource();
noise.buffer = this.noiseBuffer(8);
noise.loop = true;
const nf = ctx.createBiquadFilter();
nf.type = 'bandpass';
nf.frequency.value = 240;
nf.Q.value = 0.55;
const ng = ctx.createGain();
ng.gain.value = 0.09;
noise.connect(nf).connect(ng).connect(bus);
noise.start();
const wind = ctx.createOscillator();
wind.frequency.value = 0.043;
const windAmt = ctx.createGain();
windAmt.gain.value = 130;
wind.connect(windAmt).connect(nf.frequency);
wind.start();
/* --- a siren, far away, every now and then -------------------------- */
const siren = () => {
if (!this.ctx || this.ctx.state === 'closed') return;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.type = 'triangle';
const g = ctx.createGain();
const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
g.gain.value = 0;
o.frequency.setValueAtTime(620, t);
o.frequency.linearRampToValueAtTime(880, t + 0.7);
o.frequency.linearRampToValueAtTime(620, t + 1.4);
g.gain.linearRampToValueAtTime(0.014, t + 0.6);
g.gain.linearRampToValueAtTime(0.0, t + 2.6);
if (pan) {
pan.pan.value = Math.random() * 1.6 - 0.8;
o.connect(g).connect(pan).connect(verb);
} else {
o.connect(g).connect(verb);
}
o.start(t);
o.stop(t + 2.8);
setTimeout(siren, 24000 + Math.random() * 40000);
};
setTimeout(siren, 12000 + Math.random() * 12000);
this.applyVolume(1.8);
}
/* perceptual curve: a linear fader sounds wrong */
applyVolume(rampSeconds) {
if (!this.master) return;
const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9;
const now = this.ctx.currentTime;
this.master.gain.cancelScheduledValues(now);
this.master.gain.setValueAtTime(this.master.gain.value, now);
this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12));
}
setVolume(v) {
this.volume = Math.min(1, Math.max(0, v));
if (this.volume > 0) this.muted = false;
this.applyVolume();
}
toggleMute() {
this.muted = !this.muted;
this.applyVolume();
return this.muted;
}
}
const ambience = new Ambience();
function wireAudio() {
const vol = $('vol');
const volval = $('volval');
const mute = $('mute');
const hint = $('audiohint');
const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10);
vol.value = String(isFinite(saved) ? saved : 45);
volval.textContent = vol.value;
ambience.volume = Number(vol.value) / 100;
const tryStart = () => {
ambience.start();
if (ambience.ctx && ambience.ctx.state === 'suspended') {
hint.hidden = false;
return false;
}
hint.hidden = true;
return true;
};
/* Autoplay may be blocked; the first input of any kind releases it. */
if (!tryStart()) {
const release = () => {
if (ambience.ctx) ambience.ctx.resume();
hint.hidden = true;
window.removeEventListener('pointerdown', release);
window.removeEventListener('keydown', release);
};
window.addEventListener('pointerdown', release);
window.addEventListener('keydown', release);
}
vol.addEventListener('input', () => {
volval.textContent = vol.value;
ambience.setVolume(Number(vol.value) / 100);
mute.classList.toggle('off', Number(vol.value) === 0);
window.localStorage.setItem('rp.volume', vol.value);
});
mute.addEventListener('click', () => {
const muted = ambience.toggleMute();
mute.classList.toggle('off', muted);
});
}
/* =========================================================================
boot
========================================================================= */
function serial() {
const n = Math.floor(Math.random() * 9000 + 1000);
const m = Math.floor(Math.random() * 900 + 100);
return `${new Date().getFullYear()}-${n}${m}`;
}
function frame(now) {
drawSky(now);
Progress.tick();
requestAnimationFrame(frame);
}
window.addEventListener('resize', resizeSky);
document.addEventListener('DOMContentLoaded', () => {
$('serial').textContent = serial();
resizeSky();
Notices.render();
Notices.schedule();
wireAudio();
requestAnimationFrame(frame);
});
Binary file not shown.
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Los Santos Roleplay</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
</head>
<body>
<!-- procedural night-city flyover; no external assets -->
<canvas id="sky" aria-hidden="true"></canvas>
<div class="grain" aria-hidden="true"></div>
<div class="scrim" aria-hidden="true"></div>
<div class="vignette" aria-hidden="true"></div>
<main class="rail">
<header class="file reveal" style="--d:0ms">
<div class="eyebrow">City of Los Santos &middot; Office of Records</div>
<div class="serial data">
FILE <span id="serial">0000-0000</span> &nbsp;/&nbsp; RESIDENCY APPLICATION
</div>
</header>
<section class="brand">
<h1 class="wordmark" id="wordmark">LOS SANTOS</h1>
<div class="tagrow reveal" style="--d:520ms">
<span class="tagline">ROLEPLAY</span>
<span class="dash" aria-hidden="true"></span>
<span class="data" id="hostcount">Connecting</span>
</div>
</section>
<section class="notices reveal" style="--d:760ms" aria-live="polite">
<div class="notice-head">
<span class="eyebrow">Posted notice</span>
<span class="notice-index data" id="noticeIndex">1 / 5</span>
</div>
<div class="notice" id="notice">
<h2 class="notice-title" id="noticeTitle">Stay in character</h2>
<p class="notice-body" id="noticeBody">Loading the city.</p>
</div>
</section>
<footer class="load reveal" style="--d:940ms">
<div class="loadhead">
<span class="step data" id="step">Waiting for the game</span>
<span class="pct" id="pct">0<i>%</i></span>
</div>
<div class="track"><i class="bar" id="bar"></i></div>
<div class="fader">
<button class="mute" id="mute" type="button" aria-label="Mute ambience">
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
<path id="spk" d="M3 6h2.5L9 3v10L5.5 10H3z" fill="currentColor"/>
<path id="wave" d="M11 5.6a3.4 3.4 0 0 1 0 4.8M12.9 3.7a6 6 0 0 1 0 8.6"
fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</button>
<label class="sr-only" for="vol">Ambience volume</label>
<input id="vol" type="range" min="0" max="100" value="45" step="1">
<span class="volval data" id="volval">45</span>
</div>
<p class="audiohint data" id="audiohint" hidden>Click anywhere to start the ambience</p>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+17
View File
@@ -0,0 +1,17 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_selftest'
description 'Exercises the account/character data path and proves it survives a restart'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'server/test.lua',
}
+144
View File
@@ -0,0 +1,144 @@
-- ---------------------------------------------------------------------------
-- End-to-end check of the account and character data path.
--
-- The first run creates a marker account. Every later run finds it already
-- there and verifies it came back byte-for-byte, which is what "persists
-- between restarts" actually means. Run `selftest_reset` to clear it.
-- ---------------------------------------------------------------------------
local MARKER = 'zz_selftest'
local PASSWORD = 'correct horse 7'
local passed, failed = 0, 0
local function check(name, ok, detail)
if ok then
passed = passed + 1
print(('^2 PASS^7 %s'):format(name))
else
failed = failed + 1
print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or ''))
end
end
local function hash(pw)
local p = promise.new()
exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end)
local r = Citizen.Await(p)
return r.h, r.err
end
local function verify(pw, stored)
local p = promise.new()
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
return Citizen.Await(p).ok
end
CreateThread(function()
if not DB.WaitReady(30000) then
print('^1[selftest] database never became ready^7')
return
end
print('^5[selftest]^7 ---- account + character data path ----')
-- 1. password hashing ----------------------------------------------------
local h, herr = hash(PASSWORD)
check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr))
check('correct password verifies', verify(PASSWORD, h))
check('wrong password rejected', not verify(PASSWORD .. 'x', h))
check('empty password rejected', not verify('', h))
local h2 = hash(PASSWORD)
check('same password hashes differently (salted)', h ~= h2)
check('second hash also verifies', verify(PASSWORD, h2))
check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash'))
-- 2. the account row ------------------------------------------------------
local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER })
local firstRun = existing == nil
local accountId
if firstRun then
accountId = DB.Insert(
'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)',
{ MARKER, h, 'selftest-license' })
check('account created', type(accountId) == 'number' and accountId > 0)
print('^3[selftest]^7 marker account created - restart the server to prove persistence')
else
accountId = existing.id
check('account survived restart', true)
check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash))
print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at)))
end
-- 3. the character row, including the appearance JSON ---------------------
local appearance = Appearance.Sanitise({
model = 'f',
parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 },
hair = { style = 14, colour = 9, highlight = 2 },
features = { ['0'] = 0.4, ['13'] = -0.75 },
overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } },
components = { jacket = { drawable = 42, texture = 3 } },
})
local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId })
if not charId then
charId = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance)
VALUES (?, 0, ?, ?, ?, 'f', ?, ?)
]], { accountId, 'Zztest', 'Persistence', '1991-04-18',
'Created by the self test.', json.encode(appearance) })
check('character created', type(charId) == 'number' and charId > 0)
else
check('character survived restart', true)
end
local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId })
check('character reads back', row ~= nil)
if row then
local back = json.decode(row.appearance)
check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance),
'appearance differs after a round trip')
check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001,
tostring(back.parents.shapeMix))
check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001,
tostring(back.features['13']))
check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash
and tonumber(row.bank) == Config.Money.startingBank,
('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank)))
end
-- 4. the constraints the session code relies on ---------------------------
local dupe, dupeErr = DB.Insert(
'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h })
check('duplicate username refused by the database', dupe == nil, tostring(dupe))
local dupeName = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f')
]], { accountId })
check('duplicate character name refused by the database', dupeName == nil)
local orphan = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender)
VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm')
]], {})
check('character cannot reference a missing account', orphan == nil)
print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed))
if failed == 0 then
print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7')
else
print('^1[selftest] PROBLEMS FOUND^7')
end
end)
RegisterCommand('selftest_reset', function()
CreateThread(function()
DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade
print('^3[selftest]^7 marker account removed')
end)
end, true)
+19
View File
@@ -0,0 +1,19 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_session'
description 'Connection gate, accounts, characters and spawn'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
server_scripts {
'@rp_db/lib/db.lua',
'@rp_core/lib/callbacks_server.lua',
'server/main.lua',
}
+438
View File
@@ -0,0 +1,438 @@
-- ---------------------------------------------------------------------------
-- rp_session - everything between "player opened the server" and "player is
-- standing in the world": ban gate, accounts, characters, spawn.
--
-- The client drives the UI, but every decision is made here. The client may
-- only ever ask; it can never assert that it is logged in, that a character
-- belongs to it, or where it spawned.
-- ---------------------------------------------------------------------------
local sessions = {} -- [src] = { license, ip, accountId, username, role, stage }
-- This build of FXServer ships no `hardcap` resource (it only advertises the
-- name in /info.json), and the bundled `chat` and `monitor` resources are not
-- started, so capacity has to be enforced here - see the deferral below.
-- Kept as a guard in case a future artefact does ship one.
CreateThread(function()
if GetResourceState('hardcap') == 'started' then
StopResource('hardcap')
print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource')
end
end)
local STAGE_AUTH = 'auth'
local STAGE_CHARS = 'characters'
local STAGE_SPAWN = 'spawn'
local STAGE_LIVE = 'live'
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
local function licenseOf(src)
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
local id = GetPlayerIdentifier(src, i)
if id and id:sub(1, 8) == 'license:' then return id:sub(9) end
end
return nil
end
local function hashPassword(pw)
local p = promise.new()
exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end)
local r = Citizen.Await(p)
return r.hash, r.err
end
local function verifyPassword(pw, stored)
local p = promise.new()
exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end)
local r = Citizen.Await(p)
if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end
return r.ok == true
end
local function recordAttempt(identity, ip, success)
DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)',
{ identity, ip or DB.NULL, success and 1 or 0 })
end
--- True when this identity has burned through its allowance recently.
local function throttled(identity)
local n = DB.Scalar(
'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)',
{ identity, Config.Session.attemptWindowS })
return (tonumber(n) or 0) >= Config.Session.maxAttempts
end
local function characterRows(accountId)
return DB.Query([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters
WHERE account_id = ? AND deleted = 0
ORDER BY slot ASC, id ASC
]], { accountId }) or {}
end
--- Trim a DB row down to what the character-select screen is allowed to see.
local function characterCard(row)
return {
id = row.id,
firstName = row.first_name,
lastName = row.last_name,
dob = tostring(row.dob):sub(1, 10),
gender = row.gender,
backstory = row.backstory,
cash = math.floor(tonumber(row.cash) or 0),
bank = math.floor(tonumber(row.bank) or 0),
job = row.job,
playtime = math.floor(tonumber(row.playtime) or 0),
lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil,
appearance= row.appearance and json.decode(row.appearance) or nil,
}
end
local function sessionOf(src, requiredStage)
local s = sessions[src]
if not s then return nil, 'no session' end
if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end
return s
end
-- ---------------------------------------------------------------------------
-- connection gate
-- ---------------------------------------------------------------------------
AddEventHandler('playerConnecting', function(_, _, deferrals)
local src = source
deferrals.defer()
Wait(0)
local license = licenseOf(src)
if not license then
return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.')
end
-- Capacity. This replaces the stock `hardcap` resource, which is stopped in
-- server.cfg so that everything running here is our own code.
local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48
local online = #GetPlayers() -- the joining player is not counted yet
if online >= maxClients then
return deferrals.done(
('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients))
end
deferrals.update('Checking your record...')
if not DB.WaitReady(15000) then
return deferrals.done('The server database is not available right now. Try again in a minute.')
end
local ban = DB.Single([[
SELECT id, ban_reason, ban_until
FROM accounts
WHERE license = ? AND banned = 1
AND (ban_until IS NULL OR ban_until > NOW())
LIMIT 1
]], { license })
if ban then
local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently'
return deferrals.done(('You are banned%s.\n\nReason: %s')
:format(until_, ban.ban_reason or 'no reason recorded'))
end
-- clear bans that have run out so the account is usable again
DB.UpdateAsync([[
UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL
WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW()
]], { license })
sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH }
deferrals.done()
end)
AddEventHandler('playerDropped', function()
sessions[source] = nil
end)
-- ---------------------------------------------------------------------------
-- callbacks: authentication
-- ---------------------------------------------------------------------------
Core.RegisterCallback('session:state', function(src)
local s = sessions[src]
if not s then return nil, 'no session' end
return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:register', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
local ok, why = Rules.checkUsername(username)
if not ok then return nil, why end
ok, why = Rules.checkPassword(password)
if not ok then return nil, why end
if throttled(s.license) then
return nil, 'Too many attempts. Wait a few minutes and try again.'
end
local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username })
if taken then
recordAttempt(s.license, s.ip, false)
return nil, 'That username is already taken'
end
local hash, herr = hashPassword(password)
if not hash then
print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr)))
return nil, 'Could not create the account, try again'
end
local id, ierr = DB.Insert(
'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)',
{ username, hash, s.license, s.ip or DB.NULL })
if not id then
-- unique key race: someone registered the same name a moment ago
print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr)))
return nil, 'That username is already taken'
end
recordAttempt(s.license, s.ip, true)
s.accountId = id
s.username = username
s.role = 'player'
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license })
print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src)))
return { username = username, characters = {}, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('auth:login', function(src, data)
local s, e = sessionOf(src, STAGE_AUTH)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local username = Util.trim(data.username or '')
local password = data.password or ''
if username == '' or password == '' then return nil, 'Enter your username and password' end
if throttled(s.license) then
return nil, 'Too many failed attempts. Wait a few minutes and try again.'
end
local acc = DB.Single(
'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1',
{ username })
-- Always run a verification so a missing username and a wrong password take
-- the same amount of time and cannot be told apart from outside.
local stored = acc and acc.password_hash
or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
local good = verifyPassword(password, stored)
if not acc or not good then
recordAttempt(s.license, s.ip, false)
return nil, 'Incorrect username or password'
end
if acc.banned == 1 then
-- re-checked against NOW() so an expired ban does not keep locking them out
local active = DB.Scalar(
'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())',
{ acc.id })
if active == 1 then
return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded')
end
end
recordAttempt(s.license, s.ip, true)
DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?',
{ s.ip or DB.NULL, s.license, acc.id })
s.accountId = acc.id
s.username = acc.username
s.role = acc.role
s.stage = STAGE_CHARS
exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license })
local cards = {}
for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end
print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username))
return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars }
end)
-- ---------------------------------------------------------------------------
-- callbacks: characters
-- ---------------------------------------------------------------------------
Core.RegisterCallback('char:list', function(src)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local cards = {}
for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end
return { characters = cards, maxChars = Config.MaxChars }
end)
Core.RegisterCallback('char:create', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
if type(data) ~= 'table' then return nil, 'malformed request' end
local first = Util.properName(data.firstName or '')
local last = Util.properName(data.lastName or '')
local ok, why = Rules.checkName(first)
if not ok then return nil, 'First name: ' .. why end
ok, why = Rules.checkName(last)
if not ok then return nil, 'Last name: ' .. why end
ok, why = Rules.checkDob(data.dob or '')
if not ok then return nil, 'Date of birth: ' .. why end
local gender = (data.gender == 'f') and 'f' or 'm'
local backstory = Util.trim(data.backstory or '')
if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end
local existing = tonumber(DB.Scalar(
'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0
if existing >= Config.MaxChars then
return nil, ('You already have %d characters'):format(Config.MaxChars)
end
local clash = DB.Scalar(
'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last })
if clash then return nil, 'Someone on this server already has that name' end
local appearance = Appearance.Sanitise(data.appearance)
local id, ierr = DB.Insert([[
INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory,
appearance, cash, bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
s.accountId, existing, first, last, data.dob, gender,
backstory ~= '' and backstory or DB.NULL,
json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank,
})
if not id then
print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr)))
return nil, 'Could not create that character'
end
print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id))
local row = DB.Single([[
SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance,
cash, bank, job, job_grade, playtime, last_played_at
FROM characters WHERE id = ?
]], { id })
return { character = characterCard(row) }
end)
Core.RegisterCallback('char:delete', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
-- ownership is checked in the WHERE clause, so a forged id changes nothing
local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0',
{ id, s.accountId })
if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end
print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id))
return { deleted = id }
end)
Core.RegisterCallback('char:select', function(src, data)
local s, e = sessionOf(src, STAGE_CHARS)
if not s then return nil, e end
local id = tonumber(type(data) == 'table' and data.id or nil)
if not id then return nil, 'malformed request' end
local row = DB.Single([[
SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0
]], { id, s.accountId })
if not row then return nil, 'That character is not yours' end
exports.rp_core:attachCharacter(src, row)
s.stage = STAGE_SPAWN
s.charId = id
local last = row.position and json.decode(row.position) or nil
local spawns = {}
for _, sp in ipairs(Config.SpawnPoints) do
spawns[#spawns + 1] = {
id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map,
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w },
cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z },
look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } },
}
end
return {
character = characterCard(row),
appearance = row.appearance and json.decode(row.appearance) or nil,
spawns = spawns,
lastPosition = last,
}
end)
-- ---------------------------------------------------------------------------
-- callbacks: spawn
-- ---------------------------------------------------------------------------
Core.RegisterCallback('spawn:confirm', function(src, data)
local s, e = sessionOf(src, STAGE_SPAWN)
if not s then return nil, e end
local choice = type(data) == 'table' and data.id or nil
local coords
if choice == 'last' then
local p = exports.rp_core:getCharacter(src)
local last = p and p.position
if last and last.x then
coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 }
end
end
if not coords then
for _, sp in ipairs(Config.SpawnPoints) do
if sp.id == choice then
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
break
end
end
end
if not coords then
local sp = Config.SpawnPoints[1]
coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }
end
s.stage = STAGE_LIVE
exports.rp_core:setSpawned(src, true)
local char = exports.rp_core:getCharacter(src)
print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName))
TriggerClientEvent('rp:session:spawned', src, coords)
return { coords = coords, character = char }
end)
+188
View File
@@ -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
+508
View File
@@ -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)
+160
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_ui'
description 'Pre-spawn interface: sign in, character creation and placement'
author 'Los Santos RP'
version '1.0.0'
shared_scripts {
'@rp_core/shared/config.lua',
'@rp_core/shared/util.lua',
'@rp_core/shared/appearance.lua',
}
client_scripts {
'@rp_core/lib/callbacks_client.lua',
'client/camera.lua',
'client/ped.lua',
'client/main.lua',
}
ui_page 'html/index.html'
files {
'html/index.html',
'html/tokens.css',
'html/app.css',
'html/app.js',
'html/fonts/archivo-var.woff2',
'html/fonts/plexsans-var.woff2',
'html/fonts/plex-400.woff2',
'html/fonts/plex-500.woff2',
}
+820
View File
@@ -0,0 +1,820 @@
/* ---------------------------------------------------------------------------
Los Santos RP - the pre-spawn screens.
Every screen is a sheet of paper from the Office of Vital Records, lying on a
dark desk with the city running behind it. The body stays transparent: the
game is the room, and the paper is the only thing we draw.
--------------------------------------------------------------------------- */
body {
background: transparent;
transition: opacity 420ms var(--ease);
}
body.hidden { opacity: 0; pointer-events: none; }
/* --- the room -------------------------------------------------------------
A lamp over the desk on the left, and the dark closing in everywhere else.
This is what makes an opaque sheet of paper look lit rather than pasted on. */
.edge {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(58% 74% at 22% 46%, var(--lamp) 0%, transparent 62%),
radial-gradient(120% 96% at 26% 50%, transparent 38%, rgba(0,0,0,.58) 100%);
}
.grain {
position: fixed; inset: 0; z-index: 60; pointer-events: none;
opacity: .10; mix-blend-mode: overlay;
background-image: var(--fiber);
background-size: 160px 160px;
}
/* =========================================================================
persistent chrome: the edge of the desk
========================================================================= */
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; justify-content: space-between;
gap: 26px;
padding: 20px var(--gut-x) 26px;
background: linear-gradient(to bottom, rgba(6,7,5,.80), rgba(6,7,5,0));
pointer-events: none;
}
.mark { display: flex; align-items: center; gap: 12px; }
.seal { width: 32px; height: 32px; flex: none; opacity: .92; }
.seal-ring { fill: none; stroke: var(--stock-2); stroke-width: 3; }
.seal-ring.thin { stroke-width: 1.4; opacity: .6; }
.seal-ticks {
fill: none; stroke: var(--stock-2); stroke-width: 5; opacity: .5;
stroke-dasharray: 2 9;
}
.seal-star { fill: var(--canary); }
.mark-lines { display: flex; flex-direction: column; line-height: 1.1; }
.mark-name {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 15px; letter-spacing: .04em; text-transform: uppercase;
color: var(--stock-hi);
}
.mark-tag {
font-family: 'Plex Mono', monospace;
font-size: 9px; letter-spacing: .3em; text-transform: uppercase;
color: var(--canary);
}
/* the routing box: three stations, in the order the file moves through them */
.stages { display: flex; align-items: center; gap: 10px; }
.stagechip {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: 0; padding: 0; cursor: default;
}
.stagechip em {
font-style: normal; font-size: 9px; line-height: 1;
width: 21px; height: 19px; display: grid; place-items: center;
border: 1px solid rgba(222,216,198,.26);
color: rgba(222,216,198,.45);
transition: all 320ms var(--ease);
}
.stagechip span {
font-size: 9.5px; letter-spacing: .17em;
color: rgba(222,216,198,.42);
transition: color 320ms var(--ease);
}
.stagechip.on em {
background: var(--canary); border-color: var(--canary); color: var(--ink);
}
.stagechip.on span { color: var(--stock-hi); }
.stagechip.done em { color: transparent; border-color: rgba(222,216,198,.4); position: relative; }
.stagechip.done em::after {
content: '\00D7';
position: absolute; inset: 0; display: grid; place-items: center;
font-size: 13px; color: var(--canary);
}
.stagelink { width: 26px; height: 1px; background: rgba(222,216,198,.2); }
.filetag { display: flex; align-items: baseline; gap: 8px; font-size: 11px; color: rgba(222,216,198,.55); }
.filetag-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .19em; text-transform: uppercase;
color: rgba(222,216,198,.35);
}
/* =========================================================================
the sheet
========================================================================= */
.screen {
position: fixed; inset: 0; z-index: 10;
display: flex; align-items: center;
padding: 96px var(--gut-x) var(--gut-y);
}
.screen[hidden] { display: none; }
.rail {
position: relative;
width: var(--sheet);
max-height: 100%;
display: flex; flex-direction: column;
background: var(--stock);
box-shadow: var(--lift-2);
z-index: 2;
}
.rail.wide { width: var(--sheet-w); }
/* paper: fibre, a warmer lit edge on the lamp side, and a shaded far edge */
.rail-scrim {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(to right, transparent 70%, rgba(25,28,24,.10) 100%),
var(--fiber);
background-size: auto, auto, 160px 160px;
opacity: 1;
mix-blend-mode: normal;
}
.rail-scrim::after {
content: ''; position: absolute; inset: 0;
background-image: var(--fiber);
background-size: 150px 150px;
opacity: .05;
mix-blend-mode: multiply;
}
.rail::after {
content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
background: radial-gradient(78% 62% at 16% 6%, rgba(255,240,196,.55) 0%, rgba(255,240,196,0) 64%);
mix-blend-mode: soft-light;
}
.rail-inner {
position: relative; z-index: 1;
padding: 22px var(--gut-x) var(--gut-y);
overflow-y: auto; overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--stock-3) transparent;
}
.rail-inner::-webkit-scrollbar { width: 4px; }
.rail-inner::-webkit-scrollbar-thumb { background: var(--stock-3); }
.rail-inner::-webkit-scrollbar-track { background: transparent; }
/* --- letterhead ----------------------------------------------------------- */
.letterhead {
position: relative; z-index: 1; flex: none;
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
padding: 20px var(--gut-x) 14px;
border-bottom: 2.5px solid var(--ink);
}
.letterhead::after {
content: ''; position: absolute; left: var(--gut-x); right: var(--gut-x); bottom: -5px;
height: 1px; background: var(--ink); opacity: .45;
}
.lh-dept { display: flex; flex-direction: column; gap: 2px; }
.lh-city {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .22em; text-transform: uppercase;
color: var(--ink-3);
}
.lh-office {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 116, 'wght' 800;
font-size: 15px; letter-spacing: .015em; text-transform: uppercase;
color: var(--ink); line-height: 1.15;
}
.lh-form {
font-size: 10px; letter-spacing: .1em; color: var(--ink-2);
border: 1px solid var(--rule); padding: 3px 7px; white-space: nowrap;
}
/* --- headings ------------------------------------------------------------- */
.head {
font-size: clamp(23px, 2.05vw, 30px);
font-variation-settings: 'wdth' 108, 'wght' 800;
letter-spacing: .005em; line-height: 1.06;
color: var(--ink);
margin: 12px 0 10px;
text-wrap: balance;
}
.sub {
font-size: 13.5px; line-height: 1.6; color: var(--ink-2);
max-width: 46ch; margin-bottom: 20px;
}
.kicker { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
.hair { flex: 1; height: 1px; background: var(--rule); }
/* =========================================================================
form fields - a printed box with its caption punched through the rule
========================================================================= */
.form { display: block; }
.field { position: relative; margin-top: 24px; }
.field:first-child { margin-top: 8px; }
.field label {
position: absolute; top: -7px; left: 11px; z-index: 2;
font-size: 9px; letter-spacing: .17em;
color: var(--ink-2);
background: var(--stock);
padding: 0 6px;
}
.field input,
.field textarea {
width: 100%; display: block;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
color: var(--ink);
font-size: 14px; line-height: 1.4;
padding: 14px 13px 11px;
transition: border-color 200ms var(--ease), background 200ms var(--ease);
}
.field textarea { min-height: 108px; resize: none; padding-top: 15px; }
.field input::placeholder,
.field textarea::placeholder { color: var(--ink-3); opacity: .65; }
.field input:focus,
.field textarea:focus {
outline: none;
border-color: var(--ink);
background: rgba(255,255,255,.6);
}
/* the clerk's tick in the margin of the box */
.field::after {
content: ''; position: absolute; top: 15px; right: 13px;
font-size: 14px; line-height: 1; pointer-events: none;
opacity: 0; transition: opacity 180ms var(--ease);
}
.field.good::after {
content: ''; opacity: 1;
width: 6px; height: 11px; top: 15px; right: 16px;
border: 2px solid var(--verdi); border-top: 0; border-left: 0;
transform: rotate(42deg);
}
.field.bad::after { content: '\00D7'; color: var(--stamp); opacity: 1; }
.field.good input { border-color: var(--verdi); }
.field.bad input { border-color: var(--stamp); }
.field.good .hint { color: var(--verdi); }
.field.bad .hint { color: var(--stamp); }
.hint {
font-size: 11.5px; line-height: 1.45; color: var(--ink-3);
margin-top: 7px;
}
.counter {
font-size: 10.5px; color: var(--ink-3); margin-top: 7px; text-align: right;
}
.withbtn { position: relative; }
.withbtn input { padding-right: 68px; }
.reveal-btn {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .15em; text-transform: uppercase;
color: var(--ink-2);
border-bottom: 1px solid var(--rule-firm);
padding-bottom: 1px;
}
.reveal-btn:hover { color: var(--ink); border-bottom-color: var(--ink); }
.collapse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 380ms var(--ease); }
.collapse.open { grid-template-rows: 1fr; }
.collapse-inner { overflow: hidden; min-height: 0; }
.formerror {
margin-top: 16px;
font-family: 'Plex Sans', sans-serif;
font-size: 12.5px; line-height: 1.45;
color: var(--stamp);
border-left: 3px solid var(--stamp);
background: rgba(126,43,38,.07);
padding: 9px 12px;
animation: nudge 380ms var(--ease);
}
/* =========================================================================
buttons
========================================================================= */
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
font-size: 11px; letter-spacing: .15em;
padding: 15px 24px;
border: 1.4px solid var(--ink);
background: none; color: var(--ink);
cursor: pointer;
transition: color 260ms var(--ease), border-color 260ms var(--ease), opacity 200ms var(--ease);
}
.btn::before {
content: ''; position: absolute; inset: 0; z-index: 0;
background: var(--canary);
transform: scaleX(0); transform-origin: left center;
transition: transform 340ms var(--ease-out);
}
.btn:hover:not(:disabled)::before { transform: scaleX(1); }
.btn-label, .btn-spin { position: relative; z-index: 1; }
.btn.primary { background: var(--ink); color: var(--stock-hi); width: 100%; margin-top: 22px; }
.btn.primary:hover:not(:disabled) { color: var(--ink); }
.btn.ghost { background: rgba(255,255,255,.2); }
.btn.ghost:hover:not(:disabled) { color: var(--ink); }
.btn.danger { border-color: var(--stamp); color: var(--stamp); }
.btn.danger::before { background: var(--stamp); }
.btn.danger:hover:not(:disabled) { color: var(--stock-hi); }
.btn:disabled { opacity: .32; cursor: default; }
.btn-spin { display: none; width: 13px; height: 13px; }
.btn.busy .btn-label { opacity: .35; }
.btn.busy .btn-spin {
display: block;
border: 1.6px solid currentColor; border-top-color: transparent;
border-radius: 50%;
animation: spin 720ms linear infinite;
}
.switch { margin-top: 18px; font-size: 12.5px; color: var(--ink-2); }
.linkbtn {
background: none; border: 0; cursor: pointer; padding: 0;
font-family: inherit; font-size: inherit; color: var(--ink);
border-bottom: 1px solid var(--rule-firm);
}
.linkbtn:hover { border-bottom-color: var(--ink); }
.linkbtn.danger {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase;
color: var(--stamp); border-bottom-color: rgba(126,43,38,.4);
}
.linkbtn.danger:hover { border-bottom-color: var(--stamp); }
/* =========================================================================
02 / the register of persons - ruled index cards in a drawer
========================================================================= */
.charlist { list-style: none; }
.charcard {
position: relative;
padding: 15px 18px 14px 52px;
margin-bottom: 9px;
background: var(--stock-hi);
border: 1px solid var(--rule);
cursor: pointer;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), background 200ms var(--ease);
/* the faint ruling of a real index card */
background-image: repeating-linear-gradient(
to bottom, transparent 0 22px, rgba(46,97,85,.075) 22px 23px);
}
.charcard:hover { transform: translateY(-1px); box-shadow: 0 6px 14px -8px rgba(0,0,0,.55); }
.charslot {
position: absolute; left: 0; top: 0; bottom: 0; width: 36px;
display: grid; place-items: center;
background: var(--stock-2);
border-right: 1px solid var(--rule);
font-size: 10.5px; color: var(--ink-2);
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.charname {
display: block; font-size: 14.5px; letter-spacing: .045em;
color: var(--ink); line-height: 1.2;
}
.charmeta { display: block; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; }
.charmoney { font-size: 13px; color: var(--ink); white-space: nowrap; }
.charcard.on {
background-color: #f4f0e4;
border-color: var(--ink);
box-shadow: inset 4px 0 0 var(--canary), 0 8px 18px -8px rgba(0,0,0,.6);
}
.charcard.on .charslot { background: var(--canary); color: var(--ink); }
.charcard.empty {
background-color: transparent; background-image: none;
border: 1.4px dashed var(--rule-firm);
color: var(--ink-2);
justify-content: flex-start;
}
.charcard.empty .charslot { background: transparent; border-right-color: var(--rule-soft); font-size: 15px; }
.charcard.empty .charname { font-size: 11px; color: var(--ink-2); }
.charcard.empty:hover { background-color: rgba(255,255,255,.24); }
.charactions { display: flex; gap: 10px; align-items: stretch; margin-top: 20px; }
.charactions .btn.primary { margin-top: 0; }
.charactions .btn.ghost { width: auto; flex: none; margin-top: 0; }
/* =========================================================================
03 / application for identity
========================================================================= */
.tabs {
position: relative; z-index: 1; flex: none;
display: flex; gap: 2px;
padding: 14px var(--gut-x) 0;
border-bottom: 1.4px solid var(--rule-firm);
}
.tab {
padding: 8px 11px 7px;
font-size: 9.5px; letter-spacing: .12em;
background: var(--stock-2);
border: 1px solid var(--rule); border-bottom: 0;
color: var(--ink-2); cursor: pointer;
margin-bottom: -1.4px;
transition: background 200ms var(--ease), color 200ms var(--ease);
}
.tab:hover { background: var(--stock); color: var(--ink); }
.tab.is-on {
background: var(--stock-hi); color: var(--ink);
border-bottom: 1.4px solid var(--stock-hi);
}
.rail-inner.creator { padding-top: 20px; }
.pane { display: none; }
.pane.is-on {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 20px;
align-content: start;
}
.pane .sub { grid-column: 1 / -1; margin-bottom: 0; }
.pane .field { grid-column: 1 / -1; }
.ctrl { min-width: 0; }
.ctrl.span2 { grid-column: 1 / -1; }
.ctrl-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
margin-bottom: 7px;
}
.ctrl-label { font-size: 9.5px; letter-spacing: .14em; color: var(--ink-2); }
.ctrl-val { font-size: 10.5px; color: var(--ink-3); white-space: nowrap; }
/* a measuring scale with a surveyor's marker riding on it */
.slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 24px; background: transparent; cursor: pointer;
}
.slider::-webkit-slider-runnable-track {
height: 24px;
background-image:
linear-gradient(var(--rule-firm), var(--rule-firm)),
repeating-linear-gradient(90deg, var(--rule) 0 1px, transparent 1px 10%);
background-size: 100% 1.4px, 100% 7px;
background-position: 0 12px, 0 13px;
background-repeat: no-repeat, repeat-x;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 11px; height: 18px; margin-top: 3px;
background: var(--ink); border: 0;
clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%);
transition: background 160ms var(--ease);
}
.slider:hover::-webkit-slider-thumb { background: var(--canary-dp); }
.stepper {
display: grid; grid-template-columns: 32px 1fr 32px;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.34);
}
.stepper button {
border: 0; background: none; height: 32px; cursor: pointer;
color: var(--ink-2); font-size: 14px; line-height: 1;
transition: background 160ms var(--ease), color 160ms var(--ease);
}
.stepper button:hover { background: var(--ink); color: var(--stock-hi); }
.stepval {
display: grid; place-items: center;
font-size: 11.5px; color: var(--ink);
border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
overflow: hidden; white-space: nowrap;
}
/* a printed colour chart: a fixed grid, so it can never wrap into orphans */
.swatches {
display: grid; grid-template-columns: repeat(16, 1fr);
grid-auto-rows: 17px;
gap: 1px; padding: 1px;
background: var(--rule-firm);
border: 1px solid var(--rule-firm);
}
.swatch {
border: 0; padding: 0; cursor: pointer;
position: relative; min-width: 0;
transition: transform 140ms var(--ease);
}
.swatch:hover { transform: scale(1.18); z-index: 3; box-shadow: 0 0 0 1px var(--ink); }
.swatch.on {
z-index: 4;
box-shadow: 0 0 0 2px var(--ink), 0 0 0 4px var(--canary);
}
/* the sex field, ticked the way a form is ticked */
.seg { display: flex; gap: 24px; grid-column: 1 / -1; margin-bottom: 2px; }
.seg button {
display: inline-flex; align-items: center; gap: 9px;
background: none; border: 0; cursor: pointer;
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 108, 'wght' 700;
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-2);
}
.seg button::before {
content: ''; width: 15px; height: 15px; flex: none;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.4);
font-size: 13px; line-height: 1; color: var(--stamp);
}
.seg button.on { color: var(--ink); }
.seg button.on::before { content: '\00D7'; border-color: var(--ink); }
.creator-foot {
position: relative; z-index: 1; flex: none;
display: flex; gap: 10px;
padding: 14px var(--gut-x) 18px;
border-top: 1px solid var(--rule);
}
.creator-foot .btn { margin-top: 0; }
.creator-foot .btn.ghost { flex: none; width: auto; }
.creator-foot .btn.primary { flex: 1; }
#screen-creator .formerror { margin: 0 var(--gut-x); }
/* --- the photograph plate ------------------------------------------------
The subject stands inside this frame, and the frame is also the drag
surface, so the thing you aim at is the thing that turns. It is drawn wide
on purpose: the ped is framed generously rather than pinned to a cut-out. */
.turntable {
position: absolute; left: 36%; right: 16%; top: 16%; bottom: 17%;
z-index: 5; cursor: grab;
}
.turntable.dragging { cursor: grabbing; }
.plate-corner {
position: absolute; width: 30px; height: 30px;
border: 2px solid rgba(233,226,204,.72);
transition: border-color 260ms var(--ease);
}
.plate-corner.tl { left: 0; top: 0; border-right: 0; border-bottom: 0; }
.plate-corner.tr { right: 0; top: 0; border-left: 0; border-bottom: 0; }
.plate-corner.bl { left: 0; bottom: 0; border-right: 0; border-top: 0; }
.plate-corner.br { right: 0; bottom: 0; border-left: 0; border-top: 0; }
.turntable.dragging .plate-corner { border-color: var(--canary); }
.plate-cap {
position: absolute; left: 0; right: 0; bottom: -28px;
display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.plate-cap-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 104, 'wght' 600;
font-size: 9px; letter-spacing: .22em; text-transform: uppercase;
color: rgba(222,216,198,.5);
}
.turnhint {
display: inline-flex; align-items: center; gap: 8px;
font-family: 'Plex Mono', monospace;
font-size: 10px; letter-spacing: .1em;
color: rgba(222,216,198,.45);
transition: opacity 200ms var(--ease);
}
.turntable.dragging .turnhint { opacity: 0; }
.turnicon {
width: 14px; height: 14px; display: block; flex: none;
border: 1px solid currentColor; border-radius: 50%;
border-right-color: transparent;
transform: rotate(-30deg);
}
/* =========================================================================
04 / notice of placement
========================================================================= */
.spawnlist { list-style: none; border-top: 1px solid var(--rule); }
.spawncard {
position: relative;
padding: 13px 14px 13px 44px;
border-bottom: 1px solid var(--rule);
cursor: pointer;
transition: background 180ms var(--ease);
}
.spawncard:hover { background: rgba(255,255,255,.28); }
.spawncard::before {
content: ''; position: absolute; left: 14px; top: 15px;
width: 15px; height: 15px;
display: grid; place-items: center;
border: 1.4px solid var(--rule-firm);
background: rgba(255,255,255,.45);
font-family: 'Plex Sans', sans-serif;
font-size: 13px; line-height: 1; color: var(--stamp);
}
.spawncard.on::before { content: '\00D7'; border-color: var(--ink); }
.spawncard.on { background: rgba(217,178,60,.13); }
.spawnhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.spawnname { font-size: 12.5px; letter-spacing: .07em; color: var(--ink); }
.spawnarea {
font-family: 'Plex Mono', monospace;
font-size: 9.5px; letter-spacing: .11em; text-transform: uppercase;
color: var(--ink-3); white-space: nowrap;
}
.spawnblurb { font-size: 12px; line-height: 1.5; color: var(--ink-2); margin-top: 4px; }
/* --- the plan sheet ------------------------------------------------------- */
.chart {
position: absolute; right: 6%; top: 50%;
transform: translateY(-50%) rotate(.5deg);
width: min(33vw, 58vh); aspect-ratio: 1 / 1;
z-index: 1;
background: var(--stock);
box-shadow: var(--lift-2);
padding: 3%;
}
.chart-paper {
position: absolute; inset: 0; pointer-events: none;
background-image:
radial-gradient(80% 66% at 14% 8%, rgba(255,244,206,.26) 0%, rgba(255,244,206,0) 62%),
var(--fiber);
background-size: auto, 150px 150px;
opacity: .9;
}
.chart svg { position: relative; width: 100%; height: 100%; display: block; }
.chart-frame { fill: none; stroke: var(--rule); stroke-width: 1; }
.chart-ticks { fill: none; stroke: var(--ink); stroke-width: 2.5; opacity: .55; }
#landmass { fill: rgba(46,97,85,.07); stroke: var(--ink-2); stroke-width: 1.4; }
#coastline { fill: none; stroke: var(--ink); stroke-width: 2.6; }
.pins { position: absolute; inset: 3%; pointer-events: none; }
.pin {
position: absolute; transform: translate(-50%, -50%);
display: flex; align-items: center; gap: 7px;
}
.pin.flip { flex-direction: row-reverse; }
.pin-mark {
width: 9px; height: 9px; flex: none;
background: var(--stock);
border: 1.6px solid var(--ink);
transform: rotate(45deg);
transition: all 220ms var(--ease);
}
.pin-label {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .12em; text-transform: uppercase;
color: var(--ink-2); white-space: nowrap;
}
.pin.on .pin-mark {
background: var(--canary); border-color: var(--ink);
width: 13px; height: 13px;
box-shadow: 0 0 0 3px rgba(217,178,60,.3);
}
.pin.on .pin-label { color: var(--ink); }
.titleblock {
position: absolute; right: 3%; bottom: 3%;
border: 1.4px solid var(--ink);
background: var(--stock-hi);
min-width: 44%;
}
.tb-row { display: flex; }
.tb-row + .tb-row { border-top: 1px solid var(--rule); }
.tb-k, .tb-v { padding: 4px 7px; font-size: 8px; line-height: 1.3; }
.tb-k {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 100, 'wght' 600;
letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-3);
border-right: 1px solid var(--rule);
flex: none;
}
.tb-v {
font-family: 'Plex Mono', monospace;
color: var(--ink); flex: 1;
letter-spacing: .04em;
}
.tb-row + .tb-row .tb-k { border-left: 1px solid var(--rule); }
.tb-row + .tb-row .tb-k:first-child { border-left: 0; }
.tb-main .tb-v {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
}
/* =========================================================================
the stamp - the one bold moment, and the only thing that moves loudly
========================================================================= */
.stamp {
position: fixed; left: 17%; top: 53%; z-index: 50;
transform: translate(-50%, -50%) rotate(-8deg) scale(1);
display: none;
flex-direction: column; align-items: center; gap: 3px;
padding: 13px 30px 11px;
border: 4px solid var(--stamp);
color: var(--stamp);
pointer-events: none;
/* real rubber never inks evenly */
-webkit-mask-image: var(--inkmask);
mask-image: var(--inkmask);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.stamp::before {
content: ''; position: absolute; inset: 4px;
border: 1.4px solid var(--stamp);
}
.stamp-word {
font-family: 'Archivo', sans-serif;
font-variation-settings: 'wdth' 118, 'wght' 800;
font-size: 37px; letter-spacing: .1em; text-transform: uppercase;
line-height: 1;
}
.stamp-sub {
font-family: 'Plex Mono', monospace;
font-size: 8.5px; letter-spacing: .2em; text-transform: uppercase;
opacity: 1;
}
.stamp.show { display: flex; animation: stampdown 1700ms var(--ease-out) forwards; }
@keyframes stampdown {
0% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(2.3); filter: blur(4px); }
22% { opacity: 1; transform: translate(-50%,-50%) rotate(-8deg) scale(.93); filter: blur(0); }
32% { transform: translate(-50%,-50%) rotate(-8deg) scale(1.03); }
42% { transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
78% { opacity: 1; }
100% { opacity: 0; transform: translate(-50%,-50%) rotate(-8deg) scale(1); }
}
/* the desk takes the hit when the stamp lands */
body.struck .rail, body.struck .chart { animation: jolt 260ms var(--ease); }
@keyframes jolt {
0% { transform: translate(0,0); }
18% { transform: translate(-1px, 2px); }
46% { transform: translate(1px, -1px); }
100% { transform: translate(0,0); }
}
/* the plan sheet keeps its rotation while it shakes */
body.struck .chart { animation-name: joltchart; }
@keyframes joltchart {
0% { transform: translateY(-50%) rotate(.5deg); }
18% { transform: translate(-1px, calc(-50% + 2px)) rotate(.5deg); }
46% { transform: translate(1px, calc(-50% - 1px)) rotate(.5deg); }
100% { transform: translateY(-50%) rotate(.5deg); }
}
/* =========================================================================
entrances - one orchestrated move per screen, not seven scattered ones
========================================================================= */
.screen.is-on .rail { animation: sheetdown 520ms var(--ease-out) both; }
.screen.is-on .chart { animation: chartin 620ms var(--ease-out) 120ms both; }
.screen.is-on .turntable { animation: platein 700ms var(--ease-out) 180ms both; }
.screen.is-on .rail-inner,
.screen.is-on .creator-foot { animation: settle 520ms var(--ease-out) 160ms both; }
@keyframes sheetdown {
from { opacity: 0; transform: translateY(-14px) scale(.995); }
to { opacity: 1; transform: none; }
}
@keyframes settle {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@keyframes chartin {
from { opacity: 0; transform: translateY(-50%) rotate(.5deg) scale(.97); }
to { opacity: 1; transform: translateY(-50%) rotate(.5deg) scale(1); }
}
@keyframes platein {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes nudge {
0% { transform: translateX(0); }
22% { transform: translateX(-5px); }
44% { transform: translateX(4px); }
68% { transform: translateX(-2px); }
100% { transform: translateX(0); }
}
/* Small screens: the sheet takes the width, the plate and plan sheet stand down. */
@media (max-width: 1100px) {
.rail, .rail.wide { width: min(92vw, 560px); }
.chart { display: none; }
.turntable { left: auto; right: 4%; width: 34%; }
}
+965
View File
@@ -0,0 +1,965 @@
/* ---------------------------------------------------------------------------
Pre-spawn interface behaviour.
The client Lua owns the truth about stages; this file renders whatever it is
told and asks for changes. Validation here is for live feedback only - the
server re-checks everything and its answer is the one that counts.
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
async function post(name, data) {
try {
const res = await fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
});
return await res.json();
} catch (err) {
return { ok: false, error: 'The game stopped responding. Try again.' };
}
}
/* Fire-and-forget for high-frequency edits (slider drags). */
function poke(name, data) {
fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
}).catch(() => {});
}
/* =========================================================================
screen manager
========================================================================= */
const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
const CHIP_ORDER = ['auth', 'characters', 'spawn'];
let current = null;
function showScreen(name) {
SCREENS.forEach((s) => {
const el = $(`screen-${s}`);
if (!el) return;
if (s === name) {
el.hidden = false;
/* restart the entrance animation */
el.classList.remove('is-on');
void el.offsetWidth;
el.classList.add('is-on');
} else {
el.hidden = true;
el.classList.remove('is-on');
}
});
current = name;
paintChips(CHIP_FOR[name]);
document.body.classList.remove('hidden');
}
function paintChips(activeKey) {
const idx = CHIP_ORDER.indexOf(activeKey);
$$('.stagechip').forEach((chip) => {
const i = CHIP_ORDER.indexOf(chip.dataset.stage);
chip.classList.toggle('on', i === idx);
chip.classList.toggle('done', i < idx);
});
}
/* The stamp is the only loud thing in the interface, so it only ever marks a
real change of state: the file opened, the application approved, the
placement issued. The paper on the desk takes the hit with it. */
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
document.body.classList.remove('struck');
void el.offsetWidth;
el.classList.add('show');
setTimeout(() => document.body.classList.add('struck'), 170);
setTimeout(() => document.body.classList.remove('struck'), 600);
}
function busy(btn, on) {
btn.classList.toggle('busy', on);
btn.disabled = on;
}
function showError(el, message) {
if (!message) {
el.hidden = true;
return;
}
el.textContent = message;
el.hidden = false;
/* replay the shake even if the message is unchanged */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
}
/* =========================================================================
validation (mirrors shared/util.lua; the server still decides)
========================================================================= */
const check = {
username(v) {
v = (v || '').trim();
if (!v) return [null, 'Pick a username.'];
if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
return [true, 'That will do.'];
},
password(v) {
v = v || '';
if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
return [true, 'Strong enough.'];
},
name(v) {
v = (v || '').trim();
if (!v) return [null, 'Required.'];
if (v.length < 2) return [false, 'Too short.'];
if (v.length > 20) return [false, 'Too long.'];
if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
return [true, ''];
},
dob(v) {
v = (v || '').trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
if (!v) return [null, 'YYYY-MM-DD'];
if (!m) return [false, 'Use YYYY-MM-DD.'];
const y = +m[1], mo = +m[2], d = +m[3];
if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
return [true, ''];
},
};
/* Paints a field's state. `state` is true / false / null (neutral). */
function mark(fieldName, state, message) {
const field = document.querySelector(`.field[data-field="${fieldName}"]`);
if (!field) return;
field.classList.toggle('good', state === true);
field.classList.toggle('bad', state === false);
const hint = field.querySelector('.hint');
if (hint && message !== undefined && message !== null) hint.textContent = message;
}
/* =========================================================================
01 / auth
========================================================================= */
const Auth = {
mode: 'register',
init() {
$('switchMode').addEventListener('click', () => this.toggle());
$('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
$('revealPw').addEventListener('click', () => {
const input = $('password');
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
$('revealPw').textContent = show ? 'Hide' : 'Show';
$('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
$('username').addEventListener('input', () => {
const [ok, msg] = check.username($('username').value);
mark('username', ok, msg);
});
$('password').addEventListener('input', () => {
const [ok, msg] = check.password($('password').value);
mark('password', ok, msg);
if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
});
$('confirm').addEventListener('input', () => this.checkConfirm());
},
checkConfirm() {
const a = $('password').value;
const b = $('confirm').value;
if (!b) return mark('confirm', null, 'Both entries must match.');
mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
},
toggle() {
this.mode = this.mode === 'register' ? 'login' : 'register';
const reg = this.mode === 'register';
$('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
$('authSub').textContent = reg
? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
: 'Sign in and the city picks up where you left it.';
$('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
$('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
$('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';
$('confirmWrap').classList.toggle('open', reg);
$('confirmWrap').setAttribute('aria-hidden', String(!reg));
$('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');
showError($('authError'), null);
if (!reg) mark('confirm', null, 'Both entries must match.');
},
async submit() {
const username = $('username').value.trim();
const password = $('password').value;
const reg = this.mode === 'register';
const [uOk, uMsg] = check.username(username);
if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }
if (reg) {
const [pOk, pMsg] = check.password(password);
if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
if ($('confirm').value !== password) {
mark('confirm', false, 'These do not match yet.');
$('confirm').focus();
return;
}
} else if (!password) {
mark('password', false, 'Enter your password.');
$('password').focus();
return;
}
const btn = $('authSubmit');
busy(btn, true);
showError($('authError'), null);
const res = await post('auth:submit', { mode: this.mode, username, password });
busy(btn, false);
if (!res.ok) return showError($('authError'), res.error || 'That did not work.');
if (reg) stamp('Filed');
Characters.load(res.characters || [], res.maxChars || 3);
showScreen('characters');
},
};
/* =========================================================================
02 / characters
========================================================================= */
const Characters = {
list: [],
max: 3,
selected: null,
init() {
$('charEnter').addEventListener('click', () => this.enter());
$('charDelete').addEventListener('click', () => this.remove());
},
load(list, max) {
this.list = list || [];
this.max = max || 3;
this.selected = null;
this.render();
},
render() {
const ul = $('charList');
ul.innerHTML = '';
$('charCount').textContent = `${this.list.length} / ${this.max}`;
this.list.forEach((c, i) => {
const li = document.createElement('li');
li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<span class="charslot">0${i + 1}</span>
<span>
<span class="charname"></span>
<span class="charmeta"></span>
</span>
<span class="charmoney"></span>`;
li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
li.querySelector('.charmeta').textContent = this.meta(c);
li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;
const pick = () => this.select(c.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
});
if (this.list.length < this.max) {
const li = document.createElement('li');
li.className = 'charcard empty';
li.tabIndex = 0;
li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
const go = () => Creator.open();
li.addEventListener('click', go);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
ul.appendChild(li);
}
$('charEnter').disabled = this.selected === null;
$('charDelete').disabled = this.selected === null;
},
meta(c) {
const hours = Math.floor((c.playtime || 0) / 3600);
const played = hours > 0 ? `${hours}h played` : 'never played';
const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
return `${c.dob} · ${job} · ${played}`;
},
select(id) {
this.selected = id;
this.render();
showError($('charError'), null);
poke('char:preview', { id });
},
async enter() {
if (this.selected === null) return;
const btn = $('charEnter');
busy(btn, true);
const res = await post('char:select', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
Spawn.load(res.spawns || [], res.lastPosition, res.character);
showScreen('spawn');
},
async remove() {
if (this.selected === null) return;
const c = this.list.find((x) => x.id === this.selected);
if (!c) return;
const btn = $('charDelete');
/* two-step, in place: the button becomes the confirmation */
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = `Delete ${c.firstName}?`;
setTimeout(() => {
if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
}, 4000);
return;
}
btn.dataset.armed = '0';
btn.textContent = 'Delete';
busy(btn, true);
const res = await post('char:delete', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
this.load(res.characters || [], this.max);
},
};
/* =========================================================================
03 / creator
========================================================================= */
const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];
const Creator = {
appearance: null,
limits: null,
schema: null,
palettes: null,
section: 'heritage',
init() {
$$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
$('creatorCancel').addEventListener('click', () => this.cancel());
$('creatorPrev').addEventListener('click', () => this.step(-1));
$('creatorNext').addEventListener('click', () => this.step(1));
this.initDrag();
},
async open() {
const res = await post('creator:start', { gender: 'm' });
if (!res.ok) return showError($('charError'), 'Could not start the editor.');
this.absorb(res);
this.section = 'heritage';
this.paintTabs();
this.renderAll();
showScreen('creator');
poke('creator:frame', { section: 'heritage' });
},
absorb(res) {
this.appearance = res.appearance;
this.limits = res.limits;
this.schema = res.schema || this.schema;
this.palettes = res.palettes || this.palettes;
},
go(section) {
this.section = section;
this.paintTabs();
poke('creator:frame', { section });
},
paintTabs() {
$$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
$$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
const i = SECTIONS.indexOf(this.section);
$('creatorPrev').disabled = i === 0;
$('creatorNext').querySelector('.btn-label').textContent =
i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
},
step(dir) {
const i = SECTIONS.indexOf(this.section);
if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
this.go(next);
},
/* --- control builders ------------------------------------------------- */
slider(label, value, min, max, step, format, onInput) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<input class="slider" type="range">`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const input = wrap.querySelector('input');
input.min = min; input.max = max; input.step = step; input.value = value;
val.textContent = format(value);
input.addEventListener('input', () => {
const v = Number(input.value);
val.textContent = format(v);
onInput(v);
});
return wrap;
},
stepper(label, value, min, max, onChange) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<div class="stepper">
<button type="button" aria-label="Previous">&minus;</button>
<span class="stepval"></span>
<button type="button" aria-label="Next">+</button>
</div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const range = wrap.querySelector('.ctrl-val');
const out = wrap.querySelector('.stepval');
let v = value;
let lo = min;
let hi = max;
const paint = () => {
out.textContent = v < 0 ? 'None' : String(v);
range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}${hi}`;
};
const set = (next) => {
if (next < lo) next = hi; // wraps, so browsing a long list is quick
if (next > hi) next = lo;
v = next;
paint();
onChange(v);
};
/* Changing a garment changes how many variants it has; the variant control
is re-bounded in place rather than rebuilt. */
const setBounds = (newMax, newValue) => {
hi = typeof newMax === 'number' ? newMax : hi;
if (typeof newValue === 'number') v = newValue;
if (v > hi) v = lo;
paint();
};
wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
paint();
return { el: wrap, set, setBounds, get value() { return v; } };
},
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
/* A colour chart is 16 columns wide, so it always takes the full form. */
wrap.className = 'ctrl span2';
wrap.innerHTML = `
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
<div class="swatches"></div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const row = wrap.querySelector('.swatches');
val.textContent = String(value);
(palette || []).forEach((c, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'swatch' + (i === value ? ' on' : '');
b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
b.title = `Colour ${i}`;
b.addEventListener('click', () => {
row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
b.classList.add('on');
val.textContent = String(i);
onPick(i);
});
row.appendChild(b);
});
return wrap;
},
/* --- panes ------------------------------------------------------------ */
renderAll() {
this.renderHeritage();
this.renderFace();
this.renderHair();
this.renderBody();
this.renderClothing();
this.renderIdentity();
},
pane(section) {
const el = document.querySelector(`.pane[data-section="${section}"]`);
el.innerHTML = '';
return el;
},
renderHeritage() {
const p = this.pane('heritage');
const a = this.appearance;
const seg = document.createElement('div');
seg.className = 'seg';
seg.innerHTML = `
<button type="button" data-g="m">Male</button>
<button type="button" data-g="f">Female</button>`;
seg.querySelectorAll('button').forEach((b) => {
b.classList.toggle('on', b.dataset.g === a.model);
b.addEventListener('click', async () => {
if (b.dataset.g === this.appearance.model) return;
const res = await post('creator:gender', { gender: b.dataset.g });
if (!res.ok) return;
this.absorb(res);
this.renderAll();
this.paintTabs();
});
});
p.appendChild(seg);
p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.father = v;
poke('creator:parent', { key: 'father', value: v });
}));
p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.mother = v;
poke('creator:parent', { key: 'mother', value: v });
}));
p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
},
renderFace() {
const p = this.pane('face');
const a = this.appearance;
(this.schema.features || []).forEach((f) => {
const key = String(f.id);
const v = a.features[key] || 0;
p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
});
p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
a.eyeColour = v;
poke('creator:eyes', { value: v });
}));
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
},
renderHair() {
const p = this.pane('hair');
const a = this.appearance;
const maxHair = (this.limits && this.limits.hair) || 0;
const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
a.hair.style = v;
poke('creator:hair', { style: v });
});
p.appendChild(st.el);
p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
a.hair.colour = i;
poke('creator:hair', { colour: i });
}));
p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
a.hair.highlight = i;
poke('creator:hair', { highlight: i });
}));
/* Only the hair-tinted overlays belong here; skin and make-up sit with
the face, which keeps either pane from becoming a wall of controls. */
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
},
overlayControls(p, list) {
const a = this.appearance;
list.forEach((o) => {
const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
ov.index = v;
poke('creator:overlay', { key: o.key, index: v });
});
p.appendChild(s.el);
p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
(v) => `${Math.round(v * 100)}%`,
(v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));
if (o.tint) {
const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
ov.colour = i;
poke('creator:overlay', { key: o.key, colour: i });
}));
}
});
},
renderBody() {
const p = this.pane('body');
const a = this.appearance;
const note = document.createElement('p');
note.className = 'sub';
note.textContent = 'Build is carried by the heritage blend and these two proportions.';
p.appendChild(note);
['13', '19'].forEach((id) => {
const f = (this.schema.features || []).find((x) => String(x.id) === id);
if (!f) return;
p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
});
},
renderClothing() {
const p = this.pane('clothing');
const a = this.appearance;
(this.schema.components || []).forEach((c) => {
const cc = a.components[c.key] || { drawable: 0, texture: 0 };
const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };
const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
cc.texture = v;
poke('creator:component', { key: c.key, texture: v });
});
const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
cc.drawable = v;
const res = await post('creator:component', { key: c.key, drawable: v });
if (res && res.ok) {
cc.texture = res.texture;
tex.setBounds(res.maxTexture, res.texture);
}
});
p.appendChild(draw.el);
p.appendChild(tex.el);
});
(this.schema.props || []).forEach((pr) => {
const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
pp.drawable = v;
poke('creator:prop', { key: pr.key, drawable: v });
});
p.appendChild(s.el);
});
},
renderIdentity() {
const p = this.pane('identity');
p.innerHTML = `
<div class="field" data-field="firstName">
<label for="firstName">First name</label>
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
<p class="hint">The name people will call you.</p>
</div>
<div class="field" data-field="lastName">
<label for="lastName">Last name</label>
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
<p class="hint">Family name. Must be unique on this server.</p>
</div>
<div class="field" data-field="dob">
<label for="dob">Date of birth</label>
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
<p class="hint">YYYY-MM-DD</p>
</div>
<div class="field" data-field="backstory">
<label for="backstory">Backstory</label>
<textarea id="backstory" maxlength="2000"
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
<p class="counter"><span id="storyCount">0</span> / 2000</p>
</div>`;
$('firstName').addEventListener('input', () => {
const [ok, msg] = check.name($('firstName').value);
mark('firstName', ok, msg || 'The name people will call you.');
});
$('lastName').addEventListener('input', () => {
const [ok, msg] = check.name($('lastName').value);
mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
});
$('dob').addEventListener('input', () => {
const [ok, msg] = check.dob($('dob').value);
mark('dob', ok, msg || 'YYYY-MM-DD');
});
$('backstory').addEventListener('input', () => {
$('storyCount').textContent = String($('backstory').value.length);
});
},
/* --- drag to turn ----------------------------------------------------- */
initDrag() {
const tt = $('turntable');
let dragging = false;
let lastX = 0;
let pending = 0;
let frame = null;
const flush = () => {
frame = null;
if (pending !== 0) {
poke('creator:rotate', { delta: pending });
pending = 0;
}
};
tt.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
tt.classList.add('dragging');
tt.setPointerCapture(e.pointerId);
});
tt.addEventListener('pointermove', (e) => {
if (!dragging) return;
pending += (e.clientX - lastX) * 0.42;
lastX = e.clientX;
if (!frame) frame = requestAnimationFrame(flush);
});
const stop = (e) => {
if (!dragging) return;
dragging = false;
tt.classList.remove('dragging');
try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
};
tt.addEventListener('pointerup', stop);
tt.addEventListener('pointercancel', stop);
},
async cancel() {
await post('creator:cancel', {});
showScreen('characters');
Characters.render();
},
async submit() {
const first = $('firstName') ? $('firstName').value.trim() : '';
const last = $('lastName') ? $('lastName').value.trim() : '';
const dob = $('dob') ? $('dob').value.trim() : '';
if (this.section !== 'identity') this.go('identity');
const [fOk, fMsg] = check.name(first);
if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
const [lOk, lMsg] = check.name(last);
if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
const [dOk, dMsg] = check.dob(dob);
if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }
const btn = $('creatorNext');
busy(btn, true);
showError($('creatorError'), null);
const res = await post('creator:submit', {
firstName: first,
lastName: last,
dob,
backstory: $('backstory').value,
});
busy(btn, false);
if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');
stamp('Approved');
Characters.load(res.characters || [], Characters.max);
showScreen('characters');
},
};
/* =========================================================================
04 / spawn
========================================================================= */
const Spawn = {
spawns: [],
selected: null,
last: null,
init() {
$('spawnConfirm').addEventListener('click', () => this.confirm());
},
load(spawns, lastPosition, character) {
this.spawns = spawns || [];
this.last = lastPosition || null;
this.selected = null;
if (character) {
$('spawnSub').textContent =
`${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
}
this.render();
},
render() {
const ul = $('spawnList');
const pins = $('pins');
ul.innerHTML = '';
pins.innerHTML = '';
const entries = this.spawns.slice();
if (this.last) {
entries.unshift({
id: 'last',
label: 'Where you left off',
area: 'Last known position',
blurb: 'Pick up exactly where this character stopped.',
map: null,
});
}
entries.forEach((sp) => {
const li = document.createElement('li');
li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<div class="spawnhead">
<span class="spawnname"></span>
<span class="spawnarea"></span>
</div>
<p class="spawnblurb"></p>`;
li.querySelector('.spawnname').textContent = sp.label;
li.querySelector('.spawnarea').textContent = sp.area;
li.querySelector('.spawnblurb').textContent = sp.blurb;
const pick = () => this.select(sp.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
if (sp.map) {
const pin = document.createElement('div');
pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
pin.style.left = `${sp.map.x * 100}%`;
pin.style.top = `${sp.map.y * 100}%`;
pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
pin.querySelector('.pin-label').textContent = sp.label;
pins.appendChild(pin);
}
});
$('spawnConfirm').disabled = this.selected === null;
},
select(id) {
this.selected = id;
this.render();
showError($('spawnError'), null);
if (id !== 'last') poke('spawn:preview', { id });
},
async confirm() {
if (this.selected === null) return;
const btn = $('spawnConfirm');
busy(btn, true);
const res = await post('spawn:confirm', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
stamp('Issued');
},
};
/* =========================================================================
messages from the client script
========================================================================= */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'stage') {
if (msg.stage === 'auth') {
const d = msg.data || {};
if (d.serverName) $('markName').textContent = d.serverName;
if (d.serverTag) $('markTag').textContent = d.serverTag;
$('fileSerial').textContent = fileSerial();
showScreen('auth');
setTimeout(() => $('username').focus(), 520);
} else if (msg.stage === 'flying' || msg.stage === 'live') {
/* the camera is doing the talking now */
SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
document.body.classList.add('hidden');
} else {
showScreen(msg.stage);
}
}
if (msg.action === 'creator:sync' && msg.data) {
Creator.absorb(msg.data);
Creator.renderAll();
}
});
function fileSerial() {
const y = new Date().getFullYear();
const n = String(Math.floor(Math.random() * 900000) + 100000);
return `${y}-${n}`;
}
/* Escape backs out of the creator; nothing else steals keys from the game. */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current === 'creator') Creator.cancel();
});
document.addEventListener('DOMContentLoaded', () => {
Auth.init();
Characters.init();
Creator.init();
Spawn.init();
Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Los Santos RP</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="hidden">
<div class="edge" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<!-- ======================= persistent chrome ======================= -->
<header class="topbar">
<div class="mark">
<svg class="seal" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="46" class="seal-ring"/>
<circle cx="50" cy="50" r="37" class="seal-ring thin"/>
<circle cx="50" cy="50" r="41.5" class="seal-ticks"/>
<path class="seal-star" d="M50 30 L54.7 43.8 L69.4 44 L57.6 52.7 L62 66.6
L50 58.2 L38 66.6 L42.4 52.7 L30.6 44 L45.3 43.8 Z"/>
</svg>
<span class="mark-lines">
<span class="mark-name" id="markName">LOS SANTOS</span>
<span class="mark-tag" id="markTag">ROLEPLAY</span>
</span>
</div>
<!-- A real sequence: you cannot be placed before you have an identity, and you
cannot have an identity before the file exists. Printed as a routing box. -->
<nav class="stages" aria-label="Application progress">
<button class="stagechip" data-stage="auth" type="button" disabled>
<em>01</em><span>Credentials</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="characters" type="button" disabled>
<em>02</em><span>Identity</span>
</button>
<i class="stagelink" aria-hidden="true"></i>
<button class="stagechip" data-stage="spawn" type="button" disabled>
<em>03</em><span>Placement</span>
</button>
</nav>
<div class="filetag">
<span class="filetag-k">File</span>
<span id="fileSerial">0000-000000</span>
</div>
</header>
<!-- ======================= 01 / auth ======================= -->
<section class="screen" id="screen-auth" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Office of Vital Records</span>
</div>
<div class="lh-form data">VR&#8209;1</div>
</div>
<div class="rail-inner">
<h1 class="head" id="authTitle">Open a file</h1>
<p class="sub" id="authSub">
Everything you do in this city is recorded against this file. Choose a name you will
remember and a password you will not share.
</p>
<form class="form" id="authForm" novalidate>
<div class="field" data-field="username">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username"
spellcheck="false" maxlength="20" placeholder="dlarue">
<p class="hint" id="hint-username">3&ndash;20 characters. Letters, numbers and underscore.</p>
</div>
<div class="field" data-field="password">
<label for="password">Password</label>
<div class="withbtn">
<input id="password" name="password" type="password" autocomplete="current-password"
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<button class="reveal-btn" id="revealPw" type="button" aria-label="Show password">Show</button>
</div>
<p class="hint" id="hint-password">At least 8 characters, with a letter and a number.</p>
</div>
<!-- only present while registering; height animates, nothing reloads -->
<div class="collapse" id="confirmWrap" aria-hidden="true">
<div class="collapse-inner">
<div class="field" data-field="confirm">
<label for="confirm">Repeat password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
maxlength="72" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;">
<p class="hint" id="hint-confirm">Both entries must match.</p>
</div>
</div>
</div>
<p class="formerror" id="authError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="authSubmit" type="submit">
<span class="btn-label">Open the file</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</form>
<p class="switch">
<span id="switchText">Already have a file?</span>
<button class="linkbtn" id="switchMode" type="button">Sign in instead</button>
</p>
</div>
</div>
</section>
<!-- ======================= 02 / characters ======================= -->
<section class="screen" id="screen-characters" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Register of Persons</span>
</div>
<div class="lh-form data">VR&#8209;4</div>
</div>
<div class="rail-inner">
<h1 class="head">Who are you today?</h1>
<div class="kicker">
<span class="eyebrow">Registered identities</span>
<span class="hair" aria-hidden="true"></span>
<span class="data" id="charCount">0 / 3</span>
</div>
<ul class="charlist" id="charList"></ul>
<p class="formerror" id="charError" role="alert" aria-live="assertive" hidden></p>
<div class="charactions">
<button class="btn primary" id="charEnter" type="button" disabled>
<span class="btn-label">Enter the city</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
<button class="btn ghost danger" id="charDelete" type="button" disabled>Delete</button>
</div>
</div>
</div>
</section>
<!-- ======================= 03 / creator ======================= -->
<section class="screen" id="screen-creator" hidden>
<div class="rail wide">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Application for Identity</span>
</div>
<button class="linkbtn danger" id="creatorCancel" type="button">Discard</button>
</div>
<nav class="tabs" id="creatorTabs" aria-label="Editor sections">
<button class="tab is-on" data-section="heritage" type="button">Heritage</button>
<button class="tab" data-section="face" type="button">Face</button>
<button class="tab" data-section="hair" type="button">Hair</button>
<button class="tab" data-section="body" type="button">Body</button>
<button class="tab" data-section="clothing" type="button">Clothing</button>
<button class="tab" data-section="identity" type="button">Identity</button>
</nav>
<div class="rail-inner creator">
<div class="panes" id="creatorPanes">
<div class="pane is-on" data-section="heritage"></div>
<div class="pane" data-section="face"></div>
<div class="pane" data-section="hair"></div>
<div class="pane" data-section="body"></div>
<div class="pane" data-section="clothing"></div>
<div class="pane" data-section="identity"></div>
</div>
</div>
<p class="formerror" id="creatorError" role="alert" aria-live="assertive" hidden></p>
<div class="creator-foot">
<button class="btn ghost" id="creatorPrev" type="button">Back</button>
<button class="btn primary" id="creatorNext" type="button">
<span class="btn-label">Next</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
</div>
<!-- The plate the subject is photographed against. It is also the drag
surface, so the frame you aim at is the thing that turns. -->
<div class="turntable" id="turntable" title="Drag to turn">
<span class="plate-corner tl" aria-hidden="true"></span>
<span class="plate-corner tr" aria-hidden="true"></span>
<span class="plate-corner bl" aria-hidden="true"></span>
<span class="plate-corner br" aria-hidden="true"></span>
<div class="plate-cap">
<span class="plate-cap-k">Subject</span>
<span class="turnhint"><span class="turnicon" aria-hidden="true"></span>Drag to turn</span>
</div>
</div>
</section>
<!-- ======================= 04 / spawn ======================= -->
<section class="screen" id="screen-spawn" hidden>
<div class="rail">
<div class="rail-scrim" aria-hidden="true"></div>
<div class="letterhead">
<div class="lh-dept">
<span class="lh-city">City of Los Santos</span>
<span class="lh-office">Notice of Placement</span>
</div>
<div class="lh-form data">VR&#8209;9</div>
</div>
<div class="rail-inner">
<h1 class="head">Where does the day start?</h1>
<p class="sub" id="spawnSub">Pick a district. You can move once you are on the ground.</p>
<ul class="spawnlist" id="spawnList"></ul>
<p class="formerror" id="spawnError" role="alert" aria-live="assertive" hidden></p>
<button class="btn primary" id="spawnConfirm" type="button" disabled>
<span class="btn-label">Place me here</span>
<span class="btn-spin" aria-hidden="true"></span>
</button>
</div>
</div>
<!-- A plan sheet from the same office: a drawing of the county, not a screenshot. -->
<div class="chart" id="chart">
<div class="chart-paper" aria-hidden="true"></div>
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M50 0 L0 0 0 50" fill="none" stroke="rgba(25,28,24,.10)" stroke-width="1"/>
</pattern>
<pattern id="grid10" width="250" height="250" patternUnits="userSpaceOnUse">
<path d="M250 0 L0 0 0 250" fill="none" stroke="rgba(25,28,24,.18)" stroke-width="1"/>
</pattern>
</defs>
<rect width="1000" height="1000" fill="url(#grid)"/>
<rect width="1000" height="1000" fill="url(#grid10)"/>
<rect class="chart-frame" x="30" y="30" width="940" height="940"/>
<path class="chart-ticks"
d="M30 92 L30 30 L92 30 M908 30 L970 30 L970 92
M970 908 L970 970 L908 970 M92 970 L30 970 L30 908"/>
<path id="landmass"
d="M318 62 L470 44 L604 74 L712 66 L806 128 L858 248 L872 372
L842 468 L868 556 L826 664 L742 742 L648 812 L560 872
L462 918 L392 902 L342 836 L296 742 L262 640 L240 528
L206 424 L196 320 L232 196 L272 108 Z"/>
<!-- the western coastline is the one edge worth drawing emphatically -->
<path id="coastline"
d="M262 640 L240 528 L206 424 L196 320 L232 196 L272 108 L318 62"/>
</svg>
<div class="pins" id="pins"></div>
<!-- every drawing from this office carries a title block -->
<div class="titleblock">
<div class="tb-row tb-main">
<span class="tb-k">Sheet</span>
<span class="tb-v">Los Santos County</span>
</div>
<div class="tb-row">
<span class="tb-k">Scale</span><span class="tb-v">Not to scale</span>
<span class="tb-k">Sheet</span><span class="tb-v">1 of 1</span>
</div>
</div>
</div>
</section>
<!-- the one bold moment: the city stamps your file -->
<div class="stamp" id="stamp" aria-hidden="true">
<span class="stamp-word" id="stampText">Filed</span>
<span class="stamp-sub">Office of Vital Records</span>
</div>
<script src="app.js"></script>
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
/* ---------------------------------------------------------------------------
Los Santos RP - design tokens.
The interface is a set of documents from the city's Office of Vital Records,
lying on a dark desk while the city runs on behind them. Paper is the only
surface; the game is the room the desk is in. Nothing here is a dark glass
panel, because a city clerk does not hand you one.
Three typographic voices, and the rule between them is literal:
preprinted Archivo, expanded, caps what the form was printed with
typed IBM Plex Mono what somebody entered on it
prose IBM Plex Sans plain-English notes in the margin
Shared verbatim by rp_loading and rp_ui so every screen reads as one product.
--------------------------------------------------------------------------- */
@font-face {
font-family: 'Archivo';
src: url('fonts/archivo-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 62% 125%;
font-display: block;
}
@font-face {
font-family: 'Plex Sans';
src: url('fonts/plexsans-var.woff2') format('woff2-variations');
font-weight: 100 700;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-400.woff2') format('woff2');
font-weight: 400;
font-display: block;
}
@font-face {
font-family: 'Plex Mono';
src: url('fonts/plex-500.woff2') format('woff2');
font-weight: 500;
font-display: block;
}
:root {
/* --- the room ---------------------------------------------------------- */
--room: #0b0c0a; /* the dark the desk stands in, never pure black */
--desk: #1a1b16; /* desk surface where the lamp reaches it */
--lamp: rgba(224, 196, 128, 0.10);
/* --- the paper --------------------------------------------------------- */
--stock: #e3dbc2; /* manila card stock: warm, but grey-olive, not cream */
--stock-hi: #efe9d7; /* the top sheet, directly under the lamp */
--stock-2: #cbc09f; /* the sheet underneath, and every tab edge */
--stock-3: #b3a888; /* deepest fold */
/* --- what is written on it --------------------------------------------- */
--ink: #191c18; /* ballpoint black with a green cast */
--ink-2: #4e5449; /* second-rank text */
--ink-3: #7d8175; /* captions, disabled, ruled lines */
/* --- the three official inks, each with exactly one job ---------------- */
--canary: #d9b23c; /* municipal form yellow: what is selected, now */
--canary-dp: #a8801d;
--stamp: #7e2b26; /* oxblood rubber stamp: filed, refused, destroyed */
--verdi: #2e6155; /* municipal teal: checked, valid, approved */
/* rules printed on the form */
--rule: rgba(25, 28, 24, 0.22);
--rule-soft: rgba(25, 28, 24, 0.11);
--rule-firm: rgba(25, 28, 24, 0.55);
/* paper fibre, laid over every sheet at low opacity */
--fiber: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23f)'/%3E%3C/svg%3E");
/* uneven rubber-stamp ink: large soft blobs, not fine noise */
--inkmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.028' numOctaves='4' seed='11'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2.4 0 0 0 -0.32'/%3E%3C/filter%3E%3Crect width='400' height='200' filter='url(%23r)'/%3E%3C/svg%3E");
/* a sheet of paper casts a real shadow onto the desk */
--lift-1: 0 1px 0 rgba(255,255,255,.28) inset, 0 10px 22px -8px rgba(0,0,0,.7);
--lift-2: 0 1px 0 rgba(255,255,255,.34) inset, 0 26px 48px -18px rgba(0,0,0,.82);
--sheet: clamp(360px, 27vw, 460px);
--sheet-w: clamp(440px, 34vw, 580px);
--gut-x: clamp(26px, 2.4vw, 40px);
--gut-y: clamp(22px, 2.2vw, 34px);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: var(--room);
color: var(--ink);
font-family: 'Plex Sans', system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* ---------------------------------------------------------------------------
The three voices.
--------------------------------------------------------------------------- */
/* preprinted: everything the form arrived with */
.pp,
.eyebrow,
.head,
label,
.btn,
.tab,
.ctrl-label,
.charname,
.spawnname,
.stagechip span {
font-family: 'Archivo', system-ui, sans-serif;
font-variation-settings: 'wdth' 112, 'wght' 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* typed: everything a person put on the form */
.data,
.typed,
input,
textarea,
.ctrl-val,
.stepval,
.charmeta,
.charmoney,
.counter,
.stagechip em,
.filetag {
font-family: 'Plex Mono', ui-monospace, monospace;
font-variation-settings: normal;
text-transform: none;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
/* prose: the plain-English notes, the only voice allowed sentence case */
.sub,
.hint,
.spawnblurb,
.switch {
font-family: 'Plex Sans', system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
}
.eyebrow {
font-size: 10px;
font-variation-settings: 'wdth' 104, 'wght' 600;
letter-spacing: 0.19em;
color: var(--ink-3);
}
.data {
font-size: 11.5px;
color: var(--ink-2);
}
.rule { height: 1px; background: var(--rule); border: 0; }
/* Focus is always the canary, always visible, and never subtle. */
:focus-visible {
outline: 2px solid var(--canary-dp);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
-343
View File
@@ -1,343 +0,0 @@
-- JustRP — Client-side core
-- Manages NUI screens, cameras, spawning and position sync
local inSession = false
local charLoaded = false
local camHandle = nil
local saveTimer = 0
-- ─── NUI helper ─────────────────────────────────────────────────────────────
local function nuiMsg(action, data)
SendNUIMessage({ action = action, data = data or {} })
end
-- ─── On resource start (loading screen is already showing) ──────────────────
AddEventHandler("onClientMapStart", function()
Wait(1500) -- give the game a moment to settle
TriggerServerEvent("justrp:clientReady")
end)
-- ─── Session start from server ───────────────────────────────────────────────
RegisterNetEvent("justrp:startSession")
AddEventHandler("justrp:startSession", function()
-- Freeze world while auth is happening
SetEntityVisible(PlayerPedId(), false, false)
FreezeEntityPosition(PlayerPedId(), true)
DisplayHud(false)
DisplayRadar(false)
-- Shut loading screen, then show auth UI
Wait(800)
ShutdownLoadingScreenNui()
Wait(300)
SetNuiFocus(true, true)
nuiMsg("showAuth")
inSession = true
end)
-- ─── Auth results ────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:auth:ok")
AddEventHandler("justrp:auth:ok", function(accountId, username)
nuiMsg("authOk", { accountId = accountId, username = username })
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:auth:fail")
AddEventHandler("justrp:auth:fail", function(err)
nuiMsg("authFail", { error = err })
end)
-- ─── Character list from server ──────────────────────────────────────────────
RegisterNetEvent("justrp:chars:list")
AddEventHandler("justrp:chars:list", function(chars)
nuiMsg("showChars", { characters = chars })
end)
-- ─── Character creation result ───────────────────────────────────────────────
RegisterNetEvent("justrp:char:created")
AddEventHandler("justrp:char:created", function(charId)
-- Reload character list so the new one shows
TriggerServerEvent("justrp:chars:load")
end)
RegisterNetEvent("justrp:char:createFail")
AddEventHandler("justrp:char:createFail", function(err)
nuiMsg("charCreateFail", { error = err })
end)
-- ─── Character selected → load appearance ────────────────────────────────────
RegisterNetEvent("justrp:char:loaded")
AddEventHandler("justrp:char:loaded", function(ch)
-- Apply appearance to player ped
local ped = PlayerPedId()
local model = ch.gender == 1 and GetHashKey("mp_f_freemode_01") or GetHashKey("mp_m_freemode_01")
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
-- Face blend
SetPedHeadBlendData(
ped,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
math.floor(ch.face_blend * 45), math.floor(ch.face_blend * 45),
0,
ch.face_blend, ch.face_blend, 0.0, false
)
-- Skin overlay (skin tone)
SetPedMicroblendData(ped, ch.skin_tone, ch.skin_tone, ch.skin_tone)
-- Eyes
SetPedEyeColor(ped, ch.eye_color)
-- Hair
SetPedComponentVariation(ped, 2, ch.hair_style, 0, 2)
SetPedHairColor(ped, ch.hair_color, 0)
-- Body shape
SetPedFaceFeature(ped, 16, (ch.body_weight - 0.5) * 2) -- body weight feature
-- Outfit
SetPedComponentVariation(ped, 11, ch.outfit_top, 0, 2)
SetPedComponentVariation(ped, 4, ch.outfit_pants, 0, 2)
SetPedComponentVariation(ped, 6, ch.outfit_shoes, 0, 2)
-- Tell NUI to show spawn screen
nuiMsg("showSpawn", {
name = ch.firstname .. " " .. ch.lastname,
})
end)
-- ─── Spawn ───────────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:spawn:go")
AddEventHandler("justrp:spawn:go", function(pos)
local ped = PlayerPedId()
-- Teleport to position and unfreeze
SetEntityCoords(ped, pos.x, pos.y, pos.z, false, false, false, true)
SetEntityHeading(ped, pos.h)
-- Cinematic camera fly-down
local camX = pos.x + math.sin(math.rad(pos.h)) * 200
local camY = pos.y + math.cos(math.rad(pos.h)) * 200
camHandle = CreateCameraWithParams(
"DEFAULT_SCRIPTED_CAMERA",
camX, camY, pos.z + 180,
0.0, 0.0, 0.0,
60.0, false, 0
)
SetCamActive(camHandle, true)
RenderScriptCams(true, true, 1000, true, true)
-- Animate camera flying down to player
local steps = 80
Citizen.CreateThread(function()
for i = 1, steps do
local t = i / steps
local ease = 1 - math.pow(1 - t, 3) -- ease out cubic
local cx = camX + (pos.x - camX) * ease
local cy = camY + (pos.y - camY) * ease
local cz = (pos.z + 180) + ((pos.z + 2) - (pos.z + 180)) * ease
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pos.x, pos.y, pos.z)
Wait(16)
end
-- Transition back to player cam
RenderScriptCams(false, true, 1200, true, true)
Wait(1300)
DestroyCam(camHandle, false)
camHandle = nil
-- Reveal player
SetEntityVisible(ped, true, false)
FreezeEntityPosition(ped, false)
DisplayHud(true)
DisplayRadar(true)
SetNuiFocus(false, false)
nuiMsg("hide")
charLoaded = true
end)
end)
-- ─── NUI Callbacks ───────────────────────────────────────────────────────────
-- Auth
RegisterNUICallback("authRegister", function(data, cb)
TriggerServerEvent("justrp:auth:register", data.username, data.password)
cb("ok")
end)
RegisterNUICallback("authLogin", function(data, cb)
TriggerServerEvent("justrp:auth:login", data.username, data.password)
cb("ok")
end)
-- Character management
RegisterNUICallback("charSelect", function(data, cb)
TriggerServerEvent("justrp:char:select", tonumber(data.id))
cb("ok")
end)
RegisterNUICallback("charCreate", function(data, cb)
TriggerServerEvent("justrp:char:create", data)
cb("ok")
end)
-- Spawn selection
RegisterNUICallback("spawnRequest", function(data, cb)
TriggerServerEvent("justrp:spawn:request", data.location)
cb("ok")
end)
-- Character creation — camera rotation control
RegisterNUICallback("rotateCam", function(data, cb)
if camHandle then
-- data.delta is horizontal rotation amount
local heading = GetEntityHeading(PlayerPedId())
SetEntityHeading(PlayerPedId(), heading - (data.delta or 0) * 0.4)
end
cb("ok")
end)
-- ─── Character creation preview camera ───────────────────────────────────────
local charCamActive = false
local charCamAngle = 0.0
RegisterNUICallback("startCharCam", function(data, cb)
if charCamActive then
cb("ok")
return
end
charCamActive = true
local ped = PlayerPedId()
SetEntityVisible(ped, true, false)
camHandle = CreateCamera("DEFAULT_SCRIPTED_CAMERA", false)
SetCamActive(camHandle, true)
RenderScriptCams(true, false, 0, true, true)
Citizen.CreateThread(function()
while charCamActive do
local pedPos = GetEntityCoords(ped)
local angle = charCamAngle
local dist = 1.6
local cx = pedPos.x + math.sin(math.rad(angle)) * dist
local cy = pedPos.y - math.cos(math.rad(angle)) * dist
local cz = pedPos.z + 0.7
SetCamCoord(camHandle, cx, cy, cz)
PointCamAtCoord(camHandle, pedPos.x, pedPos.y, pedPos.z + 0.7)
charCamAngle = charCamAngle + 0.15 -- slow auto-orbit
Wait(16)
end
end)
cb("ok")
end)
RegisterNUICallback("stopCharCam", function(data, cb)
charCamActive = false
if camHandle then
RenderScriptCams(false, true, 600, true, true)
Wait(700)
DestroyCam(camHandle, false)
camHandle = nil
end
cb("ok")
end)
RegisterNUICallback("charCamDrag", function(data, cb)
charCamAngle = charCamAngle + (data.delta or 0) * 0.5
charCamActive = true -- stop auto-orbit briefly
cb("ok")
end)
-- Apply live appearance updates during char creation
RegisterNUICallback("applyAppearance", function(data, cb)
local ped = PlayerPedId()
if data.gender ~= nil then
local model = data.gender == 1
and GetHashKey("mp_f_freemode_01")
or GetHashKey("mp_m_freemode_01")
if not HasModelLoaded(model) then
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
end
SetPlayerModel(PlayerId(), model)
SetModelAsNoLongerNeeded(model)
ped = PlayerPedId()
end
if data.face_blend ~= nil then
SetPedHeadBlendData(
ped,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
math.floor(data.face_blend * 45),
math.floor(data.face_blend * 45),
0,
data.face_blend, data.face_blend, 0.0, false
)
end
if data.skin_tone ~= nil then
SetPedMicroblendData(ped, data.skin_tone, data.skin_tone, data.skin_tone)
end
if data.eye_color ~= nil then
SetPedEyeColor(ped, data.eye_color)
end
if data.hair_style ~= nil or data.hair_color ~= nil then
SetPedComponentVariation(ped, 2, data.hair_style or 0, 0, 2)
SetPedHairColor(ped, data.hair_color or 0, 0)
end
if data.body_weight ~= nil then
SetPedFaceFeature(ped, 16, (data.body_weight - 0.5) * 2)
end
if data.outfit_top ~= nil then
SetPedComponentVariation(ped, 11, data.outfit_top, 0, 2)
end
if data.outfit_pants ~= nil then
SetPedComponentVariation(ped, 4, data.outfit_pants, 0, 2)
end
if data.outfit_shoes ~= nil then
SetPedComponentVariation(ped, 6, data.outfit_shoes, 0, 2)
end
cb("ok")
end)
-- ─── Position auto-save ───────────────────────────────────────────────────────
Citizen.CreateThread(function()
while true do
Wait(60000)
if charLoaded then
local ped = PlayerPedId()
local pos = GetEntityCoords(ped)
local hdg = GetEntityHeading(ped)
TriggerServerEvent("justrp:savePosition", pos.x, pos.y, pos.z, hdg)
end
end
end)
print("[JustRP] Client core loaded.")
-25
View File
@@ -1,25 +0,0 @@
fx_version 'cerulean'
game 'gta5'
author 'JustRP'
description 'JustRP — Core authentication, character management and session control'
version '1.0.0'
loadscreen 'html/loading.html'
loadscreen_manual_shutdown 'yes'
loadscreen_cursor_shown 'yes'
ui_page 'html/app.html'
client_scripts {
'client/main.lua',
}
server_scripts {
'server/main.lua',
}
files {
'html/loading.html',
'html/app.html',
}
File diff suppressed because it is too large Load Diff
-469
View File
@@ -1,469 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Los Santos County — Resident Processing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--ink: #10131A;
--ink-2: #171B24;
--paper: #E8E4DA;
--paper-2: #D6D0C2;
--stamp: #C2462E;
--rule: rgba(232,228,218,.14);
--rule-2: rgba(232,228,218,.07);
--dim: rgba(232,228,218,.42);
--dim-2: rgba(232,228,218,.24);
--sig: 'Barlow Condensed','Oswald',sans-serif;
--body: 'IBM Plex Sans',system-ui,sans-serif;
--mono: 'IBM Plex Mono',ui-monospace,monospace;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--ink);color:var(--paper);font-family:var(--body)}
/* ── Survey footage ─────────────────────────────── */
#flyover{position:fixed;inset:0;width:100%;height:100%;filter:saturate(.55) contrast(1.06)}
.footage-tint{position:fixed;inset:0;pointer-events:none;
background:
radial-gradient(ellipse 80% 60% at 50% 42%,transparent 0%,rgba(16,19,26,.5) 68%,rgba(16,19,26,.94) 100%),
linear-gradient(180deg,rgba(16,19,26,.88) 0%,transparent 22%,transparent 62%,rgba(16,19,26,.96) 100%);
}
.scanlines{position:fixed;inset:0;pointer-events:none;opacity:.5;
background:repeating-linear-gradient(180deg,rgba(0,0,0,.22) 0px,rgba(0,0,0,.22) 1px,transparent 1px,transparent 3px)}
.grain{position:fixed;inset:-50%;pointer-events:none;opacity:.19;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grainshift .7s steps(3) infinite}
@keyframes grainshift{
0%{transform:translate(0,0)}33%{transform:translate(-3%,2%)}66%{transform:translate(2%,-3%)}100%{transform:translate(0,0)}}
/* ── Registration marks ─────────────────────────── */
.reg{position:fixed;width:22px;height:22px;pointer-events:none;opacity:0;
animation:regIn .5s steps(2) .3s forwards}
.reg::before,.reg::after{content:'';position:absolute;background:var(--dim-2)}
.reg::before{left:0;right:0;top:50%;height:1px}
.reg::after{top:0;bottom:0;left:50%;width:1px}
.reg-tl{top:26px;left:26px}.reg-tr{top:26px;right:26px}
.reg-bl{bottom:26px;left:26px}.reg-br{bottom:26px;right:26px}
@keyframes regIn{to{opacity:1}}
/* ── Header slate ───────────────────────────────── */
.slate{position:fixed;top:26px;left:64px;right:64px;display:flex;justify-content:space-between;
align-items:flex-start;gap:24px;opacity:0;animation:fadeDown .8s cubic-bezier(.2,.8,.25,1) .35s forwards}
.slate-id{font-family:var(--mono);font-size:10px;letter-spacing:.16em;color:var(--dim);line-height:2}
.slate-id b{color:var(--paper);font-weight:500}
.slate-read{font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--dim);
text-align:right;line-height:2}
.slate-read span{color:var(--stamp)}
@keyframes fadeDown{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
/* ── Title block: left-anchored, not centered ───── */
.title{position:fixed;left:64px;top:50%;transform:translateY(-50%);max-width:620px}
.title-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.3em;color:var(--stamp);
margin-bottom:14px;opacity:0;animation:riseIn .7s cubic-bezier(.2,.8,.25,1) .7s forwards}
.title-main{font-family:var(--sig);font-weight:700;font-size:clamp(64px,9.5vw,140px);
line-height:.83;letter-spacing:-.015em;text-transform:uppercase;
opacity:0;animation:riseIn .9s cubic-bezier(.2,.8,.25,1) .85s forwards}
.title-main em{font-style:normal;display:block;color:var(--stamp)}
.title-rule{height:1px;background:var(--rule);margin:26px 0 18px;transform:scaleX(0);
transform-origin:left;animation:ruleOut .9s cubic-bezier(.2,.8,.25,1) 1.25s forwards}
@keyframes ruleOut{to{transform:scaleX(1)}}
.title-sub{font-size:13px;line-height:1.75;color:var(--dim);max-width:390px;
opacity:0;animation:riseIn .7s cubic-bezier(.2,.8,.25,1) 1.4s forwards}
@keyframes riseIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
/* ── Ordinance list ─────────────────────────────── */
.ord{position:fixed;right:64px;top:50%;transform:translateY(-50%);width:290px;
opacity:0;transition:opacity .9s cubic-bezier(.2,.8,.25,1)}
.ord.on{opacity:1}
.ord-head{font-family:var(--mono);font-size:10px;letter-spacing:.2em;color:var(--dim-2);
padding-bottom:10px;border-bottom:1px solid var(--rule);margin-bottom:4px}
.ord-item{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid var(--rule-2);
opacity:0;transform:translateX(12px)}
.ord.on .ord-item{animation:ordIn .55s cubic-bezier(.2,.8,.25,1) forwards}
.ord.on .ord-item:nth-child(2){animation-delay:.05s}
.ord.on .ord-item:nth-child(3){animation-delay:.12s}
.ord.on .ord-item:nth-child(4){animation-delay:.19s}
.ord.on .ord-item:nth-child(5){animation-delay:.26s}
.ord.on .ord-item:nth-child(6){animation-delay:.33s}
.ord.on .ord-item:nth-child(7){animation-delay:.40s}
.ord.on .ord-item:nth-child(8){animation-delay:.47s}
@keyframes ordIn{to{opacity:1;transform:none}}
.ord-no{font-family:var(--mono);font-size:10px;color:var(--stamp);flex-shrink:0;padding-top:2px}
.ord-tx{font-size:12px;line-height:1.55;color:var(--dim)}
/* ── Processing bar ─────────────────────────────── */
.proc{position:fixed;left:64px;right:64px;bottom:52px;
opacity:0;animation:fadeUp .8s cubic-bezier(.2,.8,.25,1) 1.6s forwards}
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.proc-top{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--mono);font-size:10px;letter-spacing:.16em;margin-bottom:9px}
.proc-stage{color:var(--dim)}
.proc-pct{color:var(--paper);font-size:13px;font-variant-numeric:tabular-nums}
.proc-track{height:2px;background:rgba(232,228,218,.09);position:relative;overflow:hidden}
.proc-fill{position:absolute;inset:0 auto 0 0;width:0%;background:var(--stamp);
transition:width .45s cubic-bezier(.2,.8,.25,1)}
.proc-fill::after{content:'';position:absolute;right:0;top:-3px;bottom:-3px;width:1px;
background:var(--paper);opacity:.85}
/* segment ticks encode the real load phases */
.proc-ticks{position:relative;height:8px;margin-top:6px}
.proc-tick{position:absolute;top:0;width:1px;height:4px;background:var(--rule)}
.proc-tick span{position:absolute;top:7px;left:0;transform:translateX(-50%);
font-family:var(--mono);font-size:8px;letter-spacing:.1em;color:var(--dim-2);white-space:nowrap}
/* ── Audio control ──────────────────────────────── */
.aud{position:fixed;right:64px;bottom:52px;display:flex;align-items:center;gap:11px;
opacity:0;animation:fadeUp .8s cubic-bezier(.2,.8,.25,1) 1.85s forwards}
.aud-lb{font-family:var(--mono);font-size:9px;letter-spacing:.18em;color:var(--dim-2)}
input[type=range]{-webkit-appearance:none;appearance:none;width:82px;height:1px;
background:var(--rule);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:9px;height:9px;background:var(--paper);cursor:pointer;
transition:transform .18s cubic-bezier(.2,.8,.25,1),background .18s}
input[type=range]:hover::-webkit-slider-thumb{transform:scale(1.45);background:var(--stamp)}
input[type=range]:focus-visible{outline:1px solid var(--stamp);outline-offset:6px}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.01ms!important;transition-duration:.01ms!important}
.grain{animation:none}
}
@media (max-width:900px){
.ord{display:none}
.slate,.title,.proc{left:28px;right:28px}
.aud{right:28px}
}
</style>
</head>
<body>
<canvas id="flyover"></canvas>
<div class="footage-tint"></div>
<div class="scanlines"></div>
<div class="grain"></div>
<div class="reg reg-tl"></div><div class="reg reg-tr"></div>
<div class="reg reg-bl"></div><div class="reg reg-br"></div>
<div class="slate">
<div class="slate-id">
LOS SANTOS COUNTY · DEPT. OF RESIDENT AFFAIRS<br>
AERIAL SURVEY <b>LS-4471-A</b> · SECTOR <b>07</b>
</div>
<div class="slate-read">
ALT <span id="r-alt">1420</span> FT · HDG <span id="r-hdg">274</span>°<br>
<span id="r-clock">00:00:00</span> · REC <span id="r-rec"></span>
</div>
</div>
<div class="title">
<div class="title-eyebrow">INTAKE IN PROGRESS</div>
<h1 class="title-main">Just<em>RP</em></h1>
<div class="title-rule"></div>
<p class="title-sub">A persistent roleplay county. Your record begins the moment you land — every job, every debt, every name you make here is kept on file.</p>
</div>
<div class="ord" id="ord">
<div class="ord-head">COUNTY ORDINANCE · CONDITIONS OF RESIDENCY</div>
<div class="ord-item"><span class="ord-no">§1</span><span class="ord-tx">Remain in character within county limits.</span></div>
<div class="ord-item"><span class="ord-no">§2</span><span class="ord-tx">Your life has value. Act like it does.</span></div>
<div class="ord-item"><span class="ord-no">§3</span><span class="ord-tx">No violence without roleplay leading to it.</span></div>
<div class="ord-item"><span class="ord-no">§4</span><span class="ord-tx">Vehicles are not weapons.</span></div>
<div class="ord-item"><span class="ord-no">§5</span><span class="ord-tx">Knowledge your character never earned is knowledge they do not have.</span></div>
<div class="ord-item"><span class="ord-no">§6</span><span class="ord-tx">Losing is part of the story. Play it out.</span></div>
<div class="ord-item"><span class="ord-no">§7</span><span class="ord-tx">Staff rulings close the matter.</span></div>
</div>
<div class="proc">
<div class="proc-top">
<span class="proc-stage" id="stage">ESTABLISHING UPLINK</span>
<span class="proc-pct" id="pct">0%</span>
</div>
<div class="proc-track"><div class="proc-fill" id="fill"></div></div>
<div class="proc-ticks" id="ticks"></div>
</div>
<div class="aud">
<span class="aud-lb">ROOM TONE</span>
<input type="range" id="vol" min="0" max="100" value="35" aria-label="Ambient volume">
</div>
<script>
'use strict';
const $ = id => document.getElementById(id);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ══ Perspective city flyover ══════════════════════
Real 3D projection: blocks on a ground grid stream
toward the camera and are recycled behind it. */
const cv = $('flyover'), cx = cv.getContext('2d');
let W, H, FOCAL;
const CAM = { x: 0, y: 210, z: 0, speed: 1.42, drift: 0 };
const SPACING = 150, LANES = 11, DEPTH = 46, FAR = SPACING * DEPTH;
let blocks = [];
function seedBlocks () {
blocks = [];
for (let r = 0; r < DEPTH; r++) {
for (let l = 0; l < LANES; l++) {
// carve a boulevard down the middle lanes
if (l === Math.floor(LANES / 2)) continue;
if (Math.random() < 0.16) continue;
const gx = (l - (LANES - 1) / 2) * SPACING + (Math.random() - .5) * 34;
blocks.push({
x: gx,
z: r * SPACING + Math.random() * 60,
w: 46 + Math.random() * 60,
d: 46 + Math.random() * 60,
h: 34 + Math.pow(Math.random(), 2.1) * 330,
lit: Math.random()
});
}
}
}
function resize () {
W = cv.width = innerWidth;
H = cv.height = innerHeight;
FOCAL = W * 0.82;
seedBlocks();
}
addEventListener('resize', resize);
resize();
function project (x, y, z) {
const dz = z - CAM.z;
if (dz < 12) return null;
const s = FOCAL / dz;
return { sx: W / 2 + (x - CAM.x) * s, sy: H * 0.62 - (y - CAM.y) * s, s, dz };
}
function drawCity () {
// horizon haze
const hz = cx.createLinearGradient(0, H * 0.20, 0, H * 0.72);
hz.addColorStop(0, '#151A24');
hz.addColorStop(.55, '#1B212C');
hz.addColorStop(1, '#0D1016');
cx.fillStyle = hz;
cx.fillRect(0, 0, W, H);
// sun-scorched band on the horizon — the one warm note
const bd = cx.createLinearGradient(0, H * 0.44, 0, H * 0.64);
bd.addColorStop(0, 'rgba(194,70,46,0)');
bd.addColorStop(.5,'rgba(194,70,46,.13)');
bd.addColorStop(1, 'rgba(194,70,46,0)');
cx.fillStyle = bd;
cx.fillRect(0, 0, W, H);
// painter's algorithm: far blocks first
blocks.sort((a, b) => (b.z - CAM.z) - (a.z - CAM.z));
for (const b of blocks) {
const dz = b.z - CAM.z;
if (dz < 14 || dz > FAR) continue;
const hw = b.w / 2;
const bl = project(b.x - hw, 0, b.z);
const br = project(b.x + hw, 0, b.z);
const tl = project(b.x - hw, b.h, b.z);
if (!bl || !br || !tl) continue;
const fade = Math.max(0, 1 - dz / FAR);
const w = br.sx - bl.sx;
const h = bl.sy - tl.sy;
if (w < 0.6 || h < 0.6) continue;
// face
cx.globalAlpha = 0.30 + fade * 0.62;
cx.fillStyle = '#080A0F';
cx.fillRect(bl.sx, tl.sy, w, h);
// roof edge catches the light
cx.globalAlpha = 0.13 + fade * 0.30;
cx.fillStyle = '#39424F';
cx.fillRect(bl.sx, tl.sy, w, Math.max(0.7, h * 0.012));
// windows — only worth drawing when the block is near
if (dz < FAR * 0.42 && w > 7) {
const cols = Math.max(1, Math.floor(w / 7));
const rows = Math.max(1, Math.floor(h / 11));
cx.globalAlpha = fade * 0.5;
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const k = (c * 37 + r * 71 + Math.floor(b.x)) % 100;
if (k / 100 > b.lit * 0.7) continue;
cx.fillStyle = k % 9 === 0 ? '#C2462E' : '#C9C2AE';
cx.fillRect(bl.sx + 2 + c * 7, tl.sy + 3 + r * 11, 2.4, 3.4);
}
}
}
}
cx.globalAlpha = 1;
}
let last = 0;
function frame (t) {
const dt = last ? Math.min((t - last) / 16.67, 3) : 1;
last = t;
CAM.z += CAM.speed * dt * 3.1;
CAM.drift += 0.0022 * dt;
CAM.x = Math.sin(CAM.drift) * 96;
CAM.y = 210 + Math.sin(CAM.drift * 0.62) * 34;
// recycle blocks that fell behind the camera
for (const b of blocks) if (b.z - CAM.z < 10) b.z += FAR;
drawCity();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ══ Instrument readouts ══════════════════════════ */
let clockSec = 0;
setInterval(() => {
clockSec++;
const hh = String(Math.floor(clockSec / 3600) % 24).padStart(2, '0');
const mm = String(Math.floor(clockSec / 60) % 60).padStart(2, '0');
const ss = String(clockSec % 60).padStart(2, '0');
$('r-clock').textContent = `${hh}:${mm}:${ss}`;
$('r-alt').textContent = Math.round(1420 + Math.sin(CAM.drift * 0.62) * 120);
$('r-hdg').textContent = String(Math.round((274 + Math.sin(CAM.drift) * 26 + 360) % 360)).padStart(3, '0');
$('r-rec').style.opacity = clockSec % 2 ? '1' : '.25';
}, 1000);
/* ══ Processing progress ══════════════════════════ */
const STAGES = [
[0, 'ESTABLISHING UPLINK'],
[12, 'RECEIVING COUNTY MAP DATA'],
[30, 'STREAMING STRUCTURES'],
[52, 'INDEXING RESIDENT RECORDS'],
[72, 'VERIFYING CREDENTIALS'],
[88, 'PREPARING INTAKE TERMINAL'],
[99, 'CLEARED FOR ENTRY'],
];
// ticks encode the real phase boundaries, not decoration
const ticksEl = $('ticks');
STAGES.slice(1).forEach(([at, label]) => {
const d = document.createElement('div');
d.className = 'proc-tick';
d.style.left = at + '%';
d.innerHTML = `<span>${label.split(' ')[0]}</span>`;
ticksEl.appendChild(d);
});
let shown = 0, target = 0, ordOn = false;
function setTarget (v) { target = Math.min(100, Math.max(target, v)); }
(function tick () {
if (shown < target) {
shown += Math.min((target - shown) * 0.07 + 0.09, target - shown);
const p = Math.min(100, Math.round(shown));
$('fill').style.width = p + '%';
$('pct').textContent = p + '%';
for (let i = STAGES.length - 1; i >= 0; i--) {
if (shown >= STAGES[i][0]) { $('stage').textContent = STAGES[i][1]; break; }
}
if (shown >= 46 && !ordOn) { ordOn = true; $('ord').classList.add('on'); }
}
setTimeout(tick, 40);
})();
[[700,13],[1500,29],[2700,49],[4400,67],[6400,80],[9000,91]]
.forEach(([ms, v]) => setTimeout(() => setTarget(v), ms));
addEventListener('message', e => {
const d = e.data;
if (!d) return;
if (d.eventName === 'loadingScreenCall') {
for (const c of (d.data || [])) {
if (c.functionName === 'shutdown' || c.type === 'shutdown') {
setTarget(100);
setTimeout(() => {
document.body.style.transition = 'opacity .9s cubic-bezier(.2,.8,.25,1)';
document.body.style.opacity = '0';
}, 900);
}
}
}
// FiveM streams real load progress through these events
if (d.eventName === 'loadProgress' && typeof d.loadFraction === 'number') {
setTarget(Math.round(d.loadFraction * 100));
}
});
/* ══ Room tone ════════════════════════════════════
Dispatch-room HVAC hum, not a music pad. */
const vol = $('vol');
let actx = null, master = null;
function initAudio () {
if (actx) return;
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { return; }
master = actx.createGain();
master.gain.value = vol.value / 100 * 0.6;
master.connect(actx.destination);
// low room hum
[[47.5,.20],[71.3,.10],[95,.055]].forEach(([f, g]) => {
const o = actx.createOscillator(), gn = actx.createGain(), lp = actx.createBiquadFilter();
const lfo = actx.createOscillator(), lg = actx.createGain();
lfo.frequency.value = 0.037 + Math.random() * 0.06;
lg.gain.value = f * 0.004;
lfo.connect(lg); lg.connect(o.frequency);
o.type = 'sine'; o.frequency.value = f;
lp.type = 'lowpass'; lp.frequency.value = f * 4; lp.Q.value = .4;
gn.gain.setValueAtTime(0, actx.currentTime);
gn.gain.linearRampToValueAtTime(g, actx.currentTime + 5);
o.connect(lp); lp.connect(gn); gn.connect(master);
o.start(); lfo.start();
});
// filtered air noise
const buf = actx.createBuffer(1, actx.sampleRate * 3, actx.sampleRate);
const dat = buf.getChannelData(0);
for (let i = 0; i < dat.length; i++) dat[i] = Math.random() * 2 - 1;
const ns = actx.createBufferSource(), bp = actx.createBiquadFilter(), ng = actx.createGain();
ns.buffer = buf; ns.loop = true;
bp.type = 'bandpass'; bp.frequency.value = 320; bp.Q.value = .35;
ng.gain.value = 0.02;
ns.connect(bp); bp.connect(ng); ng.connect(master);
ns.start();
}
addEventListener('click', initAudio, { once: true });
addEventListener('keydown', initAudio, { once: true });
setTimeout(initAudio, 1100);
vol.addEventListener('input', () => {
if (master) master.gain.linearRampToValueAtTime(vol.value / 100 * 0.6, actx.currentTime + .08);
});
</script>
</body>
</html>
-208
View File
@@ -1,208 +0,0 @@
-- JustRP — Server-side core
-- All DB operations go through the internal Python API on :8787
local API_BASE = "http://127.0.0.1:8787"
local API_KEY = "JustRP_Internal_2026_Secret"
-- Per-player session state
local Sessions = {}
-- ─── HTTP helper ────────────────────────────────────────────────────────────
local function apiRequest(method, path, data, cb)
PerformHttpRequest(
API_BASE .. path,
function(status, body, _headers)
local ok, result = pcall(json.decode, body or "{}")
if ok then
cb(status, result)
else
cb(status, { success = false, error = "parse error" })
end
end,
method,
data and json.encode(data) or "",
{
["Content-Type"] = "application/json",
["X-API-Key"] = API_KEY,
}
)
end
-- ─── Player connecting ───────────────────────────────────────────────────────
AddEventHandler("playerConnecting", function(name, setKickReason, deferrals)
local src = source
local license = GetPlayerIdentifierByType(src, "license") or
GetPlayerIdentifierByType(src, "license2") or
"license:" .. tostring(src)
deferrals.defer()
Wait(0)
deferrals.update("Checking account…")
apiRequest("GET", "/account?license=" .. license, nil, function(status, res)
if status == 200 and res.success then
if res.account.banned == 1 then
deferrals.done("You are banned from JustRP.\nReason: " .. (res.account.ban_reason or "No reason given"))
else
deferrals.done()
end
else
-- No account yet — let them through, they'll register in-game
deferrals.done()
end
end)
end)
-- ─── Client ready → start session ───────────────────────────────────────────
RegisterNetEvent("justrp:clientReady")
AddEventHandler("justrp:clientReady", function()
local src = source
local license = GetPlayerIdentifierByType(src, "license") or
GetPlayerIdentifierByType(src, "license2") or
"license:" .. tostring(src)
Sessions[src] = { license = license, accountId = nil, charId = nil }
TriggerClientEvent("justrp:startSession", src)
end)
-- ─── Auth events ────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:auth:register")
AddEventHandler("justrp:auth:register", function(username, password)
local src = source
local ses = Sessions[src]
if not ses then return end
apiRequest("POST", "/auth/register", {
license = ses.license,
username = username,
password = password,
}, function(status, res)
if res.success then
ses.accountId = res.account_id
TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username)
else
TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error")
end
end)
end)
RegisterNetEvent("justrp:auth:login")
AddEventHandler("justrp:auth:login", function(username, password)
local src = source
local ses = Sessions[src]
if not ses then return end
apiRequest("POST", "/auth/login", {
license = ses.license,
username = username,
password = password,
}, function(status, res)
if res.success then
ses.accountId = res.account_id
TriggerClientEvent("justrp:auth:ok", src, res.account_id, res.username)
else
TriggerClientEvent("justrp:auth:fail", src, res.error or "Unknown error")
end
end)
end)
-- ─── Character events ────────────────────────────────────────────────────────
RegisterNetEvent("justrp:chars:load")
AddEventHandler("justrp:chars:load", function()
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then
TriggerClientEvent("justrp:chars:list", src, {})
return
end
apiRequest("GET", "/characters?account_id=" .. ses.accountId, nil, function(status, res)
if res.success then
TriggerClientEvent("justrp:chars:list", src, res.characters or {})
else
TriggerClientEvent("justrp:chars:list", src, {})
end
end)
end)
RegisterNetEvent("justrp:char:create")
AddEventHandler("justrp:char:create", function(data)
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then return end
data.account_id = ses.accountId
apiRequest("POST", "/character/create", data, function(status, res)
if res.success then
ses.charId = res.character_id
TriggerClientEvent("justrp:char:created", src, res.character_id)
else
TriggerClientEvent("justrp:char:createFail", src, res.error or "Unknown error")
end
end)
end)
RegisterNetEvent("justrp:char:select")
AddEventHandler("justrp:char:select", function(charId)
local src = source
local ses = Sessions[src]
if not ses or not ses.accountId then return end
apiRequest("GET", "/character?id=" .. tostring(charId), nil, function(status, res)
if res.success and res.character then
local ch = res.character
if ch.account_id ~= ses.accountId then
return -- character doesn't belong to this account
end
ses.charId = charId
TriggerClientEvent("justrp:char:loaded", src, ch)
end
end)
end)
-- ─── Spawn ───────────────────────────────────────────────────────────────────
RegisterNetEvent("justrp:spawn:request")
AddEventHandler("justrp:spawn:request", function(spawnKey)
local src = source
local ses = Sessions[src]
if not ses or not ses.charId then return end
local spawns = {
legion = { x = -166.7, y = -928.9, z = 31.4, h = 120.0 },
vespucci = { x = -1380.0, y = -1520.0, z = 4.9, h = 75.0 },
sandy = { x = 1843.4, y = 3683.0, z = 34.3, h = 200.0 },
paleto = { x = -165.8, y = 6330.0, z = 31.5, h = 270.0 },
airport = { x = -1037.0, y = -2738.0, z = 20.0, h = 330.0 },
}
local pos = spawns[spawnKey] or spawns["legion"]
TriggerClientEvent("justrp:spawn:go", src, pos)
end)
-- ─── Auto-save position every 60s ────────────────────────────────────────────
RegisterNetEvent("justrp:savePosition")
AddEventHandler("justrp:savePosition", function(x, y, z, h)
local src = source
local ses = Sessions[src]
if not ses or not ses.charId then return end
apiRequest("POST", "/character/save", {
character_id = ses.charId,
last_x = x,
last_y = y,
last_z = z,
last_heading = h,
}, function() end)
end)
-- ─── Disconnect ──────────────────────────────────────────────────────────────
AddEventHandler("playerDropped", function()
local src = source
Sessions[src] = nil
end)
print("[JustRP] Server core loaded.")