X-Git-Url: https://git.auder.net/?a=blobdiff_plain;f=client%2Fsrc%2Fviews%2FGame.vue;h=e0ae7e91cac2e6886e25a249a6c863dc6ad75a79;hb=aae89b49a846b2c101d74db7dff9151392d6db34;hp=b52de8294fc60fc60757c7a4d485fb6a2f044afa;hpb=3b959cfaf3d3a28373d7ebb48d80087150a98006;p=vchess.git diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index b52de829..e0ae7e91 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -8,54 +8,97 @@ main role="dialog" data-checkbox="modalChat" ) - #chat.card + .card label.modal-close(for="modalChat") #participants span {{ Object.keys(people).length + " " + st.tr["participant(s):"] }} span( v-for="p in Object.values(people)" - v-if="!!p.name" + v-if="p.name" ) | {{ p.name }} - span.anonymous(v-if="Object.values(people).some(p => !p.name)") + span.anonymous(v-if="Object.values(people).some(p => !p.name && p.id === 0)") | + @nonymous Chat( + ref="chatcomp" :players="game.players" :pastChats="game.chats" :newChat="newChat" @mychat="processChat" @chatcleared="clearChat" ) + input#modalConfirm.modal(type="checkbox") + div#confirmDiv(role="dialog") + .card + .diagram(v-html="curDiag") + .button-group#buttonsConfirm + // onClick for acceptBtn: set dynamically + button.acceptBtn + span {{ st.tr["Validate"] }} + button.refuseBtn(@click="cancelMove()") + span {{ st.tr["Cancel"] }} .row #aboveBoard.col-sm-12.col-md-9.col-md-offset-3.col-lg-10.col-lg-offset-2 span.variant-cadence {{ game.cadence }} span.variant-name {{ game.vname }} - button#chatBtn(onClick="window.doClick('modalChat')") Chat + span#nextGame( + v-if="nextIds.length > 0" + @click="showNextGame()" + ) + | {{ st.tr["Next_g"] }} + button#chatBtn.tooltip( + onClick="window.doClick('modalChat')" + aria-label="Chat" + ) + img(src="/images/icons/chat.svg") #actions(v-if="game.score=='*'") - button( + button.tooltip( @click="clickDraw()" :class="{['draw-' + drawOffer]: true}" + :aria-label="st.tr['Draw']" ) - | {{ st.tr["Draw"] }} - button( + img(src="/images/icons/draw.svg") + button.tooltip( v-if="!!game.mycolor" @click="abortGame()" + :aria-label="st.tr['Abort']" ) - | {{ st.tr["Abort"] }} - button( + img(src="/images/icons/abort.svg") + button.tooltip( v-if="!!game.mycolor" @click="resign()" + :aria-label="st.tr['Resign']" ) - | {{ st.tr["Resign"] }} + img(src="/images/icons/resign.svg") + button.tooltip( + v-else-if="!!game.mycolor" + @click="rematch()" + :aria-label="st.tr['Rematch']" + ) + img(src="/images/icons/rematch.svg") #playersInfo p span.name(:class="{connected: isConnected(0)}") | {{ game.players[0].name || "@nonymous" }} - span.time(v-if="game.score=='*'") {{ virtualClocks[0] }} + span.time( + v-if="game.score=='*'" + :class="{yourturn: !!vr && vr.turn == 'w'}" + ) + span.time-left {{ virtualClocks[0][0] }} + span.time-separator(v-if="!!virtualClocks[0][1]") : + span.time-right(v-if="!!virtualClocks[0][1]") + | {{ virtualClocks[0][1] }} span.split-names - span.name(:class="{connected: isConnected(1)}") | {{ game.players[1].name || "@nonymous" }} - span.time(v-if="game.score=='*'") {{ virtualClocks[1] }} + span.time( + v-if="game.score=='*'" + :class="{yourturn: !!vr && vr.turn == 'b'}" + ) + span.time-left {{ virtualClocks[1][0] }} + span.time-separator(v-if="!!virtualClocks[1][1]") : + span.time-right(v-if="!!virtualClocks[1][1]") + | {{ virtualClocks[1][1] }} BaseGame( ref="basegame" :game="game" @@ -70,12 +113,14 @@ import Chat from "@/components/Chat.vue"; import { store } from "@/store"; import { GameStorage } from "@/utils/gameStorage"; import { ppt } from "@/utils/datetime"; +import { ajax } from "@/utils/ajax"; import { extractTime } from "@/utils/timeControl"; import { getRandString } from "@/utils/alea"; +import { getDiagram } from "@/utils/printDiagram"; import { processModalClick } from "@/utils/modalClick"; -import { getFullNotation } from "@/utils/notation"; import { playMove, getFilteredMove } from "@/utils/playUndo"; import { getScoreMessage } from "@/utils/scoring"; +import { ArrayFun } from "@/utils/array"; import params from "@/parameters"; export default { name: "my-game", @@ -83,115 +128,195 @@ export default { BaseGame, Chat }, - // gameRef: to find the game in (potentially remote) storage data: function() { return { st: store.state, gameRef: { - //given in URL (rid = remote ID) + // rid = remote (socket) ID id: "", rid: "" }, - game: { - //passed to BaseGame - players: [{ name: "" }, { name: "" }], - chats: [], - rendered: false - }, - virtualClocks: [0, 0], //initialized with true game.clocks + nextIds: [], + game: {}, //passed to BaseGame + // virtualClocks will be initialized from true game.clocks + virtualClocks: [], vr: null, //"variant rules" object initialized from FEN drawOffer: "", people: {}, //players + observers + onMygames: [], //opponents (or me) on "MyGames" page lastate: undefined, //used if opponent send lastate before game is ready repeat: {}, //detect position repetition + curDiag: "", //for corr moves confirmation newChat: "", conn: null, + roomInitialized: false, + // If newmove has wrong index: ask fullgame again: + askGameTime: 0, + gameIsLoading: false, + // If asklastate got no reply, ask again: + gotLastate: false, + gotMoveIdx: -1, //last move index received + // If newmove got no pingback, send again: + opponentGotMove: false, connexionString: "", + // Intervals from setInterval(): + // TODO: limit them to e.g. 3 retries ?! + askIfPeerConnected: null, + askLastate: null, + retrySendmove: null, + clockUpdate: null, // Related to (killing of) self multi-connects: newConnect: {}, killed: {} }; }, watch: { - $route: function(to) { - this.gameRef.id = to.params["id"]; - this.gameRef.rid = to.query["rid"]; - this.loadGame(); + $route: function(to, from) { + if (from.params["id"] != to.params["id"]) { + // Change everything: + this.cleanBeforeDestroy(); + this.atCreation(); + } else { + // Same game ID + this.gameRef.id = to.params["id"]; + this.gameRef.rid = to.query["rid"]; + this.nextIds = JSON.parse(this.$route.query["next"] || "[]"); + this.loadGame(); + } } }, // NOTE: some redundant code with Hall.vue (mostly related to people array) created: function() { - // Always add myself to players' list - const my = this.st.user; - this.$set(this.people, my.sid, { id: my.id, name: my.name }); - this.gameRef.id = this.$route.params["id"]; - this.gameRef.rid = this.$route.query["rid"]; //may be undefined - // Initialize connection - this.connexionString = - params.socketUrl + - "/?sid=" + - this.st.user.sid + - "&tmpId=" + - getRandString() + - "&page=" + - encodeURIComponent(this.$route.path); - this.conn = new WebSocket(this.connexionString); - this.conn.onmessage = this.socketMessageListener; - this.conn.onclose = this.socketCloseListener; - // Socket init required before loading remote game: - const socketInit = callback => { - if (!!this.conn && this.conn.readyState == 1) - // 1 == OPEN state - callback(); - else { - // Socket not ready yet (initial loading) - // NOTE: it's important to call callback without arguments, - // otherwise first arg is Websocket object and loadGame fails. - this.conn.onopen = () => { - return callback(); - }; - } - }; - if (!this.gameRef.rid) - // Game stored locally or on server - this.loadGame(null, () => socketInit(this.roomInit)); - else { - // Game stored remotely: need socket to retrieve it - // NOTE: the callback "roomInit" will be lost, so we don't provide it. - // --> It will be given when receiving "fullgame" socket event. - // A more general approach would be to store it somewhere. - socketInit(this.loadGame); - } + this.atCreation(); }, mounted: function() { document .getElementById("chatWrap") .addEventListener("click", processModalClick); + if ("ontouchstart" in window) { + // Disable tooltips on smartphones: + document.getElementsByClassName("tooltip").forEach(elt => { + elt.classList.remove("tooltip"); + }); + } }, beforeDestroy: function() { - this.send("disconnect"); + this.cleanBeforeDestroy(); }, methods: { + atCreation: function() { + // 0] (Re)Set variables + this.gameRef.id = this.$route.params["id"]; + // rid = remote ID to find an observed live game, + // next = next corr games IDs to navigate faster + // (Both might be undefined) + this.gameRef.rid = this.$route.query["rid"]; + this.nextIds = JSON.parse(this.$route.query["next"] || "[]"); + // Always add myself to players' list + const my = this.st.user; + this.$set(this.people, my.sid, { id: my.id, name: my.name }); + this.game = { + players: [{ name: "" }, { name: "" }], + chats: [], + rendered: false + }; + let chatComp = this.$refs["chatcomp"]; + if (!!chatComp) chatComp.chats = []; + this.virtualClocks = [[0,0], [0,0]]; + this.vr = null; + this.drawOffer = ""; + this.onMygames = []; + this.lastate = undefined; + this.newChat = ""; + this.roomInitialized = false; + this.askGameTime = 0; + this.gameIsLoading = false; + this.gotLastate = false; + this.gotMoveIdx = -1; + this.opponentGotMove = false; + this.askIfPeerConnected = null; + this.askLastate = null; + this.retrySendmove = null; + this.clockUpdate = null; + this.newConnect = {}; + this.killed = {}; + // 1] Initialize connection + this.connexionString = + params.socketUrl + + "/?sid=" + + this.st.user.sid + + "&tmpId=" + + getRandString() + + "&page=" + + // Discard potential "/?next=[...]" for page indication: + encodeURIComponent(this.$route.path.match(/\/game\/[a-zA-Z0-9]+/)[0]); + this.conn = new WebSocket(this.connexionString); + this.conn.onmessage = this.socketMessageListener; + this.conn.onclose = this.socketCloseListener; + // Socket init required before loading remote game: + const socketInit = callback => { + if (!!this.conn && this.conn.readyState == 1) + // 1 == OPEN state + callback(); + else + // Socket not ready yet (initial loading) + // NOTE: it's important to call callback without arguments, + // otherwise first arg is Websocket object and loadGame fails. + this.conn.onopen = () => callback(); + }; + if (!this.gameRef.rid) + // Game stored locally or on server + this.loadGame(null, () => socketInit(this.roomInit)); + else + // Game stored remotely: need socket to retrieve it + // NOTE: the callback "roomInit" will be lost, so we don't provide it. + // --> It will be given when receiving "fullgame" socket event. + socketInit(this.loadGame); + }, + cleanBeforeDestroy: function() { + if (!!this.askIfPeerConnected) + clearInterval(this.askIfPeerConnected); + if (!!this.askLastate) + clearInterval(this.askLastate); + if (!!this.retrySendmove) + clearInterval(this.retrySendmove); + if (!!this.clockUpdate) + clearInterval(this.clockUpdate); + this.send("disconnect"); + }, roomInit: function() { - // Notify the room only now that I connected, because - // messages might be lost otherwise (if game loading is slow) - this.send("connect"); - this.send("pollclients"); + if (!this.roomInitialized) { + // Notify the room only now that I connected, because + // messages might be lost otherwise (if game loading is slow) + this.send("connect"); + this.send("pollclients"); + // We may ask fullgame several times if some moves are lost, + // but room should be init only once: + this.roomInitialized = true; + } }, send: function(code, obj) { - if (this.conn) { + if (!!this.conn) this.conn.send(JSON.stringify(Object.assign({ code: code }, obj))); - } }, isConnected: function(index) { const player = this.game.players[index]; // Is it me ? if (this.st.user.sid == player.sid || this.st.user.id == player.uid) - return true; + // Still have to check for name (because of potential multi-accounts + // on same browser, although this should be rare...) + return (!this.st.user.name || this.st.user.name == player.name); // Try to find a match in people: return ( - Object.keys(this.people).some(sid => sid == player.sid) || - Object.values(this.people).some(p => p.id == player.uid) + ( + player.sid && + Object.keys(this.people).some(sid => sid == player.sid) + ) + || + ( + player.uid && + Object.values(this.people).some(p => p.id == player.uid) + ) ); }, resetChatColor: function() { @@ -202,47 +327,85 @@ export default { this.send("newchat", { data: chat }); // NOTE: anonymous chats in corr games are not stored on server (TODO?) if (this.game.type == "corr" && this.st.user.id > 0) - GameStorage.update(this.gameRef.id, { chat: chat }); + this.updateCorrGame({ chat: chat }); }, clearChat: function() { // Nothing more to do if game is live (chats not recorded) - if (this.game.mycolor && this.game.type == "corr") { - ajax( - "/chats", - "DELETE", - {gid: this.game.id}, - () => { - // TODO: this.game.pastChats = [] could be enough here? - this.$set(this.game, "pastChats", []); - } - ); + if (this.game.type == "corr") { + if (!!this.game.mycolor) + ajax("/chats", "DELETE", {gid: this.game.id}); + this.$set(this.game, "chats", []); } }, + // Notify turn after a new move (to opponent and me on MyGames page) + notifyTurn: function(sid) { + const player = this.people[sid]; + const colorIdx = this.game.players.findIndex( + p => p.sid == sid || p.uid == player.id); + const color = ["w","b"][colorIdx]; + const movesCount = this.game.moves.length; + const yourTurn = + (color == "w" && movesCount % 2 == 0) || + (color == "b" && movesCount % 2 == 1); + this.send("turnchange", { target: sid, yourTurn: yourTurn }); + }, + showNextGame: function() { + if (this.nextIds.length == 0) return; + // Did I play in current game? If not, add it to nextIds list + if (this.game.score == "*" && this.vr.turn == this.game.mycolor) + this.nextIds.unshift(this.game.id); + const nextGid = this.nextIds.pop(); + this.$router.push( + "/game/" + nextGid + "/?next=" + JSON.stringify(this.nextIds)); + }, + rematch: function() { + alert("Unimplemented yet (soon :) )"); + // TODO: same logic as for draw, but re-click remove rematch offer (toggle) + }, + askGameAgain: function() { + this.gameIsLoading = true; + const currentUrl = document.location.href; + const doAskGame = () => { + if (currentUrl != document.location.href) return; //page change + if (!this.gameRef.rid) + // This is my game: just reload. + this.loadGame(); + else { + // Just ask fullgame again (once!), this is much simpler. + // If this fails, the user could just reload page :/ + this.send("askfullgame", { target: this.gameRef.rid }); + this.askIfPeerConnected = setInterval( + () => { + if ( + !!this.people[this.gameRef.rid] && + currentUrl != document.location.href + ) { + this.send("askfullgame", { target: this.gameRef.rid }); + clearInterval(this.askIfPeerConnected); + } + }, + 1000 + ); + } + }; + // Delay of at least 2s between two game requests + const now = Date.now(); + const delay = Math.max(2000 - (now - this.askGameTime), 0); + this.askGameTime = now; + setTimeout(doAskGame, delay); + }, socketMessageListener: function(msg) { if (!this.conn) return; const data = JSON.parse(msg.data); switch (data.code) { case "pollclients": data.sockIds.forEach(sid => { - this.$set(this.people, sid, { id: 0, name: "" }); - if (sid != this.st.user.sid) { + if (sid != this.st.user.sid) this.send("askidentity", { target: sid }); - // Ask potentially missed last state, if opponent and I play - if ( - !!this.game.mycolor && - this.game.type == "live" && - this.game.score == "*" && - this.game.players.some(p => p.sid == sid) - ) { - this.send("asklastate", { target: sid }); - } - } }); break; case "connect": - if (!this.people[data.from]) - this.$set(this.people, data.from, { name: "", id: 0 }); - if (!this.people[data.from].name) { + if (!this.people[data.from]) { this.newConnect[data.from] = true; //for self multi-connects tests this.send("askidentity", { target: data.from }); } @@ -250,17 +413,29 @@ export default { case "disconnect": this.$delete(this.people, data.from); break; + case "mconnect": { + // TODO: from MyGames page : send mconnect message with the list of gid (live and corr) + // Either me (another tab) or opponent + const sid = data.from; + if (!this.onMygames.some(s => s == sid)) + { + this.onMygames.push(sid); + this.notifyTurn(sid); //TODO: this may require server ID (so, notify after receiving identity) + } + break; + if (!this.people[sid]) + this.send("askidentity", { target: sid }); + } + case "mdisconnect": + ArrayFun.remove(this.onMygames, sid => sid == data.from); + break; case "killed": // I logged in elsewhere: - alert(this.st.tr["New connexion detected: tab now offline"]); - // TODO: this fails. See https://github.com/websockets/ws/issues/489 - //this.conn.removeEventListener("message", this.socketMessageListener); - //this.conn.removeEventListener("close", this.socketCloseListener); - //this.conn.close(); this.conn = null; + alert(this.st.tr["New connexion detected: tab now offline"]); break; case "askidentity": { - // Request for identification (TODO: anonymous shouldn't need to reply) + // Request for identification const me = { // Decompose to avoid revealing email name: this.st.user.name, @@ -272,28 +447,41 @@ export default { } case "identity": { const user = data.data; - if (user.name) { - // If I multi-connect, kill current connexion if no mark (I'm older) + this.$set(this.people, user.sid, { name: user.name, id: user.id }); + // If I multi-connect, kill current connexion if no mark (I'm older) + if (this.newConnect[user.sid]) { if ( - this.newConnect[user.sid] && user.id > 0 && user.id == this.st.user.id && - user.sid != this.st.user.sid + user.sid != this.st.user.sid && + !this.killed[this.st.user.sid] ) { - if (!this.killed[this.st.user.sid]) { this.send("killme", { sid: this.st.user.sid }); this.killed[this.st.user.sid] = true; - } } - if (user.sid != this.st.user.sid) { - //I already know my identity... - this.$set(this.people, user.sid, { - id: user.id, - name: user.name - }); + delete this.newConnect[user.sid]; + } + if (!this.killed[this.st.user.sid]) { + // Ask potentially missed last state, if opponent and I play + if ( + !!this.game.mycolor && + this.game.type == "live" && + this.game.score == "*" && + this.game.players.some(p => p.sid == user.sid) + ) { + this.send("asklastate", { target: user.sid }); + this.askLastate = setInterval( + () => { + // Ask until we got a reply (or opponent disconnect): + if (!this.gotLastate && !!this.people[user.sid]) + this.send("asklastate", { target: user.sid }); + else + clearInterval(this.askLastate); + }, + 1000 + ); } } - delete this.newConnect[user.sid]; break; } case "askgame": @@ -315,18 +503,27 @@ export default { } break; case "askfullgame": - this.send("fullgame", { data: this.game, target: data.from }); + const gameToSend = Object.keys(this.game) + .filter(k => + [ + "id","fen","players","vid","cadence","fenStart","vname", + "moves","clocks","initime","score","drawOffer" + ].includes(k)) + .reduce( + (obj, k) => { + obj[k] = this.game[k]; + return obj; + }, + {} + ); + this.send("fullgame", { data: gameToSend, target: data.from }); break; case "fullgame": // Callback "roomInit" to poll clients only after game is loaded - let game = data.data; - // Move format isn't the same in storage and in browser, - // because of the 'addTime' field. - game.moves = game.moves.map(m => { return m.move || m; }); - this.loadGame(game, this.roomInit); + this.loadGame(data.data, this.roomInit); break; case "asklastate": - // Sending last state if I played a move or score != "*" + // Sending informative last state if I played a move or score != "*" if ( (this.game.moves.length > 0 && this.vr.turn != this.game.mycolor) || this.game.score != "*" || @@ -336,8 +533,8 @@ export default { const L = this.game.moves.length; const myIdx = ["w", "b"].indexOf(this.game.mycolor); const myLastate = { - // NOTE: lastMove (when defined) includes addTime lastMove: L > 0 ? this.game.moves[L - 1] : undefined, + addTime: L > 0 ? this.game.addTimes[L - 1] : undefined, // Since we played a move (or abort or resign), // only drawOffer=="sent" is possible drawSent: this.drawOffer == "sent", @@ -346,29 +543,71 @@ export default { initime: this.game.initime[1 - myIdx] //relevant only if I played }; this.send("lastate", { data: myLastate, target: data.from }); + } else { + this.send("lastate", { data: {nothing: true}, target: data.from }); } break; - case "lastate": //got opponent infos about last move - this.lastate = data.data; - if (this.game.rendered) - // Game is rendered (Board component) - this.processLastate(); - // Else: will be processed when game is ready + case "lastate": { + // Got opponent infos about last move + this.gotLastate = true; + if (!data.data.nothing) { + this.lastate = data.data; + if (this.game.rendered) + // Game is rendered (Board component) + this.processLastate(); + // Else: will be processed when game is ready + } break; + } case "newmove": { - const move = data.data; - if (move.cancelDrawOffer) { - // Opponent refuses draw - this.drawOffer = ""; - // NOTE for corr games: drawOffer reset by player in turn - if (this.game.type == "live" && !!this.game.mycolor) - GameStorage.update(this.gameRef.id, { drawOffer: "" }); + const movePlus = data.data; + const movesCount = this.game.moves.length; + if (movePlus.index > movesCount) { + // This can only happen if I'm an observer and missed a move. + if (this.gotMoveIdx < movePlus.index) + this.gotMoveIdx = movePlus.index; + if (!this.gameIsLoading) this.askGameAgain(); + } + else { + if ( + movePlus.index < movesCount || + this.gotMoveIdx >= movePlus.index + ) { + // Opponent re-send but we already have the move: + // (maybe he didn't receive our pingback...) + this.send("gotmove", {data: movePlus.index, target: data.from}); + } else { + this.gotMoveIdx = movePlus.index; + const receiveMyMove = (movePlus.color == this.game.mycolor); + if (!receiveMyMove && !!this.game.mycolor) + // Notify opponent that I got the move: + this.send("gotmove", {data: movePlus.index, target: data.from}); + if (movePlus.cancelDrawOffer) { + // Opponent refuses draw + this.drawOffer = ""; + // NOTE for corr games: drawOffer reset by player in turn + if ( + this.game.type == "live" && + !!this.game.mycolor && + !receiveMyMove + ) { + GameStorage.update(this.gameRef.id, { drawOffer: "" }); + } + } + this.$refs["basegame"].play(movePlus.move, "received", null, true); + this.processMove( + movePlus.move, + { + addTime: movePlus.addTime, + receiveMyMove: receiveMyMove + } + ); + } } - this.$refs["basegame"].play( - move.move, - "received", - null, - {addTime:move.addTime}); + break; + } + case "gotmove": { + this.opponentGotMove = true; break; } case "resign": @@ -398,6 +637,16 @@ export default { this.conn.addEventListener("message", this.socketMessageListener); this.conn.addEventListener("close", this.socketCloseListener); }, + updateCorrGame: function(obj) { + ajax( + "/games", + "PUT", + { + gid: this.gameRef.id, + newObj: obj + } + ); + }, // lastate was received, but maybe game wasn't ready yet: processLastate: function() { const data = this.lastate; @@ -406,10 +655,11 @@ export default { if (data.movesCount > L) { // Just got last move from him this.$refs["basegame"].play( - data.lastMove.move, + data.lastMove, "received", null, - {addTime:data.lastMove.addTime, initime:data.initime}); + {addTime: data.addTime, initime: data.initime} + ); } if (data.drawSent) this.drawOffer = "received"; if (data.score != "*") { @@ -436,7 +686,12 @@ export default { if (!confirm(this.st.tr["Offer draw?"])) return; this.drawOffer = "sent"; this.send("drawoffer"); - GameStorage.update(this.gameRef.id, { drawOffer: this.game.mycolor }); + if (this.game.type == "live") { + GameStorage.update( + this.gameRef.id, + { drawOffer: this.game.mycolor } + ); + } else this.updateCorrGame({ drawOffer: this.game.mycolor }); } }, abortGame: function() { @@ -476,24 +731,21 @@ export default { game.players[0] ]; } - // corr game: need to compute the clocks + initime // NOTE: clocks in seconds, initime in milliseconds - game.clocks = [tc.mainTime, tc.mainTime]; game.moves.sort((m1, m2) => m1.idx - m2.idx); //in case of + game.clocks = [tc.mainTime, tc.mainTime]; const L = game.moves.length; if (game.score == "*") { // Set clocks + initime game.initime = [0, 0]; - if (L >= 3) { - let addTime = [0, 0]; - for (let i = 2; i < L; i++) { - addTime[i % 2] += - tc.increment - - (game.moves[i].played - game.moves[i - 1].played) / 1000; + if (L >= 1) { + const gameLastupdate = game.moves[L-1].played; + game.initime[L % 2] = gameLastupdate; + if (L >= 2) { + game.clocks[L % 2] = + tc.mainTime - (Date.now() - gameLastupdate) / 1000; } - for (let i = 0; i <= 1; i++) game.clocks[i] += addTime[i]; } - if (L >= 1) game.initime[L % 2] = game.moves[L - 1].played; } // Sort chat messages from newest to oldest game.chats.sort((c1, c2) => { @@ -533,7 +785,7 @@ export default { } } } - if (game.drawOffer) { + if (!!game.drawOffer) { if (game.drawOffer == "t") // Three repetitions this.drawOffer = "threerep"; @@ -573,19 +825,32 @@ export default { // at least oppsid or oppid is available anyway: oppsid: myIdx < 0 ? undefined : game.players[1 - myIdx].sid, oppid: myIdx < 0 ? undefined : game.players[1 - myIdx].uid, - movesCount: game.moves.length + addTimes: [], //used for live games }, game, ); + if (this.gameIsLoading) + // Re-load game because we missed some moves: + // artificially reset BaseGame (required if moves arrived in wrong order) + this.$refs["basegame"].re_setVariables(); + else + // Initial loading: + this.gotMoveIdx = game.moves.length - 1; this.re_setClocks(); this.$nextTick(() => { this.game.rendered = true; // Did lastate arrive before game was rendered? if (this.lastate) this.processLastate(); }); - if (callback) callback(); + if (this.gameIsLoading) { + this.gameIsLoading = false; + if (this.gotMoveIdx >= game.moves.length) + // Some moves arrived meanwhile... + this.askGameAgain(); + } + if (!!callback) callback(); }; - if (game) { + if (!!game) { afterRetrieval(game); return; } @@ -593,15 +858,28 @@ export default { // Remote live game: forgetting about callback func... (TODO: design) this.send("askfullgame", { target: this.gameRef.rid }); } else { - // Local or corr game + // Local or corr game on server. // NOTE: afterRetrieval() is never called if game not found - GameStorage.get(this.gameRef.id, afterRetrieval); + const gid = this.gameRef.id; + if (Number.isInteger(gid) || !isNaN(parseInt(gid))) { + // corr games identifiers are integers + ajax("/games", "GET", { gid: gid }, res => { + let g = res.game; + g.moves.forEach(m => { + m.squares = JSON.parse(m.squares); + }); + afterRetrieval(g); + }); + } + else + // Local game + GameStorage.get(this.gameRef.id, afterRetrieval); } }, re_setClocks: function() { - if (this.game.movesCount < 2 || this.game.score != "*") { + if (this.game.moves.length < 2 || this.game.score != "*") { // 1st move not completed yet, or game over: freeze time - this.virtualClocks = this.game.clocks.map(s => ppt(s)); + this.virtualClocks = this.game.clocks.map(s => ppt(s).split(':')); return; } const currentTurn = this.vr.turn; @@ -613,15 +891,15 @@ export default { this.virtualClocks = [0, 1].map(i => { const removeTime = i == colorIdx ? (Date.now() - this.game.initime[colorIdx]) / 1000 : 0; - return ppt(this.game.clocks[i] - removeTime); + return ppt(this.game.clocks[i] - removeTime).split(':'); }); - let clockUpdate = setInterval(() => { + this.clockUpdate = setInterval(() => { if ( countdown < 0 || this.game.moves.length > currentMovesCount || this.game.score != "*" ) { - clearInterval(clockUpdate); + clearInterval(this.clockUpdate); if (countdown < 0) this.gameOver( currentTurn == "w" ? "0-1" : "1-0", @@ -631,62 +909,59 @@ export default { this.$set( this.virtualClocks, colorIdx, - ppt(Math.max(0, --countdown)) + ppt(Math.max(0, --countdown)).split(':') ); }, 1000); }, - // Post-process a (potentially partial) move (which was just played in BaseGame) + // Update variables and storage after a move: processMove: function(move, data) { + if (!data) data = {}; const moveCol = this.vr.turn; const doProcessMove = () => { const colorIdx = ["w", "b"].indexOf(moveCol); const nextIdx = 1 - colorIdx; - if (this.game.mycolor) { - // NOTE: 'var' to see that variable outside this block - var filtered_move = getFilteredMove(move); - } - // Send move ("newmove" event) to people in the room (if our turn) - let addTime = data ? data.addTime : 0; - if (moveCol == this.game.mycolor) { + const origMovescount = this.game.moves.length; + let addTime = + this.game.type == "live" + ? (data.addTime || 0) + : undefined; + if (moveCol == this.game.mycolor && !data.receiveMyMove) { if (this.drawOffer == "received") // I refuse draw this.drawOffer = ""; - if (this.game.movesCount >= 2) { + if (this.game.type == "live" && origMovescount >= 2) { const elapsed = Date.now() - this.game.initime[colorIdx]; // elapsed time is measured in milliseconds addTime = this.game.increment - elapsed / 1000; } - const sendMove = { - move: filtered_move, - addTime: addTime, - cancelDrawOffer: this.drawOffer == "", - // Players' SID required for /mygames page - // TODO: precompute and add this field to game object? - players: this.game.players.map(p => p.sid) - }; - this.send("newmove", { data: sendMove }); } - // Update current game object (no need for moves stack): + // Update current game object: playMove(move, this.vr); - this.game.movesCount++; +// TODO: notifyTurn: "changeturn" message + this.game.moves.push(move); // (add)Time indication: useful in case of lastate infos requested - this.game.moves.push({move:move, addTime:addTime}); + if (this.game.type == "live") + this.game.addTimes.push(addTime); this.game.fen = this.vr.getFen(); - this.game.clocks[colorIdx] += addTime; + if (this.game.type == "live") this.game.clocks[colorIdx] += addTime; + // In corr games, just reset clock to mainTime: + else this.game.clocks[colorIdx] = extractTime(this.game.cadence).mainTime; // data.initime is set only when I receive a "lastate" move from opponent - this.game.initime[nextIdx] = (data && data.initime) ? data.initime : Date.now(); - this.re_setClocks(); + this.game.initime[nextIdx] = data.initime || Date.now(); // If repetition detected, consider that a draw offer was received: - const fenObj = V.ParseFen(this.game.fen); - let repIdx = fenObj.position + "_" + fenObj.turn; - if (fenObj.flags) repIdx += "_" + fenObj.flags; - this.repeat[repIdx] = this.repeat[repIdx] ? this.repeat[repIdx] + 1 : 1; - if (this.repeat[repIdx] >= 3) this.drawOffer = "threerep"; + const fenObj = this.vr.getFenForRepeat(); + this.repeat[fenObj] = this.repeat[fenObj] ? this.repeat[fenObj] + 1 : 1; + if (this.repeat[fenObj] >= 3) this.drawOffer = "threerep"; else if (this.drawOffer == "threerep") this.drawOffer = ""; + if (!!this.game.mycolor && !data.receiveMyMove) { + // NOTE: 'var' to see that variable outside this block + var filtered_move = getFilteredMove(move); + } // Since corr games are stored at only one location, update should be // done only by one player for each move: if ( - this.game.mycolor && + !!this.game.mycolor && + !data.receiveMyMove && (this.game.type == "live" || moveCol == this.game.mycolor) ) { let drawCode = ""; @@ -702,48 +977,105 @@ export default { break; } if (this.game.type == "corr") { - GameStorage.update(this.gameRef.id, { + // corr: only move, fen and score + this.updateCorrGame({ fen: this.game.fen, move: { squares: filtered_move, played: Date.now(), - idx: this.game.moves.length - 1 + idx: origMovescount }, // Code "n" for "None" to force reset (otherwise it's ignored) drawOffer: drawCode || "n" }); } else { - // Live game: - GameStorage.update(this.gameRef.id, { - fen: this.game.fen, - move: filtered_move, - clocks: this.game.clocks, - initime: this.game.initime, - drawOffer: drawCode - }); + const updateStorage = () => { + GameStorage.update(this.gameRef.id, { + fen: this.game.fen, + move: filtered_move, + moveIdx: origMovescount, + clocks: this.game.clocks, + initime: this.game.initime, + drawOffer: drawCode + }); + }; + // The active tab can update storage immediately + if (!document.hidden) updateStorage(); + // Small random delay otherwise + else setTimeout(updateStorage, 500 + 1000 * Math.random()); } } + // Send move ("newmove" event) to people in the room (if our turn) + if (moveCol == this.game.mycolor && !data.receiveMyMove) { + const sendMove = { + move: filtered_move, + index: origMovescount, + // color is required to check if this is my move (if several tabs opened) + color: moveCol, + addTime: addTime, //undefined for corr games + cancelDrawOffer: this.drawOffer == "" + }; + this.opponentGotMove = false; + this.send("newmove", {data: sendMove}); + // If the opponent doesn't reply gotmove soon enough, re-send move: + this.retrySendmove = setInterval( + () => { + if (this.opponentGotMove) { + clearInterval(this.retrySendmove); + return; + } + let oppsid = this.game.players[nextIdx].sid; + if (!oppsid) { + oppsid = Object.keys(this.people).find( + sid => this.people[sid].id == this.game.players[nextIdx].uid + ); + } + if (!oppsid || !this.people[oppsid]) + // Opponent is disconnected: he'll ask last state + clearInterval(this.retrySendmove); + else this.send("newmove", {data: sendMove, target: oppsid}); + }, + 1000 + ); + } }; - if (this.game.type == "corr" && moveCol == this.game.mycolor) { - setTimeout(() => { - if ( - !confirm( - this.st.tr["Move played:"] + - " " + - getFullNotation(move) + - "\n" + - this.st.tr["Are you sure?"] - ) - ) { - this.$refs["basegame"].cancelLastMove(); - return; + if ( + this.game.type == "corr" && + moveCol == this.game.mycolor && + !data.receiveMyMove + ) { + let el = document.querySelector("#buttonsConfirm > .acceptBtn"); + // We may play several moves in a row: in case of, remove listener: + let elClone = el.cloneNode(true); + el.parentNode.replaceChild(elClone, el); + elClone.addEventListener( + "click", + () => { + document.getElementById("modalConfirm").checked = false; + doProcessMove(); + if (this.st.settings.gotonext) this.showNextGame(); + else this.re_setClocks(); } - doProcessMove(); - // Let small time to finish drawing current move attempt: - }, 500); + ); + // PlayOnBoard is enough, and more appropriate for Synchrone Chess + V.PlayOnBoard(this.vr.board, move); + const position = this.vr.getBaseFen(); + V.UndoOnBoard(this.vr.board, move); + this.curDiag = getDiagram({ + position: position, + orientation: V.CanFlip ? this.game.mycolor : "w" + }); + document.getElementById("modalConfirm").checked = true; } - else doProcessMove(); + else { + doProcessMove(); + this.re_setClocks(); + } + }, + cancelMove: function() { + document.getElementById("modalConfirm").checked = false; + this.$refs["basegame"].cancelLastMove(); }, gameOver: function(score, scoreMsg) { this.game.score = score; @@ -753,10 +1085,13 @@ export default { }); if (myIdx >= 0) { // OK, I play in this game - GameStorage.update(this.gameRef.id, { + const scoreObj = { score: score, scoreMsg: scoreMsg - }); + }; + if (this.Game.type == "live") + GameStorage.update(this.gameRef.id, scoreObj); + else this.updateCorrGame(scoreObj); // Notify the score to main Hall. TODO: only one player (currently double send) this.send("result", { gid: this.game.id, score: score }); } @@ -789,9 +1124,16 @@ export default { #actions display: inline-block margin: 0 - button - display: inline-block - margin: 0 + +button + display: inline-block + margin: 0 + display: inline-flex + img + height: 24px + display: flex + @media screen and (max-width: 767px) + height: 18px @media screen and (max-width: 767px) #aboveBoard @@ -807,26 +1149,48 @@ export default { font-weight: bold padding-right: 10px -.name +span#nextGame + background-color: #edda99 + cursor: pointer + display: inline-block + margin-right: 10px + +span.name font-size: 1.5rem - padding: 1px + padding: 0 3px -.time +span.time font-size: 2rem display: inline-block - margin-left: 10px + .time-left + margin-left: 10px + .time-right + margin-left: 5px + .time-separator + margin-left: 5px + position: relative + top: -1px + +span.yourturn + color: #831B1B + .time-separator + animation: blink-animation 2s steps(3, start) infinite +@keyframes blink-animation + to + visibility: hidden .split-names display: inline-block margin: 0 15px -#chat +#chatWrap > .card padding-top: 20px max-width: 767px - border: none; + border: none -#chatBtn - margin: 0 10px 0 0 +#confirmDiv > .card + max-width: 767px + max-height: 100% .draw-sent, .draw-sent:hover background-color: lightyellow @@ -839,4 +1203,21 @@ export default { .somethingnew background-color: #c5fefe + +.diagram + margin: 0 auto + max-width: 400px + // width: 100% required for Firefox + width: 100% + +#buttonsConfirm + margin: 0 + & > button > span + width: 100% + text-align: center + +button.acceptBtn + background-color: lightgreen +button.refuseBtn + background-color: red