Commit | Line | Data |
---|---|---|
41534b92 BA |
1 | let $ = document; //shortcut |
2 | ||
3 | /////////////////// | |
4 | // Initialisations | |
5 | ||
6 | // https://stackoverflow.com/a/27747377/12660887 | |
7 | function dec2hex (dec) { return dec.toString(16).padStart(2, "0") } | |
8 | function generateId (len) { | |
9 | var arr = new Uint8Array((len || 40) / 2) | |
10 | window.crypto.getRandomValues(arr) | |
11 | return Array.from(arr, dec2hex).join('') | |
12 | } | |
13 | ||
14 | // Populate variants dropdown list | |
15 | let dropdown = $.getElementById("selectVariant"); | |
16 | dropdown[0] = new Option("? ? ?", "_random", true, true); | |
17 | dropdown[0].title = "Random variant"; | |
18 | for (let i = 0; i < variants.length; i++) { | |
19 | let newOption = new Option( | |
20 | variants[i].disp || variants[i].name, variants[i].name, false, false); | |
21 | newOption.title = variants[i].desc; | |
22 | dropdown[dropdown.length] = newOption; | |
23 | } | |
24 | ||
25 | // Ensure that I have a socket ID and a name | |
26 | if (!localStorage.getItem("sid")) | |
27 | localStorage.setItem("sid", generateId(8)); | |
28 | if (!localStorage.getItem("name")) | |
29 | localStorage.setItem("name", "@non" + generateId(4)); | |
30 | const sid = localStorage.getItem("sid"); | |
31 | $.getElementById("myName").value = localStorage.getItem("name"); | |
32 | ||
33 | ///////// | |
34 | // Utils | |
35 | ||
36 | function setName() { | |
37 | localStorage.setItem("name", $.getElementById("myName").value); | |
38 | } | |
39 | ||
40 | // Turn a "tab" on, and "close" all others | |
41 | function toggleVisible(element) { | |
42 | for (elt of document.querySelectorAll('body > div')) { | |
43 | if (elt.id != element) elt.style.display = "none"; | |
44 | else elt.style.display = "block"; | |
45 | } | |
46 | } | |
47 | ||
48 | let seek_vname; | |
49 | function seekGame() { | |
50 | seek_vname = $.getElementById("selectVariant").value; | |
51 | send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")}); | |
52 | toggleVisible("pendingSeek"); | |
53 | } | |
54 | function cancelSeek() { | |
55 | send("cancelseek", {vname: seek_vname}); | |
56 | toggleVisible("newGame"); | |
57 | } | |
58 | ||
59 | function sendRematch() { | |
60 | send("rematch", { gid: gid }); | |
61 | toggleVisible("pendingRematch"); | |
62 | } | |
63 | function cancelRematch() { | |
64 | send("norematch", { gid: gid }); | |
65 | toggleVisible("newGame"); | |
66 | } | |
67 | ||
68 | // Play with a friend (or not ^^) | |
69 | function showNewGameForm() { | |
70 | const vname = $.getElementById("selectVariant").value; | |
71 | if (vname == "_random") alert("Select a variant first"); | |
72 | else { | |
73 | $.getElementById("gameLink").innerHTML = ""; | |
74 | $.getElementById("selectColor").selectedIndex = 0; | |
75 | toggleVisible("newGameForm"); | |
76 | import(`/variants/${vname}/class.js`).then(module => { | |
77 | const Rules = module.default; | |
78 | prepareOptions(Rules); | |
79 | }); | |
80 | } | |
81 | } | |
82 | function backToNormalSeek() { toggleVisible("newGame"); } | |
83 | ||
84 | function toggleStyle(e, word) { | |
85 | options[word] = !options[word]; | |
86 | e.target.classList.toggle("highlight-word"); | |
87 | } | |
88 | ||
89 | let options; | |
90 | function prepareOptions(Rules) { | |
91 | options = {}; | |
92 | let optHtml = ""; | |
93 | for (let select of Rules.Options.select) { | |
94 | optHtml += ` | |
95 | <label for="var_${select.variable}">${select.label}</label> | |
96 | <select id="var_${select.variable}" data-numeric="1">`; | |
97 | for (option of select.options) { | |
98 | const defaut = option.value == select.defaut; | |
99 | optHtml += `<option value="${option.value}"`; | |
100 | if (defaut) optHtml += 'selected="true"'; | |
101 | optHtml += `>${option.label}</option>`; | |
102 | } | |
103 | optHtml += '</select>'; | |
104 | } | |
105 | for (let check of Rules.Options.check) { | |
106 | optHtml += ` | |
107 | <label for="var_${check.variable}">${check.label}</label> | |
108 | <input id="var_${check.variable}" | |
109 | type="checkbox"`; | |
110 | if (check.defaut) optHtml += 'checked="true"'; | |
111 | optHtml += '/>'; | |
112 | } | |
c7bf7b1b | 113 | if (Rules.Options.styles.length >= 1) optHtml += '<p class="words">'; |
41534b92 BA |
114 | for (let style of Rules.Options.styles) { |
115 | optHtml += ` | |
116 | <span class="word" onClick="toggleStyle(event, '${style}')"> | |
117 | ${style} | |
118 | </span>`; | |
119 | } | |
120 | if (Rules.Options.styles.length >= 1) optHtml += "</p>"; | |
121 | $.getElementById("gameOptions").innerHTML = optHtml; | |
122 | } | |
123 | ||
124 | function getGameLink() { | |
125 | const vname = $.getElementById("selectVariant").value; | |
126 | const color = $.getElementById("selectColor").value; | |
127 | for (const select of $.querySelectorAll("#gameOptions > select")) { | |
128 | let value = select.value; | |
129 | if (select.attributes["data-numeric"]) value = parseInt(value, 10); | |
130 | options[ select.id.split("_")[1] ] = value; | |
131 | } | |
132 | for (const check of $.querySelectorAll("#gameOptions > input")) | |
133 | options[ check.id.split("_")[1] ] = check.checked; | |
134 | send("creategame", { | |
135 | vname: vname, | |
136 | player: { sid: sid, name: localStorage.getItem("name"), color: color }, | |
137 | options: options | |
138 | }); | |
139 | } | |
140 | ||
141 | const fillGameInfos = (gameInfos, oppIndex) => { | |
142 | fetch(`/variants/${gameInfos.vname}/rules.html`) | |
143 | .then(res => res.text()) | |
144 | .then(txt => { | |
145 | let htmlContent = ` | |
146 | <p> | |
147 | <strong>${gameInfos.vdisp}</strong> | |
148 | <span>vs. ${gameInfos.players[oppIndex].name}</span> | |
149 | </p> | |
150 | <hr> | |
151 | <p>`; | |
152 | htmlContent += | |
153 | Object.entries(gameInfos.options).map(opt => { | |
154 | return ( | |
155 | '<span class="option">' + | |
156 | (opt[1] === true ? opt[0] : `${opt[0]}:${opt[1]}`) + | |
157 | '</span>' | |
158 | ); | |
159 | }) | |
160 | .join(", "); | |
161 | htmlContent += ` | |
162 | </p> | |
163 | <hr> | |
164 | <div class="rules"> | |
165 | ${txt} | |
166 | </div> | |
167 | <button onClick="toggleGameInfos()">Back to game</button>`; | |
168 | $.getElementById("gameInfos").innerHTML = htmlContent; | |
169 | }); | |
170 | }; | |
171 | ||
172 | //////////////// | |
173 | // Communication | |
174 | ||
175 | let socket, gid, attempt = 0; | |
176 | const autoReconnectDelay = () => { | |
177 | return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(attempt, 6)]; | |
178 | }; | |
179 | ||
180 | function copyClipboard(msg) { navigator.clipboard.writeText(msg); } | |
181 | function getWhatsApp(msg) { | |
182 | return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`; | |
183 | } | |
184 | ||
185 | const tryResumeGame = () => { | |
186 | attempt = 0; | |
187 | // If a game is found, resume it: | |
188 | if (localStorage.getItem("gid")) { | |
189 | gid = localStorage.getItem("gid"); | |
190 | send("getgame", { gid: gid }); | |
191 | } | |
192 | else { | |
193 | // If URL indicates "play with a friend", start game: | |
194 | const hashIdx = document.URL.indexOf('#'); | |
195 | if (hashIdx >= 0) { | |
196 | const urlParts = $.URL.split('#'); | |
197 | gid = urlParts[1]; | |
198 | send("joingame", { gid: gid, name: localStorage.getItem("name") }); | |
199 | localStorage.setItem("gid", gid); | |
200 | history.replaceState(null, '', urlParts[0]); | |
201 | } | |
202 | } | |
203 | }; | |
204 | ||
205 | const messageCenter = (msg) => { | |
206 | const obj = JSON.parse(msg.data); | |
207 | switch (obj.code) { | |
208 | // Start new game: | |
209 | case "gamestart": { | |
210 | if (!$.hasFocus()) notifyMe("game"); | |
211 | gid = obj.gid; | |
212 | initializeGame(obj); | |
213 | break; | |
214 | } | |
215 | // Game vs. friend just created on server: share link now | |
216 | case "gamecreated": { | |
217 | const link = `${Params.http_server}/#${obj.gid}`; | |
218 | $.getElementById("gameLink").innerHTML = ` | |
219 | <p> | |
220 | <a href="${getWhatsApp(link)}">WhatsApp</a> | |
221 | / | |
222 | <span onClick='copyClipboard("${link}")'>ToClipboard</span> | |
223 | </p> | |
224 | <p>${link}</p> | |
225 | `; | |
226 | break; | |
227 | } | |
228 | // Game vs. friend joined after 1 minute (try again!) | |
229 | case "jointoolate": | |
230 | alert("Game no longer available"); | |
231 | break; | |
232 | // Get infos of a running game (already launched) | |
233 | case "gameinfo": | |
234 | initializeGame(obj); | |
235 | break; | |
236 | // Tried to resume a game which is now gone: | |
237 | case "nogame": | |
238 | localStorage.removeItem("gid"); | |
239 | break; | |
240 | // Receive opponent's move: | |
241 | case "newmove": | |
242 | if (!$.hasFocus()) notifyMe("move"); | |
243 | vr.playReceivedMove(obj.moves, () => { | |
244 | if (vr.getCurrentScore(obj.moves[obj.moves.length-1]) != "*") { | |
245 | localStorage.removeItem("gid"); | |
246 | setTimeout( () => toggleVisible("gameStopped"), 2000 ); | |
247 | } | |
248 | else toggleTurnIndicator(true); | |
249 | }); | |
250 | break; | |
251 | // Opponent stopped game (draw, abort, resign...) | |
252 | case "gameover": | |
253 | toggleVisible("gameStopped"); | |
254 | localStorage.removeItem("gid"); | |
255 | break; | |
256 | // Opponent cancelled rematch: | |
257 | case "closerematch": | |
258 | toggleVisible("newGame"); | |
259 | break; | |
260 | } | |
261 | }; | |
262 | ||
263 | const handleError = (err) => { | |
264 | if (err.code === 'ECONNREFUSED') { | |
265 | removeAllListeners(); | |
266 | alert("Server refused connection. Please reload page later"); | |
267 | } | |
268 | socket.close(); | |
269 | }; | |
270 | ||
271 | const handleClose = () => { | |
272 | setTimeout(() => { | |
273 | removeAllListeners(); | |
274 | connectToWSS(); | |
275 | }, autoReconnectDelay()); | |
276 | }; | |
277 | ||
278 | const removeAllListeners = () => { | |
279 | socket.removeEventListener("open", tryResumeGame); | |
280 | socket.removeEventListener("message", messageCenter); | |
281 | socket.removeEventListener("error", handleError); | |
282 | socket.removeEventListener("close", handleClose); | |
283 | }; | |
284 | ||
285 | const connectToWSS = () => { | |
286 | socket = | |
287 | new WebSocket(`${Params.socket_server}${Params.socket_path}?sid=${sid}`); | |
288 | socket.addEventListener("open", tryResumeGame); | |
289 | socket.addEventListener("message", messageCenter); | |
290 | socket.addEventListener("error", handleError); | |
291 | socket.addEventListener("close", handleClose); | |
292 | attempt++; | |
293 | }; | |
294 | connectToWSS(); | |
295 | ||
296 | const send = (code, data) => { | |
297 | socket.send(JSON.stringify(Object.assign({code: code}, data))); | |
298 | }; | |
299 | ||
300 | /////////// | |
301 | // Playing | |
302 | ||
303 | function toggleTurnIndicator(myTurn) { | |
304 | let indicator = $.getElementById("chessboard"); | |
305 | if (myTurn) indicator.style.outline = "thick solid green"; | |
306 | else indicator.style.outline = "thick solid lightgrey"; | |
307 | } | |
308 | ||
309 | function notifyMe(code) { | |
310 | const doNotify = () => { | |
311 | // NOTE: empty body (TODO?) | |
312 | new Notification("New " + code, { vibrate: [200, 100, 200] }); | |
313 | new Audio("/assets/new_" + code + ".mp3").play(); | |
314 | } | |
315 | if (Notification.permission === 'granted') doNotify(); | |
316 | else if (Notification.permission !== 'denied') { | |
317 | Notification.requestPermission().then((permission) => { | |
318 | if (permission === 'granted') doNotify(); | |
319 | }); | |
320 | } | |
321 | } | |
322 | ||
323 | let curMoves = []; | |
324 | const afterPlay = (move) => { //pack into one moves array, then send | |
325 | curMoves.push({ | |
326 | appear: move.appear, | |
327 | vanish: move.vanish, | |
328 | start: move.start, | |
329 | end: move.end | |
330 | }); | |
21e8e712 | 331 | if (vr.turn != playerColor) { |
41534b92 BA |
332 | toggleTurnIndicator(false); |
333 | send("newmove", { gid: gid, moves: curMoves, fen: vr.getFen() }); | |
334 | curMoves = []; | |
335 | const result = vr.getCurrentScore(move); | |
336 | if (result != "*") { | |
337 | setTimeout( () => { | |
338 | toggleVisible("gameStopped"); | |
339 | send("gameover", { gid: gid }); | |
340 | }, 2000); | |
341 | } | |
342 | } | |
343 | }; | |
344 | ||
345 | // Avoid loading twice the same stylesheet: | |
346 | const conditionalLoadCSS = (vname) => { | |
347 | const allIds = [].slice.call($.styleSheets).map(s => s.id); | |
348 | const newId = vname + "_css"; | |
349 | if (!allIds.includes(newId)) { | |
350 | $.getElementsByTagName("head")[0].insertAdjacentHTML( | |
351 | "beforeend", | |
352 | `<link id="${newId}" rel="stylesheet" | |
353 | href="/variants/${vname}/style.css"/>`); | |
354 | } | |
355 | }; | |
356 | ||
21e8e712 | 357 | let vr, playerColor; |
41534b92 BA |
358 | function initializeGame(obj) { |
359 | const options = obj.options || {}; | |
360 | import(`/variants/${obj.vname}/class.js`).then(module => { | |
361 | const Rules = module.default; | |
362 | conditionalLoadCSS(obj.vname); | |
21e8e712 | 363 | playerColor = (sid == obj.players[0].sid ? "w" : "b"); |
41534b92 BA |
364 | // Init + remove potential extra DOM elements from a previous game: |
365 | document.getElementById("boardContainer").innerHTML = ` | |
366 | <div id="upLeftInfos" | |
367 | onClick="toggleGameInfos()"> | |
368 | <img src="/assets/icon_infos.svg"/> | |
369 | </div> | |
370 | <div id="upRightStop" | |
371 | onClick="confirmStopGame()"> | |
372 | <img src="/assets/icon_close.svg"/> | |
373 | </div> | |
374 | <div class="resizeable" id="chessboard"></div>`; | |
375 | vr = new Rules({ | |
376 | seed: obj.seed, //may be null if FEN already exists (running game) | |
377 | fen: obj.fen, | |
378 | element: "chessboard", | |
21e8e712 | 379 | color: playerColor, |
41534b92 BA |
380 | afterPlay: afterPlay, |
381 | options: options | |
382 | }); | |
383 | if (!obj.fen) { | |
384 | // Game creation | |
21e8e712 | 385 | if (playerColor == "w") send("setfen", {gid: obj.gid, fen: vr.getFen()}); |
41534b92 BA |
386 | localStorage.setItem("gid", obj.gid); |
387 | } | |
388 | const select = $.getElementById("selectVariant"); | |
389 | obj.vdisp = ""; | |
390 | for (let i=0; i<select.options.length; i++) { | |
391 | if (select.options[i].value == obj.vname) { | |
392 | obj.vdisp = select.options[i].text; | |
393 | break; | |
394 | } | |
395 | } | |
21e8e712 | 396 | fillGameInfos(obj, playerColor == "w" ? 1 : 0); |
41534b92 BA |
397 | if (obj.randvar) toggleVisible("gameInfos"); |
398 | else toggleVisible("boardContainer"); | |
21e8e712 | 399 | toggleTurnIndicator(vr.turn == playerColor); |
41534b92 BA |
400 | }); |
401 | } | |
402 | ||
403 | function confirmStopGame() { | |
404 | if (confirm("Stop game?")) { | |
405 | send("gameover", { gid: gid, relay: true }); | |
406 | localStorage.removeItem("gid"); | |
407 | toggleVisible("gameStopped"); | |
408 | } | |
409 | } | |
410 | ||
411 | function toggleGameInfos() { | |
412 | if ($.getElementById("gameInfos").style.display == "none") | |
413 | toggleVisible("gameInfos"); | |
21e8e712 BA |
414 | else { |
415 | toggleVisible("boardContainer"); | |
416 | // Quickfix for the "vanished piece" bug (move played while on game infos) | |
417 | vr.setupPieces(); //TODO: understand better | |
418 | } | |
41534b92 BA |
419 | } |
420 | ||
421 | $.body.addEventListener("keydown", (e) => { | |
422 | if (!localStorage.getItem("gid")) return; | |
423 | if (e.keyCode == 27) confirmStopGame(); | |
424 | else if (e.keyCode == 32) { | |
425 | e.preventDefault(); | |
426 | toggleGameInfos(); | |
427 | } | |
428 | }); |