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