From d7151dbbf8b0d111f5bdb6c4b35cbfb4864cdd3e Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Tue, 16 Jun 2026 23:41:21 +0200 Subject: [PATCH] apps: extract Snake into a self-contained SnakeGame class (own files) Signed-off-by: Dan Vybiral --- src/ui-touch/SnakeGame.cpp | 167 +++++++++++++++++++++++++++++++++++++ src/ui-touch/SnakeGame.h | 53 ++++++++++++ src/ui-touch/UITask.cpp | 139 +----------------------------- 3 files changed, 222 insertions(+), 137 deletions(-) create mode 100644 src/ui-touch/SnakeGame.cpp create mode 100644 src/ui-touch/SnakeGame.h diff --git a/src/ui-touch/SnakeGame.cpp b/src/ui-touch/SnakeGame.cpp new file mode 100644 index 0000000..ad3a63a --- /dev/null +++ b/src/ui-touch/SnakeGame.cpp @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SnakeGame.h" + +#include // random() +#include // memmove +#include // PSRAM-preferred buffer for the canvas + +// Mirrors the app's status-bar height; the overlay starts below it so the bar +// stays visible. Kept local so this module needs nothing from UITask. +static constexpr lv_coord_t kTopBar = 22; + +// Palette (hex literals so the module is self-contained). +static constexpr uint32_t kColBg = 0x0A0B0C; // playfield background +static constexpr uint32_t kColFood = 0xE0584C; // food +static constexpr uint32_t kColBody = 0x15B6A6; // snake body (brand teal) +static constexpr uint32_t kColHead = 0x15B6A6; // head (same hue; brighter via the head check) +static constexpr uint32_t kColText = 0xE6EAEE; + +SnakeGame* SnakeGame::s_active = nullptr; + +void SnakeGame::launch() { + if (s_active) return; // already running + SnakeGame* g = new SnakeGame(); + s_active = g; + if (!g->open()) { // alloc failed — clean up, no overlay + s_active = nullptr; + delete g; + } +} + +void SnakeGame::placeFood() { + for (int tries = 0; tries < 400; ++tries) { + const uint8_t fx = (uint8_t)random(kGrid); + const uint8_t fy = (uint8_t)random(kGrid); + bool on = false; + for (int i = 0; i < len_; ++i) if (bx_[i] == fx && by_[i] == fy) { on = true; break; } + if (!on) { fx_ = fx; fy_ = fy; return; } + } +} + +void SnakeGame::reset() { + len_ = 3; + for (int i = 0; i < len_; ++i) { bx_[i] = (uint8_t)(kGrid / 2 - i); by_[i] = kGrid / 2; } + dx_ = 1; dy_ = 0; ndx_ = 1; ndy_ = 0; + over_ = false; score_val_ = 0; + placeFood(); +} + +void SnakeGame::render() { + if (!canvas_) return; + const int c = cell_; + lv_canvas_fill_bg(canvas_, lv_color_hex(kColBg), LV_OPA_COVER); + lv_draw_rect_dsc_t d; lv_draw_rect_dsc_init(&d); d.radius = 2; + d.bg_color = lv_color_hex(kColFood); + lv_canvas_draw_rect(canvas_, fx_ * c + 1, fy_ * c + 1, c - 2, c - 2, &d); + for (int i = 0; i < len_; ++i) { + d.bg_color = (i == 0) ? lv_color_hex(kColHead) : lv_color_hex(kColBody); + d.bg_opa = (i == 0) ? LV_OPA_COVER : LV_OPA_80; // head reads brighter + lv_canvas_draw_rect(canvas_, bx_[i] * c + 1, by_[i] * c + 1, c - 2, c - 2, &d); + } +} + +void SnakeGame::updateScoreLabel() { + if (!score_) return; + if (over_) lv_label_set_text_fmt(score_, "Game over \xe2\x80\x94 score %d (tap to restart)", score_val_); + else lv_label_set_text_fmt(score_, "score %d", score_val_); +} + +void SnakeGame::step() { + if (over_) return; + if (!(ndx_ == -dx_ && ndy_ == -dy_)) { dx_ = ndx_; dy_ = ndy_; } + const int nx = bx_[0] + dx_, ny = by_[0] + dy_; + const bool eat = (nx == fx_ && ny == fy_); + bool over = (nx < 0 || ny < 0 || nx >= kGrid || ny >= kGrid); + const int clen = eat ? len_ : len_ - 1; // the tail vacates unless we grow + for (int i = 0; i < clen && !over; ++i) if (bx_[i] == nx && by_[i] == ny) over = true; + if (over) { over_ = true; updateScoreLabel(); return; } + + int newlen = len_ + (eat ? 1 : 0); + if (newlen > kGrid * kGrid) newlen = kGrid * kGrid; + memmove(&bx_[1], &bx_[0], (size_t)(newlen - 1)); + memmove(&by_[1], &by_[0], (size_t)(newlen - 1)); + bx_[0] = (uint8_t)nx; by_[0] = (uint8_t)ny; + len_ = newlen; + if (eat) { score_val_++; placeFood(); updateScoreLabel(); } + render(); +} + +void SnakeGame::setDir(int dx, int dy) { ndx_ = dx; ndy_ = dy; } + +bool SnakeGame::open() { + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + const lv_coord_t sh = lv_disp_get_ver_res(nullptr); + root_ = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(root_); + lv_obj_set_size(root_, sw, sh - kTopBar); + lv_obj_set_pos(root_, 0, kTopBar); + lv_obj_set_style_bg_color(root_, lv_color_hex(0x0E1216), LV_PART_MAIN); + lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(root_, gestureCb, LV_EVENT_GESTURE, this); + lv_obj_add_event_cb(root_, tapCb, LV_EVENT_CLICKED, this); + + // Largest square playfield that fits below a 26-px score/close row. + const int avail_h = (int)(sh - kTopBar) - 28; + int side = ((int)sw < avail_h) ? (int)sw : avail_h; + cell_ = side / kGrid; + if (cell_ < 6) cell_ = 6; + const int canvas_px = cell_ * kGrid; + buf_ = (lv_color_t*)lvglPsramAlloc((size_t)canvas_px * canvas_px * sizeof(lv_color_t)); + if (!buf_) return false; // caller frees the half-built instance + canvas_ = lv_canvas_create(root_); + lv_canvas_set_buffer(canvas_, buf_, canvas_px, canvas_px, LV_IMG_CF_TRUE_COLOR); + lv_obj_align(canvas_, LV_ALIGN_TOP_MID, 0, 26); + + score_ = lv_label_create(root_); + lv_obj_set_style_text_color(score_, lv_color_hex(kColText), LV_PART_MAIN); + lv_obj_align(score_, LV_ALIGN_TOP_LEFT, 6, 4); + + lv_obj_t* x = lv_btn_create(root_); + lv_obj_set_size(x, 30, 24); + lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0); + lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this); + lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); + + reset(); + lv_label_set_text_fmt(score_, "score %d (swipe to steer)", score_val_); + render(); + timer_ = lv_timer_create(timerCb, 220, this); // 220 ms / step + return true; +} + +void SnakeGame::close() { + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } + if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes the canvas child + if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } // free AFTER the canvas is gone + canvas_ = nullptr; score_ = nullptr; +} + +// ---- LVGL C-callback trampolines (user_data = the instance) ---- +void SnakeGame::timerCb(lv_timer_t* t) { + auto* self = static_cast(t->user_data); + if (self) self->step(); +} +void SnakeGame::gestureCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + switch (lv_indev_get_gesture_dir(lv_indev_get_act())) { + case LV_DIR_TOP: self->setDir(0, -1); break; + case LV_DIR_BOTTOM: self->setDir(0, 1); break; + case LV_DIR_LEFT: self->setDir(-1, 0); break; + case LV_DIR_RIGHT: self->setDir(1, 0); break; + default: break; + } +} +void SnakeGame::tapCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self && self->over_) { self->reset(); self->updateScoreLabel(); self->render(); } +} +void SnakeGame::closeCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->close(); + if (s_active == self) s_active = nullptr; + delete self; +} diff --git a/src/ui-touch/SnakeGame.h b/src/ui-touch/SnakeGame.h new file mode 100644 index 0000000..bdb0f18 --- /dev/null +++ b/src/ui-touch/SnakeGame.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +// Self-contained Snake mini-game launched from the Apps drawer. +// +// Owns a full-screen overlay on lv_layer_top: a square canvas playfield, a score +// line, and a close button. Swipe up/down/left/right to steer, tap to restart +// after a game over, the X to close. One instance lives at a time (a static +// singleton); launch() is a no-op while it's already open. +// +// Deliberately decoupled from UITask internals — it depends only on LVGL and the +// shared lvglPsramAlloc helpers, so it can live in its own translation unit. +class SnakeGame { +public: + // Open the game (creates the overlay + starts the tick). No-op if already open. + static void launch(); + +private: + static constexpr int kGrid = 14; // cells per side + + lv_obj_t* root_ = nullptr; // full-screen overlay (owns everything below) + lv_obj_t* canvas_ = nullptr; + lv_color_t* buf_ = nullptr; // canvas pixel buffer (PSRAM) + lv_obj_t* score_ = nullptr; + lv_timer_t* timer_ = nullptr; + + int cell_ = 13; // px per cell (set at open) + uint8_t bx_[kGrid * kGrid]; // snake body, [0] = head + uint8_t by_[kGrid * kGrid]; + int len_ = 0; + int dx_ = 1, dy_ = 0; // active direction + int ndx_ = 1, ndy_ = 0; // queued direction (applied next step) + uint8_t fx_ = 0, fy_ = 0; // food cell + bool over_ = false; + int score_val_ = 0; + + bool open(); // build UI + start; false on alloc failure + void close(); // stop tick + free + delete overlay + void reset(); + void placeFood(); + void render(); + void step(); + void setDir(int dx, int dy); + void updateScoreLabel(); + + static SnakeGame* s_active; // the one live game (or nullptr) + static void timerCb(lv_timer_t* t); + static void gestureCb(lv_event_t* e); + static void tapCb(lv_event_t* e); + static void closeCb(lv_event_t* e); +}; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index ed0938a..6265076 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -57,6 +57,7 @@ #endif #include #include // PSRAM-preferred alloc helpers for the map tile cache +#include "SnakeGame.h" // Apps → Snake (self-contained game module) #if defined(HAS_TOUCH_UI) #include @@ -20994,142 +20995,6 @@ static void openMentionsScreen() { } } -// ----- Snake (Apps) ----- -// A small canvas-based snake. Swipe up/down/left/right to steer (works on either -// board's touch); tap to restart after a game over; the X button closes it. The -// game advances on its own lv_timer, so it pauses naturally when closed. -static lv_obj_t* s_snake_root = nullptr; -static lv_obj_t* s_snake_canvas = nullptr; -static lv_color_t* s_snake_buf = nullptr; -static lv_obj_t* s_snake_score = nullptr; -static lv_timer_t* s_snake_timer = nullptr; -static const int SNK_GRID = 14; // cells per side -static int s_snk_cell = 13; // px/cell (set at open) -static uint8_t s_snk_bx[SNK_GRID * SNK_GRID], s_snk_by[SNK_GRID * SNK_GRID]; // body, [0]=head -static int s_snk_len = 0; -static int s_snk_dx = 1, s_snk_dy = 0; // active direction -static int s_snk_ndx = 1, s_snk_ndy = 0; // queued direction (applied next step) -static uint8_t s_snk_fx = 0, s_snk_fy = 0; // food cell -static bool s_snk_over = false; -static int s_snk_score = 0; - -static void snakePlaceFood() { - for (int tries = 0; tries < 400; ++tries) { - const uint8_t fx = (uint8_t)random(SNK_GRID); - const uint8_t fy = (uint8_t)random(SNK_GRID); - bool on = false; - for (int i = 0; i < s_snk_len; ++i) if (s_snk_bx[i] == fx && s_snk_by[i] == fy) { on = true; break; } - if (!on) { s_snk_fx = fx; s_snk_fy = fy; return; } - } -} -static void snakeReset() { - s_snk_len = 3; - for (int i = 0; i < s_snk_len; ++i) { s_snk_bx[i] = (uint8_t)(SNK_GRID / 2 - i); s_snk_by[i] = SNK_GRID / 2; } - s_snk_dx = 1; s_snk_dy = 0; s_snk_ndx = 1; s_snk_ndy = 0; - s_snk_over = false; s_snk_score = 0; - snakePlaceFood(); -} -static void snakeRender() { - if (!s_snake_canvas) return; - const int c = s_snk_cell; - lv_canvas_fill_bg(s_snake_canvas, lv_color_hex(0x0A0B0C), LV_OPA_COVER); - lv_draw_rect_dsc_t d; lv_draw_rect_dsc_init(&d); d.radius = 2; - d.bg_color = lv_color_hex(0xE0584C); // food - lv_canvas_draw_rect(s_snake_canvas, s_snk_fx * c + 1, s_snk_fy * c + 1, c - 2, c - 2, &d); - for (int i = 0; i < s_snk_len; ++i) { // body (head brighter) - d.bg_color = (i == 0) ? lv_color_hex(COLOR_ACCENT) : lv_color_hex(0x15B6A6); - lv_canvas_draw_rect(s_snake_canvas, s_snk_bx[i] * c + 1, s_snk_by[i] * c + 1, c - 2, c - 2, &d); - } -} -static void snakeStep() { - if (s_snk_over) return; - if (!(s_snk_ndx == -s_snk_dx && s_snk_ndy == -s_snk_dy)) { s_snk_dx = s_snk_ndx; s_snk_dy = s_snk_ndy; } - const int nx = s_snk_bx[0] + s_snk_dx, ny = s_snk_by[0] + s_snk_dy; - const bool eat = (nx == s_snk_fx && ny == s_snk_fy); - bool over = (nx < 0 || ny < 0 || nx >= SNK_GRID || ny >= SNK_GRID); - // Self-collision: the tail vacates this step unless we're growing, so skip it. - const int clen = eat ? s_snk_len : s_snk_len - 1; - for (int i = 0; i < clen && !over; ++i) if (s_snk_bx[i] == nx && s_snk_by[i] == ny) over = true; - if (over) { - s_snk_over = true; - if (s_snake_score) lv_label_set_text_fmt(s_snake_score, "Game over \xe2\x80\x94 score %d (tap to restart)", s_snk_score); - return; - } - int newlen = s_snk_len + (eat ? 1 : 0); - if (newlen > SNK_GRID * SNK_GRID) newlen = SNK_GRID * SNK_GRID; - memmove(&s_snk_bx[1], &s_snk_bx[0], (size_t)(newlen - 1)); - memmove(&s_snk_by[1], &s_snk_by[0], (size_t)(newlen - 1)); - s_snk_bx[0] = (uint8_t)nx; s_snk_by[0] = (uint8_t)ny; - s_snk_len = newlen; - if (eat) { s_snk_score++; snakePlaceFood(); if (s_snake_score) lv_label_set_text_fmt(s_snake_score, "score %d", s_snk_score); } - snakeRender(); -} -static void snakeTimerCb(lv_timer_t* t) { (void)t; snakeStep(); } -static void snakeGestureCb(lv_event_t* e) { - (void)e; - switch (lv_indev_get_gesture_dir(lv_indev_get_act())) { - case LV_DIR_TOP: s_snk_ndx = 0; s_snk_ndy = -1; break; - case LV_DIR_BOTTOM: s_snk_ndx = 0; s_snk_ndy = 1; break; - case LV_DIR_LEFT: s_snk_ndx = -1; s_snk_ndy = 0; break; - case LV_DIR_RIGHT: s_snk_ndx = 1; s_snk_ndy = 0; break; - default: break; - } -} -static void snakeClickCb(lv_event_t* e) { - if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; - if (s_snk_over) { snakeReset(); if (s_snake_score) lv_label_set_text_fmt(s_snake_score, "score %d", s_snk_score); snakeRender(); } -} -static void snakeClose() { - if (s_snake_timer) { lv_timer_del(s_snake_timer); s_snake_timer = nullptr; } - if (s_snake_root) { lv_obj_del(s_snake_root); s_snake_root = nullptr; } // also deletes the canvas child - if (s_snake_buf) { lvglPsramFree(s_snake_buf); s_snake_buf = nullptr; } // free AFTER the canvas is gone - s_snake_canvas = nullptr; s_snake_score = nullptr; -} -static void snakeCloseCb(lv_event_t* e) { (void)e; snakeClose(); } -static void openSnakeGame() { - if (s_snake_root) return; - const lv_coord_t sw = lv_disp_get_hor_res(nullptr); - const lv_coord_t sh = lv_disp_get_ver_res(nullptr); - s_snake_root = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(s_snake_root); - lv_obj_set_size(s_snake_root, sw, sh - STATUSBAR_H); - lv_obj_set_pos(s_snake_root, 0, STATUSBAR_H); - lv_obj_set_style_bg_color(s_snake_root, lv_color_hex(COLOR_BG), LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_snake_root, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_clear_flag(s_snake_root, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(s_snake_root, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(s_snake_root, snakeGestureCb, LV_EVENT_GESTURE, nullptr); - lv_obj_add_event_cb(s_snake_root, snakeClickCb, LV_EVENT_CLICKED, nullptr); - - // Largest square playfield that fits below a 26-px score/close row. - const int avail_h = (int)(sh - STATUSBAR_H) - 28; - int side = ((int)sw < avail_h) ? (int)sw : avail_h; - s_snk_cell = side / SNK_GRID; - if (s_snk_cell < 6) s_snk_cell = 6; - const int canvas_px = s_snk_cell * SNK_GRID; - s_snake_buf = (lv_color_t*)lvglPsramAlloc((size_t)canvas_px * canvas_px * sizeof(lv_color_t)); - if (!s_snake_buf) { snakeClose(); if (g_lv.task) g_lv.task->showAlert(TR("Out of memory"), 1500); return; } - s_snake_canvas = lv_canvas_create(s_snake_root); - lv_canvas_set_buffer(s_snake_canvas, s_snake_buf, canvas_px, canvas_px, LV_IMG_CF_TRUE_COLOR); - lv_obj_align(s_snake_canvas, LV_ALIGN_TOP_MID, 0, 26); - - s_snake_score = lv_label_create(s_snake_root); - lv_obj_set_style_text_color(s_snake_score, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); - lv_obj_set_style_text_font(s_snake_score, &g_font_12, LV_PART_MAIN); - lv_obj_align(s_snake_score, LV_ALIGN_TOP_LEFT, 6, 4); - - lv_obj_t* x = lv_btn_create(s_snake_root); - lv_obj_set_size(x, 30, 24); - lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0); - lv_obj_add_event_cb(x, snakeCloseCb, LV_EVENT_CLICKED, nullptr); - lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); - - snakeReset(); - lv_label_set_text_fmt(s_snake_score, "score %d (swipe to steer)", s_snk_score); - snakeRender(); - s_snake_timer = lv_timer_create(snakeTimerCb, 220, nullptr); // 220 ms / step -} - static void appTileCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int act = (int)(intptr_t)lv_event_get_user_data(e); @@ -21142,7 +21007,7 @@ static void appTileCb(lv_event_t* e) { case APPACT_MONITOR: openMonitorPage(); return; // RF activity graph + repeater-style radio stats case APPACT_ADVERT: openAdvertModalCb(e); return; case APPACT_POWER: openPowerMenu(); return; - case APPACT_SNAKE: openSnakeGame(); return; + case APPACT_SNAKE: SnakeGame::launch(); return; #if defined(HAS_TDECK_GT911) case APPACT_TERMINAL: homeTerminalCb(e); return; case APPACT_FILES: homeFilesCb(e); return;