/* 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 = 'Needs a password'; 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); } } })();