From b1e6242b29971d932d012f433aafb59f855e87d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Wed, 14 Oct 2026 18:46:49 +0200 Subject: [PATCH] Games App: Minesweeper and 2048 Two more Games in the row of icons, which now holds five. Minesweeper moves a cursor (a held arrow runs it along), digs with Enter, flags with f, shows where the mines were when it is lost, and keeps the fastest cleared field. 2048 slides with the arrows and keeps the best score; it has no clock, so nothing to pause. Neither keeps the screen lit. Played on the device: Minesweeper's cells went from 8 to 12 pixels to be read, and its field to 20 x 9. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- CONTEXT.md | 2 +- README.md | 4 +- assets/icons/mines.png | Bin 0 -> 135 bytes assets/icons/twenty48.png | Bin 0 -> 100 bytes lib/core/src/app_keys.h | 18 ++- site/content/guide/games.md | 32 ++++- site/data/keys.toml | 22 +++- src/apps/games_app.cpp | 227 +++++++++++++++++++++++++++++++++--- src/apps/games_app.h | 25 +++- src/ui/icons.cpp | 70 +++++++++++ src/ui/icons.h | 2 + 11 files changed, 371 insertions(+), 31 deletions(-) create mode 100644 assets/icons/mines.png create mode 100644 assets/icons/twenty48.png diff --git a/CONTEXT.md b/CONTEXT.md index fdbf36a..370a7a8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -187,7 +187,7 @@ A tag `v*` on the project's Gitea with a signed **Update File**, a factory image _Avoid_: flash, upgrade (alone) **Game**: -Something played inside the Games App: Pong, Snake or Tetris. A Game is not an App: it has no tile in the Launcher, and is found again paused when the Games App is left in the middle of one. +Something played inside the Games App: Pong, Snake, Tetris, Minesweeper or 2048. A Game is not an App: it has no tile in the Launcher, and is found again paused when the Games App is left in the middle of one. _Avoid_: mini-app, toy **Update File**: diff --git a/README.md b/README.md index 7b199f4..96462e8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ What it does today: - **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`. - **System:** load, tasks, memory, network, battery, live. - **SSH:** a terminal on another machine, with a password or the device's own key. -- **Games:** Pong, Snake and Tetris. +- **Games:** Pong, Snake, Tetris, Minesweeper and 2048. - **VPN:** a WireGuard tunnel. - **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot. - **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails. @@ -190,7 +190,7 @@ The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, ## Games -The Games App (issue #101) holds three Games, shown as a row of icons like Apps in the Launcher: Pong against the device (first to 7; the device's paddle has a top speed, so it can be beaten), Snake (38 x 19, the walls kill) and Tetris (10 x 20, the 7-bag, the next piece shown, a turn that shifts away from a wall, soft and hard drop). Each is a state machine in `lib/games`, host-tested: time and keys go in, the new state and what happened (a bounce, a line) come out, and a seed decides the dice, so a rule can be tested without playing. +The Games App (issue #101) holds five Games, shown as a row of icons like Apps in the Launcher. Two wait for a move: Minesweeper (20 x 9 with 28 mines, in cells of 12 pixels, laid at the first dig and never under or next to it; the fastest cleared field is kept) and 2048. Three run on a clock: Pong against the device (first to 7; the device's paddle has a top speed, so it can be beaten), Snake (38 x 19, the walls kill) and Tetris (10 x 20, the 7-bag, the next piece shown, a turn that shifts away from a wall, soft and hard drop). Each is a state machine in `lib/games`, host-tested: time and keys go in, the new state and what happened (a bounce, a line) come out, and a seed decides the dice, so a rule can be tested without playing. Apps used to hear only key presses. `KeyMapper::held()` says which keys are down, for a paddle that moves while a key is held and for Tetris's repeat. An App that is in use with no key pressed says so (`App::keepsScreenOn`): the screen stays lit, and an update waiting for a quiet moment keeps waiting. A Game left in the middle is found again paused. The best of each Game (for Pong, the longest rally) is kept in internal flash. Tetris plays Korobeiniki, the folk tune it has always had, a note at a time on a speaker channel of its own (`lib/games/src/tune.h`, host-tested timing); `0` switches it, `8` and `9` set its loudness, both kept; it hurries with each level. The beeps and the tune obey Settings > This device > Sound & LED. diff --git a/assets/icons/mines.png b/assets/icons/mines.png new file mode 100644 index 0000000000000000000000000000000000000000..3fd72c09241b618adb6050d7973a5704ac325104 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk@BpAX3RW*Q=pQnpsNX4z>gat-F_*tH4IB+Ow zI|O+JFt&V_=Um_^ypSPOYIQItQ2bGV(-fwX)g9p<gTe~DWM4fqR1xn literal 0 HcmV?d00001 diff --git a/assets/icons/twenty48.png b/assets/icons/twenty48.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8a03aa8ba80a58a6d4068a66f21f60a1011237 GIT binary patch literal 100 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk@BpAX3RW*Q=wx^3@NX4z>gau4L`dKbi9??p0 wNa{^toznK?L5FV$E6mdKI;Vst0IT*LI{*Lx literal 0 HcmV?d00001 diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 0a1975e..6b4be4d 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -329,7 +329,7 @@ inline constexpr KeyHelp kVpn[] = { {"; .", "up, down"}, }; -// games: Games, the three games +// games: Games, the games inline constexpr KeyHelp kGames[] = { {"Enter", "this game"}, {", /", "the game before, the next"}, @@ -365,6 +365,22 @@ inline constexpr KeyHelp kGamesTetris[] = { {"`", "the other games; this one waits"}, }; +// games-mines: Games, Minesweeper +inline constexpr KeyHelp kGamesMines[] = { + {"; . , /", "move; held, it runs"}, + {"Enter", "dig; on a number, the rest"}, + {"f", "a flag, on or off"}, + {"p", "pause; any key carries on"}, + {"`", "the other games; this one waits"}, +}; + +// games-2048: Games, 2048 +inline constexpr KeyHelp kGames2048[] = { + {"; . , /", "slide every tile that way"}, + {"Enter", "start, or start again"}, + {"`", "the other games; this one waits"}, +}; + // ssh: SSH, the hosts inline constexpr KeyHelp kSsh[] = { {"Enter", "connect, open"}, diff --git a/site/content/guide/games.md b/site/content/guide/games.md index c7cbad5..5765650 100644 --- a/site/content/guide/games.md +++ b/site/content/guide/games.md @@ -1,12 +1,12 @@ +++ title = "Games" -description = "Pong, Snake and Tetris, for when the radio is quiet." +description = "Pong, Snake, Tetris, Minesweeper and 2048, for when the radio is quiet." weight = 17 [extra] tag = "Games" +++ -The Games App opens on its three games, an icon each, as the Launcher shows Apps: , and / go from one to the next, with its name and your best so far under the row. Enter picks one, Enter again starts it. +The Games App opens on its five games, an icon each, as the Launcher shows Apps: , and / go from one to the next, with its name and your best so far under the row. Enter picks one, Enter again starts it. In every game: @@ -14,9 +14,9 @@ In every game: |---|---| | Enter | Starts, or starts again when a game is over | | p | Pauses. Any key carries on | -| ` | Back to the three games. This one waits, paused | +| ` | Back to the other games. This one waits, paused | -A game you leave in the middle, for another game or another App, is **found again paused**, and its icon has an orange dot. While a game is being played the screen does not dim. The games beep, and Tetris has its tune, unless **Settings → This device → Sound & LED** is off. There is no choice of difficulty: each game starts gently and gets faster. +A game you leave in the middle, for another game or another App, is **found again paused**, and its icon has an orange dot. While Pong, Snake or Tetris is being played the screen does not dim. The games beep, and Tetris has its tune, unless **Settings → This device → Sound & LED** is off. There is no choice of difficulty: Pong, Snake and Tetris start gently and get faster, and the other two are always the same size. ## Pong @@ -49,8 +49,30 @@ It plays to **Korobeiniki**, the tune Tetris has always had, round and round, an A full row goes: 100 points for one, 300 for two, 500 for three, **800 for four at once**, times the level. Falling faster gives a point a row, dropping two. The key that brought a piece down does not send the next one down too: let it go and press again. Every ten rows is a level, and the fall quickens. +## Minesweeper + +A field of 20 by 9 cells hides **28 mines**, about one cell in seven. Open every cell that has none. + +| Key | Does | +|---|---| +| ; . , / | Move the cursor. Held, it runs along | +| Enter | Digs the cell under the cursor | +| f | Puts a flag on a cell you think is a mine, or takes it off | + +A cell you open shows **how many mines touch it**, of the eight around; one that touches none opens its neighbours too, and so on. The **first dig is never a mine**, nor next to one, so it always opens something. + +Enter on an open number whose mines are all flagged opens the rest of the cells around it at once. If a flag is wrong, that is a mine dug. + +The top line says how many mines are left to flag and how long you have been at it; the clock starts at the first dig. Dig a mine and the field shows where they all were, with the wrong flags gone pale. What is kept is your **fastest cleared field**. + +## 2048 + +The arrows slide every tile on the 4 by 4 board as far as it goes. **Two tiles alike that meet become one of twice the value**, and after each move a new 2 (sometimes a 4) appears. The score is the sum of every tile you made. + +Reaching **2048** is said once, and the game goes on for as long as a move is left. There is nothing to pause: leave it and come back, and the board is as you left it. + ## The keys, as the device lists them What Fn + h shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. -{{ keys(scopes=["games", "games-pong", "games-snake", "games-tetris"]) }} +{{ keys(scopes=["games", "games-pong", "games-snake", "games-tetris", "games-mines", "games-2048"]) }} diff --git a/site/data/keys.toml b/site/data/keys.toml index 90c78a9..384feb1 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -387,7 +387,7 @@ rows = [ [[scope]] id = "games" -title = "Games, the three games" +title = "Games, the games" rows = [ ["Enter", "this game"], [", /", "the game before, the next"], @@ -429,6 +429,26 @@ rows = [ ["`", "the other games; this one waits"], ] +[[scope]] +id = "games-mines" +title = "Games, Minesweeper" +rows = [ + ["; . , /", "move; held, it runs"], + ["Enter", "dig; on a number, the rest"], + ["f", "a flag, on or off"], + ["p", "pause; any key carries on"], + ["`", "the other games; this one waits"], +] + +[[scope]] +id = "games-2048" +title = "Games, 2048" +rows = [ + ["; . , /", "slide every tile that way"], + ["Enter", "start, or start again"], + ["`", "the other games; this one waits"], +] + [[scope]] id = "ssh" title = "SSH, the hosts" diff --git a/src/apps/games_app.cpp b/src/apps/games_app.cpp index 1d87fda..850f7b5 100644 --- a/src/apps/games_app.cpp +++ b/src/apps/games_app.cpp @@ -21,8 +21,10 @@ namespace { constexpr uint32_t kFrameMs = 33; // 30 pictures a second while a Game runs constexpr uint32_t kTapMs = 70; // how long a tap moves Pong's paddle constexpr int kCell = 6; // Snake's and Tetris's cells, in pixels -const char* const kNames[3] = {"Pong", "Snake", "Tetris"}; -const char* const kBestKeys[3] = {"best_pong", "best_snake", "best_tetris"}; +const char* const kNames[5] = {"Pong", "Snake", "Tetris", "Minesweeper", "2048"}; +const char* const kBestKeys[5] = {"best_pong", "best_snake", "best_tetris", "best_mines", "best_2048"}; +constexpr int kMineCell = 12; // Minesweeper's cells, in pixels: a digit in each reads +constexpr uint32_t kRunAfterMs = 220, kRunEveryMs = 60; // a held arrow runs the cursor along // The seven pieces in the theme's seven accents: they follow the theme chosen. uint16_t pieceColor(uint8_t kind) { @@ -112,7 +114,7 @@ void GamesApp::onEnter() { } void GamesApp::pause() { - if (which_ != Menu && state_[which_] == State::Playing) state_[which_] = State::Paused; + if (which_ != Menu && which_ != Twenty48 && state_[which_] == State::Playing) state_[which_] = State::Paused; quiet(); } @@ -123,6 +125,12 @@ void GamesApp::begin() { if (which_ == Pong) pong_.reset(new games::Pong(seed)); else if (which_ == Snake) snake_.reset(new games::Snake(seed)); else if (which_ == Tetris) tetris_.reset(new games::Tetris(seed)); + else if (which_ == Mines) { + mines_.reset(new games::Mines(seed)); + cursorCol_ = games::Mines::kCols / 2; + cursorRow_ = games::Mines::kRows / 2; + runDir_ = -1; + } else if (which_ == Twenty48) twenty48_.reset(new games::Twenty48(seed)); state_[which_] = State::Playing; tune_.restart(); newBest_ = false; @@ -131,10 +139,12 @@ void GamesApp::begin() { lastMs_ = millis(); } +// `score`: what is kept as the best if it beats it. For Minesweeper it is the seconds a cleared +// field took, where less is better, and 0 says the field was not cleared. void GamesApp::ended(int score) { state_[which_] = State::Over; quiet(); - newBest_ = score > best(which_); + newBest_ = which_ == Mines ? score > 0 && (best(Mines) == 0 || score < best(Mines)) : score > best(which_); if (newBest_) { best_[which_] = score; store_.putInt(kBestKeys[which_], score); @@ -146,8 +156,8 @@ bool GamesApp::onKey(const KeyEvent& e) { requestRedraw(); if (which_ == Menu) { switch (e.key) { - case Key::Left: chosen_ = (chosen_ + 2) % 3; return true; - case Key::Right: chosen_ = (chosen_ + 1) % 3; return true; + case Key::Left: chosen_ = (chosen_ + kGames - 1) % kGames; return true; + case Key::Right: chosen_ = (chosen_ + 1) % kGames; return true; case Key::Select: which_ = static_cast(chosen_); lastMs_ = millis(); @@ -166,11 +176,15 @@ bool GamesApp::onKey(const KeyEvent& e) { setVolume(volume() + (e.ch == '9' ? 1 : -1)); return true; } - if (e.key == Key::Back) { // to the list; the Game waits, paused + if (e.key == Key::Back) { // to the other games; this one waits pause(); which_ = Menu; return true; } + if (which_ == Twenty48 && state != State::Ready && state != State::Over) { // no clock: nothing to pause or carry on + state = State::Playing; + return twenty48Key(e); + } if (state == State::Ready || state == State::Over) { if (e.key == Key::Select) begin(); return true; @@ -184,7 +198,48 @@ bool GamesApp::onKey(const KeyEvent& e) { pause(); return true; } - return which_ == Pong ? pongKey(e) : which_ == Snake ? snakeKey(e) : tetrisKey(e); + return which_ == Pong ? pongKey(e) : which_ == Snake ? snakeKey(e) : which_ == Tetris ? tetrisKey(e) : minesKey(e); +} + +void GamesApp::moveCursor(int dc, int dr) { + cursorCol_ = std::clamp(cursorCol_ + dc, 0, games::Mines::kCols - 1); + cursorRow_ = std::clamp(cursorRow_ + dr, 0, games::Mines::kRows - 1); +} + +bool GamesApp::minesKey(const KeyEvent& e) { + uint8_t events = 0; + switch (e.key) { + case Key::Up: moveCursor(0, -1); break; + case Key::Down: moveCursor(0, 1); break; + case Key::Left: moveCursor(-1, 0); break; + case Key::Right: moveCursor(1, 0); break; + case Key::Select: events = mines_->dig(cursorCol_, cursorRow_); break; + case Key::Char: + if (e.ch == 'f' || e.ch == 'F') events = mines_->flag(cursorCol_, cursorRow_); + break; + default: break; + } + if (events & games::Mines::Boom) ended(0); + else if (events & games::Mines::Won) ended(std::max(1, static_cast(mines_->seconds()))); + else if (events & games::Mines::Flagged) beep(660, 10); + else if (events & games::Mines::Opened) beep(880, 8); + return true; +} + +bool GamesApp::twenty48Key(const KeyEvent& e) { + using Dir = games::Twenty48::Dir; + uint8_t events = 0; + switch (e.key) { + case Key::Up: events = twenty48_->slide(Dir::Up); break; + case Key::Down: events = twenty48_->slide(Dir::Down); break; + case Key::Left: events = twenty48_->slide(Dir::Left); break; + case Key::Right: events = twenty48_->slide(Dir::Right); break; + default: break; + } + if (events & games::Twenty48::Over) ended(static_cast(twenty48_->score())); + else if (events & games::Twenty48::Reached) beep(1320, 250); + else if (events & games::Twenty48::Merged) beep(880, 10); + return true; } bool GamesApp::pongKey(const KeyEvent& e) { @@ -241,6 +296,34 @@ void GamesApp::update(uint32_t nowMs) { uint8_t events = snake_->step(ms); if (events & Snake::Died) ended(snake_->score()); else if (events & Snake::Ate) beep(990, 25); + } else if (which_ == Mines) { + mines_->tick(ms); + // An arrow held runs the cursor along, after a moment: the field is thirty cells wide. + static const Key kArrows[4] = {Key::Up, Key::Right, Key::Down, Key::Left}; + int dir = -1; + for (int i = 0; i < 4; i++) + if (keys_.held(kArrows[i])) dir = i; + if (dir != runDir_) { + runDir_ = dir; + runMs_ = 0; + running_ = false; + } else if (dir >= 0) { + runMs_ += ms; + uint32_t wait = running_ ? kRunEveryMs : kRunAfterMs; + if (runMs_ >= wait) { + runMs_ -= wait; + running_ = true; + moveCursor(dir == 1 ? 1 : dir == 3 ? -1 : 0, dir == 0 ? -1 : dir == 2 ? 1 : 0); + requestRedraw(); + } + } + if (mines_->seconds() != shownSeconds_) { + shownSeconds_ = mines_->seconds(); + requestRedraw(); + } + return; + } else if (which_ == Twenty48) { + return; // it only moves when a key says so } else { TetrisInput in = tapped_; tapped_ = TetrisInput(); @@ -271,12 +354,15 @@ void GamesApp::help(std::vector& out) const { case Pong: return keys::add(out, keys::kGamesPong); case Snake: return keys::add(out, keys::kGamesSnake); case Tetris: return keys::add(out, keys::kGamesTetris); + case Mines: return keys::add(out, keys::kGamesMines); + case Twenty48: return keys::add(out, keys::kGames2048); default: return keys::add(out, keys::kGames); } } const char* GamesApp::helpTitle() const { - return which_ == Pong ? "Games: Pong" : which_ == Snake ? "Games: Snake" : which_ == Tetris ? "Games: Tetris" : nullptr; + static const char* const kTitles[kGames] = {"Games: Pong", "Games: Snake", "Games: Tetris", "Games: Minesweeper", "Games: 2048"}; + return which_ == Menu ? nullptr : kTitles[which_]; } // A box in the middle of the field: where the Game stands when it is not running. @@ -297,13 +383,13 @@ void GamesApp::drawBanner(Canvas& c, const std::string& title, const std::string c.setTextDatum(top_left); } -// The three Games as the Launcher shows Apps: an icon each, the chosen one on a cyan tile, its +// The Games as the Launcher shows Apps: an icon each, the chosen one on a cyan tile, its // name under the row. A Game waiting paused has the Launcher's orange dot. void GamesApp::drawMenu(Canvas& c) { using namespace theme; - static const uint8_t* const kIcons[3] = {icons::pong, icons::snake, icons::tetris}; - const int tileW = 60, tileH = 36, x0 = (kContent.w - 3 * tileW) / 2, y = kContent.y + 24; - for (int i = 0; i < 3; i++) { + static const uint8_t* const kIcons[kGames] = {icons::pong, icons::snake, icons::tetris, icons::mines, icons::twenty48}; + const int tileW = 46, tileH = 36, x0 = (kContent.w - kGames * tileW) / 2, y = kContent.y + 24; // five fit the width + for (int i = 0; i < kGames; i++) { int x = x0 + i * tileW, ix = x + (tileW - icons::kSize) / 2, iy = y + (tileH - icons::kSize) / 2; bool chosen = i == chosen_; if (chosen) { @@ -313,7 +399,7 @@ void GamesApp::drawMenu(Canvas& c) { c.fillRect(tx, y + 4, tw, tileH - 8, kAccent); } c.drawBitmap(ix, iy, kIcons[i], icons::kSize, icons::kSize, chosen ? kFaint : kAccent); - if (state_[i] == State::Paused) { + if (state_[i] == State::Paused || (i == Twenty48 && state_[i] == State::Playing)) { // a game is waiting c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, chosen ? kAccent : kBackground); c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning); } @@ -324,8 +410,9 @@ void GamesApp::drawMenu(Canvas& c) { c.setTextColor(kText); c.drawString(kNames[chosen_], kContent.w / 2, y + tileH + 8); int b = best(w); - std::string under = state_[chosen_] == State::Paused ? "paused" : ""; - if (b > 0) under += (under.empty() ? "" : ", ") + std::string(w == Pong ? "longest rally " : "best ") + std::to_string(b); + std::string under = state_[chosen_] == State::Paused ? "paused" : w == Twenty48 && state_[chosen_] == State::Playing ? "waiting" : ""; + if (b > 0) + under += (under.empty() ? "" : ", ") + std::string(w == Pong ? "longest rally " : w == Mines ? "cleared in " : "best ") + std::to_string(b) + (w == Mines ? " s" : ""); c.setFont(&fonts::body); c.setTextColor(kMuted); c.drawString(under.c_str(), kContent.w / 2, y + tileH + 8 + kLineHeight + 2); @@ -437,6 +524,112 @@ void GamesApp::drawTetris(Canvas& c) { drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(tetris_->score()) : std::to_string(tetris_->lines()) + " lines"); } +// Minesweeper: the field under a line that says how it stands. No box is drawn over the field when +// the game ends: where the mines were is what one wants to see. +void GamesApp::drawMines(Canvas& c) { + using games::Mines; + const auto& area = theme::kContent; + State state = state_[GamesApp::Mines]; + const int x0 = 0, y0 = area.y + area.h - Mines::kRows * kMineCell; + static const uint16_t* const kDigit[8] = {&theme::kAccent, &theme::kGood, &theme::kRed, &theme::kViolet, + &theme::kWarning, &theme::kMessage, &theme::kYellow, &theme::kText}; + char line[40]; + c.setFont(&fonts::body); + c.setTextColor(theme::kMuted); + int b = best(GamesApp::Mines); + bool lost = mines_ && mines_->phase() == Mines::Phase::Lost, won = mines_ && mines_->phase() == Mines::Phase::Won; + if (lost) { + c.setTextColor(theme::kWarning); + std::snprintf(line, sizeof line, "A mine. %lu s", (unsigned long)mines_->seconds()); + } else if (won) { + c.setTextColor(theme::kGood); + if (newBest_) std::snprintf(line, sizeof line, "Cleared in %lu s: a new best", (unsigned long)mines_->seconds()); + else std::snprintf(line, sizeof line, "Cleared in %lu s", (unsigned long)mines_->seconds()); + } else std::snprintf(line, sizeof line, "%d mines", mines_ ? mines_->minesLeft() : Mines::kMines); + c.drawString(line, 2, area.y + 1); + c.setTextColor(theme::kMuted); + c.setTextDatum(top_right); + if (b > 0) std::snprintf(line, sizeof line, "%lu s best %d", (unsigned long)(mines_ ? mines_->seconds() : 0), b); + else std::snprintf(line, sizeof line, "%lu s", (unsigned long)(mines_ ? mines_->seconds() : 0)); + c.drawString(line, area.w - 2, area.y + 1); + c.setTextDatum(top_left); + + c.setFont(&fonts::bold); + c.setTextDatum(middle_center); + for (int row = 0; row < Mines::kRows; row++) + for (int col = 0; col < Mines::kCols; col++) { + int x = x0 + col * kMineCell, y = y0 + row * kMineCell; + bool open = mines_ && mines_->open(col, row), flagged = mines_ && mines_->flagged(col, row); + bool mine = mines_ && mines_->mine(col, row); + if (lost && mine && !flagged) { // where they were; the one dug is the bright one + c.fillRect(x, y, kMineCell - 1, kMineCell - 1, open ? theme::kRed : theme::kFaint); + c.fillCircle(x + kMineCell / 2 - 1, y + kMineCell / 2 - 1, 3, open ? theme::kText : theme::kRed); + } else if (!open) { + c.fillRect(x, y, kMineCell - 1, kMineCell - 1, theme::kFaint); + if (flagged) { // a little flag on a pole; a wrong one goes pale when the game is lost + uint16_t colour = lost && !mine ? theme::kMuted : theme::kWarning; + c.fillTriangle(x + 4, y + 2, x + 4, y + 6, x + 8, y + 4, colour); + c.drawFastVLine(x + 4, y + 2, 7, colour); + } + } else if (int n = mines_->around(col, row)) { + c.setTextColor(*kDigit[n - 1]); + c.drawString(std::to_string(n).c_str(), x + kMineCell / 2, y + kMineCell / 2); + } + } + c.setTextDatum(top_left); + if (state == State::Playing) + c.drawRect(x0 + cursorCol_ * kMineCell - 1, y0 + cursorRow_ * kMineCell - 1, kMineCell + 1, kMineCell + 1, theme::kText); + if (state == State::Ready) drawBanner(c, "Minesweeper", b > 0 ? "cleared in " + std::to_string(b) + " s" : std::to_string(Mines::kMines) + " mines"); + else if (state == State::Paused) drawBanner(c, "Paused", ""); +} + +// 2048: the board in the middle, the score beside it. +void GamesApp::drawTwenty48(Canvas& c) { + using games::Twenty48; + const auto& area = theme::kContent; + State state = state_[GamesApp::Twenty48]; + const int tile = 27, gap = 3, size = 4 * tile + 5 * gap, x0 = (area.w - size) / 2, y0 = area.y; + c.fillRect(x0, y0, size, size, theme::kBar); + // A colour a power of two, round the theme's accents; the low ones are quiet. + static const uint16_t* const kTile[] = {&theme::kFaint, &theme::kMuted, &theme::kAccent, &theme::kGood, &theme::kYellow, &theme::kWarning, + &theme::kRed, &theme::kMessage, &theme::kViolet, &theme::kAccent, &theme::kText}; + c.setTextDatum(middle_center); + for (int row = 0; row < 4; row++) + for (int col = 0; col < 4; col++) { + int x = x0 + gap + col * (tile + gap), y = y0 + gap + row * (tile + gap); + uint8_t p = twenty48_ ? twenty48_->power(col, row) : 0; + if (!p) { + c.fillRect(x, y, tile, tile, theme::kBackground); + continue; + } + c.fillRect(x, y, tile, tile, *kTile[std::min(p, 11) - 1]); + uint32_t v = twenty48_->value(col, row); + c.setFont(v < 100 ? &fonts::bold : v < 10000 ? &fonts::medium : &fonts::small); + c.setTextColor(p == 1 ? theme::kText : theme::kBackground); + c.drawString(std::to_string(v).c_str(), x + tile / 2 + 1, y + tile / 2 + 1); + } + c.setTextDatum(top_left); + c.setFont(&fonts::body); + auto readout = [&](int x, int y, const char* label, uint32_t value) { + c.setTextColor(theme::kMuted); + c.drawString(label, x, y); + c.setTextColor(theme::kText); + c.drawString(std::to_string(value).c_str(), x, y + theme::kLineHeight); + }; + readout(6, area.y + 4, "Score", twenty48_ ? twenty48_->score() : 0); + readout(x0 + size + 8, area.y + 4, "Best", static_cast(best(GamesApp::Twenty48))); + c.setFont(&fonts::small); + if (state == State::Over) { + c.setTextColor(theme::kWarning); + c.drawString("No move", 6, area.y + area.h - 20); + c.drawString(newBest_ ? "left: a best" : "left", 6, area.y + area.h - 10); + } else if (twenty48_ && twenty48_->reached()) { + c.setTextColor(theme::kGood); + c.drawString("2048!", 6, area.y + area.h - 10); + } + if (state == State::Ready) drawBanner(c, "2048", best(GamesApp::Twenty48) > 0 ? "best " + std::to_string(best(GamesApp::Twenty48)) : ""); +} + void GamesApp::draw(Canvas& c) { c.setTextDatum(top_left); lastDrawMs_ = millis(); @@ -444,6 +637,8 @@ void GamesApp::draw(Canvas& c) { case Pong: drawPong(c); break; case Snake: drawSnake(c); break; case Tetris: drawTetris(c); break; + case Mines: drawMines(c); break; + case Twenty48: drawTwenty48(c); break; default: drawMenu(c); break; } } diff --git a/src/apps/games_app.h b/src/apps/games_app.h index c919d8e..77c71eb 100644 --- a/src/apps/games_app.h +++ b/src/apps/games_app.h @@ -6,11 +6,13 @@ #include "app.h" #include "key_mapper.h" #include "key_value_store.h" +#include "mines.h" #include "pong.h" #include "settings.h" #include "snake.h" #include "tetris.h" #include "tune.h" +#include "twenty48.h" namespace roro { @@ -29,10 +31,12 @@ class GamesApp : public App { void help(std::vector& out) const override; const char* helpTitle() const override; // A Game being played is the device in use, key or no key: the screen stays lit. - bool keepsScreenOn() const override { return which_ != Menu && state_[which_] == State::Playing; } + // Not for the two that wait for a move: there a key is pressed, or nobody is playing. + bool keepsScreenOn() const override { return which_ <= Tetris && state_[which_] == State::Playing; } private: - enum Which { Pong, Snake, Tetris, Menu }; // the first three index the arrays + enum Which { Pong, Snake, Tetris, Mines, Twenty48, Menu }; // all but the last index the arrays + static constexpr int kGames = 5; enum class State : uint8_t { Ready, Playing, Paused, Over }; void begin(); // a new game of the one chosen @@ -48,6 +52,11 @@ class GamesApp : public App { bool pongKey(const KeyEvent& e); bool snakeKey(const KeyEvent& e); bool tetrisKey(const KeyEvent& e); + bool minesKey(const KeyEvent& e); + bool twenty48Key(const KeyEvent& e); + void moveCursor(int dc, int dr); + void drawMines(Canvas& c); + void drawTwenty48(Canvas& c); void drawMenu(Canvas& c); void drawPong(Canvas& c); void drawSnake(Canvas& c); @@ -57,14 +66,20 @@ class GamesApp : public App { const KeyMapper& keys_; const Settings& settings_; KeyValueStore& store_; - int chosen_ = 0; // in the row of three + int chosen_ = 0; // in the row of games Which which_ = Menu; - State state_[3] = {State::Ready, State::Ready, State::Ready}; - int best_[3] = {-1, -1, -1}; // -1: not read from the store yet + State state_[kGames] = {State::Ready, State::Ready, State::Ready, State::Ready, State::Ready}; + int best_[kGames] = {-1, -1, -1, -1, -1}; // -1: not read from the store yet bool newBest_ = false; std::unique_ptr pong_; std::unique_ptr snake_; std::unique_ptr tetris_; + std::unique_ptr mines_; + std::unique_ptr twenty48_; + // Minesweeper's cursor, and the arrow held to run it along. + int cursorCol_ = 0, cursorRow_ = 0, runDir_ = -1; + uint32_t runMs_ = 0, shownSeconds_ = 0; + bool running_ = false; uint32_t lastMs_ = 0, lastDrawMs_ = 0; // Keys that came as presses since the last step: a tap moves too, held or not. int tapMove_ = 0; diff --git a/src/ui/icons.cpp b/src/ui/icons.cpp index cd74e82..6058dab 100644 --- a/src/ui/icons.cpp +++ b/src/ui/icons.cpp @@ -178,6 +178,41 @@ const uint8_t lora[128] = { 0x00, 0x00, 0x00, 0x00, // ................................ }; +const uint8_t mines[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x0c, 0x03, 0xc0, 0x30, // ....##........####........##.... + 0x0c, 0x03, 0xc0, 0x30, // ....##........####........##.... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x03, 0xf0, 0xff, 0xc0, // ......######....##########...... + 0x03, 0xf0, 0xff, 0xc0, // ......######....##########...... + 0x03, 0xc0, 0xff, 0xc0, // ......####......##########...... + 0x03, 0xc0, 0xff, 0xc0, // ......####......##########...... + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x0c, 0x03, 0xc0, 0x30, // ....##........####........##.... + 0x0c, 0x03, 0xc0, 0x30, // ....##........####........##.... + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + const uint8_t notes[128] = { 0x00, 0x00, 0x00, 0x00, // ................................ 0x00, 0x00, 0x00, 0x00, // ................................ @@ -493,6 +528,41 @@ const uint8_t tetris[128] = { 0x00, 0x00, 0x00, 0x00, // ................................ }; +const uint8_t twenty48[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x30, 0x0c, 0xff, 0xf0, // ..##........##..############.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xc0, 0x30, // ..############..##........##.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x3f, 0xfc, 0xff, 0xf0, // ..############..############.... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + const uint8_t wifi_tools[128] = { 0x00, 0x00, 0x00, 0x00, // ................................ 0x00, 0x00, 0x00, 0x00, // ................................ diff --git a/src/ui/icons.h b/src/ui/icons.h index c67f882..fb08a75 100644 --- a/src/ui/icons.h +++ b/src/ui/icons.h @@ -13,6 +13,7 @@ extern const uint8_t gemini[128]; // gemini.png extern const uint8_t gnss[128]; // gnss.png extern const uint8_t irc[128]; // irc.png extern const uint8_t lora[128]; // lora.png +extern const uint8_t mines[128]; // mines.png extern const uint8_t notes[128]; // notes.png extern const uint8_t pong[128]; // pong.png extern const uint8_t settings[128]; // settings.png @@ -22,6 +23,7 @@ extern const uint8_t ssh[128]; // ssh.png extern const uint8_t storage[128]; // storage.png extern const uint8_t system[128]; // system.png extern const uint8_t tetris[128]; // tetris.png +extern const uint8_t twenty48[128]; // twenty48.png extern const uint8_t wifi_tools[128]; // wifi-tools.png } // namespace roro::icons