Files
redl-fivem-rp/server-code/data/resources/[rp]/rp_ui/html/app.js
T
redlandClaude Fable 5 80c1d75d2f Part 1 final build: Los Santos RP written on camera by the agent
rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db),
county-records NUI: residency-file auth, identity-record character
intake, survey-grid spawn, procedural night-city loading screen.
Secrets replaced with .example files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 19:27:48 +00:00

959 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);
});
}
function stamp(text) {
const el = $('stamp');
$('stampText').textContent = text;
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
}
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');
wrap.className = 'ctrl';
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 */
});