/* 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 = `
+
Create character
Slot ${s.slot} is free
`; card.addEventListener('click', () => openEditor({ slot: s.slot, mode: 'create' })); } else { const played = when(s.lastPlayed); card.innerHTML = `

Slot ${s.slot}

${s.firstName}
${s.lastName}

${played ? 'Last seen ' + played : 'Never played'}

${s.ready ? 'Resident' : 'Needs appearance'}
`; 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.'); } } });