First commit
[xogo.git] / app.js
... / ...
CommitLineData
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
33/////////
34// Utils
35
36function setName() {
37 localStorage.setItem("name", $.getElementById("myName").value);
38}
39
40// Turn a "tab" on, and "close" all others
41function 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
48let seek_vname;
49function seekGame() {
50 seek_vname = $.getElementById("selectVariant").value;
51 send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")});
52 toggleVisible("pendingSeek");
53}
54function cancelSeek() {
55 send("cancelseek", {vname: seek_vname});
56 toggleVisible("newGame");
57}
58
59function sendRematch() {
60 send("rematch", { gid: gid });
61 toggleVisible("pendingRematch");
62}
63function cancelRematch() {
64 send("norematch", { gid: gid });
65 toggleVisible("newGame");
66}
67
68// Play with a friend (or not ^^)
69function 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}
82function backToNormalSeek() { toggleVisible("newGame"); }
83
84function toggleStyle(e, word) {
85 options[word] = !options[word];
86 e.target.classList.toggle("highlight-word");
87}
88
89let options;
90function 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 }
113 if (Rules.Options.styles.length >= 1) optHtml += "<p>";
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
124function 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
141const 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
175let socket, gid, attempt = 0;
176const autoReconnectDelay = () => {
177 return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(attempt, 6)];
178};
179
180function copyClipboard(msg) { navigator.clipboard.writeText(msg); }
181function getWhatsApp(msg) {
182 return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`;
183}
184
185const 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
205const 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
263const handleError = (err) => {
264 if (err.code === 'ECONNREFUSED') {
265 removeAllListeners();
266 alert("Server refused connection. Please reload page later");
267 }
268 socket.close();
269};
270
271const handleClose = () => {
272 setTimeout(() => {
273 removeAllListeners();
274 connectToWSS();
275 }, autoReconnectDelay());
276};
277
278const removeAllListeners = () => {
279 socket.removeEventListener("open", tryResumeGame);
280 socket.removeEventListener("message", messageCenter);
281 socket.removeEventListener("error", handleError);
282 socket.removeEventListener("close", handleClose);
283};
284
285const 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};
294connectToWSS();
295
296const send = (code, data) => {
297 socket.send(JSON.stringify(Object.assign({code: code}, data)));
298};
299
300///////////
301// Playing
302
303function 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
309function 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
323let curMoves = [];
324const 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 });
331 if (vr.turn != color) {
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:
346const 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
357let vr, color;
358function 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);
363 color = (sid == obj.players[0].sid ? "w" : "b");
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",
379 color: color,
380 afterPlay: afterPlay,
381 options: options
382 });
383 if (!obj.fen) {
384 // Game creation
385 if (color == "w") send("setfen", {gid: obj.gid, fen: vr.getFen()});
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 }
396 fillGameInfos(obj, color == "w" ? 1 : 0);
397 if (obj.randvar) toggleVisible("gameInfos");
398 else toggleVisible("boardContainer");
399 toggleTurnIndicator(vr.turn == color);
400 });
401}
402
403function confirmStopGame() {
404 if (confirm("Stop game?")) {
405 send("gameover", { gid: gid, relay: true });
406 localStorage.removeItem("gid");
407 toggleVisible("gameStopped");
408 }
409}
410
411function toggleGameInfos() {
412 if ($.getElementById("gameInfos").style.display == "none")
413 toggleVisible("gameInfos");
414 else toggleVisible("boardContainer");
415}
416
417$.body.addEventListener("keydown", (e) => {
418 if (!localStorage.getItem("gid")) return;
419 if (e.keyCode == 27) confirmStopGame();
420 else if (e.keyCode == 32) {
421 e.preventDefault();
422 toggleGameInfos();
423 }
424});