From f6cc939c4da24ee7a38c4fec7b048ad6899867b7 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 15 May 2026 07:29:14 +1000 Subject: [PATCH] tdpro add minesweeper game --- examples/companion_radio/main.cpp | 87 +++- .../companion_radio/ui-new/GamesMenuScreen.h | 36 +- .../ui-new/MinesweeperScreen.h | 466 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 13 + examples/companion_radio/ui-new/UITask.h | 4 + 5 files changed, 568 insertions(+), 38 deletions(-) create mode 100644 examples/companion_radio/ui-new/MinesweeperScreen.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 8e205487..fea2eb54 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -31,6 +31,7 @@ #include "Tracescreen.h" #include "GamesMenuScreen.h" #include "SnakeScreen.h" + #include "MinesweeperScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -704,6 +705,7 @@ #include "Tracescreen.h" #include "GamesMenuScreen.h" #include "SnakeScreen.h" + #include "MinesweeperScreen.h" static TouchDrvGT911 gt911Touch; static bool gt911Ready = false; @@ -1229,6 +1231,11 @@ static void lastHeardToggleContact() { return '\r'; } + // Minesweeper screen: tap = Enter (reveal cell / start / restart) + if (ui_task.isOnMinesweeperScreen()) { + return '\r'; + } + // Home screen FIRST page: tile taps (virtual coordinate hit test) if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) { const int tileW = 40, tileH = 22, gapX = 1, gapY = 1; @@ -1502,6 +1509,15 @@ static void lastHeardToggleContact() { } } + // Minesweeper screen: swipes move cursor + if (ui_task.isOnMinesweeperScreen()) { + if (horizontal) { + return (dx < 0) ? 'a' : 'd'; + } else { + return (dy < 0) ? 'w' : 's'; + } + } + // Games menu: vertical swipe scrolls list if (ui_task.isOnGamesMenu()) { if (!horizontal) { @@ -1587,6 +1603,11 @@ static void lastHeardToggleContact() { return 'q'; } + // Minesweeper screen: long press toggles flag on cursor cell + if (ui_task.isOnMinesweeperScreen()) { + return 'f'; + } + // Games menu: long press exits to home if (ui_task.isOnGamesMenu()) { return 'q'; @@ -3432,6 +3453,13 @@ void loop() { ui_task.gotoGamesMenu(); } } + // Minesweeper screen: check if Exit was triggered + if (ui_task.isOnMinesweeperScreen()) { + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + ui_task.gotoGamesMenu(); + } + } // Games menu: check if game launch was triggered if (ui_task.isOnGamesMenu()) { GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); @@ -3440,6 +3468,7 @@ void loop() { gm->clearFlags(); switch (sel) { case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; default: break; } } @@ -3484,6 +3513,13 @@ void loop() { ui_task.gotoGamesMenu(); } } + // Minesweeper screen: check if Exit was triggered + if (ui_task.isOnMinesweeperScreen()) { + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + ui_task.gotoGamesMenu(); + } + } // Games menu: check if game launch was triggered if (ui_task.isOnGamesMenu()) { GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); @@ -3492,6 +3528,7 @@ void loop() { gm->clearFlags(); switch (sel) { case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; default: break; } } @@ -3696,6 +3733,12 @@ void loop() { if (ss && ss->wantsExit()) { ui_task.gotoGamesMenu(); } + } else if (ui_task.isOnMinesweeperScreen()) { + ui_task.injectKey('q'); + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + ui_task.gotoGamesMenu(); + } } else if (ui_task.isOnGamesMenu()) { ui_task.gotoHomeScreen(); } else if (ui_task.isOnChannelPickerScreen()) { @@ -3862,6 +3905,7 @@ void loop() { gm->clearFlags(); switch (sel) { case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; default: break; } } @@ -3872,6 +3916,13 @@ void loop() { if (ss && ss->wantsExit()) { ui_task.gotoGamesMenu(); } + } else if (ui_task.isOnMinesweeperScreen()) { + // Minesweeper: Enter reveals cell or starts/restarts + ui_task.injectKey('\r'); + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + ui_task.gotoGamesMenu(); + } } else if (ui_task.isOnChannelPickerScreen()) { // Channel picker: Enter selects channel ui_task.injectKey('\r'); @@ -4978,7 +5029,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -4997,7 +5048,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -5020,7 +5071,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -5039,7 +5090,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -5062,7 +5113,7 @@ void handleKeyboardInput() { } else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnSnakeScreen() + || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -5081,7 +5132,7 @@ void handleKeyboardInput() { } else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() - || ui_task.isOnSnakeScreen() + || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -5118,7 +5169,7 @@ void handleKeyboardInput() { gm->clearFlags(); switch (sel) { case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; - // case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; + case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; // case GAME_2048: ui_task.goto2048Screen(); break; default: break; } @@ -5129,6 +5180,12 @@ void handleKeyboardInput() { if (ss && ss->wantsExit()) { ui_task.gotoGamesMenu(); } + } else if (ui_task.isOnMinesweeperScreen()) { + ui_task.injectKey('\r'); + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + ui_task.gotoGamesMenu(); + } } else if (ui_task.isOnChannelPickerScreen()) { ui_task.injectKey('\r'); // Picker handles Enter: selects channel + sets wantsExit ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen(); @@ -5308,8 +5365,12 @@ void handleKeyboardInput() { break; case 'f': + // Minesweeper: F toggles flag on cursor cell + if (ui_task.isOnMinesweeperScreen()) { + ui_task.injectKey('f'); + } // Start discovery scan from home/contacts screen, or rescan on discovery screen - if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) { + else if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) { Serial.println("Starting discovery scan..."); the_mesh.startDiscovery(); ui_task.gotoDiscoveryScreen(); @@ -5423,6 +5484,16 @@ void handleKeyboardInput() { } break; } + // Minesweeper screen: Q goes back to games menu + if (ui_task.isOnMinesweeperScreen()) { + ui_task.injectKey('q'); + MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen(); + if (ms && ms->wantsExit()) { + Serial.println("Nav: Minesweeper -> Games Menu"); + ui_task.gotoGamesMenu(); + } + break; + } // Games menu: Q goes back to home if (ui_task.isOnGamesMenu()) { Serial.println("Nav: Games Menu -> Home"); diff --git a/examples/companion_radio/ui-new/GamesMenuScreen.h b/examples/companion_radio/ui-new/GamesMenuScreen.h index dd3c58c0..7bce7629 100644 --- a/examples/companion_radio/ui-new/GamesMenuScreen.h +++ b/examples/companion_radio/ui-new/GamesMenuScreen.h @@ -18,7 +18,7 @@ class UITask; enum GameID { GAME_NONE = 0, GAME_SNAKE, - // GAME_MINESWEEPER, + GAME_MINESWEEPER, // GAME_2048, GAME_COUNT // Must be last -- used for array sizing }; @@ -38,12 +38,12 @@ private: const char* description; }; - static constexpr int NUM_GAMES = 1; // Increment as games are added + static constexpr int NUM_GAMES = 2; // Increment as games are added static const GameEntry* getGames() { static const GameEntry games[NUM_GAMES] = { { GAME_SNAKE, "Snake", "Classic Nokia-style" }, - // { GAME_MINESWEEPER, "Minesweeper", "Tap to reveal" }, + { GAME_MINESWEEPER, "Minesweeper", "Find the mines" }, // { GAME_2048, "2048", "Slide and merge" }, }; return games; @@ -110,11 +110,7 @@ public: // --- Game list --- int y = 18; -#if defined(LilyGo_T5S3_EPaper_Pro) int lineH = 16; -#else - int lineH = 20; -#endif for (int i = 0; i < NUM_GAMES; i++) { bool selected = (i == _cursor); @@ -129,26 +125,10 @@ public: } #if defined(LilyGo_T5S3_EPaper_Pro) - // T5S3: name centred, description below - display.drawTextCentered(display.width() / 2, y + 1, getGames()[i].name); - if (!selected) display.setColor(DisplayDriver::GREEN); - // Description on next line at smaller size - display.setTextSize(0); - display.drawTextCentered(display.width() / 2, y + 9, getGames()[i].description); - display.setTextSize(1); - lineH = 20; // Accommodate two-line entries + display.drawTextCentered(display.width() / 2, y + 2, getGames()[i].name); #else - // T-Deck Pro: name + description on one line - char buf[48]; - snprintf(buf, sizeof(buf), "%s", getGames()[i].name); display.setCursor(6, y + 2); - display.print(buf); - - // Description in green (or dark if highlighted) - if (!selected) display.setColor(DisplayDriver::GREEN); - int nameW = display.getTextWidth(buf); - display.setCursor(6 + nameW + 8, y + 2); - display.print(getGames()[i].description); + display.print(getGames()[i].name); #endif y += lineH; @@ -174,11 +154,7 @@ public: // --- T5S3 touch: tap to select game entry --- int selectRowAtVY(int vy) { int y = 18; -#if defined(LilyGo_T5S3_EPaper_Pro) - int lineH = 20; -#else - int lineH = 20; -#endif + int lineH = 16; if (vy < y) return 0; // Above list int row = (vy - y) / lineH; if (row >= NUM_GAMES) return 0; // Below list diff --git a/examples/companion_radio/ui-new/MinesweeperScreen.h b/examples/companion_radio/ui-new/MinesweeperScreen.h new file mode 100644 index 00000000..71396368 --- /dev/null +++ b/examples/companion_radio/ui-new/MinesweeperScreen.h @@ -0,0 +1,466 @@ +#pragma once + +// ============================================================================= +// MinesweeperScreen -- Classic Minesweeper for Meck e-ink devices +// +// 9x9 grid, 10 mines (classic Beginner difficulty). +// First reveal is always safe -- mines are placed after the first click. +// Fully turn-based: no tick timer, renders only on input. Perfect for e-ink. +// +// T-Deck Pro: 14x14 pixel cells (126x126 grid area on 240x320 display) +// T5S3: 8x8 pixel cells (72x72 grid area on 128x128 virtual display) +// ============================================================================= + +#include +#include + +// Forward declarations +class UITask; + +// -- Grid parameters -- +#define MINE_GRID_W 9 +#define MINE_GRID_H 9 +#define MINE_COUNT 10 +#define MINE_TOTAL (MINE_GRID_W * MINE_GRID_H) + +// -- Cell sizes per platform -- +#if defined(LilyGo_T5S3_EPaper_Pro) + #define MINE_CELL 8 + #define MINE_HDR 14 + #define MINE_FTR 10 +#else + #define MINE_CELL 14 + #define MINE_HDR 14 + #define MINE_FTR 14 +#endif + +#define MINE_VALUE 9 // Content value indicating a mine + +class MinesweeperScreen : public UIScreen { +public: + enum GameState { READY, PLAYING, WON, LOST }; + enum CellState : uint8_t { CELL_HIDDEN, CELL_REVEALED, CELL_FLAGGED }; + +private: + UITask* _task; + bool _wantsExit; + + // Grid + uint8_t _content[MINE_TOTAL]; // 0-8 = adjacent mine count, MINE_VALUE = mine + CellState _cellState[MINE_TOTAL]; // Hidden, revealed, or flagged + + // Game state + GameState _state; + bool _minesPlaced; // False until first reveal (first-click safety) + int _cursorX, _cursorY; + int _flagCount; + int _revealedCount; + + // Display layout (recomputed each render based on game state) + int _offsetX, _offsetY; + bool _cursorBlink; // Toggles each render for slow blink cursor + + // Simple xorshift PRNG + uint16_t _rngState; + + uint16_t rng() { + _rngState ^= _rngState << 7; + _rngState ^= _rngState >> 9; + _rngState ^= _rngState << 8; + return _rngState; + } + + // -- Grid helpers -- + + int idx(int x, int y) const { return y * MINE_GRID_W + x; } + bool inBounds(int x, int y) const { return x >= 0 && x < MINE_GRID_W && y >= 0 && y < MINE_GRID_H; } + + void resetGrid() { + memset(_content, 0, sizeof(_content)); + for (int i = 0; i < MINE_TOTAL; i++) _cellState[i] = CELL_HIDDEN; + _minesPlaced = false; + _flagCount = 0; + _revealedCount = 0; + _cursorX = MINE_GRID_W / 2; + _cursorY = MINE_GRID_H / 2; + } + + // Place mines randomly, excluding the first-clicked cell and its neighbours + void placeMines(int safeX, int safeY) { + _rngState = (uint16_t)(millis() ^ 0xC0DE); + int placed = 0; + while (placed < MINE_COUNT) { + int x = rng() % MINE_GRID_W; + int y = rng() % MINE_GRID_H; + // Skip safe zone (first click + 8 neighbours) + if (abs(x - safeX) <= 1 && abs(y - safeY) <= 1) continue; + // Skip if already a mine + if (_content[idx(x, y)] == MINE_VALUE) continue; + _content[idx(x, y)] = MINE_VALUE; + placed++; + } + // Compute adjacency counts + for (int cy = 0; cy < MINE_GRID_H; cy++) { + for (int cx = 0; cx < MINE_GRID_W; cx++) { + if (_content[idx(cx, cy)] == MINE_VALUE) continue; + int count = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int nx = cx + dx, ny = cy + dy; + if (inBounds(nx, ny) && _content[idx(nx, ny)] == MINE_VALUE) count++; + } + } + _content[idx(cx, cy)] = count; + } + } + _minesPlaced = true; + } + + // Flood-fill reveal from (x,y). Reveals empty cells and their numbered borders. + void floodReveal(int x, int y) { + if (!inBounds(x, y)) return; + int i = idx(x, y); + if (_cellState[i] != CELL_HIDDEN) return; + if (_content[i] == MINE_VALUE) return; + + _cellState[i] = CELL_REVEALED; + _revealedCount++; + + // If this cell is 0 (no adjacent mines), reveal all neighbours + if (_content[i] == 0) { + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + floodReveal(x + dx, y + dy); + } + } + } + } + + // Reveal a cell. Returns false if mine was hit. + bool revealCell(int x, int y) { + int i = idx(x, y); + if (_cellState[i] != CELL_HIDDEN) return true; // Already revealed or flagged + + // First click: place mines safely + if (!_minesPlaced) { + placeMines(x, y); + } + + if (_content[i] == MINE_VALUE) { + // Hit a mine -- game over + _cellState[i] = CELL_REVEALED; + _state = LOST; + revealAllMines(); + return false; + } + + floodReveal(x, y); + checkWin(); + return true; + } + + void toggleFlag(int x, int y) { + int i = idx(x, y); + if (_cellState[i] == CELL_HIDDEN) { + _cellState[i] = CELL_FLAGGED; + _flagCount++; + } else if (_cellState[i] == CELL_FLAGGED) { + _cellState[i] = CELL_HIDDEN; + _flagCount--; + } + // Can't flag revealed cells + } + + void checkWin() { + // Win when all non-mine cells are revealed + if (_revealedCount == MINE_TOTAL - MINE_COUNT) { + _state = WON; + // Auto-flag remaining mines + for (int i = 0; i < MINE_TOTAL; i++) { + if (_content[i] == MINE_VALUE && _cellState[i] == CELL_HIDDEN) { + _cellState[i] = CELL_FLAGGED; + _flagCount++; + } + } + } + } + + void revealAllMines() { + for (int i = 0; i < MINE_TOTAL; i++) { + if (_content[i] == MINE_VALUE) { + _cellState[i] = CELL_REVEALED; + } + } + } + + // -- Drawing helpers -- + + void drawCell(DisplayDriver& display, int gx, int gy) const { + int px = _offsetX + gx * MINE_CELL; + int py = _offsetY + gy * MINE_CELL; + int i = idx(gx, gy); + bool isCursor = (gx == _cursorX && gy == _cursorY && _state == PLAYING); + + if (_cellState[i] == CELL_HIDDEN) { + if (isCursor && !_cursorBlink) { + // Cursor blink OFF phase: outline only (visible gap in the solid grid) + display.setColor(DisplayDriver::LIGHT); + display.drawRect(px, py, MINE_CELL, MINE_CELL); + display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2); + } else { + // Solid filled with 1px inset (preserves grid lines between cells) + display.setColor(DisplayDriver::LIGHT); + display.fillRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2); + } + } else if (_cellState[i] == CELL_FLAGGED) { + if (isCursor && !_cursorBlink) { + // Cursor blink OFF phase: outline with F + display.setColor(DisplayDriver::LIGHT); + display.drawRect(px, py, MINE_CELL, MINE_CELL); + display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2); + } else { + // Solid fill with 1px inset + display.setColor(DisplayDriver::LIGHT); + display.fillRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2); + } + // F overlay + display.setColor((isCursor && !_cursorBlink) ? DisplayDriver::LIGHT : DisplayDriver::DARK); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.setTextSize(0); + display.drawTextCentered(px + MINE_CELL / 2, py + 1, "F"); +#else + display.setTextSize(1); + display.drawTextCentered(px + MINE_CELL / 2, py + 3, "F"); +#endif + } else { + // Revealed cell: thin border + display.setColor(DisplayDriver::LIGHT); + display.drawRect(px, py, MINE_CELL, MINE_CELL); + + if (_content[i] == MINE_VALUE) { + // Mine: solid dot in centre + int dotR = MINE_CELL / 4; + if (dotR < 2) dotR = 2; + display.setColor(DisplayDriver::LIGHT); + display.fillRect(px + MINE_CELL/2 - dotR, py + MINE_CELL/2 - dotR, dotR*2, dotR*2); + } else if (_content[i] > 0) { + // Number 1-8 + char num[2] = { (char)('0' + _content[i]), '\0' }; + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.setTextSize(0); + display.drawTextCentered(px + MINE_CELL / 2, py + 1, num); +#else + display.setTextSize(1); + display.drawTextCentered(px + MINE_CELL / 2, py + 3, num); +#endif + } + // Revealed 0: just the border (already drawn) + + // Cursor on revealed cell: green double border + if (isCursor) { + display.setColor(DisplayDriver::GREEN); + display.drawRect(px, py, MINE_CELL, MINE_CELL); + display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2); + } + } + } + +public: + MinesweeperScreen(UITask* task) + : _task(task), _wantsExit(false), _state(READY), _minesPlaced(false), + _cursorX(MINE_GRID_W / 2), _cursorY(MINE_GRID_H / 2), + _flagCount(0), _revealedCount(0), + _offsetX(0), _offsetY(0), _cursorBlink(false), _rngState(0xBEEF) { + memset(_content, 0, sizeof(_content)); + for (int i = 0; i < MINE_TOTAL; i++) _cellState[i] = CELL_HIDDEN; + } + + bool wantsExit() const { return _wantsExit; } + void clearExit() { _wantsExit = false; } + GameState getState() const { return _state; } + + void enter() { + _wantsExit = false; + // If game was PLAYING, resume where we left off + // If READY, WON, or LOST, show that state as-is + } + + // ------- Input ------- + bool handleInput(char c) override { + switch (_state) { + case READY: + if (c == '\r') { + resetGrid(); + _state = PLAYING; + return true; + } + if (c == 'q' || c == 'Q') { _wantsExit = true; return true; } + return false; + + case PLAYING: + switch (c) { + case 'w': case 'W': if (_cursorY > 0) _cursorY--; return true; + case 's': case 'S': if (_cursorY < MINE_GRID_H - 1) _cursorY++; return true; + case 'a': case 'A': if (_cursorX > 0) _cursorX--; return true; + case 'd': case 'D': if (_cursorX < MINE_GRID_W - 1) _cursorX++; return true; + case '\r': + revealCell(_cursorX, _cursorY); + return true; + case 'f': case 'F': + toggleFlag(_cursorX, _cursorY); + return true; + case 'q': case 'Q': + _wantsExit = true; + return true; + default: return false; + } + + case WON: + case LOST: + if (c == '\r') { + resetGrid(); + _state = PLAYING; + return true; + } + if (c == 'q' || c == 'Q') { _wantsExit = true; return true; } + return false; + } + return false; + } + + // ------- Render ------- + int render(DisplayDriver& display) override { + // Compute grid offset based on state + // PLAYING: full screen (no header/footer) for clean grid + // Other states: header + footer visible + int gridPixW = MINE_GRID_W * MINE_CELL; + int gridPixH = MINE_GRID_H * MINE_CELL; + if (_state == PLAYING) { + _offsetX = (display.width() - gridPixW) / 2; + _offsetY = (display.height() - gridPixH) / 2; + } else { + int usableH = display.height() - MINE_HDR - MINE_FTR; + _offsetX = (display.width() - gridPixW) / 2; + _offsetY = MINE_HDR + (usableH - gridPixH) / 2; + } + + // Toggle cursor blink each render cycle + if (_state == PLAYING) _cursorBlink = !_cursorBlink; + + display.startFrame(); + display.setTextSize(1); + + if (_state == READY) { + // --- READY: header + instructions + footer --- + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(display.width() / 2, 2, "Minesweeper"); +#else + display.setCursor(2, 2); + display.print("Minesweeper"); +#endif + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, MINE_HDR - 2, display.width(), 1); + + int cx = display.width() / 2; + int y = MINE_HDR + 10; + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(cx, y, "Minesweeper"); + y += 16; + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(cx, y, "Swipe to move cursor"); + y += 11; + display.drawTextCentered(cx, y, "Tap to reveal"); + y += 11; + display.drawTextCentered(cx, y, "Long press to flag"); +#else + display.drawTextCentered(cx, y, "W/S/A/D to move cursor"); + y += 11; + display.drawTextCentered(cx, y, "Enter to reveal a cell"); + y += 11; + display.drawTextCentered(cx, y, "F to flag a mine"); +#endif + y += 16; + display.setColor(DisplayDriver::LIGHT); + char info[32]; + snprintf(info, sizeof(info), "%dx%d grid, %d mines", MINE_GRID_W, MINE_GRID_H, MINE_COUNT); + display.drawTextCentered(cx, y, info); + y += 16; +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(cx, y, "Tap to start"); +#else + display.drawTextCentered(cx, y, "Press Enter to start"); +#endif + + // Footer + display.setColor(DisplayDriver::LIGHT); +#if !defined(LilyGo_T5S3_EPaper_Pro) + int fy = display.height() - 12; + display.drawRect(0, fy - 2, display.width(), 1); + display.setCursor(2, fy); + display.print("Enter:Start Q:Back"); +#endif + return 5000; + + } else if (_state == PLAYING) { + // --- PLAYING: full screen grid, no header/footer --- + + // Draw grid border + display.setColor(DisplayDriver::LIGHT); + display.drawRect(_offsetX - 1, _offsetY - 1, gridPixW + 2, gridPixH + 2); + + // Draw all cells + for (int gy = 0; gy < MINE_GRID_H; gy++) { + for (int gx = 0; gx < MINE_GRID_W; gx++) { + drawCell(display, gx, gy); + } + } + + return 100; // Blink cycle -- clamped to 800ms by render floor + + } else { + // --- WON / LOST: grid + overlay --- + + // Draw grid border + display.setColor(DisplayDriver::LIGHT); + display.drawRect(_offsetX - 1, _offsetY - 1, gridPixW + 2, gridPixH + 2); + + // Draw all cells (no cursor blink in end state) + for (int gy = 0; gy < MINE_GRID_H; gy++) { + for (int gx = 0; gx < MINE_GRID_W; gx++) { + drawCell(display, gx, gy); + } + } + + // Overlay + int cx = display.width() / 2; + int cy = display.height() / 2; + int boxW = display.width() * 3 / 4; + int boxH = 50; + int boxX = cx - boxW / 2; + int boxY = cy - boxH / 2; + + display.setColor(DisplayDriver::DARK); + display.fillRect(boxX, boxY, boxW, boxH); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(boxX, boxY, boxW, boxH); + + int ty = boxY + 10; + if (_state == WON) { + display.setColor(DisplayDriver::YELLOW); + display.drawTextCentered(cx, ty, "Cleared!"); + } else { + display.drawTextCentered(cx, ty, "Boom!"); + } + ty += 16; + display.setColor(DisplayDriver::GREEN); + display.drawTextCentered(cx, ty, "Enter:Retry Q:Back"); + + return 5000; + } + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7f1794b6..77ad55c4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -11,6 +11,7 @@ #include "Tracescreen.h" #include "GamesMenuScreen.h" #include "SnakeScreen.h" +#include "MinesweeperScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -1403,6 +1404,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no trace_screen = new TraceScreen(this, &rtc_clock); games_menu_screen = new GamesMenuScreen(this); snake_screen = new SnakeScreen(this, &rtc_clock); + minesweeper_screen = new MinesweeperScreen(this); #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) lock_screen = new LockScreen(this, &rtc_clock, node_prefs); #endif @@ -3145,6 +3147,17 @@ void UITask::gotoSnakeScreen() { _next_refresh = 100; } +void UITask::gotoMinesweeperScreen() { + MinesweeperScreen* ms = (MinesweeperScreen*)minesweeper_screen; + ms->enter(); + setCurrScreen(minesweeper_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::onTraceResult(uint32_t tag, uint8_t flags, const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len, int8_t final_snr) { TraceScreen* ts = (TraceScreen*)trace_screen; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 88045fdf..691dd93a 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -102,6 +102,7 @@ class UITask : public AbstractUITask { UIScreen* trace_screen; // Trace path screen (standalone trace tool) UIScreen* games_menu_screen; // Games launcher menu UIScreen* snake_screen; // Snake game screen + UIScreen* minesweeper_screen; // Minesweeper game screen #ifdef MECK_WEB_READER UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required) #endif @@ -206,6 +207,7 @@ public: void gotoTraceScreen(); // Navigate to trace path screen void gotoGamesMenu(); // Navigate to games launcher menu void gotoSnakeScreen(); // Navigate to snake game + void gotoMinesweeperScreen(); // Navigate to minesweeper game #if HAS_GPS void gotoMapScreen(); // Navigate to map tile screen #endif @@ -265,6 +267,7 @@ public: bool isOnTraceScreen() const { return curr == trace_screen; } bool isOnGamesMenu() const { return curr == games_menu_screen; } bool isOnSnakeScreen() const { return curr == snake_screen; } + bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; } bool isOnMapScreen() const { return curr == map_screen; } #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) bool isLocked() const { return _locked; } @@ -352,6 +355,7 @@ public: UIScreen* getTraceScreen() const { return trace_screen; } UIScreen* getGamesMenuScreen() const { return games_menu_screen; } UIScreen* getSnakeScreen() const { return snake_screen; } + UIScreen* getMinesweeperScreen() const { return minesweeper_screen; } UIScreen* getMapScreen() const { return map_screen; } #ifdef MECK_WEB_READER UIScreen* getWebReaderScreen() const { return web_reader; }