"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 { clear, Connection, isEmpty, isString, ms_ns, node, off, on, q, s_ns, session_id, session_id_from_url, session_url, } from "./shared.js" const buzzer_key = { code: 0x20, name: "space bar", } let conn, clients = [] 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 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() location.hash = sid } 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 - conn.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 } } } function setup_ui() { const buzzbutton = q("#buzzbutton") const username = q("#username") on("focus", (event) => { hide(".inactive") }) on("blur", (event) => { show(".inactive") }) if (document.hasFocus()) { hide(".inactive") } let buzzing = false function buzz() { if (!buzzing) { buzzing = true conn.send("buzz", conn.servertime_now_ns()) show(".active") hide(".ready") } } function unbuzz() { buzzing = false hide(".active") show(".ready") } q("#bkey").textContent = buzzer_key.name on("keydown", (event) => { if (event.target === username) { return } if (event.keyCode === buzzer_key.code) { buzz() } }) on("keyup", (event) => { if (event.target === username) { return } if (event.keyCode === buzzer_key.code) { unbuzz() } }) on( "mousedown", (event) => { buzz() }, { target: buzzbutton }, ) on( "mouseup", (event) => { unbuzz() }, { target: buzzbutton }, ) on( "mouseleave", (event) => { unbuzz() }, { target: buzzbutton }, ) const named = Object.fromEntries( Array.from(document.querySelectorAll("[name]")).map((el) => [el.name, el]), ) if (storage["my_name"]) { username.value = storage["my_name"] } on( "change", ({ target }) => { if (!target.dataset.onchange) { return } eval(`;{${target.dataset.onchange}};`) }, { target: q("#userinfo") }, ) on( "click", ({ target }) => { if (!target.dataset.onclick) { return } eval(`;{${target.dataset.onclick}};`) }, { target: q("#userinfo") }, ) } 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.onclick) { return } eval(`;{${target.dataset.onclick}};`) }, { target: q("#points-admin") }, ) on( "select", ({ target }) => { if (!target.dataset.onselect) { return } eval(`;{${target.dataset.onselect}};`) }, { target: q("#points-admin") }, ) on( "change", ({ target }) => { if (!target.dataset.onchange) { return } eval(`;{${target.dataset.onchange}};`) }, { target: q("#points-admin") }, ) const client_for_target = (target) => { let node = target while (!node.dataset.cid && node.parentElement) { node = node.parentElement } return find_client(node.dataset.cid) } on( "click", ({ target }) => { const client = client_for_target(target) if (!client || !target.dataset.onclick) { return } eval(`;{${target.dataset.onclick}};`) }, { target: q("#info .players") }, ) on( "change", ({ target }) => { const client = client_for_target(target) if (!client || !target.dataset.onchange) { return } eval(`;{${target.dataset.onchange}};`) }, { target: q("#info .players") }, ) } function set_name(name) { storage["my_name"] = name conn.send("name", name) } function setup_ws() { let me, session_key const sid = session_id() const credentials = { id: storage["my_uid"], key: storage["my_key"] } conn = new Connection() conn.on("helo", () => { if (sid === storage["my_sid"]) { conn.send("login", credentials) } set_name(q("#username").value) }) conn.on("id", ({ value }) => { 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) }) conn.on("buzz", ({ value }) => { const { time: buzztime_ns, client: client_id } = value const duration_ns = 3 * s_ns const until_ns = buzztime_ns + duration_ns highlight(client_id, until_ns) }) conn.on("clients", ({ value }) => { clients = value.clients redraw_clients(me, clients) }) conn.on("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) }) conn.on("session_key", ({ value }) => { session_key = value storage["session_path"] = session_key.path storage["session_key"] = session_key.key disable_player_ui() enable_admin_ui() set_name("Admin") }) conn.on("control", ({ value }) => { // ignore }) conn.on("error", (msg) => { console.error(`Error: ${msg.value.reason}`) const errorbox = q("#error") q("code", errorbox).textContent = JSON.stringify(msg, null, 2) q("body").classList.add("error") }) conn.connect(session_url(sid)) } setup_url() setup_ui() setup_ws()