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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user