FiveRP: the server from the hosting account, not the earlier one
Wrong server was published before. This is the one that runs on the hosting account the video was made on: FiveRP - two-step registration (account, then an identity printed onto a passport), login that returns you to your resident, and a loading screen driven by the game's own streaming events. resources/[local]/fiverp-auth NUI + scrypt hashes + oxmysql resources/[local]/fiverp-characters identity, character load, spawn resources/[local]/fiverp-loadscreen loading screen sql/schema.sql accounts, characters docs/DESIGN.md the design system every screen follows docs/screens/ shot from the live server Only our own code is in here. cfx-server-data and oxmysql are fetched by install.sh instead of being vendored, which keeps the repo at 3.7 MB. install.sh does the whole box in one command: recommended FXServer build, cfx-server-data, oxmysql, MariaDB with the schema, resources, server.cfg (mode 600 - it carries the key and the database password), boot entry, then it waits for the Cfx registration. Tested end to end on a spare install root: 29 resources scanned, database and schema created, and it stopped exactly where a wrong licence key should stop it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
71856b15e9
commit
c857979a55
@@ -0,0 +1,145 @@
|
||||
local isOpen = false
|
||||
local cam = nil
|
||||
local authDone = false
|
||||
|
||||
-- A slow drift over the city gives the glass something to sit on.
|
||||
local CAM_POS = vector3(-1030.0, -2730.0, 320.0)
|
||||
local CAM_LOOK = vector3(-75.0, -815.0, 220.0)
|
||||
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
|
||||
local SPAWN_HDG = 145.0
|
||||
|
||||
local function startCamera()
|
||||
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA',
|
||||
CAM_POS.x, CAM_POS.y, CAM_POS.z, 0.0, 0.0, 0.0, 55.0, false, 0)
|
||||
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
|
||||
-- Drift the camera so the backdrop is never a frozen still.
|
||||
CreateThread(function()
|
||||
local angle = 0.0
|
||||
while cam and DoesCamExist(cam) and not authDone do
|
||||
angle = angle + 0.02
|
||||
local radius = 260.0
|
||||
SetCamCoord(cam,
|
||||
CAM_POS.x + math.cos(math.rad(angle)) * radius,
|
||||
CAM_POS.y + math.sin(math.rad(angle)) * radius,
|
||||
CAM_POS.z)
|
||||
PointCamAtCoord(cam, CAM_LOOK.x, CAM_LOOK.y, CAM_LOOK.z)
|
||||
Wait(20)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function stopCamera()
|
||||
if cam and DoesCamExist(cam) then
|
||||
RenderScriptCams(false, true, 900, true, true)
|
||||
DestroyCam(cam, false)
|
||||
cam = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function openAuth()
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open' })
|
||||
DoScreenFadeIn(600)
|
||||
end
|
||||
|
||||
local function closeAuth()
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
-- Keep the unauthenticated player parked and invisible behind the glass.
|
||||
CreateThread(function()
|
||||
while not authDone do
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
SetEntityCollision(ped, false, false)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
DisableAllControlActions(0)
|
||||
Wait(0)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Any map resource can re-enable auto spawn when it starts, so hold the gate
|
||||
-- shut every time that happens until the player actually has an identity.
|
||||
AddEventHandler('onClientMapStart', function()
|
||||
if not authDone then
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
-- Take spawning away from spawnmanager until the player has an identity.
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
|
||||
DoScreenFadeOut(0)
|
||||
while not NetworkIsSessionStarted() do Wait(100) end
|
||||
Wait(500)
|
||||
|
||||
startCamera()
|
||||
openAuth()
|
||||
end)
|
||||
|
||||
-- The account is known; fiverp-characters owns everything from here — the
|
||||
-- slot screen, the appearance creator and the spawn itself. Auth only has to
|
||||
-- fade out, release its camera and step aside.
|
||||
local function enterCity()
|
||||
authDone = true
|
||||
closeAuth()
|
||||
DoScreenFadeOut(500)
|
||||
Wait(600)
|
||||
stopCamera()
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
TriggerEvent('fiverp-chars:begin')
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- NUI callbacks
|
||||
RegisterNUICallback('registerAccount', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:register:account', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('registerIdentity', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:register:identity', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('resumeIdentity', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:resume:identity', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
RegisterNUICallback('login', function(data, cb)
|
||||
TriggerServerEvent('fiverp-auth:login', data)
|
||||
cb({})
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- server said
|
||||
RegisterNetEvent('fiverp-auth:register:accountResult', function(result)
|
||||
SendNUIMessage({ action = 'accountResult', payload = result })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-auth:register:identityResult', function(result)
|
||||
SendNUIMessage({ action = 'identityResult', payload = result })
|
||||
if result.ok then
|
||||
CreateThread(function()
|
||||
Wait(1600) -- let the permit finish stamping before the fade
|
||||
enterCity(result.firstName, result.lastName)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-auth:loginResult', function(result)
|
||||
SendNUIMessage({ action = 'loginResult', payload = result })
|
||||
if result.ok and not result.needsIdentity then
|
||||
CreateThread(function()
|
||||
Wait(1200)
|
||||
enterCity(result.firstName, result.lastName)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,24 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
node_version '22'
|
||||
|
||||
name 'fiverp-auth'
|
||||
author 'FiveRP'
|
||||
description 'Two-step account registration and login for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_script 'client/cl_auth.lua'
|
||||
server_script 'server/sv_auth.js'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
}
|
||||
|
||||
dependency 'oxmysql'
|
||||
@@ -0,0 +1,391 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const RESOURCE = 'fiverp-auth';
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const slab = document.getElementById('slab');
|
||||
const railFill = document.getElementById('railFill');
|
||||
const permit = document.getElementById('permit');
|
||||
const sheen = document.getElementById('sheen');
|
||||
const seal = document.getElementById('seal');
|
||||
|
||||
const cardNo = document.getElementById('cardNo');
|
||||
const cardFirst = document.getElementById('cardFirst');
|
||||
const cardLast = document.getElementById('cardLast');
|
||||
const cardDob = document.getElementById('cardDob');
|
||||
const cardIssue = document.getElementById('cardIssue');
|
||||
const cardExpiry = document.getElementById('cardExpiry');
|
||||
const cardSign = document.getElementById('cardSign');
|
||||
const mrz1 = document.getElementById('mrz1');
|
||||
const mrz2 = document.getElementById('mrz2');
|
||||
const stampDate = document.getElementById('stampDate');
|
||||
|
||||
const views = {
|
||||
login: document.getElementById('formLogin'),
|
||||
account: document.getElementById('formAccount'),
|
||||
identity: document.getElementById('formIdentity')
|
||||
};
|
||||
|
||||
let current = 'login';
|
||||
let busy = false;
|
||||
// Set when sign-in finds an account whose registration never reached step 2.
|
||||
let resuming = false;
|
||||
|
||||
// ------------------------------------------------------------------ post
|
||||
function post(endpoint, data) {
|
||||
return fetch(`https://${RESOURCE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ views
|
||||
function show(view) {
|
||||
current = view;
|
||||
slab.dataset.view = view;
|
||||
|
||||
Object.entries(views).forEach(([name, form]) => {
|
||||
form.classList.toggle('is-current', name === view);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.rail-step').forEach((step) => {
|
||||
const which = step.dataset.step;
|
||||
step.classList.toggle('is-active', which === view);
|
||||
step.classList.toggle('is-done', which === 'account' && view === 'identity');
|
||||
});
|
||||
railFill.classList.toggle('is-full', view === 'identity');
|
||||
|
||||
const first = views[view].querySelector('input');
|
||||
if (first) setTimeout(() => first.focus(), 240);
|
||||
}
|
||||
|
||||
function notice(view, message, good) {
|
||||
const el = views[view].querySelector('[data-notice]');
|
||||
if (!message) {
|
||||
el.classList.remove('is-shown', 'is-good');
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.classList.add('is-shown');
|
||||
el.classList.toggle('is-good', !!good);
|
||||
}
|
||||
|
||||
function setBusy(view, state) {
|
||||
busy = state;
|
||||
const btn = views[view].querySelector('.btn');
|
||||
btn.classList.toggle('is-busy', state);
|
||||
btn.disabled = state;
|
||||
}
|
||||
|
||||
function values(view) {
|
||||
const out = {};
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
out[input.name] = input.value.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function markBad(view, names) {
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
input.classList.toggle('is-bad', names.includes(input.name));
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================== the passport
|
||||
// Everything on the data page is derived from the name, so the same resident
|
||||
// always gets the same document — it reads as a record, not a random mock-up.
|
||||
|
||||
function seedOf(text) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash;
|
||||
}
|
||||
|
||||
function passportNumber(seed) {
|
||||
return String(seed % 1000000000).padStart(9, '0');
|
||||
}
|
||||
|
||||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||
|
||||
function human(date) {
|
||||
return String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
|
||||
}
|
||||
|
||||
// MRZ dates are YYMMDD.
|
||||
function mrzDate(date) {
|
||||
return String(date.getFullYear() % 100).padStart(2, '0') +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
|
||||
function checkDigit(field) {
|
||||
const WEIGHTS = [7, 3, 1];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < field.length; i++) {
|
||||
const c = field[i];
|
||||
let v;
|
||||
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
|
||||
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
|
||||
else v = 0; // '<' filler
|
||||
sum += v * WEIGHTS[i % 3];
|
||||
}
|
||||
return String(sum % 10);
|
||||
}
|
||||
|
||||
function pad(text, length) {
|
||||
return (text + '<'.repeat(length)).slice(0, length);
|
||||
}
|
||||
|
||||
function buildDocument(firstName, lastName) {
|
||||
const seed = seedOf((firstName + lastName).toLowerCase());
|
||||
const number = passportNumber(seed);
|
||||
|
||||
const now = new Date();
|
||||
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
|
||||
// A plausible adult birth date, stable for this name.
|
||||
const dob = new Date(
|
||||
now.getFullYear() - (21 + (seed % 34)),
|
||||
(seed >>> 7) % 12,
|
||||
1 + ((seed >>> 13) % 28)
|
||||
);
|
||||
|
||||
const surname = lastName.toUpperCase();
|
||||
const given = firstName.toUpperCase();
|
||||
|
||||
const line1 = pad('P<USA' + surname + '<<' + given, 44);
|
||||
|
||||
const numCd = checkDigit(number);
|
||||
const dobCd = checkDigit(mrzDate(dob));
|
||||
const expCd = checkDigit(mrzDate(expiry));
|
||||
const personal = '<'.repeat(14);
|
||||
const personalCd = checkDigit(personal);
|
||||
const composite = number + numCd + mrzDate(dob) + dobCd +
|
||||
mrzDate(expiry) + expCd + personal + personalCd;
|
||||
|
||||
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
|
||||
mrzDate(expiry) + expCd + personal + personalCd +
|
||||
checkDigit(composite);
|
||||
|
||||
return { number, issue, expiry, dob, line1, line2 };
|
||||
}
|
||||
|
||||
function printName(el, text) {
|
||||
if (!text) { el.textContent = ''; return; }
|
||||
if (el.textContent === text) return;
|
||||
el.textContent = '';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'printed';
|
||||
span.textContent = text;
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
const BLANK1 = 'P<USA' + '<'.repeat(39);
|
||||
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
|
||||
|
||||
function fillPassport(firstName, lastName) {
|
||||
printName(cardFirst, firstName.toUpperCase());
|
||||
printName(cardLast, lastName.toUpperCase());
|
||||
|
||||
// Below two letters there is not enough to derive a document from.
|
||||
if (firstName.length < 2 || lastName.length < 2) {
|
||||
cardNo.textContent = '—';
|
||||
cardDob.textContent = '—';
|
||||
cardIssue.textContent = '—';
|
||||
cardExpiry.textContent = '—';
|
||||
cardSign.textContent = '';
|
||||
mrz1.textContent = BLANK1;
|
||||
mrz2.textContent = BLANK2;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = buildDocument(firstName, lastName);
|
||||
cardNo.textContent = doc.number;
|
||||
cardDob.textContent = human(doc.dob);
|
||||
cardIssue.textContent = human(doc.issue);
|
||||
cardExpiry.textContent = human(doc.expiry);
|
||||
cardSign.textContent = firstName + ' ' + lastName;
|
||||
mrz1.textContent = doc.line1;
|
||||
mrz2.textContent = doc.line2;
|
||||
stampDate.textContent = human(doc.issue);
|
||||
}
|
||||
|
||||
function syncPassport() {
|
||||
const { firstName, lastName } = values('identity');
|
||||
fillPassport(firstName, lastName);
|
||||
}
|
||||
|
||||
function issuePassport() {
|
||||
permit.classList.add('is-issued');
|
||||
seal.classList.add('is-stamped');
|
||||
sheen.style.opacity = '0.85';
|
||||
}
|
||||
|
||||
// Laminate glare tracks the cursor across the page.
|
||||
permit.addEventListener('pointermove', (e) => {
|
||||
const box = permit.getBoundingClientRect();
|
||||
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
|
||||
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- password meter
|
||||
const pwInput = views.account.querySelector('input[name="password"]');
|
||||
const meter = views.account.querySelector('[data-meter]');
|
||||
|
||||
pwInput.addEventListener('input', () => {
|
||||
const v = pwInput.value;
|
||||
meter.classList.toggle('is-live', v.length > 0);
|
||||
let level = 1;
|
||||
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
|
||||
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
|
||||
meter.dataset.level = String(level);
|
||||
meter.querySelector('i').style.width = `${level * 33.4}%`;
|
||||
});
|
||||
|
||||
views.identity.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
// Keep the document honest: only English letters ever reach it.
|
||||
input.value = input.value.replace(/[^A-Za-z]/g, '');
|
||||
syncPassport();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ validation
|
||||
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
|
||||
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
|
||||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||||
|
||||
function checkAccount(v) {
|
||||
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
|
||||
if (!RE_USERNAME.test(v.username)) return ['Username must be 3–32 characters, letters, numbers or underscore.', ['username']];
|
||||
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkIdentity(v) {
|
||||
if (!RE_NAME.test(v.firstName)) return ['First name must be 2–24 English letters.', ['firstName']];
|
||||
if (!RE_NAME.test(v.lastName)) return ['Last name must be 2–24 English letters.', ['lastName']];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- submits
|
||||
views.login.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('login');
|
||||
if (!v.username || !v.password) {
|
||||
markBad('login', [!v.username ? 'username' : 'password']);
|
||||
return notice('login', 'Enter your username and password.');
|
||||
}
|
||||
markBad('login', []);
|
||||
notice('login', '');
|
||||
setBusy('login', true);
|
||||
post('login', v);
|
||||
});
|
||||
|
||||
views.account.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('account');
|
||||
const bad = checkAccount(v);
|
||||
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
|
||||
markBad('account', []);
|
||||
notice('account', '');
|
||||
setBusy('account', true);
|
||||
post('registerAccount', v);
|
||||
});
|
||||
|
||||
views.identity.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('identity');
|
||||
const bad = checkIdentity(v);
|
||||
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
|
||||
markBad('identity', []);
|
||||
notice('identity', '');
|
||||
setBusy('identity', true);
|
||||
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-goto]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
notice(current, '');
|
||||
show(btn.dataset.goto);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ messages from Lua
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
const p = msg.payload || {};
|
||||
|
||||
switch (msg.action) {
|
||||
case 'open':
|
||||
stage.classList.add('is-open');
|
||||
stage.setAttribute('aria-hidden', 'false');
|
||||
show('login');
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
stage.classList.remove('is-open');
|
||||
stage.setAttribute('aria-hidden', 'true');
|
||||
break;
|
||||
|
||||
case 'accountResult':
|
||||
setBusy('account', false);
|
||||
if (p.ok) {
|
||||
notice('account', '');
|
||||
show('identity');
|
||||
syncPassport();
|
||||
} else {
|
||||
notice('account', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'identityResult':
|
||||
setBusy('identity', false);
|
||||
if (p.ok) {
|
||||
fillPassport(String(p.firstName), String(p.lastName));
|
||||
issuePassport();
|
||||
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
|
||||
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.identity.querySelector('.btn').disabled = true;
|
||||
// Nothing left to go back to once the document exists.
|
||||
views.identity.querySelector('.switch').style.display = 'none';
|
||||
} else {
|
||||
if (p.reset) { resuming = false; show('account'); }
|
||||
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'loginResult':
|
||||
setBusy('login', false);
|
||||
if (p.ok && p.needsIdentity) {
|
||||
// Account is real but has no resident yet — finish step 2.
|
||||
resuming = true;
|
||||
show('identity');
|
||||
syncPassport();
|
||||
notice('identity', 'Your file is open. Name your resident to finish.', true);
|
||||
} else if (p.ok) {
|
||||
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
|
||||
views.login.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.login.querySelector('.btn').disabled = true;
|
||||
} else {
|
||||
markBad('login', ['username', 'password']);
|
||||
notice('login', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the error ring as soon as the player starts fixing the field.
|
||||
document.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => input.classList.remove('is-bad'));
|
||||
});
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FiveRP — Passport Office</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" id="stage" aria-hidden="true">
|
||||
<section class="slab" id="slab" data-view="login">
|
||||
|
||||
<!-- ------------------------------------------------------------- form -->
|
||||
<div class="pane pane-form">
|
||||
<p class="eyebrow">Los Santos <span class="dot"></span> Passport Office</p>
|
||||
|
||||
<div class="rail" id="rail" role="list">
|
||||
<div class="rail-step is-active" data-step="account" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Account</span>
|
||||
</div>
|
||||
<span class="rail-line"><i id="railFill"></i></span>
|
||||
<div class="rail-step" data-step="identity" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Identity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign in -->
|
||||
<form class="view" data-view="login" id="formLogin" novalidate>
|
||||
<h1 class="title">Welcome back</h1>
|
||||
<p class="sub">Sign in to return to the city.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" placeholder="••••••••">
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Sign in</span></button>
|
||||
|
||||
<p class="switch">New to Los Santos? <button type="button" class="link" data-goto="account">Create an account</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 1 -->
|
||||
<form class="view" data-view="account" id="formAccount" novalidate>
|
||||
<h1 class="title">Open your file</h1>
|
||||
<p class="sub">The registry keeps this private. It is how you sign back in.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Email</span>
|
||||
<input type="email" name="email" autocomplete="email" spellcheck="false" placeholder="you@example.com">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
<span class="hint">3–32 characters. Letters, numbers, underscore.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="At least 8 characters">
|
||||
<span class="pwmeter" data-meter><i></i></span>
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Continue</span></button>
|
||||
|
||||
<p class="switch">Already registered? <button type="button" class="link" data-goto="login">Sign in</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 2 -->
|
||||
<form class="view" data-view="identity" id="formIdentity" novalidate>
|
||||
<h1 class="title">Name your resident</h1>
|
||||
<p class="sub">This is the name the city will know you by. English letters only.</p>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="label">First name</span>
|
||||
<input type="text" name="firstName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Marcus">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">Last name</span>
|
||||
<input type="text" name="lastName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Reyes">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Issue passport</span></button>
|
||||
|
||||
<p class="switch"><button type="button" class="link" data-goto="account">Back to account details</button></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- passport -->
|
||||
<div class="pane pane-card" id="pane-card">
|
||||
<div class="passport" id="permit">
|
||||
<div class="pp-guilloche" aria-hidden="true"></div>
|
||||
<div class="pp-sheen" id="sheen" aria-hidden="true"></div>
|
||||
|
||||
<header class="pp-head">
|
||||
<span class="pp-seal" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64">
|
||||
<circle class="ring-out" cx="32" cy="32" r="30"/>
|
||||
<circle class="ring-in" cx="32" cy="32" r="26"/>
|
||||
<!-- 13 stars, arced over the shield -->
|
||||
<g class="stars">
|
||||
<circle cx="32" cy="14" r="1.5"/>
|
||||
<circle cx="25" cy="15.3" r="1.5"/><circle cx="39" cy="15.3" r="1.5"/>
|
||||
<circle cx="19" cy="19" r="1.5"/><circle cx="45" cy="19" r="1.5"/>
|
||||
<circle cx="15" cy="25" r="1.5"/><circle cx="49" cy="25" r="1.5"/>
|
||||
</g>
|
||||
<!-- shield: blue chief over 13 pales -->
|
||||
<path class="shield-chief" d="M17 28h30v7H17z"/>
|
||||
<g class="shield-pales">
|
||||
<path d="M17 35h3v9.5c0 .6.1 1.2.2 1.8H17z"/>
|
||||
<path d="M23 35h3v13h-3z"/>
|
||||
<path d="M29 35h3v13h-3z"/>
|
||||
<path d="M35 35h3v13h-3z"/>
|
||||
<path d="M41 35h3v13h-3z"/>
|
||||
</g>
|
||||
<path class="shield-edge" d="M17 28h30v16.5c0 5.5-6.5 10.5-15 13.5-8.5-3-15-8-15-13.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pp-head-text">
|
||||
<span class="pp-kind">Passport</span>
|
||||
<span class="pp-country">United States of America</span>
|
||||
</span>
|
||||
<span class="pp-chip" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 16">
|
||||
<rect x="0.6" y="0.6" width="18.8" height="14.8" rx="2.4"/>
|
||||
<path d="M7 .6v15M13 .6v15M.6 5.4h18.8M.6 10.6h18.8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="pp-body">
|
||||
<div class="pp-left">
|
||||
<div class="pp-photo">
|
||||
<svg class="pp-bust" viewBox="0 0 48 60" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="bust" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#9aa7b8"/>
|
||||
<stop offset="1" stop-color="#5d6b7e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="18" r="11" fill="url(#bust)"/>
|
||||
<path d="M3 60c0-11.5 9.4-20 21-20s21 8.5 21 20z" fill="url(#bust)"/>
|
||||
</svg>
|
||||
<span class="pp-photo-label">Photo</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-sign">
|
||||
<span class="pp-sign-ink" id="cardSign"></span>
|
||||
<span class="pp-sign-rule"></span>
|
||||
<span class="pp-sign-label">Signature</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="pp-fields">
|
||||
<div class="pp-f pp-f--type"><dt>Type</dt><dd>P</dd></div>
|
||||
<div class="pp-f pp-f--code"><dt>Code</dt><dd>USA</dd></div>
|
||||
<div class="pp-f pp-f--no"><dt>Passport No.</dt><dd id="cardNo" class="mono">—</dd></div>
|
||||
|
||||
<div class="pp-f pp-f--wide"><dt>Surname</dt>
|
||||
<dd class="pp-name" id="cardLast" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Given names</dt>
|
||||
<dd class="pp-name" id="cardFirst" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Nationality</dt>
|
||||
<dd>United States of America</dd></div>
|
||||
|
||||
<div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob">—</dd></div>
|
||||
<div class="pp-f"><dt>Sex</dt><dd>X</dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Place of birth</dt><dd>Los Santos, San Andreas</dd></div>
|
||||
<div class="pp-f"><dt>Date of issue</dt><dd class="mono" id="cardIssue">—</dd></div>
|
||||
<div class="pp-f"><dt>Date of expiration</dt><dd class="mono" id="cardExpiry">—</dd></div>
|
||||
|
||||
<!-- Spans 4 of 6 columns, leaving the bottom-right corner clear so
|
||||
the entry stamp lands on paper rather than on top of data. -->
|
||||
<div class="pp-f pp-f--auth"><dt>Authority</dt>
|
||||
<dd>United States Department of State</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="pp-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 60">
|
||||
<circle cx="24" cy="20" r="12"/>
|
||||
<path d="M2 60c0-12 10-21 22-21s22 9 22 21z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-mrz mono">
|
||||
<span id="mrz1">P<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
<span id="mrz2"><<<<<<<<<<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-stamp" id="seal" aria-hidden="true">
|
||||
<span class="pp-stamp-top">Admitted</span>
|
||||
<span class="pp-stamp-mid">Los Santos</span>
|
||||
<span class="pp-stamp-bot" id="stampDate">— — —</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pp-caption">Issued once. The city will know you by this name.</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,727 @@
|
||||
/* FiveRP — passport office (registration + sign in).
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md
|
||||
|
||||
Deliberately zero box-shadow on panels. CEF composites the shadow of a
|
||||
backdrop-filtered element as an opaque rectangle, which is what put a black
|
||||
square behind this panel in game. Depth here comes from blur, hairlines and
|
||||
inner highlights only. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
|
||||
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--panel: rgba(255, 255, 255, 0.80);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--field-bg-focus:#ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--ring: rgba(0, 113, 227, 0.18);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--hover: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
|
||||
/* passport ink */
|
||||
--pp-navy: #10315e;
|
||||
--pp-label: #46719f;
|
||||
--pp-value: #14202f;
|
||||
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent; /* the game is behind this page */
|
||||
overflow: hidden;
|
||||
font-family: var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- stage */
|
||||
.stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 460ms var(--ease), visibility 0s linear 460ms;
|
||||
/* Lightening only. Never darken the game to make the panel readable —
|
||||
that is what produced the grey box before. */
|
||||
background: radial-gradient(90% 70% at 50% 50%,
|
||||
rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 72%);
|
||||
}
|
||||
.stage.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 460ms var(--ease), visibility 0s;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- slab */
|
||||
.slab {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 520px;
|
||||
width: min(1080px, 94vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
opacity: 0;
|
||||
transition:
|
||||
width 620ms var(--ease),
|
||||
grid-template-columns 620ms var(--ease),
|
||||
transform 620ms var(--ease),
|
||||
opacity 480ms var(--ease);
|
||||
}
|
||||
.stage.is-open .slab { transform: none; opacity: 1; }
|
||||
|
||||
/* Signing in needs no passport — collapse to a single column. */
|
||||
.slab[data-view="login"] {
|
||||
width: min(460px, 94vw);
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.slab[data-view="login"] .pane-card {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.pane { min-width: 0; }
|
||||
|
||||
.pane-form {
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-card {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.14));
|
||||
transition: opacity 420ms var(--ease), transform 620ms var(--ease);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- eyebrow */
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.eyebrow .dot {
|
||||
width: 3px; height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- rail */
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 360ms var(--ease);
|
||||
}
|
||||
/* Signing in has no steps, so the rail collapses margin and all. */
|
||||
.slab[data-view="account"] .rail,
|
||||
.slab[data-view="identity"] .rail { opacity: 1; height: auto; margin-bottom: 30px; }
|
||||
|
||||
.rail-step { display: flex; align-items: center; gap: 8px; }
|
||||
.rail-mark {
|
||||
position: relative;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
background: transparent;
|
||||
transition: background-color .3s var(--ease), border-color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
.rail-step.is-done .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
/* Done and active must not look identical — done gets a tick. */
|
||||
.rail-step.is-done .rail-mark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2.4px; top: 1px;
|
||||
width: 3px; height: 5.5px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
.rail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
transition: color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-label,
|
||||
.rail-step.is-done .rail-label { color: var(--fg-secondary); }
|
||||
|
||||
.rail-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--line-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-line i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 560ms var(--ease);
|
||||
}
|
||||
.rail-line i.is-full { width: 100%; }
|
||||
|
||||
/* ------------------------------------------------------------------- views */
|
||||
.view {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view.is-current { display: flex; animation: viewIn .5s var(--ease) both; }
|
||||
|
||||
@keyframes viewIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ fields */
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 14px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--fg);
|
||||
background: var(--field-bg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
transition: background-color .2s, border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.field input::placeholder { color: var(--fg-tertiary); }
|
||||
.field input:focus {
|
||||
background: var(--field-bg-focus);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field input.is-bad {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.row .field { margin-bottom: 16px; }
|
||||
|
||||
/* password strength */
|
||||
.pwmeter {
|
||||
display: block;
|
||||
height: 3px;
|
||||
margin-top: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--line);
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
.pwmeter.is-live { opacity: 1; }
|
||||
.pwmeter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger);
|
||||
transition: width .3s var(--ease), background-color .3s ease;
|
||||
}
|
||||
.pwmeter[data-level="2"] i { background: #ff9f0a; }
|
||||
.pwmeter[data-level="3"] i { background: var(--success); }
|
||||
|
||||
/* ------------------------------------------------------------------ notice */
|
||||
.notice {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--danger);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height .3s var(--ease), opacity .3s ease, margin .3s var(--ease);
|
||||
}
|
||||
.notice.is-shown {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.notice.is-good { color: #1f8f3d; }
|
||||
|
||||
/* ----------------------------------------------------------------- buttons */
|
||||
.btn {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 14px 24px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
transition: filter .2s ease, transform .12s var(--ease), opacity .2s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.btn:active:not(:disabled) { transform: scale(0.978); }
|
||||
.btn:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
.btn.is-busy span { opacity: 0; }
|
||||
.btn.is-busy::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 16px; height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.switch {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* ==================================================================== PASSPORT
|
||||
A United States passport data page. This is the one saturated object on the
|
||||
screen; the form beside it stays neutral so the document reads as the hero. */
|
||||
|
||||
.passport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 125 / 88; /* ID-3, the real bio-page proportion */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 11px 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--pp-value);
|
||||
background:
|
||||
radial-gradient(78% 66% at 16% 14%, rgba(255,255,255,0.92), rgba(255,255,255,0) 62%),
|
||||
linear-gradient(155deg, #f8fbff 0%, #e9f1fa 46%, #f5f1e7 100%);
|
||||
border: 1px solid rgba(16, 49, 94, 0.16);
|
||||
transition: transform 700ms var(--ease);
|
||||
}
|
||||
.passport.is-issued { transform: translateY(-3px); }
|
||||
|
||||
/* Engine-turned security print. Three cheap repeating gradients read
|
||||
convincingly as guilloche at this scale. */
|
||||
.pp-guilloche {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background:
|
||||
repeating-radial-gradient(circle at 20% 44%, rgba(16,49,94,.055) 0 1px, transparent 1px 7px),
|
||||
repeating-radial-gradient(circle at 80% 62%, rgba(16,49,94,.05) 0 1px, transparent 1px 9px),
|
||||
repeating-linear-gradient(58deg, rgba(16,49,94,.032) 0 1px, transparent 1px 6px);
|
||||
}
|
||||
|
||||
/* Laminate glare that follows the cursor. */
|
||||
.pp-sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background: radial-gradient(circle at var(--mx, 78%) var(--my, 10%),
|
||||
rgba(255,255,255,0.75) 0%, rgba(255,255,255,0) 46%);
|
||||
transition: opacity .5s ease;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ passport head */
|
||||
.pp-head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid rgba(16, 49, 94, 0.18);
|
||||
}
|
||||
.pp-seal { flex: none; width: 30px; height: 30px; display: block; }
|
||||
.pp-seal svg { width: 100%; height: 100%; display: block; }
|
||||
.pp-seal .ring-out,
|
||||
.pp-seal .ring-in { fill: none; stroke: rgba(16,49,94,.55); stroke-width: 1.1; }
|
||||
.pp-seal .ring-in { stroke-width: .6; }
|
||||
.pp-seal .stars { fill: rgba(16,49,94,.6); }
|
||||
.pp-seal .shield-chief { fill: rgba(16,49,94,.75); }
|
||||
.pp-seal .shield-pales { fill: rgba(16,49,94,.28); }
|
||||
.pp-seal .shield-edge { fill: none; stroke: rgba(16,49,94,.65); stroke-width: 1.2; }
|
||||
|
||||
.pp-head-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.pp-kind {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.30em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy);
|
||||
}
|
||||
.pp-country {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
}
|
||||
.pp-chip { margin-left: auto; width: 19px; height: 15px; opacity: .6; }
|
||||
.pp-chip svg { width: 100%; height: 100%; fill: none; stroke: rgba(16,49,94,.6); stroke-width: 1; }
|
||||
|
||||
/* ------------------------------------------------------------ passport body */
|
||||
.pp-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pp-left { display: flex; flex-direction: column; gap: 9px; }
|
||||
|
||||
.pp-photo {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
border: 1px solid rgba(16, 49, 94, 0.22);
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #eef2f7, #dde5ee);
|
||||
aspect-ratio: 35 / 45; /* passport photo proportion */
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
}
|
||||
.pp-bust { width: 76%; height: 88%; align-self: end; }
|
||||
.pp-photo-label {
|
||||
position: absolute;
|
||||
top: 4px; left: 5px;
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(16, 49, 94, 0.42);
|
||||
}
|
||||
|
||||
/* The secondary faded portrait real passports carry to the right of the data. */
|
||||
.pp-ghost {
|
||||
position: absolute;
|
||||
right: 2px; top: 34%;
|
||||
width: 50px;
|
||||
opacity: .1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-ghost svg { width: 100%; display: block; fill: var(--pp-navy); }
|
||||
|
||||
.pp-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px 10px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.pp-f { grid-column: span 2; min-width: 0; }
|
||||
.pp-f--type { grid-column: span 1; }
|
||||
.pp-f--code { grid-column: span 1; }
|
||||
.pp-f--no { grid-column: span 4; }
|
||||
.pp-f--wide { grid-column: span 6; }
|
||||
|
||||
.pp-f dt {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pp-f dd {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--pp-value);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pp-f dd.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.pp-f--auth { grid-column: span 4; }
|
||||
.pp-f--auth dd { font-size: 8.5px; font-weight: 400; color: var(--pp-label); }
|
||||
|
||||
/* The two fields the player actually controls — set larger than the rest. */
|
||||
.pp-name {
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.25 !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy) !important;
|
||||
min-height: 19px;
|
||||
}
|
||||
.pp-name:empty::before {
|
||||
content: attr(data-empty);
|
||||
color: rgba(16, 49, 94, 0.25);
|
||||
}
|
||||
/* Each keystroke re-prints the value; the fade makes it feel typeset. */
|
||||
.printed { display: inline-block; animation: printIn .4s var(--ease) both; }
|
||||
@keyframes printIn {
|
||||
from { opacity: 0; transform: translateY(3px); filter: blur(3px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- signature */
|
||||
.pp-sign {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pp-sign-ink {
|
||||
font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
|
||||
font-size: 11px;
|
||||
color: rgba(16, 49, 94, 0.75);
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transform: rotate(-1.5deg);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.pp-sign-rule { height: 1px; background: rgba(16, 49, 94, 0.28); }
|
||||
.pp-sign-label {
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- MRZ */
|
||||
.pp-mrz {
|
||||
margin: 6px -15px 0;
|
||||
margin-top: auto; /* pinned to the foot of the page */
|
||||
padding: 6px 15px 7px;
|
||||
border-top: 1px solid rgba(16, 49, 94, 0.18);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.pp-mrz span {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.055em;
|
||||
color: var(--pp-navy);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- stamp */
|
||||
.pp-stamp {
|
||||
position: absolute;
|
||||
right: 13px; bottom: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 7px 13px;
|
||||
border: 2px solid rgba(176, 42, 42, 0.62);
|
||||
border-radius: 4px;
|
||||
color: rgba(176, 42, 42, 0.78);
|
||||
/* multiply lets the guilloche show through, like real stamp ink */
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(-13deg) scale(1.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 420ms var(--ease), transform 520ms cubic-bezier(.2,1.4,.4,1);
|
||||
}
|
||||
.pp-stamp.is-stamped { opacity: 1; transform: rotate(-13deg) scale(1); }
|
||||
|
||||
.pp-stamp-top, .pp-stamp-bot {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pp-stamp-mid {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pp-caption {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
||||
::selection { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
|
||||
/* ---------------------------------------------------------------- NUI final
|
||||
CEF turns backdrop-filter and box-shadow into opaque black plates at the
|
||||
element bounds, so both are gone from this file entirely. Panels earn their
|
||||
separation from opacity and hairlines instead of blur, which means the
|
||||
surface has to carry more white than it did when a blur sat behind it.
|
||||
Appended last so it wins over anything above. */
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--panel: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
.slab {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.stage { background: none; }
|
||||
.field:focus,
|
||||
.field:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
-- Character slots, live appearance creator and spawn.
|
||||
-- The preview ped IS the player ped, so what the player sculpts is literally
|
||||
-- the body they walk away with — no separate preview entity to keep in sync.
|
||||
|
||||
local isOpen = false
|
||||
local inEditor = false
|
||||
local activeChar = nil -- id of the character being played
|
||||
local editSlot = nil -- slot being filled
|
||||
local editId = nil -- id when finishing an existing draft
|
||||
local cam = nil
|
||||
local zoom = 'head'
|
||||
|
||||
local PREVIEW_POS = vector3(-1042.0, -2745.0, 21.36)
|
||||
local PREVIEW_HDG = 315.0
|
||||
local SPAWN_POS = vector3(195.17, -889.36, 30.69)
|
||||
local SPAWN_HDG = 145.0
|
||||
|
||||
-- Camera framing per zoom step: forward distance, height above the ped root,
|
||||
-- and the height it looks at.
|
||||
local FRAMES = {
|
||||
head = { dist = 0.85, height = 0.70, look = 0.68 },
|
||||
body = { dist = 2.30, height = 0.30, look = 0.25 },
|
||||
legs = { dist = 2.10, height = -0.35, look = -0.45 },
|
||||
}
|
||||
|
||||
-- Fallback counts, used only if the native that reports them is unavailable.
|
||||
local OVERLAY_FALLBACK = { 24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12 }
|
||||
|
||||
-- ------------------------------------------------------------------- helpers
|
||||
local function loadModel(name)
|
||||
local hash = GetHashKey(name)
|
||||
if not IsModelInCdimage(hash) then return nil end
|
||||
RequestModel(hash)
|
||||
local timeout = GetGameTimer() + 10000
|
||||
while not HasModelLoaded(hash) and GetGameTimer() < timeout do Wait(0) end
|
||||
return HasModelLoaded(hash) and hash or nil
|
||||
end
|
||||
|
||||
local function num(v, fallback)
|
||||
v = tonumber(v)
|
||||
if v == nil then return fallback end
|
||||
return v + 0.0
|
||||
end
|
||||
|
||||
-- Overlays 1/2/10 take hair colours, 4/5/8 take makeup colours, the rest none.
|
||||
local function overlayColourType(index)
|
||||
if index == 1 or index == 2 or index == 10 then return 1 end
|
||||
if index == 4 or index == 5 or index == 8 then return 2 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function applyAppearance(app)
|
||||
if type(app) ~= 'table' then return end
|
||||
|
||||
local wanted = GetHashKey(app.model or 'mp_m_freemode_01')
|
||||
if GetEntityModel(PlayerPedId()) ~= wanted then
|
||||
local hash = loadModel(app.model or 'mp_m_freemode_01')
|
||||
if hash then
|
||||
SetPlayerModel(PlayerId(), hash)
|
||||
SetModelAsNoLongerNeeded(hash)
|
||||
SetPedDefaultComponentVariation(PlayerPedId())
|
||||
end
|
||||
end
|
||||
|
||||
local ped = PlayerPedId()
|
||||
local b = app.blend or {}
|
||||
SetPedHeadBlendData(ped,
|
||||
math.floor(num(b.shapeFirst, 0)), math.floor(num(b.shapeSecond, 0)), 0,
|
||||
math.floor(num(b.skinFirst, 0)), math.floor(num(b.skinSecond, 0)), 0,
|
||||
num(b.shapeMix, 0.5), num(b.skinMix, 0.5), 0.0, false)
|
||||
|
||||
local features = app.features or {}
|
||||
for i = 0, 19 do
|
||||
SetPedFaceFeature(ped, i, num(features[i + 1], 0.0))
|
||||
end
|
||||
|
||||
local overlays = app.overlays or {}
|
||||
for i = 0, 11 do
|
||||
local o = overlays[i + 1] or {}
|
||||
local value = math.floor(num(o.v, 255))
|
||||
SetPedHeadOverlay(ped, i, value, num(o.o, 1.0))
|
||||
local kind = overlayColourType(i)
|
||||
if kind > 0 then
|
||||
SetPedHeadOverlayColor(ped, i, kind, math.floor(num(o.c1, 0)), math.floor(num(o.c2, 0)))
|
||||
end
|
||||
end
|
||||
|
||||
local hair = app.hair or {}
|
||||
SetPedComponentVariation(ped, 2, math.floor(num(hair.style, 0)), 0, 2)
|
||||
SetPedHairColor(ped, math.floor(num(hair.color, 0)), math.floor(num(hair.highlight, 0)))
|
||||
SetPedEyeColor(ped, math.floor(num(app.eye, 0)))
|
||||
|
||||
local fit = app.outfit or {}
|
||||
SetPedComponentVariation(ped, 11, math.floor(num(fit.torso, 0)), 0, 2) -- jacket / top
|
||||
SetPedComponentVariation(ped, 8, math.floor(num(fit.undershirt, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 4, math.floor(num(fit.legs, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 6, math.floor(num(fit.shoes, 0)), 0, 2)
|
||||
SetPedComponentVariation(ped, 3, 0, 0, 2) -- bare arms/torso
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------- camera
|
||||
local function updateCamera()
|
||||
if not cam or not DoesCamExist(cam) then return end
|
||||
local ped = PlayerPedId()
|
||||
local f = FRAMES[zoom] or FRAMES.head
|
||||
local pos = GetOffsetFromEntityInWorldCoords(ped, 0.0, f.dist, f.height)
|
||||
local look = GetOffsetFromEntityInWorldCoords(ped, 0.0, 0.0, f.look)
|
||||
SetCamCoord(cam, pos.x, pos.y, pos.z)
|
||||
PointCamAtCoord(cam, look.x, look.y, look.z)
|
||||
end
|
||||
|
||||
local function startCamera()
|
||||
if cam and DoesCamExist(cam) then DestroyCam(cam, false) end
|
||||
cam = CreateCamWithParams('DEFAULT_SCRIPTED_CAMERA', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 32.0, false, 0)
|
||||
SetCamActive(cam, true)
|
||||
RenderScriptCams(true, false, 0, true, true)
|
||||
updateCamera()
|
||||
end
|
||||
|
||||
local function stopCamera()
|
||||
if cam and DoesCamExist(cam) then
|
||||
RenderScriptCams(false, true, 700, true, true)
|
||||
DestroyCam(cam, false)
|
||||
end
|
||||
cam = nil
|
||||
end
|
||||
|
||||
-- Park the ped on the creator stage: visible, lit, frozen, facing the camera.
|
||||
local function stagePed()
|
||||
local ped = PlayerPedId()
|
||||
RequestCollisionAtCoord(PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z)
|
||||
SetEntityCoordsNoOffset(ped, PREVIEW_POS.x, PREVIEW_POS.y, PREVIEW_POS.z, false, false, false)
|
||||
SetEntityHeading(ped, PREVIEW_HDG)
|
||||
FreezeEntityPosition(ped, true)
|
||||
SetEntityInvincible(ped, true)
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
ClearPedTasksImmediately(ped)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------- NUI
|
||||
local function sendLimits()
|
||||
local ped = PlayerPedId()
|
||||
local overlays = {}
|
||||
for i = 0, 11 do
|
||||
local ok, count = pcall(GetNumHeadOverlayValues, i)
|
||||
overlays[i + 1] = (ok and count and count > 0) and count or OVERLAY_FALLBACK[i + 1]
|
||||
end
|
||||
SendNUIMessage({ action = 'limits', payload = {
|
||||
overlays = overlays,
|
||||
hair = GetNumberOfPedDrawableVariations(ped, 2),
|
||||
torso = GetNumberOfPedDrawableVariations(ped, 11),
|
||||
undershirt = GetNumberOfPedDrawableVariations(ped, 8),
|
||||
legs = GetNumberOfPedDrawableVariations(ped, 4),
|
||||
shoes = GetNumberOfPedDrawableVariations(ped, 6),
|
||||
}})
|
||||
end
|
||||
|
||||
local function openUI(view)
|
||||
isOpen = true
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ action = 'open', view = view })
|
||||
end
|
||||
|
||||
local function closeUI()
|
||||
isOpen = false
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ action = 'close' })
|
||||
end
|
||||
|
||||
-- Hold the player still and silent while the slots or the creator are up.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if isOpen then
|
||||
DisableAllControlActions(0)
|
||||
-- Mouse look would fight the scripted camera.
|
||||
DisableControlAction(0, 1, true)
|
||||
DisableControlAction(0, 2, true)
|
||||
SetPlayerInvincible(PlayerId(), true)
|
||||
Wait(0)
|
||||
else
|
||||
Wait(300)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- the flow
|
||||
local function beginSelection()
|
||||
exports.spawnmanager:setAutoSpawn(false)
|
||||
|
||||
-- A clear midday sky so nobody sculpts a face in the dark.
|
||||
NetworkOverrideClockTime(13, 0, 0)
|
||||
SetWeatherTypeNowPersist('EXTRASUNNY')
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
stagePed()
|
||||
SetEntityVisible(ped, false, false)
|
||||
|
||||
startCamera()
|
||||
zoom = 'body'
|
||||
updateCamera()
|
||||
|
||||
openUI('select')
|
||||
TriggerServerEvent('fiverp-chars:request')
|
||||
DoScreenFadeIn(600)
|
||||
end
|
||||
|
||||
-- fiverp-auth hands over with a local TriggerEvent once the account is known.
|
||||
RegisterNetEvent('fiverp-chars:begin', beginSelection)
|
||||
|
||||
AddEventHandler('onClientMapStart', function()
|
||||
if not activeChar then exports.spawnmanager:setAutoSpawn(false) end
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-chars:slots', function(slots)
|
||||
SendNUIMessage({ action = 'slots', payload = slots })
|
||||
end)
|
||||
|
||||
RegisterNetEvent('fiverp-chars:saveResult', function(result)
|
||||
SendNUIMessage({ action = 'saveResult', payload = result })
|
||||
end)
|
||||
|
||||
local function enterCity(data)
|
||||
activeChar = data.id
|
||||
inEditor = false
|
||||
closeUI()
|
||||
DoScreenFadeOut(500)
|
||||
Wait(600)
|
||||
stopCamera()
|
||||
|
||||
NetworkClearClockTimeOverride()
|
||||
ClearWeatherTypePersist()
|
||||
|
||||
local pos = data.position
|
||||
local x, y, z, h
|
||||
if pos and pos.x and pos.x ~= 0 then
|
||||
x, y, z, h = pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, (pos.heading or 0.0) + 0.0
|
||||
else
|
||||
x, y, z, h = SPAWN_POS.x, SPAWN_POS.y, SPAWN_POS.z, SPAWN_HDG
|
||||
end
|
||||
|
||||
exports.spawnmanager:spawnPlayer({
|
||||
x = x, y = y, z = z, heading = h,
|
||||
model = data.model or 'mp_m_freemode_01',
|
||||
skipFade = true
|
||||
}, function()
|
||||
-- spawnPlayer resets the model, so the face has to go back on afterwards.
|
||||
applyAppearance(data.appearance)
|
||||
local ped = PlayerPedId()
|
||||
FreezeEntityPosition(ped, false)
|
||||
SetEntityInvincible(ped, false)
|
||||
SetEntityVisible(ped, true, false)
|
||||
SetEntityCollision(ped, true, true)
|
||||
EnableAllControlActions(0)
|
||||
SetPlayerInvincible(PlayerId(), false)
|
||||
DoScreenFadeIn(800)
|
||||
|
||||
TriggerEvent('chat:addMessage', {
|
||||
color = { 0, 113, 227 },
|
||||
multiline = true,
|
||||
args = { 'Los Santos', ('Welcome back, %s %s.'):format(data.firstName, data.lastName) }
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNetEvent('fiverp-chars:spawn', function(data)
|
||||
if not data.ok then
|
||||
return SendNUIMessage({ action = 'saveResult', payload = { ok = false, error = data.error } })
|
||||
end
|
||||
enterCity(data)
|
||||
end)
|
||||
|
||||
-- Persist where the player stood, so the next session resumes there.
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(60000)
|
||||
if activeChar then
|
||||
local c = GetEntityCoords(PlayerPedId())
|
||||
TriggerServerEvent('fiverp-chars:savePosition', {
|
||||
id = activeChar, x = c.x, y = c.y, z = c.z, heading = GetEntityHeading(PlayerPedId())
|
||||
})
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler('onResourceStop', function(resource)
|
||||
if resource ~= GetCurrentResourceName() then return end
|
||||
if isOpen then SetNuiFocus(false, false) end
|
||||
stopCamera()
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------------- callbacks
|
||||
RegisterNUICallback('startEditor', function(data, cb)
|
||||
inEditor = true
|
||||
editSlot = data.slot
|
||||
editId = data.id
|
||||
zoom = 'head'
|
||||
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, true, false)
|
||||
stagePed()
|
||||
applyAppearance(data.appearance)
|
||||
stagePed()
|
||||
sendLimits()
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('preview', function(data, cb)
|
||||
if inEditor then
|
||||
local before = GetEntityModel(PlayerPedId())
|
||||
applyAppearance(data)
|
||||
if GetEntityModel(PlayerPedId()) ~= before then
|
||||
-- The model swap hands back a fresh ped: re-stage it and re-measure the
|
||||
-- wardrobe, whose drawable counts differ between the two bodies.
|
||||
stagePed()
|
||||
applyAppearance(data)
|
||||
sendLimits()
|
||||
end
|
||||
updateCamera()
|
||||
end
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('rotate', function(data, cb)
|
||||
local ped = PlayerPedId()
|
||||
SetEntityHeading(ped, (GetEntityHeading(ped) + (tonumber(data.by) or 0.0)) % 360.0)
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('zoom', function(data, cb)
|
||||
zoom = data.part or 'head'
|
||||
updateCamera()
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('createChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:create', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('finishChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:finish', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('deleteChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:delete', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('playChar', function(data, cb)
|
||||
TriggerServerEvent('fiverp-chars:play', data)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('backToSelect', function(data, cb)
|
||||
inEditor = false
|
||||
editSlot, editId = nil, nil
|
||||
zoom = 'body'
|
||||
local ped = PlayerPedId()
|
||||
SetEntityVisible(ped, false, false)
|
||||
stagePed()
|
||||
SetEntityVisible(ped, false, false)
|
||||
updateCamera()
|
||||
TriggerServerEvent('fiverp-chars:request')
|
||||
cb({ ok = true })
|
||||
end)
|
||||
@@ -0,0 +1,25 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
node_version '22'
|
||||
|
||||
name 'fiverp-characters'
|
||||
author 'FiveRP'
|
||||
description 'Character slots, appearance creator and spawn for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
ui_page 'html/index.html'
|
||||
|
||||
client_script 'client/cl_chars.lua'
|
||||
server_script 'server/sv_chars.js'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2'
|
||||
}
|
||||
|
||||
dependency 'oxmysql'
|
||||
dependency 'fiverp-auth'
|
||||
@@ -0,0 +1,512 @@
|
||||
/* FiveRP — character slots and appearance creator.
|
||||
Every control writes into one `look` object; that object is what the ped is
|
||||
rebuilt from on the client and what the row stores in the database, so the
|
||||
preview and the saved character can never drift apart. */
|
||||
|
||||
const RES = 'fiverp-characters';
|
||||
|
||||
function post(name, data) {
|
||||
return fetch(`https://${RES}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const root = $('root');
|
||||
const screens = { select: $('screenSelect'), editor: $('screenEditor') };
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
// Parent heads: the game keeps the male faces at the bottom of the list and
|
||||
// the female ones above them.
|
||||
const FATHERS = 21; // head ids 0..20
|
||||
const MOTHERS = 24; // head ids 21..44
|
||||
|
||||
const FEATURES = [
|
||||
'Nose width', 'Nose height', 'Nose length', 'Nose bridge', 'Nose tip',
|
||||
'Nose bridge shift', 'Brow height', 'Brow width', 'Cheekbone height',
|
||||
'Cheekbone width', 'Cheek width', 'Eye opening', 'Lip thickness',
|
||||
'Jaw width', 'Jaw length', 'Chin height', 'Chin length', 'Chin width',
|
||||
'Chin dimple', 'Neck thickness'
|
||||
];
|
||||
|
||||
let limits = {
|
||||
overlays: [24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12],
|
||||
hair: 1, torso: 1, undershirt: 1, legs: 1, shoes: 1
|
||||
};
|
||||
|
||||
let slots = [];
|
||||
let ctx = { slot: 1, id: null, mode: 'create' };
|
||||
let tab = 'heritage';
|
||||
let look = blankLook();
|
||||
|
||||
function blankLook() {
|
||||
return {
|
||||
model: 'mp_m_freemode_01',
|
||||
blend: { shapeFirst: 0, shapeSecond: 21, shapeMix: 0.5, skinFirst: 0, skinSecond: 21, skinMix: 0.5 },
|
||||
features: new Array(20).fill(0),
|
||||
overlays: Array.from({ length: 12 }, () => ({ v: 255, o: 1, c1: 0, c2: 0 })),
|
||||
hair: { style: 0, color: 0, highlight: 0 },
|
||||
eye: 0,
|
||||
outfit: { torso: 0, undershirt: 0, legs: 0, shoes: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// The ped rebuild is cheap but not free; coalesce a slider drag into one call
|
||||
// per frame-ish so dragging stays smooth.
|
||||
let pushTimer = null;
|
||||
function pushLook() {
|
||||
clearTimeout(pushTimer);
|
||||
pushTimer = setTimeout(() => post('preview', look), 45);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- slot screen
|
||||
function show(name) {
|
||||
Object.entries(screens).forEach(([key, el]) => el.classList.toggle('is-active', key === name));
|
||||
}
|
||||
|
||||
function when(value) {
|
||||
if (!value) return null;
|
||||
const d = new Date(String(value).replace(' ', 'T'));
|
||||
if (isNaN(d)) return null;
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderSlots() {
|
||||
const host = $('slots');
|
||||
host.innerHTML = '';
|
||||
|
||||
slots.forEach((s, i) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'slot fade-up delay-' + Math.min(i + 1, 3);
|
||||
|
||||
if (!s.id) {
|
||||
card.classList.add('slot--empty');
|
||||
card.innerHTML = `
|
||||
<div class="plus">+</div>
|
||||
<div class="label">Create character</div>
|
||||
<div class="hint">Slot ${s.slot} is free</div>`;
|
||||
card.addEventListener('click', () => openEditor({ slot: s.slot, mode: 'create' }));
|
||||
} else {
|
||||
const played = when(s.lastPlayed);
|
||||
card.innerHTML = `
|
||||
<p class="slot-no">Slot ${s.slot}</p>
|
||||
<h2 class="slot-name">${s.firstName}<br>${s.lastName}</h2>
|
||||
<p class="slot-meta">${played ? 'Last seen ' + played : 'Never played'}</p>
|
||||
<span class="slot-badge ${s.ready ? '' : 'is-draft'}">${s.ready ? 'Resident' : 'Needs appearance'}</span>
|
||||
<div class="slot-actions"></div>`;
|
||||
|
||||
const actions = card.querySelector('.slot-actions');
|
||||
|
||||
const primary = document.createElement('button');
|
||||
primary.className = 'btn-primary';
|
||||
primary.textContent = s.ready ? 'Enter the city' : 'Finish character';
|
||||
primary.addEventListener('click', () => {
|
||||
if (s.ready) post('playChar', { id: s.id });
|
||||
else openEditor({ slot: s.slot, id: s.id, mode: 'finish', first: s.firstName, last: s.lastName });
|
||||
});
|
||||
|
||||
// Deleting is permanent, so the button asks once before it fires.
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'btn-ghost btn-danger';
|
||||
remove.textContent = 'Delete';
|
||||
let armed = false;
|
||||
remove.addEventListener('click', () => {
|
||||
if (!armed) {
|
||||
armed = true;
|
||||
remove.textContent = 'Confirm delete';
|
||||
setTimeout(() => { armed = false; remove.textContent = 'Delete'; }, 4000);
|
||||
return;
|
||||
}
|
||||
post('deleteChar', { id: s.id });
|
||||
});
|
||||
|
||||
actions.append(primary, remove);
|
||||
}
|
||||
|
||||
host.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- controls
|
||||
function control(label, opts) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ctl';
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'ctl-top';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ctl-name';
|
||||
name.textContent = label;
|
||||
const val = document.createElement('span');
|
||||
val.className = 'ctl-val';
|
||||
top.append(name, val);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = opts.min;
|
||||
input.max = opts.max;
|
||||
input.step = opts.step || 1;
|
||||
input.value = opts.get();
|
||||
|
||||
const paint = () => { val.textContent = (opts.fmt || String)(Number(input.value)); };
|
||||
paint();
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
opts.set(Number(input.value));
|
||||
paint();
|
||||
pushLook();
|
||||
});
|
||||
|
||||
wrap.append(top, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function groupLabel(text) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'group-label';
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
// An overlay slider where the first stop means "none" (the game uses 255).
|
||||
function overlayControl(host, label, index, colour) {
|
||||
const count = limits.overlays[index] || 1;
|
||||
host.appendChild(control(label, {
|
||||
min: 0, max: count, step: 1,
|
||||
get: () => (look.overlays[index].v === 255 ? 0 : look.overlays[index].v + 1),
|
||||
set: (v) => { look.overlays[index].v = v === 0 ? 255 : v - 1; },
|
||||
fmt: (v) => (v === 0 ? 'None' : String(v))
|
||||
}));
|
||||
|
||||
host.appendChild(control(label + ' — strength', {
|
||||
min: 0, max: 1, step: 0.05,
|
||||
get: () => look.overlays[index].o,
|
||||
set: (v) => { look.overlays[index].o = v; },
|
||||
fmt: (v) => Math.round(v * 100) + '%'
|
||||
}));
|
||||
|
||||
if (colour) {
|
||||
host.appendChild(control(label + ' — colour', {
|
||||
min: 0, max: 63, step: 1,
|
||||
get: () => look.overlays[index].c1,
|
||||
set: (v) => { look.overlays[index].c1 = v; look.overlays[index].c2 = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
id: 'heritage', name: 'Heritage',
|
||||
build(host) {
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'toggle';
|
||||
[['mp_m_freemode_01', 'Male'], ['mp_f_freemode_01', 'Female']].forEach(([model, label]) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
b.className = look.model === model ? 'is-on' : '';
|
||||
b.addEventListener('click', () => {
|
||||
if (look.model === model) return;
|
||||
look.model = model;
|
||||
// A new body resets the wardrobe indices; the old ones may not exist.
|
||||
look.outfit = { torso: 0, undershirt: 0, legs: 0, shoes: 0 };
|
||||
look.hair.style = 0;
|
||||
post('preview', look);
|
||||
renderTab();
|
||||
});
|
||||
toggle.appendChild(b);
|
||||
});
|
||||
host.append(groupLabel('Body'), toggle);
|
||||
|
||||
host.appendChild(groupLabel('Parents'));
|
||||
host.appendChild(control('Father', {
|
||||
min: 0, max: FATHERS - 1,
|
||||
get: () => look.blend.shapeFirst,
|
||||
set: (v) => { look.blend.shapeFirst = v; look.blend.skinFirst = v; },
|
||||
fmt: (v) => String(v + 1)
|
||||
}));
|
||||
host.appendChild(control('Mother', {
|
||||
min: 21, max: 21 + MOTHERS - 1,
|
||||
get: () => look.blend.shapeSecond,
|
||||
set: (v) => { look.blend.shapeSecond = v; look.blend.skinSecond = v; },
|
||||
fmt: (v) => String(v - 20)
|
||||
}));
|
||||
host.appendChild(control('Resemblance', {
|
||||
min: 0, max: 1, step: 0.02,
|
||||
get: () => look.blend.shapeMix,
|
||||
set: (v) => { look.blend.shapeMix = v; },
|
||||
fmt: (v) => v <= 0.5
|
||||
? 'Father ' + Math.round((1 - v) * 100) + '%'
|
||||
: 'Mother ' + Math.round(v * 100) + '%'
|
||||
}));
|
||||
host.appendChild(control('Skin tone', {
|
||||
min: 0, max: 1, step: 0.02,
|
||||
get: () => look.blend.skinMix,
|
||||
set: (v) => { look.blend.skinMix = v; },
|
||||
fmt: (v) => Math.round(v * 100) + '%'
|
||||
}));
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'face', name: 'Face',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Structure'));
|
||||
FEATURES.forEach((label, i) => {
|
||||
host.appendChild(control(label, {
|
||||
min: -1, max: 1, step: 0.05,
|
||||
get: () => look.features[i],
|
||||
set: (v) => { look.features[i] = v; },
|
||||
fmt: (v) => (v > 0 ? '+' : '') + v.toFixed(2)
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hair', name: 'Hair',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Hair'));
|
||||
host.appendChild(control('Style', {
|
||||
min: 0, max: Math.max(0, limits.hair - 1),
|
||||
get: () => look.hair.style,
|
||||
set: (v) => { look.hair.style = v; },
|
||||
fmt: (v) => (v === 0 ? 'Bald' : String(v))
|
||||
}));
|
||||
host.appendChild(control('Colour', {
|
||||
min: 0, max: 63,
|
||||
get: () => look.hair.color,
|
||||
set: (v) => { look.hair.color = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
host.appendChild(control('Highlights', {
|
||||
min: 0, max: 63,
|
||||
get: () => look.hair.highlight,
|
||||
set: (v) => { look.hair.highlight = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
|
||||
host.appendChild(groupLabel('Eyebrows'));
|
||||
overlayControl(host, 'Eyebrows', 2, 'hair');
|
||||
|
||||
host.appendChild(groupLabel('Facial hair'));
|
||||
overlayControl(host, 'Beard', 1, 'hair');
|
||||
|
||||
host.appendChild(groupLabel('Body hair'));
|
||||
overlayControl(host, 'Chest hair', 10, 'hair');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'skin', name: 'Skin',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Complexion'));
|
||||
overlayControl(host, 'Complexion', 6, null);
|
||||
overlayControl(host, 'Blemishes', 0, null);
|
||||
overlayControl(host, 'Ageing', 3, null);
|
||||
overlayControl(host, 'Sun damage', 7, null);
|
||||
overlayControl(host, 'Freckles', 9, null);
|
||||
overlayControl(host, 'Body blemishes', 11, null);
|
||||
|
||||
host.appendChild(groupLabel('Eyes'));
|
||||
host.appendChild(control('Eye colour', {
|
||||
min: 0, max: 31,
|
||||
get: () => look.eye,
|
||||
set: (v) => { look.eye = v; },
|
||||
fmt: (v) => '#' + v
|
||||
}));
|
||||
|
||||
host.appendChild(groupLabel('Makeup'));
|
||||
overlayControl(host, 'Makeup', 4, 'makeup');
|
||||
overlayControl(host, 'Blush', 5, 'makeup');
|
||||
overlayControl(host, 'Lipstick', 8, 'makeup');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'style', name: 'Clothes',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Outfit'));
|
||||
const rows = [
|
||||
['Top', 'torso', 'torso'],
|
||||
['Undershirt', 'undershirt', 'undershirt'],
|
||||
['Legs', 'legs', 'legs'],
|
||||
['Shoes', 'shoes', 'shoes']
|
||||
];
|
||||
rows.forEach(([label, key, limitKey]) => {
|
||||
host.appendChild(control(label, {
|
||||
min: 0, max: Math.max(0, (limits[limitKey] || 1) - 1),
|
||||
get: () => look.outfit[key],
|
||||
set: (v) => { look.outfit[key] = v; },
|
||||
fmt: (v) => String(v + 1)
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'identity', name: 'Identity',
|
||||
build(host) {
|
||||
host.appendChild(groupLabel('Legal name'));
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'field-row';
|
||||
|
||||
[['first', 'First name', 'John'], ['last', 'Last name', 'Doe']].forEach(([key, label, hint]) => {
|
||||
const cell = document.createElement('div');
|
||||
const lab = document.createElement('label');
|
||||
lab.className = 'field-label';
|
||||
lab.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.className = 'field';
|
||||
input.id = 'name-' + key;
|
||||
input.maxLength = 24;
|
||||
input.placeholder = hint;
|
||||
input.value = ctx[key] || '';
|
||||
input.disabled = ctx.mode === 'finish';
|
||||
input.addEventListener('input', () => { ctx[key] = input.value.trim(); });
|
||||
cell.append(lab, input);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
host.appendChild(row);
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'foot-note';
|
||||
note.style.marginTop = '12px';
|
||||
note.textContent = ctx.mode === 'finish'
|
||||
? 'This name was registered with your passport and cannot be changed.'
|
||||
: 'English letters only. This is the name Los Santos will know you by.';
|
||||
host.appendChild(note);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function renderTabs() {
|
||||
const host = $('tabs');
|
||||
host.innerHTML = '';
|
||||
TABS.forEach((t) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'tab' + (t.id === tab ? ' is-on' : '');
|
||||
b.textContent = t.name;
|
||||
b.addEventListener('click', () => { tab = t.id; renderTabs(); renderTab(); });
|
||||
host.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const host = $('controls');
|
||||
host.innerHTML = '';
|
||||
host.scrollTop = 0;
|
||||
const def = TABS.find((t) => t.id === tab) || TABS[0];
|
||||
def.build(host);
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editor
|
||||
function openEditor(options) {
|
||||
ctx = {
|
||||
slot: options.slot,
|
||||
id: options.id || null,
|
||||
mode: options.mode,
|
||||
first: options.first || '',
|
||||
last: options.last || ''
|
||||
};
|
||||
look = blankLook();
|
||||
tab = 'heritage';
|
||||
notice('');
|
||||
|
||||
$('editorSlot').textContent = 'Slot ' + ctx.slot;
|
||||
$('btnSave').textContent = 'Save & enter city';
|
||||
|
||||
show('editor');
|
||||
renderTab();
|
||||
post('startEditor', { slot: ctx.slot, id: ctx.id, appearance: look });
|
||||
}
|
||||
|
||||
function notice(text) {
|
||||
const el = $('notice');
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('is-shown', !!text);
|
||||
}
|
||||
|
||||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||||
|
||||
function save() {
|
||||
if (ctx.mode === 'create') {
|
||||
if (!RE_NAME.test(ctx.first || '') || !RE_NAME.test(ctx.last || '')) {
|
||||
tab = 'identity';
|
||||
renderTab();
|
||||
return notice('Enter a first and last name — 2 to 24 English letters each.');
|
||||
}
|
||||
}
|
||||
|
||||
notice('');
|
||||
$('btnSave').disabled = true;
|
||||
|
||||
if (ctx.mode === 'finish') post('finishChar', { id: ctx.id, appearance: look });
|
||||
else post('createChar', { slot: ctx.slot, firstName: ctx.first, lastName: ctx.last, appearance: look });
|
||||
}
|
||||
|
||||
// Randomise heritage and the obvious surface details, leaving the 20 face
|
||||
// sliders alone — random values there produce a melted face, not a person.
|
||||
function randomise() {
|
||||
const r = (n) => Math.floor(Math.random() * n);
|
||||
look.blend.shapeFirst = look.blend.skinFirst = r(FATHERS);
|
||||
look.blend.shapeSecond = look.blend.skinSecond = 21 + r(MOTHERS);
|
||||
look.blend.shapeMix = Math.round(Math.random() * 50) / 50;
|
||||
look.blend.skinMix = Math.round(Math.random() * 50) / 50;
|
||||
look.hair.style = r(Math.max(1, limits.hair));
|
||||
look.hair.color = look.hair.highlight = r(24);
|
||||
look.eye = r(12);
|
||||
look.overlays[2].v = r(limits.overlays[2] || 1); // eyebrows
|
||||
look.overlays[2].c1 = look.overlays[2].c2 = look.hair.color;
|
||||
look.outfit.torso = r(Math.max(1, limits.torso));
|
||||
look.outfit.legs = r(Math.max(1, limits.legs));
|
||||
look.outfit.shoes = r(Math.max(1, limits.shoes));
|
||||
post('preview', look);
|
||||
renderTab();
|
||||
}
|
||||
|
||||
$('btnSave').addEventListener('click', save);
|
||||
$('btnRandom').addEventListener('click', randomise);
|
||||
$('btnCancel').addEventListener('click', () => {
|
||||
show('select');
|
||||
post('backToSelect', {});
|
||||
});
|
||||
$('rotLeft').addEventListener('click', () => post('rotate', { by: -20 }));
|
||||
$('rotRight').addEventListener('click', () => post('rotate', { by: 20 }));
|
||||
|
||||
document.querySelectorAll('.chip[data-zoom]').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
document.querySelectorAll('.chip[data-zoom]').forEach((c) => c.classList.remove('is-on'));
|
||||
chip.classList.add('is-on');
|
||||
post('zoom', { part: chip.dataset.zoom });
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ from client
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
|
||||
if (msg.action === 'open') {
|
||||
root.classList.add('is-open');
|
||||
show(msg.view || 'select');
|
||||
} else if (msg.action === 'close') {
|
||||
root.classList.remove('is-open');
|
||||
} else if (msg.action === 'slots') {
|
||||
slots = msg.payload || [];
|
||||
renderSlots();
|
||||
} else if (msg.action === 'limits') {
|
||||
limits = Object.assign(limits, msg.payload || {});
|
||||
if (screens.editor.classList.contains('is-active')) renderTab();
|
||||
} else if (msg.action === 'saveResult') {
|
||||
const p = msg.payload || {};
|
||||
$('btnSave').disabled = false;
|
||||
if (p.ok) {
|
||||
// Straight into the world with the character just written to the row.
|
||||
post('playChar', { id: p.id || ctx.id });
|
||||
} else {
|
||||
notice(p.error || 'Something went wrong. Try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FiveRP — Characters</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="root" id="root">
|
||||
|
||||
<!-- ------------------------------------------------ character slots -->
|
||||
<section class="screen screen--select" id="screenSelect">
|
||||
<header class="head fade-up">
|
||||
<p class="eyebrow">Los Santos · Residency Office</p>
|
||||
<h1 class="title">Choose your character</h1>
|
||||
<p class="sub">Three residency slots are available on this account.</p>
|
||||
</header>
|
||||
|
||||
<div class="slots" id="slots"></div>
|
||||
|
||||
<p class="foot-note fade-up delay-3">Deleting a resident is permanent — the slot is freed for a new file.</p>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------- appearance editor -->
|
||||
<section class="screen screen--editor" id="screenEditor">
|
||||
<aside class="panel">
|
||||
<header class="panel-head">
|
||||
<p class="eyebrow" id="editorSlot">Slot 1</p>
|
||||
<h2 class="panel-title">Create your resident</h2>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="tabs"></nav>
|
||||
|
||||
<div class="controls" id="controls"></div>
|
||||
|
||||
<p class="notice" id="notice"></p>
|
||||
|
||||
<footer class="panel-foot">
|
||||
<button class="btn-ghost" id="btnCancel" type="button">Back</button>
|
||||
<button class="btn-ghost btn-dice" id="btnRandom" type="button">Randomise</button>
|
||||
<button class="btn-primary" id="btnSave" type="button">Save & enter city</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<div class="stagebar">
|
||||
<button class="round" id="rotLeft" type="button" aria-label="Rotate left">‹</button>
|
||||
<div class="chips">
|
||||
<button class="chip is-on" data-zoom="head" type="button">Head</button>
|
||||
<button class="chip" data-zoom="body" type="button">Body</button>
|
||||
<button class="chip" data-zoom="legs" type="button">Legs</button>
|
||||
</div>
|
||||
<button class="round" id="rotRight" type="button" aria-label="Rotate right">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,297 @@
|
||||
/* FiveRP — character slots and appearance creator.
|
||||
Follows CLAUDE.md. Two hard rules from that document apply everywhere here:
|
||||
no backdrop-filter and no box-shadow. CEF composites both as opaque black
|
||||
plates the size of the element, which is the black-square artefact. Depth
|
||||
comes from opacity, hairlines and inner borders instead. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-latin.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+2122, U+2191, U+2193, U+2212;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+1E00-1EFF, U+2020, U+20A0-20AB;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
font-weight: 300 800; font-display: block;
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--surface-soft: rgba(255, 255, 255, 0.88);
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--danger: #ff3b30;
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent; /* the game is the backdrop */
|
||||
overflow: hidden;
|
||||
font-family: 'Unbounded', -apple-system, 'Segoe UI', sans-serif;
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root { position: fixed; inset: 0; display: none; }
|
||||
.root.is-open { display: block; }
|
||||
|
||||
.screen { position: absolute; inset: 0; display: none; }
|
||||
.screen.is-active { display: flex; }
|
||||
|
||||
/* --------------------------------------------------------- shared bits */
|
||||
.eyebrow {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.title {
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.035em;
|
||||
line-height: 1.1; margin-top: 10px;
|
||||
}
|
||||
.sub { margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--fg-secondary); }
|
||||
|
||||
.btn-primary {
|
||||
border: none; border-radius: 999px; padding: 11px 22px;
|
||||
font-family: inherit; font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
|
||||
color: #fff; cursor: pointer;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
transition: filter .2s var(--ease), transform .2s var(--ease);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-primary:active { transform: scale(0.97); }
|
||||
.btn-primary:disabled { opacity: .45; pointer-events: none; }
|
||||
|
||||
.btn-ghost {
|
||||
border: none; border-radius: 999px; padding: 11px 18px;
|
||||
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
color: var(--accent); background-color: var(--accent-soft); cursor: pointer;
|
||||
transition: background-color .2s var(--ease);
|
||||
}
|
||||
.btn-ghost:hover { background-color: rgba(0, 113, 227, 0.14); }
|
||||
.btn-danger { color: var(--danger); background-color: rgba(255, 59, 48, 0.08); }
|
||||
.btn-danger:hover { background-color: rgba(255, 59, 48, 0.15); }
|
||||
|
||||
/* ---------------------------------------------------------- slot screen */
|
||||
.screen--select { flex-direction: column; align-items: center; justify-content: center; gap: 34px; }
|
||||
.head { text-align: center; }
|
||||
|
||||
.slots { display: flex; gap: 16px; }
|
||||
|
||||
.slot {
|
||||
position: relative;
|
||||
width: 268px; min-height: 330px;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 26px 22px 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
transition: transform .35s var(--ease), border-color .35s var(--ease);
|
||||
}
|
||||
.slot:hover { transform: translateY(-4px); border-color: rgba(0, 0, 0, 0.14); }
|
||||
|
||||
.slot-no {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.slot-name {
|
||||
margin-top: 14px;
|
||||
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2;
|
||||
}
|
||||
.slot-meta { margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--fg-secondary); }
|
||||
|
||||
.slot-badge {
|
||||
display: inline-block; margin-top: 12px; align-self: flex-start;
|
||||
padding: 4px 11px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
}
|
||||
.slot-badge.is-draft { color: #b25000; background: rgba(255, 149, 0, 0.12); }
|
||||
|
||||
.slot-actions { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.slot-actions button { width: 100%; }
|
||||
|
||||
/* Empty slot: one big invitation, centred. */
|
||||
.slot--empty {
|
||||
align-items: center; justify-content: center; text-align: center;
|
||||
gap: 14px; cursor: pointer;
|
||||
background: var(--surface-soft);
|
||||
border-style: dashed; border-color: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.slot--empty:hover { border-color: var(--accent); background: rgba(255, 255, 255, 0.97); }
|
||||
.plus {
|
||||
width: 54px; height: 54px; border-radius: 50%;
|
||||
display: grid; place-items: center;
|
||||
font-size: 26px; font-weight: 300; line-height: 1;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
}
|
||||
.slot--empty .label { font-size: 14px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.slot--empty .hint { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
|
||||
|
||||
.foot-note { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
|
||||
|
||||
/* -------------------------------------------------------- editor screen */
|
||||
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
|
||||
.screen--editor .panel,
|
||||
.screen--editor .stagebar { pointer-events: auto; }
|
||||
|
||||
.panel {
|
||||
position: absolute; top: 24px; right: 24px; bottom: 24px;
|
||||
width: 372px;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 24px 0 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
.panel-head { padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
|
||||
.panel-title { margin-top: 8px; font-size: 20px; font-weight: 700; letter-spacing: -0.03em; }
|
||||
|
||||
.tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
border: none; border-radius: 999px; padding: 7px 13px;
|
||||
font-family: inherit; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
|
||||
transition: color .2s var(--ease), background-color .2s var(--ease);
|
||||
}
|
||||
.tab:hover { color: var(--fg); }
|
||||
.tab.is-on { color: #fff; background: var(--accent); }
|
||||
|
||||
.controls { flex: 1; overflow-y: auto; padding: 18px 24px 8px; }
|
||||
.controls::-webkit-scrollbar { width: 5px; }
|
||||
.controls::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.14); border-radius: 3px; }
|
||||
|
||||
.group-label {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.group-label:first-child { margin-top: 0; }
|
||||
|
||||
.ctl { margin-bottom: 13px; }
|
||||
.ctl-top {
|
||||
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
|
||||
}
|
||||
.ctl-name { font-size: 11px; font-weight: 500; letter-spacing: -0.01em; color: var(--fg); }
|
||||
.ctl-val { font-size: 10px; font-weight: 600; color: var(--fg-tertiary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.09); cursor: pointer; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
|
||||
background: #fff; border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
transition: transform .15s var(--ease), border-color .15s var(--ease);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.12); border-color: var(--accent); }
|
||||
|
||||
/* Two-state toggle (male / female) */
|
||||
.toggle { display: flex; gap: 6px; margin-bottom: 16px; }
|
||||
.toggle button {
|
||||
flex: 1; border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
|
||||
padding: 10px; font-family: inherit; font-size: 12px; font-weight: 600;
|
||||
letter-spacing: -0.01em; color: var(--fg-secondary);
|
||||
background: rgba(255, 255, 255, 0.7); cursor: pointer;
|
||||
transition: all .2s var(--ease);
|
||||
}
|
||||
.toggle button.is-on { color: #fff; background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field-label {
|
||||
display: block; margin-bottom: 6px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--fg-tertiary);
|
||||
}
|
||||
.field {
|
||||
width: 100%; border: 1px solid transparent; border-radius: var(--radius-sm);
|
||||
padding: 11px 12px; font-family: inherit; font-size: 13px; font-weight: 500;
|
||||
color: var(--fg); background: var(--field-bg); outline: none;
|
||||
transition: background-color .2s, border-color .2s;
|
||||
}
|
||||
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
|
||||
.field:focus { background: #fff; border-color: var(--accent); }
|
||||
|
||||
.notice {
|
||||
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger);
|
||||
opacity: 0; transition: opacity .2s var(--ease);
|
||||
}
|
||||
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
|
||||
|
||||
.panel-foot {
|
||||
display: flex; gap: 8px; padding: 16px 24px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.panel-foot .btn-primary { flex: 1; }
|
||||
.btn-dice { padding: 11px 14px; }
|
||||
|
||||
/* Camera controls, centred under the ped. */
|
||||
.stagebar {
|
||||
position: absolute; left: calc(50% - 210px); bottom: 40px;
|
||||
transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px; border-radius: 999px;
|
||||
border: 1px solid var(--line-strong); background: var(--surface);
|
||||
}
|
||||
.round {
|
||||
width: 34px; height: 34px; border: none; border-radius: 50%;
|
||||
font-family: inherit; font-size: 17px; font-weight: 500; line-height: 1;
|
||||
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
.round:hover { color: #fff; background: var(--accent); }
|
||||
.chips { display: flex; gap: 4px; }
|
||||
.chip {
|
||||
border: none; border-radius: 999px; padding: 8px 14px;
|
||||
font-family: inherit; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--fg-secondary); background: transparent; cursor: pointer;
|
||||
transition: color .2s, background-color .2s;
|
||||
}
|
||||
.chip:hover { color: var(--fg); }
|
||||
.chip.is-on { color: #fff; background: var(--accent); }
|
||||
|
||||
/* ---------------------------------------------------------------- motion */
|
||||
.fade-up { animation: fadeUp .6s var(--ease) both; }
|
||||
.delay-1 { animation-delay: .06s; }
|
||||
.delay-2 { animation-delay: .12s; }
|
||||
.delay-3 { animation-delay: .18s; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
name 'fiverp-loadscreen'
|
||||
author 'FiveRP'
|
||||
description 'Loading screen for FiveRP'
|
||||
version '1.0.0'
|
||||
|
||||
loadscreen 'html/index.html'
|
||||
|
||||
files {
|
||||
'html/index.html',
|
||||
'html/style.css',
|
||||
'html/app.js',
|
||||
'html/fonts/unbounded-latin.woff2',
|
||||
'html/fonts/unbounded-latin-ext.woff2',
|
||||
'html/fonts/unbounded-cyrillic.woff2',
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* FiveRP — loading screen behaviour.
|
||||
|
||||
The game drives this page by posting messages at `window`. The only field
|
||||
that is a genuine 0..1 measure of progress is `loadProgress.loadFraction`;
|
||||
everything else is narration we turn into the phase and detail lines. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const elPct = document.getElementById('pct');
|
||||
const elFill = document.getElementById('fill');
|
||||
const elPhase = document.getElementById('phase');
|
||||
const elDetail = document.getElementById('detail');
|
||||
const elTip = document.getElementById('tip');
|
||||
|
||||
/* ------------------------------------------------------------- progress */
|
||||
|
||||
// Shown and target values are kept apart so the number eases toward the
|
||||
// truth instead of jumping. The game reports progress in coarse steps.
|
||||
let shown = 0;
|
||||
let target = 0;
|
||||
|
||||
function setTarget(fraction) {
|
||||
const pct = Math.max(0, Math.min(100, fraction * 100));
|
||||
// Never walk backwards: a later phase reporting a lower fraction would
|
||||
// otherwise make the bar visibly retreat.
|
||||
if (pct > target) target = pct;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const gap = target - shown;
|
||||
if (gap > 0.05) {
|
||||
shown += Math.max(gap * 0.08, 0.05);
|
||||
if (shown > target) shown = target;
|
||||
elFill.style.width = shown.toFixed(2) + '%';
|
||||
elPct.textContent = Math.floor(shown);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
/* ---------------------------------------------------------------- phases */
|
||||
|
||||
function phase(text) {
|
||||
if (elPhase.textContent !== text) elPhase.textContent = text;
|
||||
}
|
||||
|
||||
function detail(text) {
|
||||
const clean = String(text || '').trim();
|
||||
if (!clean) return;
|
||||
elDetail.textContent = clean.length > 88 ? clean.slice(0, 87) + '…' : clean;
|
||||
}
|
||||
|
||||
// Init function names arrive as engine internals ("CPathServer::InitSession").
|
||||
// Strip the noise so the line reads as a status, not a stack trace.
|
||||
function humanise(name) {
|
||||
return String(name || '')
|
||||
.replace(/^dlc_?/i, 'DLC ')
|
||||
.replace(/::.*$/, '')
|
||||
.replace(/^C(?=[A-Z])/, '')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
const data = event.data || {};
|
||||
|
||||
switch (data.eventName) {
|
||||
case 'loadProgress':
|
||||
setTarget(Number(data.loadFraction) || 0);
|
||||
break;
|
||||
|
||||
case 'startInitFunctionOrder':
|
||||
phase('Loading game');
|
||||
break;
|
||||
|
||||
case 'startInitFunction':
|
||||
phase('Loading game');
|
||||
break;
|
||||
|
||||
case 'initFunctionInvoking':
|
||||
phase('Loading game');
|
||||
detail(humanise(data.name));
|
||||
// The engine gives idx/count per order; use it when loadProgress is
|
||||
// quiet so the bar keeps creeping during long orders.
|
||||
if (data.count) setTarget(0.1 + 0.5 * (Number(data.idx) / Number(data.count)));
|
||||
break;
|
||||
|
||||
case 'startDataFileEntries':
|
||||
phase('Streaming content');
|
||||
detail('Mounting ' + (data.count || 0) + ' data files');
|
||||
break;
|
||||
|
||||
case 'performMappedDataFileEntry':
|
||||
phase('Streaming content');
|
||||
detail(String(data.name || '').split('/').pop());
|
||||
break;
|
||||
|
||||
case 'onLogLine':
|
||||
detail(data.message);
|
||||
break;
|
||||
|
||||
case 'onDataFileEntry':
|
||||
phase('Streaming content');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ tips */
|
||||
|
||||
const TIPS = [
|
||||
'Your account and your character are separate — one login can hold your identity for good.',
|
||||
'Names are issued once. Pick a first and last name you want to be known by.',
|
||||
'Press F8 at any time to open the console if something looks wrong.',
|
||||
'Your passport is proof of residency — officers may ask to see it.',
|
||||
'Use a real email address. It is the only way to recover an account.',
|
||||
'Speak in character. The city is more fun when everyone stays in the story.',
|
||||
'Lost? The Legion Square spawn is the heart of downtown Los Santos.'
|
||||
];
|
||||
|
||||
let tipIndex = 0;
|
||||
setInterval(function () {
|
||||
tipIndex = (tipIndex + 1) % TIPS.length;
|
||||
elTip.classList.add('is-swapping');
|
||||
setTimeout(function () {
|
||||
elTip.textContent = TIPS[tipIndex];
|
||||
elTip.classList.remove('is-swapping');
|
||||
}, 450);
|
||||
}, 7000);
|
||||
|
||||
/* Nothing has reported in yet — creep to a believable floor so the screen
|
||||
never sits at a dead 0% while the engine warms up. */
|
||||
setTimeout(function () { setTarget(0.04); }, 400);
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FiveRP</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Slow drifting colour behind frosted white. Nothing here is interactive. -->
|
||||
<div class="sky" aria-hidden="true">
|
||||
<span class="blob blob-a"></span>
|
||||
<span class="blob blob-b"></span>
|
||||
<span class="blob blob-c"></span>
|
||||
<span class="blob blob-d"></span>
|
||||
</div>
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<main class="stage">
|
||||
<div class="mark fade-up">
|
||||
<span class="mark-glyph">FR</span>
|
||||
</div>
|
||||
|
||||
<p class="eyebrow fade-up delay-1">San Andreas · Los Santos</p>
|
||||
<h1 class="wordmark fade-up delay-1">FiveRP</h1>
|
||||
<p class="tagline fade-up delay-2">Preparing the city for your arrival</p>
|
||||
|
||||
<section class="meter fade-up delay-3" aria-label="Loading progress">
|
||||
<div class="meter-top">
|
||||
<span class="phase" id="phase">Starting up</span>
|
||||
<span class="pct"><span id="pct">0</span><i>%</i></span>
|
||||
</div>
|
||||
<div class="track">
|
||||
<div class="fill" id="fill"></div>
|
||||
</div>
|
||||
<p class="detail" id="detail">Waiting for the game to hand over…</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="dock">
|
||||
<div class="tip fade-up delay-4">
|
||||
<span class="tip-label">Tip</span>
|
||||
<span class="tip-body" id="tip">Your account and your character are separate — one login can hold your identity for good.</span>
|
||||
</div>
|
||||
<div class="badge fade-up delay-4">
|
||||
<span class="dot"></span>
|
||||
<span>192.227.168.73<i>:30120</i></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,346 @@
|
||||
/* FiveRP — loading screen.
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md
|
||||
This page owns the whole screen (no game behind it), so unlike the in-game
|
||||
panels it is allowed an opaque background and real shadows. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
|
||||
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--bg-deep: #e8e8ed;
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--surface-solid: #ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- sky */
|
||||
/* Four wide, heavily blurred washes drifting on long loops. Kept pale so the
|
||||
page still reads as white — colour is a hint, not a gradient poster. */
|
||||
.sky { position: fixed; inset: -20%; z-index: 0; filter: blur(90px); }
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.blob-a {
|
||||
width: 46vw; height: 46vw; top: 4%; left: 8%;
|
||||
background: radial-gradient(circle, rgba(0,113,227,0.30), rgba(0,113,227,0) 70%);
|
||||
animation: driftA 34s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-b {
|
||||
width: 40vw; height: 40vw; top: 32%; left: 52%;
|
||||
background: radial-gradient(circle, rgba(90,200,250,0.32), rgba(90,200,250,0) 70%);
|
||||
animation: driftB 41s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-c {
|
||||
width: 34vw; height: 34vw; top: 56%; left: 18%;
|
||||
background: radial-gradient(circle, rgba(201,182,255,0.28), rgba(201,182,255,0) 70%);
|
||||
animation: driftC 47s var(--ease) infinite alternate;
|
||||
}
|
||||
.blob-d {
|
||||
width: 30vw; height: 30vw; top: 6%; left: 68%;
|
||||
background: radial-gradient(circle, rgba(255,214,165,0.30), rgba(255,214,165,0) 70%);
|
||||
animation: driftD 38s var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes driftA { to { transform: translate3d(9vw, 7vh, 0) scale(1.14); } }
|
||||
@keyframes driftB { to { transform: translate3d(-11vw, -6vh, 0) scale(1.08); } }
|
||||
@keyframes driftC { to { transform: translate3d(7vw, -9vh, 0) scale(1.18); } }
|
||||
@keyframes driftD { to { transform: translate3d(-8vw, 8vh, 0) scale(1.10); } }
|
||||
|
||||
/* A milky sheet over the colour: this is what turns four blobs into frosted
|
||||
white rather than a lava lamp. */
|
||||
.veil {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
radial-gradient(110% 85% at 50% 42%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0.72) 46%, rgba(255,255,255,0.88) 100%),
|
||||
linear-gradient(180deg, rgba(245,245,247,0.20), rgba(232,232,237,0.42));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- stage */
|
||||
.stage {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32px 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* App-icon style badge: the one saturated object on an otherwise white page. */
|
||||
.mark {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 24px;
|
||||
background-image: linear-gradient(160deg, #35a0ff 0%, var(--accent) 52%, #0059b8 100%);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.mark-glyph {
|
||||
font-size: 27px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.22);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: clamp(52px, 6.4vw, 84px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 46px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- meter */
|
||||
/* Left-aligned: the phase and detail lines change constantly, and centred text
|
||||
that reflows on every update reads as jitter. */
|
||||
.meter { width: min(560px, 84vw); text-align: left; }
|
||||
|
||||
.meter-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.045em;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pct i {
|
||||
font-style: normal;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-tertiary);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
/* Inset rather than flat grey: on a near-white page a 7%-black bar reads as
|
||||
a smudge, a hairline-bordered groove reads as a control. */
|
||||
background: rgba(0, 0, 0, 0.055);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
border-radius: 999px;
|
||||
background-image: linear-gradient(90deg, #5ac8fa, var(--accent));
|
||||
transition: width 520ms var(--ease);
|
||||
}
|
||||
/* Travelling highlight so a stalled bar still looks alive. */
|
||||
.fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: sweep 1.9s var(--ease) infinite;
|
||||
}
|
||||
@keyframes sweep { to { transform: translateX(100%); } }
|
||||
|
||||
.detail {
|
||||
margin-top: 14px;
|
||||
height: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
color: var(--fg-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity .3s ease;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- dock */
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 32px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 62vw;
|
||||
padding: 12px 18px 12px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
.tip-label {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tip-body {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: opacity .45s ease, transform .45s var(--ease);
|
||||
}
|
||||
.tip-body.is-swapping { opacity: 0; transform: translateY(-6px); }
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-secondary);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.badge i { font-style: normal; color: var(--fg-tertiary); }
|
||||
.badge .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success, #34c759);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: .45; } }
|
||||
|
||||
/* ------------------------------------------------------------------- motion */
|
||||
.fade-up { animation: fadeUp .7s cubic-bezier(.22,1,.36,1) both; }
|
||||
.delay-1 { animation-delay: .07s; }
|
||||
.delay-2 { animation-delay: .14s; }
|
||||
.delay-3 { animation-delay: .21s; }
|
||||
.delay-4 { animation-delay: .30s; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px) scale(.995); filter: blur(6px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,32 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,78 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,62 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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'));
|
||||
@@ -1,264 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,185 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,99 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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,
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,15 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,746 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
@@ -1,11 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
-- 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)
|
||||
@@ -1,27 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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); }
|
||||
@@ -1,567 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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.
@@ -1,72 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,182 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,19 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,188 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,508 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
@@ -1,160 +0,0 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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
|
||||
@@ -1,33 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -1,820 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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%; }
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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.
@@ -1,294 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,182 +0,0 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
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