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