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>
513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
/* 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.');
|
|
}
|
|
}
|
|
});
|