From 53b251f3e7ff7ed9155a9f26dbfbcfd7ef3269b0 Mon Sep 17 00:00:00 2001 From: Zuriefais Date: Sun, 19 Apr 2026 19:48:22 +0300 Subject: [PATCH] cat <<'EOF' feat: add Snake game to tools menu New arcade game with: - 15x15 grid with wrap-around (teleport) borders - 3 difficulty levels (Easy/Medium/Hard) controlling speed - Score display above the grid - Difficulty selection screen before gameplay - Self-collision ends the game, walls wrap around --- lib/I18n/I18nKeys.h | 4 + lib/I18n/I18nStrings.cpp | 92 ++++++++ lib/I18n/translations/english.yaml | 5 + src/activities/tools/SnakeActivity.cpp | 297 +++++++++++++++++++++++++ src/activities/tools/SnakeActivity.h | 52 +++++ src/activities/tools/ToolsActivity.cpp | 4 + 6 files changed, 454 insertions(+) create mode 100644 src/activities/tools/SnakeActivity.cpp create mode 100644 src/activities/tools/SnakeActivity.h diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index 5f449b37c9..a9ec02c037 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -647,6 +647,10 @@ enum class StrId : uint16_t { STR_CHESS_BLACK_WINS, STR_CHESS_SELECT, STR_CHESS_MOVE, + STR_SNAKE, + STR_SNAKE_EASY, + STR_SNAKE_MEDIUM, + STR_SNAKE_HARD, STR_CLOCK_SET_TIME, STR_CLOCK_MODE, STR_CLOCK_NTP, diff --git a/lib/I18n/I18nStrings.cpp b/lib/I18n/I18nStrings.cpp index 3dbc30c632..341e0dde5d 100644 --- a/lib/I18n/I18nStrings.cpp +++ b/lib/I18n/I18nStrings.cpp @@ -1968,6 +1968,10 @@ const char* const STRINGS_EN[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -2980,6 +2984,10 @@ const char* const STRINGS_ES[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -4141,6 +4149,10 @@ const char* const STRINGS_FR[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -5055,6 +5067,10 @@ const char* const STRINGS_DE[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -7079,6 +7095,10 @@ const char* const STRINGS_CS[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -8122,6 +8142,10 @@ const char* const STRINGS_PO[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -17550,6 +17574,10 @@ const char* const STRINGS_RU[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -18808,6 +18836,10 @@ const char* const STRINGS_SV[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -20232,6 +20264,10 @@ const char* const STRINGS_RO[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -21288,6 +21324,10 @@ const char* const STRINGS_CA[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -31684,6 +31724,10 @@ const char* const STRINGS_UK[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -41234,6 +41278,10 @@ const char* const STRINGS_BE[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -42009,6 +42057,10 @@ const char* const STRINGS_IT[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -43457,6 +43509,10 @@ const char* const STRINGS_PL[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -44669,6 +44725,10 @@ const char* const STRINGS_FI[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -45649,6 +45709,10 @@ const char* const STRINGS_DA[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -46391,6 +46455,10 @@ const char* const STRINGS_NL[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -48195,6 +48263,10 @@ const char* const STRINGS_TR[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -56345,6 +56417,10 @@ const char* const STRINGS_KK[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -60924,6 +61000,10 @@ const char* const STRINGS_VI[] = { "\xC4" "\x90" "i", + "Snake", + "Easy", + "Medium", + "Hard", "\xC4" "\x90" "\xE1" @@ -63386,6 +63466,10 @@ const char* const STRINGS_HU[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -64496,6 +64580,10 @@ const char* const STRINGS_LT[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", @@ -65416,6 +65504,10 @@ const char* const STRINGS_SI[] = { "Black Wins!", "Select", "Move", + "Snake", + "Easy", + "Medium", + "Hard", "Set Time", "Clock Mode", "NTP (Auto)", diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a82dbc40d2..1bc2847dd5 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -598,6 +598,11 @@ STR_CHESS_BLACK_WINS: "Black Wins!" STR_CHESS_SELECT: "Select" STR_CHESS_MOVE: "Move" +STR_SNAKE: "Snake" +STR_SNAKE_EASY: "Easy" +STR_SNAKE_MEDIUM: "Medium" +STR_SNAKE_HARD: "Hard" + STR_CLOCK_SET_TIME: "Set Time" STR_CLOCK_MODE: "Clock Mode" STR_CLOCK_NTP: "NTP (Auto)" diff --git a/src/activities/tools/SnakeActivity.cpp b/src/activities/tools/SnakeActivity.cpp new file mode 100644 index 0000000000..d57a42c344 --- /dev/null +++ b/src/activities/tools/SnakeActivity.cpp @@ -0,0 +1,297 @@ +#include "SnakeActivity.h" + +#include +#include +#include + +#include +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +void SnakeActivity::initGame() { + snake.clear(); + int mid = GRID_SIZE / 2; + snake.emplace_back(mid, mid); + snake.emplace_back(mid, mid - 1); + snake.emplace_back(mid, mid - 2); + direction = Direction::RIGHT; + nextDirection = Direction::RIGHT; + score = 0; + gameOver = false; + lastMoveTime = millis(); + randomSeed(millis()); + spawnFood(); +} + +void SnakeActivity::spawnFood() { + int attempts = 0; + while (attempts < 500) { + int16_t r = random(0, GRID_SIZE); + int16_t c = random(0, GRID_SIZE); + bool occupied = false; + for (const auto& seg : snake) { + if (seg.first == r && seg.second == c) { + occupied = true; + break; + } + } + if (!occupied) { + food = {r, c}; + return; + } + attempts++; + } + // Fallback: linear scan + for (int r = 0; r < GRID_SIZE && food.first >= 0; r++) { + for (int c = 0; c < GRID_SIZE && food.first < 0; c++) { + bool occupied = false; + for (const auto& seg : snake) { + if (seg.first == r && seg.second == c) { + occupied = true; + break; + } + } + if (!occupied) food = {r, c}; + } + } +} + +bool SnakeActivity::isValidCell(int16_t r, int16_t c) const { + return r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE; +} + +void SnakeActivity::update() { + if (gameOver) return; + + direction = nextDirection; + + auto [headR, headC] = snake.front(); + + int dr = 0, dc = 0; + switch (direction) { + case Direction::UP: dr = -1; dc = 0; break; + case Direction::DOWN: dr = 1; dc = 0; break; + case Direction::LEFT: dr = 0; dc = -1; break; + case Direction::RIGHT: dr = 0; dc = 1; break; + default: return; + } + + int16_t newR = static_cast(headR + dr); + int16_t newC = static_cast(headC + dc); + + // Teleport (wrap) at borders + if (newR < 0) newR = GRID_SIZE - 1; + if (newR >= GRID_SIZE) newR = 0; + if (newC < 0) newC = GRID_SIZE - 1; + if (newC >= GRID_SIZE) newC = 0; + + // Check self collision (skip tail if not eating food — tail will move) + bool eating = (newR == food.first && newC == food.second); + int tailSize = eating ? (int)snake.size() : (int)snake.size() - 1; + for (int i = 0; i < tailSize; i++) { + if (snake[i].first == newR && snake[i].second == newC) { + gameOver = true; + return; + } + } + + snake.insert(snake.begin(), {newR, newC}); + + if (eating) { + score++; + spawnFood(); + } else { + snake.pop_back(); + } +} + +int SnakeActivity::delayMs() const { + switch (difficulty) { + case Difficulty::EASY: return 800; + case Difficulty::MEDIUM: return 600; + case Difficulty::HARD: return 450; + } + return 600; +} + +const char* SnakeActivity::difficultyLabel() const { + switch (difficulty) { + case Difficulty::EASY: return tr(STR_SNAKE_EASY); + case Difficulty::MEDIUM: return tr(STR_SNAKE_MEDIUM); + case Difficulty::HARD: return tr(STR_SNAKE_HARD); + } + return ""; +} + +void SnakeActivity::onEnter() { + Activity::onEnter(); + showingDifficultySelect = true; + requestUpdate(); +} + +void SnakeActivity::loop() { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + finish(); + return; + } + + // Difficulty selection screen + if (showingDifficultySelect) { + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + difficulty = static_cast(((int)difficulty - 1 + DIFFICULTY_COUNT) % DIFFICULTY_COUNT); + requestUpdate(); + } + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + difficulty = static_cast(((int)difficulty + 1) % DIFFICULTY_COUNT); + requestUpdate(); + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + showingDifficultySelect = false; + initGame(); + requestUpdate(); + } + return; + } + + // Game over: Confirm = difficulty select, Back = exit + if (gameOver) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + showingDifficultySelect = true; + requestUpdate(); + } + return; + } + + // Direction changes via D-pad + if (mappedInput.wasReleased(MappedInputManager::Button::Up) && direction != Direction::DOWN) { + nextDirection = Direction::UP; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Down) && direction != Direction::UP) { + nextDirection = Direction::DOWN; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Left) && direction != Direction::RIGHT) { + nextDirection = Direction::LEFT; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Right) && direction != Direction::LEFT) { + nextDirection = Direction::RIGHT; + } + + // Time-based game tick + unsigned long now = millis(); + int delay = delayMs(); + if (now - lastMoveTime >= delay) { + lastMoveTime = now; + update(); + requestUpdate(); + } +} + +void SnakeActivity::renderDifficultySelect() { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SNAKE)); + + const int centerY = (metrics.topPadding + metrics.headerHeight + pageHeight - metrics.buttonHintsHeight) / 2; + renderer.drawCenteredText(UI_12_FONT_ID, centerY - 40, tr(STR_SELECT_DIFFICULTY)); + + char label[32]; + snprintf(label, sizeof(label), "< %s >", difficultyLabel()); + renderer.drawCenteredText(UI_10_FONT_ID, centerY, label, true, EpdFontFamily::BOLD); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} + +void SnakeActivity::render(RenderLock&&) { + if (showingDifficultySelect) { + renderDifficultySelect(); + return; + } + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SNAKE)); + + const int gridW = CELL * GRID_SIZE; + const int gridH = CELL * GRID_SIZE; + const int gridX = (pageWidth - gridW) / 2; + const int contentTop = metrics.topPadding + metrics.headerHeight + 4; + const int contentBot = pageHeight - metrics.buttonHintsHeight - 4; + const int gridY = contentTop + (contentBot - contentTop - gridH) / 2; + + // Draw grid outline + renderer.drawRect(gridX, gridY, gridW, gridH); + + // Draw food (small filled rect at center of cell) + int foodPad = 6; + renderer.fillRect( + gridX + food.second * CELL + foodPad, + gridY + food.first * CELL + foodPad, + CELL - foodPad * 2, + CELL - foodPad * 2 + ); + + // Draw snake + for (int i = 0; i < (int)snake.size(); i++) { + auto [r, c] = snake[i]; + int x = gridX + c * CELL; + int y = gridY + r * CELL; + int pad = (i == 0) ? 2 : 3; // head slightly larger + + if (i == 0) { + // Head: solid black + renderer.fillRect(x + pad, y + pad, CELL - pad * 2, CELL - pad * 2); + } else { + // Body: outlined with small gap + renderer.drawRect(x + pad, y + pad, CELL - pad * 2, CELL - pad * 2); + renderer.fillRect(x + pad, y + pad, CELL - pad * 2, 1); + renderer.fillRect(x + pad, y + pad, 1, CELL - pad * 2); + } + } + + // Draw score above the grid + char scoreBuf[8]; + snprintf(scoreBuf, sizeof(scoreBuf), "%d", score); + int sw = renderer.getTextWidth(UI_10_FONT_ID, scoreBuf); + renderer.drawText(UI_10_FONT_ID, gridX + (gridW - sw) / 2, + metrics.topPadding + metrics.headerHeight + 4, scoreBuf); + + // Draw game over overlay + if (gameOver) { + const char* msg = tr(STR_GAME_OVER); + int msgW = renderer.getTextWidth(UI_12_FONT_ID, msg); + int msgH = renderer.getLineHeight(UI_12_FONT_ID); + int cardPadX = 20, cardPadY = 8; + int cardW = msgW + cardPadX * 2; + int cardH = msgH + cardPadY * 2; + int cardX = (pageWidth - cardW) / 2; + int cardY = gridY + gridH / 2 - cardH / 2 - 10; + + renderer.fillRoundedRect(cardX, cardY, cardW, cardH, 8, Color::Black); + renderer.drawCenteredText(UI_12_FONT_ID, cardY + cardH / 2, msg, false, EpdFontFamily::BOLD); + + char scoreMsg[40]; + snprintf(scoreMsg, sizeof(scoreMsg), "%d", score); + int smW = renderer.getTextWidth(SMALL_FONT_ID, scoreMsg); + renderer.drawText(SMALL_FONT_ID, cardX + cardW / 2 - smW / 2, cardY + cardH + 8, scoreMsg); + } + + // Button hints + const char* btn1 = tr(STR_BACK); + const char* btn2 = gameOver ? tr(STR_NEW_GAME) : ""; + const char* btn3 = tr(STR_DIR_UP); + const char* btn4 = tr(STR_DIR_DOWN); + const auto labels = mappedInput.mapLabels(btn1, btn2, btn3, btn4); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/tools/SnakeActivity.h b/src/activities/tools/SnakeActivity.h new file mode 100644 index 0000000000..029749a9f5 --- /dev/null +++ b/src/activities/tools/SnakeActivity.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "../Activity.h" + +// Snake — directional arcade game. +// D-pad changes direction. Confirm = new game. Back = exit. +// Difficulty controls snake speed. +class SnakeActivity final : public Activity { + static constexpr int GRID_SIZE = 15; + static constexpr int CELL = 28; + + enum class Difficulty : uint8_t { EASY = 0, MEDIUM, HARD }; + Difficulty difficulty = Difficulty::EASY; + static constexpr int DIFFICULTY_COUNT = 3; + + enum class Direction : int8_t { NONE = -1, RIGHT = 0, LEFT, DOWN, UP }; + static constexpr int DIR_COUNT = 4; + + bool showingDifficultySelect = true; + bool gameOver = false; + + std::deque> snake; // head at front + Direction direction = Direction::RIGHT; + Direction nextDirection = Direction::RIGHT; + std::pair food{0, 0}; + int score = 0; + + unsigned long lastMoveTime = 0; + int speedMs = 200; + + void initGame(); + void spawnFood(); + void update(); + bool isValidCell(int16_t r, int16_t c) const; + int delayMs() const; + void renderDifficultySelect(); + + public: + explicit SnakeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("Snake", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + const char* difficultyLabel() const; +}; diff --git a/src/activities/tools/ToolsActivity.cpp b/src/activities/tools/ToolsActivity.cpp index 2346978057..fbf767703a 100644 --- a/src/activities/tools/ToolsActivity.cpp +++ b/src/activities/tools/ToolsActivity.cpp @@ -9,6 +9,7 @@ #include "MinesweeperActivity.h" #include "SudokuActivity.h" #include "CaroActivity.h" +#include "SnakeActivity.h" #include "ChessActivity.h" #include "VirtualPetActivity.h" #include "WeatherActivity.h" @@ -81,6 +82,9 @@ void ToolsActivity::buildMenu() { menuEntries.push_back({StrId::STR_2048, [this] { activityManager.pushActivity(std::make_unique(renderer, mappedInput)); }}); + menuEntries.push_back({StrId::STR_SNAKE, [this] { + activityManager.pushActivity(std::make_unique(renderer, mappedInput)); + }}); } }