diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..511f93b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +report.pdf diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc6e987 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 huytrinhm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ea1e324 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +minesweeper: + mkdir -p build/ && cd src/ && \ + g++ -Wall -O2 -std=c++17 \ + minesweeper.cpp game_controller.cpp ui_controller.cpp \ + -o ../build/minesweeper \ + -static-libstdc++ -static-libgcc \ + -Wl,-Bstatic -lstdc++ -lpthread \ + -Wl,-Bdynamic \ + -Wl,--as-needed -Wl,--strip-all diff --git a/README.md b/README.md new file mode 100644 index 0000000..0317cd8 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +C++ Minesweeper +=============== + +![Gameplay](screenshots/game.png) + +This is a simple minesweeper game that runs directly in the terminal (with out-of-the-box **mouse** and **keyboard** navigation support). The project is implemented in C++, utilizing [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code). Terminal mouse and keyboard support was achieved by Win32 APIs (via `windows.h`), DOS console input/output APIs (via `conio.h`) on Win32 systems, and UNIX terminal input/output APIs (via `termios.h`, `sys/ioctl.h`, `unistd.h`) on UNIX systems. + +The program is tested on Windows 11 (Version 22H2) and Windows Subsystem for Linux (Version 2.0.9.0). + +See `demo.mp4` for demo gameplay. + +Features +-------- +1. **Gameplay** +- Customize board size (number of rows, columns and mines). +- First click guaranteed to be empty. +- Player can click on a opened cell with enough flags to quickly open all remaining neighbor cells. + +2. **UI** +- Main menu: New game, Resume game (if exists), Quit. +- Start game menu: customize board size and number of mines. +- Game screen: Live timer and mines counter, colorful minefield. +- Pause screen, win screen and lose screen allow player to save game or go back to main menu to retry. +- **Auto re-render UI if detecting terminal resize.** +- **Auto notify player if current terminal size is too small to display the game screen after resize.** + +3. **Control** +- By keyboard: use arrow keys to navigate through minefield, spacebar to open cell and `F` to flag cell, escape key to pause game, back to menu. +- **By mouse** (Windows only, not yet implemented for UNIX systems): hover effects, left click to select option / open cell, right click to flag cell. + +4. **Other** +- Auto-save highscore (best time) for each board size combination. +- Options to save and resume game (with continued timer). +- Timer only starts after first move. + +Compile and Run +--------------- +If you have `make` on your system, simply run `make minesweeper` to build the project. Otherwise, you can compile it using the following command: +```bash +mkdir -p build/ && cd src/ && \ +g++ -Wall -O2 -std=c++17 \ +minesweeper.cpp game_controller.cpp ui_controller.cpp \ +-o ../build/minesweeper \ +-static-libstdc++ -static-libgcc \ +-Wl,-Bstatic -lstdc++ -lpthread \ +-Wl,-Bdynamic \ +-Wl,--as-needed -Wl,--strip-all +``` + +Then, you can run `build/minesweeper.exe` or `build/minesweeper`. + +Prebuilt binaries are also provided in the [Releases](https://github.com/huytrinhm/cpp-minesweeper/releases) section. However, the underlying architecture and system APIs differ from machine to machine and cannot be static-linked, be cautious that the prebuilt binaries may not work properly. + +Project structure +----------------- +The `src/` directory includes: +1. `minesweeper.cpp` +- The entrypoint of the game. +- Contains core logic of the game loop and save/load game functionalities. + +List of functions: +```cpp +bool gameLoop(GameState& state, bool isSaved); +void saveGame(const GameState& state); +bool loadGame(GameState& state); +void deleteSave(); +void loadHighscores(int highScores[MAX_M][MAX_N][MAX_M * MAX_N], size_t size); +void saveHighscores(int highScores[MAX_M][MAX_N][MAX_M * MAX_N], size_t size); +int main(); // entrypoint +``` + +2. `game_controller.h`, `game_controller.cpp`: +- Utility library for managing game logic (create and manage game states). +- The game state is modeled as follows: +```cpp +struct GameState_s { + bool board[MAX_M][MAX_N]; // 0: bomb; 1: empty + int display[MAX_M][MAX_N]; // 0-8: numbers; 9: not yet opened; 10: flag; 11: bomb + int rows, cols, bombCount, elapsedTime; + bool generated; +}; +``` + +List of functions: +```cpp +void initBoard(GameState& state, int rows, int cols, int bombCount); // initialize game state +void genBoard(GameState& state, int r, int c); // randomize minefield +bool inBound(const GameState& state, int r, int c); // check position is inside minefield +int updateDisplayPosition(GameState& state, int r, int c); // update mine count number +bool openPosition(GameState& state, int r, int c); // triggered when player click on a cell +void toggleFlagPosition(GameState& state, int r, int c); // triggered when player flag a cell +void openAllBomb(GameState& state); // game over procedure +bool isWinState(GameState& state); +``` + +3. `ui_controller.h`, `ui_controller.cpp`: +- Utility library for managing game UI (render menu and minefield, handle player mouse and keyboard inputs). +- Using preprocessor directive `#ifdef _WIN32` to implement platform dependent features (terminal input/output APIs, WIN32 APIs to capture mouse events). + +List of functions: +```cpp +void initConsole(); +void closeConsole(); +void hideCursor(); +void showCursor(); +void getConsoleWidthHeight(int& width, int& height); +void clearScreen(int mode); +void clearScreenInline(int mode); +bool screenToBoard(GameState& state, // convert screen coordinates to board coordinates + int screen_r, + int screen_c, + int& board_r, + int& board_c); +void render(const GameState& state, // render game board + int cursor_r, + int cursor_c, + bool skipHeader = false, + bool skipBoard = false, + bool skipFooter = false); +int getInput(); // platform-specific get keyboard input +bool getMouseInput(int& r, int& c, int& event); // platform-specific get mouse input (TODO: implement for UNIX system) +int mainMenu(bool saved); // render main menu +void startGameMenu(int& rows, int& cols, int& bombCount); // render start game menu +bool loseMenu(const GameState& state, int cursor_r, int cursor_c); // render lose menu +bool winMenu(const GameState& state, int bestTime); // render win menu +int pauseMenu(const GameState& state); // render pause menu +void wait(); // wait for keyboard input +``` + +Gameplay +-------- + +Main menu: +![Main menu](screenshots/menu.png) + +Start game menu: +![Start game menu](screenshots/options.png) + +Game screen: +![Game screen](screenshots/game.png) + +Lose screen: +![Lose screen](screenshots/lose.png) + +Win screen: +![Win screen](screenshots/win.png) diff --git a/screenshots/game.png b/screenshots/game.png new file mode 100644 index 0000000..0231081 Binary files /dev/null and b/screenshots/game.png differ diff --git a/screenshots/lose.png b/screenshots/lose.png new file mode 100644 index 0000000..1013000 Binary files /dev/null and b/screenshots/lose.png differ diff --git a/screenshots/menu.png b/screenshots/menu.png new file mode 100644 index 0000000..702ab3b Binary files /dev/null and b/screenshots/menu.png differ diff --git a/screenshots/options.png b/screenshots/options.png new file mode 100644 index 0000000..33261f9 Binary files /dev/null and b/screenshots/options.png differ diff --git a/screenshots/win.png b/screenshots/win.png new file mode 100644 index 0000000..6dd5da6 Binary files /dev/null and b/screenshots/win.png differ diff --git a/src/game_controller.cpp b/src/game_controller.cpp new file mode 100644 index 0000000..6fb27a2 --- /dev/null +++ b/src/game_controller.cpp @@ -0,0 +1,133 @@ +#include "game_controller.h" +#include +#include +#include + +const int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; +const int dc[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; + +std::mt19937_64 rng( + std::chrono::steady_clock::now().time_since_epoch().count()); + +void initBoard(GameState& state, int rows, int cols, int bombCount) { + state.rows = rows; + state.cols = cols; + state.elapsedTime = 0; + state.bombCount = bombCount; + state.generated = false; + + for (int r = 0; r <= rows + 1; r++) { + for (int c = 0; c <= cols + 1; c++) { + state.board[r][c] = 0; + state.display[r][c] = 9; + } + } +} + +void genBoard(GameState& state, int r, int c) { + int bombCandidate[state.rows * state.cols - 1]; + + for (int i = 0, p = 0; i < state.rows * state.cols; i++) + if (i != (r - 1) * state.cols + c - 1) + bombCandidate[p++] = i; + + std::shuffle(bombCandidate, bombCandidate + state.rows * state.cols - 1, rng); + + for (int i = 0; i < state.bombCount; i++) + state.board[bombCandidate[i] / state.cols + 1] + [bombCandidate[i] % state.cols + 1] = 1; + state.generated = true; +} + +bool inBound(const GameState& state, int r, int c) { + return r > 0 && c > 0 && r <= state.rows && c <= state.cols; +} + +int updateDisplayPosition(GameState& state, int r, int c) { + if (state.board[r][c] == 1) + return -1; + int count = 0; + for (int i = 0; i < 8; i++) + count += state.board[r + dr[i]][c + dc[i]]; + state.display[r][c] = count; + return count != 0; +} + +bool openPosition(GameState& state, int r, int c) { + if (!inBound(state, r, c)) + return true; + + int stack[MAX_M * MAX_N + 10][2]; + int sTop = 0; + + if (state.display[r][c] <= 8) { + int count = 0; + for (int i = 0; i < 8; i++) + count += state.display[r + dr[i]][c + dc[i]] == 10; + if (count == state.display[r][c]) { + for (int i = 0; i < 8; i++) + if (state.display[r + dr[i]][c + dc[i]] == 9 && + state.board[r + dr[i]][c + dc[i]]) { + return false; + } else if (state.display[r + dr[i]][c + dc[i]] == 9) { + stack[sTop][0] = r + dr[i]; + stack[sTop++][1] = c + dc[i]; + } + } + } + + stack[sTop][0] = r; + stack[sTop++][1] = c; + + while (sTop > 0) { + int sR = stack[--sTop][0]; + int sC = stack[sTop][1]; + + if (state.display[sR][sC] <= 8) + continue; + + int updateResult = updateDisplayPosition(state, sR, sC); + + if (updateResult == -1) { + state.display[sR][sC] = 11; + return false; + } + + if (updateResult != 0) + continue; + + for (int i = 0; i < 8; i++) { + if (inBound(state, sR + dr[i], sC + dc[i]) && + state.display[sR + dr[i]][sC + dc[i]] > 8) { + stack[sTop][0] = sR + dr[i]; + stack[sTop++][1] = sC + dc[i]; + } + } + } + + return true; +} + +void toggleFlagPosition(GameState& state, int r, int c) { + if (!inBound(state, r, c) || state.display[r][c] <= 8) + return; + state.display[r][c] = state.display[r][c] == 9 ? 10 : 9; +} + +void openAllBomb(GameState& state) { + for (int r = 1; r <= state.rows; r++) + for (int c = 1; c <= state.cols; c++) + if (state.board[r][c]) { + if (state.display[r][c] != 10) + state.display[r][c] = 11; + } else if (state.display[r][c] == 10) + state.display[r][c] = 12; +} + +bool isWinState(GameState& state) { + for (int r = 1; r <= state.rows; r++) + for (int c = 1; c <= state.cols; c++) + if (!state.board[r][c] && state.display[r][c] > 8) + return false; + return true; +} diff --git a/src/game_controller.h b/src/game_controller.h new file mode 100644 index 0000000..fa318d8 --- /dev/null +++ b/src/game_controller.h @@ -0,0 +1,24 @@ +#ifndef GAME_CONTROLLER_H +#define GAME_CONTROLLER_H + +const int MAX_M = 120; +const int MAX_N = 120; + +struct GameState_s { + bool board[MAX_M][MAX_N]; + int display[MAX_M][MAX_N]; + int rows, cols, bombCount, elapsedTime; + bool generated; +}; +typedef struct GameState_s GameState; + +void initBoard(GameState& state, int rows, int cols, int bombCount); +void genBoard(GameState& state, int r, int c); +bool inBound(const GameState& state, int r, int c); +int updateDisplayPosition(GameState& state, int r, int c); +bool openPosition(GameState& state, int r, int c); +void toggleFlagPosition(GameState& state, int r, int c); +void openAllBomb(GameState& state); +bool isWinState(GameState& state); + +#endif diff --git a/src/minesweeper.cpp b/src/minesweeper.cpp new file mode 100644 index 0000000..698971c --- /dev/null +++ b/src/minesweeper.cpp @@ -0,0 +1,181 @@ +#include +#include +#include +#include "game_controller.h" +#include "ui_controller.h" + +bool gameLoop(GameState& state, bool isSaved); +void saveGame(const GameState& state); +bool loadGame(GameState& state); +void deleteSave(); +void loadHighscores(int highScores[40][40][40 * 20], size_t size); +void saveHighscores(int highScores[40][40][40 * 20], size_t size); + +int highScores[40][40][40 * 20] = {0}; + +int main() { + initConsole(); + hideCursor(); + + loadHighscores(highScores, sizeof highScores); + + bool playing = true; + + do { + GameState state; + GameState savedState; + bool saved = loadGame(savedState); + + int result = mainMenu(saved); + + if (result == 0) + break; + else if (result == 1) { + int rows, cols, bombCount; + startGameMenu(rows, cols, bombCount); + initBoard(state, rows, cols, bombCount); + } else if (result == 2) { + state = savedState; + } + + if (!gameLoop(state, result == 2)) + break; + } while (playing); + + showCursor(); + closeConsole(); + return 0; +} + +bool gameLoop(GameState& state, bool isSaved) { + clearScreenInline(40); + int cursor_r = 1, cursor_c = 1; + + bool paused = false; + std::chrono::steady_clock::time_point startTimepoint, lastTimepoint, + currentTimepoint; + std::chrono::steady_clock::duration pauseDuration = + std::chrono::steady_clock::duration::zero(); + if (isSaved) + startTimepoint = std::chrono::steady_clock::now() - + std::chrono::duration_cast( + std::chrono::duration(state.elapsedTime)); + while (true) { + render(state, cursor_r, cursor_c); + currentTimepoint = std::chrono::steady_clock::now(); + if (paused) { + paused = false; + if (state.generated) + pauseDuration += currentTimepoint - lastTimepoint; + } + lastTimepoint = currentTimepoint; + if (state.generated) + state.elapsedTime = std::chrono::duration_cast( + currentTimepoint - startTimepoint - pauseDuration) + .count(); + + int keyCode = getInput(); + int mouse_r, mouse_c, mouse_event; + if (getMouseInput(mouse_r, mouse_c, mouse_event)) { + if (mouse_event == 0) { + screenToBoard(state, mouse_r, mouse_c, cursor_r, cursor_c); + } else if (mouse_event == 1) { + if (screenToBoard(state, mouse_r, mouse_c, cursor_r, cursor_c)) + keyCode = ' '; + } else if (mouse_event == 2) { + if (screenToBoard(state, mouse_r, mouse_c, cursor_r, cursor_c)) + keyCode = 'f'; + } + } + if (keyCode == KEY_RIGHT_ARROW) { + cursor_c = std::min(state.cols, cursor_c + 1); + } else if (keyCode == KEY_LEFT_ARROW) { + cursor_c = std::max(1, cursor_c - 1); + } else if (keyCode == KEY_DOWN_ARROW) { + cursor_r = std::min(state.rows, cursor_r + 1); + } else if (keyCode == KEY_UP_ARROW) { + cursor_r = std::max(1, cursor_r - 1); + } else if (keyCode == 'f') { + toggleFlagPosition(state, cursor_r, cursor_c); + } else if (keyCode == ' ') { + if (!state.generated) { + genBoard(state, cursor_r, cursor_c); + startTimepoint = std::chrono::steady_clock::now(); + } + + if (!openPosition(state, cursor_r, cursor_c)) { + openAllBomb(state); + deleteSave(); + return loseMenu(state, cursor_r, cursor_c); + } + if (isWinState(state)) { + deleteSave(); + int modeHighscores = + highScores[state.rows - 1][state.cols - 1][state.bombCount - 1]; + if (modeHighscores == 0 || modeHighscores - 1 > state.elapsedTime) { + highScores[state.rows - 1][state.cols - 1][state.bombCount - 1] = + state.elapsedTime + 1; + saveHighscores(highScores, sizeof highScores); + return winMenu(state, state.elapsedTime); + } else { + return winMenu(state, modeHighscores - 1); + } + } + } else if (keyCode == KEY_ESC) { + paused = true; + int result = pauseMenu(state); + if (result == 0) + return false; + else if (result == 1) + continue; + else if (result == 2) { + saveGame(state); + return true; + } + } + } +} + +void saveGame(const GameState& state) { + std::ofstream file("game_state.bin", std::ios::binary); + if (file.is_open()) { + file.write((char*)&state, sizeof state); + } + file.close(); +} + +bool loadGame(GameState& state) { + std::ifstream file("game_state.bin", std::ios::binary); + if (!file.is_open()) + return false; + + if (file.read((char*)&state, sizeof state)) { + file.close(); + return true; + } else { + file.close(); + return false; + } +} + +void deleteSave() { + std::filesystem::remove("game_state.bin"); +} + +void loadHighscores(int highScores[40][40][40 * 20], size_t size) { + std::ifstream file("highScores.bin", std::ios::binary); + if (!file.is_open()) + return; + + file.read((char*)highScores, size); + file.close(); +} + +void saveHighscores(int highScores[40][40][40 * 20], size_t size) { + std::ofstream file("highScores.bin", std::ios::binary); + if (!file.is_open()) + return; + + file.write((char*)highScores, size); + file.close(); +} diff --git a/src/ui_controller.cpp b/src/ui_controller.cpp new file mode 100644 index 0000000..cabcd6a --- /dev/null +++ b/src/ui_controller.cpp @@ -0,0 +1,673 @@ +#include "ui_controller.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 + +#include +#include + +void initConsole() { + HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE); + HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD idwMode, odwMode; + + GetConsoleMode(hInput, &idwMode); + GetConsoleMode(hOutput, &odwMode); + + idwMode = ENABLE_PROCESSED_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_MOUSE_INPUT; + odwMode |= ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + + SetConsoleMode(hInput, idwMode); + SetConsoleMode(hOutput, odwMode); + SetConsoleOutputCP(CP_UTF8); + clearScreen(40); +} + +void closeConsole() { + HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE); + DWORD idwMode; + GetConsoleMode(hInput, &idwMode); + idwMode &= ~ENABLE_MOUSE_INPUT; + SetConsoleMode(hInput, idwMode); + clearScreen(0); +} + +void getConsoleWidthHeight(int& width, int& height) { + CONSOLE_SCREEN_BUFFER_INFO csbi; + + GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi); + width = csbi.srWindow.Right - csbi.srWindow.Left + 1; + height = csbi.srWindow.Bottom - csbi.srWindow.Top + 1; +} + +int getInput() { + int ch = -1; + if (kbhit()) { + ch = getch(); + if (ch >= 'A' && ch <= 'Z') + ch = tolower(ch); + if (ch == 224) + ch = getch(); + else if (ch == 0) + getch(); + } + return ch; +} + +bool getMouseInput(int& r, int& c, int& event) { + HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE); + DWORD cNumRead = 0; + INPUT_RECORD irInBuf[1]; + bool retcode = PeekConsoleInput(hInput, irInBuf, 1, &cNumRead); + if (!retcode || cNumRead == 0) + return false; + + ReadConsoleInput(hInput, irInBuf, 1, &cNumRead); + // FlushConsoleInputBuffer(hInput); + if (irInBuf[0].EventType != MOUSE_EVENT) + return false; + + MOUSE_EVENT_RECORD mouseEvent = irInBuf[0].Event.MouseEvent; + r = mouseEvent.dwMousePosition.Y + 1; + c = mouseEvent.dwMousePosition.X + 1; + + if (mouseEvent.dwEventFlags == MOUSE_MOVED) { + event = 0; + return true; + } + + if (mouseEvent.dwEventFlags != 0) + return false; + + if (mouseEvent.dwButtonState == FROM_LEFT_1ST_BUTTON_PRESSED) + event = 1; + else if (mouseEvent.dwButtonState == RIGHTMOST_BUTTON_PRESSED) + event = 2; + else + return false; + + return true; +} + +#else + +#include +#include +#include + +void initConsole() { + termios term; + tcgetattr(0, &term); + term.c_lflag &= ~(ICANON | ECHO); + tcsetattr(0, TCSANOW, &term); + clearScreen(40); +} + +void closeConsole() { + clearScreen(0); + termios term; + tcgetattr(0, &term); + term.c_lflag |= ICANON | ECHO; + tcsetattr(0, TCSANOW, &term); + tcflush(0, TCIFLUSH); +} + +bool kbhit() { + int byteswaiting; + ioctl(0, FIONREAD, &byteswaiting); + return byteswaiting > 0; +} + +int getch() { + unsigned char ch; + int retcode; + retcode = read(STDIN_FILENO, &ch, 1); + return retcode <= 0 ? EOF : (int)ch; +} + +static int getchs(char* buffer) { + return read(STDIN_FILENO, buffer, 8); +} + +void getConsoleWidthHeight(int& width, int& height) { + struct winsize w; + ioctl(STDOUT_FILENO, TIOCGWINSZ, &w); + + width = w.ws_col; + height = w.ws_row; +} + +int getInput() { + if (!kbhit()) + return -1; + + char buffer[8]; + int bytes = getchs(buffer); + + if (bytes <= 0) + return -1; + + if (bytes == 1) + return tolower(buffer[0]); + + if (bytes == 3 && buffer[0] == KEY_ESC && buffer[1] == 91) + return buffer[2]; + + return -1; +} + +bool getMouseInput(int& r, int& c, int& event) { + return false; +} + +#endif + +int lastWidth = -1; +int lastHeight = -1; + +char symbols[][32] = { + " ", "\x1b[94m𝟏", "\x1b[32m𝟐", "\x1b[91m𝟑", "\x1b[34m𝟒", + "\x1b[31m𝟓", "\x1b[36m𝟔", "\x1b[30m𝟕", "\x1b[37m𝟖", "■", + "\x1b[93m⚑", "\x1b[95m*", "\x1b[7;93m⚑\x1b[27m"}; + +void clearScreen(int mode) { + printf("\x1b[%d;97m", mode); + printf("\x1b[2J"); + printf("\x1b[H"); +} + +void clearScreenInline(int mode) { + printf("\x1b[%d;97m", mode); + int rows, cols; + getConsoleWidthHeight(cols, rows); + for (int r = 1; r <= rows; r++) + for (int c = 1; c <= cols; c++) + printf("\x1b[%d;%dH ", r, c); + printf("\x1b[H"); +} + +void hideCursor() { + printf("\x1b[?25l"); +} + +void showCursor() { + printf("\x1b[?25h"); +} + +static void assertScreenSize(int rows, int cols) { + int width, height; + getConsoleWidthHeight(width, height); + if (width >= cols && height >= rows) + return; + + char msg[] = "Screen too small!"; + + printf("\x1b[2J"); + printf("\x1b\x1b[%d;%dH%s", (height - 1) / 2 + 1, + (width - (int)strlen(msg)) / 2 + 1, msg); + fflush(stdout); + + while (width < cols || height < rows) { + if (width != lastWidth || height != lastHeight) { + printf("\x1b[2J"); + printf("\x1b\x1b[%d;%dH%s", (height - 1) / 2 + 1, + (width - (int)strlen(msg)) / 2 + 1, msg); + fflush(stdout); + } + + lastWidth = width; + lastHeight = height; + + getConsoleWidthHeight(width, height); + } +} + +static char* cellToChar(int cell) { + return symbols[cell]; +} + +bool screenToBoard(GameState& state, + int screen_r, + int screen_c, + int& board_r, + int& board_c) { + int boardWidth = 2 * state.cols - 1 + 4; + int boardHeight = state.rows + 2; + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + int pos_r = (consoleHeight - boardHeight - 3) / 2 + 3 + 1; + int pos_c = (consoleWidth - boardWidth) / 2 + 1 + 2; + + if ((screen_r < pos_r) || (screen_r >= pos_r + state.rows)) + return false; + if ((screen_c < pos_c) || (screen_c >= pos_c + 2 * state.cols - 1)) + return false; + + if ((screen_c - pos_c) % 2 != 0) + return false; + + board_r = screen_r - pos_r + 1; + board_c = (screen_c - pos_c) / 2 + 1; + + return true; +} + +static void printBoardBorder(int pos_r, int pos_c, int rows, int cols) { + printf("\x1b[%d;%dH", pos_r, pos_c + 1); + for (int i = 1; i <= 2 * cols + 1; i++) + printf("═"); + printf("\x1b[%d;%dH", pos_r + rows + 1, pos_c + 1); + for (int i = 1; i <= 2 * cols + 1; i++) + printf("═"); + for (int i = pos_r + 1; i <= pos_r + rows; i++) + printf("\x1b[%d;%dH║", i, pos_c); + for (int i = pos_r + 1; i <= pos_r + rows; i++) + printf("\x1b[%d;%dH║", i, pos_c + 2 * cols + 2); + + printf("\x1b[%d;%dH╔", pos_r, pos_c); + printf("\x1b[%d;%dH╗", pos_r, pos_c + 2 * cols + 2); + printf("\x1b[%d;%dH╚", pos_r + rows + 1, pos_c); + printf("\x1b[%d;%dH╝", pos_r + rows + 1, pos_c + 2 * cols + 2); +} + +static void printBoard(const GameState& state, + int pos_r, + int pos_c, + int cursor_r, + int cursor_c) { + int innerBoard_r = pos_r + 1; + int innerBoard_c = pos_c + 2; + + printBoardBorder(pos_r, pos_c, state.rows, state.cols); + for (int r = 1; r <= state.rows; r++) { + for (int c = 1; c <= state.cols; c++) { + if (r == cursor_r && c == cursor_c) + printf("\x1b[7m"); + printf("\x1b[%d;%dH", innerBoard_r + r - 1, innerBoard_c + 2 * (c - 1)); + printf("%s", cellToChar(state.display[r][c])); + if (r == cursor_r && c == cursor_c) + printf("\x1b[27m"); + printf("\x1b[97m"); + } + } +} + +static void renderHeader(char fmt[], char header[], int pos_r, int pos_c) { + printf("\x1b[%d;%dH", pos_r, pos_c); + printf("\x1b[2K"); + printf(fmt, header); +} + +void render(const GameState& state, + int cursor_r, + int cursor_c, + bool skipHeader, + bool skipBoard, + bool skipFooter) { + int boardWidth = 2 * state.cols - 1 + 4; + int boardHeight = state.rows + 2; + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + int board_r = (consoleHeight - boardHeight - 3) / 2 + 3; + int board_c = (consoleWidth - boardWidth) / 2 + 1; + if (consoleWidth != lastWidth || consoleHeight != lastHeight) + clearScreenInline(40); + + lastWidth = consoleWidth; + lastHeight = consoleHeight; + + assertScreenSize(boardHeight + 3, std::max(boardWidth, 63)); + + // HEADER + if (!skipHeader) { + int flagCount = 0; + for (int r = 1; r <= state.rows; r++) + for (int c = 1; c <= state.cols; c++) + if (state.display[r][c] == 10) + flagCount++; + char header[100]; + sprintf(header, "Time: %3ds | Mines: %2d/%d", state.elapsedTime, + flagCount, state.bombCount); + renderHeader((char*)"%s", header, 1, + (consoleWidth - strlen(header)) / 2 + 1); + } + + // FOOTER + if (!skipFooter) { + printf("\x1b[%d;1H", consoleHeight); + printf( + "\x1b[34m[ESC]\x1b[97m Pause \x1b[34m[SPACE]\x1b[97m Open cell " + "\x1b[34m[F]\x1b[97m Flag cell \x1b[34m[ARROWS]\x1b[97m Move"); + } + + if (!skipBoard) + printBoard(state, board_r, board_c, cursor_r, cursor_c); + fflush(stdout); +} + +static void renderPauseMenu(const GameState& state) { + int boardWidth = 2 * state.cols - 1 + 4; + int boardHeight = state.rows + 2; + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + int board_r = (consoleHeight - boardHeight - 3) / 2 + 3; + int board_c = (consoleWidth - boardWidth) / 2 + 1; + + lastWidth = consoleWidth; + lastHeight = consoleHeight; + + assertScreenSize(boardHeight + 3, std::max(boardWidth, 41)); + + // HEADER + int flagCount = 0; + for (int r = 1; r <= state.rows; r++) + for (int c = 1; c <= state.cols; c++) + if (state.display[r][c] == 10) + flagCount++; + char header[100]; + sprintf(header, "Time: %3ds | Mines: %2d/%d", state.elapsedTime, + flagCount, state.bombCount); + renderHeader((char*)"%s", header, 1, (consoleWidth - strlen(header)) / 2 + 1); + + // FOOTER + printf("\x1b[%d;1H", consoleHeight); + printf("\x1b[2K"); + printf( + "\x1b[34m[ESC]\x1b[97m Continue \x1b[34m[S]\x1b[97m Save game " + "\x1b[34m[Q]\x1b[97m Quit"); + + printBoardBorder(board_r, board_c, state.rows, state.cols); + + for (int r = board_r + 1; r <= board_r + state.rows; r++) + for (int c = board_c + 1; c <= board_c + 2 * state.cols + 1; c++) + printf("\x1b[%d;%dH ", r, c); + + printf("\x1b[%d;%dH%s", board_r + (state.rows - 1) / 2 + 1, + board_c + (boardWidth - 2 - 8) / 2 + 1, "⏳PAUSED⏳"); + fflush(stdout); +} + +int pauseMenu(const GameState& state) { + renderPauseMenu(state); + while (true) { + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + if (consoleWidth != lastWidth || consoleHeight != lastHeight) { + clearScreenInline(40); + renderPauseMenu(state); + } + lastWidth = consoleWidth; + lastHeight = consoleHeight; + + int keyCode = getInput(); + if (keyCode == 'q') + return 0; + else if (keyCode == KEY_ESC) { + clearScreenInline(40); + return 1; + } else if (keyCode == 's') + return 2; + } +} + +int mainMenu(bool saved) { + clearScreenInline(40); + int numberOfOptions = saved ? 3 : 2; + int select = 1; + while (true) { + int width, height; + getConsoleWidthHeight(width, height); + if (width != lastWidth || height != lastHeight) + clearScreenInline(40); + lastWidth = width; + lastHeight = height; + int menuHeight = 2 + 4 + 3 * numberOfOptions + 1; + int menuWidth = 36; + int menu_r = (height - menuHeight) / 2 + 1; + int menu_c = (width - menuWidth) / 2 + 1; + assertScreenSize(menuHeight, menuWidth); + + printf("\x1b[%d;%dH", menu_r, menu_c + 1); + for (int i = 1; i < menuWidth - 1; i++) + printf("═"); + printf("\x1b[%d;%dH", menu_r + menuHeight - 1, menu_c + 1); + for (int i = 1; i < menuWidth - 1; i++) + printf("═"); + for (int i = menu_r + 1; i < menu_r + menuHeight - 1; i++) + printf("\x1b[%d;%dH║", i, menu_c); + for (int i = menu_r + 1; i < menu_r + menuHeight - 1; i++) + printf("\x1b[%d;%dH║", i, menu_c + menuWidth - 1); + + printf("\x1b[%d;%dH╔", menu_r, menu_c); + printf("\x1b[%d;%dH╗", menu_r, menu_c + menuWidth - 1); + printf("\x1b[%d;%dH╚", menu_r + menuHeight - 1, menu_c); + printf("\x1b[%d;%dH╝", menu_r + menuHeight - 1, menu_c + menuWidth - 1); + + int newGame_r = menu_r + 2 + 4; + int newGame_c = menu_c + (menuWidth - 10) / 2; + int resumeGame_r = menu_r + 2 + 4 + 3; + int resumeGame_c = menu_c + (menuWidth - 12) / 2; + int quitGame_r = menu_r + 2 + 4 + (numberOfOptions - 1) * 3; + int quitGame_c = menu_c + (menuWidth - 6) / 2; + + printf("\x1b[%d;%dH\x1b[91m%s\x1b[97m", menu_r + 2, + menu_c + (menuWidth - 16) / 2, "🚩 MINESWEEPER 🚩"); + printf("\x1b[%d;%dH\x1b[%dm%s\x1b[27m", newGame_r, newGame_c, + select == 1 ? 7 : 27, "[NEW GAME]"); + if (saved) + printf("\x1b[%d;%dH\x1b[%dm%s\x1b[27m", resumeGame_r, resumeGame_c, + select == 2 ? 7 : 27, "[RESUME GAME]"); + printf("\x1b[%d;%dH\x1b[%dm%s\x1b[27m", quitGame_r, quitGame_c, + select + !saved == 3 ? 7 : 27, "[QUIT]"); + fflush(stdout); + + int keyCode = getInput(); + int mouse_r, mouse_c, mouse_event; + if (getMouseInput(mouse_r, mouse_c, mouse_event)) { + if (mouse_event == 0) { + if (mouse_r == newGame_r && mouse_c >= newGame_c && + mouse_c < newGame_c + 10) + select = 1; + else if (mouse_r == resumeGame_r && mouse_c >= resumeGame_c && + mouse_c < resumeGame_c + 13 && saved) + select = 2; + else if (mouse_r == quitGame_r && mouse_c >= quitGame_c && + mouse_c < quitGame_c + 6) + select = 2 + saved; + } else if (mouse_event == 1) { + if (mouse_r == newGame_r && mouse_c >= newGame_c && + mouse_c < newGame_c + 10) + return 1; + else if (mouse_r == resumeGame_r && mouse_c >= resumeGame_c && + mouse_c < resumeGame_c + 13 && saved) + return 2; + else if (mouse_r == quitGame_r && mouse_c >= quitGame_c && + mouse_c < quitGame_c + 6) + return 0; + } + } + + if (keyCode == KEY_DOWN_ARROW) + select = std::min(numberOfOptions, select + 1); + else if (keyCode == KEY_UP_ARROW) + select = std::max(1, select - 1); + else if (keyCode == ' ' || keyCode == '\r' || keyCode == '\n') { + if (select == 1) + return 1; + else if (select == 2) + return saved ? 2 : 0; + else + return 0; + } + } +} + +int lastRows = 16, lastCols = 30, lastBombCount = 99; + +void startGameMenu(int& rows, int& cols, int& bombCount) { + clearScreenInline(40); + int select = 1; + rows = lastRows, cols = lastCols, bombCount = lastBombCount; + while (true) { + int width, height; + getConsoleWidthHeight(width, height); + if (width != lastWidth || height != lastHeight) + clearScreenInline(40); + lastWidth = width; + lastHeight = height; + int menuHeight = 2 + 2 * 3 + 1; + int menuWidth = 36; + int menu_r = (height - menuHeight - 1) / 2 + 1; + int menu_c = (width - menuWidth) / 2 + 1; + + assertScreenSize(menuHeight + 1, std::max(menuWidth, 70)); + + printf("\x1b[%d;%dH", menu_r, menu_c + 1); + for (int i = 1; i < menuWidth - 1; i++) + printf("═"); + printf("\x1b[%d;%dH", menu_r + menuHeight - 1, menu_c + 1); + for (int i = 1; i < menuWidth - 1; i++) + printf("═"); + for (int i = menu_r + 1; i < menu_r + menuHeight - 1; i++) + printf("\x1b[%d;%dH║", i, menu_c); + for (int i = menu_r + 1; i < menu_r + menuHeight - 1; i++) + printf("\x1b[%d;%dH║", i, menu_c + menuWidth - 1); + + printf("\x1b[%d;%dH╔", menu_r, menu_c); + printf("\x1b[%d;%dH╗", menu_r, menu_c + menuWidth - 1); + printf("\x1b[%d;%dH╚", menu_r + menuHeight - 1, menu_c); + printf("\x1b[%d;%dH╝", menu_r + menuHeight - 1, menu_c + menuWidth - 1); + + int MIN_ROWS = 3; + int MIN_COLS = 5; + int MAX_ROWS = 40; + int MAX_COLS = 40; + int MIN_BOMB = 1; + int MAX_BOMB = rows * cols / 2; + + if (bombCount > MAX_BOMB) + bombCount = MAX_BOMB; + + char str_rows[100], str_cols[100], str_bomb[100]; + sprintf(str_rows, "Minefield Height: \x1b[%dm< %2d >\x1b[27m", + select == 1 ? 7 : 27, rows); + sprintf(str_cols, "Minefield Width: \x1b[%dm< %2d >\x1b[27m", + select == 2 ? 7 : 27, cols); + sprintf(str_bomb, "Number of Mines: \x1b[%dm< %*s%2d%*s >\x1b[27m", + select == 3 ? 7 : 27, bombCount > 99 ? 0 : 1, "", bombCount, + bombCount > 999 ? 0 : 1, ""); + printf("\x1b[%d;%dH%s", menu_r + 2, menu_c + 4, str_rows); + printf("\x1b[%d;%dH%s", menu_r + 2 + 2, menu_c + 4, str_cols); + printf("\x1b[%d;%dH%s", menu_r + 2 + 2 + 2, menu_c + 4, str_bomb); + + // FOOTER + printf("\x1b[%d;1H", height); + printf( + "\x1b[34m[UP/DOWN]\x1b[97m Choose options " + "\x1b[34m[LEFT/RIGHT]\x1b[97m Change value \x1b[34m[ENTER]\x1b[97m " + "Confirm"); + fflush(stdout); + + int keyCode = getInput(); + if (keyCode == KEY_DOWN_ARROW) + select = std::min(3, select + 1); + else if (keyCode == KEY_UP_ARROW) + select = std::max(1, select - 1); + else if (keyCode == KEY_LEFT_ARROW) { + if (select == 1) + rows = std::max(MIN_ROWS, rows - 1); + else if (select == 2) + cols = std::max(MIN_COLS, cols - 1); + else + bombCount = std::max(MIN_BOMB, bombCount - 1); + } else if (keyCode == KEY_RIGHT_ARROW) { + if (select == 1) + rows = std::min(MAX_ROWS, rows + 1); + else if (select == 2) + cols = std::min(MAX_COLS, cols + 1); + else + bombCount = std::min(MAX_BOMB, bombCount + 1); + } else if (keyCode == '\r' || keyCode == '\n') { + lastRows = rows, lastCols = cols, lastBombCount = bombCount; + return; + } + } +} + +static void renderLoseMenu(const GameState& state, int cursor_r, int cursor_c) { + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + render(state, cursor_r, cursor_c, false, false, true); + + // HEADER + char msg[] = "YOU LOSE!"; + renderHeader((char*)"\x1b[41m%s\x1b[40m", msg, 2, + (consoleWidth - strlen(msg)) / 2 + 1); + + // FOOTER + printf("\x1b[%d;1H", consoleHeight); + printf("\x1b[2K"); + printf("\x1b[34m[ESC]\x1b[97m Back to Menu \x1b[34m[Q]\x1b[97m Quit"); + fflush(stdout); +} + +bool loseMenu(const GameState& state, int cursor_r, int cursor_c) { + renderLoseMenu(state, cursor_r, cursor_c); + while (true) { + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + if (consoleWidth != lastWidth || consoleHeight != lastHeight) + renderLoseMenu(state, cursor_r, cursor_c); + + int keyCode = getInput(); + if (keyCode == 'q') + return false; + else if (keyCode == KEY_ESC) + return true; + } +} + +static void renderWinMenu(const GameState& state, int bestTime) { + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + render(state, 0, 0, true, false, true); + + // HEADER + char header[100]; + sprintf(header, "Time: %3ds | Best Time: %3ds", state.elapsedTime, + bestTime); + renderHeader((char*)"%s", header, 1, (consoleWidth - strlen(header)) / 2 + 1); + char msg[] = "YOU WIN!"; + renderHeader((char*)"\x1b[42m%s\x1b[40m", msg, 2, + (consoleWidth - strlen(msg)) / 2 + 1); + + // FOOTER + printf("\x1b[%d;1H", consoleHeight); + printf("\x1b[2K"); + printf("\x1b[34m[ESC]\x1b[97m Back to Menu \x1b[34m[Q]\x1b[97m Quit"); + fflush(stdout); +} + +bool winMenu(const GameState& state, int bestTime) { + renderWinMenu(state, bestTime); + while (true) { + int consoleWidth, consoleHeight; + getConsoleWidthHeight(consoleWidth, consoleHeight); + if (consoleWidth != lastWidth || consoleHeight != lastHeight) + renderWinMenu(state, bestTime); + + int keyCode = getInput(); + if (keyCode == 'q') + return false; + else if (keyCode == KEY_ESC) + return true; + } +} + +void wait() { + getch(); +} diff --git a/src/ui_controller.h b/src/ui_controller.h new file mode 100644 index 0000000..2df038e --- /dev/null +++ b/src/ui_controller.h @@ -0,0 +1,50 @@ +#ifndef UI_CONTROLLER_H +#define UI_CONTROLLER_H +#include "game_controller.h" + +#ifdef __WIN32 + +#define KEY_UP_ARROW 72 +#define KEY_DOWN_ARROW 80 +#define KEY_LEFT_ARROW 75 +#define KEY_RIGHT_ARROW 77 +#define KEY_ESC 27 + +#else + +#define KEY_UP_ARROW 65 +#define KEY_DOWN_ARROW 66 +#define KEY_LEFT_ARROW 68 +#define KEY_RIGHT_ARROW 67 +#define KEY_ESC 27 + +#endif + +void initConsole(); +void closeConsole(); +void hideCursor(); +void showCursor(); +void getConsoleWidthHeight(int& width, int& height); +void clearScreen(int mode); +void clearScreenInline(int mode); +bool screenToBoard(GameState& state, + int screen_r, + int screen_c, + int& board_r, + int& board_c); +void render(const GameState& state, + int cursor_r, + int cursor_c, + bool skipHeader = false, + bool skipBoard = false, + bool skipFooter = false); +int getInput(); +bool getMouseInput(int& r, int& c, int& event); +int mainMenu(bool saved); +void startGameMenu(int& rows, int& cols, int& bombCount); +bool loseMenu(const GameState& state, int cursor_r, int cursor_c); +bool winMenu(const GameState& state, int bestTime); +int pauseMenu(const GameState& state); +void wait(); + +#endif