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