// Copyright 2018-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) require('./subModule.js').extend(TicTacToe); // Extends the SubModule class. /** * @classdesc Manages a tic-tac-toe game. * @class * @augments SubModule * @listens Command#ticTacToe */ function TicTacToe() { const self = this; /** @inheritdoc */ this.myName = 'TicTacToe'; /** @inheritdoc */ this.initialize = function() { self.command.on('tictactoe', commandTicTacToe); }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('tictactoe'); }; /** @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 /** * 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 tic tac toe 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#ticTacToe */ function commandTicTacToe(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 tic tac toe 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; /** * An array of 9 elements that stores 0, 1, or 2 to signify who owns which * space of the board. 0 is nobody, 1 is player 1, 2 is player 2. * * @type {number[]} */ this.board = [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; /** * The template string for the game's board. * * @private * @type {string} * @constant * @default */ const boardString = '```css\n | | \n{0}|{1}|{2}\n___|___|___\n' + ' | | \n{3}|{4}|{5}\n___|___|___\n' + ' | | \n{6}|{7}|{8}\n | | \n```'; /** * 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!'); } const finalBoard = boardString.replace(/\{(.)\}/g, function(match, num) { switch (game.board[num]) { case 1: if (winner > 0 && winner != 1) return ' x '; return ' X '; case 2: if (winner > 0 && winner != 2) return ' o '; return ' O '; default: if (winner > 0) return ' '; return ' ' + num + ' '; } }); embed.addFields([{name: '\u200B', value: 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 TicTacToe...`'}).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 < 8) 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 {TicTacToe~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 < 9; 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) { game.players.p1 = reactions.first().users.cache.first(2)[1]; } if (!game.players.p2 && game.turn == 2) { game.players.p2 = reactions.first().users.cache.first(2)[1]; } // reactions.first().users.remove(self.client.user).catch(() => {}); let move = -1; const choice = reactions.first().emoji; for (let i = 0; i < 9; i++) { if (emoji[i] == choice.name && game.board[i] === 0) { move = i; break; } } if (move == -1) { addListener(msg, game); return; } game.board[move] = game.turn; const winner = checkWin(game.board, 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} latest The 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, latest) { const player = board[latest]; // Column const col = latest % 3; for (let i = 0; i < 3; i++) { if (board[i * 3 + col] != player) break; if (i == 2) return player; } // Row const row = Math.floor(latest / 3); for (let i = 0; i < 3; i++) { if (board[i + row * 3] != player) break; if (i == 2) return player; } // Diagonals switch (latest) { case 0: case 4: case 8: if (board[0] == board[4] && board[4] == board[8]) return player; break; default: break; } switch (latest) { case 2: case 4: case 6: if (board[2] == board[4] && board[4] == board[6]) return player; break; default: break; } // Is board full for (let i = 0; i < 9; i++) { if (board[i] == 0) return 0; } return 3; } } module.exports = new TicTacToe();