Skip to content
Snippets Groups Projects
Select Git revision
  • master default protected
1 result

cw2-2048

  • Clone with SSH
  • Clone with HTTPS
  • k23-lloyd's avatar
    k23-lloyd authored
    32f47f74
    History
    Name Last commit Last update
    screenshots
    game.cpp
    game.hpp
    main.cpp
    readme.md

    Programming in C++ CW2: 2048

    1. Introduction

    This project is a terminal-based implementation of the game 2048.

    2. Implementation Details

    • Game Class (Game.hpp, Game.cpp)

      • How: All game states (board, score, size) and logic (moves, display, game over detection) are encapsulated within the Game class. Public methods provide the interface for interaction (e.g., processInput, displayBoard), while private members and methods handle the internal state and helper logic.
      • Why: Using a class follows object-oriented principles by grouping related data and functions together. This makes the code modular, easier to understand & maintain, and prevents polluting the global namespace. It clearly separates the core game engine from the main execution driver in main.cpp.
    • Board Representation (Game.hpp)

      • How: A std::vector<std::vector<int>> board is used as a private member variable within the Game class to represent the game grid. Integer values store the tile numbers, with 0 indicating an empty cell.
      • Why: std::vector provides a standard, dynamic C++ container. A 2D vector is a natural fit for representing a grid, allowing intuitive access via row and column indices (board[r][c]). Storing integers is sufficient for the tile values in 2048.
    • Initialization (Game::Game, initializeBoard, spawnInitialTiles in Game.cpp)

      • How: The Game constructor initializes the board size and score. It then calls initializeBoard() to resize the board vector to the correct dimensions and fill it with zeros. Finally, it calls spawnInitialTiles(), which in turn calls addRandomTile() twice to place the starting tiles.
      • Why: This ensures every game starts with a clean, correctly sized empty board and the standard two initial random tiles. Breaking initialization into specific helper functions (initializeBoard, spawnInitialTiles, addRandomTile) makes the constructor cleaner and the initialization steps explicit and reusable.
    • Random Tile Generation (addRandomTile in Game.cpp)

      • How: This function first scans the board to find all cells containing 0 and stores their coordinates ({row, col}) in a std::vector<std::pair<int, int>> emptyCells. If no empty cells are found, it returns immediately. Otherwise, it uses C++'s <random> library (std::random_device, std::mt19937, std::uniform_int_distribution) to randomly select one of the empty cells. Another random distribution determines whether to place a 2 (90% chance) or a 4 (10% chance) in the selected cell.
      • Why: This function implements a core game mechanic of 2048. Finding empty cells first is necessary to know where a tile can be placed. Using <random> ensures less predictable tile placement than older methods like rand(). The 90/10 probability for 2s and 4s matches the behaviour of the original game. Returning early if no cells are empty prevents errors.
    • Core Tile Merging Logic (mergeRow in Game.cpp)

      • How: This private helper function processes a single row (std::vector<int>) as if moving left.
        1. It creates a temporary vector (temp) containing only the non-zero tile values from the input row, effectively sliding them left.
        2. It iterates through temp. If two adjacent tiles are identical, they are merged into a single tile (value doubled) in the final mergedRow vector, the score is updated, and the second tile is skipped. Non-matching tiles are copied directly.
        3. The mergedRow is padded with zeros on the right until it reaches the original size.
      • Why: This function encapsulates the fundamental slide-and-merge action. By creating this generic "merge left" function for a single row, it can be reused for all four move directions by manipulating the board orientation beforehand, significantly reducing code duplication. Updating the score here ensures points are awarded correctly at the moment of merging.
    • Directional Move Implementation (moveLeft, moveRight, moveUp, moveDown in Game.cpp)

      • How:
        • moveLeft: Iterates through each row of the board and replaces it with the result of calling mergeRow on it.
        • moveRight: Calls reverseRows() to reverse each row, then calls moveLeft() (which uses mergeRow), then calls reverseRows() again to restore the original orientation.
        • moveUp: Calls transposeBoard() (swaps rows and columns), then calls moveLeft(), then calls transposeBoard() again.
        • moveDown: Calls transposeBoard(), then calls moveRight() (which itself uses reversing and moveLeft), then calls transposeBoard() again.
      • Why: This strategy cleverly reuses the mergeRow logic for all directions. Instead of writing separate merge logic for up, down, and right, the board is temporarily transformed (reverseRows, transposeBoard) so that the moveLeft logic achieves the desired effect. This is efficient and less error-prone than implementing four distinct merge algorithms.
    • Board Transformation Helpers (transposeBoard, reverseRows in Game.cpp)

      • How:
        • transposeBoard: Creates a new temporary 2D vector. It copies board[r][c] to transposed[c][r] and then replaces the original board with the transposed version.
        • reverseRows: Iterates through each row vector in the board and uses std::reverse (from <algorithm>) to reverse the elements within that row in-place.
      • Why: These helper functions provide the necessary board manipulations (swapping rows/columns and reversing row contents) to allow the single mergeRow function (within moveLeft) to handle all four movement directions.
    • Input Processing (processInput in Game.cpp)

      • How: Takes the user's input character (move). It creates a copy of the board before processing the move. It converts the input to lowercase (tolower) and uses a switch statement to call the corresponding directional move function (moveUp, moveDown, etc.). After the move function executes, it compares the current board with the copy made earlier. If they are different (meaning the move changed the board state), it calls addRandomTile().
      • Why: This function acts as the bridge between user commands and game actions. Using tolower makes the controls case-insensitive for better usability. The crucial step is comparing the board state before and after the move; this prevents adding a new random tile if the player attempts a move that doesn't change anything (e.g., pushing tiles against a wall), which is essential to correct 2048 gameplay.
    • Display (displayBoard in Game.cpp)

      • How: Uses system("clear") (for Linux/macOS) to clear the terminal screen. Prints the current score. Then, it iterates through the 2D board vector, printing a simple text-based grid structure. std::setw(4) (from <iomanip>) is used to ensure numbers are aligned within cells. Cells containing 0 are printed as blank spaces. Finally, it prints the control key instructions.
      • Why: To provide the player with a constantly updated visual representation of the game state (board layout and score). Clearing the screen avoids clutter from previous turns. setw creates a neater, more readable grid. Displaying controls reminds the user how to play.
    • Game Over Detection (isGameOver in Game.cpp)

      • How: First, it iterates through the entire board to check if any cell contains 0. If an empty cell is found, the game cannot be over, so it immediately returns false. If the loop completes without finding empty cells (meaning the board is full), it proceeds to check for possible merges. It does this by checking adjacent pairs: board[r][c] vs board[r][c+1] (horizontal) and board[r][c] vs board[r+1][c] (vertical). If any adjacent identical pair is found, a merge is possible, so the game is not over, and it returns false. If the board is full and no merges are possible, it returns true.
      • Why: This correctly implements the win/loss condition for 2048. The game only ends when the player can no longer make any moves, which occurs precisely when the board is full and no adjacent tiles can be combined. Checking for empty cells first is a quick optimization.
    • Score Management (score member and getScore method)

      • How: A private integer score is part of the Game class, initialized to 0. It's incremented within mergeRow by the value of the newly formed tile whenever a merge occurs. A public const method getScore() provides read-only access to the score.
      • Why: To track player performance, which is essential feedback in a game. Incrementing the score during the merge operation ensures it's updated accurately. Providing a getScore accessor method allows other parts of the program (like main or displayBoard) to retrieve the score without being able to modify it directly, adhering to encapsulation principles.
    • Main Game Loop (main.cpp)

      • How: The main function creates a Game object. It then enters a while loop that continues until the input character is 'q'. Inside the loop, it calls game.displayBoard(), checks game.isGameOver(), prompts for and reads user input using std::cin, checks if the input is 'q' (to break the loop), validates if the input is one of the move keys ('w', 'a', 's', 'd'), and if valid, calls game.processInput(). If the game is over, it prints a "Game Over" message and the final score before breaking the loop.
      • Why: This structure drives the gameplay. The loop ensures the cycle of display -> input -> update -> check state repeats until the game ends (quit or game over). Input validation prevents errors from unexpected characters. Separating this driver logic from the Game class keeps concerns separated.
    • File Structure (.hpp, .cpp)

      • How: The Game class declaration (member variables, method signatures) is placed in game.hpp. The Game class definitions (method implementations) are in game.cpp. The main execution code is in main.cpp. Both game.cpp and main.cpp include game.hpp to access the class declaration. Header guards (#ifndef GAME_HPP, #define GAME_HPP, #endif) are used in game.hpp.
      • Why: This is the standard C++ approach for organizing code. It separates the interface (.hpp) from the implementation (.cpp), improving modularity and readability. It also speeds up compilation, as changes to the implementation (.cpp) don't require recompiling files that only include the header (.hpp) if the header itself hasn't changed. Header guards prevent issues if the header is accidentally included multiple times in the same compilation unit.

    3. How to Build and Run

    This project was designed using - and designed to be built and run on - the csctcloud.uwe.ac.uk development environment.

    Dependencies

    • A C++ compiler supporting C++11 or later (like g++)
    • Standard C++ libraries (<iostream>, <vector>, <random>, <iomanip>, <algorithm>, <string>)

    Building

    1. Navigate to the directory containing the source and header files: Navigating to the directory
    2. Build the game, including the source files, the name for the executable, and the version of C++ to use (C++11 and above contains the necessary libraries) Building the game g++ main.cpp game.cpp -o 2048game -std=c++11

    Running

    1. Execute the compiled program: ./2048game Running the game
    2. Play the game: Use the W, A, S, D keys (followed by the Return key) to move the tiles. The game will handle incorrect inputs by ignoring them and clearing the input area. (Note: due to an issue I have been unable to fix, the game can take multiple key inputs in one go i.e. A, W, A, W, Return).
      • W: Move Up
      • A: Move Left
      • S: Move Down
      • D: Move Right
        A newly started game: A newly started game A game in progress: A game in progress The game over screen: Game over screen
    3. Quit: Enter Q to exit the game at any time.

    4. Author

    Kit Lloyd 23074402