Try to fix sound exception...
[vchess.git] / public / javascripts / components / game.js
1 // TODO: use indexedDB instead of localStorage? (more flexible: allow several games)
2 Vue.component('my-game', {
3 data: function() {
4 return {
5 vr: null, //object to check moves, store them, FEN..
6 mycolor: "w",
7 possibleMoves: [], //filled after each valid click/dragstart
8 choices: [], //promotion pieces, or checkered captures... (contain possible pieces)
9 start: {}, //pixels coordinates + id of starting square (click or drag)
10 selectedPiece: null, //moving piece (or clicked piece)
11 conn: null, //socket messages
12 score: "*", //'*' means 'unfinished'
13 mode: "idle", //human, computer or idle (when not playing)
14 oppid: "", //opponent ID in case of HH game
15 oppConnected: false,
16 seek: false,
17 fenStart: "",
18 incheck: [],
19 };
20 },
21 render(h) {
22 let [sizeX,sizeY] = VariantRules.size;
23 // Precompute hints squares to facilitate rendering
24 let hintSquares = doubleArray(sizeX, sizeY, false);
25 this.possibleMoves.forEach(m => { hintSquares[m.end.x][m.end.y] = true; });
26 // Also precompute in-check squares
27 let incheckSq = doubleArray(sizeX, sizeY, false);
28 this.incheck.forEach(sq => { incheckSq[sq[0]][sq[1]] = true; });
29 let elementArray = [];
30 let square00 = document.getElementById("sq-0-0");
31 let squareWidth = !!square00
32 ? parseFloat(window.getComputedStyle(square00).width.slice(0,-2))
33 : 0;
34 const playingHuman = (this.mode == "human");
35 const playingComp = (this.mode == "computer");
36 let actionArray = [
37 h('button',
38 {
39 on: { click: this.clickGameSeek },
40 attrs: { "aria-label": 'New game VS human' },
41 'class': {
42 "tooltip": true,
43 "seek": this.seek,
44 "playing": playingHuman,
45 },
46 },
47 [h('i', { 'class': { "material-icons": true } }, "accessibility")]),
48 h('button',
49 {
50 on: { click: this.clickComputerGame },
51 attrs: { "aria-label": 'New game VS computer' },
52 'class': {
53 "tooltip":true,
54 "playing": playingComp,
55 },
56 },
57 [h('i', { 'class': { "material-icons": true } }, "computer")])
58 ];
59 if (!!this.vr)
60 {
61 if (this.mode == "human")
62 {
63 let connectedIndic = h(
64 'div',
65 {
66 "class": {
67 "connected": this.oppConnected,
68 "disconnected": !this.oppConnected,
69 },
70 }
71 );
72 elementArray.push(connectedIndic);
73 }
74 let choices = h('div',
75 {
76 attrs: { "id": "choices" },
77 'class': { 'row': true },
78 style: {
79 //"position": "relative",
80 "display": this.choices.length>0?"block":"none",
81 "top": "-" + ((sizeY/2)*squareWidth+squareWidth/2) + "px",
82 "width": (this.choices.length * squareWidth) + "px",
83 "height": squareWidth + "px",
84 },
85 },
86 this.choices.map( m => { //a "choice" is a move
87 return h('div',
88 {
89 'class': { 'board': true },
90 style: {
91 'width': (100/this.choices.length) + "%",
92 'padding-bottom': (100/this.choices.length) + "%",
93 },
94 },
95 [h('img',
96 {
97 attrs: { "src": '/images/pieces/' + VariantRules.getPpath(m.appear[0].c+m.appear[0].p) + '.svg' },
98 'class': { 'choice-piece': true, 'board': true },
99 on: { "click": e => { this.play(m); this.choices=[]; } },
100 })
101 ]
102 );
103 })
104 );
105 // Create board element (+ reserves if needed by variant or mode)
106 let gameDiv = h('div',
107 {
108 'class': { 'game': true },
109 },
110 [_.range(sizeX).map(i => {
111 let ci = this.mycolor=='w' ? i : sizeX-i-1;
112 return h(
113 'div',
114 {
115 'class': {
116 'row': true,
117 },
118 style: { 'opacity': this.choices.length>0?"0.5":"1" },
119 },
120 _.range(sizeY).map(j => {
121 let cj = this.mycolor=='w' ? j : sizeY-j-1;
122 let elems = [];
123 if (this.vr.board[ci][cj] != VariantRules.EMPTY)
124 {
125 elems.push(
126 h(
127 'img',
128 {
129 'class': {
130 'piece': true,
131 'ghost': !!this.selectedPiece && this.selectedPiece.parentNode.id == "sq-"+ci+"-"+cj,
132 },
133 attrs: {
134 src: "/images/pieces/" + VariantRules.getPpath(this.vr.board[ci][cj]) + ".svg",
135 },
136 }
137 )
138 );
139 }
140 if (hintSquares[ci][cj])
141 {
142 elems.push(
143 h(
144 'img',
145 {
146 'class': {
147 'mark-square': true,
148 },
149 attrs: {
150 src: "/images/mark.svg",
151 },
152 }
153 )
154 );
155 }
156 const lm = this.vr.lastMove;
157 const highlight = !!lm && _.isMatch(lm.end, {x:ci,y:cj});
158 return h(
159 'div',
160 {
161 'class': {
162 'board': true,
163 'light-square': !highlight && (i+j)%2==0,
164 'dark-square': !highlight && (i+j)%2==1,
165 'highlight': highlight,
166 'incheck': incheckSq[ci][cj],
167 },
168 attrs: {
169 id: this.getSquareId({x:ci,y:cj}),
170 },
171 },
172 elems
173 );
174 })
175 );
176 }), choices]
177 );
178 actionArray.push(
179 h('button',
180 {
181 on: { click: this.resign },
182 attrs: { "aria-label": 'Resign' },
183 'class': { "tooltip":true },
184 },
185 [h('i', { 'class': { "material-icons": true } }, "flag")])
186 );
187 elementArray.push(gameDiv);
188 // if (!!vr.reserve)
189 // {
190 // let reserve = h('div',
191 // {'class':{'game':true}}, [
192 // h('div',
193 // { 'class': { 'row': true }},
194 // [
195 // h('div',
196 // {'class':{'board':true}},
197 // [h('img',{'class':{"piece":true},attrs:{"src":"/images/pieces/wb.svg"}})]
198 // )
199 // ]
200 // )
201 // ],
202 // );
203 // elementArray.push(reserve);
204 // }
205 const eogMessage = this.getEndgameMessage(this.score);
206 let elemsOfEog =
207 [
208 h('label',
209 {
210 attrs: { "for": "modal-eog" },
211 "class": { "modal-close": true },
212 }
213 ),
214 h('h3',
215 {
216 "class": { "section": true },
217 domProps: { innerHTML: eogMessage },
218 }
219 )
220 ];
221 if (this.score != "*")
222 {
223 elemsOfEog.push(
224 h('p', //'textarea', //TODO: selectable!
225 {
226 domProps: { innerHTML: this.vr.getPGN(this.mycolor, this.score, this.fenStart) },
227 //attrs: { "readonly": true },
228 }
229 )
230 );
231 }
232 const modalEog = [
233 h('input',
234 {
235 attrs: { "id": "modal-eog", type: "checkbox" },
236 "class": { "modal": true },
237 }),
238 h('div',
239 {
240 attrs: { "role": "dialog", "aria-labelledby": "dialog-title" },
241 },
242 [
243 h('div',
244 {
245 "class": { "card": true, "smallpad": true },
246 },
247 elemsOfEog
248 )
249 ]
250 )
251 ];
252 elementArray = elementArray.concat(modalEog);
253 }
254 const modalNewgame = [
255 h('input',
256 {
257 attrs: { "id": "modal-newgame", type: "checkbox" },
258 "class": { "modal": true },
259 }),
260 h('div',
261 {
262 attrs: { "role": "dialog", "aria-labelledby": "dialog-title" },
263 },
264 [
265 h('div',
266 {
267 "class": { "card": true, "smallpad": true },
268 },
269 [
270 h('label',
271 {
272 attrs: { "id": "close-newgame", "for": "modal-newgame" },
273 "class": { "modal-close": true },
274 }
275 ),
276 h('h3',
277 {
278 "class": { "section": true },
279 domProps: { innerHTML: "New game" },
280 }
281 ),
282 h('p',
283 {
284 "class": { "section": true },
285 domProps: { innerHTML: "Waiting for opponent..." },
286 }
287 )
288 ]
289 )
290 ]
291 )
292 ];
293 elementArray = elementArray.concat(modalNewgame);
294 const actions = h('div',
295 {
296 attrs: { "id": "actions" },
297 'class': { 'text-center': true },
298 },
299 actionArray
300 );
301 elementArray.push(actions);
302 return h(
303 'div',
304 {
305 'class': {
306 "col-sm-12":true,
307 "col-md-8":true,
308 "col-md-offset-2":true,
309 "col-lg-6":true,
310 "col-lg-offset-3":true,
311 },
312 // NOTE: click = mousedown + mouseup --> what about smartphone?!
313 on: {
314 mousedown: this.mousedown,
315 mousemove: this.mousemove,
316 mouseup: this.mouseup,
317 touchdown: this.mousedown,
318 touchmove: this.mousemove,
319 touchup: this.mouseup,
320 },
321 },
322 elementArray
323 );
324 },
325 created: function() {
326 const url = socketUrl;
327 const continuation = (localStorage.getItem("variant") === variant);
328 this.myid = continuation
329 ? localStorage.getItem("myid")
330 // random enough (TODO: function)
331 : (Date.now().toString(36) + Math.random().toString(36).substr(2, 7)).toUpperCase();
332 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
333 const socketOpenListener = () => {
334 if (continuation)
335 {
336 const fen = localStorage.getItem("fen");
337 const mycolor = localStorage.getItem("mycolor");
338 const oppid = localStorage.getItem("oppid");
339 const moves = JSON.parse(localStorage.getItem("moves"));
340 this.newGame("human", fen, mycolor, oppid, moves, true);
341 // Send ping to server (answer pong if opponent is connected)
342 this.conn.send(JSON.stringify({code:"ping",oppid:this.oppid}));
343 }
344 else if (localStorage.getItem("newgame") === variant)
345 {
346 // New game request has been cancelled on disconnect
347 this.seek = true;
348 this.newGame("human", undefined, undefined, undefined, undefined, "reconnect");
349 }
350 };
351 const socketMessageListener = msg => {
352 const data = JSON.parse(msg.data);
353 console.log("Receive message: " + data.code);
354 switch (data.code)
355 {
356 case "newgame": //opponent found
357 this.newGame("human", data.fen, data.color, data.oppid); //oppid: opponent socket ID
358 break;
359 case "newmove": //..he played!
360 this.play(data.move, "animate");
361 break;
362 case "pong": //received if we sent a ping (game still alive on our side)
363 this.oppConnected = true;
364 const L = this.vr.moves.length;
365 // Send our "last state" informations to opponent
366 this.conn.send(JSON.stringify({
367 code:"lastate",
368 oppid:this.oppid,
369 lastMove:L>0?this.vr.moves[L-1]:undefined,
370 movesCount:L,
371 }));
372 break;
373 case "lastate": //got opponent infos about last move (we might have resigned)
374 if (this.mode!="human" || this.oppid!=data.oppid)
375 {
376 // OK, we resigned
377 this.conn.send(JSON.stringify({
378 code:"lastate",
379 oppid:this.oppid,
380 lastMove:undefined,
381 movesCount:-1,
382 }));
383 }
384 else if (data.movesCount < 0)
385 {
386 // OK, he resigned
387 this.endGame(this.mycolor=="w"?"1-0":"0-1");
388 }
389 else if (data.movesCount < this.vr.moves.length)
390 {
391 // We must tell last move to opponent
392 const L = this.vr.moves.length;
393 this.conn.send(JSON.stringify({
394 code:"lastate",
395 oppid:this.oppid,
396 lastMove:this.vr.moves[L-1],
397 movesCount:L,
398 }));
399 }
400 else if (data.movesCount > this.vr.moves.length) //just got last move from him
401 this.play(data.lastMove, "animate");
402 break;
403 case "resign": //..you won!
404 this.endGame(this.mycolor=="w"?"1-0":"0-1");
405 break;
406 // TODO: also use (dis)connect info to count online players?
407 case "connect":
408 case "disconnect":
409 if (this.mode == "human" && this.oppid == data.id)
410 this.oppConnected = (data.code == "connect");
411 break;
412 }
413 };
414 const socketCloseListener = () => {
415 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
416 this.conn.addEventListener('open', socketOpenListener);
417 this.conn.addEventListener('message', socketMessageListener);
418 this.conn.addEventListener('close', socketCloseListener);
419 };
420 this.conn.onopen = socketOpenListener;
421 this.conn.onmessage = socketMessageListener;
422 this.conn.onclose = socketCloseListener;
423 },
424 methods: {
425 endGame: function(score) {
426 this.score = score;
427 let modalBox = document.getElementById("modal-eog");
428 modalBox.checked = true;
429 //setTimeout(() => { modalBox.checked = false; }, 2000); //disabled, to show PGN
430 if (this.mode == "human")
431 this.clearStorage();
432 this.mode = "idle";
433 this.oppid = "";
434 },
435 getEndgameMessage: function(score) {
436 let eogMessage = "Unfinished";
437 switch (this.score)
438 {
439 case "1-0":
440 eogMessage = "White win";
441 break;
442 case "0-1":
443 eogMessage = "Black win";
444 break;
445 case "1/2":
446 eogMessage = "Draw";
447 break;
448 }
449 return eogMessage;
450 },
451 resign: function() {
452 if (this.mode == "human" && this.oppConnected)
453 {
454 try {
455 this.conn.send(JSON.stringify({code: "resign", oppid: this.oppid}));
456 } catch (INVALID_STATE_ERR) {
457 return; //socket is not ready (and not yet reconnected)
458 }
459 }
460 this.endGame(this.mycolor=="w"?"0-1":"1-0");
461 },
462 setStorage: function() {
463 localStorage.setItem("myid", this.myid);
464 localStorage.setItem("variant", variant);
465 localStorage.setItem("mycolor", this.mycolor);
466 localStorage.setItem("oppid", this.oppid);
467 localStorage.setItem("fenStart", this.fenStart);
468 localStorage.setItem("moves", JSON.stringify(this.vr.moves));
469 localStorage.setItem("fen", this.vr.getFen());
470 },
471 updateStorage: function() {
472 localStorage.setItem("moves", JSON.stringify(this.vr.moves));
473 localStorage.setItem("fen", this.vr.getFen());
474 },
475 clearStorage: function() {
476 delete localStorage["variant"];
477 delete localStorage["myid"];
478 delete localStorage["mycolor"];
479 delete localStorage["oppid"];
480 delete localStorage["fenStart"];
481 delete localStorage["fen"];
482 delete localStorage["moves"];
483 },
484 clickGameSeek: function() {
485 if (this.mode == "human")
486 return; //no newgame while playing
487 if (this.seek)
488 delete localStorage["newgame"]; //cancel game seek
489 else
490 {
491 localStorage["newgame"] = variant;
492 this.newGame("human");
493 }
494 this.seek = !this.seek;
495 },
496 clickComputerGame: function() {
497 if (this.mode == "human")
498 return; //no newgame while playing
499 this.newGame("computer");
500 },
501 newGame: function(mode, fenInit, color, oppId, moves, continuation) {
502 const fen = fenInit || VariantRules.GenRandInitFen();
503 console.log(fen); //DEBUG
504 this.score = "*";
505 if (mode=="human" && !oppId)
506 {
507 const storageVariant = localStorage.getItem("variant");
508 if (!!storageVariant && storageVariant !== variant)
509 {
510 // TODO: find a better way to ensure this. Newgame system is currently a mess.
511 alert("Finish your " + storageVariant + " game first!");
512 return;
513 }
514 // Send game request and wait..
515 this.clearStorage(); //in case of
516 try {
517 this.conn.send(JSON.stringify({code:"newgame", fen:fen}));
518 } catch (INVALID_STATE_ERR) {
519 return; //nothing achieved
520 }
521 if (continuation !== "reconnect") //TODO: bad HACK...
522 {
523 let modalBox = document.getElementById("modal-newgame");
524 modalBox.checked = true;
525 setTimeout(() => { modalBox.checked = false; }, 2000);
526 }
527 return;
528 }
529 this.vr = new VariantRules(fen, moves || []);
530 this.mode = mode;
531 this.incheck = []; //in case of
532 this.fenStart = continuation
533 ? localStorage.getItem("fenStart")
534 : fen.split(" ")[0]; //Only the position matters
535 if (mode=="human")
536 {
537 // Opponent found!
538 if (!continuation)
539 {
540 // Not playing sound on game continuation:
541 new Audio("/sounds/newgame.mp3").play().then(() => {}).catch(err => {});
542 document.getElementById("modal-newgame").checked = false;
543 }
544 this.oppid = oppId;
545 this.oppConnected = true;
546 this.mycolor = color;
547 this.seek = false;
548 if (!!moves && moves.length > 0) //imply continuation
549 {
550 const oppCol = this.vr.turn;
551 const lastMove = moves[moves.length-1];
552 this.vr.undo(lastMove, "ingame");
553 this.incheck = this.vr.getCheckSquares(lastMove, oppCol);
554 this.vr.play(lastMove, "ingame");
555 }
556 delete localStorage["newgame"];
557 this.setStorage(); //in case of interruptions
558 }
559 else //against computer
560 {
561 this.mycolor = Math.random() < 0.5 ? 'w' : 'b';
562 if (this.mycolor == 'b')
563 setTimeout(this.playComputerMove, 500);
564 }
565 },
566 playComputerMove: function() {
567 const compColor = this.mycolor=='w' ? 'b' : 'w';
568 const compMove = this.vr.getComputerMove(compColor);
569 // HACK: avoid selecting elements before they appear on page:
570 setTimeout(() => this.play(compMove, "animate"), 500);
571 },
572 // Get the identifier of a HTML table cell from its numeric coordinates o.x,o.y.
573 getSquareId: function(o) {
574 // NOTE: a separator is required to allow any size of board
575 return "sq-" + o.x + "-" + o.y;
576 },
577 // Inverse function
578 getSquareFromId: function(id) {
579 let idParts = id.split('-');
580 return [parseInt(idParts[1]), parseInt(idParts[2])];
581 },
582 mousedown: function(e) {
583 e = e || window.event;
584 e.preventDefault(); //disable native drag & drop
585 if (!this.selectedPiece && e.target.classList.contains("piece"))
586 {
587 // Next few lines to center the piece on mouse cursor
588 let rect = e.target.parentNode.getBoundingClientRect();
589 this.start = {
590 x: rect.x + rect.width/2,
591 y: rect.y + rect.width/2,
592 id: e.target.parentNode.id
593 };
594 this.selectedPiece = e.target.cloneNode();
595 this.selectedPiece.style.position = "absolute";
596 this.selectedPiece.style.top = 0;
597 this.selectedPiece.style.display = "inline-block";
598 this.selectedPiece.style.zIndex = 3000;
599 let startSquare = this.getSquareFromId(e.target.parentNode.id);
600 this.possibleMoves = this.vr.canIplay(this.mycolor,startSquare)
601 ? this.vr.getPossibleMovesFrom(startSquare)
602 : [];
603 e.target.parentNode.appendChild(this.selectedPiece);
604 }
605 },
606 mousemove: function(e) {
607 if (!this.selectedPiece)
608 return;
609 e = e || window.event;
610 // If there is an active element, move it around
611 if (!!this.selectedPiece)
612 {
613 this.selectedPiece.style.left = (e.clientX-this.start.x) + "px";
614 this.selectedPiece.style.top = (e.clientY-this.start.y) + "px";
615 }
616 },
617 mouseup: function(e) {
618 if (!this.selectedPiece)
619 return;
620 e = e || window.event;
621 // Read drop target (or parentElement, parentNode... if type == "img")
622 this.selectedPiece.style.zIndex = -3000; //HACK to find square from final coordinates
623 let landing = document.elementFromPoint(e.clientX, e.clientY);
624 this.selectedPiece.style.zIndex = 3000;
625 while (landing.tagName == "IMG") //classList.contains(piece) fails because of mark/highlight
626 landing = landing.parentNode;
627 if (this.start.id == landing.id) //a click: selectedPiece and possibleMoves already filled
628 return;
629 // OK: process move attempt
630 let endSquare = this.getSquareFromId(landing.id);
631 let moves = this.findMatchingMoves(endSquare);
632 this.possibleMoves = [];
633 if (moves.length > 1)
634 this.choices = moves;
635 else if (moves.length==1)
636 this.play(moves[0]);
637 // Else: impossible move
638 this.selectedPiece.parentNode.removeChild(this.selectedPiece);
639 delete this.selectedPiece;
640 this.selectedPiece = null;
641 },
642 findMatchingMoves: function(endSquare) {
643 // Run through moves list and return the matching set (if promotions...)
644 let moves = [];
645 this.possibleMoves.forEach(function(m) {
646 if (endSquare[0] == m.end.x && endSquare[1] == m.end.y)
647 moves.push(m);
648 });
649 return moves;
650 },
651 animateMove: function(move) {
652 let startSquare = document.getElementById(this.getSquareId(move.start));
653 let endSquare = document.getElementById(this.getSquareId(move.end));
654 let rectStart = startSquare.getBoundingClientRect();
655 let rectEnd = endSquare.getBoundingClientRect();
656 let translation = {x:rectEnd.x-rectStart.x, y:rectEnd.y-rectStart.y};
657 let movingPiece = document.querySelector("#" + this.getSquareId(move.start) + " > img.piece");
658 // HACK for animation (otherwise with positive translate, image slides "under background"...)
659 // Possible improvement: just alter squares on the piece's way...
660 squares = document.getElementsByClassName("board");
661 for (let i=0; i<squares.length; i++)
662 {
663 let square = squares.item(i);
664 if (square.id != this.getSquareId(move.start))
665 square.style.zIndex = "-1";
666 }
667 movingPiece.style.transform = "translate(" + translation.x + "px," + translation.y + "px)";
668 movingPiece.style.transitionDuration = "0.2s";
669 movingPiece.style.zIndex = "3000";
670 setTimeout( () => {
671 for (let i=0; i<squares.length; i++)
672 squares.item(i).style.zIndex = "auto";
673 movingPiece.style = {}; //required e.g. for 0-0 with KR swap
674 this.play(move);
675 }, 200);
676 },
677 play: function(move, programmatic) {
678 if (!!programmatic) //computer or human opponent
679 {
680 this.animateMove(move);
681 return;
682 }
683 const oppCol = this.vr.getOppCol(this.vr.turn);
684 this.incheck = this.vr.getCheckSquares(move, oppCol); //is opponent in check?
685 // Not programmatic, or animation is over
686 if (this.mode == "human" && this.vr.turn == this.mycolor)
687 this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
688 new Audio("/sounds/chessmove1.mp3").play().then(() => {}).catch(err => {});
689 this.vr.play(move, "ingame");
690 if (this.mode == "human")
691 this.updateStorage(); //after our moves and opponent moves
692 const eog = this.vr.checkGameOver(this.vr.turn);
693 if (eog != "*")
694 this.endGame(eog);
695 else if (this.mode == "computer" && this.vr.turn != this.mycolor)
696 setTimeout(this.playComputerMove, 500);
697 },
698 },
699 })