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