/* ---------------------------------------------------------------------------
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 = `
0${i + 1}
`;
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 = `+ Create a new identity `;
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 = `
`;
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 = `
−
+
`;
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 = `
`;
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 = `
Male
Female `;
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 = `
`;
$('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 = `
`;
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 = ' ';
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 */
});