From cc805abe801c66e29dc9e96cbe3db24e7797edbf Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Mon, 13 Oct 2025 14:36:27 +0200 Subject: [PATCH] snake app --- Firmware/main/src/app_main.cpp | 2 + Firmware/sdk/apps/CMakeLists.txt | 1 + Firmware/sdk/apps/snake/CMakeLists.txt | 9 + .../snake/include/cardboy/apps/snake_app.hpp | 11 + Firmware/sdk/apps/snake/src/snake_app.cpp | 432 ++++++++++++++++++ Firmware/sdk/launchers/desktop/src/main.cpp | 2 + 6 files changed, 457 insertions(+) create mode 100644 Firmware/sdk/apps/snake/CMakeLists.txt create mode 100644 Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp create mode 100644 Firmware/sdk/apps/snake/src/snake_app.cpp diff --git a/Firmware/main/src/app_main.cpp b/Firmware/main/src/app_main.cpp index 81297b4..c671a43 100644 --- a/Firmware/main/src/app_main.cpp +++ b/Firmware/main/src/app_main.cpp @@ -4,6 +4,7 @@ #include "cardboy/apps/gameboy_app.hpp" #include "cardboy/apps/menu_app.hpp" #include "cardboy/apps/settings_app.hpp" +#include "cardboy/apps/snake_app.hpp" #include "cardboy/apps/tetris_app.hpp" #include "cardboy/backend/esp_backend.hpp" #include "cardboy/sdk/app_system.hpp" @@ -234,6 +235,7 @@ extern "C" void app_main() { system.registerApp(apps::createMenuAppFactory()); system.registerApp(apps::createSettingsAppFactory()); system.registerApp(apps::createClockAppFactory()); + system.registerApp(apps::createSnakeAppFactory()); system.registerApp(apps::createTetrisAppFactory()); system.registerApp(apps::createGameboyAppFactory()); diff --git a/Firmware/sdk/apps/CMakeLists.txt b/Firmware/sdk/apps/CMakeLists.txt index b7de8c7..3beac57 100644 --- a/Firmware/sdk/apps/CMakeLists.txt +++ b/Firmware/sdk/apps/CMakeLists.txt @@ -16,4 +16,5 @@ add_subdirectory(menu) add_subdirectory(clock) add_subdirectory(settings) add_subdirectory(gameboy) +add_subdirectory(snake) add_subdirectory(tetris) diff --git a/Firmware/sdk/apps/snake/CMakeLists.txt b/Firmware/sdk/apps/snake/CMakeLists.txt new file mode 100644 index 0000000..5f7f59e --- /dev/null +++ b/Firmware/sdk/apps/snake/CMakeLists.txt @@ -0,0 +1,9 @@ +target_sources(cardboy_apps + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/snake_app.cpp +) + +target_include_directories(cardboy_apps + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) diff --git a/Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp b/Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp new file mode 100644 index 0000000..3dee22c --- /dev/null +++ b/Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include "cardboy/sdk/app_framework.hpp" + +#include + +namespace apps { + +std::unique_ptr createSnakeAppFactory(); + +} // namespace apps diff --git a/Firmware/sdk/apps/snake/src/snake_app.cpp b/Firmware/sdk/apps/snake/src/snake_app.cpp new file mode 100644 index 0000000..aefe5c0 --- /dev/null +++ b/Firmware/sdk/apps/snake/src/snake_app.cpp @@ -0,0 +1,432 @@ +#include "cardboy/apps/snake_app.hpp" + +#include "cardboy/apps/menu_app.hpp" +#include "cardboy/gfx/font16x8.hpp" +#include "cardboy/sdk/app_framework.hpp" +#include "cardboy/sdk/app_system.hpp" +#include "cardboy/sdk/display_spec.hpp" +#include "cardboy/sdk/input_state.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace apps { +namespace { + +using cardboy::sdk::AppButtonEvent; +using cardboy::sdk::AppContext; +using cardboy::sdk::AppEvent; +using cardboy::sdk::AppEventType; +using cardboy::sdk::AppTimerHandle; +using cardboy::sdk::InputState; + +constexpr char kSnakeAppName[] = "Snake"; + +constexpr int kBoardWidth = 32; +constexpr int kBoardHeight = 20; +constexpr int kCellSize = 10; +constexpr int kInitialSnakeLength = 5; +constexpr int kScorePerFood = 10; +constexpr int kMinMoveIntervalMs = 80; +constexpr int kBaseMoveIntervalMs = 220; +constexpr int kIntervalSpeedupPerSegment = 4; + +struct Point { + int x = 0; + int y = 0; + + bool operator==(const Point& other) const { return x == other.x && y == other.y; } +}; + +enum class Direction { Up, Down, Left, Right }; + +[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) { + if (auto* rnd = ctx.random()) + return rnd->nextUint32(); + static std::random_device rd; + return rd(); +} + +class SnakeGame { +public: + explicit SnakeGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) { + rng.seed(randomSeed(context)); + loadHighScore(); + reset(); + } + + void onStart() { + scheduleMoveTimer(); + dirty = true; + renderIfNeeded(); + } + + void onStop() { cancelMoveTimer(); } + + void handleEvent(const AppEvent& event) { + switch (event.type) { + case AppEventType::Button: + handleButtons(event.button); + break; + case AppEventType::Timer: + handleTimer(event.timer.handle); + break; + } + renderIfNeeded(); + } + +private: + AppContext& context; + typename AppContext::Framebuffer& framebuffer; + + std::deque snake; + Point food{}; + Direction direction = Direction::Right; + Direction queuedDirection = Direction::Right; + bool paused = false; + bool gameOver = false; + bool dirty = false; + int score = 0; + int highScore = 0; + AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer; + std::mt19937 rng; + + void handleButtons(const AppButtonEvent& evt) { + const auto& cur = evt.current; + const auto& prev = evt.previous; + if (cur.b && !prev.b) { + context.requestAppSwitchByName(kMenuAppName); + return; + } + + if (cur.select && !prev.select) { + reset(); + return; + } + + if (cur.start && !prev.start) { + if (gameOver) + reset(); + else { + paused = !paused; + dirty = true; + } + } + + if (gameOver) + return; + + if (cur.up && !prev.up) + queueDirection(Direction::Up); + else if (cur.down && !prev.down) + queueDirection(Direction::Down); + else if (cur.left && !prev.left) + queueDirection(Direction::Left); + else if (cur.right && !prev.right) + queueDirection(Direction::Right); + + if (cur.a && !prev.a && !paused) + advance(); + } + + void handleTimer(AppTimerHandle handle) { + if (handle == moveTimer && !paused && !gameOver) + advance(); + } + + void reset() { + cancelMoveTimer(); + + snake.clear(); + const int centerX = kBoardWidth / 2; + const int centerY = kBoardHeight / 2; + for (int i = 0; i < kInitialSnakeLength; ++i) + snake.push_back(Point{centerX - i, centerY}); + + direction = Direction::Right; + queuedDirection = Direction::Right; + paused = false; + gameOver = false; + score = 0; + dirty = true; + + if (!spawnFood()) + onGameOver(); + + scheduleMoveTimer(); + } + + void advance() { + direction = queuedDirection; + Point nextHead = snake.front(); + switch (direction) { + case Direction::Up: + --nextHead.y; + break; + case Direction::Down: + ++nextHead.y; + break; + case Direction::Left: + --nextHead.x; + break; + case Direction::Right: + ++nextHead.x; + break; + } + + if (isCollision(nextHead)) { + onGameOver(); + return; + } + + snake.push_front(nextHead); + if (nextHead == food) { + score += kScorePerFood; + updateHighScore(); + if (!spawnFood()) { + onGameOver(); + return; + } + scheduleMoveTimer(); + if (auto* buzzer = context.buzzer()) + buzzer->beepMove(); + } else { + snake.pop_back(); + } + + dirty = true; + } + + [[nodiscard]] bool isCollision(const Point& nextHead) const { + if (nextHead.x < 0 || nextHead.x >= kBoardWidth || nextHead.y < 0 || nextHead.y >= kBoardHeight) + return true; + return std::find(snake.begin(), snake.end(), nextHead) != snake.end(); + } + + void onGameOver() { + if (gameOver) + return; + gameOver = true; + cancelMoveTimer(); + dirty = true; + if (auto* buzzer = context.buzzer()) + buzzer->beepGameOver(); + } + + void queueDirection(Direction next) { + if (isOpposite(direction, next) || isOpposite(queuedDirection, next)) + return; + queuedDirection = next; + } + + [[nodiscard]] static bool isOpposite(Direction a, Direction b) { + if ((a == Direction::Up && b == Direction::Down) || (a == Direction::Down && b == Direction::Up)) + return true; + if ((a == Direction::Left && b == Direction::Right) || (a == Direction::Right && b == Direction::Left)) + return true; + return false; + } + + bool spawnFood() { + std::vector freeCells; + freeCells.reserve(kBoardWidth * kBoardHeight - static_cast(snake.size())); + for (int y = 0; y < kBoardHeight; ++y) { + for (int x = 0; x < kBoardWidth; ++x) { + Point p{x, y}; + if (std::find(snake.begin(), snake.end(), p) == snake.end()) + freeCells.push_back(p); + } + } + if (freeCells.empty()) + return false; + std::uniform_int_distribution dist(0, freeCells.size() - 1); + food = freeCells[dist(rng)]; + return true; + } + + void scheduleMoveTimer() { + cancelMoveTimer(); + const std::uint32_t interval = currentInterval(); + moveTimer = context.scheduleRepeatingTimer(interval); + } + + void cancelMoveTimer() { + if (moveTimer != cardboy::sdk::kInvalidAppTimer) { + context.cancelTimer(moveTimer); + moveTimer = cardboy::sdk::kInvalidAppTimer; + } + } + + [[nodiscard]] std::uint32_t currentInterval() const { + int interval = kBaseMoveIntervalMs - static_cast(snake.size()) * kIntervalSpeedupPerSegment; + if (interval < kMinMoveIntervalMs) + interval = kMinMoveIntervalMs; + return static_cast(interval); + } + + void updateHighScore() { + if (score <= highScore) + return; + highScore = score; + if (auto* storage = context.storage()) + storage->writeUint32("snake", "best", static_cast(highScore)); + } + + void loadHighScore() { + if (auto* storage = context.storage()) { + std::uint32_t stored = 0; + if (storage->readUint32("snake", "best", stored)) + highScore = static_cast(stored); + } + } + + void renderIfNeeded() { + if (!dirty) + return; + dirty = false; + + framebuffer.frameReady(); + framebuffer.clear(false); + + drawBoard(); + drawFood(); + drawSnake(); + drawHud(); + + framebuffer.sendFrame(); + } + + [[nodiscard]] int boardOriginX() const { + return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2; + } + + [[nodiscard]] int boardOriginY() const { + const int centered = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2; + return std::max(24, centered); + } + + void drawBoard() { + const int originX = boardOriginX(); + const int originY = boardOriginY(); + const int width = kBoardWidth * kCellSize; + const int height = kBoardHeight * kCellSize; + + const int x0 = originX; + const int y0 = originY; + const int x1 = originX + width - 1; + const int y1 = originY + height - 1; + for (int x = x0; x <= x1; ++x) { + framebuffer.drawPixel(x, y0, true); + framebuffer.drawPixel(x, y1, true); + } + for (int y = y0; y <= y1; ++y) { + framebuffer.drawPixel(x0, y, true); + framebuffer.drawPixel(x1, y, true); + } + } + + void drawSnake() { + if (snake.empty()) + return; + std::size_t index = 0; + for (const auto& segment: snake) { + drawSnakeSegment(segment, index == 0); + ++index; + } + } + + void drawSnakeSegment(const Point& segment, bool head) { + const int originX = boardOriginX() + segment.x * kCellSize; + const int originY = boardOriginY() + segment.y * kCellSize; + for (int dy = 0; dy < kCellSize; ++dy) { + for (int dx = 0; dx < kCellSize; ++dx) { + const bool border = dx == 0 || dy == 0 || dx == kCellSize - 1 || dy == kCellSize - 1; + bool fill = ((dx + dy) & 0x1) == 0; + if (head) + fill = true; + const bool on = border || fill; + framebuffer.drawPixel(originX + dx, originY + dy, on); + } + } + } + + void drawFood() { + const int cx = boardOriginX() + food.x * kCellSize + kCellSize / 2; + const int cy = boardOriginY() + food.y * kCellSize + kCellSize / 2; + const int r = std::max(2, kCellSize / 2 - 1); + for (int dy = -r; dy <= r; ++dy) { + for (int dx = -r; dx <= r; ++dx) { + if (std::abs(dx) + std::abs(dy) <= r) + framebuffer.drawPixel(cx + dx, cy + dy, true); + } + } + } + + void drawHud() { + const int margin = 12; + const int textY = 8; + const std::string scoreStr = "SCORE " + std::to_string(score); + const std::string bestStr = "BEST " + std::to_string(highScore); + font16x8::drawText(framebuffer, margin, textY, scoreStr, 1, true, 1); + const int bestX = cardboy::sdk::kDisplayWidth - font16x8::measureText(bestStr, 1, 1) - margin; + font16x8::drawText(framebuffer, bestX, textY, bestStr, 1, true, 1); + + const int footerY = cardboy::sdk::kDisplayHeight - 24; + const std::string menuStr = "B MENU"; + const std::string selectStr = "SELECT RESET"; + const std::string startStr = "START PAUSE"; + const int selectX = (cardboy::sdk::kDisplayWidth - font16x8::measureText(selectStr, 1, 1)) / 2; + const int startX = cardboy::sdk::kDisplayWidth - font16x8::measureText(startStr, 1, 1) - margin; + font16x8::drawText(framebuffer, margin, footerY, menuStr, 1, true, 1); + font16x8::drawText(framebuffer, selectX, footerY, selectStr, 1, true, 1); + font16x8::drawText(framebuffer, startX, footerY, startStr, 1, true, 1); + + if (paused && !gameOver) + drawBanner("PAUSED"); + else if (gameOver) + drawBanner("GAME OVER"); + } + + void drawBanner(std::string_view text) { + const int w = font16x8::measureText(text, 2, 1); + const int h = font16x8::kGlyphHeight * 2; + const int x = (cardboy::sdk::kDisplayWidth - w) / 2; + const int y = boardOriginY() + kBoardHeight * kCellSize / 2 - h / 2; + for (int yy = -4; yy < h + 4; ++yy) + for (int xx = -6; xx < w + 6; ++xx) + framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5); + font16x8::drawText(framebuffer, x, y, text, 2, true, 1); + } +}; + +class SnakeApp final : public cardboy::sdk::IApp { +public: + explicit SnakeApp(AppContext& ctx) : game(ctx) {} + + void onStart() override { game.onStart(); } + void onStop() override { game.onStop(); } + void handleEvent(const AppEvent& event) override { game.handleEvent(event); } + +private: + SnakeGame game; +}; + +class SnakeFactory final : public cardboy::sdk::IAppFactory { +public: + const char* name() const override { return kSnakeAppName; } + std::unique_ptr create(AppContext& context) override { + return std::make_unique(context); + } +}; + +} // namespace + +std::unique_ptr createSnakeAppFactory() { return std::make_unique(); } + +} // namespace apps diff --git a/Firmware/sdk/launchers/desktop/src/main.cpp b/Firmware/sdk/launchers/desktop/src/main.cpp index 564ac35..cf02b94 100644 --- a/Firmware/sdk/launchers/desktop/src/main.cpp +++ b/Firmware/sdk/launchers/desktop/src/main.cpp @@ -2,6 +2,7 @@ #include "cardboy/apps/gameboy_app.hpp" #include "cardboy/apps/menu_app.hpp" #include "cardboy/apps/settings_app.hpp" +#include "cardboy/apps/snake_app.hpp" #include "cardboy/apps/tetris_app.hpp" #include "cardboy/backend/desktop_backend.hpp" #include "cardboy/sdk/app_system.hpp" @@ -29,6 +30,7 @@ int main() { system.registerApp(apps::createSettingsAppFactory()); system.registerApp(apps::createClockAppFactory()); system.registerApp(apps::createGameboyAppFactory()); + system.registerApp(apps::createSnakeAppFactory()); system.registerApp(apps::createTetrisAppFactory()); system.run();