* 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 GitHub Thomas Göttgens coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Ixitxachitl Copilot Autofix powered by AI
parent d3d02af817
commit 8d1fbbf55f
41 changed files with 4106 additions and 34 deletions
+8
View File
@@ -19,6 +19,9 @@
#if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES
#include "modules/CannedMessageModule.h"
#endif
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "modules/games/GamesModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR
#include "modules/DetectionSensorModule.h"
#endif
@@ -206,6 +209,11 @@ void setupModules()
cannedMessageModule = new CannedMessageModule();
}
#endif
#if HAS_SCREEN && BASEUI_HAS_GAMES
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
gamesModule = new GamesModule();
}
#endif
#if ARCH_PORTDUINO
new HostMetricsModule();
#endif
+315
View File
@@ -0,0 +1,315 @@
#include "Breakout.h"
// ===========================================================================
// Pure BreakoutGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
uint32_t BreakoutGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
void BreakoutGame::buildBricks()
{
for (uint8_t r = 0; r < BRICK_ROWS; r++)
for (uint8_t c = 0; c < BRICK_COLS; c++)
bricks[r][c] = 1;
bricksLeft = static_cast<uint16_t>(BRICK_ROWS) * BRICK_COLS;
}
void BreakoutGame::serveBall()
{
// Centre the paddle and launch the ball upward from just above it, at a slight angle whose
// side is chosen randomly so successive serves are not identical.
paddleLeft = (BOARD_W - PADDLE_W) / 2;
ballPxX = static_cast<int32_t>(BOARD_W / 2) * SUBPX;
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
ballVx = (nextRandom() & 1u) ? 28 : -28;
ballVy = -BALL_VY;
}
void BreakoutGame::nextLevel()
{
levelNum++;
buildBricks();
serveBall();
}
void BreakoutGame::reset(uint32_t seed)
{
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
points = 0;
livesLeft = START_LIVES;
levelNum = 1;
alive = true;
ballTick = false;
buildBricks();
serveBall();
}
void BreakoutGame::movePaddle(int16_t dxPx)
{
if (!alive)
return;
paddleLeft += dxPx;
if (paddleLeft < 0)
paddleLeft = 0;
else if (paddleLeft > BOARD_W - PADDLE_W)
paddleLeft = BOARD_W - PADDLE_W;
}
void BreakoutGame::moveLeft()
{
movePaddle(-PADDLE_STEP);
}
void BreakoutGame::moveRight()
{
movePaddle(PADDLE_STEP);
}
bool BreakoutGame::step()
{
if (!alive)
return false;
// The ball advances on every other step() so the caller can tick (and poll the paddle) at twice
// the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster.
ballTick = !ballTick;
if (!ballTick)
return true;
ballPxX += ballVx;
ballPxY += ballVy;
int16_t px = static_cast<int16_t>(ballPxX / SUBPX);
int16_t py = static_cast<int16_t>(ballPxY / SUBPX);
// Side walls.
if (px <= 0) {
ballPxX = 0;
px = 0;
ballVx = -ballVx;
} else if (px >= BOARD_W - 1) {
ballPxX = static_cast<int32_t>(BOARD_W - 1) * SUBPX;
px = BOARD_W - 1;
ballVx = -ballVx;
}
// Top wall.
if (py <= 0) {
ballPxY = 0;
py = 0;
ballVy = -ballVy;
}
// Bricks: at most one brick per step (single, blocky reflection off the bottom/top face).
if (py >= BRICK_TOP && py < BRICK_TOP + BRICK_ROWS * BRICK_H) {
const int r = (py - BRICK_TOP) / BRICK_H;
const int c = px / BRICK_W;
if (r >= 0 && r < BRICK_ROWS && c >= 0 && c < BRICK_COLS && bricks[r][c]) {
bricks[r][c] = 0;
bricksLeft--;
points += POINTS_PER_BRICK;
ballVy = -ballVy;
if (bricksLeft == 0) {
nextLevel();
return true;
}
}
}
// Paddle: bounce up and steer horizontally based on where the ball struck.
if (ballVy > 0 && py >= PADDLE_Y - 1 && py <= PADDLE_Y + PADDLE_H) {
if (px >= paddleLeft && px < paddleLeft + PADDLE_W) {
ballPxY = static_cast<int32_t>(PADDLE_Y - 1) * SUBPX;
ballVy = -BALL_VY;
// Six zones across the paddle map to increasing outward angles; no zone is vertical.
static const int16_t vxByZone[6] = {-48, -28, -8, 8, 28, 48};
int zone = ((px - paddleLeft) * 6) / PADDLE_W;
if (zone < 0)
zone = 0;
else if (zone > 5)
zone = 5;
ballVx = vxByZone[zone];
}
}
// Ball lost past the bottom edge.
if (py >= BOARD_H) {
if (livesLeft > 0)
livesLeft--;
if (livesLeft == 0) {
alive = false;
return false;
}
serveBall();
}
return alive;
}
// ===========================================================================
// Breakout adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
#if ARCH_PORTDUINO && defined(__linux__)
#include "input/LinuxJoystick.h"
#endif
// Paddle pixels moved per tick while a joystick direction is held (continuous polling path).
static constexpr int16_t PADDLE_POLL_STEP = 3;
#if GRAPHICS_TFT_COLORING_ENABLED
// Classic Breakout brick-wall colours, top row to bottom.
static uint16_t brickRowColor(uint8_t row)
{
using namespace graphics;
switch (row) {
case 0:
return TFTPalette::Red;
case 1:
return TFTPalette::Orange;
case 2:
return TFTPalette::Yellow;
case 3:
return TFTPalette::Green;
default:
return TFTPalette::Cyan;
}
}
#endif
Breakout::Breakout()
{
scores_.load();
}
int32_t Breakout::tickIntervalMs() const
{
// Tick at twice the ball's cadence: the ball advances every other step() (BreakoutGame::step),
// so halving the interval keeps the ball speed the same while the paddle is polled/redrawn twice
// as often. Speed ramps with level: ~22 ms base, floor 10 ms.
int32_t iv = 45 - static_cast<int32_t>(game.level() - 1) * 5;
if (iv < 20)
iv = 20;
return iv / 2;
}
bool Breakout::tick()
{
#if ARCH_PORTDUINO && defined(__linux__)
// Poll the joystick's held direction directly so the paddle glides continuously instead of
// creeping along at the D-pad's slow auto-repeat rate.
if (aLinuxJoystick) {
const int held = aLinuxJoystick->heldXZone();
if (held < 0)
game.movePaddle(-PADDLE_POLL_STEP);
else if (held > 0)
game.movePaddle(PADDLE_POLL_STEP);
}
#endif
return game.step();
}
void Breakout::handleInput(input_broker_event ev)
{
#if ARCH_PORTDUINO && defined(__linux__)
// When a joystick is present the paddle is polled continuously in tick(); ignore the discrete
// (and slow) repeat events so we don't double-move.
if (aLinuxJoystick)
return;
#endif
switch (ev) {
case INPUT_BROKER_LEFT:
game.moveLeft();
break;
case INPUT_BROKER_RIGHT:
game.moveRight();
break;
default:
break;
}
}
void Breakout::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "B R E A K O U T");
const int16_t logoX = x + (w - breakout_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, breakout_width, breakout_height, breakout);
#if GRAPHICS_TFT_COLORING_ENABLED
// The glyph is three brick courses, a ball, and a paddle -- colour each to match the game.
const uint16_t abg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(logoX, logoY + 1, breakout_width, 2, graphics::TFTPalette::Red, abg);
graphics::registerTFTColorRegionDirect(logoX, logoY + 4, breakout_width, 2, graphics::TFTPalette::Yellow, abg);
graphics::registerTFTColorRegionDirect(logoX, logoY + 7, breakout_width, 2, graphics::TFTPalette::Green, abg);
graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 14, 8, 2, graphics::TFTPalette::Blue, abg); // paddle
graphics::registerTFTColorRegionDirect(logoX + 7, logoY + 10, 2, 2, graphics::TFTPalette::White, abg); // ball
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Score row (top-left), remaining lives as small squares (top-right).
char buf[16];
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Sc %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y, buf);
for (uint8_t i = 0; i < game.lives(); i++)
display->fillRect(x + display->getWidth() - 3 - i * 4, y + 2, 2, 2);
// Bricks.
for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
for (uint8_t c = 0; c < BreakoutGame::BRICK_COLS; c++)
if (game.brickAt(r, c))
display->fillRect(x + c * BreakoutGame::BRICK_W, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H,
BreakoutGame::BRICK_W - 1, BreakoutGame::BRICK_H - 1);
// Paddle.
display->fillRect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, BreakoutGame::PADDLE_H);
// Ball.
display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2);
#if GRAPHICS_TFT_COLORING_ENABLED
// Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's
// lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register
// last so the ball always wins where it overlaps.
const uint16_t bg = graphics::getThemeBodyBg();
for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
graphics::registerTFTColorRegionDirect(x, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, BreakoutGame::BOARD_W,
BreakoutGame::BRICK_H - 1, brickRowColor(r), bg);
graphics::registerTFTColorRegionDirect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W,
BreakoutGame::PADDLE_H, graphics::TFTPalette::Blue, bg);
graphics::registerTFTColorRegionDirect(x + game.ballX(), y + game.ballY(), 2, 2, graphics::TFTPalette::White, bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Breakout game logic.
*
* No Arduino/display dependencies - designed to be unit-tested natively (see test/test_breakout)
* and reused by the Breakout adapter below without pulling in the display stack.
*
* The board is a fixed BOARD_W x BOARD_H pixel field. A grid of bricks sits near the top, a paddle
* slides along the bottom, and a ball bounces between them. Ball position/velocity are tracked in
* fixed-point sub-pixels (SUBPX per pixel) so movement is smooth and deterministic; everything is
* integer math and statically sized (no heap).
*/
class BreakoutGame
{
public:
// Playfield, in pixels (a standard 128x64 OLED in landscape).
static constexpr int16_t BOARD_W = 128;
static constexpr int16_t BOARD_H = 64;
// Brick grid.
static constexpr uint8_t BRICK_COLS = 8;
static constexpr uint8_t BRICK_ROWS = 5;
static constexpr int16_t BRICK_W = BOARD_W / BRICK_COLS; // 16
static constexpr int16_t BRICK_H = 4;
static constexpr int16_t BRICK_TOP = 12; // top of the first course; leaves room for the score row
// Paddle.
static constexpr int16_t PADDLE_W = 24;
static constexpr int16_t PADDLE_H = 2;
static constexpr int16_t PADDLE_Y = BOARD_H - 3; // top edge of the paddle
static constexpr int16_t PADDLE_STEP = 6; // pixels moved per input event
static constexpr uint8_t START_LIVES = 3;
static constexpr uint8_t POINTS_PER_BRICK = 10;
/** (Re)start a full game: rebuild bricks, reset lives/score, serve the ball. `seed` drives the
* xorshift32 RNG used for the initial serve direction. */
void reset(uint32_t seed);
/** Advance the simulation by one tick (move the ball, resolve collisions). Returns true if the
* game is still in progress, false once the last life is lost. No-op returning false once dead. */
bool step();
/** Slide the paddle; the ball is unaffected. moveLeft/moveRight use the default per-press step;
* movePaddle takes an explicit signed pixel delta (used when polling a held joystick). */
void movePaddle(int16_t dxPx);
void moveLeft();
void moveRight();
bool isPlaying() const { return alive; }
uint32_t score() const { return points; }
uint8_t lives() const { return livesLeft; }
uint8_t level() const { return levelNum; }
uint16_t bricksRemaining() const { return bricksLeft; }
// --- Rendering accessors (all in board pixels) ---
int16_t paddleX() const { return paddleLeft; }
int16_t ballX() const { return static_cast<int16_t>(ballPxX / SUBPX); }
int16_t ballY() const { return static_cast<int16_t>(ballPxY / SUBPX); }
bool brickAt(uint8_t row, uint8_t col) const { return row < BRICK_ROWS && col < BRICK_COLS && bricks[row][col]; }
private:
static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
static constexpr int32_t BALL_VY = 40; // vertical ball speed (sub-pixels/step)
static constexpr int16_t BALL_SIZE = 2; // ball is drawn BALL_SIZE x BALL_SIZE
void buildBricks();
void serveBall();
void nextLevel();
uint32_t nextRandom();
uint8_t bricks[BRICK_ROWS][BRICK_COLS] = {0}; // 0 == cleared, 1 == present
uint16_t bricksLeft = 0;
int16_t paddleLeft = 0; // left edge of the paddle, in pixels
int32_t ballPxX = 0, ballPxY = 0; // ball centre, in sub-pixels
int32_t ballVx = 0, ballVy = 0; // ball velocity, in sub-pixels/step
uint32_t points = 0;
uint8_t livesLeft = 0;
uint8_t levelNum = 1;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
bool ballTick = false; // ball advances on every other step() (see step())
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Breakout as a hosted Game. Wraps the pure BreakoutGame logic above and supplies the attract art,
* the playfield renderer, the paddle input, the level-based speed curve, and its own local
* high-score table. No mesh protocol (scores are local-only).
*/
class Breakout : public Game
{
public:
Breakout();
const char *name() const override { return "Breakout"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override; // polls a held joystick for the paddle, then advances the ball
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record. New file (magic 'BRKT', version 1); layout mirrors Snake's.
struct BreakoutEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
BreakoutGame game;
HighScoreTable<BreakoutEntry> scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+303
View File
@@ -0,0 +1,303 @@
#include "ChirpyRunner.h"
// ===========================================================================
// Pure ChirpyRunnerGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
uint32_t ChirpyRunnerGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
int16_t ChirpyRunnerGame::pickGapSteps()
{
return static_cast<int16_t>(GAP_STEPS_MIN + static_cast<int16_t>(nextRandom() % (GAP_STEPS_MAX - GAP_STEPS_MIN + 1)));
}
void ChirpyRunnerGame::resetClouds()
{
// Spread the clouds across the sky at staggered x, at varied heights near the top.
for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
cloud[i].xSub = static_cast<int32_t>(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX;
cloud[i].y = static_cast<int16_t>(10 + nextRandom() % 10u); // 10..19 (below the score row)
}
}
void ChirpyRunnerGame::scrollClouds()
{
// Slow parallax drift; wrap back to the right (at a fresh height) once off the left edge.
for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
cloud[i].xSub -= CLOUD_SPEED_SUB;
if (cloud[i].xSub / SUBPX + CLOUD_W < 0) {
cloud[i].xSub = static_cast<int32_t>(BOARD_W + nextRandom() % 24u) * SUBPX;
cloud[i].y = static_cast<int16_t>(10 + nextRandom() % 10u);
}
}
}
void ChirpyRunnerGame::spawnObstacle()
{
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (obst[i].active)
continue;
obst[i].active = true;
obst[i].scored = false;
obst[i].xSub = static_cast<int32_t>(BOARD_W) * SUBPX;
obst[i].w = OBST_W;
// Three height tiers so timing varies (kept clearable with margin for a forgiving jump).
const uint32_t tier = nextRandom() % 3u;
obst[i].h = BUILDING_HEIGHTS[tier];
obst[i].colorIdx = static_cast<uint8_t>((spawnCount / 10u) % OBST_COLOR_COUNT);
spawnCount++;
return;
}
}
void ChirpyRunnerGame::reset(uint32_t seed)
{
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
points = 0;
alive = true;
chirpyTop = groundedTopSub();
vy = 0;
jumpGravity = GRAVITY;
grounded = true;
for (uint8_t i = 0; i < MAX_OBSTACLES; i++)
obst[i] = {};
speedSub = SPEED_BASE;
spawnTimer = 0; // first obstacle spawns on the first step
spawnCount = 0;
resetClouds();
}
int32_t ChirpyRunnerGame::jumpScaleQ8() const
{
// ratio = current scroll speed / base scroll speed, in Q8 (256 == 1.0).
const int32_t ratioQ8 = (speedSub * 256) / SPEED_BASE;
// Feed only JUMP_SPEEDUP_PCT of the speed increase into the jump scale: k = 1 + (ratio-1)*pct.
const int32_t kQ8 = 256 + ((ratioQ8 - 256) * JUMP_SPEEDUP_PCT) / 100;
return kQ8 < 256 ? 256 : kQ8; // never slower than the base hop
}
void ChirpyRunnerGame::jump()
{
if (!alive || !grounded)
return;
// Scale velocity by k and gravity by k*k: the apex height is unchanged (Chirpy still clears the
// same buildings) but the air-time shrinks by k, so the clearing window tightens with the speed.
const int32_t kQ8 = jumpScaleQ8();
vy = -(JUMP_V * kQ8) / 256;
jumpGravity = (GRAVITY * kQ8 * kQ8) / (256 * 256);
if (jumpGravity < GRAVITY)
jumpGravity = GRAVITY;
grounded = false;
}
bool ChirpyRunnerGame::step()
{
if (!alive)
return false;
scrollClouds(); // decorative background parallax
// --- Chirpy vertical motion (gravity latched at launch, so the arc stays consistent mid-air) ---
vy += jumpGravity;
chirpyTop += vy;
const int32_t gt = groundedTopSub();
if (chirpyTop >= gt) {
chirpyTop = gt;
vy = 0;
grounded = true;
} else {
grounded = false;
}
// --- Scroll obstacles, score, retire off-screen ones ---
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (!obst[i].active)
continue;
obst[i].xSub -= speedSub;
const int16_t ox = obstacleX(i);
if (!obst[i].scored && ox + obst[i].w < CHIRPY_X) {
obst[i].scored = true;
points++;
}
if (ox + obst[i].w < 0)
obst[i].active = false;
}
// --- Spawn on a tick timer (time-based spacing that scales with speed) ---
if (spawnTimer > 0)
spawnTimer--;
if (spawnTimer <= 0) {
spawnObstacle();
spawnTimer = pickGapSteps();
}
// --- Difficulty ramp (scroll speed grows with score, then caps) ---
const uint32_t capped = points < SPEED_CAP_PTS ? points : SPEED_CAP_PTS;
speedSub = SPEED_BASE + static_cast<int32_t>(capped) * SPEED_INC;
// --- Collision (forgiving hitbox: skip the antenna, inset the sides) ---
const int16_t hx = CHIRPY_X + 2;
const int16_t hxr = hx + (CHIRPY_W - 4);
const int16_t hBottom = chirpyY() + CHIRPY_H;
const int16_t hTop = chirpyY() + 4;
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (!obst[i].active)
continue;
const int16_t ox = obstacleX(i);
const int16_t oxr = ox + obst[i].w;
const int16_t oTop = GROUND_Y - obst[i].h;
if (hx < oxr && hxr > ox && hTop < GROUND_Y && hBottom > oTop) {
alive = false;
return false;
}
}
return alive;
}
// ===========================================================================
// ChirpyRunner adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
ChirpyRunner::ChirpyRunner()
{
scores_.load();
}
void ChirpyRunner::handleInput(input_broker_event ev)
{
// SELECT is the jump (as requested); UP is accepted as a convenient alternate.
if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP)
game.jump();
}
void ChirpyRunner::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "CHIRPY DASH");
const int16_t logoX = x + (w - chirpy_run_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, chirpy_run_width, chirpy_run_height, chirpy_run);
#if GRAPHICS_TFT_COLORING_ENABLED
// Chirpy is green, with white eyes. The eyes are the lit pixels at rows 5-7, cols 4-7 of the
// glyph; a white region registered after the green one wins there.
graphics::registerTFTColorRegionDirect(logoX, logoY, chirpy_run_width, chirpy_run_height,
graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg());
graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
#if GRAPHICS_TFT_COLORING_ENABLED
// Obstacle colour palette; the game logic advances the index every 10 spawns.
static uint16_t obstacleColor(uint8_t idx)
{
using namespace graphics;
switch (idx) {
case 0:
return TFTPalette::Red;
case 1:
return TFTPalette::Orange;
case 2:
return TFTPalette::Yellow;
case 3:
return TFTPalette::Magenta;
case 4:
return TFTPalette::Cyan;
default:
return TFTPalette::Blue;
}
}
#endif
void ChirpyRunner::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Clouds drifting in the background (drawn first so everything else sits in front).
for (uint8_t i = 0; i < ChirpyRunnerGame::cloudSlots(); i++) {
const int16_t cxp = x + game.cloudX(i);
const int16_t cyp = y + game.cloudY(i);
display->fillRect(cxp + 2, cyp, 4, 1);
display->fillRect(cxp + 1, cyp + 1, 6, 1);
display->fillRect(cxp, cyp + 2, 8, 1);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(cxp, cyp, 8, 3, graphics::TFTPalette::LightGray, graphics::getThemeBodyBg());
#endif
}
// Score (top-left).
char buf[16];
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Sc %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y, buf);
// Ground line.
display->drawLine(x, y + ChirpyRunnerGame::GROUND_Y, x + display->getWidth() - 1, y + ChirpyRunnerGame::GROUND_Y);
// Obstacles drawn as little buildings: a solid tower with two columns of punched-out windows
// (dark holes). On colour displays the walls cycle colour every 10 spawns and the windows glow
// (they are the region's off-pixels, so they take the off-colour).
for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) {
if (!game.obstacleActive(i))
continue;
const int16_t oh = game.obstacleH(i);
const int16_t ow = game.obstacleW(i);
const int16_t oxp = x + game.obstacleX(i);
const int16_t oyp = y + ChirpyRunnerGame::GROUND_Y - oh;
display->setColor(WHITE);
display->fillRect(oxp, oyp, ow, oh);
// Windows: 1px holes in the left and right columns, every other row, skipping the roof row
// and the ground-floor rows so the tower reads as a building.
display->setColor(BLACK);
for (int16_t wy = oyp + 2; wy <= oyp + oh - 3; wy += 2) {
display->fillRect(oxp + 1, wy, 1, 1);
display->fillRect(oxp + ow - 2, wy, 1, 1);
}
display->setColor(WHITE);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(oxp, oyp, ow, oh, obstacleColor(game.obstacleColorIndex(i)),
graphics::TFTPalette::White); // lit windows
#endif
}
// Chirpy.
const int16_t cxp = x + ChirpyRunnerGame::CHIRPY_X;
const int16_t cyp = y + game.chirpyY();
display->drawXbm(cxp, cyp, chirpy_run_width, chirpy_run_height, chirpy_run);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(cxp, cyp, chirpy_run_width, chirpy_run_height, graphics::TFTPalette::MeshtasticGreen,
graphics::getThemeBodyBg());
graphics::registerTFTColorRegionDirect(cxp + 4, cyp + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+177
View File
@@ -0,0 +1,177 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Chirpy Runner game logic (a dino-runner / flappy-style side-scroller).
*
* No Arduino/display dependencies - designed to be unit-tested natively (see test/test_chirpy)
* and reused by the ChirpyRunner adapter below without pulling in the display stack.
*
* Chirpy runs in place at a fixed x on the ground; obstacles scroll in from the right and the
* player presses jump (SELECT) to hop over them. Gravity pulls Chirpy back down. Clearing an
* obstacle scores a point and nudges the scroll speed up. A collision ends the run. Chirpy's
* vertical motion is tracked in fixed-point sub-pixels (SUBPX per pixel); everything is integer
* math and statically sized (no heap).
*/
class ChirpyRunnerGame
{
public:
// Playfield, in pixels (a standard 128x64 OLED in landscape).
static constexpr int16_t BOARD_W = 128;
static constexpr int16_t BOARD_H = 64;
// Chirpy sprite box and fixed horizontal position; GROUND_Y is where his feet rest.
static constexpr int16_t CHIRPY_W = 12;
static constexpr int16_t CHIRPY_H = 16;
static constexpr int16_t CHIRPY_X = 14;
static constexpr int16_t GROUND_Y = BOARD_H - 2; // 62
static constexpr uint8_t MAX_OBSTACLES = 4;
static constexpr int16_t OBST_W = 6;
static constexpr uint8_t OBST_COLOR_COUNT = 6; // obstacle colour advances every 10 spawns
static constexpr uint8_t CLOUD_COUNT = 3; // decorative background clouds
/** (Re)start a run: Chirpy on the ground, no obstacles yet, score 0. `seed` drives the
* xorshift32 RNG used for obstacle heights and spacing. */
void reset(uint32_t seed);
/** Advance one tick (gravity + scroll + spawn + collision). Returns true while the run is in
* progress, false once Chirpy hits an obstacle. No-op returning false once dead. */
bool step();
/** Hop, if Chirpy is on the ground (single jump; ignored while airborne). */
void jump();
bool isPlaying() const { return alive; }
uint32_t score() const { return points; }
bool onGround() const { return grounded; }
// --- Rendering accessors (board pixels) ---
int16_t chirpyY() const { return static_cast<int16_t>(chirpyTop / SUBPX); } // sprite top
static constexpr uint8_t obstacleSlots() { return MAX_OBSTACLES; }
bool obstacleActive(uint8_t i) const { return i < MAX_OBSTACLES && obst[i].active; }
int16_t obstacleX(uint8_t i) const { return static_cast<int16_t>(obst[i].xSub / SUBPX); }
uint8_t obstacleW(uint8_t i) const { return obst[i].w; }
uint8_t obstacleH(uint8_t i) const { return obst[i].h; }
uint8_t obstacleColorIndex(uint8_t i) const { return obst[i].colorIdx; }
static constexpr uint8_t cloudSlots() { return CLOUD_COUNT; }
int16_t cloudX(uint8_t i) const { return static_cast<int16_t>(cloud[i].xSub / SUBPX); }
int16_t cloudY(uint8_t i) const { return cloud[i].y; }
private:
static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
static constexpr int32_t GRAVITY = 7; // downward accel (sub-px/step^2)
static constexpr int32_t JUMP_V = 75; // initial upward velocity on a hop (sub-px/step)
static constexpr int32_t SPEED_BASE = 32; // base scroll speed (sub-px/step == 2 px)
static constexpr int32_t SPEED_INC = 2; // speed added per point scored
static constexpr uint32_t SPEED_CAP_PTS = 40; // score at which the speed ramp caps (== 5 px/step)
// Obstacle spacing is measured in TICKS, not pixels, so the time between obstacles stays
// constant as the scroll speed ramps up -- otherwise a fixed pixel gap collapses into fewer
// ticks at high speed until it drops below the jump duration and clearing becomes impossible.
// The min is kept just above the ~22-tick jump so obstacles come in a tight but landable rhythm.
static constexpr int16_t GAP_STEPS_MIN = 23; // min ticks between spawns (> jump duration)
static constexpr int16_t GAP_STEPS_MAX = 40; // max ticks between spawns
// Chirpy's hop scales with the scroll speed. A fixed-length jump actually makes the game EASIER
// as the buildings speed up: his "high enough to clear" window stays ~constant while the time a
// building spends crossing his x-range shrinks (width / speed). Scaling the launch velocity by k
// and gravity by k*k keeps the apex height identical (so he still clears the same buildings)
// while cutting the air-time by k, which shrinks the clearing window in step with the world.
// JUMP_SPEEDUP_PCT is how much of the scroll-speed increase feeds into k:
// 100 == fully proportional (difficulty stays flat), lower == Chirpy speeds up sub-linearly
// (so it still eases off slightly at high speed), higher == it gets harder.
static constexpr int32_t JUMP_SPEEDUP_PCT = 50;
static constexpr int8_t BUILDING_HEIGHTS[] = {8, 12, 16}; // three tiers of obstacle heights (pixels)
static constexpr int32_t CLOUD_SPEED_SUB = 6; // background scroll speed (sub-px/step, slow parallax)
static constexpr int16_t CLOUD_W = 8; // cloud puff width (for off-screen wrap)
struct Obstacle {
int32_t xSub; // left edge, sub-pixels
uint8_t w;
uint8_t h;
uint8_t colorIdx; // which colour batch (advances every 10 spawns)
bool active;
bool scored;
};
struct Cloud {
int32_t xSub; // left edge, sub-pixels
int16_t y; // top, pixels
};
int32_t groundedTopSub() const { return static_cast<int32_t>(GROUND_Y - CHIRPY_H) * SUBPX; }
// Jump scale factor k for the current scroll speed, in Q8 fixed point (256 == 1.0 == base speed).
int32_t jumpScaleQ8() const;
uint32_t nextRandom();
void spawnObstacle();
int16_t pickGapSteps();
void resetClouds();
void scrollClouds();
int32_t chirpyTop = 0; // sprite-top y, sub-pixels
int32_t vy = 0; // vertical velocity, sub-pixels/step
int32_t jumpGravity = GRAVITY; // gravity for the current hop (latched at launch, scaled by k*k)
bool grounded = true;
Obstacle obst[MAX_OBSTACLES] = {};
Cloud cloud[CLOUD_COUNT] = {};
int32_t speedSub = SPEED_BASE;
int16_t spawnTimer = 0; // ticks until the next obstacle spawns
uint32_t spawnCount = 0; // total obstacles spawned (drives the colour cycle)
uint32_t points = 0;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Chirpy Runner as a hosted Game. Wraps the pure logic above and supplies the attract art, the
* side-scroller renderer (Chirpy sprite + obstacles + ground), the jump input, and its own
* local high-score table. No mesh protocol (scores are local-only).
*/
class ChirpyRunner : public Game
{
public:
ChirpyRunner();
const char *name() const override { return "Chirpy Dash"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record. New file (magic 'CHRP', version 1); layout mirrors Snake's.
struct ChirpyEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
ChirpyRunnerGame game;
HighScoreTable<ChirpyEntry> scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "HighScoreTable.h"
#include "input/InputBroker.h" // input_broker_event
#include "mesh/MeshModule.h" // ProcessMessage, meshtastic_MeshPacket
#include <cstdint>
class OLEDDisplay;
class OLEDDisplayUiState;
class GamesModule;
/**
* A single game hosted by GamesModule. The host owns the shared UI state machine (attract /
* playing / paused / game-over / high-scores), the initials picker, high-score persistence calls,
* the tick thread, and the mesh port; each Game supplies only the game-specific pieces: its
* attract art, its playfield, its per-key input while playing, its speed curve, and its own
* high-score table (and, optionally, a mesh announce/receive protocol).
*/
class Game
{
public:
virtual ~Game() = default;
virtual const char *name() const = 0;
// --- Lifecycle ---
virtual void start(uint32_t seed) = 0; // (re)start the underlying game logic
virtual bool tick() = 0; // advance one step; returns isPlaying() afterwards
virtual bool isPlaying() const = 0;
virtual uint32_t score() const = 0;
virtual int32_t tickIntervalMs() const = 0; // per-game speed curve
// --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) ---
virtual void handleInput(input_broker_event ev) = 0;
// --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) ---
virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint
virtual void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) = 0; // playfield only
virtual const char *gameOverHint() const { return "SELECT: scores"; }
// --- High scores (the host runs the initials picker + save) ---
virtual HighScoreTableBase &scores() = 0;
// --- Mesh (defaults are no-ops; only games with a wire protocol override these) ---
// Note: the new-high-score announcement is NOT here -- it is shared by every game and lives in
// GamesModule (which splices name() into one common message).
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) { return ProcessMessage::CONTINUE; }
virtual bool wantsPeriodicMesh() const { return false; }
// Perform any due periodic broadcast and return ms until the next one (-1 == nothing pending).
virtual int32_t meshTick(GamesModule &host) { return -1; }
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+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
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "Observer.h"
#include "concurrency/OSThread.h"
#include "input/InputBroker.h"
#include "mesh/SinglePortModule.h"
#include <vector>
// Broadcasting a new all-time #1 to the mesh is a COMPILE-TIME option, default OFF. Spending shared
// airtime must be opted into at build time with -DGAMES_ANNOUNCE_HIGH_SCORE=1; when disabled (the
// default) the announcement code is compiled out entirely -- there is no runtime toggle. The
// announcement is shared by every hosted game, with the game's name() spliced into the message.
#ifndef GAMES_ANNOUNCE_HIGH_SCORE
#define GAMES_ANNOUNCE_HIGH_SCORE 0
#endif
#ifndef GAMES_HIGH_SCORE_STRING
#define GAMES_HIGH_SCORE_STRING "New %s high score %lu by %s!"
#endif
enum GamesUiState : uint8_t {
GAMES_IDLE, // attract screen of the selected game; OSThread idle (unless a game broadcasts)
GAMES_PLAYING, // active game running; tick thread ticking
GAMES_PAUSED, // paused mid-game
GAMES_GAMEOVER, // final score / new-high notice
GAMES_HISCORES, // top-5 table of the active/selected game
};
/**
* The single host for all BaseUI games. It owns the always-present "games" frame (drawn through
* Screen's trampoline right after home), the shared UI state machine, the game-tick OSThread, the
* initials picker + high-score persistence flow, and the PRIVATE_APP mesh port. Individual games
* are self-contained Game subclasses registered in the constructor (see src/modules/games/); the
* attract screen cycles between them with UP/DOWN and SELECT plays the shown game.
*/
class GamesModule : public SinglePortModule, public Observable<const UIFrameEvent *>, private concurrency::OSThread
{
public:
GamesModule();
/// Start the currently-selected game (invoked when SELECT is pressed on the games frame). The
/// games frame is already current, so this just begins play.
void launchGame();
// Drawn through the games-frame trampoline, and queried by Screen's input gating / nav-bar, so
// these are public. While a game is active we own the D-pad; on the attract screen the D-pad
// cycles games (UP/DOWN) and otherwise navigates between frames as usual.
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
virtual bool interceptingKeyboardInput() override { return uiState != GAMES_IDLE; }
/// Mesh passthrough for hosted games (a Game is not itself a MeshModule).
meshtastic_MeshPacket *gameAllocDataPacket() { return allocDataPacket(); }
protected:
virtual int32_t runOnce() override; // game tick + idle mesh scheduling
virtual Observable<const UIFrameEvent *> *getUIFrameObservable() override { return this; }
virtual bool wantUIFrame() override { return false; } // shares the games frame; no own slot
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
private:
int handleInputEvent(const InputEvent *event);
CallbackObserver<GamesModule, const InputEvent *> inputObserver =
CallbackObserver<GamesModule, const InputEvent *>(this, &GamesModule::handleInputEvent);
// === State transitions ===
void startPlaying();
void enterGameOver();
void exitToIdle();
void requestRedraw();
void kickTick();
// === Shared game-over / high-score flow ===
void promptForInitials();
void recordHighScore(const char *initials);
#if GAMES_ANNOUNCE_HIGH_SCORE
// Broadcast a new all-time #1 as a plain text message, shared by every game (name() spliced in).
void announceHighScore(const char *initials, uint32_t score);
#endif
// === Shared rendering ===
void drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count);
void drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores);
std::vector<Game *> games;
uint8_t selected = 0; // attract-screen cursor (index into games)
Game *active = nullptr; // game currently playing / whose scores are shown; null when idle
GamesUiState uiState = GAMES_IDLE;
uint32_t lastScore = 0; // score of the just-finished game (for the GAME OVER screen)
int lastRank = -1; // rank achieved last game (-1 == didn't place)
bool lastWasNewTop = false; // last game set a new all-time #1
uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs
};
extern GamesModule *gamesModule;
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+176
View File
@@ -0,0 +1,176 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "DebugConfiguration.h"
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "concurrency/LockGuard.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h" // owner (short-name fallback)
#include <ErriezCRC32.h>
#include <cstddef>
#include <cstdint>
#include <cstring>
/**
* Non-templated view of a game's top-N high-score table, so the shared games UI (attract line,
* high-score screen) and the Game interface can talk to any game's table without knowing its
* on-disk record layout.
*/
class HighScoreTableBase
{
public:
static constexpr uint8_t HS_COUNT = 5; // entries kept per game
static constexpr uint8_t INITIALS_LEN = 3; // arcade-style initials captured per high score
virtual ~HighScoreTableBase() = default;
virtual uint32_t scoreAt(uint8_t i) const = 0;
virtual const char *nameAt(uint8_t i) const = 0;
// True if `score` would place on the sorted-descending table (peek; no mutation).
virtual bool qualifies(uint32_t score) const = 0;
// Insert under `initials` with the given `nodeNum` (0 == local, or a foreign node for merged
// remote scores). Returns the 0-based rank if it placed, else -1; isNewTop set on the #1 slot.
virtual int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) = 0;
virtual void clear() = 0;
virtual void load() = 0;
virtual void save() = 0;
virtual bool loaded() const = 0;
};
/**
* Templated top-N table shared by every game. The algorithm (qualify / insert / load / save / CRC)
* is identical across games; only the on-disk record layout differs, so `Entry` is a template
* parameter. Each game supplies its own packed `Entry` (with `score`, `shortName[5]`, `nodeNum`,
* `epoch` fields, in whatever byte order its existing save file used) plus a file path, magic, and
* version -- so pre-existing save files keep loading byte-for-byte after the refactor.
*/
template <typename Entry> class HighScoreTable : public HighScoreTableBase
{
public:
HighScoreTable(const char *path, uint32_t magic, uint8_t version, const char *logTag)
: path_(path), magic_(magic), version_(version), logTag_(logTag)
{
}
uint32_t scoreAt(uint8_t i) const override { return entries_[i].score; }
const char *nameAt(uint8_t i) const override { return entries_[i].shortName; }
const Entry &entryAt(uint8_t i) const { return entries_[i]; }
bool qualifies(uint32_t score) const override
{
if (score == 0)
return false;
for (int i = 0; i < HS_COUNT; i++) {
if (score > entries_[i].score)
return true;
}
return false;
}
int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) override
{
isNewTop = false;
if (score == 0)
return -1;
int pos = -1;
for (int i = 0; i < HS_COUNT; i++) {
if (score > entries_[i].score) {
pos = i;
break;
}
}
if (pos < 0)
return -1; // not good enough to place
for (int i = HS_COUNT - 1; i > pos; i--)
entries_[i] = entries_[i - 1];
Entry &e = entries_[pos];
memset(&e, 0, sizeof(e));
e.score = score;
e.nodeNum = nodeNum;
strncpy(e.shortName, (initials && initials[0]) ? initials : owner.short_name, sizeof(e.shortName) - 1);
e.shortName[sizeof(e.shortName) - 1] = '\0';
e.epoch = getValidTime(RTCQualityDevice, false); // 0 when no valid RTC
isNewTop = (pos == 0);
return pos;
}
void clear() override { memset(entries_, 0, sizeof(entries_)); }
bool loaded() const override { return loaded_; }
void load() override
{
loaded_ = true;
memset(entries_, 0, sizeof(entries_));
#ifdef FSCom
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(path_, FILE_O_READ);
if (!f)
return;
File file;
const bool readOk = (f.read(reinterpret_cast<uint8_t *>(&file), sizeof(file)) == sizeof(file));
f.close();
if (!readOk || file.magic != magic_ || file.version != version_) {
LOG_DEBUG("%s: no valid high-score file, starting fresh", logTag_);
return;
}
if (crc32Buffer(&file, offsetof(File, crc)) != file.crc) {
LOG_WARN("%s: high-score CRC mismatch, resetting table", logTag_);
return;
}
memcpy(entries_, file.entries, sizeof(entries_));
LOG_INFO("%s: loaded high scores (top=%lu)", logTag_, static_cast<unsigned long>(entries_[0].score));
#endif
}
void save() override
{
#ifdef FSCom
{
concurrency::LockGuard g(spiLock);
FSCom.mkdir("/prefs");
}
File file;
memset(&file, 0, sizeof(file));
file.magic = magic_;
file.version = version_;
memcpy(file.entries, entries_, sizeof(entries_));
file.crc = crc32Buffer(&file, offsetof(File, crc));
auto sf = SafeFile(path_, true);
const size_t written = sf.write(reinterpret_cast<uint8_t *>(&file), sizeof(file));
if (!sf.close() || written != sizeof(file))
LOG_WARN("%s: failed to save high scores", logTag_);
#endif
}
private:
// On-disk layout. The 8-byte header (magic + version + 3 reserved) matches both the original
// Snake and Tetris files byte-for-byte, so their save files still load unchanged.
struct File {
uint32_t magic;
uint8_t version;
uint8_t reserved[3];
Entry entries[HighScoreTableBase::HS_COUNT];
uint32_t crc; // crc32 over every preceding byte
} __attribute__((packed));
Entry entries_[HS_COUNT] = {};
const char *path_;
uint32_t magic_;
uint8_t version_;
const char *logTag_;
bool loaded_ = false;
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+264
View File
@@ -0,0 +1,264 @@
#include "Snake.h"
// ===========================================================================
// Pure SnakeGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
void SnakeGame::reset(uint32_t seed)
{
memset(occ, 0, sizeof(occ));
len = 0;
points = 0;
alive = true;
won = false;
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
// Spawn a START_LEN snake horizontally in the middle of the board, heading right, with the
// head at the centre and the body trailing to its left.
const uint8_t cx = GRID_W / 2;
const uint8_t cy = GRID_H / 2;
dir = DIR_RIGHT;
pendingDir = DIR_RIGHT;
tailIdx = 0;
for (uint8_t i = 0; i < START_LEN; i++) {
const uint8_t x = static_cast<uint8_t>(cx - (START_LEN - 1) + i);
body[i] = {x, cy};
setOcc(cellIndex(x, cy));
len++;
}
headIdx = static_cast<uint16_t>(START_LEN - 1);
placeFood();
}
bool SnakeGame::isReverse(Direction a, Direction b)
{
return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) ||
(a == DIR_RIGHT && b == DIR_LEFT);
}
bool SnakeGame::setDirection(Direction d)
{
if (isReverse(dir, d))
return false;
pendingDir = d;
return true;
}
uint32_t SnakeGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
bool SnakeGame::placeFood()
{
const uint16_t free = static_cast<uint16_t>(CELL_COUNT - len);
if (free == 0)
return false; // board full -> caller treats as a win
// Pick the k-th free cell (k uniform in [0, free)) via a single linear scan. Deterministic,
// always valid, and cheap for a 384-cell board -- no rejection sampling / near-full special case.
uint16_t k = static_cast<uint16_t>(nextRandom() % free);
for (uint16_t idx = 0; idx < CELL_COUNT; idx++) {
if (!getOcc(idx)) {
if (k == 0) {
foodCell = {static_cast<uint8_t>(idx % GRID_W), static_cast<uint8_t>(idx / GRID_W)};
return true;
}
k--;
}
}
return false; // unreachable while free > 0
}
bool SnakeGame::step()
{
if (!alive)
return false;
dir = pendingDir; // commit the latched heading
const Cell h = body[headIdx];
int16_t nx = h.x;
int16_t ny = h.y;
switch (dir) {
case DIR_UP:
ny--;
break;
case DIR_DOWN:
ny++;
break;
case DIR_LEFT:
nx--;
break;
case DIR_RIGHT:
nx++;
break;
}
// Wall collision (signed math catches the 0-- underflow too).
if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) {
alive = false;
return false;
}
const uint16_t nidx = cellIndex(static_cast<uint8_t>(nx), static_cast<uint8_t>(ny));
const bool eating = (nx == foodCell.x && ny == foodCell.y);
// When not eating the tail vacates this tick, so free it first: moving into the cell the
// tail is leaving is legal (classic snake), moving into any other body cell is fatal.
if (!eating) {
clearOcc(cellIndex(body[tailIdx].x, body[tailIdx].y));
tailIdx = static_cast<uint16_t>((tailIdx + 1) % CAP);
len--;
}
if (getOcc(nidx)) {
alive = false;
return false;
}
// Advance the head into the new cell.
headIdx = static_cast<uint16_t>((headIdx + 1) % CAP);
body[headIdx] = {static_cast<uint8_t>(nx), static_cast<uint8_t>(ny)};
setOcc(nidx);
len++;
if (eating) {
points++;
if (!placeFood()) {
won = true;
alive = false; // board completely filled -- the player won
}
}
return alive;
}
// ===========================================================================
// Snake adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
// --- Pixel layout on a 128x64 OLED -----------------------------------------------------------
// Each cell is CELL_PX square. The GRID_W x GRID_H playfield (128x48) is bottom-aligned, leaving
// a SCORE_H-pixel score bar across the top. On taller displays the board bottom-aligns and
// centres horizontally; screen edges are the walls.
static constexpr int16_t CELL_PX = 4;
static constexpr int16_t SCORE_H = 16;
Snake::Snake()
{
scores_.load();
}
int32_t Snake::tickIntervalMs() const
{
// Speed ramps up as the snake grows: 160 ms base, down to a 70 ms floor.
int32_t iv = 160 - static_cast<int32_t>(game.length()) * 3;
return iv < 70 ? 70 : iv;
}
void Snake::handleInput(input_broker_event ev)
{
switch (ev) {
case INPUT_BROKER_UP:
game.setDirection(SnakeGame::DIR_UP);
break;
case INPUT_BROKER_DOWN:
game.setDirection(SnakeGame::DIR_DOWN);
break;
case INPUT_BROKER_LEFT:
game.setDirection(SnakeGame::DIR_LEFT);
break;
case INPUT_BROKER_RIGHT:
game.setDirection(SnakeGame::DIR_RIGHT);
break;
default:
break;
}
}
void Snake::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "S N A K E");
// Snake glyph, centred below the title.
const int16_t logoX = x + (w - snake_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, snake_width, snake_height, snake);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display, paint the snake green with a red tongue. The forked tongue is the pair
// of pixels poking out past the body on the right edge (~row 6 of the 16x16 glyph); a red
// region registered after the green one wins where they overlap, so the body stays green.
const uint16_t bg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(logoX, logoY, snake_width, snake_height, graphics::TFTPalette::Green, bg);
graphics::registerTFTColorRegionDirect(logoX + 13, logoY + 5, 3, 4, graphics::TFTPalette::Red, bg);
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Snake::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
char buf[24];
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Score bar: current score on the left, best on the right.
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Score %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y + 2, buf);
if (scores_.scoreAt(0) > 0) {
display->setTextAlignment(TEXT_ALIGN_RIGHT);
snprintf(buf, sizeof(buf), "Hi %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(x + display->getWidth() - 2, y + 2, buf);
}
display->drawLine(x, y + SCORE_H - 1, x + display->getWidth() - 1, y + SCORE_H - 1);
// Board is bottom-aligned; centre it horizontally.
const int16_t boardW = SnakeGame::GRID_W * CELL_PX;
const int16_t boardH = SnakeGame::GRID_H * CELL_PX;
const int16_t ox = x + (display->getWidth() - boardW) / 2;
const int16_t oy = y + display->getHeight() - boardH;
for (uint16_t i = 0; i < game.length(); i++) {
SnakeGame::Cell c = game.bodyAt(i);
display->fillRect(ox + c.x * CELL_PX, oy + c.y * CELL_PX, CELL_PX, CELL_PX);
}
// Food: a 2x2 dot centred in its cell.
SnakeGame::Cell f = game.food();
display->fillRect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display, paint the whole snake green, then the apple red on top. One region over
// the board tints every lit body cell green (the snake can be too long for per-cell regions);
// a red region over the food pixel wins where it overlaps.
const uint16_t bg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(ox, oy, boardW, boardH, graphics::TFTPalette::MeshtasticGreen, bg);
graphics::registerTFTColorRegionDirect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2, graphics::TFTPalette::Red, bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+152
View File
@@ -0,0 +1,152 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Snake game logic.
*
* Deliberately free of Arduino / Screen / heap dependencies so it can be unit-tested natively
* (see test/test_snake) and reused by the Snake adapter below without pulling in the display stack.
*
* The board is a fixed GRID_W x GRID_H grid of cells. The snake body lives in a ring buffer
* sized to the whole board, plus an occupancy bitmap for O(1) collision and food-placement
* checks. No dynamic allocation: total state is ~1 KB and statically sized.
*/
class SnakeGame
{
public:
// Playfield dimensions in cells. Chosen so that at CELL_PX = 4 the board is 128x48, leaving
// a 16 px score bar at the top of a 128x64 OLED (see Snake.cpp for the pixel layout).
static constexpr uint8_t GRID_W = 32;
static constexpr uint8_t GRID_H = 12;
static constexpr uint16_t CELL_COUNT = static_cast<uint16_t>(GRID_W) * GRID_H; // 384
// Initial snake length at the start of a game.
static constexpr uint8_t START_LEN = 3;
enum Direction : uint8_t { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT };
struct Cell {
uint8_t x;
uint8_t y;
};
/**
* (Re)start a game. The snake spawns horizontally in the middle of the board heading right,
* and the first food is placed. `seed` drives deterministic food placement (xorshift32).
*/
void reset(uint32_t seed);
/**
* Latch a new heading to be applied on the next step(). A 180-degree reversal of the
* currently-committed direction is rejected (returns false) because it would immediately
* run the head into the neck. Comparing against the committed direction (not the pending
* one) means multiple key presses within a single tick can't chain into a reversal.
*/
bool setDirection(Direction d);
/**
* Advance the simulation by one tick. Returns true if the snake is still alive afterwards,
* false if this move ended the game (wall hit, self-collision, or board filled == win).
* Once dead, further step() calls are no-ops returning false.
*/
bool step();
bool isPlaying() const { return alive; }
bool isWon() const { return won; }
uint16_t length() const { return len; }
uint32_t score() const { return points; }
Cell head() const { return body[headIdx]; }
Cell food() const { return foodCell; }
Direction direction() const { return dir; }
/// True if cell (x,y) is currently part of the snake body.
bool occupied(uint8_t x, uint8_t y) const { return getOcc(cellIndex(x, y)); }
/// Iterate the body from tail (i == 0) to head (i == length()-1); used by the renderer.
Cell bodyAt(uint16_t i) const { return body[(tailIdx + i) % CAP]; }
/**
* Test/aid seam: force the next food to a specific cell so unit tests can drive
* deterministic growth. Unused in production. Caller must pass an unoccupied cell.
*/
void placeFoodAt(uint8_t x, uint8_t y) { foodCell = {x, y}; }
private:
static constexpr uint16_t CAP = CELL_COUNT; // ring capacity == board size (len is tracked explicitly)
Cell body[CAP] = {0};
uint16_t headIdx = 0; // ring index of the head (front) cell
uint16_t tailIdx = 0; // ring index of the tail (oldest) cell
uint16_t len = 0; // number of live body cells
uint8_t occ[(CELL_COUNT + 7) / 8] = {0}; // occupancy bitmap, indexed by cellIndex()
Cell foodCell = {0, 0};
Direction dir = DIR_RIGHT;
Direction pendingDir = DIR_RIGHT;
uint32_t points = 0;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
bool won = false;
static uint16_t cellIndex(uint8_t x, uint8_t y) { return static_cast<uint16_t>(y) * GRID_W + x; }
bool getOcc(uint16_t idx) const { return (occ[idx >> 3] >> (idx & 7)) & 1u; }
void setOcc(uint16_t idx) { occ[idx >> 3] |= static_cast<uint8_t>(1u << (idx & 7)); }
void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast<uint8_t>(~(1u << (idx & 7))); }
uint32_t nextRandom();
bool placeFood(); // returns false if the board is full (no free cell -> win)
static bool isReverse(Direction a, Direction b);
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Snake as a hosted Game. Wraps the pure SnakeGame logic above and supplies the attract art, the
* playfield renderer, the direction input, the length-based speed curve, and its own high-score
* table. The new-high-score mesh announcement is shared by all games and lives in GamesModule.
*/
class Snake : public Game
{
public:
Snake();
const char *name() const override { return "Snake"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record; layout preserved from the original SnakeModule so snake.dat keeps
// loading. Magic 'SNEK', file version 1.
struct SnakeEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
SnakeGame game;
HighScoreTable<SnakeEntry> scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+494
View File
@@ -0,0 +1,494 @@
#include "Tetris.h"
// ===========================================================================
// Pure TetrisGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
// ---------------------------------------------------------------------------
// Shape table
//
// SHAPES[type][rot][row] encodes each row of the 4×4 bounding box as a 4-bit
// column bitmask (bit 0 = leftmost column). All seven SRS tetrominoes in their
// four CW rotations.
//
// Type 0 I Type 1 O Type 2 T Type 3 S
// Type 4 Z Type 5 J Type 6 L
// ---------------------------------------------------------------------------
const uint8_t TetrisGame::SHAPES[PIECE_TYPES][4][4] = {
// I ---- rot0: .XXXX rot1: ..X.. rot2: ..... rot3: .X...
{{0, 15, 0, 0}, {4, 4, 4, 4}, {0, 0, 15, 0}, {2, 2, 2, 2}},
// O ---- same all rotations
{{6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}},
// T ----
{{2, 7, 0, 0}, {2, 6, 2, 0}, {0, 7, 2, 0}, {2, 3, 2, 0}},
// S ----
{{6, 3, 0, 0}, {1, 3, 2, 0}, {0, 6, 3, 0}, {2, 6, 4, 0}},
// Z ----
{{3, 6, 0, 0}, {2, 3, 1, 0}, {0, 3, 6, 0}, {4, 6, 2, 0}},
// J ----
{{1, 7, 0, 0}, {6, 2, 2, 0}, {0, 7, 4, 0}, {2, 2, 3, 0}},
// L ----
{{4, 7, 0, 0}, {2, 2, 6, 0}, {0, 7, 1, 0}, {3, 2, 2, 0}},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
bool TetrisGame::pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc)
{
if (type >= PIECE_TYPES || rot >= 4 || pr >= 4 || pc >= 4)
return false;
return (SHAPES[type][rot][pr] >> pc) & 1u;
}
uint32_t TetrisGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
TetrisGame::Piece TetrisGame::spawnPiece(uint8_t type) const
{
// Centre horizontally in the 10-wide board; bounding box starts at col 3.
Piece p;
p.type = type;
p.rot = 0;
p.col = static_cast<int8_t>((BOARD_COLS - 4) / 2); // 3 for a 10-wide board
p.row = 0;
return p;
}
bool TetrisGame::canPlace(const Piece &p) const
{
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!pieceCell(p.type, p.rot, pr, pc))
continue;
const int16_t br = static_cast<int16_t>(p.row) + pr;
const int16_t bc = static_cast<int16_t>(p.col) + pc;
if (br < 0)
continue; // above the board - allowed during spawn
if (br >= BOARD_ROWS || bc < 0 || bc >= BOARD_COLS)
return false;
if (board[br][bc] != 0)
return false;
}
}
return true;
}
void TetrisGame::lockPiece()
{
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int16_t br = static_cast<int16_t>(cur.row) + pr;
const int16_t bc = static_cast<int16_t>(cur.col) + pc;
if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS)
board[br][bc] = static_cast<uint8_t>(cur.type + 1); // colour 1..7
}
}
}
int TetrisGame::clearLines()
{
int cleared = 0;
for (int r = BOARD_ROWS - 1; r >= 0;) {
bool full = true;
for (int c = 0; c < BOARD_COLS && full; c++) {
if (board[r][c] == 0)
full = false;
}
if (full) {
// Shift every row above down by one.
for (int rr = r; rr > 0; rr--)
memcpy(board[rr], board[rr - 1], BOARD_COLS);
memset(board[0], 0, BOARD_COLS);
cleared++;
// Recheck same index - it now contains the row that was above.
} else {
r--;
}
}
return cleared;
}
void TetrisGame::advanceNext()
{
lockPiece();
const int cleared = clearLines();
if (cleared > 0) {
lines += static_cast<uint16_t>(cleared);
// Nintendo-style line-clear scoring (×level).
static const uint16_t LINE_PTS[5] = {0, 100, 300, 500, 800};
pts += LINE_PTS[cleared < 5 ? cleared : 4] * lvl;
// Level up every 10 lines, cap at 20.
const uint8_t newLvl = static_cast<uint8_t>(lines / 10 + 1);
lvl = newLvl > 20 ? 20 : newLvl;
}
// nxt becomes the active piece; generate a fresh nxt.
cur = nxt;
if (!canPlace(cur)) {
alive = false;
return;
}
nxt = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void TetrisGame::reset(uint32_t seed)
{
memset(board, 0, sizeof(board));
pts = 0;
lvl = 1;
lines = 0;
alive = true;
rng = seed ? seed : 0xA5A5A5A5u;
cur = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
nxt = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
}
bool TetrisGame::moveLeft()
{
Piece p = cur;
p.col--;
if (!canPlace(p))
return false;
cur = p;
return true;
}
bool TetrisGame::moveRight()
{
Piece p = cur;
p.col++;
if (!canPlace(p))
return false;
cur = p;
return true;
}
bool TetrisGame::rotate()
{
Piece p = cur;
p.rot = static_cast<uint8_t>((p.rot + 1) % 4);
if (canPlace(p)) {
cur = p;
return true;
}
// Wall-kick: try ±1, ±2 column offsets.
const int8_t kicks[] = {-1, 1, -2, 2};
for (int8_t kick : kicks) {
Piece q = p;
q.col = static_cast<int8_t>(p.col + kick);
if (canPlace(q)) {
cur = q;
return true;
}
}
return false;
}
bool TetrisGame::softDrop()
{
Piece p = cur;
p.row++;
if (!canPlace(p)) {
advanceNext(); // locks cur, clears lines, spawns next; may set alive=false
return false;
}
cur = p;
return true;
}
void TetrisGame::hardDrop()
{
const int8_t land = ghostRow();
const uint32_t dropped = static_cast<uint32_t>(land - cur.row);
pts += dropped * 2;
cur.row = land;
advanceNext();
}
int8_t TetrisGame::ghostRow() const
{
Piece p = cur;
while (true) {
Piece q = p;
q.row++;
if (!canPlace(q))
break;
p = q;
}
return p.row;
}
bool TetrisGame::step()
{
if (!alive)
return false;
softDrop(); // may lock and advance, potentially setting alive=false
return alive;
}
// ===========================================================================
// Tetris adapter (display + persistence + mesh; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "GamesModule.h"
#include "NodeDB.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
#include <cstddef>
#include <cstring>
// ---------------------------------------------------------------------------
// Vertical pixel layout on a 128×64 OLED
//
// Board occupies the left side of the screen:
// x = col × CELL_PX (col 0 at left edge)
// y = row × CELL_PX (row 0 at top edge)
// 10 cols × 4 px = 40 px wide
// 16 rows × 4 px = 64 px tall (fills the full display height)
//
// Score panel: x = SCORE_OX .. 127 (86 px wide)
// Labels (SCR / LVL / NXT) + values + next-piece preview.
// ---------------------------------------------------------------------------
static constexpr int16_t CELL_PX = 4;
Tetris::Tetris()
{
scores_.load();
}
int32_t Tetris::tickIntervalMs() const
{
// Speed ramps with level: 600 ms base, 45 ms per level, floor 50 ms.
int32_t iv = 600 - static_cast<int32_t>(game.level()) * 45;
return iv < 50 ? 50 : iv;
}
void Tetris::handleInput(input_broker_event ev)
{
switch (ev) {
case INPUT_BROKER_UP:
game.rotate();
break;
case INPUT_BROKER_LEFT:
game.moveLeft();
break;
case INPUT_BROKER_RIGHT:
game.moveRight();
break;
case INPUT_BROKER_DOWN:
game.softDrop();
break;
case INPUT_BROKER_SELECT:
case INPUT_BROKER_SELECT_LONG:
game.hardDrop();
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
#if GRAPHICS_TFT_COLORING_ENABLED
// Classic tetromino colours, indexed by piece type (0..6 == I O T S Z J L). Native RGB565.
static uint16_t tetrominoColor(uint8_t type)
{
using namespace graphics;
switch (type) {
case 0:
return TFTPalette::Cyan; // I
case 1:
return TFTPalette::Yellow; // O
case 2:
return TFTPalette::Magenta; // T
case 3:
return TFTPalette::Green; // S
case 4:
return TFTPalette::Red; // Z
case 5:
return TFTPalette::Blue; // J
case 6:
return TFTPalette::Orange; // L
default:
return TFTPalette::White;
}
}
#endif
void Tetris::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "T E T R I S");
const int16_t logoX = x + (w - tetris_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, tetris_width, tetris_height, tetris);
#if GRAPHICS_TFT_COLORING_ENABLED
// The logo glyph is a T tetromino -- tint it the T-piece colour on colour displays.
graphics::registerTFTColorRegionDirect(logoX, logoY, tetris_width, tetris_height, tetrominoColor(2),
graphics::getThemeBodyBg());
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Tetris::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
// Centered vertical layout:
// board: 10 cols × CELL_PX wide, fills display height (BOARD_ROWS × CELL_PX)
// left panel (NXT preview) : x = 0 .. ox-2
// right panel (SCR / LVL) : x = ox+boardW+1 .. display.width-1
const int16_t boardW = TetrisGame::BOARD_COLS * CELL_PX; // 40
const int16_t ox = x + (display->getWidth() - boardW) / 2; // horizontal centre
const int16_t oy = y;
display->setColor(WHITE);
// Separator lines either side of the board, plus bottom wall.
display->drawLine(ox - 1, oy, ox - 1, oy + display->getHeight() - 1);
display->drawLine(ox + boardW, oy, ox + boardW, oy + display->getHeight() - 1);
display->drawLine(ox - 1, oy + display->getHeight() - 1, ox + boardW, oy + display->getHeight() - 1);
// Cell helper.
auto drawCell = [&](int8_t col, int8_t row) {
if (col < 0 || row < 0 || col >= TetrisGame::BOARD_COLS || row >= TetrisGame::BOARD_ROWS)
return;
display->fillRect(ox + static_cast<int16_t>(col) * CELL_PX, oy + static_cast<int16_t>(row) * CELL_PX, CELL_PX - 1,
CELL_PX - 1);
};
// Locked cells.
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
if (game.board[r][c])
drawCell(static_cast<int8_t>(c), static_cast<int8_t>(r));
// Ghost piece - hollow outline.
const TetrisGame::Piece &cur = game.current();
const int8_t ghostR = game.ghostRow();
if (ghostR != cur.row) {
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int8_t gc = static_cast<int8_t>(cur.col + pc);
const int8_t gr = static_cast<int8_t>(ghostR + pr);
if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS)
continue;
display->setPixel(ox + static_cast<int16_t>(gc) * CELL_PX + 1, oy + static_cast<int16_t>(gr) * CELL_PX + 1);
}
}
}
// Active piece - filled.
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
drawCell(static_cast<int8_t>(cur.col + pc), static_cast<int8_t>(cur.row + pr));
}
}
// --- Right panel: SCR and LVL ---
const int16_t rpx = ox + boardW + 2;
char buf[12];
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->drawString(rpx, y + 2, "SCR");
snprintf(buf, sizeof(buf), "%lu", static_cast<unsigned long>(game.score()));
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL, buf);
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 2 + 2, "LVL");
snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(game.level()));
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 3 + 2, buf);
// --- Left panel: NXT (next piece preview) centred in the panel ---
const int16_t lpanelW = ox - 2; // pixels available left of board separator
static constexpr int16_t PREV_PX = 3; // px per preview cell
const int16_t previewW = 4 * PREV_PX; // 12 px
const int16_t lpx = x + (lpanelW - previewW) / 2;
const int16_t nxtLabelY = y + 2;
const int16_t nxtPreviewY = nxtLabelY + FONT_HEIGHT_SMALL + 2;
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(x + lpanelW / 2, nxtLabelY, "NXT");
const TetrisGame::Piece &nxt = game.next();
for (uint8_t pr = 0; pr < 4; pr++)
for (uint8_t pc = 0; pc < 4; pc++)
if (TetrisGame::pieceCell(nxt.type, nxt.rot, pr, pc))
display->fillRect(lpx + static_cast<int16_t>(pc) * PREV_PX, nxtPreviewY + static_cast<int16_t>(pr) * PREV_PX,
PREV_PX - 1, PREV_PX - 1);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display (e.g. HUB75), tint every block with its tetromino colour. The mono buffer
// still carries the block pixels drawn above; registering a colour region over each run of
// same-colour cells makes those "on" pixels render in colour instead of the theme foreground.
// Runs are merged horizontally per row to stay within the region budget, and empty cells cost
// nothing, so the region count only grows with how full the board is.
const uint16_t bg = graphics::getThemeBodyBg();
// Combined colour grid: locked cells plus the falling piece (same colour source == type + 1).
uint8_t cg[TetrisGame::BOARD_ROWS][TetrisGame::BOARD_COLS];
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
cg[r][c] = game.board[r][c];
for (uint8_t pr = 0; pr < 4; pr++)
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int br = cur.row + pr, bc = cur.col + pc;
if (br >= 0 && br < TetrisGame::BOARD_ROWS && bc >= 0 && bc < TetrisGame::BOARD_COLS)
cg[br][bc] = static_cast<uint8_t>(cur.type + 1);
}
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) {
uint8_t c = 0;
while (c < TetrisGame::BOARD_COLS) {
const uint8_t v = cg[r][c];
if (v == 0) {
c++;
continue;
}
const uint8_t c0 = c;
while (c < TetrisGame::BOARD_COLS && cg[r][c] == v)
c++;
const int16_t rx = ox + static_cast<int16_t>(c0) * CELL_PX;
const int16_t ry = oy + static_cast<int16_t>(r) * CELL_PX;
const int16_t rw = static_cast<int16_t>(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap
graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast<uint8_t>(v - 1)), bg);
}
}
// Next-piece preview: one region over the 4x4 grid tinted with its colour.
graphics::registerTFTColorRegionDirect(lpx, nxtPreviewY, 4 * PREV_PX, 4 * PREV_PX, tetrominoColor(nxt.type), bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Tetris game logic.
*
* No Arduino/display dependencies - designed to be unit-tested natively and reused by the Tetris
* adapter below without pulling in the display stack.
*
* Board coordinate system: col=0 is leftmost, row=0 is top (gravity goes toward higher rows).
* board[row][col] holds 0 (empty) or 1..7 (locked piece colour index).
* No dynamic allocation: total struct size is ~260 bytes.
*/
class TetrisGame
{
public:
static constexpr uint8_t BOARD_COLS = 10;
static constexpr uint8_t BOARD_ROWS = 16; // 16×4 px = 64 px - fills a standard 64px OLED
static constexpr uint8_t PIECE_TYPES = 7; // I O T S Z J L
struct Piece {
int8_t col; // left column of the 4×4 bounding box (may be negative during spawn)
int8_t row; // top row of the 4×4 bounding box (may be negative during spawn)
uint8_t type; // 0..6
uint8_t rot; // 0..3
};
// board[row][col]: 0 = empty, 1..7 = locked piece colour
uint8_t board[BOARD_ROWS][BOARD_COLS];
/** Start (or restart) the game. seed drives the xorshift32 RNG. */
void reset(uint32_t seed);
/** Shift the current piece left one column. Returns true if it moved. */
bool moveLeft();
/** Shift the current piece right one column. Returns true if it moved. */
bool moveRight();
/**
* Rotate the current piece CW. Tries a basic wall-kick (±1, ±2 column) if the
* natural rotation overlaps a wall or locked cell. Returns true if it rotated.
*/
bool rotate();
/**
* Move the current piece down one row. If it cannot fall it is locked,
* lines are cleared, the next piece becomes current, and a new next is generated.
* Returns false after locking (game may or may not be over).
*/
bool softDrop();
/**
* Instantly drop the current piece to where it would land and lock it.
* Awards 2 pts per row dropped.
*/
void hardDrop();
/**
* Gravity tick: same as softDrop() - move down one row, lock if needed.
* Returns true while the game is alive, false after game-over.
*/
bool step();
bool isPlaying() const { return alive; }
uint32_t score() const { return pts; }
uint8_t level() const { return lvl; }
uint16_t linesCleared() const { return lines; }
const Piece &current() const { return cur; }
const Piece &next() const { return nxt; }
/**
* Returns the top row the current piece would occupy if instantly dropped.
* Used by the renderer to show a ghost/shadow piece.
*/
int8_t ghostRow() const;
/**
* Returns true if cell (pr, pc) within the 4×4 bounding box is filled for
* the given piece type and rotation. Safe for any (type, rot, pr, pc).
*/
static bool pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc);
private:
Piece cur = {};
Piece nxt = {};
uint32_t pts = 0;
uint8_t lvl = 1;
uint16_t lines = 0;
uint32_t rng = 1; // xorshift32 state - must never be 0
bool alive = false;
uint32_t nextRandom();
Piece spawnPiece(uint8_t type) const;
void advanceNext(); // lock cur, clear lines, shift nxt→cur, spawn new nxt
bool canPlace(const Piece &p) const;
void lockPiece();
int clearLines(); // returns number of lines cleared (0..4)
// Shape table: SHAPES[type][rot][row] = 4-bit column bitmask.
// Bit 0 = leftmost column (col 0), bit 3 = rightmost (col 3) of the 4×4 box.
static const uint8_t SHAPES[PIECE_TYPES][4][4];
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Tetris as a hosted Game. Wraps the pure TetrisGame logic above and supplies the attract art, the
* portrait playfield renderer, the rotate/move/drop input, the level-based speed curve, and its
* own high-score table. The new-high-score mesh announcement is shared by all games and lives in
* GamesModule.
*/
class Tetris : public Game
{
public:
Tetris();
const char *name() const override { return "Tetris"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
const char *gameOverHint() const override { return "SEL: scores BCK: exit"; }
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record; layout preserved from the original TetrisModule so tetris.dat
// keeps loading. Magic 'TETR', file version 1.
struct TetrisEntry {
uint32_t score;
char shortName[5]; // NUL-terminated 3-char display name
uint32_t nodeNum;
uint32_t epoch;
} __attribute__((packed));
TetrisGame game;
HighScoreTable<TetrisEntry> scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES