// Copyright 2018 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) require('./subModule.js').extend(Connect4); // Extends the SubModule class. /** * @classdesc Manages a Connect 4 game. * @class * @augments SubModule * @listens Command#connect4 */ function Connect4() { const self = this; /** @inheritdoc */ this.myName = 'Connect4'; /** @inheritdoc */ this.initialize = function() { self.command.on('connect4', commandConnect4); }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('connect4'); }; /** @inheritdoc */ this.unloadable = function() { return numGames === 0; }; /** * Maximum amount of time to wait for reactions to a message. Also becomes * maximum amount of time a game will run with no input, because controls will * be disabled after this timeout. * * @private * @constant * @type {number} * @default 5 Minutes */ const maxReactAwaitTime = 5 * 1000 * 60; // 5 Minutes /** * The number of rows in the board. * * @private * @constant * @type {number} * @default */ const numRows = 6; /** * The number of columns in the board. * * @private * @constant * @type {number} * @default */ const numCols = 7; /** * Helper object of emoji characters mapped to names. * * @private * @type {object.<string>} * @constant * @default */ const emoji = { 0: '\u0030\u20E3', 1: '\u0031\u20E3', 2: '\u0032\u20E3', 3: '\u0033\u20E3', 4: '\u0034\u20E3', 5: '\u0035\u20E3', 6: '\u0036\u20E3', 7: '\u0037\u20E3', 8: '\u0038\u20E3', 9: '\u0039\u20E3', X: '❌', O: '⭕', }; /** * The number of currently active games. Used to determine of submodule is * unloadable. * * @private * @type {number} * @default */ let numGames = 0; /** * Starts a connect 4 game. If someone is mentioned it will start a game * between the message author and the mentioned person. Otherwise, waits for * someone to play. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#connect4 */ function commandConnect4(msg) { const players = {p1: msg.author, p2: null}; if (msg.mentions.users.size > 0) { players.p2 = msg.mentions.users.first(); } self.createGame(players, msg.channel); } /** * Class that stores the current state of a connect 4 game. * * @class * * @public * @param {{p1: Discord~User, p2: Discord~User}} players The players in this * game. * @param {Discord~Message} msg The message displaying the current game. */ this.Game = function(players, msg) { const game = this; /** * The players in this game. * * @type {{p1: Discord~User, p2: Discord~User}} */ this.players = players; /** * 2D Array of a 7w x 6h board. 0 is nobody, 1 is player 1, 2 is player 2. * * @type {Array.<number[]>} */ this.board = [ [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ]; /** * Which player's turn it is. Either 1 or 2. * * @type {number} */ this.turn = 1; /** * The message displaying the current game. * * @type {Discord~Message} */ this.msg = msg; /** * Edit the current message to show the current board. * * @param {number} [winner=0] The player who has won the game. 0 is game not * done, 1 is player 1, 2 is player 2, 3 is draw. */ this.print = function(winner = 0) { const embed = new self.Discord.EmbedBuilder(); const names = ['Nobody', 'Nobody']; let gameFull = true; if (this.players.p1) { names[0] = this.players.p1.username; } else { gameFull = false; } if (this.players.p2) { names[1] = this.players.p2.username; } else { gameFull = false; } embed.setTitle(names[0] + ' vs ' + names[1]); if (!gameFull) { embed.setDescription('To join the game, just make a move!'); } let finalBoard = '```css\n' + // '012345678901234567890123456\n' + ' Connect Four \n' + this.board .map((row, rowNum) => { return row .map((cell, colNum) => { switch (game.board[rowNum][colNum]) { case 1: if (winner > 0 && winner != 1) { return ' x '; } return ' X '; case 2: if (winner > 0 && winner != 2) { return ' o '; } return ' O '; default: return ' '; } }) .join('|'); }) .join('\n'); finalBoard += '\n'; for (let i = 0; i < numCols; i++) { finalBoard += '___'; if (i != numCols - 1) finalBoard += '|'; } finalBoard += '\n'; for (let i = 0; i < numCols; i++) { finalBoard += ' ' + i + ' '; if (i != numCols - 1) finalBoard += '|'; } finalBoard += '\n```'; embed.addFields([{name: '\u200B', values: finalBoard}]); if (winner == 0) { embed.addFields([{ name: names[this.turn - 1] + '\'s turn (' + (this.turn == 1 ? 'X' : 'O') + ')', value: '`' + names[0] + '` is X\n`' + names[1] + '` is O', }]); } else { numGames--; embed.addFields([{ name: '\u200B', value: '`' + names[0] + '` was X\n`' + names[1] + '` was O', }]); } if (winner == 3) { embed.addFields([{name: 'Draw game!', value: 'Nobody wins'}]); } else if (winner == 2) { embed.addFields([{ name: names[1] + ' Won! ' + emoji.O, value: names[0] + ', try harder next time.', }]); } else if (winner == 1) { embed.addFields([{ name: names[0] + ' Won! ' + emoji.X, value: names[1] + ', try harder next time.', }]); } msg.edit({content: '\u200B', embeds: [embed]}); }; }; /** * Create a game with the given players in a given text channel. * * @public * @param {{p1: Discord~User, p2: Discord~User}} players The players in the * game. * @param {Discord~TextChannel} channel The text channel to send messages. */ this.createGame = function(players, channel) { numGames++; channel.send({content: '`Loading Connect 4...`'}).then((msg) => { const game = new self.Game(players, msg); game.print(); addReactions(msg); addListener(msg, game); }); }; /** * Add the reactions to a message for controls of the game. Recursive. * * @private * @param {Discord~Message} msg The message to add the reactions to. * @param {number} index The number of reactions we have added so far. */ function addReactions(msg, index = 0) { msg.react(emoji[index]).then(() => { if (index < numCols - 1) addReactions(msg, index + 1); }); } /** * Add the listener for reactions to the game. * * @private * @param {Discord~Message} msg The message to add the reactions to. * @param {Connect4~Game} game The game to update when changes are made. */ function addListener(msg, game) { const filter = (reaction, user) => { if (user.id != self.client.user.id) { // reaction.users.remove(user).catch(() => {}); } else { return false; } if (game.turn == 1 && game.players.p1 && user.id != game.players.p1.id) { return false; } if (game.turn == 2 && game.players.p2 && user.id != game.players.p2.id) { return false; } for (let i = 0; i < numCols; i++) { if (emoji[i] == reaction.emoji.name) return true; } return false; }; msg.awaitReactions({filter, max: 1, time: maxReactAwaitTime}) .then((reactions) => { if (reactions.size == 0) { msg.reactions.removeAll().catch(() => {}); msg.edit({ content: 'Game timed out!\nThe game has ended because nobody made a ' + 'move in too long!', }); game.print(game.turn == 1 ? 2 : 1); return; } if (!game.players.p1 && game.turn == 1) { const reactUsers = reactions.first().users.cache.first(2); game.players.p1 = reactUsers[1] || reactUsers[0]; } if (!game.players.p2 && game.turn == 2) { const reactUsers = reactions.first().users.cache.first(2); game.players.p2 = reactUsers[1] || reactUsers[0]; } let move = -1; const choice = reactions.first().emoji; for (let i = 0; i < numCols; i++) { if (emoji[i] == choice.name) { move = i; break; } } if (move == -1) { addListener(msg, game); return; } if (game.board[0][move] != 0) { addListener(msg, game); return; } /* if (game.board[1][move] != 0) { reactions.first().users.remove(self.client.user); } */ let row; for (row = 1; row < numRows; row++) { if (game.board[row][move] != 0) { break; } } row--; game.board[row][move] = game.turn; const winner = checkWin(game.board, row, move); if (winner != 0) { msg.reactions.removeAll().catch(() => {}); } else { game.turn = game.turn === 1 ? 2 : 1; addListener(msg, game); } game.print(winner); }); } /** * Checks if the given board has a winner, or if the game is over. * * @param {number[]} board Array of 9 numbers defining a board. 0 is nobody, 1 * is player 1, 2 is player 2. * @param {number} latestR The row index where the latest move occurred. * @param {number} latestC The column index where the latest move occurred. * @returns {number} Returns 0 if game is not over, 1 if player 1 won, 2 if * player 2 won, 3 if draw. */ function checkWin(board, latestR, latestC) { const player = board[latestR][latestC]; // Column let count = 0; for (let r = latestR - 3; r <= latestR + 3 && r < numRows; r++) { if (r < 0) continue; if (board[r][latestC] == player) count++; else count = 0; if (count == 4) return player; } // Row count = 0; for (let c = latestC - 3; c <= latestC + 3 && c < numCols; c++) { if (c < 0) continue; if (board[latestR][c] == player) count++; else count = 0; if (count == 4) return player; } // Diag TL to BR count = 0; for (let r = latestR - 3, c = latestC - 3; r <= latestR + 3 && r < numRows && c <= latestC + 3 && c < numCols; r++, c++) { if (r < 0) continue; if (c < 0) continue; if (board[r][c] == player) count++; else count = 0; if (count == 4) return player; } // Diag BL to TR count = 0; for (let r = latestR + 3, c = latestC - 3; r >= latestR - 3 && r >= 0 && c <= latestC + 3 && c < numCols; r--, c++) { if (r > numRows - 1) continue; if (c < 0) continue; if (board[r][c] == player) count++; else count = 0; if (count == 4) return player; } // Is board full for (let r = 0; r < numRows; r++) { for (let c = 0; c < numCols; c++) { if (board[r][c] === 0) return 0; } } return 3; } } module.exports = new Connect4();