Allow resuming computer games, attempt to fix 'ghost move bug'
[vchess.git] / public / javascripts / components / game.js
1 // Game logic on a variant page
2 Vue.component('my-game', {
3 props: ["problem"],
4 data: function() {
5 return {
6 vr: null, //object to check moves, store them, FEN..
7 mycolor: "w",
8 possibleMoves: [], //filled after each valid click/dragstart
9 choices: [], //promotion pieces, or checkered captures... (as moves)
10 start: {}, //pixels coordinates + id of starting square (click or drag)
11 selectedPiece: null, //moving piece (or clicked piece)
12 conn: null, //socket connection
13 score: "*", //'*' means 'unfinished'
14 mode: "idle", //human, chat, friend, problem, computer or idle (if not playing)
15 myid: "", //our ID, always set
16 oppid: "", //opponent ID in case of HH game
17 myname: getCookie("username","anonymous"),
18 oppName: "anonymous", //opponent name, revealed after a game (if provided)
19 chats: [], //chat messages after human game
20 oppConnected: false,
21 seek: false,
22 fenStart: "",
23 incheck: [],
24 pgnTxt: "",
25 hints: (getCookie("hints") === "1" ? true : false),
26 color: getCookie("color", "lichess"), //lichess, chesscom or chesstempo
27 // sound level: 0 = no sound, 1 = sound only on newgame, 2 = always
28 sound: parseInt(getCookie("sound", "2")),
29 // Web worker to play computer moves without freezing interface:
30 compWorker: new Worker('/javascripts/playCompMove.js'),
31 timeStart: undefined, //time when computer starts thinking
32 };
33 },
34 watch: {
35 problem: function(p) {
36 // 'problem' prop changed: update board state
37 this.newGame("problem", p.fen, V.ParseFen(p.fen).turn);
38 },
39 },
40 render(h) {
41 const [sizeX,sizeY] = [V.size.x,V.size.y];
42 const smallScreen = (window.innerWidth <= 420);
43 // Precompute hints squares to facilitate rendering
44 let hintSquares = doubleArray(sizeX, sizeY, false);
45 this.possibleMoves.forEach(m => { hintSquares[m.end.x][m.end.y] = true; });
46 // Also precompute in-check squares
47 let incheckSq = doubleArray(sizeX, sizeY, false);
48 this.incheck.forEach(sq => { incheckSq[sq[0]][sq[1]] = true; });
49 let elementArray = [];
50 let actionArray = [];
51 actionArray.push(
52 h('button',
53 {
54 on: { click: this.clickGameSeek },
55 attrs: { "aria-label": 'New online game' },
56 'class': {
57 "tooltip": true,
58 "bottom": true, //display below
59 "seek": this.seek,
60 "playing": this.mode == "human",
61 "small": smallScreen,
62 },
63 },
64 [h('i', { 'class': { "material-icons": true } }, "accessibility")])
65 );
66 if (["idle","chat","computer"].includes(this.mode))
67 {
68 actionArray.push(
69 h('button',
70 {
71 on: { click: this.clickComputerGame },
72 attrs: { "aria-label": 'New game VS computer' },
73 'class': {
74 "tooltip":true,
75 "bottom": true,
76 "playing": this.mode == "computer",
77 "small": smallScreen,
78 },
79 },
80 [h('i', { 'class': { "material-icons": true } }, "computer")])
81 );
82 }
83 if (["idle","chat","friend"].includes(this.mode))
84 {
85 actionArray.push(
86 h('button',
87 {
88 on: { click: this.clickFriendGame },
89 attrs: { "aria-label": 'New IRL game' },
90 'class': {
91 "tooltip":true,
92 "bottom": true,
93 "playing": this.mode == "friend",
94 "small": smallScreen,
95 },
96 },
97 [h('i', { 'class': { "material-icons": true } }, "people")])
98 );
99 }
100 if (!!this.vr)
101 {
102 const square00 = document.getElementById("sq-0-0");
103 const squareWidth = !!square00
104 ? parseFloat(window.getComputedStyle(square00).width.slice(0,-2))
105 : 0;
106 const settingsBtnElt = document.getElementById("settingsBtn");
107 const indicWidth = !!settingsBtnElt //-2 for border:
108 ? parseFloat(window.getComputedStyle(settingsBtnElt).height.slice(0,-2)) - 2
109 : (smallScreen ? 31 : 37);
110 if (this.mode == "human")
111 {
112 const connectedIndic = h(
113 'div',
114 {
115 "class": {
116 "topindicator": true,
117 "indic-left": true,
118 "connected": this.oppConnected,
119 "disconnected": !this.oppConnected,
120 },
121 style: {
122 "width": indicWidth + "px",
123 "height": indicWidth + "px",
124 },
125 }
126 );
127 elementArray.push(connectedIndic);
128 }
129 else if (this.mode == "chat")
130 {
131 // Also show connection indication, but also nickname
132 const nicknameOpponent = h(
133 'div',
134 {
135 "class": {
136 "clickable": true,
137 "topindicator": true,
138 "indic-left": true,
139 "name-connected": this.oppConnected,
140 "name-disconnected": !this.oppConnected,
141 },
142 domProps: {
143 innerHTML: this.oppName,
144 },
145 on: {
146 "click": () => { document.getElementById("modal-chat").checked = true; },
147 },
148 }
149 );
150 elementArray.push(nicknameOpponent);
151 }
152 const turnIndic = h(
153 'div',
154 {
155 "class": {
156 "topindicator": true,
157 "indic-right": true,
158 "white-turn": this.vr.turn=="w",
159 "black-turn": this.vr.turn=="b",
160 },
161 style: {
162 "width": indicWidth + "px",
163 "height": indicWidth + "px",
164 },
165 }
166 );
167 elementArray.push(turnIndic);
168 const settingsBtn = h(
169 'button',
170 {
171 on: { click: this.showSettings },
172 attrs: {
173 "aria-label": 'Settings',
174 "id": "settingsBtn",
175 },
176 'class': {
177 "tooltip": true,
178 "topindicator": true,
179 "indic-right": true,
180 "settings-btn": !smallScreen,
181 "settings-btn-small": smallScreen,
182 },
183 },
184 [h('i', { 'class': { "material-icons": true } }, "settings")]
185 );
186 elementArray.push(settingsBtn);
187 if (this.mode == "problem")
188 {
189 // Show problem instructions
190 elementArray.push(
191 h('div',
192 {
193 attrs: { id: "instructions-div" },
194 "class": {
195 "clearer": true,
196 "section-content": true,
197 },
198 },
199 [
200 h('p',
201 {
202 attrs: { id: "problem-instructions" },
203 domProps: { innerHTML: this.problem.instructions }
204 }
205 )
206 ]
207 )
208 );
209 }
210 const choices = h('div',
211 {
212 attrs: { "id": "choices" },
213 'class': { 'row': true },
214 style: {
215 "display": this.choices.length>0?"block":"none",
216 "top": "-" + ((sizeY/2)*squareWidth+squareWidth/2) + "px",
217 "width": (this.choices.length * squareWidth) + "px",
218 "height": squareWidth + "px",
219 },
220 },
221 this.choices.map( m => { //a "choice" is a move
222 return h('div',
223 {
224 'class': {
225 'board': true,
226 ['board'+sizeY]: true,
227 },
228 style: {
229 'width': (100/this.choices.length) + "%",
230 'padding-bottom': (100/this.choices.length) + "%",
231 },
232 },
233 [h('img',
234 {
235 attrs: { "src": '/images/pieces/' +
236 VariantRules.getPpath(m.appear[0].c+m.appear[0].p) + '.svg' },
237 'class': { 'choice-piece': true },
238 on: { "click": e => { this.play(m); this.choices=[]; } },
239 })
240 ]
241 );
242 })
243 );
244 // Create board element (+ reserves if needed by variant or mode)
245 const lm = this.vr.lastMove;
246 const showLight = this.hints &&
247 (!["idle","chat"].includes(this.mode) || this.cursor==this.vr.moves.length);
248 const gameDiv = h('div',
249 {
250 'class': { 'game': true },
251 },
252 [_.range(sizeX).map(i => {
253 let ci = (this.mycolor=='w' ? i : sizeX-i-1);
254 return h(
255 'div',
256 {
257 'class': {
258 'row': true,
259 },
260 style: { 'opacity': this.choices.length>0?"0.5":"1" },
261 },
262 _.range(sizeY).map(j => {
263 let cj = (this.mycolor=='w' ? j : sizeY-j-1);
264 let elems = [];
265 if (this.vr.board[ci][cj] != VariantRules.EMPTY)
266 {
267 elems.push(
268 h(
269 'img',
270 {
271 'class': {
272 'piece': true,
273 'ghost': !!this.selectedPiece
274 && this.selectedPiece.parentNode.id == "sq-"+ci+"-"+cj,
275 },
276 attrs: {
277 src: "/images/pieces/" +
278 VariantRules.getPpath(this.vr.board[ci][cj]) + ".svg",
279 },
280 }
281 )
282 );
283 }
284 if (this.hints && hintSquares[ci][cj])
285 {
286 elems.push(
287 h(
288 'img',
289 {
290 'class': {
291 'mark-square': true,
292 },
293 attrs: {
294 src: "/images/mark.svg",
295 },
296 }
297 )
298 );
299 }
300 return h(
301 'div',
302 {
303 'class': {
304 'board': true,
305 ['board'+sizeY]: true,
306 'light-square': (i+j)%2==0,
307 'dark-square': (i+j)%2==1,
308 [this.color]: true,
309 'highlight': showLight && !!lm && _.isMatch(lm.end, {x:ci,y:cj}),
310 'incheck': showLight && incheckSq[ci][cj],
311 },
312 attrs: {
313 id: this.getSquareId({x:ci,y:cj}),
314 },
315 },
316 elems
317 );
318 })
319 );
320 }), choices]
321 );
322 if (!["idle","chat"].includes(this.mode))
323 {
324 actionArray.push(
325 h('button',
326 {
327 on: { click: this.resign },
328 attrs: { "aria-label": 'Resign' },
329 'class': {
330 "tooltip":true,
331 "bottom": true,
332 "small": smallScreen,
333 },
334 },
335 [h('i', { 'class': { "material-icons": true } }, "flag")])
336 );
337 }
338 else if (this.vr.moves.length > 0)
339 {
340 // A game finished, and another is not started yet: allow navigation
341 actionArray = actionArray.concat([
342 h('button',
343 {
344 on: { click: e => this.undo() },
345 attrs: { "aria-label": 'Undo' },
346 "class": {
347 "small": smallScreen,
348 "marginleft": true,
349 },
350 },
351 [h('i', { 'class': { "material-icons": true } }, "fast_rewind")]),
352 h('button',
353 {
354 on: { click: e => this.play() },
355 attrs: { "aria-label": 'Play' },
356 "class": { "small": smallScreen },
357 },
358 [h('i', { 'class': { "material-icons": true } }, "fast_forward")]),
359 ]
360 );
361 }
362 if (["friend","problem"].includes(this.mode))
363 {
364 actionArray = actionArray.concat(
365 [
366 h('button',
367 {
368 on: { click: this.undoInGame },
369 attrs: { "aria-label": 'Undo' },
370 "class": {
371 "small": smallScreen,
372 "marginleft": true,
373 },
374 },
375 [h('i', { 'class': { "material-icons": true } }, "undo")]
376 ),
377 h('button',
378 {
379 on: { click: () => { this.mycolor = this.vr.getOppCol(this.mycolor) } },
380 attrs: { "aria-label": 'Flip' },
381 "class": { "small": smallScreen },
382 },
383 [h('i', { 'class': { "material-icons": true } }, "cached")]
384 ),
385 ]);
386 }
387 elementArray.push(gameDiv);
388 if (!!this.vr.reserve)
389 {
390 const shiftIdx = (this.mycolor=="w" ? 0 : 1);
391 let myReservePiecesArray = [];
392 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
393 {
394 myReservePiecesArray.push(h('div',
395 {
396 'class': {'board':true, ['board'+sizeY]:true},
397 attrs: { id: this.getSquareId({x:sizeX+shiftIdx,y:i}) }
398 },
399 [
400 h('img',
401 {
402 'class': {"piece":true},
403 attrs: {
404 "src": "/images/pieces/" +
405 this.vr.getReservePpath(this.mycolor,i) + ".svg",
406 }
407 }),
408 h('sup',
409 {"class": { "reserve-count": true } },
410 [ this.vr.reserve[this.mycolor][VariantRules.RESERVE_PIECES[i]] ]
411 )
412 ]));
413 }
414 let oppReservePiecesArray = [];
415 const oppCol = this.vr.getOppCol(this.mycolor);
416 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
417 {
418 oppReservePiecesArray.push(h('div',
419 {
420 'class': {'board':true, ['board'+sizeY]:true},
421 attrs: { id: this.getSquareId({x:sizeX+(1-shiftIdx),y:i}) }
422 },
423 [
424 h('img',
425 {
426 'class': {"piece":true},
427 attrs: {
428 "src": "/images/pieces/" +
429 this.vr.getReservePpath(oppCol,i) + ".svg",
430 }
431 }),
432 h('sup',
433 {"class": { "reserve-count": true } },
434 [ this.vr.reserve[oppCol][VariantRules.RESERVE_PIECES[i]] ]
435 )
436 ]));
437 }
438 let reserves = h('div',
439 {
440 'class':{
441 'game': true,
442 "reserve-div": true,
443 },
444 },
445 [
446 h('div',
447 {
448 'class': {
449 'row': true,
450 "reserve-row-1": true,
451 },
452 },
453 myReservePiecesArray
454 ),
455 h('div',
456 { 'class': { 'row': true }},
457 oppReservePiecesArray
458 )
459 ]
460 );
461 elementArray.push(reserves);
462 }
463 const modalEog = [
464 h('input',
465 {
466 attrs: { "id": "modal-eog", type: "checkbox" },
467 "class": { "modal": true },
468 }),
469 h('div',
470 {
471 attrs: { "role": "dialog", "aria-labelledby": "eogMessage" },
472 },
473 [
474 h('div',
475 {
476 "class": { "card": true, "smallpad": true },
477 },
478 [
479 h('label',
480 {
481 attrs: { "for": "modal-eog" },
482 "class": { "modal-close": true },
483 }
484 ),
485 h('h3',
486 {
487 attrs: { "id": "eogMessage" },
488 "class": { "section": true },
489 domProps: { innerHTML: this.endgameMessage },
490 }
491 )
492 ]
493 )
494 ]
495 )
496 ];
497 elementArray = elementArray.concat(modalEog);
498 }
499 // NOTE: this modal could be in Pug view (no usage of Vue functions or variables)
500 const modalNewgame = [
501 h('input',
502 {
503 attrs: { "id": "modal-newgame", type: "checkbox" },
504 "class": { "modal": true },
505 }),
506 h('div',
507 {
508 attrs: { "role": "dialog", "aria-labelledby": "newGameTxt" },
509 },
510 [
511 h('div',
512 {
513 "class": { "card": true, "smallpad": true },
514 },
515 [
516 h('label',
517 {
518 attrs: { "id": "close-newgame", "for": "modal-newgame" },
519 "class": { "modal-close": true },
520 }
521 ),
522 h('h3',
523 {
524 attrs: { "id": "newGameTxt" },
525 "class": { "section": true },
526 domProps: { innerHTML: "New game" },
527 }
528 ),
529 h('p',
530 {
531 "class": { "section": true },
532 domProps: { innerHTML: "Waiting for opponent..." },
533 }
534 )
535 ]
536 )
537 ]
538 )
539 ];
540 elementArray = elementArray.concat(modalNewgame);
541 const modalFenEdit = [
542 h('input',
543 {
544 attrs: { "id": "modal-fenedit", type: "checkbox" },
545 "class": { "modal": true },
546 }),
547 h('div',
548 {
549 attrs: { "role": "dialog", "aria-labelledby": "titleFenedit" },
550 },
551 [
552 h('div',
553 {
554 "class": { "card": true, "smallpad": true },
555 },
556 [
557 h('label',
558 {
559 attrs: { "id": "close-fenedit", "for": "modal-fenedit" },
560 "class": { "modal-close": true },
561 }
562 ),
563 h('h3',
564 {
565 attrs: { "id": "titleFenedit" },
566 "class": { "section": true },
567 domProps: { innerHTML: "Position + flags (FEN):" },
568 }
569 ),
570 h('input',
571 {
572 attrs: {
573 "id": "input-fen",
574 type: "text",
575 value: VariantRules.GenRandInitFen(),
576 },
577 }
578 ),
579 h('button',
580 {
581 on: { click:
582 () => {
583 const fen = document.getElementById("input-fen").value;
584 document.getElementById("modal-fenedit").checked = false;
585 this.newGame("friend", fen);
586 }
587 },
588 domProps: { innerHTML: "Ok" },
589 }
590 ),
591 h('button',
592 {
593 on: { click:
594 () => {
595 document.getElementById("input-fen").value =
596 VariantRules.GenRandInitFen();
597 }
598 },
599 domProps: { innerHTML: "Random" },
600 }
601 ),
602 ]
603 )
604 ]
605 )
606 ];
607 elementArray = elementArray.concat(modalFenEdit);
608 const modalSettings = [
609 h('input',
610 {
611 attrs: { "id": "modal-settings", type: "checkbox" },
612 "class": { "modal": true },
613 }),
614 h('div',
615 {
616 attrs: { "role": "dialog", "aria-labelledby": "settingsTitle" },
617 },
618 [
619 h('div',
620 {
621 "class": { "card": true, "smallpad": true },
622 },
623 [
624 h('label',
625 {
626 attrs: { "id": "close-settings", "for": "modal-settings" },
627 "class": { "modal-close": true },
628 }
629 ),
630 h('h3',
631 {
632 attrs: { "id": "settingsTitle" },
633 "class": { "section": true },
634 domProps: { innerHTML: "Preferences" },
635 }
636 ),
637 h('fieldset',
638 { },
639 [
640 h('label',
641 {
642 attrs: { for: "nameSetter" },
643 domProps: { innerHTML: "My name is..." },
644 },
645 ),
646 h('input',
647 {
648 attrs: {
649 "id": "nameSetter",
650 type: "text",
651 value: this.myname,
652 },
653 on: { "change": this.setMyname },
654 }
655 ),
656 ]
657 ),
658 h('fieldset',
659 { },
660 [
661 h('label',
662 {
663 attrs: { for: "setHints" },
664 domProps: { innerHTML: "Show hints?" },
665 },
666 ),
667 h('input',
668 {
669 attrs: {
670 "id": "setHints",
671 type: "checkbox",
672 checked: this.hints,
673 },
674 on: { "change": this.toggleHints },
675 }
676 ),
677 ]
678 ),
679 h('fieldset',
680 { },
681 [
682 h('label',
683 {
684 attrs: { for: "selectColor" },
685 domProps: { innerHTML: "Board colors" },
686 },
687 ),
688 h("select",
689 {
690 attrs: { "id": "selectColor" },
691 on: { "change": this.setColor },
692 },
693 [
694 h("option",
695 {
696 domProps: {
697 "value": "lichess",
698 innerHTML: "brown"
699 },
700 attrs: { "selected": this.color=="lichess" },
701 }
702 ),
703 h("option",
704 {
705 domProps: {
706 "value": "chesscom",
707 innerHTML: "green"
708 },
709 attrs: { "selected": this.color=="chesscom" },
710 }
711 ),
712 h("option",
713 {
714 domProps: {
715 "value": "chesstempo",
716 innerHTML: "blue"
717 },
718 attrs: { "selected": this.color=="chesstempo" },
719 }
720 ),
721 ],
722 ),
723 ]
724 ),
725 h('fieldset',
726 { },
727 [
728 h('label',
729 {
730 attrs: { for: "selectSound" },
731 domProps: { innerHTML: "Play sounds?" },
732 },
733 ),
734 h("select",
735 {
736 attrs: { "id": "selectSound" },
737 on: { "change": this.setSound },
738 },
739 [
740 h("option",
741 {
742 domProps: {
743 "value": "0",
744 innerHTML: "None"
745 },
746 attrs: { "selected": this.sound==0 },
747 }
748 ),
749 h("option",
750 {
751 domProps: {
752 "value": "1",
753 innerHTML: "Newgame"
754 },
755 attrs: { "selected": this.sound==1 },
756 }
757 ),
758 h("option",
759 {
760 domProps: {
761 "value": "2",
762 innerHTML: "All"
763 },
764 attrs: { "selected": this.sound==2 },
765 }
766 ),
767 ],
768 ),
769 ]
770 ),
771 ]
772 )
773 ]
774 )
775 ];
776 elementArray = elementArray.concat(modalSettings);
777 let chatEltsArray =
778 [
779 h('label',
780 {
781 attrs: { "id": "close-chat", "for": "modal-chat" },
782 "class": { "modal-close": true },
783 }
784 ),
785 h('h3',
786 {
787 attrs: { "id": "titleChat" },
788 "class": { "section": true },
789 domProps: { innerHTML: "Chat with " + this.oppName },
790 }
791 )
792 ];
793 for (let chat of this.chats)
794 {
795 chatEltsArray.push(
796 h('p',
797 {
798 "class": {
799 "my-chatmsg": chat.author==this.myid,
800 "opp-chatmsg": chat.author==this.oppid,
801 },
802 domProps: { innerHTML: chat.msg }
803 }
804 )
805 );
806 }
807 chatEltsArray = chatEltsArray.concat([
808 h('input',
809 {
810 attrs: {
811 "id": "input-chat",
812 type: "text",
813 placeholder: "Type here",
814 },
815 on: { keyup: this.trySendChat }, //if key is 'enter'
816 }
817 ),
818 h('button',
819 {
820 on: { click: this.sendChat },
821 domProps: { innerHTML: "Send" },
822 }
823 )
824 ]);
825 const modalChat = [
826 h('input',
827 {
828 attrs: { "id": "modal-chat", type: "checkbox" },
829 "class": { "modal": true },
830 }),
831 h('div',
832 {
833 attrs: { "role": "dialog", "aria-labelledby": "titleChat" },
834 },
835 [
836 h('div',
837 {
838 "class": { "card": true, "smallpad": true },
839 },
840 chatEltsArray
841 )
842 ]
843 )
844 ];
845 elementArray = elementArray.concat(modalChat);
846 const actions = h('div',
847 {
848 attrs: { "id": "actions" },
849 'class': { 'text-center': true },
850 },
851 actionArray
852 );
853 elementArray.push(actions);
854 if (this.score != "*" && this.pgnTxt.length > 0)
855 {
856 elementArray.push(
857 h('div',
858 {
859 attrs: { id: "pgn-div" },
860 "class": { "section-content": true },
861 },
862 [
863 h('a',
864 {
865 attrs: {
866 id: "download",
867 href: "#",
868 }
869 }
870 ),
871 h('p',
872 {
873 attrs: { id: "pgn-game" },
874 domProps: { innerHTML: this.pgnTxt }
875 }
876 ),
877 h('button',
878 {
879 attrs: { "id": "downloadBtn" },
880 on: { click: this.download },
881 domProps: { innerHTML: "Download game" },
882 }
883 ),
884 ]
885 )
886 );
887 }
888 else if (this.mode != "idle")
889 {
890 if (this.mode == "problem")
891 {
892 // Show problem solution (on click)
893 elementArray.push(
894 h('div',
895 {
896 attrs: { id: "solution-div" },
897 "class": { "section-content": true },
898 },
899 [
900 h('h3',
901 {
902 domProps: { innerHTML: "Show solution" },
903 on: { click: this.toggleShowSolution },
904 }
905 ),
906 h('p',
907 {
908 attrs: { id: "problem-solution" },
909 domProps: { innerHTML: this.problem.solution }
910 }
911 )
912 ]
913 )
914 );
915 }
916 // Show current FEN
917 elementArray.push(
918 h('div',
919 {
920 attrs: { id: "fen-div" },
921 "class": { "section-content": true },
922 },
923 [
924 h('p',
925 {
926 attrs: { id: "fen-string" },
927 domProps: { innerHTML: this.vr.getBaseFen() }
928 }
929 )
930 ]
931 )
932 );
933 }
934 return h(
935 'div',
936 {
937 'class': {
938 "col-sm-12":true,
939 "col-md-8":true,
940 "col-md-offset-2":true,
941 "col-lg-6":true,
942 "col-lg-offset-3":true,
943 },
944 // NOTE: click = mousedown + mouseup
945 on: {
946 mousedown: this.mousedown,
947 mousemove: this.mousemove,
948 mouseup: this.mouseup,
949 touchstart: this.mousedown,
950 touchmove: this.mousemove,
951 touchend: this.mouseup,
952 },
953 },
954 elementArray
955 );
956 },
957 computed: {
958 endgameMessage: function() {
959 let eogMessage = "Unfinished";
960 switch (this.score)
961 {
962 case "1-0":
963 eogMessage = "White win";
964 break;
965 case "0-1":
966 eogMessage = "Black win";
967 break;
968 case "1/2":
969 eogMessage = "Draw";
970 break;
971 }
972 return eogMessage;
973 },
974 },
975 created: function() {
976 const url = socketUrl;
977 const humanContinuation = (localStorage.getItem("variant") === variant);
978 const computerContinuation = (localStorage.getItem("comp-variant") === variant);
979 this.myid = (humanContinuation ? localStorage.getItem("myid") : getRandString());
980 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
981 const socketOpenListener = () => {
982 if (humanContinuation) //game VS human has priority
983 {
984 const fen = localStorage.getItem("fen");
985 const mycolor = localStorage.getItem("mycolor");
986 const oppid = localStorage.getItem("oppid");
987 const moves = JSON.parse(localStorage.getItem("moves"));
988 this.newGame("human", fen, mycolor, oppid, moves, true);
989 // Send ping to server (answer pong if opponent is connected)
990 this.conn.send(JSON.stringify({code:"ping",oppid:this.oppid}));
991 }
992 else if (computerContinuation)
993 {
994 const fen = localStorage.getItem("comp-fen");
995 const mycolor = localStorage.getItem("comp-mycolor");
996 const moves = JSON.parse(localStorage.getItem("comp-moves"));
997 this.newGame("computer", fen, mycolor, undefined, moves, true);
998 }
999 };
1000 const socketMessageListener = msg => {
1001 const data = JSON.parse(msg.data);
1002 switch (data.code)
1003 {
1004 case "oppname":
1005 // Receive opponent's name
1006 this.oppName = data.name;
1007 break;
1008 case "newchat":
1009 // Receive new chat
1010 this.chats.push({msg:data.msg, author:this.oppid});
1011 break;
1012 case "duplicate":
1013 // We opened another tab on the same game
1014 this.mode = "idle";
1015 this.vr = null;
1016 alert("Already playing a game in this variant on another tab!");
1017 break;
1018 case "newgame": //opponent found
1019 // oppid: opponent socket ID
1020 this.newGame("human", data.fen, data.color, data.oppid);
1021 break;
1022 case "newmove": //..he played!
1023 this.play(data.move, "animate");
1024 break;
1025 case "pong": //received if we sent a ping (game still alive on our side)
1026 this.oppConnected = true;
1027 const L = this.vr.moves.length;
1028 // Send our "last state" informations to opponent
1029 this.conn.send(JSON.stringify({
1030 code:"lastate",
1031 oppid:this.oppid,
1032 lastMove:L>0?this.vr.moves[L-1]:undefined,
1033 movesCount:L,
1034 }));
1035 break;
1036 case "lastate": //got opponent infos about last move (we might have resigned)
1037 if (this.mode!="human" || this.oppid!=data.oppid)
1038 {
1039 // OK, we resigned
1040 this.conn.send(JSON.stringify({
1041 code:"lastate",
1042 oppid:this.oppid,
1043 lastMove:undefined,
1044 movesCount:-1,
1045 }));
1046 }
1047 else if (data.movesCount < 0)
1048 {
1049 // OK, he resigned
1050 this.endGame(this.mycolor=="w"?"1-0":"0-1");
1051 }
1052 else if (data.movesCount < this.vr.moves.length)
1053 {
1054 // We must tell last move to opponent
1055 const L = this.vr.moves.length;
1056 this.conn.send(JSON.stringify({
1057 code:"lastate",
1058 oppid:this.oppid,
1059 lastMove:this.vr.moves[L-1],
1060 movesCount:L,
1061 }));
1062 }
1063 else if (data.movesCount > this.vr.moves.length) //just got last move from him
1064 this.play(data.lastMove, "animate");
1065 break;
1066 case "resign": //..you won!
1067 this.endGame(this.mycolor=="w"?"1-0":"0-1");
1068 break;
1069 // TODO: also use (dis)connect info to count online players?
1070 case "connect":
1071 case "disconnect":
1072 if (["human","chat"].includes(this.mode) && this.oppid == data.id)
1073 this.oppConnected = (data.code == "connect");
1074 if (this.oppConnected && this.mode == "chat")
1075 {
1076 // Send our name to the opponent, in case of he hasn't it
1077 this.conn.send(JSON.stringify({
1078 code:"myname", name:this.myname, oppid: this.oppid}));
1079 }
1080 break;
1081 }
1082 };
1083 const socketCloseListener = () => {
1084 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
1085 this.conn.addEventListener('open', socketOpenListener);
1086 this.conn.addEventListener('message', socketMessageListener);
1087 this.conn.addEventListener('close', socketCloseListener);
1088 };
1089 this.conn.onopen = socketOpenListener;
1090 this.conn.onmessage = socketMessageListener;
1091 this.conn.onclose = socketCloseListener;
1092 // Listen to keyboard left/right to navigate in game
1093 document.onkeydown = event => {
1094 if (["idle","chat"].includes(this.mode) &&
1095 !!this.vr && this.vr.moves.length > 0 && [37,39].includes(event.keyCode))
1096 {
1097 event.preventDefault();
1098 if (event.keyCode == 37) //Back
1099 this.undo();
1100 else //Forward (39)
1101 this.play();
1102 }
1103 };
1104 // Computer moves web worker logic:
1105 this.compWorker.postMessage(["scripts",variant]);
1106 const self = this;
1107 this.compWorker.onmessage = function(e) {
1108 let compMove = e.data;
1109 compMove.computer = true; //TODO: imperfect attempt to avoid ghost move
1110 // (first move) HACK: small delay to avoid selecting elements
1111 // before they appear on page:
1112 const delay = Math.max(500-(Date.now()-self.timeStart), 0);
1113 setTimeout(() => {
1114 if (self.mode == "computer") //warning: mode could have changed!
1115 self.play(compMove, "animate")
1116 }, delay);
1117 }
1118 },
1119 methods: {
1120 setMyname: function(e) {
1121 this.myname = e.target.value;
1122 setCookie("username",this.myname);
1123 },
1124 trySendChat: function(e) {
1125 if (e.keyCode == 13) //'enter' key
1126 this.sendChat();
1127 },
1128 sendChat: function() {
1129 let chatInput = document.getElementById("input-chat");
1130 const chatTxt = chatInput.value;
1131 chatInput.value = "";
1132 this.chats.push({msg:chatTxt, author:this.myid});
1133 this.conn.send(JSON.stringify({
1134 code:"newchat", oppid: this.oppid, msg: chatTxt}));
1135 },
1136 toggleShowSolution: function() {
1137 let problemSolution = document.getElementById("problem-solution");
1138 problemSolution.style.display =
1139 !problemSolution.style.display || problemSolution.style.display == "none"
1140 ? "block"
1141 : "none";
1142 },
1143 download: function() {
1144 let content = document.getElementById("pgn-game").innerHTML;
1145 content = content.replace(/<br>/g, "\n");
1146 // Prepare and trigger download link
1147 let downloadAnchor = document.getElementById("download");
1148 downloadAnchor.setAttribute("download", "game.pgn");
1149 downloadAnchor.href = "data:text/plain;charset=utf-8," +
1150 encodeURIComponent(content);
1151 downloadAnchor.click();
1152 },
1153 showScoreMsg: function() {
1154 let modalBox = document.getElementById("modal-eog");
1155 modalBox.checked = true;
1156 setTimeout(() => { modalBox.checked = false; }, 2000);
1157 },
1158 endGame: function(score) {
1159 this.score = score;
1160 this.showScoreMsg();
1161 // Variants may have special PGN structure (so next function isn't defined here)
1162 this.pgnTxt = this.vr.getPGN(this.mycolor, this.score, this.fenStart, this.mode);
1163 if (this.mode == "human" && this.oppConnected)
1164 {
1165 // Send our nickname to opponent
1166 this.conn.send(JSON.stringify({
1167 code:"myname", name:this.myname, oppid:this.oppid}));
1168 }
1169 this.mode = (this.mode=="human" ? "chat" : "idle");
1170 this.cursor = this.vr.moves.length; //to navigate in finished game
1171 if (this.mode == "idle") //keep oppid in case of chat after human game
1172 this.oppid = "";
1173 },
1174 setStorage: function() {
1175 if (this.mode=="human")
1176 {
1177 localStorage.setItem("myid", this.myid);
1178 localStorage.setItem("oppid", this.oppid);
1179 }
1180 // 'prefix' = "comp-" to resume games vs. computer
1181 const prefix = (this.mode=="computer" ? "comp-" : "");
1182 localStorage.setItem(prefix+"variant", variant);
1183 localStorage.setItem(prefix+"mycolor", this.mycolor);
1184 localStorage.setItem(prefix+"fenStart", this.fenStart);
1185 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1186 localStorage.setItem(prefix+"fen", this.vr.getFen());
1187 },
1188 updateStorage: function() {
1189 const prefix = (this.mode=="computer" ? "comp-" : "");
1190 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1191 localStorage.setItem(prefix+"fen", this.vr.getFen());
1192 },
1193 // Now unused (maybe later, a button "clear all")
1194 clearStorage: function() {
1195 if (this.mode=="human")
1196 {
1197 delete localStorage["myid"];
1198 delete localStorage["oppid"];
1199 }
1200 const prefix = (this.mode=="computer" ? "comp-" : "");
1201 delete localStorage[prefix+"variant"];
1202 delete localStorage[prefix+"mycolor"];
1203 delete localStorage[prefix+"fenStart"];
1204 delete localStorage[prefix+"fen"];
1205 delete localStorage[prefix+"moves"];
1206 },
1207 // HACK because mini-css tooltips are persistent after click...
1208 getRidOfTooltip: function(elt) {
1209 elt.style.visibility = "hidden";
1210 setTimeout(() => { elt.style.visibility="visible"; }, 100);
1211 },
1212 showSettings: function(e) {
1213 this.getRidOfTooltip(e.currentTarget);
1214 document.getElementById("modal-settings").checked = true;
1215 },
1216 toggleHints: function() {
1217 this.hints = !this.hints;
1218 setCookie("hints", this.hints ? "1" : "0");
1219 },
1220 setColor: function(e) {
1221 this.color = e.target.options[e.target.selectedIndex].value;
1222 setCookie("color", this.color);
1223 },
1224 setSound: function(e) {
1225 this.sound = parseInt(e.target.options[e.target.selectedIndex].value);
1226 setCookie("sound", this.sound);
1227 },
1228 clickGameSeek: function(e) {
1229 this.getRidOfTooltip(e.currentTarget);
1230 if (this.mode == "human")
1231 return; //no newgame while playing
1232 if (this.seek)
1233 {
1234 this.conn.send(JSON.stringify({code:"cancelnewgame"}));
1235 this.seek = false;
1236 }
1237 else
1238 this.newGame("human");
1239 },
1240 clickComputerGame: function(e) {
1241 this.getRidOfTooltip(e.currentTarget);
1242 this.newGame("computer");
1243 },
1244 clickFriendGame: function(e) {
1245 this.getRidOfTooltip(e.currentTarget);
1246 document.getElementById("modal-fenedit").checked = true;
1247 },
1248 resign: function(e) {
1249 this.getRidOfTooltip(e.currentTarget);
1250 if (this.mode == "human" && this.oppConnected)
1251 {
1252 try {
1253 this.conn.send(JSON.stringify({code: "resign", oppid: this.oppid}));
1254 } catch (INVALID_STATE_ERR) {
1255 return; //socket is not ready (and not yet reconnected)
1256 }
1257 }
1258 this.endGame(this.mycolor=="w"?"0-1":"1-0");
1259 },
1260 newGame: function(mode, fenInit, color, oppId, moves, continuation) {
1261 let fen = fenInit || VariantRules.GenRandInitFen();
1262 console.log(fen); //DEBUG
1263 if (mode=="human" && !oppId)
1264 {
1265 const storageVariant = localStorage.getItem("variant");
1266 if (!!storageVariant && storageVariant !== variant)
1267 return alert("Finish your " + storageVariant + " game first!");
1268 // Send game request and wait..
1269 try {
1270 this.conn.send(JSON.stringify({code:"newgame", fen:fen}));
1271 } catch (INVALID_STATE_ERR) {
1272 return; //nothing achieved
1273 }
1274 this.seek = true;
1275 let modalBox = document.getElementById("modal-newgame");
1276 modalBox.checked = true;
1277 setTimeout(() => { modalBox.checked = false; }, 2000);
1278 return;
1279 }
1280 if (mode == "computer")
1281 {
1282 const storageVariant = localStorage.getItem("comp-variant");
1283 if (!!storageVariant)
1284 {
1285 if (storageVariant !== variant)
1286 {
1287 if (!confirm("Unfinished " + storageVariant +
1288 " computer game will be erased"))
1289 {
1290 return;
1291 }
1292 }
1293 else
1294 {
1295 // This is a continuation (click on new comp game after human game)
1296 fen = localStorage.getItem("comp-fen");
1297 color = localStorage.getItem("comp-mycolor");
1298 moves = JSON.parse(localStorage.getItem("comp-moves"));
1299 continuation = true;
1300 }
1301 }
1302 }
1303 this.vr = new VariantRules(fen, moves || []);
1304 this.score = "*";
1305 this.pgnTxt = ""; //redundant with this.score = "*", but cleaner
1306 this.mode = mode;
1307 if (continuation && moves.length > 0) //NOTE: "continuation": redundant test
1308 {
1309 const lastMove = moves[moves.length-1];
1310 this.vr.undo(lastMove);
1311 this.incheck = this.vr.getCheckSquares(lastMove);
1312 this.vr.play(lastMove, "ingame");
1313 }
1314 else
1315 this.incheck = [];
1316 if (continuation)
1317 {
1318 const prefix = (mode=="computer" ? "comp-" : "");
1319 this.fenStart = localStorage.getItem(prefix+"fenStart");
1320 }
1321 else
1322 this.fenStart = V.ParseFen(fen).position; //this is enough
1323 if (mode=="human")
1324 {
1325 // Opponent found!
1326 this.oppid = oppId;
1327 this.oppConnected = !continuation;
1328 this.mycolor = color;
1329 this.seek = false;
1330 if (!continuation) //not playing sound on game continuation
1331 {
1332 if (this.sound >= 1)
1333 new Audio("/sounds/newgame.mp3").play().catch(err => {});
1334 document.getElementById("modal-newgame").checked = false;
1335 this.setStorage(); //in case of interruptions
1336 }
1337 else
1338 {
1339 // Maybe we loaded a finished game (just enter chat mode)
1340 const eog = this.vr.checkGameOver();
1341 if (eog != "*")
1342 setTimeout(() => this.endGame(eog), 100);
1343 }
1344 }
1345 else if (mode == "computer")
1346 {
1347 this.compWorker.postMessage(["init",this.vr.getFen()]);
1348 this.mycolor = color || (Math.random() < 0.5 ? 'w' : 'b');
1349 if (!continuation)
1350 this.setStorage(); //store game state
1351 else
1352 {
1353 // Maybe we loaded a finished game (just show it)
1354 const eog = this.vr.checkGameOver();
1355 if (eog != "*")
1356 setTimeout(() => this.endGame(eog), 100);
1357 }
1358 if (this.mycolor != this.vr.turn)
1359 this.playComputerMove();
1360 }
1361 //else: against a (IRL) friend or problem solving: nothing more to do
1362 },
1363 playComputerMove: function() {
1364 this.timeStart = Date.now();
1365 this.compWorker.postMessage(["askmove"]);
1366 },
1367 // Get the identifier of a HTML table cell from its numeric coordinates o.x,o.y.
1368 getSquareId: function(o) {
1369 // NOTE: a separator is required to allow any size of board
1370 return "sq-" + o.x + "-" + o.y;
1371 },
1372 // Inverse function
1373 getSquareFromId: function(id) {
1374 let idParts = id.split('-');
1375 return [parseInt(idParts[1]), parseInt(idParts[2])];
1376 },
1377 mousedown: function(e) {
1378 e = e || window.event;
1379 let ingame = false;
1380 let elem = e.target;
1381 while (!ingame && elem !== null)
1382 {
1383 if (elem.classList.contains("game"))
1384 {
1385 ingame = true;
1386 break;
1387 }
1388 elem = elem.parentElement;
1389 }
1390 if (!ingame) //let default behavior (click on button...)
1391 return;
1392 e.preventDefault(); //disable native drag & drop
1393 if (!this.selectedPiece && e.target.classList.contains("piece"))
1394 {
1395 // Next few lines to center the piece on mouse cursor
1396 let rect = e.target.parentNode.getBoundingClientRect();
1397 this.start = {
1398 x: rect.x + rect.width/2,
1399 y: rect.y + rect.width/2,
1400 id: e.target.parentNode.id
1401 };
1402 this.selectedPiece = e.target.cloneNode();
1403 this.selectedPiece.style.position = "absolute";
1404 this.selectedPiece.style.top = 0;
1405 this.selectedPiece.style.display = "inline-block";
1406 this.selectedPiece.style.zIndex = 3000;
1407 const startSquare = this.getSquareFromId(e.target.parentNode.id);
1408 this.possibleMoves = [];
1409 if (!["idle","chat"].includes(this.mode))
1410 {
1411 const color = ["friend","problem"].includes(this.mode)
1412 ? this.vr.turn
1413 : this.mycolor;
1414 if (this.vr.canIplay(color,startSquare))
1415 this.possibleMoves = this.vr.getPossibleMovesFrom(startSquare);
1416 }
1417 // Next line add moving piece just after current image
1418 // (required for Crazyhouse reserve)
1419 e.target.parentNode.insertBefore(this.selectedPiece, e.target.nextSibling);
1420 }
1421 },
1422 mousemove: function(e) {
1423 if (!this.selectedPiece)
1424 return;
1425 e = e || window.event;
1426 // If there is an active element, move it around
1427 if (!!this.selectedPiece)
1428 {
1429 const [offsetX,offsetY] = !!e.clientX
1430 ? [e.clientX,e.clientY] //desktop browser
1431 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY]; //smartphone
1432 this.selectedPiece.style.left = (offsetX-this.start.x) + "px";
1433 this.selectedPiece.style.top = (offsetY-this.start.y) + "px";
1434 }
1435 },
1436 mouseup: function(e) {
1437 if (!this.selectedPiece)
1438 return;
1439 e = e || window.event;
1440 // Read drop target (or parentElement, parentNode... if type == "img")
1441 this.selectedPiece.style.zIndex = -3000; //HACK to find square from final coords
1442 const [offsetX,offsetY] = !!e.clientX
1443 ? [e.clientX,e.clientY]
1444 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY];
1445 let landing = document.elementFromPoint(offsetX, offsetY);
1446 this.selectedPiece.style.zIndex = 3000;
1447 // Next condition: classList.contains(piece) fails because of marks
1448 while (landing.tagName == "IMG")
1449 landing = landing.parentNode;
1450 if (this.start.id == landing.id)
1451 {
1452 // A click: selectedPiece and possibleMoves are already filled
1453 return;
1454 }
1455 // OK: process move attempt
1456 let endSquare = this.getSquareFromId(landing.id);
1457 let moves = this.findMatchingMoves(endSquare);
1458 this.possibleMoves = [];
1459 if (moves.length > 1)
1460 this.choices = moves;
1461 else if (moves.length==1)
1462 this.play(moves[0]);
1463 // Else: impossible move
1464 this.selectedPiece.parentNode.removeChild(this.selectedPiece);
1465 delete this.selectedPiece;
1466 this.selectedPiece = null;
1467 },
1468 findMatchingMoves: function(endSquare) {
1469 // Run through moves list and return the matching set (if promotions...)
1470 let moves = [];
1471 this.possibleMoves.forEach(function(m) {
1472 if (endSquare[0] == m.end.x && endSquare[1] == m.end.y)
1473 moves.push(m);
1474 });
1475 return moves;
1476 },
1477 animateMove: function(move) {
1478 let startSquare = document.getElementById(this.getSquareId(move.start));
1479 let endSquare = document.getElementById(this.getSquareId(move.end));
1480 let rectStart = startSquare.getBoundingClientRect();
1481 let rectEnd = endSquare.getBoundingClientRect();
1482 let translation = {x:rectEnd.x-rectStart.x, y:rectEnd.y-rectStart.y};
1483 let movingPiece =
1484 document.querySelector("#" + this.getSquareId(move.start) + " > img.piece");
1485 // HACK for animation (with positive translate, image slides "under background")
1486 // Possible improvement: just alter squares on the piece's way...
1487 squares = document.getElementsByClassName("board");
1488 for (let i=0; i<squares.length; i++)
1489 {
1490 let square = squares.item(i);
1491 if (square.id != this.getSquareId(move.start))
1492 square.style.zIndex = "-1";
1493 }
1494 movingPiece.style.transform = "translate(" + translation.x + "px," +
1495 translation.y + "px)";
1496 movingPiece.style.transitionDuration = "0.2s";
1497 movingPiece.style.zIndex = "3000";
1498 setTimeout( () => {
1499 for (let i=0; i<squares.length; i++)
1500 squares.item(i).style.zIndex = "auto";
1501 movingPiece.style = {}; //required e.g. for 0-0 with KR swap
1502 this.play(move);
1503 }, 250);
1504 },
1505 play: function(move, programmatic) {
1506 if (!move)
1507 {
1508 // Navigate after game is over
1509 if (this.cursor >= this.vr.moves.length)
1510 return; //already at the end
1511 move = this.vr.moves[this.cursor++];
1512 }
1513 if (!!programmatic) //computer or human opponent
1514 {
1515 this.animateMove(move);
1516 return;
1517 }
1518 // Not programmatic, or animation is over
1519 if (this.mode == "human" && this.vr.turn == this.mycolor)
1520 this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
1521 if (this.sound == 2)
1522 new Audio("/sounds/chessmove1.mp3").play().catch(err => {});
1523 if (!["idle","chat"].includes(this.mode))
1524 {
1525 // Emergency check, if human game started "at the same time"
1526 // TODO: robustify this...
1527 if (this.mode == "human" && !!move.computer)
1528 return;
1529 this.incheck = this.vr.getCheckSquares(move); //is opponent in check?
1530 this.vr.play(move, "ingame");
1531 if (this.mode == "computer")
1532 {
1533 // Send the move to web worker (TODO: including his own moves?!)
1534 this.compWorker.postMessage(["newmove",move]);
1535 }
1536 }
1537 else
1538 {
1539 VariantRules.PlayOnBoard(this.vr.board, move);
1540 this.$forceUpdate(); //TODO: ?!
1541 }
1542 if (["human","computer"].includes(this.mode))
1543 this.updateStorage(); //after our moves and opponent moves
1544 if (!["idle","chat"].includes(this.mode))
1545 {
1546 const eog = this.vr.checkGameOver();
1547 if (eog != "*")
1548 {
1549 if (["human","computer"].includes(this.mode))
1550 this.endGame(eog);
1551 else
1552 {
1553 // Just show score on screen (allow undo)
1554 this.score = eog;
1555 this.showScoreMsg();
1556 }
1557 }
1558 }
1559 if (this.mode == "computer" && this.vr.turn != this.mycolor)
1560 this.playComputerMove();
1561 },
1562 undo: function() {
1563 // Navigate after game is over
1564 if (this.cursor == 0)
1565 return; //already at the beginning
1566 if (this.cursor == this.vr.moves.length)
1567 this.incheck = []; //in case of...
1568 const move = this.vr.moves[--this.cursor];
1569 VariantRules.UndoOnBoard(this.vr.board, move);
1570 this.$forceUpdate(); //TODO: ?!
1571 },
1572 undoInGame: function() {
1573 const lm = this.vr.lastMove;
1574 if (!!lm)
1575 {
1576 this.vr.undo(lm);
1577 const lmBefore = this.vr.lastMove;
1578 if (!!lmBefore)
1579 {
1580 this.vr.undo(lmBefore);
1581 this.incheck = this.vr.getCheckSquares(lmBefore);
1582 this.vr.play(lmBefore, "ingame");
1583 }
1584 else
1585 this.incheck = [];
1586 }
1587 },
1588 },
1589 })