Some fixes, add basic preview logic for new problem
[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 (this.mode == "friend")
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: "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 = problemSolution.style.display == "none"
973 ? "block"
974 : "none";
975 },
976 download: function() {
977 let content = document.getElementById("pgn-game").innerHTML;
978 content = content.replace(/<br>/g, "\n");
979 // Prepare and trigger download link
980 let downloadAnchor = document.getElementById("download");
981 downloadAnchor.setAttribute("download", "game.pgn");
982 downloadAnchor.href = "data:text/plain;charset=utf-8," +
983 encodeURIComponent(content);
984 downloadAnchor.click();
985 },
986 showScoreMsg: function() {
987 let modalBox = document.getElementById("modal-eog");
988 modalBox.checked = true;
989 setTimeout(() => { modalBox.checked = false; }, 2000);
990 },
991 endGame: function(score) {
992 this.score = score;
993 this.showScoreMsg();
994 // Variants may have special PGN structure (so next function isn't defined here)
995 this.pgnTxt = this.vr.getPGN(this.mycolor, this.score, this.fenStart, this.mode);
996 if (["human","computer"].includes(this.mode))
997 this.clearStorage();
998 this.mode = "idle";
999 this.cursor = this.vr.moves.length; //to navigate in finished game
1000 this.oppid = "";
1001 },
1002 setStorage: function() {
1003 if (this.mode=="human")
1004 {
1005 localStorage.setItem("myid", this.myid);
1006 localStorage.setItem("oppid", this.oppid);
1007 }
1008 // 'prefix' = "comp-" to resume games vs. computer
1009 const prefix = (this.mode=="computer" ? "comp-" : "");
1010 localStorage.setItem(prefix+"variant", variant);
1011 localStorage.setItem(prefix+"mycolor", this.mycolor);
1012 localStorage.setItem(prefix+"fenStart", this.fenStart);
1013 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1014 localStorage.setItem(prefix+"fen", this.vr.getFen());
1015 },
1016 updateStorage: function() {
1017 const prefix = (this.mode=="computer" ? "comp-" : "");
1018 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1019 localStorage.setItem(prefix+"fen", this.vr.getFen());
1020 },
1021 clearStorage: function() {
1022 if (this.mode=="human")
1023 {
1024 delete localStorage["myid"];
1025 delete localStorage["oppid"];
1026 }
1027 const prefix = (this.mode=="computer" ? "comp-" : "");
1028 delete localStorage[prefix+"variant"];
1029 delete localStorage[prefix+"mycolor"];
1030 delete localStorage[prefix+"fenStart"];
1031 delete localStorage[prefix+"fen"];
1032 delete localStorage[prefix+"moves"];
1033 },
1034 // HACK because mini-css tooltips are persistent after click...
1035 getRidOfTooltip: function(elt) {
1036 elt.style.visibility = "hidden";
1037 setTimeout(() => { elt.style.visibility="visible"; }, 100);
1038 },
1039 showSettings: function(e) {
1040 this.getRidOfTooltip(e.currentTarget);
1041 document.getElementById("modal-settings").checked = true;
1042 },
1043 toggleHints: function() {
1044 this.hints = !this.hints;
1045 setCookie("hints", this.hints ? "1" : "0");
1046 },
1047 setColor: function(e) {
1048 this.color = e.target.options[e.target.selectedIndex].value;
1049 setCookie("color", this.color);
1050 },
1051 setSound: function(e) {
1052 this.sound = parseInt(e.target.options[e.target.selectedIndex].value);
1053 setCookie("sound", this.sound);
1054 },
1055 clickGameSeek: function(e) {
1056 this.getRidOfTooltip(e.currentTarget);
1057 if (this.mode == "human")
1058 return; //no newgame while playing
1059 if (this.seek)
1060 {
1061 this.conn.send(JSON.stringify({code:"cancelnewgame"}));
1062 this.seek = false;
1063 }
1064 else
1065 this.newGame("human");
1066 },
1067 clickComputerGame: function(e) {
1068 this.getRidOfTooltip(e.currentTarget);
1069 if (this.mode == "human")
1070 return; //no newgame while playing
1071 this.newGame("computer");
1072 },
1073 clickFriendGame: function(e) {
1074 this.getRidOfTooltip(e.currentTarget);
1075 document.getElementById("modal-fenedit").checked = true;
1076 },
1077 resign: function(e) {
1078 this.getRidOfTooltip(e.currentTarget);
1079 if (this.mode == "human" && this.oppConnected)
1080 {
1081 try {
1082 this.conn.send(JSON.stringify({code: "resign", oppid: this.oppid}));
1083 } catch (INVALID_STATE_ERR) {
1084 return; //socket is not ready (and not yet reconnected)
1085 }
1086 }
1087 this.endGame(this.mycolor=="w"?"0-1":"1-0");
1088 },
1089 newGame: function(mode, fenInit, color, oppId, moves, continuation) {
1090 const fen = fenInit || VariantRules.GenRandInitFen();
1091 console.log(fen); //DEBUG
1092 if (mode=="human" && !oppId)
1093 {
1094 const storageVariant = localStorage.getItem("variant");
1095 if (!!storageVariant && storageVariant !== variant)
1096 return alert("Finish your " + storageVariant + " game first!");
1097 // Send game request and wait..
1098 try {
1099 this.conn.send(JSON.stringify({code:"newgame", fen:fen}));
1100 } catch (INVALID_STATE_ERR) {
1101 return; //nothing achieved
1102 }
1103 this.seek = true;
1104 let modalBox = document.getElementById("modal-newgame");
1105 modalBox.checked = true;
1106 setTimeout(() => { modalBox.checked = false; }, 2000);
1107 return;
1108 }
1109 if (mode == "computer" && !continuation)
1110 {
1111 const storageVariant = localStorage.getItem("comp-variant");
1112 if (!!storageVariant && storageVariant !== variant)
1113 {
1114 if (!confirm("Unfinished " + storageVariant +
1115 " computer game will be erased"))
1116 {
1117 return;
1118 }
1119 }
1120 }
1121 if (this.mode == "computer" && mode == "human")
1122 {
1123 // Save current computer game to resume it later
1124 this.setStorage();
1125 }
1126 this.vr = new VariantRules(fen, moves || []);
1127 this.score = "*";
1128 this.pgnTxt = ""; //redundant with this.score = "*", but cleaner
1129 this.mode = mode;
1130 if (continuation && moves.length > 0) //NOTE: "continuation": redundant test
1131 {
1132 const lastMove = moves[moves.length-1];
1133 this.vr.undo(lastMove);
1134 this.incheck = this.vr.getCheckSquares(lastMove);
1135 this.vr.play(lastMove, "ingame");
1136 }
1137 else
1138 this.incheck = [];
1139 if (continuation)
1140 {
1141 const prefix = (mode=="computer" ? "comp-" : "");
1142 this.fenStart = localStorage.getItem(prefix+"fenStart");
1143 }
1144 else
1145 this.fenStart = fen;
1146 if (mode=="human")
1147 {
1148 // Opponent found!
1149 if (!continuation) //not playing sound on game continuation
1150 {
1151 if (this.sound >= 1)
1152 new Audio("/sounds/newgame.mp3").play().catch(err => {});
1153 document.getElementById("modal-newgame").checked = false;
1154 }
1155 this.oppid = oppId;
1156 this.oppConnected = !continuation;
1157 this.mycolor = color;
1158 this.seek = false;
1159 this.setStorage(); //in case of interruptions
1160 }
1161 else if (mode == "computer")
1162 {
1163 this.mycolor = Math.random() < 0.5 ? 'w' : 'b';
1164 if (this.mycolor == 'b')
1165 setTimeout(this.playComputerMove, 100); //small delay for drawing board
1166 }
1167 //else: against a (IRL) friend or problem solving: nothing more to do
1168 },
1169 playComputerMove: function() {
1170 const timeStart = Date.now();
1171 const compMove = this.vr.getComputerMove();
1172 // (first move) HACK: avoid selecting elements before they appear on page:
1173 const delay = Math.max(250-(Date.now()-timeStart), 0);
1174 setTimeout(() => {
1175 if (this.mode == "computer") //Warning: mode could have changed!
1176 this.play(compMove, "animate")
1177 }, delay);
1178 },
1179 // Get the identifier of a HTML table cell from its numeric coordinates o.x,o.y.
1180 getSquareId: function(o) {
1181 // NOTE: a separator is required to allow any size of board
1182 return "sq-" + o.x + "-" + o.y;
1183 },
1184 // Inverse function
1185 getSquareFromId: function(id) {
1186 let idParts = id.split('-');
1187 return [parseInt(idParts[1]), parseInt(idParts[2])];
1188 },
1189 mousedown: function(e) {
1190 e = e || window.event;
1191 let ingame = false;
1192 let elem = e.target;
1193 while (!ingame && elem !== null)
1194 {
1195 if (elem.classList.contains("game"))
1196 {
1197 ingame = true;
1198 break;
1199 }
1200 elem = elem.parentElement;
1201 }
1202 if (!ingame) //let default behavior (click on button...)
1203 return;
1204 e.preventDefault(); //disable native drag & drop
1205 if (!this.selectedPiece && e.target.classList.contains("piece"))
1206 {
1207 // Next few lines to center the piece on mouse cursor
1208 let rect = e.target.parentNode.getBoundingClientRect();
1209 this.start = {
1210 x: rect.x + rect.width/2,
1211 y: rect.y + rect.width/2,
1212 id: e.target.parentNode.id
1213 };
1214 this.selectedPiece = e.target.cloneNode();
1215 this.selectedPiece.style.position = "absolute";
1216 this.selectedPiece.style.top = 0;
1217 this.selectedPiece.style.display = "inline-block";
1218 this.selectedPiece.style.zIndex = 3000;
1219 const startSquare = this.getSquareFromId(e.target.parentNode.id);
1220 this.possibleMoves = [];
1221 if (this.mode != "idle")
1222 {
1223 const color = ["friend","problem"].includes(this.mode)
1224 ? this.vr.turn
1225 : this.mycolor;
1226 if (this.vr.canIplay(color,startSquare))
1227 this.possibleMoves = this.vr.getPossibleMovesFrom(startSquare);
1228 }
1229 // Next line add moving piece just after current image
1230 // (required for Crazyhouse reserve)
1231 e.target.parentNode.insertBefore(this.selectedPiece, e.target.nextSibling);
1232 }
1233 },
1234 mousemove: function(e) {
1235 if (!this.selectedPiece)
1236 return;
1237 e = e || window.event;
1238 // If there is an active element, move it around
1239 if (!!this.selectedPiece)
1240 {
1241 const [offsetX,offsetY] = !!e.clientX
1242 ? [e.clientX,e.clientY] //desktop browser
1243 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY]; //smartphone
1244 this.selectedPiece.style.left = (offsetX-this.start.x) + "px";
1245 this.selectedPiece.style.top = (offsetY-this.start.y) + "px";
1246 }
1247 },
1248 mouseup: function(e) {
1249 if (!this.selectedPiece)
1250 return;
1251 e = e || window.event;
1252 // Read drop target (or parentElement, parentNode... if type == "img")
1253 this.selectedPiece.style.zIndex = -3000; //HACK to find square from final coords
1254 const [offsetX,offsetY] = !!e.clientX
1255 ? [e.clientX,e.clientY]
1256 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY];
1257 let landing = document.elementFromPoint(offsetX, offsetY);
1258 this.selectedPiece.style.zIndex = 3000;
1259 // Next condition: classList.contains(piece) fails because of marks
1260 while (landing.tagName == "IMG")
1261 landing = landing.parentNode;
1262 if (this.start.id == landing.id)
1263 {
1264 // A click: selectedPiece and possibleMoves are already filled
1265 return;
1266 }
1267 // OK: process move attempt
1268 let endSquare = this.getSquareFromId(landing.id);
1269 let moves = this.findMatchingMoves(endSquare);
1270 this.possibleMoves = [];
1271 if (moves.length > 1)
1272 this.choices = moves;
1273 else if (moves.length==1)
1274 this.play(moves[0]);
1275 // Else: impossible move
1276 this.selectedPiece.parentNode.removeChild(this.selectedPiece);
1277 delete this.selectedPiece;
1278 this.selectedPiece = null;
1279 },
1280 findMatchingMoves: function(endSquare) {
1281 // Run through moves list and return the matching set (if promotions...)
1282 let moves = [];
1283 this.possibleMoves.forEach(function(m) {
1284 if (endSquare[0] == m.end.x && endSquare[1] == m.end.y)
1285 moves.push(m);
1286 });
1287 return moves;
1288 },
1289 animateMove: function(move) {
1290 let startSquare = document.getElementById(this.getSquareId(move.start));
1291 let endSquare = document.getElementById(this.getSquareId(move.end));
1292 let rectStart = startSquare.getBoundingClientRect();
1293 let rectEnd = endSquare.getBoundingClientRect();
1294 let translation = {x:rectEnd.x-rectStart.x, y:rectEnd.y-rectStart.y};
1295 let movingPiece =
1296 document.querySelector("#" + this.getSquareId(move.start) + " > img.piece");
1297 // HACK for animation (with positive translate, image slides "under background")
1298 // Possible improvement: just alter squares on the piece's way...
1299 squares = document.getElementsByClassName("board");
1300 for (let i=0; i<squares.length; i++)
1301 {
1302 let square = squares.item(i);
1303 if (square.id != this.getSquareId(move.start))
1304 square.style.zIndex = "-1";
1305 }
1306 movingPiece.style.transform = "translate(" + translation.x + "px," +
1307 translation.y + "px)";
1308 movingPiece.style.transitionDuration = "0.2s";
1309 movingPiece.style.zIndex = "3000";
1310 setTimeout( () => {
1311 for (let i=0; i<squares.length; i++)
1312 squares.item(i).style.zIndex = "auto";
1313 movingPiece.style = {}; //required e.g. for 0-0 with KR swap
1314 this.play(move);
1315 }, 250);
1316 },
1317 play: function(move, programmatic) {
1318 if (!move)
1319 {
1320 // Navigate after game is over
1321 if (this.cursor >= this.vr.moves.length)
1322 return; //already at the end
1323 move = this.vr.moves[this.cursor++];
1324 }
1325 if (!!programmatic) //computer or human opponent
1326 {
1327 this.animateMove(move);
1328 return;
1329 }
1330 // Not programmatic, or animation is over
1331 if (this.mode == "human" && this.vr.turn == this.mycolor)
1332 this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
1333 if (this.sound == 2)
1334 new Audio("/sounds/chessmove1.mp3").play().catch(err => {});
1335 if (this.mode != "idle")
1336 {
1337 this.incheck = this.vr.getCheckSquares(move); //is opponent in check?
1338 this.vr.play(move, "ingame");
1339 }
1340 else
1341 {
1342 VariantRules.PlayOnBoard(this.vr.board, move);
1343 this.$forceUpdate(); //TODO: ?!
1344 }
1345 if (["human","computer"].includes(this.mode))
1346 this.updateStorage(); //after our moves and opponent moves
1347 if (this.mode != "idle")
1348 {
1349 const eog = this.vr.checkGameOver();
1350 if (eog != "*")
1351 {
1352 if (["human","computer"].includes(this.mode))
1353 this.endGame(eog);
1354 else
1355 {
1356 // Just show score on screen (allow undo)
1357 this.score = eog;
1358 this.showScoreMsg();
1359 }
1360 }
1361 }
1362 if (this.mode == "computer" && this.vr.turn != this.mycolor)
1363 setTimeout(this.playComputerMove, 250); //small delay for animation
1364 },
1365 undo: function() {
1366 // Navigate after game is over
1367 if (this.cursor == 0)
1368 return; //already at the beginning
1369 if (this.cursor == this.vr.moves.length)
1370 this.incheck = []; //in case of...
1371 const move = this.vr.moves[--this.cursor];
1372 VariantRules.UndoOnBoard(this.vr.board, move);
1373 this.$forceUpdate(); //TODO: ?!
1374 },
1375 undoInGame: function() {
1376 const lm = this.vr.lastMove;
1377 if (!!lm)
1378 this.vr.undo(lm);
1379 },
1380 },
1381 })