The server itself: repo now mirrors the machine it runs on, plus install.sh
The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.
Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):
resources/[rp]/ rp_db, rp_core, rp_session, rp_loading, rp_ui,
rp_selftest, rp_dbtest
bin/ supervise.sh (keep-alive + console FIFO), rcon, init script
etc/schema.sql accounts, characters, transactions, inventory, vehicles
assets/fonts/ the bundled subsets
docs/SERVER.md how it is put together, resource by resource
install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.
No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80c1d75d2f
commit
71856b15e9
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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 · Office of Records</div>
|
||||
<div class="serial data">
|
||||
FILE <span id="serial">0000-0000</span> / 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,188 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Scripted cameras for the pre-spawn screens.
|
||||
--
|
||||
-- One camera object is reused throughout. Everything is driven from a single
|
||||
-- render thread that eases the live values towards target values, so a change
|
||||
-- requested by the UI never snaps.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Cam = {}
|
||||
|
||||
local cam = nil
|
||||
local mode = 'off' -- 'off' | 'shots' | 'orbit' | 'fly'
|
||||
local renderThread = false
|
||||
|
||||
-- live and target orbit values
|
||||
local orbit = {
|
||||
angle = 180.0, targetAngle = 180.0,
|
||||
radius = 1.6, targetRadius = 1.6,
|
||||
height = 0.65, targetHeight = 0.65,
|
||||
focus = nil, -- entity to orbit
|
||||
pitch = 0.0, targetPitch = 0.0,
|
||||
}
|
||||
|
||||
local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 }
|
||||
local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil }
|
||||
|
||||
local function ensureCam()
|
||||
if cam and DoesCamExist(cam) then return cam end
|
||||
cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
return cam
|
||||
end
|
||||
|
||||
local function lerp(a, b, t) return a + (b - a) * t end
|
||||
|
||||
local function vlerp(a, b, t)
|
||||
return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t))
|
||||
end
|
||||
|
||||
-- smoothstep, so fly-throughs start and end at rest
|
||||
local function ease(t)
|
||||
t = math.max(0.0, math.min(1.0, t))
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
end
|
||||
|
||||
local function startRenderThread()
|
||||
if renderThread then return end
|
||||
renderThread = true
|
||||
|
||||
CreateThread(function()
|
||||
while mode ~= 'off' do
|
||||
local c = ensureCam()
|
||||
|
||||
if mode == 'shots' then
|
||||
local shot = shots.list[shots.index]
|
||||
if shot then
|
||||
local t = (GetGameTimer() - shots.startedAt) / shots.duration
|
||||
if t >= 1.0 then
|
||||
shots.index = (shots.index % #shots.list) + 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
shot = shots.list[shots.index]
|
||||
t = 0.0
|
||||
end
|
||||
-- a slow linear dolly reads as a helicopter, not a spline
|
||||
local pos = vlerp(shot.from, shot.to, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z)
|
||||
end
|
||||
|
||||
elseif mode == 'orbit' then
|
||||
orbit.angle = lerp(orbit.angle, orbit.targetAngle, 0.14)
|
||||
orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10)
|
||||
orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10)
|
||||
orbit.pitch = lerp(orbit.pitch, orbit.targetPitch, 0.10)
|
||||
|
||||
local ent = orbit.focus
|
||||
if ent and DoesEntityExist(ent) then
|
||||
local base = GetEntityCoords(ent)
|
||||
local rad = math.rad(orbit.angle)
|
||||
local px = base.x + math.sin(rad) * orbit.radius
|
||||
local py = base.y + math.cos(rad) * orbit.radius
|
||||
local pz = base.z + orbit.height
|
||||
SetCamCoord(c, px, py, pz)
|
||||
PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch)
|
||||
end
|
||||
|
||||
elseif mode == 'fly' then
|
||||
local t = ease((GetGameTimer() - fly.startedAt) / fly.duration)
|
||||
local pos = vlerp(fly.from, fly.to, t)
|
||||
local look = vlerp(fly.lookFrom, fly.lookTo, t)
|
||||
SetCamCoord(c, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(c, look.x, look.y, look.z)
|
||||
if t >= 1.0 then
|
||||
mode = 'idle'
|
||||
if fly.done then
|
||||
local fn = fly.done
|
||||
fly.done = nil
|
||||
fn()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Wait(0)
|
||||
end
|
||||
renderThread = false
|
||||
end)
|
||||
end
|
||||
|
||||
--- Slow establishing shots over the city, cycled forever.
|
||||
function Cam.Shots(list)
|
||||
shots.list = list
|
||||
shots.index = 1
|
||||
shots.startedAt = GetGameTimer()
|
||||
mode = 'shots'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 42.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Orbit an entity. `snap` places the camera immediately instead of easing in.
|
||||
function Cam.Orbit(entity, radius, height, angle, snap)
|
||||
orbit.focus = entity
|
||||
orbit.targetRadius = radius or orbit.targetRadius
|
||||
orbit.targetHeight = height or orbit.targetHeight
|
||||
if angle then orbit.targetAngle = angle end
|
||||
if snap then
|
||||
orbit.radius = orbit.targetRadius
|
||||
orbit.height = orbit.targetHeight
|
||||
orbit.angle = orbit.targetAngle
|
||||
end
|
||||
mode = 'orbit'
|
||||
ensureCam()
|
||||
SetCamFov(cam, 34.0)
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
--- Framing presets used by the creator's section tabs.
|
||||
function Cam.OrbitFrame(radius, height, pitch)
|
||||
orbit.targetRadius = radius
|
||||
orbit.targetHeight = height
|
||||
orbit.targetPitch = pitch or 0.0
|
||||
end
|
||||
|
||||
function Cam.Nudge(deltaDegrees)
|
||||
orbit.targetAngle = orbit.targetAngle + deltaDegrees
|
||||
end
|
||||
|
||||
function Cam.SetAngle(deg)
|
||||
orbit.targetAngle = deg
|
||||
end
|
||||
|
||||
function Cam.Angle() return orbit.targetAngle end
|
||||
|
||||
--- Fly from wherever we are to a point looking at a target, then call `done`.
|
||||
function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done)
|
||||
fly.from = fromPos
|
||||
fly.to = toPos
|
||||
fly.lookFrom = fromLook
|
||||
fly.lookTo = toLook
|
||||
fly.duration = duration or 3000
|
||||
fly.startedAt = GetGameTimer()
|
||||
fly.done = done
|
||||
mode = 'fly'
|
||||
ensureCam()
|
||||
startRenderThread()
|
||||
end
|
||||
|
||||
function Cam.SetFov(fov)
|
||||
if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end
|
||||
end
|
||||
|
||||
function Cam.Position()
|
||||
if cam and DoesCamExist(cam) then return GetCamCoord(cam) end
|
||||
return GetEntityCoords(PlayerPedId())
|
||||
end
|
||||
|
||||
--- Hand control back to the gameplay camera. With a duration the engine
|
||||
--- interpolates between the two, which hides the cut entirely.
|
||||
function Cam.Release(duration)
|
||||
mode = 'off'
|
||||
duration = duration or 0
|
||||
RenderScriptCams(false, duration > 0, duration, true, true)
|
||||
if cam and DoesCamExist(cam) then
|
||||
DestroyCam(cam, true)
|
||||
end
|
||||
cam = nil
|
||||
end
|
||||
@@ -0,0 +1,508 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- rp_ui - the pre-spawn experience.
|
||||
--
|
||||
-- Owns the stage machine (auth -> characters -> creator -> spawn -> live),
|
||||
-- the NUI bridge, and the transition into the world. The server decides
|
||||
-- everything that matters; this file only asks and then animates the answer.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local stage = 'boot'
|
||||
local account = nil
|
||||
local characters = {}
|
||||
local draft = nil -- appearance currently being edited
|
||||
local previewPed = nil
|
||||
local spawnData = nil -- payload from char:select
|
||||
local chosenChar = nil
|
||||
|
||||
local NEUTRAL = vector3(-1037.0, -2738.0, 20.17) -- flat, always-streamed tarmac
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function ui(payload)
|
||||
SendNUIMessage(payload)
|
||||
end
|
||||
|
||||
local function focusUI(on)
|
||||
SetNuiFocus(on, on)
|
||||
SetNuiFocusKeepInput(false)
|
||||
end
|
||||
|
||||
local function holdPlayer()
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetEntityInvincible(ped, true)
|
||||
SetPlayerControl(PlayerId(), false, 0)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
DisplayHud(false)
|
||||
DisplayRadar(false)
|
||||
ClearPedTasksImmediately(ped)
|
||||
end
|
||||
|
||||
local function releasePlayer()
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
FreezeEntityPosition(ped, false)
|
||||
SetEntityInvincible(ped, false)
|
||||
SetPlayerControl(PlayerId(), true, 0)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
DisplayHud(true)
|
||||
DisplayRadar(true)
|
||||
end
|
||||
|
||||
--- Dusk, so the sodium palette of the interface matches the world behind it.
|
||||
local function setSceneMood(on)
|
||||
if on then
|
||||
NetworkOverrideClockTime(20, 40, 0)
|
||||
SetWeatherTypeNowPersist('EXTRASUNNY')
|
||||
else
|
||||
NetworkClearClockTimeOverride()
|
||||
ClearOverrideWeatherType()
|
||||
SetWeatherTypeNow('EXTRASUNNY')
|
||||
end
|
||||
end
|
||||
|
||||
--- Keep the world streamed around a point while no player ped is there.
|
||||
local function focusOn(pos)
|
||||
SetFocusPosAndVel(pos.x, pos.y, pos.z, 0.0, 0.0, 0.0)
|
||||
RequestCollisionAtCoord(pos.x, pos.y, pos.z)
|
||||
end
|
||||
|
||||
local function destroyPreview()
|
||||
if previewPed and DoesEntityExist(previewPed) then
|
||||
DeleteEntity(previewPed)
|
||||
end
|
||||
previewPed = nil
|
||||
end
|
||||
|
||||
local function setStage(next, data)
|
||||
stage = next
|
||||
ui({ action = 'stage', stage = next, data = data })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- boot
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CreateThread(function()
|
||||
while not NetworkIsSessionStarted() do Wait(50) end
|
||||
|
||||
holdPlayer()
|
||||
setSceneMood(true)
|
||||
|
||||
local first = Config.CinematicShots[1]
|
||||
focusOn(first.from)
|
||||
SetEntityCoords(PlayerPedId(), NEUTRAL.x, NEUTRAL.y, NEUTRAL.z, false, false, false, false)
|
||||
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
|
||||
-- give the world a moment to stream in behind the loading screen
|
||||
SendLoadingScreenMessage(json.encode({
|
||||
eventName = 'rp:server',
|
||||
name = Config.ServerName,
|
||||
status = 'Connected',
|
||||
}))
|
||||
SendLoadingScreenMessage(json.encode({ eventName = 'rp:rules', rules = Config.Rules }))
|
||||
|
||||
local waited = 0
|
||||
while waited < 6000 do
|
||||
SendLoadingScreenMessage(json.encode({
|
||||
eventName = 'rp:progress',
|
||||
fraction = 0.85 + (waited / 6000) * 0.15,
|
||||
step = 'Streaming the city',
|
||||
}))
|
||||
Wait(250)
|
||||
waited = waited + 250
|
||||
end
|
||||
|
||||
SendLoadingScreenMessage(json.encode({ eventName = 'rp:handover' }))
|
||||
Wait(1500)
|
||||
ShutdownLoadingScreenNui()
|
||||
|
||||
focusUI(true)
|
||||
setStage('auth', {
|
||||
serverName = Config.ServerName,
|
||||
serverTag = Config.ServerTag,
|
||||
maxChars = Config.MaxChars,
|
||||
})
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- authentication
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('auth:submit', function(data, cb)
|
||||
CreateThread(function()
|
||||
local mode = (data and data.mode == 'register') and 'auth:register' or 'auth:login'
|
||||
local res, err = Core.Callback('rp_session', mode, {
|
||||
username = data.username,
|
||||
password = data.password,
|
||||
})
|
||||
|
||||
if not res then return cb({ ok = false, error = err or 'Something went wrong' }) end
|
||||
|
||||
account = { username = res.username, role = res.role }
|
||||
characters = res.characters or {}
|
||||
cb({ ok = true, account = account, characters = characters, maxChars = res.maxChars })
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- character select
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Stand the highlighted character up so the list is not just text.
|
||||
RegisterNUICallback('char:preview', function(data, cb)
|
||||
CreateThread(function()
|
||||
destroyPreview()
|
||||
|
||||
local appearance
|
||||
for _, c in ipairs(characters) do
|
||||
if c.id == data.id then appearance = c.appearance end
|
||||
end
|
||||
if not appearance then return cb({ ok = false }) end
|
||||
|
||||
appearance = Appearance.Sanitise(appearance)
|
||||
focusOn(Config.CreatorScene.ped)
|
||||
previewPed = PedBuild.CreatePreview(appearance, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
|
||||
if previewPed then
|
||||
PedBuild.Idle(previewPed)
|
||||
Cam.Orbit(previewPed, 2.4, 0.25, Config.CreatorScene.ped.w + 180.0, true)
|
||||
Cam.OrbitFrame(2.4, 0.25, 0.35)
|
||||
end
|
||||
cb({ ok = previewPed ~= nil })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('char:delete', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:delete', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
local list = Core.Callback('rp_session', 'char:list')
|
||||
characters = (list and list.characters) or {}
|
||||
destroyPreview()
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
cb({ ok = true, characters = characters })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('char:select', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:select', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
spawnData = res
|
||||
chosenChar = res.character
|
||||
destroyPreview()
|
||||
|
||||
-- become the character now, off-screen, so the fly-down reveals them
|
||||
if res.appearance then
|
||||
PedBuild.ApplyToPlayer(Appearance.Sanitise(res.appearance))
|
||||
holdPlayer()
|
||||
end
|
||||
|
||||
cb({ ok = true, spawns = res.spawns, lastPosition = res.lastPosition, character = res.character })
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- character creation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Read the game's own colour tables so the swatches in the UI are the real
|
||||
--- colours, not an approximation that drifts from what the ped ends up wearing.
|
||||
local paletteCache = nil
|
||||
local function palettes()
|
||||
if paletteCache then return paletteCache end
|
||||
local hair, makeup = {}, {}
|
||||
for i = 0, 63 do
|
||||
local ok, r, g, b = pcall(GetPedHairRgbColor, i)
|
||||
hair[#hair + 1] = ok and { r = r, g = g, b = b } or { r = 40, g = 40, b = 40 }
|
||||
local ok2, r2, g2, b2 = pcall(GetPedMakeupRgbColor, i)
|
||||
makeup[#makeup + 1] = ok2 and { r = r2, g = g2, b = b2 } or { r = 60, g = 40, b = 40 }
|
||||
end
|
||||
paletteCache = { hair = hair, makeup = makeup }
|
||||
return paletteCache
|
||||
end
|
||||
|
||||
--- The creator UI is built from this schema rather than a hardcoded copy, so
|
||||
--- adding a slider is a one-line change in shared/appearance.lua.
|
||||
local function pushDraft(cb)
|
||||
local payload = {
|
||||
ok = true,
|
||||
appearance = draft,
|
||||
limits = PedBuild.Limits(previewPed),
|
||||
palettes = palettes(),
|
||||
schema = {
|
||||
features = Appearance.FEATURES,
|
||||
overlays = Appearance.OVERLAYS,
|
||||
components = Appearance.COMPONENTS,
|
||||
props = Appearance.PROPS,
|
||||
},
|
||||
}
|
||||
if cb then cb(payload) else ui({ action = 'creator:sync', data = payload }) end
|
||||
end
|
||||
|
||||
local function rebuildPreview()
|
||||
destroyPreview()
|
||||
focusOn(Config.CreatorScene.ped)
|
||||
previewPed = PedBuild.CreatePreview(draft, Config.CreatorScene.ped, Config.CreatorScene.ped.w)
|
||||
if previewPed then
|
||||
PedBuild.Idle(previewPed)
|
||||
Cam.Orbit(previewPed, 1.5, 0.62, Config.CreatorScene.ped.w + 180.0, true)
|
||||
end
|
||||
end
|
||||
|
||||
RegisterNUICallback('creator:start', function(data, cb)
|
||||
CreateThread(function()
|
||||
draft = Appearance.Default(data and data.gender or 'm')
|
||||
rebuildPreview()
|
||||
pushDraft(cb)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:gender', function(data, cb)
|
||||
CreateThread(function()
|
||||
draft = Appearance.Default(data.gender == 'f' and 'f' or 'm')
|
||||
rebuildPreview()
|
||||
pushDraft(cb)
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:parent', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local key = data.key
|
||||
if key == 'father' or key == 'mother' then
|
||||
draft.parents[key] = math.floor(tonumber(data.value) or 0)
|
||||
elseif key == 'shapeMix' or key == 'skinMix' then
|
||||
draft.parents[key] = tonumber(data.value) or 0.5
|
||||
end
|
||||
SetPedHeadBlendData(previewPed,
|
||||
draft.parents.father, draft.parents.mother, 0,
|
||||
draft.parents.father, draft.parents.mother, 0,
|
||||
draft.parents.shapeMix + 0.0, draft.parents.skinMix + 0.0, 0.0, false)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:feature', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local id = math.floor(tonumber(data.id) or 0)
|
||||
local v = math.max(-1.0, math.min(1.0, tonumber(data.value) or 0.0))
|
||||
draft.features[tostring(id)] = v
|
||||
SetPedFaceFeature(previewPed, id, v + 0.0)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:overlay', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local slot
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do if o.key == data.key then slot = o end end
|
||||
if not slot then return cb({ ok = false }) end
|
||||
|
||||
local ov = draft.overlays[data.key]
|
||||
if data.index ~= nil then ov.index = math.floor(tonumber(data.index)) end
|
||||
if data.opacity ~= nil then ov.opacity = tonumber(data.opacity) end
|
||||
if data.colour ~= nil then ov.colour = math.floor(tonumber(data.colour)) end
|
||||
|
||||
local index = (ov.index == nil or ov.index < 0) and 255 or ov.index
|
||||
SetPedHeadOverlay(previewPed, slot.id, index, ov.opacity + 0.0)
|
||||
if slot.tint and index ~= 255 then
|
||||
SetPedHeadOverlayColor(previewPed, slot.id, slot.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:hair', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
if data.style ~= nil then draft.hair.style = math.floor(tonumber(data.style)) end
|
||||
if data.colour ~= nil then draft.hair.colour = math.floor(tonumber(data.colour)) end
|
||||
if data.highlight ~= nil then draft.hair.highlight = math.floor(tonumber(data.highlight)) end
|
||||
SetPedComponentVariation(previewPed, 2, draft.hair.style, 0, 0)
|
||||
SetPedHairColor(previewPed, draft.hair.colour, draft.hair.highlight)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:eyes', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
draft.eyeColour = math.floor(tonumber(data.value) or 0)
|
||||
SetPedEyeColor(previewPed, draft.eyeColour)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:component', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local comp
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do if c.key == data.key then comp = c end end
|
||||
if not comp then return cb({ ok = false }) end
|
||||
|
||||
local cc = draft.components[data.key]
|
||||
if data.drawable ~= nil then cc.drawable = math.floor(tonumber(data.drawable)) end
|
||||
if data.texture ~= nil then cc.texture = math.floor(tonumber(data.texture)) end
|
||||
|
||||
-- a new drawable usually has a different number of textures
|
||||
local maxTex = math.max(0, GetNumberOfPedTextureVariations(previewPed, comp.id, cc.drawable) - 1)
|
||||
if cc.texture > maxTex then cc.texture = 0 end
|
||||
|
||||
SetPedComponentVariation(previewPed, comp.id, cc.drawable, cc.texture, 0)
|
||||
cb({ ok = true, texture = cc.texture, maxTexture = maxTex })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:prop', function(data, cb)
|
||||
if not draft or not previewPed then return cb({ ok = false }) end
|
||||
local prop
|
||||
for _, p in ipairs(Appearance.PROPS) do if p.key == data.key then prop = p end end
|
||||
if not prop then return cb({ ok = false }) end
|
||||
|
||||
local pp = draft.props[data.key]
|
||||
if data.drawable ~= nil then pp.drawable = math.floor(tonumber(data.drawable)) end
|
||||
if data.texture ~= nil then pp.texture = math.floor(tonumber(data.texture)) end
|
||||
|
||||
if pp.drawable < 0 then
|
||||
ClearPedProp(previewPed, prop.id)
|
||||
cb({ ok = true, texture = 0, maxTexture = 0 })
|
||||
else
|
||||
local maxTex = math.max(0, GetNumberOfPedPropTextureVariations(previewPed, prop.id, pp.drawable) - 1)
|
||||
if pp.texture > maxTex then pp.texture = 0 end
|
||||
SetPedPropIndex(previewPed, prop.id, pp.drawable, pp.texture, true)
|
||||
cb({ ok = true, texture = pp.texture, maxTexture = maxTex })
|
||||
end
|
||||
end)
|
||||
|
||||
--- Camera framing per creator section.
|
||||
local FRAMES = {
|
||||
heritage = { radius = 1.05, height = 0.66, pitch = 0.02 },
|
||||
face = { radius = 0.85, height = 0.68, pitch = 0.0 },
|
||||
hair = { radius = 1.05, height = 0.70, pitch = 0.0 },
|
||||
body = { radius = 2.60, height = 0.20, pitch = 0.45 },
|
||||
clothing = { radius = 2.30, height = 0.28, pitch = 0.40 },
|
||||
identity = { radius = 1.90, height = 0.45, pitch = 0.25 },
|
||||
}
|
||||
|
||||
RegisterNUICallback('creator:frame', function(data, cb)
|
||||
local f = FRAMES[data.section] or FRAMES.face
|
||||
Cam.OrbitFrame(f.radius, f.height, f.pitch)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:rotate', function(data, cb)
|
||||
Cam.Nudge(tonumber(data.delta) or 0.0)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:submit', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'char:create', {
|
||||
firstName = data.firstName,
|
||||
lastName = data.lastName,
|
||||
dob = data.dob,
|
||||
gender = draft and draft.model or 'm',
|
||||
backstory = data.backstory,
|
||||
appearance = draft,
|
||||
})
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
local list = Core.Callback('rp_session', 'char:list')
|
||||
characters = (list and list.characters) or {}
|
||||
destroyPreview()
|
||||
draft = nil
|
||||
cb({ ok = true, characters = characters })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('creator:cancel', function(_, cb)
|
||||
destroyPreview()
|
||||
draft = nil
|
||||
Cam.Shots(Config.CinematicShots)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- spawn
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('spawn:preview', function(data, cb)
|
||||
CreateThread(function()
|
||||
if not spawnData then return cb({ ok = false }) end
|
||||
for _, sp in ipairs(spawnData.spawns) do
|
||||
if sp.id == data.id then
|
||||
local from = Cam.Position()
|
||||
focusOn(vector3(sp.coords.x, sp.coords.y, sp.coords.z))
|
||||
Cam.FlyTo(
|
||||
from, vector3(sp.cam.look.x, sp.cam.look.y, sp.cam.look.z),
|
||||
vector3(sp.cam.pos.x, sp.cam.pos.y, sp.cam.pos.z),
|
||||
vector3(sp.coords.x, sp.coords.y, sp.coords.z),
|
||||
2200
|
||||
)
|
||||
break
|
||||
end
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('spawn:confirm', function(data, cb)
|
||||
CreateThread(function()
|
||||
local res, err = Core.Callback('rp_session', 'spawn:confirm', { id = data.id })
|
||||
if not res then return cb({ ok = false, error = err }) end
|
||||
|
||||
cb({ ok = true })
|
||||
focusUI(false)
|
||||
ui({ action = 'stage', stage = 'flying' })
|
||||
|
||||
local c = res.coords
|
||||
local ped = PlayerPedId()
|
||||
|
||||
-- stream the ground before anyone stands on it
|
||||
focusOn(vector3(c.x, c.y, c.z))
|
||||
RequestCollisionAtCoord(c.x, c.y, c.z)
|
||||
local waited = 0
|
||||
while not HasCollisionLoadedAroundEntity(ped) and waited < 8000 do
|
||||
RequestCollisionAtCoord(c.x, c.y, c.z)
|
||||
Wait(50)
|
||||
waited = waited + 50
|
||||
end
|
||||
|
||||
SetEntityCoordsNoOffset(ped, c.x, c.y, c.z, false, false, false)
|
||||
SetEntityHeading(ped, c.h or 0.0)
|
||||
|
||||
local from = Cam.Position()
|
||||
Cam.FlyTo(
|
||||
from, vector3(c.x, c.y, c.z),
|
||||
vector3(c.x - 2.4, c.y - 2.4, c.z + 1.4), vector3(c.x, c.y, c.z + 0.2),
|
||||
3400,
|
||||
function()
|
||||
SetEntityVisible(ped, true, false)
|
||||
releasePlayer()
|
||||
setSceneMood(false)
|
||||
ClearFocus()
|
||||
Cam.Release(1400)
|
||||
ui({ action = 'stage', stage = 'live' })
|
||||
TriggerEvent('rp:ui:spawned', chosenChar)
|
||||
end
|
||||
)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- misc
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
RegisterNUICallback('ui:log', function(data, cb)
|
||||
print(('^3[rp_ui]^7 %s'):format(tostring(data and data.message)))
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(res)
|
||||
if res ~= GetCurrentResourceName() then return end
|
||||
destroyPreview()
|
||||
Cam.Release(0)
|
||||
focusUI(false)
|
||||
releasePlayer()
|
||||
setSceneMood(false)
|
||||
ClearFocus()
|
||||
end)
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Building a freemode ped from an appearance table.
|
||||
--
|
||||
-- The same code paints the creator preview and the live player, so what you
|
||||
-- saw while editing is exactly what walks into the world.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
PedBuild = {}
|
||||
|
||||
local NO_OVERLAY = 255 -- the native's "none" value; our model stores -1
|
||||
|
||||
local function loadModel(name)
|
||||
local hash = joaat(name)
|
||||
if not IsModelInCdimage(hash) or not IsModelValid(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local waited = 0
|
||||
while not HasModelLoaded(hash) and waited < 10000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if not HasModelLoaded(hash) then return nil end
|
||||
return hash
|
||||
end
|
||||
|
||||
PedBuild.LoadModel = loadModel
|
||||
|
||||
--- Apply an appearance table to an existing freemode ped.
|
||||
function PedBuild.Apply(ped, a)
|
||||
if not ped or not DoesEntityExist(ped) or type(a) ~= 'table' then return end
|
||||
|
||||
SetPedHeadBlendData(
|
||||
ped,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.father, a.parents.mother, 0,
|
||||
a.parents.shapeMix + 0.0, a.parents.skinMix + 0.0, 0.0,
|
||||
false
|
||||
)
|
||||
|
||||
for _, f in ipairs(Appearance.FEATURES) do
|
||||
local v = a.features[tostring(f.id)]
|
||||
if v then SetPedFaceFeature(ped, f.id, v + 0.0) end
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
local ov = a.overlays[o.key]
|
||||
if ov then
|
||||
local index = (ov.index == nil or ov.index < 0) and NO_OVERLAY or ov.index
|
||||
SetPedHeadOverlay(ped, o.id, index, ov.opacity + 0.0)
|
||||
if o.tint and index ~= NO_OVERLAY then
|
||||
-- tint type 1 = hair palette, 2 = make-up palette
|
||||
SetPedHeadOverlayColor(ped, o.id, o.tint == 'hair' and 1 or 2, ov.colour, ov.colour)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SetPedComponentVariation(ped, 2, a.hair.style, 0, 0)
|
||||
SetPedHairColor(ped, a.hair.colour, a.hair.highlight)
|
||||
SetPedEyeColor(ped, a.eyeColour)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local cc = a.components[c.key]
|
||||
if cc then SetPedComponentVariation(ped, c.id, cc.drawable, cc.texture, 0) end
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local pp = a.props[p.key]
|
||||
if pp then
|
||||
if not pp.drawable or pp.drawable < 0 then
|
||||
ClearPedProp(ped, p.id)
|
||||
else
|
||||
SetPedPropIndex(ped, p.id, pp.drawable, pp.texture, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- How many variations this particular model actually has, so the creator's
|
||||
--- sliders stop at real values instead of guessed ones.
|
||||
function PedBuild.Limits(ped)
|
||||
local limits = { components = {}, props = {}, overlays = {}, hair = 0 }
|
||||
if not ped or not DoesEntityExist(ped) then return limits end
|
||||
|
||||
limits.hair = math.max(0, GetNumberOfPedDrawableVariations(ped, 2) - 1)
|
||||
|
||||
for _, c in ipairs(Appearance.COMPONENTS) do
|
||||
local drawables = math.max(0, GetNumberOfPedDrawableVariations(ped, c.id) - 1)
|
||||
local current = GetPedDrawableVariation(ped, c.id)
|
||||
limits.components[c.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedTextureVariations(ped, c.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, p in ipairs(Appearance.PROPS) do
|
||||
local drawables = math.max(-1, GetNumberOfPedPropDrawableVariations(ped, p.id) - 1)
|
||||
local current = GetPedPropIndex(ped, p.id)
|
||||
limits.props[p.key] = {
|
||||
drawable = drawables,
|
||||
texture = math.max(0, GetNumberOfPedPropTextureVariations(ped, p.id, current) - 1),
|
||||
}
|
||||
end
|
||||
|
||||
for _, o in ipairs(Appearance.OVERLAYS) do
|
||||
limits.overlays[o.key] = o.max
|
||||
end
|
||||
|
||||
return limits
|
||||
end
|
||||
|
||||
--- Create a standalone preview ped (never networked).
|
||||
function PedBuild.CreatePreview(appearance, coords, heading)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return nil end
|
||||
|
||||
local ped = CreatePed(2, hash, coords.x, coords.y, coords.z, heading + 0.0, false, false)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
SetEntityInvincible(ped, true)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetBlockingOfNonTemporaryEvents(ped, true)
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
SetPedCanRagdoll(ped, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return ped
|
||||
end
|
||||
|
||||
--- Turn the local player into this character.
|
||||
function PedBuild.ApplyToPlayer(appearance)
|
||||
local modelName = Appearance.MODELS[appearance.model] or Appearance.MODELS.m
|
||||
local hash = loadModel(modelName)
|
||||
if not hash then return false end
|
||||
|
||||
SetPlayerModel(PlayerId(), hash)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetPedDefaultComponentVariation(ped)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
-- freemode peds start with no head blend applied until this settles a frame
|
||||
Wait(50)
|
||||
PedBuild.Apply(ped, appearance)
|
||||
return true
|
||||
end
|
||||
|
||||
--- A calm idle so the preview does not stand like a mannequin.
|
||||
function PedBuild.Idle(ped)
|
||||
local dict = 'anim@heists@heist_corona@team_idles@female_a'
|
||||
RequestAnimDict(dict)
|
||||
local waited = 0
|
||||
while not HasAnimDictLoaded(dict) and waited < 3000 do
|
||||
Wait(10)
|
||||
waited = waited + 10
|
||||
end
|
||||
if HasAnimDictLoaded(dict) then
|
||||
TaskPlayAnim(ped, dict, 'idle', 2.0, 2.0, -1, 1, 0, false, false, false)
|
||||
end
|
||||
end
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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">−</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.
@@ -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‑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–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="••••••••">
|
||||
<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="••••••••">
|
||||
<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‑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‑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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user