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