Use document.hidden instead of document.hasFocus()
[xogo.git] / app.js
CommitLineData
41534b92
BA
1let $ = document; //shortcut
2
3///////////////////
4// Initialisations
5
6// https://stackoverflow.com/a/27747377/12660887
7function dec2hex (dec) { return dec.toString(16).padStart(2, "0") }
8function 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
15let dropdown = $.getElementById("selectVariant");
16dropdown[0] = new Option("? ? ?", "_random", true, true);
17dropdown[0].title = "Random variant";
18for (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
26if (!localStorage.getItem("sid"))
27 localStorage.setItem("sid", generateId(8));
28if (!localStorage.getItem("name"))
29 localStorage.setItem("name", "@non" + generateId(4));
30const sid = localStorage.getItem("sid");
31$.getElementById("myName").value = localStorage.getItem("name");
32
86f3c2cd
BA
33// "Material" input field name
34let inputName = document.getElementById("myName");
35let formField = document.getElementById("ng-name");
36const 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};
45inputName.onblur = () => setActive(false);
46inputName.onfocus = () => setActive(true);
47inputName.focus();
48
41534b92
BA
49/////////
50// Utils
51
52function setName() {
53 localStorage.setItem("name", $.getElementById("myName").value);
54}
55
56// Turn a "tab" on, and "close" all others
57function toggleVisible(element) {
86f3c2cd 58 for (elt of document.querySelectorAll('main > div')) {
41534b92
BA
59 if (elt.id != element) elt.style.display = "none";
60 else elt.style.display = "block";
61 }
86f3c2cd
BA
62 if (element.id == "newGame") {
63 // Workaround "superposed texts" effect
64 inputName.focus();
65 inputName.blur();
66 }
41534b92
BA
67}
68
69let seek_vname;
70function seekGame() {
71 seek_vname = $.getElementById("selectVariant").value;
72 send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")});
73 toggleVisible("pendingSeek");
74}
75function cancelSeek() {
76 send("cancelseek", {vname: seek_vname});
77 toggleVisible("newGame");
78}
79
80function sendRematch() {
81 send("rematch", { gid: gid });
82 toggleVisible("pendingRematch");
83}
84function cancelRematch() {
85 send("norematch", { gid: gid });
86 toggleVisible("newGame");
87}
88
89// Play with a friend (or not ^^)
90function 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}
103function backToNormalSeek() { toggleVisible("newGame"); }
104
105function toggleStyle(e, word) {
106 options[word] = !options[word];
107 e.target.classList.toggle("highlight-word");
108}
109
110let options;
111function prepareOptions(Rules) {
112 options = {};
86f3c2cd
BA
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;
41534b92 156 }
86f3c2cd 157 optHtml += "</div>";
41534b92 158 }
41534b92
BA
159 $.getElementById("gameOptions").innerHTML = optHtml;
160}
161
162function getGameLink() {
163 const vname = $.getElementById("selectVariant").value;
164 const color = $.getElementById("selectColor").value;
86f3c2cd 165 for (const select of $.querySelectorAll("#gameOptions select")) {
41534b92
BA
166 let value = select.value;
167 if (select.attributes["data-numeric"]) value = parseInt(value, 10);
168 options[ select.id.split("_")[1] ] = value;
169 }
86f3c2cd 170 for (const check of $.querySelectorAll("#gameOptions input"))
41534b92
BA
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
179const fillGameInfos = (gameInfos, oppIndex) => {
180 fetch(`/variants/${gameInfos.vname}/rules.html`)
181 .then(res => res.text())
182 .then(txt => {
183 let htmlContent = `
86f3c2cd
BA
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 }
41534b92 209 htmlContent += `
86f3c2cd
BA
210 <div class="rules">${txt}</div>
211 <div class="btn-wrap">
212 <button onClick="toggleGameInfos()">Back to game</button>
213 </div>`;
41534b92
BA
214 $.getElementById("gameInfos").innerHTML = htmlContent;
215 });
216};
217
218////////////////
219// Communication
220
221let socket, gid, attempt = 0;
222const autoReconnectDelay = () => {
223 return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(attempt, 6)];
224};
225
226function copyClipboard(msg) { navigator.clipboard.writeText(msg); }
227function getWhatsApp(msg) {
228 return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`;
229}
230
231const 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
251const messageCenter = (msg) => {
252 const obj = JSON.parse(msg.data);
253 switch (obj.code) {
254 // Start new game:
255 case "gamestart": {
016306e3 256 if (document.hidden) notifyMe("game");
41534b92
BA
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":
016306e3 288 if (document.hidden) notifyMe("move");
41534b92
BA
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
309const handleError = (err) => {
310 if (err.code === 'ECONNREFUSED') {
311 removeAllListeners();
312 alert("Server refused connection. Please reload page later");
313 }
314 socket.close();
315};
316
317const handleClose = () => {
318 setTimeout(() => {
319 removeAllListeners();
320 connectToWSS();
321 }, autoReconnectDelay());
322};
323
324const removeAllListeners = () => {
325 socket.removeEventListener("open", tryResumeGame);
326 socket.removeEventListener("message", messageCenter);
327 socket.removeEventListener("error", handleError);
328 socket.removeEventListener("close", handleClose);
329};
330
331const 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};
340connectToWSS();
341
342const send = (code, data) => {
343 socket.send(JSON.stringify(Object.assign({code: code}, data)));
344};
345
346///////////
347// Playing
348
349function 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
355function 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') {
016306e3 363 Notification.requestPermission().then(permission => {
41534b92
BA
364 if (permission === 'granted') doNotify();
365 });
366 }
367}
368
369let curMoves = [];
370const 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 });
21e8e712 377 if (vr.turn != playerColor) {
41534b92
BA
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:
392const 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
21e8e712 403let vr, playerColor;
41534b92
BA
404function 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);
21e8e712 409 playerColor = (sid == obj.players[0].sid ? "w" : "b");
41534b92
BA
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",
21e8e712 425 color: playerColor,
41534b92
BA
426 afterPlay: afterPlay,
427 options: options
428 });
429 if (!obj.fen) {
430 // Game creation
21e8e712 431 if (playerColor == "w") send("setfen", {gid: obj.gid, fen: vr.getFen()});
41534b92
BA
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 }
21e8e712 442 fillGameInfos(obj, playerColor == "w" ? 1 : 0);
41534b92
BA
443 if (obj.randvar) toggleVisible("gameInfos");
444 else toggleVisible("boardContainer");
21e8e712 445 toggleTurnIndicator(vr.turn == playerColor);
41534b92
BA
446 });
447}
448
449function confirmStopGame() {
450 if (confirm("Stop game?")) {
451 send("gameover", { gid: gid, relay: true });
452 localStorage.removeItem("gid");
453 toggleVisible("gameStopped");
454 }
455}
456
457function toggleGameInfos() {
458 if ($.getElementById("gameInfos").style.display == "none")
459 toggleVisible("gameInfos");
21e8e712
BA
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 }
41534b92
BA
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});