|
| 1 | +/** |
| 2 | + * GameStatus constants |
| 3 | + */ |
| 4 | +export const GameStatuses = { |
| 5 | + WAITING: 'WAITING', // waiting player to join |
| 6 | + STARTED: 'STARTED', // all spots are filled; can start playing |
| 7 | + FINISHED: 'FINISHED', // game is finished |
| 8 | + ABANDONED: 'ABANDONED' // all players left; game is abandoned |
| 9 | +} |
| 10 | + |
| 11 | +/** |
| 12 | + * Game model, encapsulating game-related logics |
| 13 | + * It is data store independent |
| 14 | + */ |
| 15 | +export class Game { |
| 16 | + /** |
| 17 | + * Constructor accepting a single param gameDoc. |
| 18 | + * gameDoc should contain the permanent fields of the game instance. |
| 19 | + * Normally, the fields are saved into data store, and later get retrieved |
| 20 | + * |
| 21 | + * If gameDoc is not given, then we will instantiate a new object with default fields |
| 22 | + * |
| 23 | + * @param {Object} [gameDoc] Optional doc retrieved from Games collection |
| 24 | + */ |
| 25 | + constructor(gameDoc) { |
| 26 | + if (gameDoc) { |
| 27 | + _.extend(this, gameDoc); |
| 28 | + } else { |
| 29 | + this.status = GameStatuses.WAITING; |
| 30 | + this.board = [[null, null, null], [null, null, null], [null, null, null]]; |
| 31 | + this.players = []; |
| 32 | + } |
| 33 | + } |
| 34 | + |
| 35 | +/** |
| 36 | + * Return a list of fields that are required for permanent storage |
| 37 | + * |
| 38 | + * @return {[]String] List of fields required persistent storage |
| 39 | + */ |
| 40 | + persistentFields() { |
| 41 | + return ['status', 'board', 'players']; |
| 42 | + } |
| 43 | + |
| 44 | +/** |
| 45 | + * Handle join game action |
| 46 | + * |
| 47 | + * @param {User} user Meteor.user object |
| 48 | + */ |
| 49 | + userJoin(user) { |
| 50 | + if (this.status !== GameStatuses.WAITING) { |
| 51 | + throw "cannot join at current state"; |
| 52 | + } |
| 53 | + if (this.userIndex(user) !== null) { |
| 54 | + throw "user already in game"; |
| 55 | + } |
| 56 | + |
| 57 | +this.players.push({ |
| 58 | + userId: user._id, |
| 59 | + username: user.username |
| 60 | + }); |
| 61 | + |
| 62 | +// game automatically start with 2 players |
| 63 | + if (this.players.length === 2) { |
| 64 | + this.status = GameStatuses.STARTED; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | +/** |
| 69 | + * Handle leave game action |
| 70 | + * |
| 71 | + * @param {User} user Meteor.user object |
| 72 | + */ |
| 73 | + userLeave(user) { |
| 74 | + if (this.status !== GameStatuses.WAITING) { |
| 75 | + throw "cannot leave at current state"; |
| 76 | + } |
| 77 | + if (this.userIndex(user) === null) { |
| 78 | + throw "user not in game"; |
| 79 | + } |
| 80 | + this.players = _.reject(this.players, (player) => { |
| 81 | + return player.userId === user._id; |
| 82 | + }); |
| 83 | + |
| 84 | +// game is considered abandoned when all players left |
| 85 | + if (this.players.length === 0) { |
| 86 | + this.status = GameStatuses.ABANDONED; |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | +/** |
| 91 | + * Handle user action. i.e. putting marker on the game board |
| 92 | + * |
| 93 | + * @param {User} user |
| 94 | + * @param {Number} row Row index of the board |
| 95 | + * @param {Number} col Col index of the board |
| 96 | + */ |
| 97 | + userMark(user, row, col) { |
| 98 | + let playerIndex = this.userIndex(user); |
| 99 | + let currentPlayerIndex = this.currentPlayerIndex(); |
| 100 | + if (currentPlayerIndex !== playerIndex) { |
| 101 | + throw "user cannot make move at current state"; |
| 102 | + } |
| 103 | + if (row < 0 || row >= this.board.length || col < 0 || col >= this.board[row].length) { |
| 104 | + throw "invalid row|col input"; |
| 105 | + } |
| 106 | + if (this.board[row][col] !== null) { |
| 107 | + throw "spot is filled"; |
| 108 | + } |
| 109 | + this.board[row][col] = playerIndex; |
| 110 | + |
| 111 | + let winner = this.winner(); |
| 112 | + if (winner !== null) { |
| 113 | + this.status = GameStatuses.FINISHED; |
| 114 | + } |
| 115 | + if (this._filledCount() === 9) { |
| 116 | + this.status = GameStatuses.FINISHED; |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * @return {Number} currentPlayerIndex 0 or 1 |
| 122 | + */ |
| 123 | + currentPlayerIndex() { |
| 124 | + if (this.status !== GameStatuses.STARTED) { |
| 125 | + return null; |
| 126 | + } |
| 127 | + |
| 128 | +// determine the current player by counting the filled cells |
| 129 | + // if even, then it's first player, otherwise it's second player |
| 130 | + let filledCount = this._filledCount(); |
| 131 | + return (filledCount % 2 === 0? 0: 1); |
| 132 | + } |
| 133 | + |
| 134 | +/** |
| 135 | + * Determine the winner of the game |
| 136 | + * |
| 137 | + * @return {Number} playerIndex of the winner (0 or 1). null if not finished |
| 138 | + */ |
| 139 | + winner() { |
| 140 | + let board = this.board; |
| 141 | + for (let playerIndex = 0; playerIndex < 2; playerIndex++) { |
| 142 | + // check rows |
| 143 | + for (let r = 0; r < 3; r++) { |
| 144 | + let allMarked = true; |
| 145 | + for (let c = 0; c < 3; c++) { |
| 146 | + if (board[r][c] !== playerIndex) allMarked = false; |
| 147 | + } |
| 148 | + if (allMarked) return playerIndex; |
| 149 | + } |
| 150 | + |
| 151 | +// check cols |
| 152 | + for (let c = 0; c < 3; c++) { |
| 153 | + let allMarked = true; |
| 154 | + for (let r = 0; r < 3; r++) { |
| 155 | + if (board[r][c] !== playerIndex) allMarked = false; |
| 156 | + } |
| 157 | + if (allMarked) return playerIndex; |
| 158 | + } |
| 159 | + |
| 160 | +// check diagonals |
| 161 | + if (board[0][0] === playerIndex && board[1][1] === playerIndex && board[2][2] === playerIndex) { |
| 162 | + return playerIndex; |
| 163 | + } |
| 164 | + if (board[0][2] === playerIndex && board[1][1] === playerIndex && board[2][0] === playerIndex) { |
| 165 | + return playerIndex; |
| 166 | + } |
| 167 | + } |
| 168 | + return null; |
| 169 | + } |
| 170 | + |
| 171 | +/** |
| 172 | + * Helper method to retrieve the player index of a user |
| 173 | + * |
| 174 | + * @param {User} user Meteor.user object |
| 175 | + * @return {Number} index 0-based index, or null if not found |
| 176 | + */ |
| 177 | + userIndex(user) { |
| 178 | + for (let i = 0; i < this.players.length; i++) { |
| 179 | + if (this.players[i].userId === user._id) { |
| 180 | + return i; |
| 181 | + } |
| 182 | + } |
| 183 | + return null; |
| 184 | + } |
| 185 | + |
| 186 | + _filledCount() { |
| 187 | + let filledCount = 0; |
| 188 | + for (let r = 0; r < 3; r++) { |
| 189 | + for (let c = 0; c < 3; c++) { |
| 190 | + if (this.board[r][c] !== null) filledCount++; |
| 191 | + } |
| 192 | + } |
| 193 | + return filledCount; |
| 194 | + } |
| 195 | +} |
0 commit comments