From 57ea79fb79a2239d4398b7d968ff03d2ad9a5ba5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 5 Jan 2023 13:25:31 -0500 Subject: [PATCH] Improve game design --- vanilla-refactor/css/index.css | 108 ++++++++++---- vanilla-refactor/index.html | 72 +++++----- vanilla-refactor/js/app.js | 65 ++++++++- vanilla-refactor/js/controller.js | 70 --------- vanilla-refactor/js/store.js | 228 ++++++++++++++++-------------- vanilla-refactor/js/view.js | 179 +++++++++++++---------- 6 files changed, 404 insertions(+), 318 deletions(-) delete mode 100644 vanilla-refactor/js/controller.js diff --git a/vanilla-refactor/css/index.css b/vanilla-refactor/css/index.css index e55825f..108e100 100644 --- a/vanilla-refactor/css/index.css +++ b/vanilla-refactor/css/index.css @@ -1,10 +1,11 @@ -@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap"); :root { --dark-gray: #1a2a32; --gray: #2e4756; --turquoise: #3cc4bf; --yellow: #f2b147; + --light-gray: #d3d3d3; } * { @@ -54,7 +55,8 @@ button:hover { } } -/* Shared */ +/* Shared utility classes */ + .hidden { display: none !important; } @@ -72,54 +74,100 @@ button:hover { rgba(0, 0, 0, 0.3) 0px 7px 13px -3px, rgba(0, 0, 0, 0.2) 0px -3px 0px inset; } +.border { + border: 1px solid rgba(211, 211, 211, 0.4) !important; +} + /* Game controls (symbols, current turn indicator, wipe score btn) */ -.control-1, -.control-2, -.control-3 { +.turn { + align-self: center; + grid-column-start: 1; + grid-column-end: 3; display: flex; - justify-content: center; align-items: center; - gap: 5px; + gap: 20px; } -.control-1 { - font-size: 30px; +@keyframes turn-icon-animation { + 0% { + transform: scale(1); + } + 25% { + transform: scale(1.4); + } + 100% { + transform: scale(1); + } } -.control-2 { - font-size: 12px; - font-weight: 600; - display: flex; - justify-content: space-around; - border-radius: 10px; - background-color: var(--gray); - white-space: nowrap; +.turn i { + font-size: 1.8rem; + margin-left: 10px; + animation: 0.6s ease-in-out turn-icon-animation; } -.control-2 p:nth-child(2) { - font-size: 10px; - font-weight: 400; - color: rgba(255, 255, 255, 0.3); +@keyframes turn-text-animation { + 0% { + opacity: 0; + transform: translateX(-20px); + } + 100% { + opacity: 100%; + transform: translateX(0); + } } -control-2 i { - font-size: 16px; +.turn p { + font-size: 14px; + animation: 0.6s ease-in-out turn-text-animation; } -.control-3 { - border-radius: 10px; - color: white; +.menu { + position: relative; +} + +.menu .items { + position: absolute; + z-index: 10; + top: 60px; + right: 0; + background-color: #203139; + border-radius: 2px; + padding: 10px; +} + +.menu .items button { background-color: transparent; + padding: 8px; + color: white; +} + +.menu .items button:hover { text-decoration: underline; + cursor: pointer; } -.control-3:hover { - opacity: 90%; +.menu-btn { + width: 100%; + height: 100%; + display: flex; + justify-content: space-around; + align-items: center; + border-radius: 10px; + color: white; + background-color: rgba(211, 211, 211, 0.05); + border: 1px solid transparent; +} + +.menu-btn:focus, +.menu-btn:hover { + background-color: rgba(211, 211, 211, 0.07); } /* Game board buttons */ -.option { + +.square { height: 100%; width: 100%; background-color: var(--gray); @@ -130,7 +178,7 @@ control-2 i { font-size: 3rem; } -.option:hover { +.square:hover { cursor: pointer; opacity: 90%; } diff --git a/vanilla-refactor/index.html b/vanilla-refactor/index.html index 2138119..6b6d5e1 100644 --- a/vanilla-refactor/index.html +++ b/vanilla-refactor/index.html @@ -12,42 +12,48 @@ Vanilla Refactor -
-
- - -
-
-
-

Player 1

-

You're up!

+
+
+
+ +

Player 1, you're up!

- -
- - -
-
-
-
-
-
-
-
-
+
diff --git a/vanilla-refactor/js/app.js b/vanilla-refactor/js/app.js index cd16d24..53eac55 100644 --- a/vanilla-refactor/js/app.js +++ b/vanilla-refactor/js/app.js @@ -1,9 +1,64 @@ -import Controller from "./controller.js"; import Store from "./store.js"; import View from "./view.js"; -const store = new Store("game-state-key"); -const view = new View(); -const controller = new Controller(store, view); +const config = { + /** + * A possible improvement to the game would be to allow for 3+ players + * and a UI to show a leaderboard. This would require a refactor since + * our logic assumes there are only 2 possible players. + */ + player1: { + id: 1, + name: "Player 1", + iconClass: "fa-x", + colorClass: "turquoise", + }, + player2: { + id: 2, + name: "Player 2", + iconClass: "fa-o", + colorClass: "yellow", + }, +}; -controller.init(); +// MV* pattern +function init() { + // "Model" + const store = new Store("game-state-key"); + + // "View" + const view = new View(); + + /** + * "Controller" logic (event listeners + handlers) + */ + view.bindPlayerMoveEvent((squareId) => { + store.playerMove(squareId); + }); + + view.bindGameResetEvent(() => { + view.closeAll(); + store.reset(); + }); + + view.bindNewRoundEvent(() => { + view.closeAll(); + store.newRound(); + }); + + /** + * ----------------------------------------------------------------------- + * IMPORTANT: This is where we listen for state changes. When the state + * changes, we re-render the entire application. + * ----------------------------------------------------------------------- + */ + store.addEventListener("statechange", (event) => { + view.render(event.target); // event.target is the Store class instance + }); + + // Loads existing state from local storage + store.init(config); +} + +// On window load, initialize app +window.addEventListener("load", () => init()); diff --git a/vanilla-refactor/js/controller.js b/vanilla-refactor/js/controller.js deleted file mode 100644 index c549454..0000000 --- a/vanilla-refactor/js/controller.js +++ /dev/null @@ -1,70 +0,0 @@ -// These imports are only used for typings to enable editor autocomplete -import Store from "./store.js"; -import View from "./view.js"; - -export default class Controller { - /** - * Controller is responsible for orchestrating UI and state changes - * based on user events - * - * @param {!Store} store - * @param {!View} view - */ - constructor(store, view) { - this.store = store; - this.view = view; - - /** - * The following lines will "register" all our element event handlers - * where the View class is responsible for adding the event listeners, but - * will pass the event (or some form of it) to the corresponding Controller - * method in this class bound to the Controller `this` context. - */ - view.bindPlayerMoveEvent(this.handlePlayerMoveEvent.bind(this)); - view.bindModalCloseEvent(this.handleCloseModal.bind(this)); - view.bindResetEvent(this.reset.bind(this)); - } - - init() { - this.view.renderView(this.store.getCurrentState()); - } - - handlePlayerMoveEvent(el) { - const start = this.store.getCurrentState(); - this.store.move(el.id, start.status.currentPlayerId); - this.view.handlePlayerMove(el, start.status.currentPlayerId); - - // Recalculate game state after move was made - const end = this.store.getCurrentState(); - - if (end.status.isComplete) { - this.handleGameEnd(end.status.winnerId); - } - } - - /** - * - * @param {(number | null)} winnerId - */ - handleGameEnd(winnerId) { - this.view.openModal( - winnerId === 1 - ? "Player 1 wins!" - : winnerId === 2 - ? "Player 2 wins!" - : "Tie!" - ); - - this.store.endGame(); - } - - handleCloseModal() { - this.view.closeModal(); - this.view.renderView(this.store.getCurrentState()); - } - - reset() { - this.store.reset(); - this.view.renderView(this.store.getCurrentState()); - } -} diff --git a/vanilla-refactor/js/store.js b/vanilla-refactor/js/store.js index bb475bf..0ff76c3 100644 --- a/vanilla-refactor/js/store.js +++ b/vanilla-refactor/js/store.js @@ -1,36 +1,16 @@ -/** - * This is the default state we load the page with - * - * NOTE: Our UI makes the assumption that there are only 2 possible - * players, and therefore, I have made this same assumption here. This - * would need a refactor if additional players were added. - */ -const defaultPlayer1 = { - id: 1, - name: "Player 1", -}; - -const defaultPlayer2 = { - id: 2, - name: "Player 2", -}; - -const defaultState = { - active: { - player1: defaultPlayer1, - player2: defaultPlayer2, - moves: [], +const initialState = { + currentGameMoves: [], // All the player moves for the active game + history: { + currentRoundGames: [], + allGames: [], }, - round: [], - history: [], }; -export default class Store { +export default class Store extends EventTarget { constructor(key) { + super(); this.storageKey = key; - this.#saveState(); - /** * Detects changes in local storage from DIFFERENT browser tabs (not the current one) * @@ -43,55 +23,114 @@ export default class Store { console.info( "State changed from another window. Updating UI with latest state." ); - this.#saveState(); + this.#refreshStorage(); }); } - getCurrentState() { + init(config) { + this.P1 = config.player1; + this.P2 = config.player2; + this.#refreshStorage(); + } + + get stats() { const state = this.#getState(); - const results = state.round.map(this.getWinner); + return state.history.currentRoundGames.reduce( + (prev, curr) => { + return { + p1Wins: prev.p1Wins + (curr.status.winner?.id === this.P1.id ? 1 : 0), + p2Wins: prev.p2Wins + (curr.status.winner?.id === this.P2.id ? 1 : 0), + ties: prev.ties + (curr.status.winner === null ? 1 : 0), + }; + }, + { + p1Wins: 0, + p2Wins: 0, + ties: 0, + } + ); + } - const player1Wins = results.filter( - (winnerId) => winnerId === defaultPlayer1.id - ).length; - const player2Wins = results.filter( - (winnerId) => winnerId === defaultPlayer2.id - ).length; - const ties = results.length - player1Wins - player2Wins; + get game() { + const state = this.#getState(); - let currentPlayerId = defaultPlayer1.id; - if (state.active.moves.length) { - currentPlayerId = state.active.moves.at(-1).playerId === 1 ? 2 : 1; + /** + * Player 1 always starts game. If no moves yet, it is P1's turn. + * + * Otherwise, check who played last to determine who's turn it is. + */ + let currentPlayer = this.P1; + if (state.currentGameMoves.length) { + const lastPlayer = state.currentGameMoves.at(-1).player; + currentPlayer = lastPlayer.id === this.P1.id ? this.P2 : this.P1; } - const winnerId = this.getWinner(state.active); + const winner = this.#getWinner(state.currentGameMoves); return { + moves: state.currentGameMoves, + currentPlayer, status: { - isComplete: state.active.moves.length === 9 || winnerId != null, - moves: state.active.moves, - currentPlayerId, - winnerId, - }, - stats: { - totalGames: results.length, - ties, - player1Wins, - player2Wins, + isComplete: winner != null || state.currentGameMoves.length === 9, + winner, }, }; } - getWinner(game) { - const player1Moves = game.moves - .filter((m) => m.playerId === defaultPlayer1.id) - .map((m) => +m.squareId); + playerMove(squareId) { + const { currentGameMoves } = structuredClone(this.#getState()); + + currentGameMoves.push({ + player: this.game.currentPlayer, + squareId, + }); + + this.#saveState((prev) => ({ ...prev, currentGameMoves })); + } + + /** + * Resets the game. + * + * If the current game is complete, the game is archived. + * If the current game is NOT complete, it is deleted. + */ + reset() { + const stateCopy = structuredClone(this.#getState()); - const player2Moves = game.moves - .filter((m) => m.playerId === defaultPlayer2.id) - .map((m) => +m.squareId); + // If game is complete, archive it to history object + if (this.game.status.isComplete) { + const { moves, status } = this.game; + stateCopy.history.currentRoundGames.push({ moves, status }); + } + stateCopy.currentGameMoves = []; + this.#saveState(stateCopy); + } + + /** + * Resets the scoreboard (wins, losses, and ties) + */ + newRound() { + this.reset(); + + const stateCopy = structuredClone(this.#getState()); + stateCopy.history.allGames.push(...stateCopy.history.currentRoundGames); + stateCopy.history.currentRoundGames = []; + + this.#saveState(stateCopy); + } + + #getWinner(moves) { + const p1Moves = moves + .filter((move) => move.player.id === this.P1.id) + .map((move) => +move.squareId); + + const p2Moves = moves + .filter((move) => move.player.id === this.P2.id) + .map((move) => +move.squareId); + + // Our grid starts in top-left corner and increments left=>right, top=>bottom const winningPatterns = [ [1, 2, 3], [1, 5, 9], @@ -103,68 +142,49 @@ export default class Store { [7, 8, 9], ]; - let winnerId = null; + let winner = null; winningPatterns.forEach((pattern) => { - const player1Wins = pattern.every((v) => player1Moves.includes(v)); - const player2Wins = pattern.every((v) => player2Moves.includes(v)); + const p1Wins = pattern.every((v) => p1Moves.includes(v)); + const p2Wins = pattern.every((v) => p2Moves.includes(v)); - if (player1Wins) winnerId = 1; - if (player2Wins) winnerId = 2; + if (p1Wins) winner = this.P1; + if (p2Wins) winner = this.P2; }); - return winnerId; - } - - move(squareId, playerId) { - // Make a copy of prev state, mutate the copy, set as new state - const newState = structuredClone(this.#getState()); - newState.active.moves.push({ - playerId, - squareId, - }); - this.#saveState(newState); + return winner; } - endGame() { - const newState = structuredClone(this.#getState()); - - // Save the game to the current round - newState.round.push(newState.active); - - // Reset active game - newState.active = defaultState.active; - - this.#saveState(newState); + #refreshStorage() { + this.#saveState(this.#getState()); } - // Resets the scoreboard, moves all prior games to the history object - reset() { - const newState = structuredClone(this.#getState()); + #saveState(stateOrFn) { + const prevState = this.#getState(); - newState.active = defaultState.active; - newState.history = [...newState.history, ...newState.round]; - newState.round = []; + let newState; - this.#saveState(newState); - } + switch (typeof stateOrFn) { + // When callback fn is passed, call it with the prior state and derive the new state from it + case "function": + newState = stateOrFn(prevState); + break; - /** - * Saves the game state. - * - * Private method - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_class_fields - * - * If no state provided, saves the current state, which - * is useful when the state changes in another browser tab. - */ - #saveState(gameState) { - const currentState = gameState ?? this.#getState(); + // When object passed, set it directly + case "object": + newState = stateOrFn; + break; + default: + throw new Error("Must pass object or fn to #saveState() method"); + } - window.localStorage.setItem(this.storageKey, JSON.stringify(currentState)); + // Update state and emit event + window.localStorage.setItem(this.storageKey, JSON.stringify(newState)); + this.dispatchEvent(new Event("statechange")); } #getState() { const item = window.localStorage.getItem(this.storageKey); - return item ? JSON.parse(item) : defaultState; + return item ? JSON.parse(item) : initialState; } } diff --git a/vanilla-refactor/js/view.js b/vanilla-refactor/js/view.js index 8a88d3c..52842c6 100644 --- a/vanilla-refactor/js/view.js +++ b/vanilla-refactor/js/view.js @@ -1,119 +1,146 @@ +// This import is only for jsdoc typings and intellisense +import Store from "./store.js"; + export default class View { constructor() { - // The $ prefix contains all of our selected HTML elements + /** + * Select elements we want to control for convenience and clarity + */ this.$grid = document.querySelector(".grid"); - this.$options = document.querySelectorAll(".option"); - this.$resetBtn = document.querySelector(".control-3"); + this.$squares = document.querySelectorAll(".square"); + this.$resetBtn = document.querySelector('[data-id="reset-btn"]'); + this.$newRoundBtn = document.querySelector('[data-id="new-round-btn"]'); this.$modal = document.querySelector(".modal"); this.$modalText = this.$modal.querySelector("p"); this.$modalNewGame = this.$modal.querySelector("button"); - this.$turnBox = document.querySelector(".control-2"); + this.$turnBox = document.querySelector('[data-id="turn"]'); this.$player1Stats = document.querySelector('[data-id="player1-stats"]'); this.$ties = document.querySelector('[data-id="ties"]'); this.$player2Stats = document.querySelector('[data-id="player2-stats"]'); + this.$menuBtn = document.querySelector('[data-id="menu-button"]'); + this.$menuPopover = document.querySelector('[data-id="menu-popover"]'); + + /** + * UI-only event listeners + * + * These are listeners that do not mutate state and therefore + * can be contained within View entirely. + */ + this.$menuBtn.addEventListener("click", (event) => { + this.#toggleMenu(); + }); } - renderView(currentState) { - const { - stats: { player1Wins, player2Wins, ties }, - status: { moves }, - } = currentState; + /** + * This application follows a declarative rendering methodology + * and will re-render every time the state changes + * + * @param {Store!} store - the store object that contains state getters + */ + render(store) { + const { stats, game } = store; + + this.$player1Stats.textContent = `${stats.p1Wins} wins`; + this.$player2Stats.textContent = `${stats.p2Wins} wins`; + this.$ties.textContent = stats.ties; - this.$player1Stats.textContent = `${player1Wins}W ${player2Wins}L`; - this.$player2Stats.textContent = `${player2Wins}W ${player1Wins}L`; - this.$ties.textContent = ties; + this.$squares.forEach((square) => { + // Clears existing icons if there are any + square.replaceChildren(); + + const move = game.moves.find((m) => m.squareId === +square.id); + + if (!move?.player) return; - // If current game doesn't have moves, make sure player 1 is up - if (!moves.length) { - this.setTurnIndicator(1); + this.#handlePlayerMove(square, move.player); + }); + + if (game.status.isComplete) { + this.#openModal( + game.status.winner ? `${game.status.winner.name} wins!` : "Tie!" + ); + } else { + this.closeAll(); + this.#setTurnIndicator(game.currentPlayer); } + } - this.$options.forEach((option) => { - // Clears existing icons if there are any - option.replaceChildren(); + closeAll() { + this.#closeMenu(); + this.#closeModal(); + } - const move = moves.find((m) => m.squareId === option.id); + /** + * Events that are handled by the "Controller" in app.js + * ---------------------------------------------------------- + */ - if (!move?.playerId) return; + bindGameResetEvent(handler) { + this.$resetBtn.addEventListener("click", () => handler()); + this.$modalNewGame.addEventListener("click", () => handler()); + } + + bindNewRoundEvent(handler) { + this.$newRoundBtn.addEventListener("click", (event) => + handler(event.target) + ); + } - this.handlePlayerMove(option, move.playerId); + bindPlayerMoveEvent(handler) { + this.#delegate(this.$grid, ".square", "click", (event) => { + handler(+event.target.id); }); } + /** + * All methods below ⬇️ are private convenience methods used for updating the UI + * ----------------------------------------------------------------------------- + */ + /** * @param {!Element} el - * @param {!number} currentPlayerId + * @param {!array} classList */ - handlePlayerMove(el, currentPlayerId) { + #handlePlayerMove(el, player) { const icon = document.createElement("i"); - icon.classList.add("fa-solid"); + icon.classList.add("fa-solid", player.iconClass, player.colorClass); + el.replaceChildren(icon); + } - // Different icon depending on who made the play - if (currentPlayerId === 1) { - icon.classList.add("fa-x", "turquoise"); - } else { - icon.classList.add("fa-o", "yellow"); - } + #setTurnIndicator(player) { + const { iconClass, colorClass, name } = player; - el.replaceChildren(icon); + const icon = document.createElement("i"); + icon.classList.add("fa-solid", iconClass, colorClass); - const nextPlayerId = currentPlayerId === 1 ? 2 : 1; + const label = document.createElement("p"); + label.classList.add(colorClass); + label.innerText = `${name}, you're up!`; - this.setTurnIndicator(nextPlayerId); + this.$turnBox.replaceChildren(icon, label); } - openModal(resultText) { + #openModal(resultText) { this.$modalText.textContent = resultText; this.$modal.classList.remove("hidden"); } - closeModal() { + #closeModal() { this.$modal.classList.add("hidden"); } - setTurnIndicator(playerId) { - // Clear existing content - this.$turnBox.replaceChildren(); - - if (playerId === 1) { - this.$turnBox.insertAdjacentHTML( - "afterbegin", - ` -
-

Player 1

-

You're up!

-
- - ` - ); - } else { - this.$turnBox.insertAdjacentHTML( - "afterbegin", - ` -
-

Player 2

-

You're up!

-
- - ` - ); - } - } - - bindPlayerMoveEvent(handler) { - this.#delegate(this.$grid, ".option", "click", (event) => { - handler(event.target); - }); - } - - bindModalCloseEvent(handler) { - this.$modalNewGame.addEventListener("click", (event) => - handler(event.target) - ); + #closeMenu() { + this.$menuPopover.classList.add("hidden"); + this.$menuBtn.classList.remove("border"); + this.$menuBtn.querySelector("i").classList.add("fa-chevron-down"); + this.$menuBtn.querySelector("i").classList.remove("fa-chevron-up"); } - bindResetEvent(handler) { - this.$resetBtn.addEventListener("click", (event) => handler(event.target)); + #toggleMenu() { + this.$menuPopover.classList.toggle("hidden"); + this.$menuBtn.classList.toggle("border"); + this.$menuBtn.querySelector("i").classList.toggle("fa-chevron-down"); + this.$menuBtn.querySelector("i").classList.toggle("fa-chevron-up"); } /**