updated firmware build date; added [J] Games to home screen; added game sub screen; added basic Snake game

This commit is contained in:
pelgraine
2026-05-15 06:59:49 +10:00
parent 1e589b3eb9
commit 2907cc64f3
7 changed files with 924 additions and 19 deletions
+1 -1
View File
@@ -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
+165 -3
View File
@@ -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()) {
@@ -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 <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
// 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
}
};
@@ -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 <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
// 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;
}
};
+45 -9
View File
@@ -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;
+11 -3
View File
@@ -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 roomconversation 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; }
+18 -3
View File
@@ -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