const HEIGHT = 300; const WIDTH = 250; const CHARACTER_HEIGHT = 50; const MARGIN_TOP = 100; const MARGIN_BOTTOM = 75; let client = null; let sessionId = null; const vm = new Vue({ el: '#vue-app', data: { players: [], games: [], messages: [], username: null, usernameError: '', myGame: null, myScore: 0, myGuess: '', myGuesses: [], myProgress: [], myResults: [], opponentScore: 0, opponentResults: [], opponentUsername: null, lastWord: null }, computed: { inGame: function() { return this.myGame !== null; }, inStartedGame: function() { return this.myGame !== null && this.myGame.started === true; }, characterWidth: function() { return this.myGame === null ? 50 : (WIDTH / this.myGame.wordLength); }, wordLength: function() { return this.myGame === null ? 5 : this.myGame.wordLength; } }, directives: { autoscroll: { bind: function(element, binding) { const observer = new MutationObserver(scrollToBottom); const config = { childList: true }; observer.observe(element, config); function scrollToBottom() { element.scrollTop = element.scrollHeight; } } } }, methods: { drawMyBoard: function(ctx) { const x = 25; const y = MARGIN_TOP; this.drawUsername(ctx, x, y, this.username); this.drawScore(ctx, x, y, this.myScore); this.drawInput(ctx, x, y, this.myGuess); const yStart = this.drawGuesses(ctx, x, y, this.myGuesses, this.myResults); this.drawHint(ctx, x, yStart, this.myProgress); this.drawGrid(ctx, x, y); }, drawOpponentBoard: function(ctx) { const x = 325; const y = MARGIN_TOP; this.drawUsername(ctx, x, y, this.opponentUsername); this.drawScore(ctx, x, y, this.opponentScore); this.drawResults(ctx, x, y, this.opponentResults); this.drawGrid(ctx, x, y); }, drawLastWord: function(canvas, ctx) { if (this.lastWord) { const x = canvas.width / 2; const y = canvas.height - MARGIN_BOTTOM / 2; ctx.fillStyle = 'black'; ctx.fillText('Previous word: ' + this.lastWord.toUpperCase(), x, y); } }, drawUsername: function(ctx, x, y, username) { const usernameX = x + WIDTH / 2; const usernameY = y - 60; ctx.fillStyle = 'black'; ctx.fillText(username, usernameX, usernameY); }, drawScore: function(ctx, x, y, score) { const scoreX = x + WIDTH / 2; const scoreY = y - 25; ctx.fillStyle = 'black'; ctx.fillText(score, scoreX, scoreY); }, drawGrid: function(ctx, xOrigin, yOrigin) { ctx.beginPath(); for (let x = 0; x <= WIDTH; x += this.characterWidth) { ctx.moveTo(xOrigin + x, yOrigin); ctx.lineTo(xOrigin + x, yOrigin + HEIGHT); } for (let y = 0; y <= HEIGHT; y += CHARACTER_HEIGHT) { ctx.moveTo(xOrigin, yOrigin + y); ctx.lineTo(xOrigin + WIDTH, yOrigin + y); } ctx.strokeStyle = 'black'; ctx.stroke(); }, drawInput: function(ctx, xOrigin, yOrigin, input) { ctx.fillStyle = 'green'; let x = xOrigin + this.characterWidth * 0.5; let y = yOrigin + CHARACTER_HEIGHT * 0.5; for (let i = 0; i < input.length; i++) { ctx.fillText(input[i], x, y); x += this.characterWidth; } }, drawGuesses: function(ctx, xOrigin, yOrigin, guesses, results) { let y = yOrigin + CHARACTER_HEIGHT * 1.5; const numGuesses = Math.min(4, guesses.length); for (let i = 0; i < numGuesses; i++) { let x = xOrigin + this.characterWidth * 0.5; const guess = guesses[guesses.length - numGuesses + i]; const result = results[results.length - numGuesses + i]; for (let j = 0; j < this.wordLength; j++) { if (result[j] === 1) { ctx.fillStyle = 'yellow'; ctx.fillRect(x - this.characterWidth * 0.5, y - CHARACTER_HEIGHT * 0.5, this.characterWidth, CHARACTER_HEIGHT); } else if (result[j] === 2) { ctx.fillStyle = 'orange'; ctx.fillRect(x - this.characterWidth * 0.5, y - CHARACTER_HEIGHT * 0.5, this.characterWidth, CHARACTER_HEIGHT); } ctx.fillStyle = 'green'; ctx.fillText(guess[j], x, y); x += this.characterWidth; } y += CHARACTER_HEIGHT; } return y; }, drawResults: function(ctx, xOrigin, yOrigin, results) { let y = yOrigin + CHARACTER_HEIGHT * 1.5; const numResults = Math.min(4, results.length); for (let i = 0; i < numResults; i++) { let x = xOrigin + this.characterWidth * 0.5; const result = results[results.length - numResults + i]; for (let j = 0; j < this.wordLength; j++) { if (result[j] === 1) { ctx.fillStyle = 'yellow'; ctx.fillRect(x - this.characterWidth * 0.5, y - CHARACTER_HEIGHT * 0.5, this.characterWidth, CHARACTER_HEIGHT); } else if (result[j] === 2) { ctx.fillStyle = 'orange'; ctx.fillRect(x - this.characterWidth * 0.5, y - CHARACTER_HEIGHT * 0.5, this.characterWidth, CHARACTER_HEIGHT); } else if (result[j] === 9) { ctx.fillStyle = 'green'; ctx.fillText('-', x, y); } x += this.characterWidth; } y += CHARACTER_HEIGHT; } return y; }, drawHint: function(ctx, xOrigin, yOrigin, progress) { let x = xOrigin + this.characterWidth * 0.5; for (let i = 0; i < this.wordLength; i++) { ctx.fillText(progress[i], x, yOrigin); x += this.characterWidth; } }, getGame: function(gameId) { for (let i = 0; i < this.games.length; i++) { if (this.games[i].id === gameId) { return this.games[i]; } } return null; }, hostGame5: function(e) { client.publish({destination: '/app/hostGame5'}) }, hostGame6: function(e) { client.publish({destination: '/app/hostGame6'}) }, joinGame: function(e) { // Discard 'game-' prefix const buttonId = e.target.id; const gameId = buttonId.substr(5); client.publish({destination: '/app/joinGame', body: gameId}) }, leaveGame: function(e) { client.publish({destination: '/app/leaveGame'}) }, removeGame: function(gameId) { let indexToRemove = null; for (let i = 0; i < this.games.length; i++) { if (this.games[i].id === gameId) { indexToRemove = i; break; } } this.games.splice(indexToRemove, 1); }, removePlayer: function(name) { let indexToRemove = null; for (let i = 0; i < this.players.length; i++) { if (this.players[i].username === name) { indexToRemove = i; break; } } this.players.splice(indexToRemove, 1); }, onCanvasKeydown: function(e) { if (e.key === 'Backspace') { e.preventDefault(); this.myGuess = this.myGuess.substr(0, this.myGuess.length - 1); this.repaint(); } else if (e.key === 'Enter') { e.preventDefault(); if (this.myGuess.length === this.wordLength) { client.publish({destination: '/app/guess', body: this.myGuess}) this.myGuess = ''; this.repaint(); } } }, onCanvasKeypress: function(e) { const key = e.key.toUpperCase(); if (this.myGuess.length < this.wordLength && isCharacter(key)) { this.myGuess += key; this.repaint(); } }, onChatKeypress: function(e) { const messageInput = e.target; if (e.key === 'Enter') { // Shift+Enter -> new line if (!e.shiftKey) { e.preventDefault(); const text = messageInput.value.trim(); if (text.length === 0) { return; } messageInput.value = ''; client.publish({destination: '/app/chat', body: text}) addChatMessage(this.username, text); } } }, repaint: function() { const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); ctx.font = '25px Monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.clearRect(0, 0, canvas.width, canvas.height); this.drawMyBoard(ctx); this.drawOpponentBoard(ctx); this.drawLastWord(canvas, ctx); }, reset: function(firstLetter, clearScore) { if (!firstLetter) { firstLetter = ''; } this.myGuess = ''; this.myGuesses = []; this.myProgress = [firstLetter]; for (let i = 1; i < this.wordLength; i++) { this.myProgress[i] = ''; } this.myResults = []; this.opponentResults = []; if (clearScore) { this.myScore = 0; this.opponentScore = 0; } } }, mounted: function() { document.getElementById('nicknameInput').focus(); } }); function afterConnected(stompConnectedFrame) { console.log('Connected to STOMP endpoint') let sessionIdSubscription = null; const sessionIdTopic = '/user/topic/sessionId'; const sessionIdHandler = function(message) { console.log('Session ID: ' + message.body); sessionId = message.body; sessionIdSubscription.unsubscribe(); } sessionIdSubscription = client.subscribe(sessionIdTopic, sessionIdHandler); } function main() { let webSocketProtocol = 'ws'; if (location.protocol.startsWith('https')) { webSocketProtocol = 'wss'; } client = new StompJs.Client({ brokerURL: `${webSocketProtocol}://${location.host}/stomp`, connectHeaders: {}, //reconnectDelay: 5000, // TODO: implement reconnect functionality reconnectDelay: 0, // Disable automatic reconnects heartbeatIncoming: 25000, // send PING every 25 seconds heartbeatOutgoing: 25000, // receive PONG every 25 seconds //debug: function (str) { console.log(str); } // Print debug logs }); client.onConnect = function(frame) { afterConnected(frame); } // Will be invoked in case of error encountered at broker. // Bad login/passcode typically will cause an error. // Complaint brokers will set `message` header with a brief message. Body may contain details. // Compliant brokers will terminate the connection after any error. client.onStompError = function(frame) { console.log('Broker reported error: ' + frame.headers['message']); console.log('Additional details: ' + frame.body); const errorMessage = frame.headers['message']; addChatAnnouncement(errorMessage); addChatAnnouncement('Please reload the page!'); } client.onWebSocketClose = function(wsCloseEvent) { console.log('WebSocket close event: ' + JSON.stringify(wsCloseEvent)); addChatAnnouncement('WebSocket closed!'); addChatAnnouncement('Please reload the page!'); } client.onWebSocketError = function(wsErrorEvent) { console.log('WebSocket error event: ' + JSON.stringify(wsErrorEvent)); addChatAnnouncement('WebSocket error!'); addChatAnnouncement('Please reload the page!'); } client.activate(); let usernameSubscription = null; const usernameInput = document.getElementById('nicknameInput'); const usernameTopic = '/user/topic/sessionUsername'; const usernameHandler = function(message) { const response = JSON.parse(message.body); if (response.success === true) { console.log('Username: ' + response.username); vm.username = response.username; start(); } else { vm.usernameError = response.errorMessage; } }; usernameInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); if (sessionId === null) { vm.usernameError = 'Not connected to server'; return; } const usernameValue = usernameInput.value.trim(); if (usernameValue.length === 0) { vm.usernameError = 'Name cannot be empty'; return; } if (usernameSubscription === null) { usernameSubscription = client.subscribe(usernameTopic, usernameHandler); client.subscribe('/topic/playerJoined', onPlayerJoined); } client.publish({destination: '/app/setUsername', body: usernameValue}) } }); usernameInput.addEventListener('keyup', function(e) { if (e.key === 'Enter') { return; } const usernameValue = usernameInput.value.trim(); if (usernameValue.length !== 0) { vm.usernameError = ''; } }); } function start() { // Request permission to show notifications try { Notification.requestPermission().then(function(result) { console.log('Notification permission: ' + result); }); } catch (error) { // Safari doesn't return a promise for requestPermissions and it throws a TypeError. // It takes a callback as the first argument instead. if (error instanceof TypeError) { Notification.requestPermission(() => { console.log('Notification permission: ' + Notification.permission); }); } else { throw error; } } // Load initial data doHttpGet('/data', function(data) { const players = data.players; const games = data.games; for (let i = 0; i < games.length; i++) { const game = games[i]; vm.games.push({ id: game.id, playerOne: game.playerOne.username, playerTwo: game.playerTwo ? game.playerTwo.username : null, wordLength: game.wordLength, started: game.playerTwo !== null }); } for (let i = 0; i < players.length; i++) { const player = players[i]; vm.players.push({ username: player.username }); } }); // Subscribe to updates client.subscribe('/topic/chat', onChat); client.subscribe('/topic/gameClosed', onGameClosed); client.subscribe('/topic/gameHosted', onGameHosted); client.subscribe('/topic/gameJoined', onGameJoined); client.subscribe('/topic/gameLeft', onGameLeft); client.subscribe('/topic/playerLeft', onPlayerLeft); client.subscribe('/user/topic/opponentJoined', onOpponentJoined); client.subscribe('/user/topic/opponentReports', onOpponentReport); client.subscribe('/user/topic/playerReports', onPlayerReport); } function addChatAnnouncement(body) { vm.messages.push({ body: body }); showNotification('Announcement', body); } function addChatMessage(sender, body) { vm.messages.push({ sender: sender, body: body }); showNotification(sender, body); } function doHttpGet(url, callback) { const xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { const response = JSON.parse(xhr.responseText); callback(response); } }; xhr.open('GET', url, true); xhr.send(); } function isCharacter(key) { return key >= "A" && key <= "Z"; } function onChat(message) { const chatMessage = JSON.parse(message.body); const messageSender = chatMessage.username; const messageBody = chatMessage.message; if (messageSender === null) { addChatAnnouncement(messageBody); } else if (messageSender === vm.username) { // Ignore messages sent by yourself } else { console.log('Message from ' + messageSender + ': ' + messageBody); addChatMessage(messageSender, messageBody); } } function onGameClosed(message) { const game = JSON.parse(message.body); const gameId = game.id; const playerOne = game.playerOne.username; console.log(playerOne + ' closed Game ' + gameId); if (playerOne === vm.username) { vm.myGame = null; } vm.removeGame(gameId); } function onGameHosted(message) { const game = JSON.parse(message.body); const gameId = game.id; const wordLength = game.wordLength; const playerOne = game.playerOne.username; console.log(playerOne + ' hosted Game ' + gameId); const vueGame = { id: gameId, playerOne: playerOne, wordLength: wordLength, started: false }; vm.games.push(vueGame); if (playerOne === vm.username) { vm.myGame = vueGame; } } function onGameJoined(message) { const game = JSON.parse(message.body); const gameId = game.id; const wordLength = game.wordLength; const playerOne = game.playerOne.username; const playerTwo = game.playerTwo.username; console.log(playerTwo + ' joined ' + playerOne + "'s game"); let vueGame = null; for (let i = 0; i < vm.games.length; i++) { if (vm.games[i].id === gameId) { vm.games[i].playerTwo = playerTwo; vm.games[i].wordLength = wordLength; vm.games[i].started = true; vueGame = vm.games[i]; break; } } if (playerTwo === vm.username) { vm.myGame = vueGame; } } function onGameLeft(message) { const report = JSON.parse(message.body); const game = report.game; const gameId = game.id; const playerOne = game.playerOne.username; const gameLeaver = report.gameLeaver.username; console.log(gameLeaver + ' left ' + playerOne + "'s game"); const previousPlayers = []; for (let i = 0; i < vm.games.length; i++) { if (vm.games[i].id === gameId) { previousPlayers.push(vm.games[i].playerOne); previousPlayers.push(vm.games[i].playerTwo); vm.games[i].playerOne = playerOne; vm.games[i].playerTwo = game.playerTwo ? game.playerTwo.username : null; vm.games[i].started = false; break; } } if (gameLeaver === vm.username) { vm.myGame = null; } if (previousPlayers.indexOf(vm.username) !== -1) { vm.opponentUsername = null; vm.lastWord = null; vm.repaint(); } } function onOpponentJoined(message) { const report = JSON.parse(message.body); const firstLetter = report[0]; vm.opponentUsername = report[1]; console.log('Opponent username: ' + vm.opponentUsername); vm.reset(firstLetter, true); vm.repaint(); } function onOpponentReport(message) { console.log('Opponent report: ' + message.body); const report = JSON.parse(message.body); if (report.correct === true) { const guess = report.guess; const firstLetter = report.firstLetter; vm.opponentScore = vm.opponentScore + 100; vm.lastWord = guess; vm.reset(firstLetter, false); vm.repaint(); } else { const result = report.result; vm.opponentResults.push(result); vm.repaint(); } } function onPlayerReport(message) { console.log('My report: ' + message.body); const report = JSON.parse(message.body); if (report.correct === true) { const guess = report.guess; const firstLetter = report.firstLetter; vm.myScore = vm.myScore + 100; vm.lastWord = guess; vm.reset(firstLetter, false); vm.repaint(); } else { const guess = report.guess; const result = report.result; if (result[0] === 9) { const invalidGuess = '-'.repeat(vm.wordLength); vm.myGuesses.push(invalidGuess); } else { for (let i = 0; i < vm.wordLength; i++) { if (result[i] === 2) { vm.myProgress[i] = guess[i]; } } vm.myGuesses.push(guess); } vm.myResults.push(result); vm.repaint(); } } function onPlayerJoined(message) { const report = JSON.parse(message.body); const username = report[0]; const numUsers = report[1]; if (username === vm.username) { addChatAnnouncement('Welcome to Lingo!'); if (numUsers === 1) { addChatAnnouncement('You are the only player online'); } else { addChatAnnouncement('There are ' + numUsers + ' players online'); } } else { addChatAnnouncement(username + ' joined'); vm.players.push({ username: username }); } } function onPlayerLeft(message) { const username = message.body; addChatAnnouncement(username + ' left'); vm.removePlayer(username); } function canShowNotification() { if (document.hidden === 'undefined' || document.hidden === false) { return false; } return Notification.permission === 'granted'; } function showNotification(messageSender, messageBody) { if (canShowNotification()) { const title = messageSender; const options = { body : messageBody, icon : '/chat-bubble.png' }; const notification = new Notification(title, options); setTimeout(function() { notification.close(); }, 3000); } } main();