Files
redl-gamepanel/resources/[rp]/rp_ui/html/app.js
T
Claude Opus 5andClaude Opus 5 71856b15e9 The server itself: repo now mirrors the machine it runs on, plus install.sh
The repository carried two different servers side by side - the early
`justrp` prototype with its Python auth API, and a snapshot of the real
one under `server-code/`. Only the second one is a server anyone should
start from, so the prototype is gone and the real one moved to the root.

Taken from the live box, so the UI is the finished version (the tokens,
the county-records sheets and the variable fonts landed after the last
snapshot was pushed):

  resources/[rp]/   rp_db, rp_core, rp_session, rp_loading, rp_ui,
                    rp_selftest, rp_dbtest
  bin/              supervise.sh (keep-alive + console FIFO), rcon, init script
  etc/schema.sql    accounts, characters, transactions, inventory, vehicles
  assets/fonts/     the bundled subsets
  docs/SERVER.md    how it is put together, resource by resource

install.sh turns a clean Ubuntu/Debian box into this server in one
command: recommended FXServer build, MariaDB with the schema, resources,
server.cfg + a generated database password, boot entry (systemd or
init.d), then it waits for the Cfx registration. --name/--port/--db-name
let a second server live on the same machine. Tested end to end on a
spare install root: build 25770 fetched, schema applied, boot entry
written, FXServer started and stopped exactly where a wrong licence key
should stop it.

No secrets travel with it: the licence key and the database password live
in data/secrets.cfg on the machine, and .gitignore now names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:23:33 +00:00

966 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ---------------------------------------------------------------------------
Pre-spawn interface behaviour.
The client Lua owns the truth about stages; this file renders whatever it is
told and asks for changes. Validation here is for live feedback only - the
server re-checks everything and its answer is the one that counts.
--------------------------------------------------------------------------- */
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
async function post(name, data) {
try {
const res = await fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
});
return await res.json();
} catch (err) {
return { ok: false, error: 'The game stopped responding. Try again.' };
}
}
/* Fire-and-forget for high-frequency edits (slider drags). */
function poke(name, data) {
fetch(`https://rp_ui/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {}),
}).catch(() => {});
}
/* =========================================================================
screen manager
========================================================================= */
const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
const CHIP_ORDER = ['auth', 'characters', 'spawn'];
let current = null;
function showScreen(name) {
SCREENS.forEach((s) => {
const el = $(`screen-${s}`);
if (!el) return;
if (s === name) {
el.hidden = false;
/* restart the entrance animation */
el.classList.remove('is-on');
void el.offsetWidth;
el.classList.add('is-on');
} else {
el.hidden = true;
el.classList.remove('is-on');
}
});
current = name;
paintChips(CHIP_FOR[name]);
document.body.classList.remove('hidden');
}
function paintChips(activeKey) {
const idx = CHIP_ORDER.indexOf(activeKey);
$$('.stagechip').forEach((chip) => {
const i = CHIP_ORDER.indexOf(chip.dataset.stage);
chip.classList.toggle('on', i === idx);
chip.classList.toggle('done', i < idx);
});
}
/* The stamp is the only loud thing in the interface, so it only ever marks a
real change of state: the file opened, the application approved, the
placement issued. The paper on the desk takes the hit with it. */
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
document.body.classList.remove('struck');
void el.offsetWidth;
el.classList.add('show');
setTimeout(() => document.body.classList.add('struck'), 170);
setTimeout(() => document.body.classList.remove('struck'), 600);
}
function busy(btn, on) {
btn.classList.toggle('busy', on);
btn.disabled = on;
}
function showError(el, message) {
if (!message) {
el.hidden = true;
return;
}
el.textContent = message;
el.hidden = false;
/* replay the shake even if the message is unchanged */
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
}
/* =========================================================================
validation (mirrors shared/util.lua; the server still decides)
========================================================================= */
const check = {
username(v) {
v = (v || '').trim();
if (!v) return [null, 'Pick a username.'];
if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
return [true, 'That will do.'];
},
password(v) {
v = v || '';
if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
return [true, 'Strong enough.'];
},
name(v) {
v = (v || '').trim();
if (!v) return [null, 'Required.'];
if (v.length < 2) return [false, 'Too short.'];
if (v.length > 20) return [false, 'Too long.'];
if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
return [true, ''];
},
dob(v) {
v = (v || '').trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
if (!v) return [null, 'YYYY-MM-DD'];
if (!m) return [false, 'Use YYYY-MM-DD.'];
const y = +m[1], mo = +m[2], d = +m[3];
if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
return [true, ''];
},
};
/* Paints a field's state. `state` is true / false / null (neutral). */
function mark(fieldName, state, message) {
const field = document.querySelector(`.field[data-field="${fieldName}"]`);
if (!field) return;
field.classList.toggle('good', state === true);
field.classList.toggle('bad', state === false);
const hint = field.querySelector('.hint');
if (hint && message !== undefined && message !== null) hint.textContent = message;
}
/* =========================================================================
01 / auth
========================================================================= */
const Auth = {
mode: 'register',
init() {
$('switchMode').addEventListener('click', () => this.toggle());
$('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
$('revealPw').addEventListener('click', () => {
const input = $('password');
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
$('revealPw').textContent = show ? 'Hide' : 'Show';
$('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
$('username').addEventListener('input', () => {
const [ok, msg] = check.username($('username').value);
mark('username', ok, msg);
});
$('password').addEventListener('input', () => {
const [ok, msg] = check.password($('password').value);
mark('password', ok, msg);
if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
});
$('confirm').addEventListener('input', () => this.checkConfirm());
},
checkConfirm() {
const a = $('password').value;
const b = $('confirm').value;
if (!b) return mark('confirm', null, 'Both entries must match.');
mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
},
toggle() {
this.mode = this.mode === 'register' ? 'login' : 'register';
const reg = this.mode === 'register';
$('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
$('authSub').textContent = reg
? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
: 'Sign in and the city picks up where you left it.';
$('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
$('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
$('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';
$('confirmWrap').classList.toggle('open', reg);
$('confirmWrap').setAttribute('aria-hidden', String(!reg));
$('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');
showError($('authError'), null);
if (!reg) mark('confirm', null, 'Both entries must match.');
},
async submit() {
const username = $('username').value.trim();
const password = $('password').value;
const reg = this.mode === 'register';
const [uOk, uMsg] = check.username(username);
if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }
if (reg) {
const [pOk, pMsg] = check.password(password);
if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
if ($('confirm').value !== password) {
mark('confirm', false, 'These do not match yet.');
$('confirm').focus();
return;
}
} else if (!password) {
mark('password', false, 'Enter your password.');
$('password').focus();
return;
}
const btn = $('authSubmit');
busy(btn, true);
showError($('authError'), null);
const res = await post('auth:submit', { mode: this.mode, username, password });
busy(btn, false);
if (!res.ok) return showError($('authError'), res.error || 'That did not work.');
if (reg) stamp('Filed');
Characters.load(res.characters || [], res.maxChars || 3);
showScreen('characters');
},
};
/* =========================================================================
02 / characters
========================================================================= */
const Characters = {
list: [],
max: 3,
selected: null,
init() {
$('charEnter').addEventListener('click', () => this.enter());
$('charDelete').addEventListener('click', () => this.remove());
},
load(list, max) {
this.list = list || [];
this.max = max || 3;
this.selected = null;
this.render();
},
render() {
const ul = $('charList');
ul.innerHTML = '';
$('charCount').textContent = `${this.list.length} / ${this.max}`;
this.list.forEach((c, i) => {
const li = document.createElement('li');
li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<span class="charslot">0${i + 1}</span>
<span>
<span class="charname"></span>
<span class="charmeta"></span>
</span>
<span class="charmoney"></span>`;
li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
li.querySelector('.charmeta').textContent = this.meta(c);
li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;
const pick = () => this.select(c.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
});
if (this.list.length < this.max) {
const li = document.createElement('li');
li.className = 'charcard empty';
li.tabIndex = 0;
li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
const go = () => Creator.open();
li.addEventListener('click', go);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
ul.appendChild(li);
}
$('charEnter').disabled = this.selected === null;
$('charDelete').disabled = this.selected === null;
},
meta(c) {
const hours = Math.floor((c.playtime || 0) / 3600);
const played = hours > 0 ? `${hours}h played` : 'never played';
const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
return `${c.dob} · ${job} · ${played}`;
},
select(id) {
this.selected = id;
this.render();
showError($('charError'), null);
poke('char:preview', { id });
},
async enter() {
if (this.selected === null) return;
const btn = $('charEnter');
busy(btn, true);
const res = await post('char:select', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
Spawn.load(res.spawns || [], res.lastPosition, res.character);
showScreen('spawn');
},
async remove() {
if (this.selected === null) return;
const c = this.list.find((x) => x.id === this.selected);
if (!c) return;
const btn = $('charDelete');
/* two-step, in place: the button becomes the confirmation */
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = `Delete ${c.firstName}?`;
setTimeout(() => {
if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
}, 4000);
return;
}
btn.dataset.armed = '0';
btn.textContent = 'Delete';
busy(btn, true);
const res = await post('char:delete', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
this.load(res.characters || [], this.max);
},
};
/* =========================================================================
03 / creator
========================================================================= */
const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];
const Creator = {
appearance: null,
limits: null,
schema: null,
palettes: null,
section: 'heritage',
init() {
$$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
$('creatorCancel').addEventListener('click', () => this.cancel());
$('creatorPrev').addEventListener('click', () => this.step(-1));
$('creatorNext').addEventListener('click', () => this.step(1));
this.initDrag();
},
async open() {
const res = await post('creator:start', { gender: 'm' });
if (!res.ok) return showError($('charError'), 'Could not start the editor.');
this.absorb(res);
this.section = 'heritage';
this.paintTabs();
this.renderAll();
showScreen('creator');
poke('creator:frame', { section: 'heritage' });
},
absorb(res) {
this.appearance = res.appearance;
this.limits = res.limits;
this.schema = res.schema || this.schema;
this.palettes = res.palettes || this.palettes;
},
go(section) {
this.section = section;
this.paintTabs();
poke('creator:frame', { section });
},
paintTabs() {
$$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
$$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
const i = SECTIONS.indexOf(this.section);
$('creatorPrev').disabled = i === 0;
$('creatorNext').querySelector('.btn-label').textContent =
i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
},
step(dir) {
const i = SECTIONS.indexOf(this.section);
if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
this.go(next);
},
/* --- control builders ------------------------------------------------- */
slider(label, value, min, max, step, format, onInput) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<input class="slider" type="range">`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const input = wrap.querySelector('input');
input.min = min; input.max = max; input.step = step; input.value = value;
val.textContent = format(value);
input.addEventListener('input', () => {
const v = Number(input.value);
val.textContent = format(v);
onInput(v);
});
return wrap;
},
stepper(label, value, min, max, onChange) {
const wrap = document.createElement('div');
wrap.className = 'ctrl';
wrap.innerHTML = `
<div class="ctrl-head">
<span class="ctrl-label"></span>
<span class="ctrl-val"></span>
</div>
<div class="stepper">
<button type="button" aria-label="Previous">&minus;</button>
<span class="stepval"></span>
<button type="button" aria-label="Next">+</button>
</div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const range = wrap.querySelector('.ctrl-val');
const out = wrap.querySelector('.stepval');
let v = value;
let lo = min;
let hi = max;
const paint = () => {
out.textContent = v < 0 ? 'None' : String(v);
range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}${hi}`;
};
const set = (next) => {
if (next < lo) next = hi; // wraps, so browsing a long list is quick
if (next > hi) next = lo;
v = next;
paint();
onChange(v);
};
/* Changing a garment changes how many variants it has; the variant control
is re-bounded in place rather than rebuilt. */
const setBounds = (newMax, newValue) => {
hi = typeof newMax === 'number' ? newMax : hi;
if (typeof newValue === 'number') v = newValue;
if (v > hi) v = lo;
paint();
};
wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
paint();
return { el: wrap, set, setBounds, get value() { return v; } };
},
swatches(label, palette, value, onPick) {
const wrap = document.createElement('div');
/* A colour chart is 16 columns wide, so it always takes the full form. */
wrap.className = 'ctrl span2';
wrap.innerHTML = `
<div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
<div class="swatches"></div>`;
wrap.querySelector('.ctrl-label').textContent = label;
const val = wrap.querySelector('.ctrl-val');
const row = wrap.querySelector('.swatches');
val.textContent = String(value);
(palette || []).forEach((c, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'swatch' + (i === value ? ' on' : '');
b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
b.title = `Colour ${i}`;
b.addEventListener('click', () => {
row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
b.classList.add('on');
val.textContent = String(i);
onPick(i);
});
row.appendChild(b);
});
return wrap;
},
/* --- panes ------------------------------------------------------------ */
renderAll() {
this.renderHeritage();
this.renderFace();
this.renderHair();
this.renderBody();
this.renderClothing();
this.renderIdentity();
},
pane(section) {
const el = document.querySelector(`.pane[data-section="${section}"]`);
el.innerHTML = '';
return el;
},
renderHeritage() {
const p = this.pane('heritage');
const a = this.appearance;
const seg = document.createElement('div');
seg.className = 'seg';
seg.innerHTML = `
<button type="button" data-g="m">Male</button>
<button type="button" data-g="f">Female</button>`;
seg.querySelectorAll('button').forEach((b) => {
b.classList.toggle('on', b.dataset.g === a.model);
b.addEventListener('click', async () => {
if (b.dataset.g === this.appearance.model) return;
const res = await post('creator:gender', { gender: b.dataset.g });
if (!res.ok) return;
this.absorb(res);
this.renderAll();
this.paintTabs();
});
});
p.appendChild(seg);
p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.father = v;
poke('creator:parent', { key: 'father', value: v });
}));
p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
a.parents.mother = v;
poke('creator:parent', { key: 'mother', value: v });
}));
p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
(v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
(v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
},
renderFace() {
const p = this.pane('face');
const a = this.appearance;
(this.schema.features || []).forEach((f) => {
const key = String(f.id);
const v = a.features[key] || 0;
p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
});
p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
a.eyeColour = v;
poke('creator:eyes', { value: v });
}));
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
},
renderHair() {
const p = this.pane('hair');
const a = this.appearance;
const maxHair = (this.limits && this.limits.hair) || 0;
const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
a.hair.style = v;
poke('creator:hair', { style: v });
});
p.appendChild(st.el);
p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
a.hair.colour = i;
poke('creator:hair', { colour: i });
}));
p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
a.hair.highlight = i;
poke('creator:hair', { highlight: i });
}));
/* Only the hair-tinted overlays belong here; skin and make-up sit with
the face, which keeps either pane from becoming a wall of controls. */
this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
},
overlayControls(p, list) {
const a = this.appearance;
list.forEach((o) => {
const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
ov.index = v;
poke('creator:overlay', { key: o.key, index: v });
});
p.appendChild(s.el);
p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
(v) => `${Math.round(v * 100)}%`,
(v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));
if (o.tint) {
const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
ov.colour = i;
poke('creator:overlay', { key: o.key, colour: i });
}));
}
});
},
renderBody() {
const p = this.pane('body');
const a = this.appearance;
const note = document.createElement('p');
note.className = 'sub';
note.textContent = 'Build is carried by the heritage blend and these two proportions.';
p.appendChild(note);
['13', '19'].forEach((id) => {
const f = (this.schema.features || []).find((x) => String(x.id) === id);
if (!f) return;
p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
(x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
(x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
});
},
renderClothing() {
const p = this.pane('clothing');
const a = this.appearance;
(this.schema.components || []).forEach((c) => {
const cc = a.components[c.key] || { drawable: 0, texture: 0 };
const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };
const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
cc.texture = v;
poke('creator:component', { key: c.key, texture: v });
});
const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
cc.drawable = v;
const res = await post('creator:component', { key: c.key, drawable: v });
if (res && res.ok) {
cc.texture = res.texture;
tex.setBounds(res.maxTexture, res.texture);
}
});
p.appendChild(draw.el);
p.appendChild(tex.el);
});
(this.schema.props || []).forEach((pr) => {
const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
pp.drawable = v;
poke('creator:prop', { key: pr.key, drawable: v });
});
p.appendChild(s.el);
});
},
renderIdentity() {
const p = this.pane('identity');
p.innerHTML = `
<div class="field" data-field="firstName">
<label for="firstName">First name</label>
<input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
<p class="hint">The name people will call you.</p>
</div>
<div class="field" data-field="lastName">
<label for="lastName">Last name</label>
<input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
<p class="hint">Family name. Must be unique on this server.</p>
</div>
<div class="field" data-field="dob">
<label for="dob">Date of birth</label>
<input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
<p class="hint">YYYY-MM-DD</p>
</div>
<div class="field" data-field="backstory">
<label for="backstory">Backstory</label>
<textarea id="backstory" maxlength="2000"
placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
<p class="counter"><span id="storyCount">0</span> / 2000</p>
</div>`;
$('firstName').addEventListener('input', () => {
const [ok, msg] = check.name($('firstName').value);
mark('firstName', ok, msg || 'The name people will call you.');
});
$('lastName').addEventListener('input', () => {
const [ok, msg] = check.name($('lastName').value);
mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
});
$('dob').addEventListener('input', () => {
const [ok, msg] = check.dob($('dob').value);
mark('dob', ok, msg || 'YYYY-MM-DD');
});
$('backstory').addEventListener('input', () => {
$('storyCount').textContent = String($('backstory').value.length);
});
},
/* --- drag to turn ----------------------------------------------------- */
initDrag() {
const tt = $('turntable');
let dragging = false;
let lastX = 0;
let pending = 0;
let frame = null;
const flush = () => {
frame = null;
if (pending !== 0) {
poke('creator:rotate', { delta: pending });
pending = 0;
}
};
tt.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
tt.classList.add('dragging');
tt.setPointerCapture(e.pointerId);
});
tt.addEventListener('pointermove', (e) => {
if (!dragging) return;
pending += (e.clientX - lastX) * 0.42;
lastX = e.clientX;
if (!frame) frame = requestAnimationFrame(flush);
});
const stop = (e) => {
if (!dragging) return;
dragging = false;
tt.classList.remove('dragging');
try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
};
tt.addEventListener('pointerup', stop);
tt.addEventListener('pointercancel', stop);
},
async cancel() {
await post('creator:cancel', {});
showScreen('characters');
Characters.render();
},
async submit() {
const first = $('firstName') ? $('firstName').value.trim() : '';
const last = $('lastName') ? $('lastName').value.trim() : '';
const dob = $('dob') ? $('dob').value.trim() : '';
if (this.section !== 'identity') this.go('identity');
const [fOk, fMsg] = check.name(first);
if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
const [lOk, lMsg] = check.name(last);
if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
const [dOk, dMsg] = check.dob(dob);
if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }
const btn = $('creatorNext');
busy(btn, true);
showError($('creatorError'), null);
const res = await post('creator:submit', {
firstName: first,
lastName: last,
dob,
backstory: $('backstory').value,
});
busy(btn, false);
if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');
stamp('Approved');
Characters.load(res.characters || [], Characters.max);
showScreen('characters');
},
};
/* =========================================================================
04 / spawn
========================================================================= */
const Spawn = {
spawns: [],
selected: null,
last: null,
init() {
$('spawnConfirm').addEventListener('click', () => this.confirm());
},
load(spawns, lastPosition, character) {
this.spawns = spawns || [];
this.last = lastPosition || null;
this.selected = null;
if (character) {
$('spawnSub').textContent =
`${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
}
this.render();
},
render() {
const ul = $('spawnList');
const pins = $('pins');
ul.innerHTML = '';
pins.innerHTML = '';
const entries = this.spawns.slice();
if (this.last) {
entries.unshift({
id: 'last',
label: 'Where you left off',
area: 'Last known position',
blurb: 'Pick up exactly where this character stopped.',
map: null,
});
}
entries.forEach((sp) => {
const li = document.createElement('li');
li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
li.tabIndex = 0;
li.innerHTML = `
<div class="spawnhead">
<span class="spawnname"></span>
<span class="spawnarea"></span>
</div>
<p class="spawnblurb"></p>`;
li.querySelector('.spawnname').textContent = sp.label;
li.querySelector('.spawnarea').textContent = sp.area;
li.querySelector('.spawnblurb').textContent = sp.blurb;
const pick = () => this.select(sp.id);
li.addEventListener('click', pick);
li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
ul.appendChild(li);
if (sp.map) {
const pin = document.createElement('div');
pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
pin.style.left = `${sp.map.x * 100}%`;
pin.style.top = `${sp.map.y * 100}%`;
pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
pin.querySelector('.pin-label').textContent = sp.label;
pins.appendChild(pin);
}
});
$('spawnConfirm').disabled = this.selected === null;
},
select(id) {
this.selected = id;
this.render();
showError($('spawnError'), null);
if (id !== 'last') poke('spawn:preview', { id });
},
async confirm() {
if (this.selected === null) return;
const btn = $('spawnConfirm');
busy(btn, true);
const res = await post('spawn:confirm', { id: this.selected });
busy(btn, false);
if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
stamp('Issued');
},
};
/* =========================================================================
messages from the client script
========================================================================= */
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'stage') {
if (msg.stage === 'auth') {
const d = msg.data || {};
if (d.serverName) $('markName').textContent = d.serverName;
if (d.serverTag) $('markTag').textContent = d.serverTag;
$('fileSerial').textContent = fileSerial();
showScreen('auth');
setTimeout(() => $('username').focus(), 520);
} else if (msg.stage === 'flying' || msg.stage === 'live') {
/* the camera is doing the talking now */
SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
document.body.classList.add('hidden');
} else {
showScreen(msg.stage);
}
}
if (msg.action === 'creator:sync' && msg.data) {
Creator.absorb(msg.data);
Creator.renderAll();
}
});
function fileSerial() {
const y = new Date().getFullYear();
const n = String(Math.floor(Math.random() * 900000) + 100000);
return `${y}-${n}`;
}
/* Escape backs out of the creator; nothing else steals keys from the game. */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current === 'creator') Creator.cancel();
});
document.addEventListener('DOMContentLoaded', () => {
Auth.init();
Characters.init();
Creator.init();
Spawn.init();
Auth.toggle(); /* start on "sign in"; the copy makes registering obvious */
});