"use strict" /* global document, window */ const crypto = window.crypto const location = document.location const performance = window.performance const storage = window.sessionStorage // TODOs // - measure/report latency // - use server reported time to find winner import config from "./config.js" const buzzer_key = { code: 0x20, name: "space bar", } function isString(x) { return typeof x === "string" } function isEmpty(x) { return (Array.isArray(x) ? x : Object.keys(x)).length === 0 } const q = (selector, root = document) => root.querySelector(selector) const _evlisteners = {} /** * @param {String} event [description] * @param {Function} cb [description] * @param {?{once: bool, target: Element = document}} options */ function on(event, cb, options = {}) { const target = options.target || document if ((_evlisteners[target] ?? {})[event]) { console.warn(`Replacing previous event listener for '${event}' on target:`, target) off(event, target) } target.addEventListener(event, cb, { once: !!options.once }) if (!_evlisteners[target]) { _evlisteners[target] = {} } _evlisteners[target][event] = cb } function off(event, target = document) { if (!_evlisteners[target] || !_evlisteners[target][event]) { return } target.removeEventListener(event, _evlisteners[target][event]) delete _evlisteners[target][event] if (isEmpty(_evlisteners[target])) { delete _evlisteners[target] } } function node(type, { appendTo, cls, text, data, style, ...attrs } = {}) { let elem = isString(type) ? document.createElement(type) : type if (cls) { if (isString(cls)) { elem.className = cls } else { for (const [name, on] of Object.entries(cls)) { if (on) { elem.classList.add(name) } else { elem.classList.remove(name) } } } } if (text) { elem.textContent = text } Object.assign(elem.dataset, data ?? {}) Object.assign(elem.style, style ?? {}) for (const name in attrs) { elem.setAttribute(name, attrs[name]) } if (appendTo) { elem = appendTo.appendChild(elem) } return elem } /** * Some duration conversion constants. */ const ms_ns = 1_000_000 // nanoseconds in a millisecond const s_ms = 1_000 // milliseconds in a second const s_ns = 1_000_000_000 // nanoseconds in a second let socket, servertime, toffset_ms, clients = [], me, session_key function hide(e) { e = isString(e) ? q(`#${e}`) : e e.style.display = "none" } function show(e) { e = isString(e) ? q(`#${e}`) : e e.style.display = "block" } function find_client(client_id) { return clients.find((c) => c.id === client_id) } function session_id() { const match = /^#?(.+)/.exec(document.location.hash) return match ? match[1] : null } function new_session_id() { if (!crypto) { return Math.random().toString(36).substr(2) } const data = new Uint8Array(10) crypto.getRandomValues(data) return Array.from(data, (v) => v.toString(36)).join("") } function setup_url() { const sid = session_id() || new_session_id() document.location.hash = sid } function send(type, value) { // console.debug('sending', value) socket.send(JSON.stringify({ type, value })) } function clear(container) { while (container.children.length > 0) { const child = container.children[0] child.remove() } } const player_list = q("#info ul") function redraw_clients(me, clients) { if (!me) { return } clear(player_list) const player_tpl = q("template#player").content.firstElementChild for (const c of clients) { const player_el = node(player_tpl.cloneNode(true), { data: { cid: c.id }, appendTo: player_list, cls: { me: c.id === me.id, inactive: !c.active, }, }) q(".name", player_el).textContent = c.name q(".points", player_el).textContent = c.points q(".tokens", player_el).textContent = c.tokens } } const highlights = {} function highlight(client_id, until_ns) { if (highlights[client_id]) { return } const timeout_ms = (until_ns - servertime_now_ns()) / ms_ns if (timeout_ms <= 10) { console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms) return } for (const li of player_list.children) { if (li.dataset.cid === client_id) { li.classList.add("buzzing") if (!isEmpty(highlights)) { li.classList.add("too-late") } else { li.classList.add("first") } highlights[client_id] = setTimeout(() => { delete highlights[client_id] li.classList.remove("buzzing") li.classList.remove("too-late") li.classList.remove("first") }, timeout_ms) return } } } /** * Guess the exact current server time. */ function servertime_now_ns() { const now_ms = performance.now() const delta_ns = ms_ns * (now_ms - toffset_ms) return servertime + delta_ns } function setup_ui() { on("focus", (event) => { hide("active") hide("inactive") show("ready") }) on("blur", (event) => { hide("active") show("inactive") hide("ready") }) if (document.hasFocus()) { hide("inactive") } else { hide("ready") } q("#bkey").textContent = buzzer_key.name let buzzing = false on("keydown", (event) => { if (!buzzing && event.keyCode === buzzer_key.code) { buzzing = true send("buzz", servertime_now_ns()) show("active") hide("ready") } }) on("keyup", (event) => { if (event.keyCode === buzzer_key.code) { buzzing = false hide("active") show("ready") } }) const username_el = q("#username") if (storage["my_name"]) { username_el.value = storage["my_name"] } on( "change", (event) => { set_name(event.target.value) }, { target: username_el }, ) } function disable_player_ui() { off("focus") off("blur") off("keydown") off("keyup") node(q("body"), { cls: { player: 0 } }) } function enable_admin_ui() { const body = q("body") if (body.classList.contains("admin")) { return } body.classList.add("admin") const named = Object.fromEntries( Array.from(document.querySelectorAll("[name]")).map((el) => [el.name, el]), ) on( "click", ({ target }) => { if (!target.dataset.eval) { return } eval(target.dataset.eval) }, { target: q("#points-admin") }, ) on( "click", ({ target }) => { let node = target while (!node.dataset.cid && node.parentElement) { node = node.parentElement } if (!target.dataset.eval) { return } const client = find_client(node.dataset.cid) if (!client) { return } eval(target.dataset.eval) }, { target: q("#info .players") }, ) } function set_name(name) { storage["my_name"] = name send("name", name) } function session_url(sid) { return `${config.wsurl}/${sid}` } function session_id_from_url(url) { const wsurl = new URL(config.wsurl) const match = RegExp(`${wsurl.pathname}/([^/]+)$`).exec(url) return !match ? null : match[1] } function setup_ws() { const sid = session_id() const credentials = { id: storage["my_uid"], key: storage["my_key"] } socket = new WebSocket(`${session_url(sid)}`) socket.addEventListener("open", function (event) { if (sid === storage["my_sid"]) { send("login", credentials) } set_name(q("#username").value) }) socket.addEventListener("message", function (event) { const msg = JSON.parse(event.data) const { type, value } = msg if (type === "time") { servertime = value.time toffset_ms = performance.now() } else if (type === "id") { me = value storage["my_uid"] = me.id storage["my_key"] = me.key storage["my_sid"] = session_id_from_url(me.path) redraw_clients(me, clients) } else if (type === "session_key") { session_key = value storage["session_path"] = session_key.path storage["session_key"] = session_key.key disable_player_ui() enable_admin_ui() } else if (type === "buzz") { const buzztime_ns = value.time const client_id = value.client const duration_ns = 3 * s_ns const until_ns = buzztime_ns + duration_ns highlight(client_id, until_ns) } else if (type === "clients") { clients = value.clients redraw_clients(me, clients) } else if (type === "client") { const client = value.client const idx = clients.findIndex((c) => c.id === client.id) if (idx >= 0) { clients[idx] = client } else { clients.push(client) } redraw_clients(me, clients) } else if (type === "error") { console.error(`Error: ${value.reason}`) const errorbox = q("#error") q("code", errorbox).textContent = JSON.stringify(msg, null, 2) q("body").classList.add("error") } else { console.error(`Unknown message: ${event.data}`) } }) } setup_url() setup_ui() setup_ws()