mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-18 01:11:42 +10:00
Snake! (#10936)
* 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:
co-authored by
Copilot Autofix powered by AI
Thomas Göttgens
coderabbitai[bot]
Ixitxachitl
parent
d3d02af817
commit
8d1fbbf55f
@@ -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
|
||||
Reference in New Issue
Block a user