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