diff options
| author | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
| commit | e69ad01e700e80fa959dfee08cd556ca0d777ffd (patch) | |
| tree | 645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/app.js | |
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser:
A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide
everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and
Test room worlds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/app.js')
| -rw-r--r-- | public/js/app.js | 652 |
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); + } + } +})(); |