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