/* --------------------------------------------------------------------------- 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 = ` 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'); wrap.className = 'ctrl'; 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 = ` `; 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 = `

The name people will call you.

Family name. Must be unique on this server.

YYYY-MM-DD

0 / 2000

`; $('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 */ });