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