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