quiz/public/buzzer.js
ducklet 9e7000054b fix connection time syncing handling
Share the time syncing code, and while we're at it wrap the whole
connection thing in a class.  Makes it easier to pass the connection
around & later on add more advanced handler registration if we want to.
2021-02-02 21:02:30 +01:00

277 lines
6 KiB
JavaScript

"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 socket,
conn,
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 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() {
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
conn.send("buzz", conn.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
conn.send("name", name)
}
function setup_ws() {
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()
})
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()