* 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
+1 -1
View File
@@ -1 +1 @@
32
35
+103
View File
@@ -0,0 +1,103 @@
#include "TestUtil.h"
#include "modules/games/Breakout.h"
#include <unity.h>
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
// a straight-up serve, and the ball staying within the board. No device globals or display stack.
static const uint32_t kSeed = 0xC0FFEEu;
void test_reset_initialState()
{
BreakoutGame game;
game.reset(kSeed);
TEST_ASSERT_TRUE(game.isPlaying());
TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives());
TEST_ASSERT_EQUAL_UINT8(1, game.level());
TEST_ASSERT_EQUAL_UINT32(0, game.score());
// Every brick present at the start.
TEST_ASSERT_EQUAL_UINT16(static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS, game.bricksRemaining());
// Paddle centred, ball above it and inside the board.
TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX());
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H);
}
void test_paddle_clampsToEdges()
{
BreakoutGame game;
game.reset(kSeed);
for (int i = 0; i < 100; i++)
game.moveLeft();
TEST_ASSERT_EQUAL_INT16(0, game.paddleX());
for (int i = 0; i < 100; i++)
game.moveRight();
TEST_ASSERT_EQUAL_INT16(BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W, game.paddleX());
}
void test_serve_clearsABrickAndScores()
{
BreakoutGame game;
game.reset(kSeed);
// The ball serves upward from just above the paddle straight into the brick field; within a
// few dozen steps it must clear at least one brick and score.
for (int i = 0;
i < 60 && game.bricksRemaining() == static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++)
game.step();
TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS);
TEST_ASSERT_TRUE(game.score() > 0);
}
void test_ball_staysInBounds()
{
BreakoutGame game;
game.reset(kSeed);
// Drive the paddle to follow the ball so the game keeps going, and check the ball never leaves
// the board horizontally across a long run.
for (int i = 0; i < 500 && game.isPlaying(); i++) {
if (game.ballX() < game.paddleX())
game.moveLeft();
else
game.moveRight();
game.step();
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
TEST_ASSERT_TRUE(game.ballY() >= 0);
}
}
void test_deadGame_stepIsNoOp()
{
BreakoutGame game;
game.reset(kSeed);
// Park the paddle in a corner and never move it; the ball is eventually lost every life.
game.moveLeft();
for (int i = 0; i < 20000 && game.isPlaying(); i++) {
for (int j = 0; j < 40; j++) // hold the paddle pinned left
game.moveLeft();
game.step();
}
TEST_ASSERT_FALSE(game.isPlaying());
const uint32_t scoreBefore = game.score();
TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
}
void setUp(void) {}
void tearDown(void) {}
extern "C" {
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_reset_initialState);
RUN_TEST(test_paddle_clampsToEdges);
RUN_TEST(test_serve_clearsABrickAndScores);
RUN_TEST(test_ball_staysInBounds);
RUN_TEST(test_deadGame_stepIsNoOp);
exit(UNITY_END());
}
void loop() {}
}
+100
View File
@@ -0,0 +1,100 @@
#include "TestUtil.h"
#include "modules/games/ChirpyRunner.h"
#include <unity.h>
// Pure-logic tests for ChirpyRunnerGame: initial state, jump lifts Chirpy off the ground,
// obstacles spawn and a collision ends the run, and a dead game is inert. No device globals.
static const uint32_t kSeed = 0xC0FFEEu;
void test_reset_initialState()
{
ChirpyRunnerGame game;
game.reset(kSeed);
TEST_ASSERT_TRUE(game.isPlaying());
TEST_ASSERT_EQUAL_UINT32(0, game.score());
TEST_ASSERT_TRUE(game.onGround());
TEST_ASSERT_EQUAL_INT16(ChirpyRunnerGame::GROUND_Y - ChirpyRunnerGame::CHIRPY_H, game.chirpyY());
}
void test_jump_liftsChirpy()
{
ChirpyRunnerGame game;
game.reset(kSeed);
const int16_t groundY = game.chirpyY();
game.jump();
game.step();
TEST_ASSERT_FALSE(game.onGround());
TEST_ASSERT_TRUE(game.chirpyY() < groundY); // rose above the ground rest position
}
void test_jump_ignoredWhileAirborne()
{
ChirpyRunnerGame game;
game.reset(kSeed);
game.jump();
game.step();
const int16_t yAfterFirst = game.chirpyY();
// A second jump mid-air must not re-launch: after another step Chirpy keeps descending toward
// the ground under gravity rather than shooting back up.
game.jump();
game.step();
// Not asserting exact physics, only that we're still airborne and moving as one arc, not reset.
TEST_ASSERT_FALSE(game.onGround());
(void)yAfterFirst;
}
void test_obstacleSpawnsAndCollisionEndsGame()
{
ChirpyRunnerGame game;
game.reset(kSeed);
// One step spawns the first obstacle.
game.step();
bool any = false;
for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++)
any = any || game.obstacleActive(i);
TEST_ASSERT_TRUE(any);
// Never jumping, an obstacle must reach grounded Chirpy and end the run.
int steps = 0;
while (game.isPlaying() && steps < 2000) {
game.step();
steps++;
}
TEST_ASSERT_FALSE(game.isPlaying());
}
void test_deadGame_stepIsNoOp()
{
ChirpyRunnerGame game;
game.reset(kSeed);
int steps = 0;
while (game.isPlaying() && steps < 2000) {
game.step();
steps++;
}
TEST_ASSERT_FALSE(game.isPlaying());
const uint32_t scoreBefore = game.score();
TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
}
void setUp(void) {}
void tearDown(void) {}
extern "C" {
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_reset_initialState);
RUN_TEST(test_jump_liftsChirpy);
RUN_TEST(test_jump_ignoredWhileAirborne);
RUN_TEST(test_obstacleSpawnsAndCollisionEndsGame);
RUN_TEST(test_deadGame_stepIsNoOp);
exit(UNITY_END());
}
void loop() {}
}
+180
View File
@@ -0,0 +1,180 @@
#include "TestUtil.h"
#include "modules/games/Snake.h"
#include <unity.h>
// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision,
// growth on eat, and food-placement validity. No device globals or display stack required.
static const uint32_t kSeed = 0xC0FFEEu;
// Count how many board cells the snake currently occupies (cross-check for len()).
static uint16_t countOccupied(const SnakeGame &game)
{
uint16_t n = 0;
for (uint8_t y = 0; y < SnakeGame::GRID_H; y++)
for (uint8_t x = 0; x < SnakeGame::GRID_W; x++)
if (game.occupied(x, y))
n++;
return n;
}
static void test_reset_initialState()
{
SnakeGame game;
game.reset(kSeed);
TEST_ASSERT_TRUE(game.isPlaying());
TEST_ASSERT_FALSE(game.isWon());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length());
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction());
// Head spawns at board centre; the whole test file relies on this anchor.
SnakeGame::Cell head = game.head();
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x);
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y);
// Exactly START_LEN cells occupied, and the head is one of them.
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
TEST_ASSERT_TRUE(game.occupied(head.x, head.y));
}
static void test_food_isValidAndOffBody()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell food = game.food();
TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W);
TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H);
TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake
}
static void test_setDirection_rejectsReversal()
{
SnakeGame game;
game.reset(kSeed); // heading right
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op)
// A double-input within one tick can't chain into a reversal: after latching UP, LEFT is
// still checked against the committed RIGHT and rejected, so the neck stays safe.
game.setDirection(SnakeGame::DIR_UP);
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT));
}
static void test_step_movesAndTailFollows()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell head = game.head();
game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step
TEST_ASSERT_TRUE(game.step());
SnakeGame::Cell newHead = game.head();
TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right
TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y);
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
}
static void test_eat_growsAndScores()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell head = game.head();
game.placeFoodAt(head.x + 1, head.y); // food directly ahead
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one
TEST_ASSERT_EQUAL_UINT32(1u, game.score());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game));
// A fresh food was placed and is not on the snake.
SnakeGame::Cell food = game.food();
TEST_ASSERT_FALSE(game.occupied(food.x, food.y));
}
static void test_wallCollision_endsGame()
{
SnakeGame game;
game.reset(kSeed);
game.placeFoodAt(0, 0);
game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall
bool alive = true;
int guard = 0;
while (alive && guard++ < SnakeGame::GRID_H + 4) {
game.placeFoodAt(0, 0); // keep food out of the way each tick
alive = game.step();
}
TEST_ASSERT_FALSE(alive);
TEST_ASSERT_FALSE(game.isPlaying());
}
static void test_selfCollision_endsGame()
{
SnakeGame game;
game.reset(kSeed);
TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below
TEST_ASSERT_EQUAL_UINT8(6, game.head().y);
// Grow to length 5 along a straight horizontal line (cells (14..18, 6)).
game.placeFoodAt(17, 6);
TEST_ASSERT_TRUE(game.step());
game.placeFoodAt(18, 6);
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_UINT16(5, game.length());
// Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell.
game.setDirection(SnakeGame::DIR_DOWN);
game.placeFoodAt(0, 0);
TEST_ASSERT_TRUE(game.step());
game.setDirection(SnakeGame::DIR_LEFT);
game.placeFoodAt(0, 0);
TEST_ASSERT_TRUE(game.step());
game.setDirection(SnakeGame::DIR_UP);
game.placeFoodAt(0, 0);
TEST_ASSERT_FALSE(game.step()); // bites its own body
TEST_ASSERT_FALSE(game.isPlaying());
}
static void test_deadGame_stepIsNoOp()
{
SnakeGame game;
game.reset(kSeed);
game.setDirection(SnakeGame::DIR_UP);
for (int i = 0; i < SnakeGame::GRID_H + 4; i++) {
game.placeFoodAt(0, 0);
game.step();
}
TEST_ASSERT_FALSE(game.isPlaying());
uint32_t scoreBefore = game.score();
TEST_ASSERT_FALSE(game.step()); // stays dead, no state change
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
}
void setUp(void) {}
void tearDown(void) {}
extern "C" {
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_reset_initialState);
RUN_TEST(test_food_isValidAndOffBody);
RUN_TEST(test_setDirection_rejectsReversal);
RUN_TEST(test_step_movesAndTailFollows);
RUN_TEST(test_eat_growsAndScores);
RUN_TEST(test_wallCollision_endsGame);
RUN_TEST(test_selfCollision_endsGame);
RUN_TEST(test_deadGame_stepIsNoOp);
exit(UNITY_END());
}
void loop() {}
}