mirror of
https://github.com/pelgraine/Meck.git
synced 2026-07-04 00:31:17 +02:00
489 lines
14 KiB
C++
489 lines
14 KiB
C++
#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 == KEY_CANCEL) { _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 KEY_CANCEL: _wantsExit = true; return true;
|
|
default: return false;
|
|
}
|
|
case GAME_OVER:
|
|
if (c == '\r') { resetGame(); _state = PLAYING; _lastTick = millis(); return true; }
|
|
if (c == KEY_CANCEL) { _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 Sh+Del: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("Sh+Del:Back");
|
|
} else if (_state == READY) {
|
|
display.setCursor(2, fy);
|
|
display.print("Enter:Start Sh+Del:Back");
|
|
}
|
|
#endif
|
|
|
|
if (_state == PLAYING) return 100;
|
|
return 5000;
|
|
}
|
|
}; |