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:
Claude Opus 5
2026-08-12 23:42:20 +00:00
co-authored by Claude Opus 5
parent 71856b15e9
commit c857979a55
88 changed files with 3904 additions and 7463 deletions
View File
@@ -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'
+391
View File
@@ -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 332 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 224 English letters.', ['firstName']];
if (!RE_NAME.test(v.lastName)) return ['Last name must be 224 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'));
});
})();
@@ -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&nbsp;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">332 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&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
<span id="mrz2">&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;USA&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</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.');
}
}
});
@@ -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 &middot; 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 &amp; enter city</button>
</footer>
</aside>
<div class="stagebar">
<button class="round" id="rotLeft" type="button" aria-label="Rotate left">&#8249;</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">&#8250;</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);
})();
@@ -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 &middot; 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&hellip;</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;
}
}