Neale Pickett
·
2023-11-16
scoreboard.mjs
1import * as moth from "./moth.mjs"
2import * as common from "./common.mjs"
3
4const server = new moth.Server(".")
5/** Don't let any team's score exceed this percentage width */
6const MaxScoreWidth = 90
7
8/**
9 * Returns a promise that resolves after timeout.
10 *
11 * This uses setTimeout instead of some other fancy thing like
12 * requestAnimationFrame, because who actually cares about scoreboard update
13 * framerate?
14 *
15 * @param {Number} timeout How long to sleep (milliseconds)
16 * @returns {Promise}
17 */
18function sleep(timeout) {
19 return new Promise(resolve => setTimeout(resolve, timeout));
20}
21
22/**
23 * Pull new points log, and update the scoreboard.
24 *
25 * The update is animated, because I think that looks cool.
26 */
27async function update() {
28 let config = {}
29 try {
30 config = await common.Config()
31 }
32 catch (err) {
33 console.warn("Parsing config.json:", err)
34 }
35
36 // Pull configuration settings
37 if (!config.Scoreboard) {
38 console.warn("config.json has empty Scoreboard section")
39 }
40 let ScoreboardConfig = config.Scoreboard ?? {}
41 let state = await server.GetState()
42
43 // Show URL of server
44 for (let e of document.querySelectorAll(".location")) {
45 e.textContent = common.BaseURL
46 e.classList.toggle("hidden", !(ScoreboardConfig.DisplayServerURLWhenEnabled && state.Enabled))
47 }
48
49 // Rotate views
50 for (let e of document.querySelectorAll(".rotate")) {
51 e.appendChild(e.firstChild)
52 }
53
54 // Render rankings
55 for (let e of document.querySelectorAll(".rankings")) {
56 if (e.classList.contains("classic")) {
57 classicRankings(e, state, ScoreboardConfig)
58 } else if (e.classList.contains("category")) {
59 categoryRankings(e, state, ScoreboardConfig)
60 }
61 }
62
63}
64
65async function classicRankings(rankingsElement, state, ScoreboardConfig) {
66 let ReplayHistory = ScoreboardConfig.ReplayHistory ?? false
67 let ReplayDurationMS = ScoreboardConfig.ReplayDurationMS ?? 300
68 let ReplayFPS = ScoreboardConfig.ReplayFPS ?? 24
69
70 let logSize = state.PointsLog.length
71
72 // Figure out the timing so that we can replay the scoreboard in about
73 // ReplayDurationMS.
74 let frameModulo = 1
75 let delay = 0
76 while (delay < (common.Second / ReplayFPS)) {
77 frameModulo += 1
78 delay = ReplayDurationMS / (logSize / frameModulo)
79 }
80
81 let frame = 0
82 for (let scores of state.ScoresHistory()) {
83 frame += 1
84 if (frame < state.PointsLog.length) { // Always render the last frame
85 if (!ReplayHistory || (frame % frameModulo)) { // Skip if we're not animating, or if we need to drop frames
86 continue
87 }
88 }
89
90 while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
91
92 let sortedTeamIDs = [...scores.TeamIDs]
93 sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b))
94 sortedTeamIDs.reverse()
95
96 let topScore = scores.CyFiScore(sortedTeamIDs[0])
97 for (let teamID of sortedTeamIDs) {
98 let teamName = state.TeamNames[teamID] ?? "rodney"
99
100 let row = rankingsElement.appendChild(document.createElement("div"))
101
102 let teamname = row.appendChild(document.createElement("span"))
103 teamname.textContent = teamName
104 teamname.classList.add("teamname")
105
106 let teampoints = row.appendChild(document.createElement("span"))
107 teampoints.classList.add("teampoints")
108 for (let category of scores.Categories) {
109 let score = scores.CyFiCategoryScore(category, teamID)
110 if (!score) {
111 continue
112 }
113
114 // XXX: Figure out how to do this properly with flexbox
115 let block = row.appendChild(document.createElement("span"))
116 let points = scores.GetPoints(category, teamID)
117 let width = MaxScoreWidth * score / topScore
118 let categoryNumber = [...scores.Categories].indexOf(category)
119
120 block.textContent = category
121 block.title = `${points} points`
122 block.style.width = `${width}%`
123 block.classList.add("category", `cat${categoryNumber}`)
124 block.classList.toggle("topscore", (score == 1) && ScoreboardConfig.ShowCategoryLeaders)
125
126 categoryNumber += 1
127 }
128 }
129 await sleep(delay)
130 }
131
132 for (let e of document.querySelectorAll(".no-scores")) {
133 e.innerHTML = ScoreboardConfig.NoScoresHtml
134 e.classList.toggle("hidden", frame > 0)
135 }
136}
137
138/**
139 *
140 * @param {*} rankingsElement
141 * @param {moth.State} state
142 * @param {*} ScoreboardConfig
143 */
144async function categoryRankings(rankingsElement, state, ScoreboardConfig) {
145 while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
146 let scores = state.CurrentScores()
147 for (let category of scores.Categories) {
148 let categoryBox = rankingsElement.appendChild(document.createElement("div"))
149 categoryBox.classList.add("category")
150
151 categoryBox.appendChild(document.createElement("h2")).textContent = category
152
153 let categoryScores = []
154 for (let teamID in state.TeamNames) {
155 categoryScores.push({
156 teamName: state.TeamNames[teamID],
157 score: scores.GetPoints(category, teamID),
158 })
159 }
160 categoryScores.sort((a, b) => b.score - a.score)
161
162 let table = categoryBox.appendChild(document.createElement("table"))
163 let rows = 0
164 for (let categoryScore of categoryScores) {
165 let row = table.appendChild(document.createElement("tr"))
166 row.appendChild(document.createElement("td")).textContent = categoryScore.teamName
167 let td = row.appendChild(document.createElement("td"))
168 td.textContent = categoryScore.score
169 td.classList.add("number")
170 rows += 1
171 if (rows == 5) {
172 break
173 }
174 }
175 }
176}
177
178function init() {
179 setInterval(update, common.Minute)
180 update()
181}
182
183common.WhenDOMLoaded(init)