* Snake!

* Add spiLock to snake score saving

* Check fixes

* More careful locking

* WIP: Big Display Node

* Update src/graphics/HUB75Display.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add HUB75 Native

* Add Tetris game module with core logic and UI integration

- Implement TetrisGame class for game logic, including piece movement, rotation, and line clearing.
- Create TetrisModule class to manage game state, input handling, and rendering on OLED display.
- Introduce high score tracking with persistence and optional mesh broadcasting.
- Define UI states for title, playing, paused, game over, and high scores.
- Implement input handling for game controls and state transitions.
- Add rendering functions for the game board, high scores, and title screen.

* feat(snake): add snake graphics and update display logic in SnakeModule

* Prompt for Initials for Tetris, too

* refactor games module

* Games refactor

* hub75 native double buffer

* Games tuning

* Make joystick repeat events on held button

* Add clouds and colors

* Fix breakout and more color

* difficulty tuning

* trunk

* refactor game announcements, etc

* Scale chirpy gravity as game speed increases

* Portduino, check for hub75 display before reading in hub75 options

* Final(?) games tuning

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Properly ignore input when games screen not shown

* Fail gracefully when HUB75 is selected but not supported.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Ixitxachitl <kramerfm@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jonathan Bennett
2026-07-16 18:35:33 -05:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Thomas Göttgens coderabbitai[bot] Ixitxachitl
parent d3d02af817
commit 8d1fbbf55f
41 changed files with 4106 additions and 34 deletions
+373
View File
@@ -0,0 +1,373 @@
#include "GamesModule.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Breakout.h"
#include "ChirpyRunner.h"
#include "PowerFSM.h"
#include "Snake.h"
#include "Tetris.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "main.h"
#include "mesh/NodeDB.h"
#if GAMES_ANNOUNCE_HIGH_SCORE
#include "MeshService.h"
#endif
GamesModule *gamesModule;
GamesModule::GamesModule() : SinglePortModule("games", meshtastic_PortNum_PRIVATE_APP), concurrency::OSThread("Games")
{
// Register the hosted games. Order sets the attract-screen cycle order (Snake is shown first).
games.push_back(new Snake());
games.push_back(new Tetris());
games.push_back(new ChirpyRunner());
games.push_back(new Breakout());
inputObserver.observe(inputBroker);
// Keep the tick thread alive at boot only if a game broadcasts periodically; otherwise idle
// until the player launches a game. The first idle tick reschedules to the real cadence.
bool periodic = false;
for (Game *g : games)
periodic = periodic || g->wantsPeriodicMesh();
if (periodic)
setIntervalFromNow(1000);
else
disable();
}
// ---------------------------------------------------------------------------
// Lifecycle / state transitions
// ---------------------------------------------------------------------------
void GamesModule::launchGame()
{
if (games.empty())
return;
// The games frame is already current (the player is on the attract screen), so just begin play
// with the selected game -- no focus change or frameset regeneration needed.
active = games[selected];
startPlaying();
}
void GamesModule::startPlaying()
{
active->start(static_cast<uint32_t>(random()) ^ millis());
uiState = GAMES_PLAYING;
lastAwakeKickMs = millis();
kickTick();
requestRedraw();
}
void GamesModule::enterGameOver()
{
lastScore = active ? active->score() : 0;
lastRank = -1;
lastWasNewTop = false;
uiState = GAMES_GAMEOVER;
// Arcade-style: if the score placed, prompt for initials, then record it in the picker's
// callback. Otherwise just show the game-over screen.
if (active && active->scores().qualifies(lastScore))
promptForInitials();
requestRedraw();
}
void GamesModule::promptForInitials()
{
screen->showAlphanumericPicker("New High Score!\nEnter initials", "AAA", 60000, HighScoreTableBase::INITIALS_LEN,
[this](const std::string &initials) { this->recordHighScore(initials.c_str()); });
}
void GamesModule::recordHighScore(const char *initials)
{
if (!active)
return;
bool isNewTop = false;
lastRank = active->scores().insert(lastScore, initials, nodeDB ? nodeDB->getNodeNum() : 0, isNewTop);
lastWasNewTop = isNewTop;
if (lastRank >= 0)
active->scores().save(); // table changed -- the only time we write flash
#if GAMES_ANNOUNCE_HIGH_SCORE
if (isNewTop && lastScore > 0)
announceHighScore(initials, lastScore);
#endif
requestRedraw();
}
#if GAMES_ANNOUNCE_HIGH_SCORE
void GamesModule::announceHighScore(const char *initials, uint32_t score)
{
if (!active || !service)
return;
if (!initials || initials[0] == '\0')
return;
meshtastic_MeshPacket *p = allocDataPacket();
p->to = NODENUM_BROADCAST;
p->channel = 0; // primary channel
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
// One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu
// if a receiving node's font lacks a glyph.
p->decoded.payload.size = snprintf(reinterpret_cast<char *>(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes),
GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast<unsigned long>(score));
service->sendToMesh(p);
LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast<unsigned long>(score));
}
#endif
void GamesModule::exitToIdle()
{
uiState = GAMES_IDLE;
active = nullptr;
// The games frame is always present, so we just return it to the attract screen and redraw --
// no frameset change. interceptingKeyboardInput() now returns false, so the D-pad navigates
// between frames again. Keep ticking only if a game still needs its periodic broadcast.
bool periodic = false;
for (Game *g : games)
periodic = periodic || g->wantsPeriodicMesh();
if (periodic)
setIntervalFromNow(1000);
else
disable();
requestRedraw();
}
void GamesModule::requestRedraw()
{
UIFrameEvent e;
e.action = UIFrameEvent::Action::REDRAW_ONLY;
notifyObservers(&e);
}
void GamesModule::kickTick()
{
enabled = true;
setIntervalFromNow(250); // brief beat so the player sees the board before it moves
}
int32_t GamesModule::runOnce()
{
if (uiState == GAMES_PLAYING && active) {
if (!active->tick()) {
enterGameOver();
return disable();
}
// Keep the display awake through long runs that generate no key presses.
const uint32_t now = millis();
if (now - lastAwakeKickMs > 1500) {
powerFSM.trigger(EVENT_PRESS);
lastAwakeKickMs = now;
}
requestRedraw();
return active->tickIntervalMs();
}
// Idle: service any game that broadcasts periodically; sleep until the soonest one is due.
int32_t next = -1;
for (Game *g : games) {
const int32_t due = g->meshTick(*this);
if (due >= 0 && (next < 0 || due < next))
next = due;
}
return next < 0 ? disable() : next;
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
int GamesModule::handleInputEvent(const InputEvent *event)
{
// Ignore all input unless the games frame is the one actually on screen -- otherwise the attract
// screen's UP/DOWN would hijack normal frame navigation from wherever the player happens to be.
if (!screen || !screen->isGamesFrameShown())
return 0;
if (screen->isOverlayBannerShowing())
return 0; // a menu banner is up; don't steal its input
const input_broker_event ev = event->inputEvent;
const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK);
switch (uiState) {
case GAMES_IDLE:
// Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it;
// long-press SELECT opens that game's high-score table. Everything else passes through so
// the D-pad still navigates between frames.
if (!games.empty() && (ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_UP)) {
const uint8_t n = static_cast<uint8_t>(games.size());
selected = (ev == INPUT_BROKER_DOWN) ? (selected + 1) % n : (selected + n - 1) % n;
requestRedraw();
return 1;
}
if (ev == INPUT_BROKER_SELECT_LONG && !games.empty()) {
active = games[selected];
uiState = GAMES_HISCORES;
requestRedraw();
return 1;
}
return 0;
case GAMES_PLAYING:
if (isBack) {
uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit
disable();
requestRedraw();
} else if (active) {
active->handleInput(ev);
if (!active->isPlaying()) {
enterGameOver();
return 1;
}
requestRedraw();
}
return 1;
case GAMES_PAUSED:
if (isBack) {
exitToIdle(); // quit from pause
} else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT ||
ev == INPUT_BROKER_RIGHT) {
uiState = GAMES_PLAYING;
kickTick();
requestRedraw();
}
return 1;
case GAMES_GAMEOVER:
if (ev == INPUT_BROKER_SELECT) {
uiState = GAMES_HISCORES;
requestRedraw();
} else if (isBack) {
exitToIdle();
}
return 1;
case GAMES_HISCORES:
if (ev == INPUT_BROKER_SELECT_LONG && active) {
static const char *opts[] = {"No", "Yes"};
graphics::BannerOverlayOptions confirm;
confirm.message = "Clear Scores?";
confirm.optionsArrayPtr = opts;
confirm.optionsCount = 2;
confirm.bannerCallback = [this](int sel) {
if (sel == 1 && active) {
active->scores().clear();
active->scores().save();
requestRedraw();
LOG_INFO("Games: high scores cleared");
}
};
if (screen)
screen->showOverlayBanner(confirm);
} else if (ev == INPUT_BROKER_SELECT || isBack) {
exitToIdle();
}
return 1;
default:
return 0;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
void GamesModule::drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count)
{
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
const int16_t lineH = FONT_HEIGHT_SMALL;
const int16_t total = static_cast<int16_t>(count) * lineH;
int16_t startY = y + (display->getHeight() - total) / 2;
if (startY < y)
startY = y;
const int16_t cx = x + display->getWidth() / 2;
for (uint8_t i = 0; i < count; i++)
display->drawString(cx, startY + i * lineH, lines[i]);
}
void GamesModule::drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores)
{
display->setFont(FONT_SMALL);
display->setColor(WHITE);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->drawString(x, y, "HIGH SCORES");
display->setTextAlignment(TEXT_ALIGN_LEFT);
const int16_t rowH = (display->getHeight() - FONT_HEIGHT_SMALL) / HighScoreTableBase::HS_COUNT;
for (uint8_t i = 0; i < HighScoreTableBase::HS_COUNT; i++) {
char row[32];
if (scores.scoreAt(i) > 0) {
snprintf(row, sizeof(row), "%u. %-4s %lu", static_cast<unsigned>(i + 1), scores.nameAt(i),
static_cast<unsigned long>(scores.scoreAt(i)));
} else {
snprintf(row, sizeof(row), "%u. ---", static_cast<unsigned>(i + 1));
}
display->drawString(x + 6, y + FONT_HEIGHT_SMALL + i * rowH, row);
}
}
void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/, int16_t x, int16_t y)
{
display->setColor(WHITE);
switch (uiState) {
case GAMES_IDLE:
if (!games.empty())
games[selected]->drawAttract(display, x, y);
break;
case GAMES_PLAYING:
if (active)
active->drawPlaying(display, x, y);
break;
case GAMES_PAUSED:
if (active) {
active->drawPlaying(display, x, y);
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(x + display->getWidth() / 2, y + display->getHeight() / 2 - FONT_HEIGHT_SMALL / 2, "- PAUSED -");
}
break;
case GAMES_GAMEOVER: {
char scoreLine[24];
snprintf(scoreLine, sizeof(scoreLine), "Score: %lu", static_cast<unsigned long>(lastScore));
const char *status = lastWasNewTop ? "NEW HIGH SCORE!" : (lastRank >= 0 ? "You made the top 5!" : "");
const char *hint = active ? active->gameOverHint() : "SELECT: scores";
const char *lines[] = {"GAME OVER", scoreLine, status, hint};
drawCenteredLines(display, x, y, lines, 4);
break;
}
case GAMES_HISCORES:
if (active)
drawHighScores(display, x, y, active->scores());
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Mesh receive - dispatch to each hosted game
// ---------------------------------------------------------------------------
ProcessMessage GamesModule::handleReceived(const meshtastic_MeshPacket &mp)
{
for (Game *g : games) {
const ProcessMessage r = g->handleReceived(mp);
if (r != ProcessMessage::CONTINUE)
return r;
}
requestRedraw(); // a merged remote score should show up if the high-score screen is open
return ProcessMessage::CONTINUE;
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES