Always show game seek button (main purpose of website)
[vchess.git] / public / javascripts / components / game.js
1 Vue.component('my-game', {
2 data: function() {
3 return {
4 vr: null, //object to check moves, store them, FEN..
5 mycolor: "w",
6 possibleMoves: [], //filled after each valid click/dragstart
7 choices: [], //promotion pieces, or checkered captures... (contain possible pieces)
8 start: {}, //pixels coordinates + id of starting square (click or drag)
9 selectedPiece: null, //moving piece (or clicked piece)
10 conn: null, //socket messages
11 score: "*", //'*' means 'unfinished'
12 mode: "idle", //human, friend, computer or idle (when not playing)
13 oppid: "", //opponent ID in case of HH game
14 oppConnected: false,
15 seek: false,
16 fenStart: "",
17 incheck: [],
18 pgnTxt: "",
19 expert: getCookie("expert") === "1" ? true : false,
20 gameId: "", //used to limit computer moves' time
21 };
22 },
23 render(h) {
24 const [sizeX,sizeY] = VariantRules.size;
25 // Precompute hints squares to facilitate rendering
26 let hintSquares = doubleArray(sizeX, sizeY, false);
27 this.possibleMoves.forEach(m => { hintSquares[m.end.x][m.end.y] = true; });
28 // Also precompute in-check squares
29 let incheckSq = doubleArray(sizeX, sizeY, false);
30 this.incheck.forEach(sq => { incheckSq[sq[0]][sq[1]] = true; });
31 let elementArray = [];
32 let actionArray = [];
33 actionArray.push(
34 h('button',
35 {
36 on: { click: this.clickGameSeek },
37 attrs: { "aria-label": 'New online game' },
38 'class': {
39 "tooltip": true,
40 "bottom": true, //display below
41 "seek": this.seek,
42 "playing": this.mode == "human",
43 },
44 },
45 [h('i', { 'class': { "material-icons": true } }, "accessibility")])
46 );
47 if (["idle","computer"].includes(this.mode))
48 {
49 actionArray.push(
50 h('button',
51 {
52 on: { click: this.clickComputerGame },
53 attrs: { "aria-label": 'New game VS computer' },
54 'class': {
55 "tooltip":true,
56 "bottom": true,
57 "playing": this.mode == "computer",
58 },
59 },
60 [h('i', { 'class': { "material-icons": true } }, "computer")])
61 );
62 }
63 if (["idle","friend"].includes(this.mode))
64 {
65 actionArray.push(
66 h('button',
67 {
68 on: { click: this.clickFriendGame },
69 attrs: { "aria-label": 'New IRL game' },
70 'class': {
71 "tooltip":true,
72 "bottom": true,
73 "playing": this.mode == "friend",
74 },
75 },
76 [h('i', { 'class': { "material-icons": true } }, "people")])
77 );
78 }
79 if (!!this.vr)
80 {
81 const square00 = document.getElementById("sq-0-0");
82 const squareWidth = !!square00
83 ? parseFloat(window.getComputedStyle(square00).width.slice(0,-2))
84 : 0;
85 const indicWidth = (squareWidth>0 ? squareWidth/2 : 20);
86 if (this.mode == "human")
87 {
88 let connectedIndic = h(
89 'div',
90 {
91 "class": {
92 "topindicator": true,
93 "indic-left": true,
94 "connected": this.oppConnected,
95 "disconnected": !this.oppConnected,
96 },
97 style: {
98 "width": indicWidth + "px",
99 "height": indicWidth + "px",
100 },
101 }
102 );
103 elementArray.push(connectedIndic);
104 }
105 let turnIndic = h(
106 'div',
107 {
108 "class": {
109 "topindicator": true,
110 "indic-right": true,
111 "white-turn": this.vr.turn=="w",
112 "black-turn": this.vr.turn=="b",
113 },
114 style: {
115 "width": indicWidth + "px",
116 "height": indicWidth + "px",
117 },
118 }
119 );
120 elementArray.push(turnIndic);
121 let expertSwitch = h(
122 'button',
123 {
124 on: { click: this.toggleExpertMode },
125 attrs: { "aria-label": 'Toggle expert mode' },
126 'class': {
127 "tooltip":true,
128 "topindicator": true,
129 "indic-right": true,
130 "expert-switch": true,
131 "expert-mode": this.expert,
132 },
133 },
134 [h('i', { 'class': { "material-icons": true } }, "visibility_off")]
135 );
136 elementArray.push(expertSwitch);
137 let choices = h('div',
138 {
139 attrs: { "id": "choices" },
140 'class': { 'row': true },
141 style: {
142 "display": this.choices.length>0?"block":"none",
143 "top": "-" + ((sizeY/2)*squareWidth+squareWidth/2) + "px",
144 "width": (this.choices.length * squareWidth) + "px",
145 "height": squareWidth + "px",
146 },
147 },
148 this.choices.map( m => { //a "choice" is a move
149 return h('div',
150 {
151 'class': {
152 'board': true,
153 ['board'+sizeY]: true,
154 },
155 style: {
156 'width': (100/this.choices.length) + "%",
157 'padding-bottom': (100/this.choices.length) + "%",
158 },
159 },
160 [h('img',
161 {
162 attrs: { "src": '/images/pieces/' +
163 VariantRules.getPpath(m.appear[0].c+m.appear[0].p) + '.svg' },
164 'class': { 'choice-piece': true },
165 on: { "click": e => { this.play(m); this.choices=[]; } },
166 })
167 ]
168 );
169 })
170 );
171 // Create board element (+ reserves if needed by variant or mode)
172 let gameDiv = h('div',
173 {
174 'class': { 'game': true },
175 },
176 [_.range(sizeX).map(i => {
177 let ci = this.mycolor=='w' ? i : sizeX-i-1;
178 return h(
179 'div',
180 {
181 'class': {
182 'row': true,
183 },
184 style: { 'opacity': this.choices.length>0?"0.5":"1" },
185 },
186 _.range(sizeY).map(j => {
187 let cj = this.mycolor=='w' ? j : sizeY-j-1;
188 let elems = [];
189 if (this.vr.board[ci][cj] != VariantRules.EMPTY)
190 {
191 elems.push(
192 h(
193 'img',
194 {
195 'class': {
196 'piece': true,
197 'ghost': !!this.selectedPiece
198 && this.selectedPiece.parentNode.id == "sq-"+ci+"-"+cj,
199 },
200 attrs: {
201 src: "/images/pieces/" +
202 VariantRules.getPpath(this.vr.board[ci][cj]) + ".svg",
203 },
204 }
205 )
206 );
207 }
208 if (!this.expert && hintSquares[ci][cj])
209 {
210 elems.push(
211 h(
212 'img',
213 {
214 'class': {
215 'mark-square': true,
216 },
217 attrs: {
218 src: "/images/mark.svg",
219 },
220 }
221 )
222 );
223 }
224 const lm = this.vr.lastMove;
225 const showLight = !this.expert &&
226 (this.mode!="idle" || this.cursor==this.vr.moves.length);
227 return h(
228 'div',
229 {
230 'class': {
231 'board': true,
232 ['board'+sizeY]: true,
233 'light-square': (i+j)%2==0,
234 'dark-square': (i+j)%2==1,
235 'highlight': showLight && !!lm && _.isMatch(lm.end, {x:ci,y:cj}),
236 'incheck': showLight && incheckSq[ci][cj],
237 },
238 attrs: {
239 id: this.getSquareId({x:ci,y:cj}),
240 },
241 },
242 elems
243 );
244 })
245 );
246 }), choices]
247 );
248 if (this.mode != "idle")
249 {
250 actionArray.push(
251 h('button',
252 {
253 on: { click: this.resign },
254 attrs: { "aria-label": 'Resign' },
255 'class': {
256 "tooltip":true,
257 "bottom": true,
258 },
259 },
260 [h('i', { 'class': { "material-icons": true } }, "flag")])
261 );
262 }
263 else if (this.vr.moves.length > 0)
264 {
265 // A game finished, and another is not started yet: allow navigation
266 actionArray = actionArray.concat([
267 h('button',
268 {
269 style: { "margin-left": "30px" },
270 on: { click: e => this.undo() },
271 attrs: { "aria-label": 'Undo' },
272 },
273 [h('i', { 'class': { "material-icons": true } }, "fast_rewind")]),
274 h('button',
275 {
276 on: { click: e => this.play() },
277 attrs: { "aria-label": 'Play' },
278 },
279 [h('i', { 'class': { "material-icons": true } }, "fast_forward")]),
280 ]
281 );
282 }
283 if (this.mode == "friend")
284 {
285 actionArray = actionArray.concat(
286 [
287 h('button',
288 {
289 style: { "margin-left": "30px" },
290 on: { click: this.undoInGame },
291 attrs: { "aria-label": 'Undo' },
292 },
293 [h('i', { 'class': { "material-icons": true } }, "undo")]
294 ),
295 h('button',
296 {
297 on: { click: () => { this.mycolor = this.vr.getOppCol(this.mycolor) } },
298 attrs: { "aria-label": 'Flip' },
299 },
300 [h('i', { 'class': { "material-icons": true } }, "cached")]
301 ),
302 ]);
303 }
304 elementArray.push(gameDiv);
305 if (!!this.vr.reserve)
306 {
307 const shiftIdx = (this.mycolor=="w" ? 0 : 1);
308 let myReservePiecesArray = [];
309 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
310 {
311 myReservePiecesArray.push(h('div',
312 {
313 'class': {'board':true, ['board'+sizeY]:true},
314 attrs: { id: this.getSquareId({x:sizeX+shiftIdx,y:i}) }
315 },
316 [
317 h('img',
318 {
319 'class': {"piece":true},
320 attrs: {
321 "src": "/images/pieces/" +
322 this.vr.getReservePpath(this.mycolor,i) + ".svg",
323 }
324 }),
325 h('sup',
326 {style: { "padding-left":"40%"} },
327 [ this.vr.reserve[this.mycolor][VariantRules.RESERVE_PIECES[i]] ]
328 )
329 ]));
330 }
331 let oppReservePiecesArray = [];
332 const oppCol = this.vr.getOppCol(this.mycolor);
333 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
334 {
335 oppReservePiecesArray.push(h('div',
336 {
337 'class': {'board':true, ['board'+sizeY]:true},
338 attrs: { id: this.getSquareId({x:sizeX+(1-shiftIdx),y:i}) }
339 },
340 [
341 h('img',
342 {
343 'class': {"piece":true},
344 attrs: {
345 "src": "/images/pieces/" +
346 this.vr.getReservePpath(oppCol,i) + ".svg",
347 }
348 }),
349 h('sup',
350 {style: { "padding-left":"40%"} },
351 [ this.vr.reserve[oppCol][VariantRules.RESERVE_PIECES[i]] ]
352 )
353 ]));
354 }
355 let reserves = h('div',
356 {
357 'class':{'game':true},
358 style: {"margin-bottom": "20px"},
359 },
360 [
361 h('div',
362 {
363 'class': { 'row': true },
364 style: {"margin-bottom": "15px"},
365 },
366 myReservePiecesArray
367 ),
368 h('div',
369 { 'class': { 'row': true }},
370 oppReservePiecesArray
371 )
372 ]
373 );
374 elementArray.push(reserves);
375 }
376 const eogMessage = this.getEndgameMessage(this.score);
377 const modalEog = [
378 h('input',
379 {
380 attrs: { "id": "modal-eog", type: "checkbox" },
381 "class": { "modal": true },
382 }),
383 h('div',
384 {
385 attrs: { "role": "dialog", "aria-labelledby": "modal-eog" },
386 },
387 [
388 h('div',
389 {
390 "class": { "card": true, "smallpad": true },
391 },
392 [
393 h('label',
394 {
395 attrs: { "for": "modal-eog" },
396 "class": { "modal-close": true },
397 }
398 ),
399 h('h3',
400 {
401 "class": { "section": true },
402 domProps: { innerHTML: eogMessage },
403 }
404 )
405 ]
406 )
407 ]
408 )
409 ];
410 elementArray = elementArray.concat(modalEog);
411 }
412 const modalNewgame = [
413 h('input',
414 {
415 attrs: { "id": "modal-newgame", type: "checkbox" },
416 "class": { "modal": true },
417 }),
418 h('div',
419 {
420 attrs: { "role": "dialog", "aria-labelledby": "modal-newgame" },
421 },
422 [
423 h('div',
424 {
425 "class": { "card": true, "smallpad": true },
426 },
427 [
428 h('label',
429 {
430 attrs: { "id": "close-newgame", "for": "modal-newgame" },
431 "class": { "modal-close": true },
432 }
433 ),
434 h('h3',
435 {
436 "class": { "section": true },
437 domProps: { innerHTML: "New game" },
438 }
439 ),
440 h('p',
441 {
442 "class": { "section": true },
443 domProps: { innerHTML: "Waiting for opponent..." },
444 }
445 )
446 ]
447 )
448 ]
449 )
450 ];
451 elementArray = elementArray.concat(modalNewgame);
452 const modalFenEdit = [
453 h('input',
454 {
455 attrs: { "id": "modal-fenedit", type: "checkbox" },
456 "class": { "modal": true },
457 }),
458 h('div',
459 {
460 attrs: { "role": "dialog", "aria-labelledby": "modal-fenedit" },
461 },
462 [
463 h('div',
464 {
465 "class": { "card": true, "smallpad": true },
466 },
467 [
468 h('label',
469 {
470 attrs: { "id": "close-fenedit", "for": "modal-fenedit" },
471 "class": { "modal-close": true },
472 }
473 ),
474 h('h3',
475 {
476 "class": { "section": true },
477 domProps: { innerHTML: "Position + flags (FEN):" },
478 }
479 ),
480 h('input',
481 {
482 attrs: {
483 "id": "input-fen",
484 type: "text",
485 value: VariantRules.GenRandInitFen(),
486 },
487 }
488 ),
489 h('button',
490 {
491 on: { click:
492 () => {
493 const fen = document.getElementById("input-fen").value;
494 document.getElementById("modal-fenedit").checked = false;
495 this.newGame("friend", fen);
496 }
497 },
498 domProps: { innerHTML: "Ok" },
499 }
500 ),
501 h('button',
502 {
503 on: { click:
504 () => {
505 document.getElementById("input-fen").value =
506 VariantRules.GenRandInitFen();
507 }
508 },
509 domProps: { innerHTML: "Random" },
510 }
511 ),
512 ]
513 )
514 ]
515 )
516 ];
517 elementArray = elementArray.concat(modalFenEdit);
518 const actions = h('div',
519 {
520 attrs: { "id": "actions" },
521 'class': { 'text-center': true },
522 },
523 actionArray
524 );
525 elementArray.push(actions);
526 if (this.score != "*")
527 {
528 elementArray.push(
529 h('div',
530 { attrs: { id: "pgn-div" } },
531 [
532 h('a',
533 {
534 attrs: {
535 id: "download",
536 href: "#",
537 }
538 }
539 ),
540 h('p',
541 {
542 attrs: { id: "pgn-game" },
543 on: { click: this.download },
544 domProps: { innerHTML: this.pgnTxt }
545 }
546 )
547 ]
548 )
549 );
550 }
551 else if (this.mode != "idle")
552 {
553 // Show current FEN
554 elementArray.push(
555 h('div',
556 { attrs: { id: "fen-div" } },
557 [
558 h('p',
559 {
560 attrs: { id: "fen-string" },
561 domProps: { innerHTML: this.vr.getFen() }
562 }
563 )
564 ]
565 )
566 );
567 }
568 return h(
569 'div',
570 {
571 'class': {
572 "col-sm-12":true,
573 "col-md-8":true,
574 "col-md-offset-2":true,
575 "col-lg-6":true,
576 "col-lg-offset-3":true,
577 },
578 // NOTE: click = mousedown + mouseup
579 on: {
580 mousedown: this.mousedown,
581 mousemove: this.mousemove,
582 mouseup: this.mouseup,
583 touchstart: this.mousedown,
584 touchmove: this.mousemove,
585 touchend: this.mouseup,
586 },
587 },
588 elementArray
589 );
590 },
591 created: function() {
592 const url = socketUrl;
593 const continuation = (localStorage.getItem("variant") === variant);
594 this.myid = continuation ? localStorage.getItem("myid") : getRandString();
595 if (!continuation)
596 {
597 // HACK: play a small silent sound to allow "new game" sound later if tab not focused
598 new Audio("/sounds/silent.mp3").play().then(() => {}).catch(err => {});
599 }
600 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
601 const socketOpenListener = () => {
602 if (continuation)
603 {
604 const fen = localStorage.getItem("fen");
605 const mycolor = localStorage.getItem("mycolor");
606 const oppid = localStorage.getItem("oppid");
607 const moves = JSON.parse(localStorage.getItem("moves"));
608 this.newGame("human", fen, mycolor, oppid, moves, true);
609 // Send ping to server (answer pong if opponent is connected)
610 this.conn.send(JSON.stringify({code:"ping",oppid:this.oppid}));
611 }
612 else if (localStorage.getItem("newgame") === variant)
613 {
614 // New game request has been cancelled on disconnect
615 this.newGame("human", undefined, undefined, undefined, undefined, "reconnect");
616 }
617 };
618 const socketMessageListener = msg => {
619 const data = JSON.parse(msg.data);
620 switch (data.code)
621 {
622 case "newgame": //opponent found
623 this.newGame("human", data.fen, data.color, data.oppid); //oppid: opponent socket ID
624 break;
625 case "newmove": //..he played!
626 this.play(data.move, "animate");
627 break;
628 case "pong": //received if we sent a ping (game still alive on our side)
629 this.oppConnected = true;
630 const L = this.vr.moves.length;
631 // Send our "last state" informations to opponent
632 this.conn.send(JSON.stringify({
633 code:"lastate",
634 oppid:this.oppid,
635 lastMove:L>0?this.vr.moves[L-1]:undefined,
636 movesCount:L,
637 }));
638 break;
639 case "lastate": //got opponent infos about last move (we might have resigned)
640 if (this.mode!="human" || this.oppid!=data.oppid)
641 {
642 // OK, we resigned
643 this.conn.send(JSON.stringify({
644 code:"lastate",
645 oppid:this.oppid,
646 lastMove:undefined,
647 movesCount:-1,
648 }));
649 }
650 else if (data.movesCount < 0)
651 {
652 // OK, he resigned
653 this.endGame(this.mycolor=="w"?"1-0":"0-1");
654 }
655 else if (data.movesCount < this.vr.moves.length)
656 {
657 // We must tell last move to opponent
658 const L = this.vr.moves.length;
659 this.conn.send(JSON.stringify({
660 code:"lastate",
661 oppid:this.oppid,
662 lastMove:this.vr.moves[L-1],
663 movesCount:L,
664 }));
665 }
666 else if (data.movesCount > this.vr.moves.length) //just got last move from him
667 this.play(data.lastMove, "animate");
668 break;
669 case "resign": //..you won!
670 this.endGame(this.mycolor=="w"?"1-0":"0-1");
671 break;
672 // TODO: also use (dis)connect info to count online players?
673 case "connect":
674 case "disconnect":
675 if (this.mode == "human" && this.oppid == data.id)
676 this.oppConnected = (data.code == "connect");
677 break;
678 }
679 };
680 const socketCloseListener = () => {
681 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
682 this.conn.addEventListener('open', socketOpenListener);
683 this.conn.addEventListener('message', socketMessageListener);
684 this.conn.addEventListener('close', socketCloseListener);
685 };
686 this.conn.onopen = socketOpenListener;
687 this.conn.onmessage = socketMessageListener;
688 this.conn.onclose = socketCloseListener;
689 // Listen to keyboard left/right to navigate in game
690 document.onkeydown = event => {
691 if (this.mode == "idle" && !!this.vr && this.vr.moves.length > 0
692 && [37,39].includes(event.keyCode))
693 {
694 event.preventDefault();
695 if (event.keyCode == 37) //Back
696 this.undo();
697 else //Forward (39)
698 this.play();
699 }
700 };
701 },
702 methods: {
703 download: function() {
704 let content = document.getElementById("pgn-game").innerHTML;
705 content = content.replace(/<br>/g, "\n");
706 // Prepare and trigger download link
707 let downloadAnchor = document.getElementById("download");
708 downloadAnchor.setAttribute("download", "game.pgn");
709 downloadAnchor.href = "data:text/plain;charset=utf-8," + encodeURIComponent(content);
710 downloadAnchor.click();
711 },
712 endGame: function(score) {
713 this.score = score;
714 let modalBox = document.getElementById("modal-eog");
715 modalBox.checked = true;
716 // Variants may have special PGN structure (so next function isn't defined here)
717 this.pgnTxt = this.vr.getPGN(this.mycolor, this.score, this.fenStart, this.mode);
718 setTimeout(() => { modalBox.checked = false; }, 2000);
719 if (this.mode == "human")
720 this.clearStorage();
721 this.mode = "idle";
722 this.cursor = this.vr.moves.length; //to navigate in finished game
723 this.oppid = "";
724 },
725 getEndgameMessage: function(score) {
726 let eogMessage = "Unfinished";
727 switch (this.score)
728 {
729 case "1-0":
730 eogMessage = "White win";
731 break;
732 case "0-1":
733 eogMessage = "Black win";
734 break;
735 case "1/2":
736 eogMessage = "Draw";
737 break;
738 }
739 return eogMessage;
740 },
741 setStorage: function() {
742 localStorage.setItem("myid", this.myid);
743 localStorage.setItem("variant", variant);
744 localStorage.setItem("mycolor", this.mycolor);
745 localStorage.setItem("oppid", this.oppid);
746 localStorage.setItem("fenStart", this.fenStart);
747 localStorage.setItem("moves", JSON.stringify(this.vr.moves));
748 localStorage.setItem("fen", this.vr.getFen());
749 },
750 updateStorage: function() {
751 localStorage.setItem("moves", JSON.stringify(this.vr.moves));
752 localStorage.setItem("fen", this.vr.getFen());
753 },
754 clearStorage: function() {
755 delete localStorage["variant"];
756 delete localStorage["myid"];
757 delete localStorage["mycolor"];
758 delete localStorage["oppid"];
759 delete localStorage["fenStart"];
760 delete localStorage["fen"];
761 delete localStorage["moves"];
762 },
763 // HACK because mini-css tooltips are persistent after click...
764 getRidOfTooltip: function(elt) {
765 elt.style.visibility = "hidden";
766 setTimeout(() => { elt.style.visibility="visible"; }, 100);
767 },
768 clickGameSeek: function(e) {
769 this.getRidOfTooltip(e.currentTarget);
770 if (this.mode == "human")
771 return; //no newgame while playing
772 if (this.seek)
773 {
774 this.conn.send(JSON.stringify({code:"cancelnewgame"}));
775 delete localStorage["newgame"]; //cancel game seek
776 this.seek = false;
777 }
778 else
779 this.newGame("human");
780 },
781 clickComputerGame: function(e) {
782 this.getRidOfTooltip(e.currentTarget);
783 if (this.mode == "human")
784 return; //no newgame while playing
785 this.newGame("computer");
786 },
787 clickFriendGame: function(e) {
788 this.getRidOfTooltip(e.currentTarget);
789 document.getElementById("modal-fenedit").checked = true;
790 },
791 toggleExpertMode: function(e) {
792 this.getRidOfTooltip(e.currentTarget);
793 this.expert = !this.expert;
794 setCookie("expert", this.expert ? "1" : "0");
795 },
796 resign: function(e) {
797 this.getRidOfTooltip(e.currentTarget);
798 if (this.mode == "human" && this.oppConnected)
799 {
800 try {
801 this.conn.send(JSON.stringify({code: "resign", oppid: this.oppid}));
802 } catch (INVALID_STATE_ERR) {
803 return; //socket is not ready (and not yet reconnected)
804 }
805 }
806 this.endGame(this.mycolor=="w"?"0-1":"1-0");
807 },
808 newGame: function(mode, fenInit, color, oppId, moves, continuation) {
809 const fen = fenInit || VariantRules.GenRandInitFen();
810 console.log(fen); //DEBUG
811 if (mode=="human" && !oppId)
812 {
813 const storageVariant = localStorage.getItem("variant");
814 if (!!storageVariant && storageVariant !== variant)
815 {
816 alert("Finish your " + storageVariant + " game first!");
817 return;
818 }
819 // Send game request and wait..
820 localStorage["newgame"] = variant;
821 this.seek = true;
822 this.clearStorage(); //in case of
823 try {
824 this.conn.send(JSON.stringify({code:"newgame", fen:fen}));
825 } catch (INVALID_STATE_ERR) {
826 return; //nothing achieved
827 }
828 if (continuation !== "reconnect") //TODO: bad HACK...
829 {
830 let modalBox = document.getElementById("modal-newgame");
831 modalBox.checked = true;
832 setTimeout(() => { modalBox.checked = false; }, 2000);
833 }
834 return;
835 }
836 this.gameId = getRandString();
837 this.vr = new VariantRules(fen, moves || []);
838 this.score = "*";
839 this.pgnTxt = ""; //redundant with this.score = "*", but cleaner
840 this.mode = mode;
841 this.incheck = []; //in case of
842 this.fenStart = (continuation ? localStorage.getItem("fenStart") : fen);
843 if (mode=="human")
844 {
845 // Opponent found!
846 if (!continuation)
847 {
848 // Not playing sound on game continuation:
849 new Audio("/sounds/newgame.mp3").play().then(() => {}).catch(err => {});
850 document.getElementById("modal-newgame").checked = false;
851 }
852 this.oppid = oppId;
853 this.oppConnected = true;
854 this.mycolor = color;
855 this.seek = false;
856 if (!!moves && moves.length > 0) //imply continuation
857 {
858 const lastMove = moves[moves.length-1];
859 this.vr.undo(lastMove);
860 this.incheck = this.vr.getCheckSquares(lastMove);
861 this.vr.play(lastMove, "ingame");
862 }
863 delete localStorage["newgame"];
864 this.setStorage(); //in case of interruptions
865 }
866 else if (mode == "computer")
867 {
868 this.mycolor = Math.random() < 0.5 ? 'w' : 'b';
869 if (this.mycolor == 'b')
870 setTimeout(this.playComputerMove, 500);
871 }
872 //else: against a (IRL) friend: nothing more to do
873 },
874 playComputerMove: function() {
875 const timeStart = Date.now();
876 const nbMoves = this.vr.moves.length; //using played moves to know if search finished
877 const gameId = this.gameId; //to know if game was reset before timer end
878 setTimeout(
879 () => {
880 if (gameId != this.gameId)
881 return; //game stopped
882 const L = this.vr.moves.length;
883 if (nbMoves == L || !this.vr.moves[L-1].notation) //move search didn't finish
884 this.vr.shouldReturn = true;
885 }, 5000);
886 const compMove = this.vr.getComputerMove();
887 // (first move) HACK: avoid selecting elements before they appear on page:
888 const delay = Math.max(500-(Date.now()-timeStart), 0);
889 setTimeout(() => this.play(compMove, "animate"), delay);
890 },
891 // Get the identifier of a HTML table cell from its numeric coordinates o.x,o.y.
892 getSquareId: function(o) {
893 // NOTE: a separator is required to allow any size of board
894 return "sq-" + o.x + "-" + o.y;
895 },
896 // Inverse function
897 getSquareFromId: function(id) {
898 let idParts = id.split('-');
899 return [parseInt(idParts[1]), parseInt(idParts[2])];
900 },
901 mousedown: function(e) {
902 e = e || window.event;
903 let ingame = false;
904 let elem = e.target;
905 while (!ingame && elem !== null)
906 {
907 if (elem.classList.contains("game"))
908 {
909 ingame = true;
910 break;
911 }
912 elem = elem.parentElement;
913 }
914 if (!ingame) //let default behavior (click on button...)
915 return;
916 e.preventDefault(); //disable native drag & drop
917 if (!this.selectedPiece && e.target.classList.contains("piece"))
918 {
919 // Next few lines to center the piece on mouse cursor
920 let rect = e.target.parentNode.getBoundingClientRect();
921 this.start = {
922 x: rect.x + rect.width/2,
923 y: rect.y + rect.width/2,
924 id: e.target.parentNode.id
925 };
926 this.selectedPiece = e.target.cloneNode();
927 this.selectedPiece.style.position = "absolute";
928 this.selectedPiece.style.top = 0;
929 this.selectedPiece.style.display = "inline-block";
930 this.selectedPiece.style.zIndex = 3000;
931 let startSquare = this.getSquareFromId(e.target.parentNode.id);
932 const iCanPlay = this.mode!="idle"
933 && (this.mode=="friend" || this.vr.canIplay(this.mycolor,startSquare));
934 this.possibleMoves = iCanPlay ? this.vr.getPossibleMovesFrom(startSquare) : [];
935 // Next line add moving piece just after current image (required for Crazyhouse reserve)
936 e.target.parentNode.insertBefore(this.selectedPiece, e.target.nextSibling);
937 }
938 },
939 mousemove: function(e) {
940 if (!this.selectedPiece)
941 return;
942 e = e || window.event;
943 // If there is an active element, move it around
944 if (!!this.selectedPiece)
945 {
946 const [offsetX,offsetY] = !!e.clientX
947 ? [e.clientX,e.clientY] //desktop browser
948 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY]; //smartphone
949 this.selectedPiece.style.left = (offsetX-this.start.x) + "px";
950 this.selectedPiece.style.top = (offsetY-this.start.y) + "px";
951 }
952 },
953 mouseup: function(e) {
954 if (!this.selectedPiece)
955 return;
956 e = e || window.event;
957 // Read drop target (or parentElement, parentNode... if type == "img")
958 this.selectedPiece.style.zIndex = -3000; //HACK to find square from final coordinates
959 const [offsetX,offsetY] = !!e.clientX
960 ? [e.clientX,e.clientY]
961 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY];
962 let landing = document.elementFromPoint(offsetX, offsetY);
963 this.selectedPiece.style.zIndex = 3000;
964 while (landing.tagName == "IMG") //classList.contains(piece) fails because of mark/highlight
965 landing = landing.parentNode;
966 if (this.start.id == landing.id) //a click: selectedPiece and possibleMoves already filled
967 return;
968 // OK: process move attempt
969 let endSquare = this.getSquareFromId(landing.id);
970 let moves = this.findMatchingMoves(endSquare);
971 this.possibleMoves = [];
972 if (moves.length > 1)
973 this.choices = moves;
974 else if (moves.length==1)
975 this.play(moves[0]);
976 // Else: impossible move
977 this.selectedPiece.parentNode.removeChild(this.selectedPiece);
978 delete this.selectedPiece;
979 this.selectedPiece = null;
980 },
981 findMatchingMoves: function(endSquare) {
982 // Run through moves list and return the matching set (if promotions...)
983 let moves = [];
984 this.possibleMoves.forEach(function(m) {
985 if (endSquare[0] == m.end.x && endSquare[1] == m.end.y)
986 moves.push(m);
987 });
988 return moves;
989 },
990 animateMove: function(move) {
991 let startSquare = document.getElementById(this.getSquareId(move.start));
992 let endSquare = document.getElementById(this.getSquareId(move.end));
993 let rectStart = startSquare.getBoundingClientRect();
994 let rectEnd = endSquare.getBoundingClientRect();
995 let translation = {x:rectEnd.x-rectStart.x, y:rectEnd.y-rectStart.y};
996 let movingPiece =
997 document.querySelector("#" + this.getSquareId(move.start) + " > img.piece");
998 // HACK for animation (with positive translate, image slides "under background"...)
999 // Possible improvement: just alter squares on the piece's way...
1000 squares = document.getElementsByClassName("board");
1001 for (let i=0; i<squares.length; i++)
1002 {
1003 let square = squares.item(i);
1004 if (square.id != this.getSquareId(move.start))
1005 square.style.zIndex = "-1";
1006 }
1007 movingPiece.style.transform = "translate(" + translation.x + "px," + translation.y + "px)";
1008 movingPiece.style.transitionDuration = "0.2s";
1009 movingPiece.style.zIndex = "3000";
1010 setTimeout( () => {
1011 for (let i=0; i<squares.length; i++)
1012 squares.item(i).style.zIndex = "auto";
1013 movingPiece.style = {}; //required e.g. for 0-0 with KR swap
1014 this.play(move);
1015 }, 200);
1016 },
1017 play: function(move, programmatic) {
1018 if (!move)
1019 {
1020 // Navigate after game is over
1021 if (this.cursor >= this.vr.moves.length)
1022 return; //already at the end
1023 move = this.vr.moves[this.cursor++];
1024 }
1025 if (!!programmatic) //computer or human opponent
1026 {
1027 this.animateMove(move);
1028 return;
1029 }
1030 // Not programmatic, or animation is over
1031 if (this.mode == "human" && this.vr.turn == this.mycolor)
1032 this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
1033 new Audio("/sounds/chessmove1.mp3").play().then(() => {}).catch(err => {});
1034 if (this.mode != "idle")
1035 {
1036 this.incheck = this.vr.getCheckSquares(move); //is opponent in check?
1037 this.vr.play(move, "ingame");
1038 }
1039 else
1040 {
1041 VariantRules.PlayOnBoard(this.vr.board, move);
1042 this.$forceUpdate(); //TODO: ?!
1043 }
1044 if (this.mode == "human")
1045 this.updateStorage(); //after our moves and opponent moves
1046 if (this.mode != "idle")
1047 {
1048 const eog = this.vr.checkGameOver();
1049 if (eog != "*")
1050 this.endGame(eog);
1051 }
1052 if (this.mode == "computer" && this.vr.turn != this.mycolor)
1053 setTimeout(this.playComputerMove, 500);
1054 },
1055 undo: function() {
1056 // Navigate after game is over
1057 if (this.cursor == 0)
1058 return; //already at the beginning
1059 if (this.cursor == this.vr.moves.length)
1060 this.incheck = []; //in case of...
1061 const move = this.vr.moves[--this.cursor];
1062 VariantRules.UndoOnBoard(this.vr.board, move);
1063 this.$forceUpdate(); //TODO: ?!
1064 },
1065 undoInGame: function() {
1066 const lm = this.vr.lastMove;
1067 if (!!lm)
1068 this.vr.undo(lm);
1069 },
1070 },
1071 })