quiz/public/buzzer.js

379 lines
8.9 KiB
JavaScript
Raw Normal View History

"use strict"
2021-01-30 13:41:25 +01:00
/* global document, window */
const crypto = window.crypto
const location = document.location
const performance = window.performance
2021-01-30 13:41:25 +01:00
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 = [],
2021-01-30 13:41:25 +01:00
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() {
2021-01-30 13:41:25 +01:00
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
2021-01-30 13:41:25 +01:00
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
2021-01-30 13:41:25 +01:00
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()