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