Control messages are messages that are broadcast to all clients and have no clearly defined content. The idea is that this can be used to control a monitor without having to keep adding support for specific commands on the protocol layer. This also changes some of the existing messages and adds another ridiculous convenience layer to our HTML/JS templating: data-eval. We should probably just bite the bullet and use some reactive framework.
378 lines
8.9 KiB
JavaScript
378 lines
8.9 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 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()
|