summaryrefslogtreecommitdiff
path: root/public/js/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/app.js')
-rw-r--r--public/js/app.js652
1 files changed, 652 insertions, 0 deletions
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..586cfa3
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,652 @@
+/* global NAF, THREE */
+// Lobby (open rooms, starting or joining one, name, avatar, color), loading
+// the chosen world, connecting, the mic and VR buttons, and the little status
+// readouts on screen and on your left wrist.
+(function () {
+ // All CC0, from Polygonal Mind's 100Avatars collection.
+ const AVATARS = [
+ ['oldmoustache', 'Old Moustache'], ['bigbro', 'Bottle'], ['lilbro', 'Green Bottle'],
+ ['teddy', 'Teddy'], ['froggy', 'Froggy'], ['astronaut', 'Astronaut'],
+ ['polybot', 'Polybot'], ['wizzir', 'Wizzir'],
+ ];
+ const COLORS = [
+ ['#ffb36b', 'Orange'], ['#ff6b6b', 'Red'], ['#ffe36b', 'Yellow'], ['#8cff8c', 'Green'],
+ ['#5ef0d0', 'Teal'], ['#6bc5ff', 'Blue'], ['#d58cff', 'Purple'], ['#ffffff', 'White'],
+ ];
+ const STORE_KEY = 'yesod:v1';
+ const OLD_STORE_KEY = 'campfire:v1'; // what it was called before, still read once
+ const BASE_PATH = location.pathname.replace(/[^/]*$/, '');
+ const REFRESH_MS = 5000;
+ const LOCK_ICON = '<svg viewBox="0 0 16 16" role="img" aria-label="Needs a password"><title>Needs a password</title><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/></svg>';
+
+ const $ = (id) => document.getElementById(id);
+ const scene = document.querySelector('a-scene');
+ const rig = $('rig');
+ const leftHand = $('left-hand');
+ const rightHand = $('right-hand');
+
+ let worlds = [];
+ let site = null; // the server's /api/site: its name and where the sky is
+ let rooms = [];
+ let relayAvailable = false; // the server has a TURN relay to hide addresses behind
+ let setupFor = null; // null when starting a room, or the room being joined
+ let micAvailable = false;
+ let muted = false;
+ let joined = false;
+ let solo = false;
+ let sharingScreen = false; // joined as "My screen" (share.js)
+
+ // --- small helpers -------------------------------------------------------
+
+ function loadPrefs() {
+ try {
+ return JSON.parse(localStorage.getItem(STORE_KEY) || localStorage.getItem(OLD_STORE_KEY)) || {};
+ } catch (e) {
+ return {};
+ }
+ }
+
+ function savePrefs(prefs) {
+ try {
+ localStorage.setItem(STORE_KEY, JSON.stringify(prefs));
+ } catch (e) {
+ // Private browsing: nothing to remember, that's fine.
+ }
+ }
+
+ let bannerTimer = null;
+ function showBanner(text, ms) {
+ const banner = $('banner');
+ banner.textContent = text;
+ banner.hidden = false;
+ clearTimeout(bannerTimer);
+ if (ms) bannerTimer = setTimeout(() => { banner.hidden = true; }, ms);
+ }
+
+ function showError(el, text) {
+ el.textContent = text || '';
+ el.hidden = !text;
+ }
+
+ // A radio button wrapped in a label, showing `content` instead of the dot.
+ function choice(name, value, label, checked, content) {
+ const wrap = document.createElement('label');
+ wrap.className = 'choice';
+ wrap.title = label;
+ const input = document.createElement('input');
+ input.type = 'radio';
+ input.name = name;
+ input.value = value;
+ input.checked = checked;
+ input.setAttribute('aria-label', label);
+ wrap.append(input, ...content);
+ return wrap;
+ }
+
+ function el(tag, text) {
+ const node = document.createElement(tag);
+ if (text) node.textContent = text;
+ return node;
+ }
+
+ function img(src) {
+ const node = document.createElement('img');
+ node.src = src;
+ node.alt = '';
+ return node;
+ }
+
+ function worldById(id) {
+ return worlds.find((w) => w.id === id) || worlds[0];
+ }
+
+ function duration(seconds) {
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 1) return 'just started';
+ if (minutes < 60) return `${minutes} min`;
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
+ }
+
+ async function api(path, body) {
+ let res;
+ try {
+ res = await fetch(`api/${path}`, body ? {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ } : { cache: 'no-store' });
+ } catch (err) {
+ throw new Error("Can't reach the server.");
+ }
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || `Server error (${res.status}).`);
+ return data;
+ }
+
+ // --- lobby: setup form ---------------------------------------------------
+
+ const prefs = loadPrefs();
+ const form = $('setup-form');
+
+ $('name').value = prefs.name || '';
+
+ const chosenAvatar = AVATARS.some(([slug]) => slug === prefs.avatar) ? prefs.avatar : AVATARS[Math.floor(Math.random() * AVATARS.length)][0];
+ for (const [slug, label] of AVATARS) {
+ $('avatar-grid').append(choice('avatar', slug, label, slug === chosenAvatar, [img(`assets/avatars/${slug}-portrait.webp`), el('span', label)]));
+ }
+
+ const chosenColor = COLORS.some(([hex]) => hex === prefs.color) ? prefs.color : COLORS[0][0];
+ for (const [hex, label] of COLORS) {
+ const swatch = el('span');
+ swatch.style.background = hex;
+ $('color-row').append(choice('color', hex, label, hex === chosenColor, [swatch]));
+ }
+
+ form.elements.mode.value = prefs.mode === 'solo' ? 'solo' : 'open';
+ form.elements.as.value = prefs.as === 'screen' ? 'screen' : 'me';
+ $('hide-ip').checked = prefs.hideIp !== false;
+ const canShare = ScreenShare.supported();
+
+ function buildWorldChoices() {
+ const chosen = worldById(prefs.world).id;
+ for (const world of worlds) {
+ $('world-grid').append(choice('world', world.id, world.name, world.id === chosen, [img(world.thumbnail), el('b', world.name), el('span', world.blurb)]));
+ }
+ }
+
+ // Shows only the fields that matter for what you're doing.
+ function updateFields() {
+ const joining = Boolean(setupFor);
+ const isSolo = !joining && form.elements.mode.value === 'solo';
+ const isScreen = !isSolo && canShare && form.elements.as.value === 'screen';
+ $('world-field').hidden = joining;
+ $('mode-field').hidden = joining;
+ $('as-field').hidden = isSolo || !canShare;
+ $('password-field').hidden = isSolo || (joining && !setupFor.locked);
+ $('password-hint').hidden = joining;
+ $('name-field').hidden = isSolo;
+ $('avatar-field').hidden = isSolo || isScreen; // you never see your own body
+ $('color-field').hidden = isScreen;
+ $('hide-ip-field').hidden = isSolo || !relayAvailable;
+ $('name').required = !isSolo;
+ $('password').required = joining && setupFor.locked;
+ $('go-btn').textContent = joining ? 'Join' : isSolo ? 'Go' : 'Start';
+ }
+ form.addEventListener('change', updateFields);
+
+ function showSetup(room) {
+ setupFor = room;
+ if (room) {
+ $('setup-title').textContent = `Join ${room.host}'s ${worldById(room.world).name}`;
+ } else {
+ $('setup-title').textContent = 'Start a room';
+ }
+ $('password').value = '';
+ showError($('setup-error'), '');
+ updateFields();
+ $('rooms-view').hidden = true;
+ form.hidden = false;
+ $('lobby').scrollTop = 0;
+ }
+
+ function showRooms() {
+ setupFor = null;
+ form.hidden = true;
+ $('rooms-view').hidden = false;
+ history.replaceState(null, '', location.pathname + location.search);
+ refreshRooms();
+ }
+
+ $('start-btn').addEventListener('click', () => showSetup(null));
+ $('back-btn').addEventListener('click', showRooms);
+
+ // --- lobby: open rooms ---------------------------------------------------
+
+ function renderRooms() {
+ const list = $('room-list');
+ list.replaceChildren();
+ for (const room of rooms) {
+ const world = worldById(room.world);
+ const li = el('li');
+ const about = el('div');
+ about.className = 'about';
+ const title = el('b', world.name);
+ if (room.locked) title.insertAdjacentHTML('beforeend', LOCK_ICON);
+ const full = room.people >= room.max;
+ about.append(title, el('span', `${room.host} · ${duration(room.seconds)} · ${room.people} of ${room.max}`));
+ const join = el('button', full ? 'Full' : 'Join');
+ join.type = 'button';
+ join.disabled = full;
+ join.setAttribute('aria-label', `Join ${room.host}'s room`);
+ join.addEventListener('click', () => {
+ history.replaceState(null, '', `#room=${room.id}`);
+ showSetup(room);
+ });
+ li.append(img(world.thumbnail), about, join);
+ list.append(li);
+ }
+ list.hidden = rooms.length === 0;
+ $('no-rooms').hidden = rooms.length > 0;
+ }
+
+ async function refreshRooms() {
+ try {
+ const data = await api('rooms');
+ rooms = data.rooms;
+ relayAvailable = data.relay;
+ showError($('rooms-error'), '');
+ } catch (err) {
+ showError($('rooms-error'), err.message);
+ }
+ renderRooms();
+ }
+
+ setInterval(() => {
+ if (!joined && !$('rooms-view').hidden && !document.hidden) refreshRooms();
+ }, REFRESH_MS);
+
+ // --- lobby: start or join ------------------------------------------------
+
+ form.addEventListener('submit', async (evt) => {
+ evt.preventDefault();
+ const data = new FormData(form);
+ const me = {
+ name: String(data.get('name') || '').trim().slice(0, 20),
+ avatar: String(data.get('avatar') || chosenAvatar),
+ color: String(data.get('color') || chosenColor),
+ };
+ const password = String(data.get('password') || '');
+ const hideIp = $('hide-ip').checked;
+ const room = setupFor;
+ const mode = room ? 'open' : String(data.get('mode'));
+ const as = canShare && mode === 'open' && data.get('as') === 'screen' ? 'screen' : 'me';
+ const worldId = room ? room.world : String(data.get('world'));
+
+ const button = $('go-btn');
+ const label = button.textContent;
+ const error = $('setup-error');
+ showError(error, '');
+ button.disabled = true;
+ button.textContent = 'Checking…';
+
+ let roomId = room && room.id;
+ try {
+ if (room) {
+ await api(`rooms/${room.id}/check`, { password });
+ } else if (mode === 'open') {
+ roomId = (await api('rooms', { world: worldId, host: me.name, password })).id;
+ }
+ } catch (err) {
+ showError(error, err.message);
+ button.disabled = false;
+ button.textContent = label;
+ return;
+ }
+
+ const remembered = as === 'screen' ? { name: me.name || prefs.name } : { ...me, name: me.name || prefs.name };
+ savePrefs({ ...prefs, ...remembered, world: room ? prefs.world : worldId, mode: room ? prefs.mode : mode, as: mode === 'open' ? as : prefs.as, hideIp });
+
+ if (as === 'screen') {
+ // Ask here, inside the click, so browsers show the picker. Cancelling is
+ // fine: there's a Share button on the next page.
+ button.textContent = 'Choose what to share…';
+ let first = null;
+ try {
+ first = await ScreenShare.pick();
+ } catch (err) {
+ first = null;
+ }
+ const title = `Your screen in ${room ? room.host : me.name}'s ${worldById(worldId).name}`;
+ enterScreen(me, { id: roomId, password, hideIp: hideIp && relayAvailable }, first, title);
+ return;
+ }
+
+ if (mode === 'solo') {
+ micAvailable = false;
+ THREE.AudioContext.getContext().resume();
+ enter(worldId, me, null);
+ return;
+ }
+
+ button.textContent = 'Requesting microphone…';
+ // Ask here, inside the click, so browsers show the permission prompt.
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ stream.getTracks().forEach((t) => t.stop());
+ micAvailable = true;
+ } catch (err) {
+ micAvailable = false;
+ showBanner('Microphone unavailable. You can listen but not talk.', 10000);
+ }
+ THREE.AudioContext.getContext().resume();
+
+ enter(worldId, me, { id: roomId, password, hideIp: hideIp && relayAvailable });
+ });
+
+ // --- entering a world ------------------------------------------------------
+
+ // Adds the world's entities to the scene and returns its [data-world] entity.
+ function loadWorld(world) {
+ if (world.fog) scene.setAttribute('fog', world.fog);
+ const content = $(`world-${world.id}`).content.cloneNode(true);
+ const sky = content.querySelector('[sky]');
+ if (sky && site) {
+ const { latitude, longitude } = site.sky;
+ sky.setAttribute('sky', [sky.getAttribute('sky'), `latitude: ${latitude}`, `longitude: ${longitude}`].filter(Boolean).join('; '));
+ }
+ scene.append(content);
+ return scene.querySelector('[data-world]');
+ }
+
+ // A world can offer one thing to do (worlds.json `action`, e.g. the temple's
+ // Veil): V on desktop. In VR you reach for it instead (the world's
+ // `touchables`, see wield.js).
+ function setUpAction(world, worldEl) {
+ if (!world.action) return;
+ for (const hint of document.querySelectorAll('.world-action')) {
+ hint.lastElementChild.textContent = world.action.label.toLowerCase();
+ hint.hidden = false;
+ }
+ const act = () => {
+ const component = worldEl.components[worldEl.dataset.world];
+ if (component && typeof component[world.action.method] === 'function') component[world.action.method]();
+ };
+ window.addEventListener('keydown', (evt) => {
+ if (evt.code === 'KeyV' && !evt.repeat && !(evt.target.closest && evt.target.closest('input'))) act();
+ });
+ }
+
+ // Keeps the world properties listed in worlds.json `shared` (e.g. the Veil)
+ // the same for everyone: changes are sent to the room, and everyone already
+ // here tells each newcomer how things stand. Values from others are only
+ // accepted for those properties, and only with the type they already have.
+ const CATCH_UP_MS = 3000;
+ function shareWorldState(world, worldEl) {
+ const props = world.shared || [];
+ if (!props.length) return;
+ const name = worldEl.dataset.world;
+ const snapshot = () => {
+ const data = worldEl.getAttribute(name);
+ return Object.fromEntries(props.map((p) => [p, data[p]]));
+ };
+ let applying = false;
+ // Until you've heard how things stand (or waited long enough to know no
+ // one will tell you), your copy is just the defaults, so you don't pass
+ // it on to anyone, or a newcomer would reset everyone else.
+ let caughtUp = false;
+ document.body.addEventListener('connected', () => {
+ setTimeout(() => { caughtUp = true; }, CATCH_UP_MS);
+ }, { once: true });
+ NAF.connection.subscribeToDataChannel('world-state', (from, type, data) => {
+ caughtUp = true;
+ const current = worldEl.getAttribute(name);
+ const update = {};
+ for (const p of props) {
+ if (data && typeof data[p] === typeof current[p]) update[p] = data[p];
+ }
+ applying = true;
+ worldEl.setAttribute(name, update);
+ applying = false;
+ });
+ worldEl.addEventListener('componentchanged', (evt) => {
+ if (applying || evt.detail.name !== name) return;
+ caughtUp = true;
+ NAF.connection.broadcastDataGuaranteed('world-state', snapshot());
+ });
+ document.body.addEventListener('clientConnected', (evt) => {
+ if (caughtUp) NAF.connection.sendDataGuaranteed(evt.detail.clientId, 'world-state', snapshot());
+ });
+ }
+
+ // `room` is { id, password, hideIp } for a shared room, or null to stay on this device.
+ function enter(worldId, me, room) {
+ joined = true;
+ solo = !room;
+ muted = !micAvailable;
+ const world = worldById(worldId);
+ const worldEl = loadWorld(world);
+ rig.setAttribute('player-info', { name: me.name, color: me.color, avatar: me.avatar, muted });
+ leftHand.setAttribute('networked-hand-controls', 'color', me.color);
+ rightHand.setAttribute('networked-hand-controls', 'color', me.color);
+ // The spawn point comes from the world, which starts up on its next tick.
+ const spawn = () => {
+ rig.components.locomotion.spawn();
+ const tools = worldEl.components[worldEl.dataset.world].tools;
+ for (const hint of document.querySelectorAll('.world-tools')) hint.hidden = !(tools && tools.length);
+ for (const hint of document.querySelectorAll('.world-chess')) hint.hidden = !worldEl.querySelector('[chess]');
+ };
+ if (worldEl.hasLoaded) spawn();
+ else worldEl.addEventListener('loaded', spawn, { once: true });
+ setUpAction(world, worldEl);
+
+ $('lobby').hidden = true;
+ $('hud').hidden = false;
+ $('mic-btn').hidden = solo;
+ $('mic-btn').disabled = !micAvailable;
+ $('people').hidden = solo;
+ $('link-btn').hidden = solo || !navigator.clipboard;
+ renderMic();
+ if (solo) return;
+
+ shareWorldState(world, worldEl);
+ connect(room, micAvailable);
+ }
+
+ // "My screen": you're in the room only as what you share (share.js), so
+ // there's no body, hands or world here, and nothing plays. If you share this
+ // computer's sound, the room's voices would otherwise go round again.
+ function enterScreen(me, room, first, title) {
+ joined = true;
+ sharingScreen = true;
+ for (const node of [rig, $('player'), leftHand, rightHand]) node.removeAttribute('networked');
+ rig.removeAttribute('wield');
+ rig.removeAttribute('screens');
+ scene.renderer.setAnimationLoop(null);
+ THREE.AudioContext.getContext().suspend();
+ $('rooms-view').hidden = true;
+ form.hidden = true;
+ $('share-view').hidden = false;
+ $('lobby').scrollTop = 0;
+ ScreenShare.run({ name: me.name, title, first, showBanner });
+ connect(room, false);
+ }
+
+ // Opus is set up for a voice unless both sides say otherwise. Stereo at up
+ // to 128 kbps lets a shared screen's sound through as it is.
+ function stereoOpus(sdp) {
+ const opus = /a=rtpmap:(\d+) opus\/48000\/2/i.exec(sdp);
+ if (!opus) return sdp;
+ return sdp.replace(new RegExp(`a=fmtp:${opus[1]} ([^\\r\\n]*)`, 'g'), (line, params) => {
+ const have = new Set(params.split(';').map((p) => p.split('=')[0].trim()));
+ const add = ['stereo=1', 'sprop-stereo=1', 'maxaveragebitrate=128000'].filter((p) => !have.has(p.split('=')[0]));
+ return add.length ? `${line};${add.join(';')}` : line;
+ });
+ }
+
+ function connect(room, audio) {
+ // Reloading brings you back to this room's join screen.
+ history.replaceState(null, '', `#room=${room.id}`);
+
+ scene.addEventListener('adapter-ready', (evt) => {
+ const adapter = evt.detail;
+ // Signaling lives under this page's path (e.g. /vr/socket.io behind nginx).
+ adapter.setServerUrl = () => adapter.easyrtc.setSocketUrl(location.origin, {
+ path: `${BASE_PATH}socket.io`,
+ transports: ['polling', 'websocket'],
+ withCredentials: false,
+ reconnection: false,
+ forceNew: true,
+ pingTimeout: 10000,
+ pingInterval: 5000,
+ });
+ adapter.easyrtc.setCredential({ room: room.id, password: room.password });
+ // Relay-only: the browser offers only the relay's address, never your own.
+ if (room.hideIp) adapter.easyrtc.setPeerConnectionConfig({ iceTransportPolicy: 'relay', voiceActivityDetection: true });
+ adapter.easyrtc.setSdpFilters(stereoOpus, stereoOpus);
+ adapter.easyrtc.setDisconnectListener(() => {
+ showBanner('Connection lost. Reload to rejoin.');
+ });
+
+ const failure = NAF.connection.connectFailure.bind(NAF.connection);
+ NAF.connection.connectFailure = (code, message) => {
+ failure(code, message);
+ showBanner(`Couldn't connect (${code}). Reload to try again.`);
+ };
+ }, { once: true });
+
+ scene.setAttribute('networked-scene', { room: room.id, audio });
+ scene.emit('connect');
+ }
+
+ document.body.addEventListener('connected', () => {
+ showBanner('Connected.', 3000);
+ });
+
+ for (const id of ['leave-btn', 'share-leave-btn']) {
+ $(id).addEventListener('click', () => {
+ location.href = BASE_PATH;
+ });
+ }
+
+ $('link-btn').addEventListener('click', () => {
+ navigator.clipboard.writeText(location.href).then(
+ () => showBanner('Link copied. Share the password separately, if there is one.', 4000),
+ () => showBanner(location.href, 8000),
+ );
+ });
+
+ // --- mic -----------------------------------------------------------------
+
+ function setMuted(value) {
+ if (!micAvailable || !joined) return;
+ muted = value;
+ if (NAF.connection.adapter) NAF.connection.adapter.enableMicrophone(!muted);
+ rig.setAttribute('player-info', 'muted', muted);
+ renderMic();
+ }
+
+ function renderMic() {
+ const btn = $('mic-btn');
+ btn.textContent = !micAvailable ? 'No mic' : muted ? 'Muted' : 'Mic on';
+ btn.setAttribute('aria-pressed', String(muted));
+ renderWrist();
+ }
+
+ $('mic-btn').addEventListener('click', () => setMuted(!muted));
+ window.addEventListener('keydown', (evt) => {
+ if (evt.code === 'KeyM' && !evt.repeat && !(evt.target.closest && evt.target.closest('input'))) setMuted(!muted);
+ });
+ leftHand.addEventListener('ybuttondown', () => setMuted(!muted));
+ rightHand.addEventListener('bbuttondown', () => setMuted(!muted));
+
+ // --- desktop mouse look ---------------------------------------------------
+
+ // A-Frame enters VR when F is let go of, which on a computer only goes
+ // fullscreen; here F picks things up (wield.js, chess.js) and the VR button
+ // enters VR.
+ const noShortcutF = () => scene.setAttribute('keyboard-shortcuts', 'enterVR', false);
+ if (scene.hasLoaded) noShortcutF();
+ else scene.addEventListener('loaded', noShortcutF, { once: true });
+
+ // look-controls captures the mouse on click; show a dot while it's captured.
+ document.addEventListener('pointerlockchange', () => {
+ $('crosshair').hidden = document.pointerLockElement !== scene.canvas;
+ });
+
+ // --- VR ------------------------------------------------------------------
+
+ const vrButton = $('vr-btn');
+ vrButton.hidden = true;
+ if (navigator.xr && navigator.xr.isSessionSupported) {
+ navigator.xr.isSessionSupported('immersive-vr').then((ok) => {
+ vrButton.hidden = !ok;
+ document.body.classList.toggle('xr-capable', ok);
+ }).catch(() => {});
+ }
+ vrButton.addEventListener('click', () => scene.enterVR());
+
+ // --- who's here ------------------------------------------------------------
+
+ let peopleText = '';
+ let hintText = '';
+ let hintTimer = null;
+ function renderWrist() {
+ const lines = solo ? [] : [!micAvailable ? 'no mic' : muted ? 'MUTED' : 'mic on', peopleText];
+ if (hintText) lines.push(hintText);
+ $('wrist-text').setAttribute('value', lines.join('\n'));
+ }
+
+ // Shown on screen and on your wrist for a few seconds.
+ function showHint(text) {
+ showBanner(text, 3000);
+ hintText = text;
+ renderWrist();
+ clearTimeout(hintTimer);
+ hintTimer = setTimeout(() => {
+ hintText = '';
+ renderWrist();
+ }, 3000);
+ }
+
+ rig.addEventListener('wield-failed', (evt) => {
+ showHint(evt.detail.reason === 'held' ? `Someone is holding ${evt.detail.label}` : 'Look at something you can pick up, closer');
+ });
+
+ rig.addEventListener('sit-failed', (evt) => {
+ showHint(evt.detail.reason === 'taken' ? 'That seat is taken' : 'Get closer to a log or stump to sit');
+ });
+
+ rig.addEventListener('screen-busy', (evt) => {
+ showHint(`Someone is moving ${evt.detail.name}'s screen`);
+ });
+
+ rig.addEventListener('screens-changed', () => {
+ const screens = rig.components.screens;
+ const any = Boolean(screens && screens.screens.size);
+ for (const hint of document.querySelectorAll('.screen-hint')) hint.hidden = !any;
+ });
+
+ setInterval(() => {
+ if (!joined || solo) return;
+ const names = [...document.querySelectorAll('[player-info]')]
+ .filter((node) => node !== rig)
+ .map((node) => node.getAttribute('player-info').name || 'Someone');
+ if (rig.components.screens) names.push(...rig.components.screens.labels());
+ peopleText = names.length ? `Here: ${names.join(', ')}` : 'No one else here';
+ $(sharingScreen ? 'share-people' : 'people').textContent = peopleText;
+ renderWrist();
+ }, 1000);
+
+ // --- start ---------------------------------------------------------------
+
+ (async function start() {
+ try {
+ [worlds, site] = await Promise.all([fetch('worlds.json').then((res) => res.json()), api('site')]);
+ } catch (err) {
+ showError($('rooms-error'), "Can't reach the server.");
+ return;
+ }
+ document.title = site.name;
+ $('site-name').textContent = site.name;
+ buildWorldChoices();
+ updateFields();
+ await refreshRooms();
+
+ await openLinkedRoom();
+ window.addEventListener('hashchange', () => {
+ if (!joined) openLinkedRoom();
+ });
+ })();
+
+ // A shared link (#room=id) goes straight to that room's join screen.
+ async function openLinkedRoom() {
+ const linked = new URLSearchParams(location.hash.slice(1)).get('room');
+ if (!linked || (setupFor && setupFor.id === linked)) return;
+ try {
+ showSetup(await api(`rooms/${encodeURIComponent(linked)}`));
+ } catch (err) {
+ history.replaceState(null, '', location.pathname + location.search);
+ showBanner(err.message, 6000);
+ }
+ }
+})();