mirror of
https://github.com/pelgraine/Meck.git
synced 2026-07-06 09:41:22 +02:00
495 lines
16 KiB
C++
495 lines
16 KiB
C++
#pragma once
|
|
|
|
// =============================================================================
|
|
// MinesweeperScreen -- Classic Minesweeper for Meck e-ink devices
|
|
//
|
|
// 9x9 grid, 10 mines (classic Beginner difficulty) on T-Deck Pro.
|
|
// 14x14 grid, 25 mines on T5S3 (fills the larger virtual display).
|
|
// 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: 14x14 grid at 8x8 pixel cells (112x112 on 128x128 virtual display)
|
|
// =============================================================================
|
|
|
|
#include <helpers/ui/UIScreen.h>
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
|
|
// Forward declarations
|
|
class UITask;
|
|
|
|
// -- Grid and cell parameters per platform --
|
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
|
#define MINE_GRID_W 14
|
|
#define MINE_GRID_H 14
|
|
#define MINE_COUNT 25
|
|
#define MINE_CELL_W 8
|
|
#define MINE_CELL_H 8
|
|
#define MINE_HDR 14
|
|
#define MINE_FTR 10
|
|
#elif defined(LilyGo_TDeck_Pro_Max)
|
|
// MAX renders into a 128x128 virtual canvas with non-uniform scaling
|
|
// (X 1.875, Y 2.5) and a (2,5) virtual offset applied by GxEPDDisplay.
|
|
// Cells are 8x6 virtual so they land square on the panel (8*1.875 = 6*2.5
|
|
// = 15 px). The grid is sized to the usable area, and the display offset is
|
|
// subtracted when centring so nothing clips at the bottom/right edge.
|
|
#define MINE_GRID_W 15
|
|
#define MINE_GRID_H 20
|
|
#define MINE_COUNT 50
|
|
#define MINE_CELL_W 8
|
|
#define MINE_CELL_H 6
|
|
#define MINE_HDR 14
|
|
#define MINE_FTR 14
|
|
#define MINE_OFFX_ADJ 2
|
|
#define MINE_OFFY_ADJ 5
|
|
#else
|
|
#define MINE_GRID_W 9
|
|
#define MINE_GRID_H 9
|
|
#define MINE_COUNT 10
|
|
#define MINE_CELL_W 14
|
|
#define MINE_CELL_H 14
|
|
#define MINE_HDR 14
|
|
#define MINE_FTR 14
|
|
#endif
|
|
#ifndef MINE_OFFX_ADJ
|
|
#define MINE_OFFX_ADJ 0
|
|
#endif
|
|
#ifndef MINE_OFFY_ADJ
|
|
#define MINE_OFFY_ADJ 0
|
|
#endif
|
|
// Cell-text size + vertical nudge. GxEPDDisplay::setCursor adds a +5 baseline
|
|
// fudge sized for the tall FreeSans fonts; for the small built-in (size 0)
|
|
// font on MAX that lands the glyph at the cell's bottom edge, so it needs a
|
|
// negative nudge to sit centred. T5S3 (FastEPD, no fudge) and Pro are unchanged.
|
|
#if defined(LilyGo_TDeck_Pro_Max)
|
|
#define MINE_TEXT_SIZE 0
|
|
#define MINE_TEXT_DY (-3)
|
|
#elif defined(LilyGo_T5S3_EPaper_Pro)
|
|
#define MINE_TEXT_SIZE 0
|
|
#define MINE_TEXT_DY 1
|
|
#else
|
|
#define MINE_TEXT_SIZE 1
|
|
#define MINE_TEXT_DY 3
|
|
#endif
|
|
#define MINE_TOTAL (MINE_GRID_W * MINE_GRID_H)
|
|
|
|
#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_W;
|
|
int py = _offsetY + gy * MINE_CELL_H;
|
|
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_W, MINE_CELL_H);
|
|
display.drawRect(px + 1, py + 1, MINE_CELL_W - 2, MINE_CELL_H - 2);
|
|
} else {
|
|
// Solid filled with 1px inset (preserves grid lines between cells)
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.fillRect(px + 1, py + 1, MINE_CELL_W - 2, MINE_CELL_H - 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_W, MINE_CELL_H);
|
|
display.drawRect(px + 1, py + 1, MINE_CELL_W - 2, MINE_CELL_H - 2);
|
|
} else {
|
|
// Solid fill with 1px inset
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.fillRect(px + 1, py + 1, MINE_CELL_W - 2, MINE_CELL_H - 2);
|
|
}
|
|
// F overlay
|
|
display.setColor((isCursor && !_cursorBlink) ? DisplayDriver::LIGHT : DisplayDriver::DARK);
|
|
display.setTextSize(MINE_TEXT_SIZE);
|
|
display.drawTextCentered(px + MINE_CELL_W / 2, py + MINE_TEXT_DY, "F");
|
|
} else {
|
|
// Revealed cell: thin border
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.drawRect(px, py, MINE_CELL_W, MINE_CELL_H);
|
|
|
|
if (_content[i] == MINE_VALUE) {
|
|
// Mine: solid dot in centre
|
|
int dotR = (MINE_CELL_W < MINE_CELL_H ? MINE_CELL_W : MINE_CELL_H) / 4;
|
|
if (dotR < 2) dotR = 2;
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
display.fillRect(px + MINE_CELL_W/2 - dotR, py + MINE_CELL_H/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);
|
|
display.setTextSize(MINE_TEXT_SIZE);
|
|
display.drawTextCentered(px + MINE_CELL_W / 2, py + MINE_TEXT_DY, num);
|
|
}
|
|
// 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_W, MINE_CELL_H);
|
|
display.drawRect(px + 1, py + 1, MINE_CELL_W - 2, MINE_CELL_H - 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 == KEY_CANCEL) { _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 KEY_CANCEL:
|
|
_wantsExit = true;
|
|
return true;
|
|
default: return false;
|
|
}
|
|
|
|
case WON:
|
|
case LOST:
|
|
if (c == '\r') {
|
|
resetGrid();
|
|
_state = PLAYING;
|
|
return true;
|
|
}
|
|
if (c == KEY_CANCEL) { _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_W;
|
|
int gridPixH = MINE_GRID_H * MINE_CELL_H;
|
|
if (_state == PLAYING) {
|
|
_offsetX = (display.width() - gridPixW) / 2 - MINE_OFFX_ADJ;
|
|
_offsetY = (display.height() - gridPixH) / 2 - MINE_OFFY_ADJ;
|
|
} else {
|
|
int usableH = display.height() - MINE_HDR - MINE_FTR;
|
|
_offsetX = (display.width() - gridPixW) / 2 - MINE_OFFX_ADJ;
|
|
_offsetY = MINE_HDR + (usableH - gridPixH) / 2 - MINE_OFFY_ADJ;
|
|
}
|
|
|
|
// 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 Sh+Del: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 Sh+Del:Back");
|
|
|
|
return 5000;
|
|
}
|
|
}
|
|
}; |