From 2907cc64f3df6938960d7f302fc0ae14fe77e483 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 15 May 2026 06:59:49 +1000 Subject: [PATCH] updated firmware build date; added [J] Games to home screen; added game sub screen; added basic Snake game --- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/main.cpp | 168 +++++- .../companion_radio/ui-new/GamesMenuScreen.h | 195 +++++++ examples/companion_radio/ui-new/Snakescreen.h | 489 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 54 +- examples/companion_radio/ui-new/UITask.h | 14 +- examples/companion_radio/ui-new/homeicons.h | 21 +- 7 files changed, 924 insertions(+), 19 deletions(-) create mode 100644 examples/companion_radio/ui-new/GamesMenuScreen.h create mode 100644 examples/companion_radio/ui-new/Snakescreen.h diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 66fef174..53a26f39 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,7 +8,7 @@ #define FIRMWARE_VER_CODE 11 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "12 May 2026" +#define FIRMWARE_BUILD_DATE "15 May 2026" #endif #ifndef FIRMWARE_VERSION diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 209842b8..8e205487 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -29,6 +29,8 @@ #include "LastHeardScreen.h" #include "PathEditorScreen.h" #include "Tracescreen.h" + #include "GamesMenuScreen.h" + #include "SnakeScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -700,6 +702,8 @@ #include "LastHeardScreen.h" #include "PathEditorScreen.h" #include "Tracescreen.h" + #include "GamesMenuScreen.h" + #include "SnakeScreen.h" static TouchDrvGT911 gt911Touch; static bool gt911Ready = false; @@ -1210,6 +1214,21 @@ static void lastHeardToggleContact() { return 0; } + // Games menu: tap to select game entry + if (ui_task.isOnGamesMenu()) { + GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); + if (gm) { + int result = gm->selectRowAtVY(vy); + if (result == 2) return '\r'; // Tapped current selection -- launch + } + return 0; // Cursor moved or miss -- just refresh + } + + // Snake screen: tap = Enter (start / restart) + if (ui_task.isOnSnakeScreen()) { + return '\r'; + } + // Home screen FIRST page: tile taps (virtual coordinate hit test) if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) { const int tileW = 40, tileH = 22, gapX = 1, gapY = 1; @@ -1235,8 +1254,9 @@ static void lastHeardToggleContact() { #else if (row == 1 && col == 2) { ui_task.gotoDiscoveryScreen(); return 0; } #endif - // Third row: only centre tile (col 1) is real; col 0 and col 2 fall through to page-flip - if (row == 2 && col == 1) { ui_task.gotoTraceScreen(); return 0; } + // Third row: Trace (col 0) + Games (col 1) + if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; } + if (row == 2 && col == 1) { ui_task.gotoGamesMenu(); return 0; } } // Tap outside tiles — left half backward, right half forward return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT; @@ -1473,6 +1493,23 @@ static void lastHeardToggleContact() { if (ui_task.isOnSMSScreen()) return 0; #endif + // Snake screen: swipes control direction + if (ui_task.isOnSnakeScreen()) { + if (horizontal) { + return (dx < 0) ? 'a' : 'd'; + } else { + return (dy < 0) ? 'w' : 's'; + } + } + + // Games menu: vertical swipe scrolls list + if (ui_task.isOnGamesMenu()) { + if (!horizontal) { + return (dy > 0) ? 's' : 'w'; + } + return 0; + } + // Reader (reading mode): swipe left/right for page turn if (ui_task.isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); @@ -1545,6 +1582,16 @@ static void lastHeardToggleContact() { if (ui_task.isOnSMSScreen()) return 0; #endif + // Snake screen: long press exits to games menu + if (ui_task.isOnSnakeScreen()) { + return 'q'; + } + + // Games menu: long press exits to home + if (ui_task.isOnGamesMenu()) { + return 'q'; + } + // Home screen: long press = activate current page action // (BLE toggle, send advert, hibernate, GPS toggle, etc.) if (ui_task.isOnHomeScreen()) { @@ -3378,6 +3425,25 @@ void loop() { ui_task.gotoHomeScreen(); } } + // Snake screen: check if Exit was triggered + if (ui_task.isOnSnakeScreen()) { + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + ui_task.gotoGamesMenu(); + } + } + // Games menu: check if game launch was triggered + if (ui_task.isOnGamesMenu()) { + GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); + if (gm && gm->wantsLaunch()) { + GameID sel = gm->selectedGame(); + gm->clearFlags(); + switch (sel) { + case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + default: break; + } + } + } // Channel picker: check if long-press Enter was handled (wantsExit) if (ui_task.isOnChannelPickerScreen()) { ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen(); @@ -3411,6 +3477,25 @@ void loop() { ui_task.gotoHomeScreen(); } } + // Snake screen: check if Exit was triggered + if (ui_task.isOnSnakeScreen()) { + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + ui_task.gotoGamesMenu(); + } + } + // Games menu: check if game launch was triggered + if (ui_task.isOnGamesMenu()) { + GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); + if (gm && gm->wantsLaunch()) { + GameID sel = gm->selectedGame(); + gm->clearFlags(); + switch (sel) { + case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + default: break; + } + } + } // Channel picker: check if Enter/Q was handled (wantsExit) if (ui_task.isOnChannelPickerScreen()) { ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen(); @@ -3554,6 +3639,7 @@ void loop() { case 'f': ui_task.gotoDiscoveryScreen(); break; case 'h': ui_task.gotoLastHeardScreen(); break; case 'r': ui_task.gotoTraceScreen(); break; + case 'j': ui_task.gotoGamesMenu(); break; case (char)0xF3: ui_task.injectKey(KEY_LEFT); break; // Left arrow → prev page case (char)0xF4: ui_task.injectKey(KEY_RIGHT); break; // Right arrow → next page #ifdef MECK_WEB_READER @@ -3604,7 +3690,15 @@ void loop() { if (!handled) { // ESC or Q → back navigation if (ckb == 0x1B || ckb == 'q') { - if (ui_task.isOnChannelPickerScreen()) { + if (ui_task.isOnSnakeScreen()) { + ui_task.injectKey('q'); + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + ui_task.gotoGamesMenu(); + } + } else if (ui_task.isOnGamesMenu()) { + ui_task.gotoHomeScreen(); + } else if (ui_task.isOnChannelPickerScreen()) { ui_task.gotoHomeScreen(); } else if (ui_task.isOnChannelScreen()) { ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); @@ -3759,6 +3853,25 @@ void loop() { if (ts && ts->wantsExit()) { ui_task.gotoHomeScreen(); } + } else if (ui_task.isOnGamesMenu()) { + // Games menu: Enter launches selected game + ui_task.injectKey('\r'); + GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); + if (gm && gm->wantsLaunch()) { + GameID sel = gm->selectedGame(); + gm->clearFlags(); + switch (sel) { + case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + default: break; + } + } + } else if (ui_task.isOnSnakeScreen()) { + // Snake: Enter starts/restarts game + ui_task.injectKey('\r'); + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + ui_task.gotoGamesMenu(); + } } else if (ui_task.isOnChannelPickerScreen()) { // Channel picker: Enter selects channel ui_task.injectKey('\r'); @@ -4865,6 +4978,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -4883,6 +4997,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -4905,6 +5020,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -4923,6 +5039,7 @@ void handleKeyboardInput() { || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -4945,6 +5062,7 @@ void handleKeyboardInput() { } else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnSnakeScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -4963,6 +5081,7 @@ void handleKeyboardInput() { } else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() || ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen() || ui_task.isOnTraceScreen() + || ui_task.isOnSnakeScreen() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -4991,6 +5110,25 @@ void handleKeyboardInput() { Serial.println("TraceScreen: Exit -- returning to home"); ui_task.gotoHomeScreen(); } + } else if (ui_task.isOnGamesMenu()) { + ui_task.injectKey('\r'); + GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen(); + if (gm && gm->wantsLaunch()) { + GameID sel = gm->selectedGame(); + gm->clearFlags(); + switch (sel) { + case GAME_SNAKE: ui_task.gotoSnakeScreen(); break; + // case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break; + // case GAME_2048: ui_task.goto2048Screen(); break; + default: break; + } + } + } else if (ui_task.isOnSnakeScreen()) { + ui_task.injectKey('\r'); + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + ui_task.gotoGamesMenu(); + } } else if (ui_task.isOnChannelPickerScreen()) { ui_task.injectKey('\r'); // Picker handles Enter: selects channel + sets wantsExit ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen(); @@ -5188,6 +5326,14 @@ void handleKeyboardInput() { } break; + case 'j': + // Open games menu from home screen + if (ui_task.isOnHomeScreen()) { + Serial.println("Opening games menu"); + ui_task.gotoGamesMenu(); + } + break; + case 'l': // L = Login/Admin — from DM conversation, open repeater admin with auto-login if (ui_task.isOnChannelScreen()) { @@ -5267,6 +5413,22 @@ void handleKeyboardInput() { } break; } + // Snake screen: Q goes back to games menu + if (ui_task.isOnSnakeScreen()) { + ui_task.injectKey('q'); + SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen(); + if (ss && ss->wantsExit()) { + Serial.println("Nav: Snake -> Games Menu"); + ui_task.gotoGamesMenu(); + } + break; + } + // Games menu: Q goes back to home + if (ui_task.isOnGamesMenu()) { + Serial.println("Nav: Games Menu -> Home"); + ui_task.gotoHomeScreen(); + break; + } // Alarm screen: Q/backspace routing depends on sub-mode #ifdef MECK_AUDIO_VARIANT if (ui_task.isOnAlarmScreen()) { diff --git a/examples/companion_radio/ui-new/GamesMenuScreen.h b/examples/companion_radio/ui-new/GamesMenuScreen.h new file mode 100644 index 00000000..dd3c58c0 --- /dev/null +++ b/examples/companion_radio/ui-new/GamesMenuScreen.h @@ -0,0 +1,195 @@ +#pragma once + +// ============================================================================= +// GamesMenuScreen -- Game launcher menu for Meck +// +// Lists available games. W/S to navigate, Enter to launch, Q to exit. +// Uses wantsExit() and wantsLaunch() flags for navigation -- same pattern +// as ChannelPickerScreen. +// ============================================================================= + +#include +#include + +// Forward declarations +class UITask; + +// Game identifiers -- add new entries here as games are added +enum GameID { + GAME_NONE = 0, + GAME_SNAKE, + // GAME_MINESWEEPER, + // GAME_2048, + GAME_COUNT // Must be last -- used for array sizing +}; + +class GamesMenuScreen : public UIScreen { +private: + UITask* _task; + bool _wantsExit; + bool _wantsLaunch; + int _cursor; + GameID _selectedGame; + + // Game registry -- add new games here + struct GameEntry { + GameID id; + const char* name; + const char* description; + }; + + static constexpr int NUM_GAMES = 1; // Increment as games are added + + static const GameEntry* getGames() { + static const GameEntry games[NUM_GAMES] = { + { GAME_SNAKE, "Snake", "Classic Nokia-style" }, + // { GAME_MINESWEEPER, "Minesweeper", "Tap to reveal" }, + // { GAME_2048, "2048", "Slide and merge" }, + }; + return games; + } + +public: + GamesMenuScreen(UITask* task) + : _task(task), _wantsExit(false), _wantsLaunch(false), + _cursor(0), _selectedGame(GAME_NONE) {} + + bool wantsExit() const { return _wantsExit; } + bool wantsLaunch() const { return _wantsLaunch; } + GameID selectedGame() const { return _selectedGame; } + + void enter() { + _wantsExit = false; + _wantsLaunch = false; + _selectedGame = GAME_NONE; + // Preserve cursor position so returning from a game stays on the same entry + } + + void clearFlags() { + _wantsExit = false; + _wantsLaunch = false; + _selectedGame = GAME_NONE; + } + + // ------- Input ------- + bool handleInput(char c) override { + switch (c) { + case 'w': case 'W': + if (_cursor > 0) _cursor--; + return true; + case 's': case 'S': + if (_cursor < NUM_GAMES - 1) _cursor++; + return true; + case '\r': + _selectedGame = getGames()[_cursor].id; + _wantsLaunch = true; + return true; + case 'q': case 'Q': + _wantsExit = true; + return true; + default: + return false; + } + } + + // ------- Render ------- + int render(DisplayDriver& display) override { + display.startFrame(); + display.setTextSize(1); + + // --- Header --- + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(display.width() / 2, 2, "Games"); +#else + display.setCursor(2, 2); + display.print("Games"); +#endif + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 12, display.width(), 1); + + // --- Game list --- + int y = 18; +#if defined(LilyGo_T5S3_EPaper_Pro) + int lineH = 16; +#else + int lineH = 20; +#endif + + for (int i = 0; i < NUM_GAMES; i++) { + bool selected = (i == _cursor); + + if (selected) { + // Highlight bar + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), lineH); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + +#if defined(LilyGo_T5S3_EPaper_Pro) + // T5S3: name centred, description below + display.drawTextCentered(display.width() / 2, y + 1, getGames()[i].name); + if (!selected) display.setColor(DisplayDriver::GREEN); + // Description on next line at smaller size + display.setTextSize(0); + display.drawTextCentered(display.width() / 2, y + 9, getGames()[i].description); + display.setTextSize(1); + lineH = 20; // Accommodate two-line entries +#else + // T-Deck Pro: name + description on one line + char buf[48]; + snprintf(buf, sizeof(buf), "%s", getGames()[i].name); + display.setCursor(6, y + 2); + display.print(buf); + + // Description in green (or dark if highlighted) + if (!selected) display.setColor(DisplayDriver::GREEN); + int nameW = display.getTextWidth(buf); + display.setCursor(6 + nameW + 8, y + 2); + display.print(getGames()[i].description); +#endif + + y += lineH; + } + + // --- Footer --- + display.setColor(DisplayDriver::LIGHT); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.setTextSize(0); + display.drawTextCentered(display.width() / 2, display.height() - 8, "Tap to select"); + display.setTextSize(1); +#else + display.setTextSize(1); + int fy = display.height() - 12; + display.drawRect(0, fy - 2, display.width(), 1); + display.setCursor(2, fy); + display.print("Enter:Play Q:Back"); +#endif + + return 5000; // Static menu -- slow refresh + } + + // --- T5S3 touch: tap to select game entry --- + int selectRowAtVY(int vy) { + int y = 18; +#if defined(LilyGo_T5S3_EPaper_Pro) + int lineH = 20; +#else + int lineH = 20; +#endif + if (vy < y) return 0; // Above list + int row = (vy - y) / lineH; + if (row >= NUM_GAMES) return 0; // Below list + + if (row == _cursor) { + // Tapped current selection -- launch + _selectedGame = getGames()[_cursor].id; + _wantsLaunch = true; + return 2; + } + _cursor = row; + return 1; // Moved cursor + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/Snakescreen.h b/examples/companion_radio/ui-new/Snakescreen.h new file mode 100644 index 00000000..f680c8f3 --- /dev/null +++ b/examples/companion_radio/ui-new/Snakescreen.h @@ -0,0 +1,489 @@ +#pragma once + +// ============================================================================= +// SnakeScreen -- Classic Nokia-style Snake for Meck e-ink devices +// +// T-Deck Pro: 8x8 pixel cells on 240x320 display +// T5S3: 4x4 pixel cells on 128x128 virtual display +// +// The 800ms partial refresh floor naturally produces Nokia-era tick speed. +// Snake body stored as circular buffer -- ~1KB, no PSRAM needed. +// Game state persists when switching screens (auto-pause on exit). +// +// High scores: top 10 scores with dates stored to /games/snake_hi.dat on SD. +// ============================================================================= + +#include +#include +#include + +// Forward declarations +class UITask; + +// -- Grid sizing per platform -- +#if defined(LilyGo_T5S3_EPaper_Pro) + #define SNAKE_CELL 4 + #define SNAKE_HDR 14 + #define SNAKE_FTR 10 +#else + #define SNAKE_CELL 8 + #define SNAKE_HDR 14 + #define SNAKE_FTR 14 +#endif + +#define SNAKE_MAX_LEN 512 +#define SNAKE_HI_COUNT 10 +#define SNAKE_HI_PATH "/games/snake_hi.dat" +#define SNAKE_HI_VERSION 1 + +class SnakeScreen : public UIScreen { +public: + enum GameState { READY, PLAYING, GAME_OVER }; + enum Direction { UP, DOWN, LEFT, RIGHT }; + + struct HiScoreEntry { + uint16_t score; + uint32_t timestamp; // Unix epoch from RTC + }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + bool _wantsExit; + + // Grid dimensions (computed once from display size on first render) + int _gridW, _gridH; + int _offsetX, _offsetY; + + // Snake body circular buffer + struct Cell { uint8_t x, y; }; + Cell _body[SNAKE_MAX_LEN]; + int _headIdx; + int _length; + + // Game state + GameState _state; + Direction _dir; + Direction _pendingDir; + Cell _food; + int _score; + unsigned long _lastTick; + unsigned long _tickInterval; + + // High scores + HiScoreEntry _hiScores[SNAKE_HI_COUNT]; + int _hiCount; + bool _newHiScore; + int _newHiRank; // 0-based rank of newly inserted score (-1 if none) + + // Simple xorshift PRNG + uint16_t _rngState; + + uint16_t rng() { + _rngState ^= _rngState << 7; + _rngState ^= _rngState >> 9; + _rngState ^= _rngState << 8; + return _rngState; + } + + void spawnFood() { + for (int attempt = 0; attempt < 50; attempt++) { + int fx = rng() % _gridW; + int fy = rng() % _gridH; + if (!isSnakeAt(fx, fy)) { + _food.x = fx; + _food.y = fy; + return; + } + } + for (int gy = 0; gy < _gridH; gy++) { + for (int gx = 0; gx < _gridW; gx++) { + if (!isSnakeAt(gx, gy)) { + _food.x = gx; + _food.y = gy; + return; + } + } + } + } + + bool isSnakeAt(int x, int y) const { + for (int i = 0; i < _length; i++) { + int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN; + if (_body[idx].x == x && _body[idx].y == y) return true; + } + return false; + } + + Cell getHead() const { return _body[_headIdx]; } + + void resetGame() { + int cx = _gridW / 2; + int cy = _gridH / 2; + _length = 3; + _headIdx = 2; + _body[0] = { (uint8_t)(cx - 2), (uint8_t)cy }; + _body[1] = { (uint8_t)(cx - 1), (uint8_t)cy }; + _body[2] = { (uint8_t)cx, (uint8_t)cy }; + _dir = RIGHT; + _pendingDir = RIGHT; + _score = 0; + _tickInterval = 500; + _newHiScore = false; + _newHiRank = -1; + _rngState = (uint16_t)(millis() ^ 0xA5A5); + spawnFood(); + } + + bool tick() { + _dir = _pendingDir; + Cell head = getHead(); + int nx = head.x; + int ny = head.y; + switch (_dir) { + case UP: ny--; break; + case DOWN: ny++; break; + case LEFT: nx--; break; + case RIGHT: nx++; break; + } + + if (nx < 0 || nx >= _gridW || ny < 0 || ny >= _gridH) { + onDeath(); + return false; + } + + bool eating = (nx == _food.x && ny == _food.y); + int checkLen = eating ? _length : (_length - 1); + for (int i = 0; i < checkLen; i++) { + int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN; + if (_body[idx].x == nx && _body[idx].y == ny) { + onDeath(); + return false; + } + } + + _headIdx = (_headIdx + 1) % SNAKE_MAX_LEN; + _body[_headIdx] = { (uint8_t)nx, (uint8_t)ny }; + + if (eating) { + _length++; + if (_length >= SNAKE_MAX_LEN) _length = SNAKE_MAX_LEN; + _score += 10; + spawnFood(); + } + return true; + } + + void onDeath() { + _state = GAME_OVER; + if (_score > 0) { + _newHiRank = insertHiScore(_score); + _newHiScore = (_newHiRank >= 0); + if (_newHiScore) saveHiScores(); + } + } + + void drawCellFilled(DisplayDriver& display, int gx, int gy) const { + int px = _offsetX + gx * SNAKE_CELL; + int py = _offsetY + gy * SNAKE_CELL; + display.fillRect(px, py, SNAKE_CELL, SNAKE_CELL); + } + + void drawCellOutline(DisplayDriver& display, int gx, int gy) const { + int px = _offsetX + gx * SNAKE_CELL; + int py = _offsetY + gy * SNAKE_CELL; + display.drawRect(px, py, SNAKE_CELL, SNAKE_CELL); + } + + // --- High score persistence --- + + void loadHiScores() { + _hiCount = 0; + memset(_hiScores, 0, sizeof(_hiScores)); + if (!SD.exists(SNAKE_HI_PATH)) return; + + File f = SD.open(SNAKE_HI_PATH, FILE_READ); + if (!f) return; + + uint8_t ver = 0; + if (f.read(&ver, 1) != 1 || ver != SNAKE_HI_VERSION) { f.close(); return; } + + uint8_t count = 0; + if (f.read(&count, 1) != 1) { f.close(); return; } + if (count > SNAKE_HI_COUNT) count = SNAKE_HI_COUNT; + + for (int i = 0; i < count; i++) { + uint8_t buf[6]; + if (f.read(buf, 6) != 6) break; + _hiScores[i].score = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8); + _hiScores[i].timestamp = (uint32_t)buf[2] | ((uint32_t)buf[3] << 8) + | ((uint32_t)buf[4] << 16) | ((uint32_t)buf[5] << 24); + _hiCount++; + } + f.close(); + } + + void saveHiScores() { + if (!SD.exists("/games")) SD.mkdir("/games"); + if (SD.exists(SNAKE_HI_PATH)) SD.remove(SNAKE_HI_PATH); + + File f = SD.open(SNAKE_HI_PATH, FILE_WRITE); + if (!f) return; + + uint8_t ver = SNAKE_HI_VERSION; + f.write(&ver, 1); + uint8_t count = (uint8_t)_hiCount; + f.write(&count, 1); + + for (int i = 0; i < _hiCount; i++) { + uint8_t buf[6]; + buf[0] = _hiScores[i].score & 0xFF; + buf[1] = (_hiScores[i].score >> 8) & 0xFF; + buf[2] = _hiScores[i].timestamp & 0xFF; + buf[3] = (_hiScores[i].timestamp >> 8) & 0xFF; + buf[4] = (_hiScores[i].timestamp >> 16) & 0xFF; + buf[5] = (_hiScores[i].timestamp >> 24) & 0xFF; + f.write(buf, 6); + } + f.close(); + } + + int insertHiScore(int score) { + uint32_t now = (_rtc != nullptr) ? _rtc->getCurrentTime() : 0; + int pos = _hiCount; + for (int i = 0; i < _hiCount; i++) { + if ((uint16_t)score > _hiScores[i].score) { pos = i; break; } + } + if (pos >= SNAKE_HI_COUNT) return -1; + + int newCount = _hiCount + 1; + if (newCount > SNAKE_HI_COUNT) newCount = SNAKE_HI_COUNT; + for (int i = newCount - 1; i > pos; i--) _hiScores[i] = _hiScores[i - 1]; + + _hiScores[pos].score = (uint16_t)score; + _hiScores[pos].timestamp = now; + _hiCount = newCount; + return pos; + } + + static void formatDate(uint32_t ts, char* buf, int bufLen) { + if (ts == 0) { snprintf(buf, bufLen, "--"); return; } + uint32_t days = ts / 86400; + int year = 1970; + while (true) { + int diy = ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) ? 366 : 365; + if (days < (uint32_t)diy) break; + days -= diy; + year++; + } + static const int dim[] = {31,28,31,30,31,30,31,31,30,31,30,31}; + static const char* mn[] = {"Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec"}; + bool leap = ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0); + int month = 0; + for (month = 0; month < 12; month++) { + int d = dim[month]; + if (month == 1 && leap) d = 29; + if (days < (uint32_t)d) break; + days -= d; + } + if (month > 11) month = 11; + snprintf(buf, bufLen, "%d %s %d", (int)(days + 1), mn[month], year); + } + +public: + SnakeScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _wantsExit(false), _gridW(0), _gridH(0), + _offsetX(0), _offsetY(0), _headIdx(0), _length(0), + _state(READY), _dir(RIGHT), _pendingDir(RIGHT), + _score(0), _lastTick(0), _tickInterval(500), _rngState(0xBEEF), + _hiCount(0), _newHiScore(false), _newHiRank(-1) { + _food = {0, 0}; + memset(_body, 0, sizeof(_body)); + memset(_hiScores, 0, sizeof(_hiScores)); + } + + bool wantsExit() const { return _wantsExit; } + void clearExit() { _wantsExit = false; } + GameState getState() const { return _state; } + + void enter() { + _wantsExit = false; + loadHiScores(); + _lastTick = millis(); + } + + bool handleInput(char c) override { + switch (_state) { + case READY: + if (c == '\r') { _state = PLAYING; _lastTick = millis(); return true; } + if (c == 'q' || c == 'Q') { _wantsExit = true; return true; } + return false; + case PLAYING: + switch (c) { + case 'w': case 'W': if (_dir != DOWN) _pendingDir = UP; return true; + case 's': case 'S': if (_dir != UP) _pendingDir = DOWN; return true; + case 'a': case 'A': if (_dir != RIGHT) _pendingDir = LEFT; return true; + case 'd': case 'D': if (_dir != LEFT) _pendingDir = RIGHT; return true; + case 'q': case 'Q': _wantsExit = true; return true; + default: return false; + } + case GAME_OVER: + if (c == '\r') { resetGame(); _state = PLAYING; _lastTick = millis(); return true; } + if (c == 'q' || c == 'Q') { _wantsExit = true; return true; } + return false; + } + return false; + } + + int render(DisplayDriver& display) override { + if (_gridW == 0) { + int usableW = display.width(); + int usableH = display.height() - SNAKE_HDR - SNAKE_FTR; + _gridW = usableW / SNAKE_CELL; + _gridH = usableH / SNAKE_CELL; + _offsetX = (usableW - _gridW * SNAKE_CELL) / 2; + _offsetY = SNAKE_HDR + (usableH - _gridH * SNAKE_CELL) / 2; + resetGame(); + } + + display.startFrame(); + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(display.width() / 2, 2, "Snake"); +#else + display.setCursor(2, 2); + display.print("Snake"); + char scoreBuf[16]; + snprintf(scoreBuf, sizeof(scoreBuf), "Score: %d", _score); + int sw = display.getTextWidth(scoreBuf); + display.setCursor(display.width() - sw - 2, 2); + display.print(scoreBuf); +#endif + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, SNAKE_HDR - 2, display.width(), 1); + + if (_state == READY) { + int cx = display.width() / 2; + int y = SNAKE_HDR + 6; + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(cx, y, "Classic Snake"); + y += 14; + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(cx, y, "Swipe to steer"); +#else + display.drawTextCentered(cx, y, "W/S/A/D to steer"); +#endif + y += 11; + display.drawTextCentered(cx, y, "Eat food to grow"); + y += 11; + display.drawTextCentered(cx, y, "Steer clear of walls"); + y += 16; + + if (_hiCount > 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(cx, y, "-- High Scores --"); + y += 12; + display.setColor(DisplayDriver::GREEN); +#if defined(LilyGo_T5S3_EPaper_Pro) + int showCount = (_hiCount < 5) ? _hiCount : 5; +#else + int showCount = (_hiCount < 10) ? _hiCount : 10; +#endif + for (int i = 0; i < showCount; i++) { + char dateBuf[16]; + formatDate(_hiScores[i].timestamp, dateBuf, sizeof(dateBuf)); + char line[48]; + snprintf(line, sizeof(line), "%d. %d %s", i + 1, _hiScores[i].score, dateBuf); + display.drawTextCentered(cx, y, line); + y += 10; + } + y += 4; + } else { + y += 8; + } + + display.setColor(DisplayDriver::LIGHT); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.drawTextCentered(cx, y, "Tap to start"); +#else + display.drawTextCentered(cx, y, "Press Enter to start"); +#endif + } else { + if (_state == PLAYING) { + unsigned long now = millis(); + if (now - _lastTick >= _tickInterval) { tick(); _lastTick = now; } + } + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(_offsetX - 1, _offsetY - 1, + _gridW * SNAKE_CELL + 2, _gridH * SNAKE_CELL + 2); + + display.setColor(DisplayDriver::GREEN); + drawCellFilled(display, _food.x, _food.y); + + display.setColor(DisplayDriver::LIGHT); + for (int i = 0; i < _length; i++) { + int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN; + if (i == 0) drawCellFilled(display, _body[idx].x, _body[idx].y); + else drawCellOutline(display, _body[idx].x, _body[idx].y); + } + + if (_state == GAME_OVER) { + int cx = display.width() / 2; + int cy = display.height() / 2; + int boxW = display.width() * 3 / 4; + int boxH = _newHiScore ? 60 : 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 + 8; + display.drawTextCentered(cx, ty, "Game Over"); + ty += 14; + char finalScore[24]; + snprintf(finalScore, sizeof(finalScore), "Score: %d", _score); + display.drawTextCentered(cx, ty, finalScore); + ty += 12; + if (_newHiScore) { + display.setColor(DisplayDriver::YELLOW); + char rankBuf[32]; + snprintf(rankBuf, sizeof(rankBuf), "New #%d High Score!", _newHiRank + 1); + display.drawTextCentered(cx, ty, rankBuf); + ty += 12; + } + display.setColor(DisplayDriver::GREEN); + display.drawTextCentered(cx, ty, "Enter:Retry Q:Back"); + } + } + + display.setColor(DisplayDriver::LIGHT); +#if defined(LilyGo_T5S3_EPaper_Pro) + char footBuf[32]; + snprintf(footBuf, sizeof(footBuf), "Score: %d", _score); + display.setTextSize(0); + display.drawTextCentered(display.width() / 2, display.height() - 8, footBuf); + display.setTextSize(1); +#else + display.setTextSize(1); + int fy = display.height() - 12; + display.drawRect(0, fy - 2, display.width(), 1); + if (_state == PLAYING) { + display.setCursor(2, fy); + display.print("Q:Back"); + } else if (_state == READY) { + display.setCursor(2, fy); + display.print("Enter:Start Q:Back"); + } +#endif + + if (_state == PLAYING) return 100; + return 5000; + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b7a17304..7f1794b6 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -9,6 +9,8 @@ #include "DiscoveryScreen.h" #include "LastHeardScreen.h" #include "Tracescreen.h" +#include "GamesMenuScreen.h" +#include "SnakeScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -536,20 +538,29 @@ public: } } - // Third row: single centred Trace tile (column 1 position only) + // Third row: Trace (col 0) + Games (col 1) { int row3y = gridY + 2 * (tileH + gapY); - int col1x = gridX + (tileW + gapX); + // Trace tile (column 0) + int col0x = gridX; display.setColor(DisplayDriver::LIGHT); - display.drawRect(col1x, row3y, tileW, tileH); - - int iconX = col1x + (tileW - HOME_ICON_W) / 2; + display.drawRect(col0x, row3y, tileW, tileH); + int iconX = col0x + (tileW - HOME_ICON_W) / 2; int iconY = row3y + 2; display.drawXbm(iconX, iconY, icon_trace, HOME_ICON_W, HOME_ICON_H); - display.setTextSize(_node_prefs->smallTextSize()); - display.drawTextCentered(col1x + tileW / 2, row3y + 15, "Trace"); + display.drawTextCentered(col0x + tileW / 2, row3y + 15, "Trace"); + + // Games tile (column 1) + int col1x = gridX + (tileW + gapX); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(col1x, row3y, tileW, tileH); + iconX = col1x + (tileW - HOME_ICON_W) / 2; + iconY = row3y + 2; + display.drawXbm(iconX, iconY, icon_gamepad, HOME_ICON_W, HOME_ICON_H); + display.setTextSize(_node_prefs->smallTextSize()); + display.drawTextCentered(col1x + tileW / 2, row3y + 15, "Games"); } // Nav hint at bottom of screen @@ -632,7 +643,8 @@ public: #endif y += menuLH; display.setColor(DisplayDriver::YELLOW); - display.drawTextCentered(display.width() / 2, y, "[R] Trace"); + display.setCursor(col1, y); display.print("[R] Trace"); + display.setCursor(col2, y); display.print("[J] Games"); display.setColor(DisplayDriver::LIGHT); y += menuLH; y += 2; @@ -670,7 +682,7 @@ public: #endif y += 10; display.setColor(DisplayDriver::YELLOW); - display.drawTextCentered(display.width() / 2, y, "[R] Trace"); + display.drawTextCentered(display.width() / 2, y, "[R] Trace [J] Games "); display.setColor(DisplayDriver::LIGHT); y += 14; } @@ -1389,6 +1401,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no discovery_screen = new DiscoveryScreen(this, &rtc_clock); last_heard_screen = new LastHeardScreen(&rtc_clock); trace_screen = new TraceScreen(this, &rtc_clock); + games_menu_screen = new GamesMenuScreen(this); + snake_screen = new SnakeScreen(this, &rtc_clock); #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) lock_screen = new LockScreen(this, &rtc_clock, node_prefs); #endif @@ -3109,6 +3123,28 @@ void UITask::gotoTraceScreen() { _next_refresh = 100; } +void UITask::gotoGamesMenu() { + GamesMenuScreen* gm = (GamesMenuScreen*)games_menu_screen; + gm->enter(); + setCurrScreen(games_menu_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +void UITask::gotoSnakeScreen() { + SnakeScreen* ss = (SnakeScreen*)snake_screen; + ss->enter(); + setCurrScreen(snake_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::onTraceResult(uint32_t tag, uint8_t flags, const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len, int8_t final_snr) { TraceScreen* ts = (TraceScreen*)trace_screen; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 076af1db..88045fdf 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -62,7 +62,7 @@ class UITask : public AbstractUITask { unsigned long _alert_expiry; bool _hintActive = false; // Boot navigation hint overlay unsigned long _hintExpiry = 0; // Auto-dismiss time for hint - bool _pendingBootHint = false; // Deferred hint — show after splash screen + bool _pendingBootHint = false; // Deferred hint -- show after splash screen int _msgcount; unsigned long ui_started_at, next_batt_chck; uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce @@ -100,6 +100,8 @@ class UITask : public AbstractUITask { UIScreen* discovery_screen; // Node discovery scan screen UIScreen* last_heard_screen; // Last heard passive advert list UIScreen* trace_screen; // Trace path screen (standalone trace tool) + UIScreen* games_menu_screen; // Games launcher menu + UIScreen* snake_screen; // Snake game screen #ifdef MECK_WEB_READER UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required) #endif @@ -119,7 +121,7 @@ class UITask : public AbstractUITask { UIScreen* _screenBeforeVKB = nullptr; unsigned long _vkbOpenedAt = 0; - // Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi) + // Powersaving: light sleep when locked + idle (standalone only -- no BLE/WiFi) // Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer #if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION) unsigned long _psLastActive = 0; // millis() at last wake or lock entry @@ -202,6 +204,8 @@ public: void gotoDiscoveryScreen(); // Navigate to node discovery scan void gotoLastHeardScreen(); // Navigate to last heard passive list void gotoTraceScreen(); // Navigate to trace path screen + void gotoGamesMenu(); // Navigate to games launcher menu + void gotoSnakeScreen(); // Navigate to snake game #if HAS_GPS void gotoMapScreen(); // Navigate to map tile screen #endif @@ -234,7 +238,7 @@ public: int getDMUnreadCount(int contactIdx) const; void clearDMUnread(int contactIdx); - // Flag: suppress room→conversation redirect on next login (L key admin access) + // Flag: suppress room->conversation redirect on next login (L key admin access) bool _skipRoomRedirect = false; bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; @@ -259,6 +263,8 @@ public: bool isOnDiscoveryScreen() const { return curr == discovery_screen; } bool isOnLastHeardScreen() const { return curr == last_heard_screen; } bool isOnTraceScreen() const { return curr == trace_screen; } + bool isOnGamesMenu() const { return curr == games_menu_screen; } + bool isOnSnakeScreen() const { return curr == snake_screen; } bool isOnMapScreen() const { return curr == map_screen; } #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) bool isLocked() const { return _locked; } @@ -344,6 +350,8 @@ public: UIScreen* getDiscoveryScreen() const { return discovery_screen; } UIScreen* getLastHeardScreen() const { return last_heard_screen; } UIScreen* getTraceScreen() const { return trace_screen; } + UIScreen* getGamesMenuScreen() const { return games_menu_screen; } + UIScreen* getSnakeScreen() const { return snake_screen; } UIScreen* getMapScreen() const { return map_screen; } #ifdef MECK_WEB_READER UIScreen* getWebReaderScreen() const { return web_reader; } diff --git a/examples/companion_radio/ui-new/homeicons.h b/examples/companion_radio/ui-new/homeicons.h index 61f6fa2e..8afc282f 100644 --- a/examples/companion_radio/ui-new/homeicons.h +++ b/examples/companion_radio/ui-new/homeicons.h @@ -1,6 +1,6 @@ #pragma once // ============================================================================= -// HomeIcons — 12x12 icon sprites for T5S3 home screen tiles +// HomeIcons -- 12x12 icon sprites for T5S3 home screen tiles // MSB-first, 2 bytes per row (same format as emoji sprites) // ============================================================================= @@ -48,7 +48,7 @@ static const uint8_t icon_search[] PROGMEM = { 0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00, }; -// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon +// ⏰ Alarm Clock (AlarmScreen) -- 12x12 home tile icon static const uint8_t icon_alarm[] PROGMEM = { 0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40, 0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20, @@ -60,7 +60,22 @@ static const uint8_t icon_trace[] PROGMEM = { 0xFF,0xF0, 0x00,0xE0, 0x00,0xC0, 0x00,0x80, 0x00,0x00, 0x00,0x00, }; -// 🔔 Bell — 7x8 status bar indicator (alarm enabled) +// 🎮 Gamepad (Games) +// ..########.. +// .#........#. +// .#..#..##.#. +// .#.###....#. +// .#..#..##.#. +// .#........#. +// ..##....##.. +// ...#....#... +// ...######... +static const uint8_t icon_gamepad[] PROGMEM = { + 0x00,0x00, 0x3F,0xC0, 0x40,0x20, 0x49,0xA0, 0x5C,0x20, 0x49,0xA0, + 0x40,0x20, 0x30,0xC0, 0x10,0x80, 0x1F,0x80, 0x00,0x00, 0x00,0x00, +}; + +// 🔔 Bell -- 7x8 status bar indicator (alarm enabled) // MSB-first, 1 byte per row #define BELL_ICON_W 7 #define BELL_ICON_H 8