diff --git a/public/buzzer.html b/public/buzzer.html index 8ad8f46..08052b6 100644 --- a/public/buzzer.html +++ b/public/buzzer.html @@ -42,8 +42,7 @@
  • player name
    - (points pts) - (tokens tks) + (points pts) (tokens tks) diff --git a/public/buzzer.js b/public/buzzer.js index 4cb7d0f..04e8111 100644 --- a/public/buzzer.js +++ b/public/buzzer.js @@ -10,89 +10,27 @@ const storage = window.sessionStorage // - measure/report latency // - use server reported time to find winner -import config from "./config.js" +import { + clear, + isEmpty, + isString, + ms_ns, + node, + off, + on, + q, + s_ns, + servertime_now_ns, + session_id, + session_id_from_url, + session_url, +} from "./shared.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, @@ -114,11 +52,6 @@ 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) @@ -130,7 +63,7 @@ function new_session_id() { function setup_url() { const sid = session_id() || new_session_id() - document.location.hash = sid + location.hash = sid } function send(type, value) { @@ -138,13 +71,6 @@ function send(type, 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) { @@ -196,15 +122,6 @@ function highlight(client_id, until_ns) { } } -/** - * 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") @@ -306,20 +223,10 @@ function set_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 = new WebSocket(session_url(sid)) socket.addEventListener("open", function (event) { if (sid === storage["my_sid"]) { send("login", credentials) @@ -362,6 +269,8 @@ function setup_ws() { clients.push(client) } redraw_clients(me, clients) + } else if (type === "control") { + // ignore } else if (type === "error") { console.error(`Error: ${value.reason}`) const errorbox = q("#error") diff --git a/public/monitor.css b/public/monitor.css new file mode 100644 index 0000000..77209ae --- /dev/null +++ b/public/monitor.css @@ -0,0 +1,60 @@ +body { + font-family: "Arial Rounded MT Bold", sans-serif; + background-color: black; + margin: 0px auto; + overflow: hidden; +} +* { + margin: 0; + padding: 0; +} +/* +# colors +citrus: c2d72f +bleue: 0093ff +*/ +#info { + background-color: #c2d72f; + padding: 1em 0 0 1em; + width: 1280px; + height: 720px; + outline: 10px solid gold; +} +.player { + width: 336px; + display: flex; + flex-direction: column; + position: relative; +} +.player + .player { + margin-top: 1.5em; +} +.box { + background-color: #0f0; + height: 189px; + outline: 0.3em solid grey; + -moz-outline-radius: 1em; +} +.points { + position: absolute; + top: 1em; + right: 1em; + background-color: #c2d72f; + width: 3em; + padding: 0.2em; + text-align: right; + border-radius: 0.5em; + box-shadow: 0 0 0.3em #c2d72f, 0 0 0.8em #c2d72f, 0 0 1em #c2d72f, 0 0 1.2em #c2d72f; +} +.player.buzzing .box { + outline: 0.7em solid #0093ff; +} +.name { + margin-top: 0.5em; + text-align: center; + letter-spacing: 0.3em; + font-variant: small-caps; + /*font-weight: bold;*/ +} +/*1280 x 720*/ +/*1920 x 1080*/ diff --git a/public/monitor.html b/public/monitor.html new file mode 100644 index 0000000..25eb39b --- /dev/null +++ b/public/monitor.html @@ -0,0 +1,24 @@ + + + + +
    +
    +
    -36'000
    +
    +
    Player 1
    +
    +
    +
    0
    +
    +
    Player 2
    +
    +
    +
    900
    +
    +
    Player 3
    +
    +
    + + + diff --git a/public/monitor.js b/public/monitor.js new file mode 100644 index 0000000..1c9e311 --- /dev/null +++ b/public/monitor.js @@ -0,0 +1,149 @@ +"use strict" + +/* global document, window */ +const location = document.location +const performance = window.performance + +import { + clear, + ms_ns, + node, + q, + s_ns, + servertime_now_ns, + session_id, + session_url, +} from "./shared.js" + +let socket, + servertime, + toffset_ms, + clients = {}, + me + +function setup_url() { + const sid = session_id() || "" + location.hash = sid +} + +function send(type, value) { + // console.debug('sending', value) + socket.send(JSON.stringify({ type, value })) +} + +function prettynum(n) { + let i = Math.abs(n) | 0 + let s = [] + while (true) { + const m = i % 1000 + i = (i / 1000) | 0 + if (i === 0) { + s.unshift(String(m)) + break + } else { + s.unshift(String(m).padStart(3, "0")) + } + } + return (n < 0 ? "-" : "") + s.join("'") +} + +function assert(expected, got) { + if (expected !== got) { + throw Error("Assertion failed.") + } +} + +function test_prettynum() { + assert("0", prettynum(0)) + assert("1", prettynum(1)) + assert("1'000", prettynum(1000)) + assert("36'000", prettynum(36000)) + assert("0", prettynum(-0)) + assert("-1", prettynum(-1)) + assert("-1'000", prettynum(-1000)) + assert("-36'000", prettynum(-36000)) + assert("-36'600", prettynum(-36600)) +} +test_prettynum() + +const player_list = q("#info") +function redraw_clients(me, clients) { + if (!me) { + return + } + clear(player_list) + for (const c of Object.values(clients)) { + if (c.id === me.id) { + continue + } + const player = node("div", { data: { cid: c.id }, cls: "player" }) + node("div", { cls: "points", text: prettynum(c.points), appendTo: player }) + node("div", { cls: "box", appendTo: player }) + node("div", { cls: "name", text: c.name, appendTo: player }) + player_list.appendChild(player) + } +} + +let highlighted = false +function highlight(client_id, until_ns) { + if (highlighted) { + 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") + highlighted = true + setTimeout(() => { + highlighted = false + li.classList.remove("buzzing") + }, timeout_ms) + return + } + } +} + +function setup_ws() { + const sid = session_id() + socket = new WebSocket(session_url(sid)) + socket.addEventListener("open", function (event) { + send("name", "Monitor") + }) + socket.addEventListener("message", function (event) { + const msg = JSON.parse(event.data) + const { type, value } = msg + if (msg.type === "time") { + servertime = value.time + toffset_ms = performance.now() + } else if (msg.type === "id") { + me = value + redraw_clients(me, clients) + } else if (msg.type === "buzz") { + const buzztime_ns = value.time + const client_id = value.client + const duration_ns = 12 * s_ns + const until_ns = buzztime_ns + duration_ns + highlight(client_id, until_ns) + } else if (msg.type === "clients") { + clients = Object.fromEntries(value.clients.map((c) => [c.id, c])) + redraw_clients(me, clients) + } else if (msg.type === "client") { + const client = value.client + clients[client.id] = client + redraw_clients(me, clients) + } else if (type === "session_key") { + // ignore + } else if (type === "error") { + console.error(`Error: ${value.reason}`) + } else { + console.error(`Unknown message: ${event.data}`) + } + }) +} + +setup_url() +setup_ws() diff --git a/public/shared.js b/public/shared.js new file mode 100644 index 0000000..18257d3 --- /dev/null +++ b/public/shared.js @@ -0,0 +1,115 @@ +"use strict" + +/* global document, window */ +const location = document.location +const performance = window.performance + +import config from "./config.js" + +export function isString(x) { + return typeof x === "string" +} + +export function isEmpty(x) { + return (Array.isArray(x) ? x : Object.keys(x)).length === 0 +} + +export 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 + */ +export 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 +} + +export 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] + } +} + +export 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. + */ +export const ms_ns = 1_000_000 // nanoseconds in a millisecond +export const s_ms = 1_000 // milliseconds in a second +export const s_ns = 1_000_000_000 // nanoseconds in a second + +export function session_url(sid) { + return `${config.wsurl}/${sid}` +} + +export function session_id_from_url(url) { + const wsurl = new URL(config.wsurl) + const match = RegExp(`${wsurl.pathname}/([^/]+)$`).exec(url) + return !match ? null : match[1] +} + +export function clear(container) { + while (container.children.length > 0) { + const child = container.children[0] + child.remove() + } +} + +export function session_id() { + const match = /^#?(.+)/.exec(location.hash) + return match ? match[1] : null +} + +/** + * Guess the exact current server time. + */ +export function servertime_now_ns() { + const now_ms = performance.now() + const delta_ns = ms_ns * (now_ms - toffset_ms) + return servertime + delta_ns +} diff --git a/stubs/websockets.pyi b/stubs/websockets.pyi index 3451014..311877e 100644 --- a/stubs/websockets.pyi +++ b/stubs/websockets.pyi @@ -104,5 +104,3 @@ class Serve: __iter__ = __await__ serve = Serve - -WebSocketServerProtocol