* 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
parent d3d02af817
commit 8d1fbbf55f
41 changed files with 4106 additions and 34 deletions

View File

@@ -153,6 +153,30 @@ Display:
### You can also specify the spi device for the display to use
# spidev: spidev0.0
### HUB75 RGB LED matrix panel (Raspberry Pi only, via hzeller/rpi-rgb-led-matrix).
### Requires the librgbmatrix library to be installed (so `pkg-config rgbmatrix` resolves) and
### the firmware built with it present. meshtasticd must run as root (or with /dev/gpiomem
### access) and on-board audio disabled (dtparam=audio=off) so it doesn't fight the PWM timing.
### The library owns the GPIO pins - they are chosen by HardwareMapping, not set individually.
# Panel: HUB75
# HUB75:
# HardwareMapping: adafruit-hat-pwm # regular | adafruit-hat | adafruit-hat-pwm | regular-pi1 ...
# Rows: 64 # pixel rows per panel (e.g. 32 or 64)
# Cols: 64 # pixel columns per panel
# ChainLength: 1 # panels daisy-chained (width = Cols * ChainLength)
# Parallel: 1 # parallel chains (height = Rows * Parallel)
# Brightness: 80 # percent, 1..100
# PWMBits: 11 # lower = higher refresh, fewer colors
# GPIOSlowdown: 4 # raise for faster Pis / long cables if the panel ghosts or flickers
# RGBSequence: RGB # reorder if your panel's colors are swapped (e.g. RBG, BGR)
# ScanMode: 0 # 0=progressive, 1=interlaced
# RowAddressType: 0 # 0=default, 1=AB-addressed (some 64x64 panels)
# Multiplexing: 0 # 0=direct, 1=stripe, 2=checker, ...
# PanelType: "" # e.g. FM6126A for panels needing a special init
# PixelMapper: "" # e.g. "U-mapper;Rotate:90"
# LimitRefreshRateHz: 0 # 0 = no limit
# DisableHardwarePulsing: false
Touchscreen:
### Note, at least for now, the touchscreen must have a CS pin defined, even if you let Linux manage the CS switching.
@@ -167,7 +191,6 @@ Touchscreen:
### You can also specify the spi device for the touchscreen to use
# spidev: spidev0.0
Input:
### Configure device for direct keyboard input

View File

@@ -0,0 +1,37 @@
Meta:
name: HUB75 RGB LED Matrix (64x64)
support: community
compatible:
- raspberry-pi
### HUB75 RGB LED matrix panel driven by hzeller/rpi-rgb-led-matrix.
###
### Prerequisites:
### - Install the rpi-rgb-led-matrix library so `pkg-config rgbmatrix` resolves, and build the
### firmware with it present (the HUB75 backend is compiled in automatically when found).
### - meshtasticd must run as root (or have /dev/gpiomem access) for the library's direct GPIO.
### - Disable on-board audio (dtparam=audio=off in /boot/firmware/config.txt) - it shares the
### PWM hardware and causes flicker otherwise.
###
### The library owns the GPIO pins; they are selected by HardwareMapping (matching your HAT /
### wiring), not assigned individually.
Display:
Panel: HUB75
HUB75:
HardwareMapping: adafruit-hat-pwm
Rows: 64
Cols: 64
ChainLength: 1
Parallel: 1
Brightness: 80
PWMBits: 11
GPIOSlowdown: 4
RGBSequence: RGB
# Uncomment/tune as needed for your specific panel:
# ScanMode: 0
# RowAddressType: 0
# Multiplexing: 0
# PanelType: FM6126A
# PixelMapper: ""
# LimitRefreshRateHz: 0
# DisableHardwarePulsing: false

View File

@@ -424,6 +424,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#ifndef HAS_TFT
#define HAS_TFT 0
#endif
// Opt-in: build the BaseUI games frame (Snake). Off by default; enable per build/variant with
// -DBASEUI_HAS_GAMES=1 (requires HAS_SCREEN and a non-color BaseUI display).
#ifndef BASEUI_HAS_GAMES
#define BASEUI_HAS_GAMES 0
#endif
#ifndef HAS_WIRE
#define HAS_WIRE 0
#endif

View File

@@ -0,0 +1,156 @@
#include "configuration.h"
#if defined(USE_HUB75)
#include "HUB75Display.h"
#include "TFTColorRegions.h"
#include "TFTPalette.h"
#include <ESP32-HUB75-MatrixPanel-I2S-DMA.h>
#include <string.h>
HUB75Display::HUB75Display(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C)
{
// The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306
// page layout). Geometry is fixed by the wired panel size.
#if defined(SCREEN_ROTATE)
setGeometry(GEOMETRY_RAWMODE, TFT_HEIGHT, TFT_WIDTH);
#else
setGeometry(GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
#endif
LOG_DEBUG("HUB75Display %dx%d", (int)TFT_WIDTH, (int)TFT_HEIGHT);
}
HUB75Display::~HUB75Display()
{
if (matrix) {
delete matrix;
matrix = nullptr;
}
}
// Bring up the matrix DMA driver from the wired HUB75 pins.
bool HUB75Display::connect()
{
LOG_INFO("Do HUB75 init");
HUB75_I2S_CFG::i2s_pins pins = {HUB75_R1, HUB75_G1, HUB75_B1, HUB75_R2, HUB75_G2, HUB75_B2, HUB75_A,
HUB75_B, HUB75_C, HUB75_D, HUB75_E, HUB75_LAT, HUB75_OE, HUB75_CLK};
HUB75_I2S_CFG cfg(TFT_WIDTH, TFT_HEIGHT, 1 /* chain length */, pins);
cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity)
cfg.latch_blanking = 4; // clean row transitions
cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half
cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA
// footprint. display() draws directly into the
// live buffer with a dirty-diff
matrix = new MatrixPanel_I2S_DMA(cfg);
bool ok = matrix->begin();
if (!ok) {
LOG_ERROR("HUB75 matrix->begin() failed (DMA buffer alloc?)");
delete matrix;
matrix = nullptr;
return false;
}
matrix->setBrightness8(brightness);
matrix->clearScreen();
return true;
}
void HUB75Display::display()
{
if (!matrix)
return;
const uint16_t onNative = graphics::TFTPalette::White;
const uint16_t offNative = graphics::getThemeBodyBg();
const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8));
const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8));
bool forceFull = firstFrame || !haveThemeDefaults || onBe != lastOnBe || offBe != lastOffBe;
#if GRAPHICS_TFT_COLORING_ENABLED
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
const uint32_t colorSig = graphics::getTFTColorFrameSignature();
if (colorSig != lastColorSig)
forceFull = true;
#endif
for (uint16_t y = 0; y < displayHeight; y++) {
const uint32_t yByteIndex = (y / 8) * displayWidth;
const uint8_t yByteMask = (uint8_t)(1 << (y & 7));
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions)
graphics::beginTFTColorRow((int16_t)y);
#endif
for (uint16_t x = 0; x < displayWidth; x++) {
const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0;
// Skip pixels whose mono bit is unchanged (unless a full repaint is
// forced). The panel is single-buffered, so untouched pixels persist.
if (!forceFull && (((buffer_back[x + yByteIndex] & yByteMask) != 0) == isset))
continue;
uint16_t be;
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions)
be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe);
else
be = isset ? onBe : offBe;
#else
be = isset ? onBe : offBe;
#endif
const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565
const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3);
const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2);
const uint8_t b = (uint8_t)((c & 0x1F) << 3);
matrix->drawPixelRGB888(x, y, r, g, b);
}
}
// Remember what the panel now shows so the next frame can diff against it.
memcpy(buffer_back, buffer, displayBufferSize);
haveThemeDefaults = true;
lastOnBe = onBe;
lastOffBe = offBe;
firstFrame = false;
#if GRAPHICS_TFT_COLORING_ENABLED
lastColorSig = colorSig;
// Regions are re-registered every frame by the renderers; clear so they
// don't accumulate across frames.
graphics::clearTFTColorRegions();
#endif
}
void HUB75Display::sendCommand(uint8_t com)
{
if (!matrix)
return;
switch (com) {
case DISPLAYON:
matrix->setBrightness8(brightness);
break;
case DISPLAYOFF:
matrix->setBrightness8(0);
break;
default:
// Drop all other SSD1306 init/config commands - not meaningful for the matrix.
break;
}
}
void HUB75Display::setDisplayBrightness(uint8_t _brightness)
{
brightness = _brightness;
if (matrix)
matrix->setBrightness8(brightness);
}
#endif // USE_HUB75

View File

@@ -0,0 +1,48 @@
#pragma once
#include "configuration.h"
#if defined(USE_HUB75)
#include <OLEDDisplay.h>
#ifndef HUB75_BRIGHTNESS_DEFAULT
#define HUB75_BRIGHTNESS_DEFAULT 180
#endif
class MatrixPanel_I2S_DMA;
/**
* A color display backend that drives a HUB75 RGB LED matrix panel from the
* BaseUI color path.
*/
class HUB75Display : public OLEDDisplay
{
public:
HUB75Display(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
~HUB75Display();
// Write the buffer to the matrix (full-frame, region-aware color expansion).
void display() override;
void setDisplayBrightness(uint8_t brightness);
protected:
int getBufferOffset(void) override { return 0; }
void sendCommand(uint8_t com) override;
bool connect() override;
private:
MatrixPanel_I2S_DMA *matrix = nullptr;
uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT;
bool firstFrame = true;
bool haveThemeDefaults = false;
uint16_t lastOnBe = 0;
uint16_t lastOffBe = 0;
uint32_t lastColorSig = 0;
};
#endif // USE_HUB75

View File

@@ -29,6 +29,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include <OLEDDisplay.h>
#if defined(USE_HUB75)
#include "graphics/HUB75Display.h" // ESP32 HUB75 (I2S-DMA)
#endif
#if defined(HAS_HUB75_NATIVE)
#include "HUB75Native.h" // native/Portduino HUB75 (rpi-rgb-led-matrix), from variants/native/portduino
#endif
#include "DisplayFormatters.h"
#include "TimeFormatters.h"
@@ -67,6 +73,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "mesh/Default.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "modules/ExternalNotificationModule.h"
#if BASEUI_HAS_GAMES
#include "modules/games/GamesModule.h"
#endif
#include "modules/WaypointModule.h"
#include "sleep.h"
#include "target_specific.h"
@@ -99,7 +108,11 @@ namespace graphics
#define COMPASS_ACTIVE_FRAMERATE 20
// DEBUG
#if BASEUI_HAS_GAMES
#define NUM_EXTRA_FRAMES 4 // text message, debug frame, and the always-present games frame
#else
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
#endif
// if defined a pixel will blink to show redraws
// #define SHOW_REDRAWS
#define ASCII_BELL '\x07'
@@ -369,6 +382,43 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
updateUiFrame(ui);
}
// Called to trigger an arcade-style initials picker (see showNumberPicker for the sibling flow).
void Screen::showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length,
std::function<void(const std::string &)> bannerCallback)
{
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus
#endif
if (length >= sizeof(NotificationRenderer::alphanumericValue))
length = sizeof(NotificationRenderer::alphanumericValue) - 1;
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
NotificationRenderer::textInputCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
NotificationRenderer::current_notification_type = notificationTypeEnum::alphanumeric_picker;
NotificationRenderer::numDigits = length;
// Seed each position from initialText (uppercased & filtered to A-Z/0-9), defaulting to 'A'.
const size_t seedLen = initialText ? strnlen(initialText, length) : 0;
for (uint8_t i = 0; i < length; i++) {
char c = (i < seedLen) ? initialText[i] : 'A';
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - 'a' + 'A');
if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
c = 'A';
NotificationRenderer::alphanumericValue[i] = c;
}
NotificationRenderer::alphanumericValue[length] = '\0';
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
ui->setTargetFPS(60);
updateUiFrame(ui);
}
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function<void(const std::string &)> textCallback)
{
@@ -412,6 +462,17 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
pi.drawFrame(display, state, x, y);
}
#if BASEUI_HAS_GAMES
// The games frame is a dedicated, always-present frame (unlike generic module frames it is placed
// at a fixed position right after home), so it draws through its own trampoline rather than the
// moduleFrames lockstep used by drawModuleFrame.
static void drawGamesFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
if (gamesModule)
gamesModule->drawFrame(display, state, x, y);
}
#endif
/**
* Given a recent lat/lon return a guess of the heading the user is walking on.
*
@@ -499,6 +560,8 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#else
dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
#endif
#elif defined(USE_HUB75)
dispdev = new HUB75Display(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE);
#elif defined(USE_SSD1306)
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -517,7 +580,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
LOG_INFO("SSD1306 init success");
}
#elif ARCH_PORTDUINO
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
// HUB75 RGB matrix (hzeller/rpi-rgb-led-matrix) is a BaseUI framebuffer panel, selected at
// runtime via config.yaml Display: Panel: HUB75.
if (portduino_config.displayPanel == hub75) {
#if defined(HAS_HUB75_NATIVE)
LOG_DEBUG("Make HUB75Native!");
dispdev = new HUB75Native(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE);
#else
LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in!");
#endif
} else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.displayPanel != no_screen) {
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
@@ -1304,6 +1377,15 @@ void Screen::setFrames(FrameFocus focus)
indicatorIcons.push_back(icon_home);
}
#if BASEUI_HAS_GAMES
// Games frame: always present (even with no game running), positioned directly after home.
if (gamesModule) {
fsi.positions.games = numframes;
normalFrames[numframes++] = drawGamesFrame;
indicatorIcons.push_back(joystick_small);
}
#endif
fsi.positions.textMessage = numframes;
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
indicatorIcons.push_back(icon_mail);
@@ -2076,6 +2158,12 @@ int Screen::handleInputEvent(const InputEvent *event)
if (module && module->interceptingKeyboardInput())
inputIntercepted = true;
}
#if BASEUI_HAS_GAMES
// The games frame isn't a moduleFrame, so check it explicitly: while a game is running it
// owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
if (gamesModule && gamesModule->interceptingKeyboardInput())
inputIntercepted = true;
#endif
// If no modules are using the input, move between frames
if (!inputIntercepted) {
@@ -2150,6 +2238,11 @@ int Screen::handleInputEvent(const InputEvent *event)
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) {
menuHandler::homeBaseMenu();
#if BASEUI_HAS_GAMES
} else if (gamesModule && framesetInfo.positions.games != 255 &&
this->ui->getUiState()->currentFrame == framesetInfo.positions.games) {
gamesModule->launchGame(); // launch the game shown on the attract screen
#endif
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) {
menuHandler::systemBaseMenu();
#if HAS_GPS
@@ -2217,6 +2310,11 @@ bool Screen::isOverlayBannerShowing()
return NotificationRenderer::isOverlayBannerShowing();
}
bool Screen::isGamesFrameShown()
{
return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games;
}
} // namespace graphics
#else

View File

@@ -26,6 +26,9 @@ enum notificationTypeEnum {
// LOCKED frame. Without this, a first-pair on a locked device cannot
// complete because the PIN never renders.
pairing_pin,
// Arcade-style initials entry: like number_picker/hex_picker, but each position cycles
// through A-Z and 0-9. The assembled string is returned via a text (std::string) callback.
alphanumeric_picker,
};
struct BannerOverlayOptions {
@@ -284,6 +287,10 @@ class Screen : public concurrency::OSThread
bool isOverlayBannerShowing();
// True if the always-present games frame is the one currently on screen. Lets the games module
// ignore D-pad input when the player has navigated to a different frame.
bool isGamesFrameShown();
bool isScreenOn() { return screenOn; }
// Stores the last 4 of our hardware ID, to make finding the device for pairing easier
@@ -346,6 +353,11 @@ class Screen : public concurrency::OSThread
void showNodePicker(const char *message, uint32_t durationMs, std::function<void(uint32_t)> bannerCallback);
void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16,
std::function<void(uint32_t)> bannerCallback);
// Arcade-style initials entry. `length` positions each cycle A-Z/0-9 (UP/DOWN), LEFT/RIGHT
// moves the cursor, SELECT advances; the assembled string is delivered to `bannerCallback`.
// `initialText` pre-seeds the positions (uppercased & filtered), defaulting to 'A'.
void showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length,
std::function<void(const std::string &)> bannerCallback);
void showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function<void(const std::string &)> textCallback);
@@ -735,6 +747,7 @@ class Screen : public concurrency::OSThread
uint8_t system = 255;
uint8_t gps = 255;
uint8_t home = 255;
uint8_t games = 255;
uint8_t textMessage = 255;
uint8_t nodelist_nodes = 255;
uint8_t nodelist_location = 255;

View File

@@ -16,8 +16,9 @@ struct TFTColorRegion {
// Required by ST7789 driver: it scans until the first disabled entry.
bool enabled = false;
};
#ifndef MAX_TFT_COLOR_REGIONS
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
#endif
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
enum class TFTColorRole : uint8_t {
@@ -39,7 +40,7 @@ enum class TFTColorRole : uint8_t {
Count
};
#if HAS_TFT || defined(HAS_SPI_TFT)
#if HAS_TFT || defined(HAS_SPI_TFT) || defined(HAS_HUB75_NATIVE)
#define GRAPHICS_TFT_COLORING_ENABLED 1
#else
#define GRAPHICS_TFT_COLORING_ENABLED 0

View File

@@ -743,17 +743,18 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
int line = 1;
int scale = 1;
int iconX = SCREEN_WIDTH - chirpy_width - (chirpy_width / 3);
int iconY = (SCREEN_HEIGHT - chirpy_height) / 2;
int textX_offset = 10;
if (currentResolution == ScreenResolution::High) {
textX_offset = textX_offset * 4;
const int scale = 2;
scale = 2;
iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3);
iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2;
const int bytesPerRow = (chirpy_width + 7) / 8;
for (int yy = 0; yy < chirpy_height; ++yy) {
iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3);
iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2;
const uint8_t *rowPtr = chirpy + yy * bytesPerRow;
for (int xx = 0; xx < chirpy_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
@@ -767,6 +768,19 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1
display->drawXbm(iconX, iconY, chirpy_width, chirpy_height, chirpy);
}
#if GRAPHICS_TFT_COLORING_ENABLED
// Colour Chirpy on colour displays. The glyph is a filled head silhouette whose eyes are holes
// (off pixels), so two stacked regions render the proper mascot without a second bitmap:
// A) whole glyph -> green body / frame / legs
// B) the eye band -> black face, with the eye holes turning white via the region's off-colour
// Start the face band one column in from the head's left edge (col 6) so that edge stays green,
// matching the green column already left on the right edge (col 31).
graphics::registerTFTColorRegionDirect(iconX, iconY, chirpy_width * scale, chirpy_height * scale,
graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg());
graphics::registerTFTColorRegionDirect(iconX + 7 * scale, iconY + 12 * scale, 24 * scale, 16 * scale,
graphics::TFTPalette::Black, graphics::TFTPalette::White);
#endif
int textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("Hello") / 2);
display->drawString(textX, getTextPositions(display)[line++], "Hello");
textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("World!") / 2);

View File

@@ -54,6 +54,7 @@ bool NotificationRenderer::pauseBanner = false;
notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none;
uint32_t NotificationRenderer::numDigits = 0;
uint32_t NotificationRenderer::currentNumber = 0;
char NotificationRenderer::alphanumericValue[16] = {0};
VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr;
std::function<void(const std::string &)> NotificationRenderer::textInputCallback = nullptr;
@@ -277,6 +278,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
case notificationTypeEnum::hex_picker:
drawHexPicker(display, state);
break;
case notificationTypeEnum::alphanumeric_picker:
drawAlphanumericPicker(display, state);
break;
}
}
@@ -462,6 +466,96 @@ void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiStat
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
// Arcade-style initials entry. Mirrors drawHexPicker's cursor/confirm flow, but each position
// holds a character from ALPHANUMERIC_CHARS (cycled with UP/DOWN) instead of a packed digit, and
// the assembled string is returned through textInputCallback.
void NotificationRenderer::drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
static const char ALPHANUMERIC_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
constexpr int ALPHANUMERIC_COUNT = sizeof(ALPHANUMERIC_CHARS) - 1; // exclude the NUL
const char *lineStarts[MAX_LINES + 1] = {0};
uint16_t lineCount = 0;
// Parse lines (identical to the number/hex pickers)
char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
lineStarts[lineCount] = alertBannerMessage;
while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
if (lineStarts[lineCount + 1][0] == '\n')
lineStarts[lineCount + 1] += 1;
lineCount++;
}
auto alphaIndex = [&](char c) -> int {
for (int i = 0; i < ALPHANUMERIC_COUNT; i++)
if (ALPHANUMERIC_CHARS[i] == c)
return i;
return 0;
};
// Handle input
if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS ||
inEvent.inputEvent == INPUT_BROKER_UP_LONG) {
int idx = (alphaIndex(alphanumericValue[curSelected]) + 1) % ALPHANUMERIC_COUNT;
alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx];
} else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS ||
inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) {
int idx = (alphaIndex(alphanumericValue[curSelected]) + ALPHANUMERIC_COUNT - 1) % ALPHANUMERIC_COUNT;
alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx];
} else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) {
char k = inEvent.kbchar;
if (k >= 'a' && k <= 'z')
k = static_cast<char>(k - 'a' + 'A');
if ((k >= 'A' && k <= 'Z') || (k >= '0' && k <= '9')) { // direct keyboard entry
alphanumericValue[curSelected] = k;
curSelected++;
}
} else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) {
curSelected++;
} else if (inEvent.inputEvent == INPUT_BROKER_LEFT) {
curSelected--;
} else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) &&
alertBannerUntil != 0) {
resetBanner();
return;
}
if (curSelected < 0)
curSelected = 0;
if (curSelected == static_cast<int8_t>(numDigits)) {
auto callback = textInputCallback; // capture before clearing to avoid re-entrancy surprises
std::string result(alphanumericValue, numDigits);
textInputCallback = nullptr;
resetBanner();
if (callback)
callback(result);
return;
}
inEvent.inputEvent = INPUT_BROKER_NONE;
if (alertBannerMessage[0] == '\0')
return;
uint16_t totalLines = lineCount + 2;
const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation
for (uint16_t i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
std::string chars = " ";
std::string arrowPointer = " ";
for (uint16_t i = 0; i < numDigits; i++) {
chars += std::string(1, alphanumericValue[i]) + " ";
arrowPointer += (curSelected == static_cast<int8_t>(i)) ? "^ " : "_ ";
}
linePointers[lineCount++] = chars.c_str();
linePointers[lineCount++] = arrowPointer.c_str();
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
static uint32_t selectedNodenum = 0;

View File

@@ -26,6 +26,7 @@ class NotificationRenderer
static std::function<void(int)> alertBannerCallback;
static uint32_t numDigits;
static uint32_t currentNumber;
static char alphanumericValue[16]; // working buffer for the alphanumeric_picker
static VirtualKeyboard *virtualKeyboard;
static std::function<void(const std::string &)> textInputCallback;
@@ -43,6 +44,7 @@ class NotificationRenderer
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1],

View File

@@ -9,6 +9,9 @@
#if !MESHTASTIC_EXCLUDE_STATUS
#include "modules/StatusMessageModule.h"
#endif
#if BASEUI_HAS_GAMES
#include "modules/games/GamesModule.h"
#endif
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
@@ -1817,6 +1820,13 @@ constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
{
#if BASEUI_HAS_GAMES
// Hide the navigation bar while a game owns the screen (the attract screen doesn't intercept,
// so the nav bar stays visible there).
if (gamesModule && gamesModule->interceptingKeyboardInput())
return;
#endif
uint8_t frameToHighlight = state->currentFrame;
if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) {
frameToHighlight = state->transitionFrameTarget;

View File

@@ -319,6 +319,46 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f};
#define connection_icon_height 5
const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36};
// Joystick icon (16x16): a round knob on a shaft over an elliptical base. Drawn on the Snake
// attract screen as a "use the D-pad" controls hint.
#define joystick_width 16
#define joystick_height 16
static const uint8_t joystick[] PROGMEM = {0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x07, 0xC0,
0x03, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xF8, 0x1F,
0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xF8, 0x1F};
// 8x8 joystick, for the navigation bar (which draws 8x8 glyphs, scaled up on hi-res displays).
#define joystick_small_width 8
#define joystick_small_height 8
static const uint8_t joystick_small[] PROGMEM = {0x18, 0x3C, 0x3C, 0x18, 0x7E, 0x99, 0xC3, 0x7E};
#define snake_width 16
#define snake_height 16
static const uint8_t snake[] PROGMEM = {0x80, 0x1F, 0x40, 0x20, 0x20, 0x48, 0x20, 0x40, 0xA0, 0x44, 0x20,
0x39, 0x40, 0xDE, 0x86, 0x44, 0x0A, 0x19, 0x32, 0x22, 0xC4, 0x47,
0x1C, 0x4D, 0x6A, 0x4A, 0xFA, 0x47, 0x04, 0x20, 0xF8, 0x1F};
#define tetris_width 16
#define tetris_height 16
static const uint8_t tetris[] PROGMEM = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x01, 0x80, 0xFD, 0xBF, 0xFD,
0xBF, 0x81, 0x81, 0xBF, 0xFD, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05,
0x20, 0x04, 0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
#define breakout_width 16
#define breakout_height 16
// Three staggered brick courses, a ball, and a paddle. XBM: 2 bytes/row, LSB = leftmost pixel.
static const uint8_t breakout[] PROGMEM = {0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0xDD, 0xDD, 0xDD,
0xDD, 0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0x80, 0x01,
0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x0F};
// Tiny Chirpy runner sprite: square head with two pill eyes + mouth, antenna, and long legs.
// XBM, 2 bytes/row, LSB = leftmost pixel. Used by the Chirpy Runner game.
#define chirpy_run_width 12
#define chirpy_run_height 16
static const uint8_t chirpy_run[] PROGMEM = {0x40, 0x00, 0x20, 0x00, 0x40, 0x00, 0xfc, 0x03, 0x04, 0x02, 0x94,
0x02, 0x94, 0x02, 0x94, 0x02, 0x04, 0x02, 0xf4, 0x02, 0xfc, 0x03,
0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x08, 0x01};
#ifdef OLED_TINY
#include "img/icon_small.xbm"
#else

View File

@@ -79,6 +79,16 @@ static int axisZone(int value)
return 0;
}
void LinuxJoystick::emitEvent(input_broker_event event)
{
InputEvent e = {};
e.inputEvent = event;
e.source = this->_originName;
e.kbchar = 0;
// LOG_DEBUG("joystick: %s event %d", this->_originName, event);
this->notifyObservers(&e);
}
int32_t LinuxJoystick::runOnce()
{
if (firstTime) {
@@ -107,8 +117,6 @@ int32_t LinuxJoystick::runOnce()
if (nfds < 0) {
perror("joystick: epoll_wait failed");
return disable();
} else if (nfds == 0) {
return 50;
}
for (int i = 0; i < nfds; i++) {
@@ -117,42 +125,53 @@ int32_t LinuxJoystick::runOnce()
if (rd < (signed int)sizeof(struct input_event))
continue;
for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) {
InputEvent e = {};
e.inputEvent = INPUT_BROKER_NONE;
e.source = this->_originName;
e.kbchar = 0;
unsigned int type = evs[j].type;
unsigned int code = evs[j].code;
int value = evs[j].value;
if (type == EV_ABS) {
// D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values.
// Emit exactly once when an axis crosses from center to an edge.
// D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. Emit on the
// transition to an edge and arm auto-repeat; a held direction repeats below.
if (code == ABS_X) {
int zone = axisZone(value);
if (zone != axisZone(lastX) && zone != 0)
e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT;
lastX = value;
if (zone != heldX) {
heldX = zone;
if (zone != 0) {
emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT);
nextRepeatX = millis() + JOY_REPEAT_DELAY_MS;
}
}
} else if (code == ABS_Y) {
int zone = axisZone(value);
if (zone != axisZone(lastY) && zone != 0)
e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN;
lastY = value;
if (zone != heldY) {
heldY = zone;
if (zone != 0) {
emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN);
nextRepeatY = millis() + JOY_REPEAT_DELAY_MS;
}
}
}
} else if (type == EV_KEY && value == 1) {
// Look up the configured action for this button; unmapped buttons are ignored.
// Buttons fire once per press (no auto-repeat).
auto mapped = buttonMap.find(code);
if (mapped != buttonMap.end())
e.inputEvent = mapped->second;
}
if (e.inputEvent != INPUT_BROKER_NONE) {
LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent);
this->notifyObservers(&e);
emitEvent(mapped->second);
}
}
}
// Auto-repeat held D-pad directions. Signed comparison is wraparound-safe.
uint32_t now = millis();
if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) {
emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT);
nextRepeatX = now + JOY_REPEAT_INTERVAL_MS;
}
if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) {
emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN);
nextRepeatY = now + JOY_REPEAT_INTERVAL_MS;
}
return 50; // Poll every 50msec
}

View File

@@ -20,6 +20,10 @@
#define JOY_AXIS_LOW 64 // below this -> "min" edge (0)
#define JOY_AXIS_HIGH 192 // above this -> "max" edge (255)
// D-pad auto-repeat while a direction is held (typematic).
#define JOY_REPEAT_DELAY_MS 400 // hold this long before repeats start
#define JOY_REPEAT_INTERVAL_MS 150 // then repeat this often
class LinuxJoystick : public Observable<const InputEvent *>, public concurrency::OSThread
{
public:
@@ -27,10 +31,18 @@ class LinuxJoystick : public Observable<const InputEvent *>, public concurrency:
void init(); // Registers this source with the InputBroker
void deInit(); // Strictly for cleanly "rebooting" the binary on native
// Current held D-pad zone, updated the instant the axis moves (independent of the typematic
// auto-repeat). -1 = left/up edge, 0 = centered, +1 = right/down edge. Lets a game poll for
// smooth continuous control instead of waiting for the slow repeat events.
int heldXZone() const { return heldX; }
int heldYZone() const { return heldY; }
protected:
virtual int32_t runOnce() override;
private:
void emitEvent(input_broker_event event);
const char *_originName;
bool firstTime = true;
@@ -42,10 +54,12 @@ class LinuxJoystick : public Observable<const InputEvent *>, public concurrency:
int fd = -1;
int epollfd = -1;
// Latch the last emitted axis position so a held direction fires exactly
// once (on the transition away from center), not a continuous stream.
int lastX = JOY_AXIS_CENTER;
int lastY = JOY_AXIS_CENTER;
// D-pad auto-repeat state: currently held zone per axis (-1 / 0 / +1) and the
// next millis() timestamp at which to re-emit while the direction is held.
int heldX = 0;
int heldY = 0;
uint32_t nextRepeatX = 0;
uint32_t nextRepeatY = 0;
};
extern LinuxJoystick *aLinuxJoystick;
#endif

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

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

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

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

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
src/modules/games/Game.h Normal file
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

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

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

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
src/modules/games/Snake.cpp Normal file
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
src/modules/games/Snake.h Normal file
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

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
src/modules/games/Tetris.h Normal file
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

View File

@@ -0,0 +1,163 @@
#include "configuration.h"
#if defined(HAS_HUB75_NATIVE)
#include "HUB75Native.h"
#include "PortduinoGlue.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include <led-matrix.h>
HUB75Native::HUB75Native(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C)
{
// The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 page layout). The
// panel size comes from config.yaml at runtime (cols*chain x rows*parallel, computed in
// loadConfig()). Config is fully loaded by portduinoSetup() before Screen constructs.
setGeometry(GEOMETRY_RAWMODE, portduino_config.displayWidth, portduino_config.displayHeight);
LOG_DEBUG("HUB75Native %dx%d", portduino_config.displayWidth, portduino_config.displayHeight);
}
HUB75Native::~HUB75Native()
{
if (matrix) {
delete matrix;
matrix = nullptr;
}
}
// Bring up the rpi-rgb-led-matrix driver from config.yaml. The library owns the GPIO pins
// (selected by HardwareMapping) and runs its own refresh thread, so there is no DMA/I2S setup.
bool HUB75Native::connect()
{
LOG_INFO("Do HUB75 init");
rgb_matrix::RGBMatrix::Options options;
options.hardware_mapping = portduino_config.hub75_hardware_mapping.c_str();
options.rows = portduino_config.hub75_rows;
options.cols = portduino_config.hub75_cols;
options.chain_length = portduino_config.hub75_chain_length;
options.parallel = portduino_config.hub75_parallel;
options.pwm_bits = portduino_config.hub75_pwm_bits;
options.pwm_lsb_nanoseconds = portduino_config.hub75_pwm_lsb_nanoseconds;
options.brightness = portduino_config.hub75_brightness;
options.scan_mode = portduino_config.hub75_scan_mode;
options.row_address_type = portduino_config.hub75_row_address_type;
options.multiplexing = portduino_config.hub75_multiplexing;
options.disable_hardware_pulsing = portduino_config.hub75_disable_hardware_pulsing;
options.show_refresh_rate = portduino_config.hub75_show_refresh_rate;
options.inverse_colors = portduino_config.hub75_inverse_colors;
// RGB order is handled by the library instead of a software swap (see display()).
options.led_rgb_sequence = portduino_config.hub75_led_rgb_sequence.c_str();
options.limit_refresh_rate_hz = portduino_config.hub75_limit_refresh_rate_hz;
if (!portduino_config.hub75_pixel_mapper_config.empty())
options.pixel_mapper_config = portduino_config.hub75_pixel_mapper_config.c_str();
if (!portduino_config.hub75_panel_type.empty())
options.panel_type = portduino_config.hub75_panel_type.c_str();
rgb_matrix::RuntimeOptions runtime;
runtime.gpio_slowdown = portduino_config.hub75_gpio_slowdown;
runtime.daemon = 0; // run in-process; the library starts its own refresh thread
runtime.drop_privileges = 0; // meshtasticd manages its own privileges (keeps GPIO/LoRa access)
matrix = rgb_matrix::CreateMatrixFromOptions(options, runtime);
if (matrix == nullptr) {
LOG_ERROR("HUB75 CreateMatrixFromOptions() failed (need root / /dev/gpiomem on a Pi?)");
return false;
}
brightness = portduino_config.hub75_brightness; // library scale is 1..100
matrix->SetBrightness(brightness); // applies to the matrix and all its FrameCanvases
matrix->Clear();
// Offscreen buffer for double-buffered, tear-free presentation (see display()).
offscreen = matrix->CreateFrameCanvas();
return true;
}
void HUB75Native::display()
{
if (!matrix || !offscreen)
return;
const uint16_t onNative = graphics::TFTPalette::White;
const uint16_t offNative = graphics::getThemeBodyBg();
const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8));
const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8));
#if GRAPHICS_TFT_COLORING_ENABLED
const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
#endif
// Full-frame redraw into the offscreen canvas every call (no dirty-diff needed at these
// resolutions), then SwapOnVSync() below presents it atomically for tear-free output.
for (uint16_t y = 0; y < displayHeight; y++) {
const uint32_t yByteIndex = (y / 8) * displayWidth;
const uint8_t yByteMask = (uint8_t)(1 << (y & 7));
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions)
graphics::beginTFTColorRow((int16_t)y);
#endif
for (uint16_t x = 0; x < displayWidth; x++) {
const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0;
uint16_t be;
#if GRAPHICS_TFT_COLORING_ENABLED
if (hasColorRegions)
be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe);
else
be = isset ? onBe : offBe;
#else
be = isset ? onBe : offBe;
#endif
const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565
const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3);
const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2);
const uint8_t b = (uint8_t)((c & 0x1F) << 3);
offscreen->SetPixel(x, y, r, g, b);
}
}
#if GRAPHICS_TFT_COLORING_ENABLED
// Regions are re-registered every frame by the renderers; clear so they don't accumulate.
graphics::clearTFTColorRegions();
#endif
// Present the finished frame at the next vsync; the returned canvas (the previously shown one)
// becomes our next offscreen buffer.
offscreen = matrix->SwapOnVSync(offscreen);
}
void HUB75Native::sendCommand(uint8_t com)
{
if (!matrix || !offscreen)
return;
switch (com) {
case DISPLAYON:
matrix->SetBrightness(brightness);
break;
case DISPLAYOFF:
// rpi-rgb-led-matrix clamps brightness 0 up to 1 and its refresh thread keeps scanning the
// presented canvas, so SetBrightness(0) alone leaves the last frame faintly lit. Present a
// cleared frame so the panel actually goes black while asleep. (matrix->Clear() would only
// clear the RGBMatrix's own buffer, not the FrameCanvas we swap in.) Wake repaints via the
// next display().
offscreen->Clear();
offscreen = matrix->SwapOnVSync(offscreen);
break;
default:
// Drop all other SSD1306 init/config commands - not meaningful for the matrix.
break;
}
}
void HUB75Native::setDisplayBrightness(uint8_t _brightness)
{
brightness = _brightness;
if (matrix)
matrix->SetBrightness(brightness);
}
#endif // HAS_HUB75_NATIVE

View File

@@ -1017,6 +1017,39 @@ bool loadConfig(const char *configPath)
}
}
}
#if !defined(HAS_HUB75_NATIVE)
if (portduino_config.displayPanel == hub75) {
std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl;
exit(EXIT_FAILURE);
}
#endif
// HUB75 RGB matrix (Raspberry Pi). Options map onto rgb_matrix::RGBMatrix::Options +
// RuntimeOptions; the library owns its GPIO pins so nothing is read via readGPIOFromYaml.
if (portduino_config.displayPanel == hub75 && yamlConfig["Display"]["HUB75"]) {
YAML::Node hub75 = yamlConfig["Display"]["HUB75"];
portduino_config.hub75_hardware_mapping = hub75["HardwareMapping"].as<std::string>("regular");
portduino_config.hub75_rows = hub75["Rows"].as<int>(64);
portduino_config.hub75_cols = hub75["Cols"].as<int>(64);
portduino_config.hub75_chain_length = hub75["ChainLength"].as<int>(1);
portduino_config.hub75_parallel = hub75["Parallel"].as<int>(1);
portduino_config.hub75_pwm_bits = hub75["PWMBits"].as<int>(11);
portduino_config.hub75_pwm_lsb_nanoseconds = hub75["PWMLSBNanoseconds"].as<int>(130);
portduino_config.hub75_brightness = hub75["Brightness"].as<int>(100);
portduino_config.hub75_scan_mode = hub75["ScanMode"].as<int>(0);
portduino_config.hub75_row_address_type = hub75["RowAddressType"].as<int>(0);
portduino_config.hub75_multiplexing = hub75["Multiplexing"].as<int>(0);
portduino_config.hub75_disable_hardware_pulsing = hub75["DisableHardwarePulsing"].as<bool>(false);
portduino_config.hub75_show_refresh_rate = hub75["ShowRefreshRate"].as<bool>(false);
portduino_config.hub75_inverse_colors = hub75["InverseColors"].as<bool>(false);
portduino_config.hub75_led_rgb_sequence = hub75["RGBSequence"].as<std::string>("RGB");
portduino_config.hub75_pixel_mapper_config = hub75["PixelMapper"].as<std::string>("");
portduino_config.hub75_panel_type = hub75["PanelType"].as<std::string>("");
portduino_config.hub75_limit_refresh_rate_hz = hub75["LimitRefreshRateHz"].as<int>(0);
portduino_config.hub75_gpio_slowdown = hub75["GPIOSlowdown"].as<int>(1);
// The BaseUI framebuffer geometry is the full panel size in pixels.
portduino_config.displayWidth = portduino_config.hub75_cols * portduino_config.hub75_chain_length;
portduino_config.displayHeight = portduino_config.hub75_rows * portduino_config.hub75_parallel;
}
}
if (yamlConfig["Touchscreen"]) {
if (yamlConfig["Touchscreen"]["Module"].as<std::string>("") == "XPT2046")

View File

@@ -34,7 +34,7 @@ inline const std::unordered_map<std::string, std::string> configProducts = {
{"RAK6421-13300-S1", "lora-RAK6421-13300-slot1.yaml"},
{"RAK6421-13300-S2", "lora-RAK6421-13300-slot2.yaml"}};
enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d };
enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d, hub75 };
enum touchscreen_modules { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 };
enum portduino_log_level { level_error, level_warn, level_info, level_debug, level_trace };
enum lora_module_enum {
@@ -83,7 +83,7 @@ extern struct portduino_config_struct {
std::map<screen_modules, std::string> screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"},
{st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"},
{ili9341, "ILI9341"}, {ili9342, "ILI9342"}, {ili9486, "ILI9486"},
{ili9488, "ILI9488"}, {hx8357d, "HX8357D"}};
{ili9488, "ILI9488"}, {hx8357d, "HX8357D"}, {hub75, "HUB75"}};
lora_module_enum lora_module;
bool has_rfswitch_table = false;
@@ -147,6 +147,29 @@ extern struct portduino_config_struct {
pinMapping displayBacklightPWMChannel = {"Display", "BacklightPWMChannel"};
pinMapping displayReset = {"Display", "Reset"};
// Display -> HUB75 (Raspberry Pi RGB matrix via hzeller/rpi-rgb-led-matrix).
// These mirror rgb_matrix::RGBMatrix::Options + RuntimeOptions; the library owns the GPIO
// pins (chosen by hub75_hardware_mapping), so there are no per-pin mappings here.
std::string hub75_hardware_mapping = "regular";
int hub75_rows = 64;
int hub75_cols = 64;
int hub75_chain_length = 1;
int hub75_parallel = 1;
int hub75_pwm_bits = 11;
int hub75_pwm_lsb_nanoseconds = 130;
int hub75_brightness = 100; // percent, 1..100
int hub75_scan_mode = 0;
int hub75_row_address_type = 0;
int hub75_multiplexing = 0;
bool hub75_disable_hardware_pulsing = false;
bool hub75_show_refresh_rate = false;
bool hub75_inverse_colors = false;
std::string hub75_led_rgb_sequence = "RGB";
std::string hub75_pixel_mapper_config = "";
std::string hub75_panel_type = "";
int hub75_limit_refresh_rate_hz = 0;
int hub75_gpio_slowdown = 1; // RuntimeOptions; higher for faster Pis / long cables
// Touchscreen
std::string touchscreen_spi_dev = "";
int touchscreen_spi_dev_int = 0;
@@ -423,6 +446,30 @@ extern struct portduino_config_struct {
out << YAML::Key << "OffsetRotate" << YAML::Value << displayOffsetRotate;
if (displayPanel == hub75) {
out << YAML::Key << "HUB75" << YAML::Value << YAML::BeginMap;
out << YAML::Key << "HardwareMapping" << YAML::Value << hub75_hardware_mapping;
out << YAML::Key << "Rows" << YAML::Value << hub75_rows;
out << YAML::Key << "Cols" << YAML::Value << hub75_cols;
out << YAML::Key << "ChainLength" << YAML::Value << hub75_chain_length;
out << YAML::Key << "Parallel" << YAML::Value << hub75_parallel;
out << YAML::Key << "PWMBits" << YAML::Value << hub75_pwm_bits;
out << YAML::Key << "PWMLSBNanoseconds" << YAML::Value << hub75_pwm_lsb_nanoseconds;
out << YAML::Key << "Brightness" << YAML::Value << hub75_brightness;
out << YAML::Key << "ScanMode" << YAML::Value << hub75_scan_mode;
out << YAML::Key << "RowAddressType" << YAML::Value << hub75_row_address_type;
out << YAML::Key << "Multiplexing" << YAML::Value << hub75_multiplexing;
out << YAML::Key << "DisableHardwarePulsing" << YAML::Value << hub75_disable_hardware_pulsing;
out << YAML::Key << "ShowRefreshRate" << YAML::Value << hub75_show_refresh_rate;
out << YAML::Key << "InverseColors" << YAML::Value << hub75_inverse_colors;
out << YAML::Key << "RGBSequence" << YAML::Value << hub75_led_rgb_sequence;
out << YAML::Key << "PixelMapper" << YAML::Value << hub75_pixel_mapper_config;
out << YAML::Key << "PanelType" << YAML::Value << hub75_panel_type;
out << YAML::Key << "LimitRefreshRateHz" << YAML::Value << hub75_limit_refresh_rate_hz;
out << YAML::Key << "GPIOSlowdown" << YAML::Value << hub75_gpio_slowdown;
out << YAML::EndMap; // HUB75
}
out << YAML::EndMap; // Display
}

View File

@@ -30,4 +30,12 @@
#define TB_LEFT (uint8_t) portduino_config.tbLeftPin.pin
#define TB_RIGHT (uint8_t) portduino_config.tbRightPin.pin
#define TB_PRESS (uint8_t) portduino_config.tbPressPin.pin
#endif
// HUB75 RGB-matrix panel support on Raspberry Pi (Portduino) turns on automatically when the
// hzeller/rpi-rgb-led-matrix library is installed - its headers land on the include path via
// `pkg-config rgbmatrix` (wired up in variants/native/portduino/platformio.ini). When absent,
// HAS_HUB75_NATIVE stays undefined and the backend compiles out. See src/graphics/HUB75Display.cpp.
#if defined(ARCH_PORTDUINO) && __has_include(<led-matrix.h>)
#define HAS_HUB75_NATIVE 1
#endif

View File

@@ -1 +1 @@
32
35

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() {}
}

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() {}
}

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() {}
}

View File

@@ -0,0 +1,20 @@
; Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) + 128x64 HUB75 matrix + Wio-SX1262 - DIY
[env:visualizer-hub75]
extends = esp32s3_base
board = esp32-s3-devkitc-1
board_check = true
board_build.partitions = default_16MB.csv
board_level = extra
board_upload.flash_size = 16MB ; N16R8 -> 16MB flash
board_build.arduino.memory_type = qio_opi ; N16R8 -> 8MB octal (OPI) PSRAM
build_flags =
${esp32s3_base.build_flags}
-D VISUALIZER_HUB75
-D BOARD_HAS_PSRAM
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
-I variants/esp32s3/visualizer-hub75
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=github-tags depName=ESP32-HUB75-MatrixPanel-I2S-DMA packageName=mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA
https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA/archive/refs/tags/3.0.14.zip

View File

@@ -0,0 +1,66 @@
// Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) driving a 128x64 HUB75 RGB matrix
// via the seengreat "RGB Matrix Adapter Board (E)" Rev. 2.2 (V2.x), plus a
// Wio-SX1262 (for XIAO, Seeed p/n 6379) LoRa header board.
#define BUTTON_PIN 0
#define I2C_SDA 41
#define I2C_SCL 42
// --- Display: HUB75 via seengreat Shield (V2.x, hard-wired) -----------------
#define HAS_SPI_TFT 1 // enable the color BaseUI path (TFTColorRegions/theming)
#define DISPLAY_FORCE_SMALL_FONTS // 128x64 panel: keep OLED-sized fonts, not big TFT fonts
#define USE_HUB75 // select HUB75Display in Screen.cpp
#define TFT_WIDTH 128
#define TFT_HEIGHT 64
#define HUB75_BRIGHTNESS_DEFAULT 180
// RGB data lines.
// This panel is BGR: R and B are swapped per half vs. the nominal seengreat
// mapping (R1<->B1, R2<->B2), verified at bring-up. Software-only fix; the
// shield board is unchanged. G / address / control lines stay as routed.
#define HUB75_R1 17
#define HUB75_G1 8
#define HUB75_B1 18
#define HUB75_R2 15
#define HUB75_G2 1
#define HUB75_B2 16
// Row-address lines (1/32 scan -> A..E)
#define HUB75_A 7
#define HUB75_B 48
#define HUB75_C 6
#define HUB75_D 47
#define HUB75_E 2
// Control lines
#define HUB75_CLK 5
#define HUB75_LAT 21
#define HUB75_OE 4
// --- Radio: Wio-SX1262 for XIAO (header board, Seeed p/n 6379) ---------------
#define USE_SX1262
#define LORA_SCK 12
#define LORA_MISO 13
#define LORA_MOSI 11
#define LORA_CS 10
#define LORA_RESET 9
#define LORA_DIO1 40
#define SX126X_CS LORA_CS
#define SX126X_SCK LORA_SCK
#define SX126X_MOSI LORA_MOSI
#define SX126X_MISO LORA_MISO
#define SX126X_DIO1 LORA_DIO1
#define SX126X_BUSY 14
#define SX126X_RESET LORA_RESET
#define SX126X_DIO2_AS_RF_SWITCH
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
#define SX126X_RXEN 39
#define SX126X_TXEN RADIOLIB_NC
#define SX126X_MAX_POWER 22
#define LED_HEARTBEAT 38

View File

@@ -56,6 +56,8 @@ build_flags_common =
-luv
-std=gnu17
-std=gnu++17
-DBASEUI_HAS_GAMES=1
-DMAX_TFT_COLOR_REGIONS=64
build_flags =
${portduino_base.build_flags_common}

View File

@@ -0,0 +1,57 @@
#pragma once
#include "configuration.h"
#if defined(HAS_HUB75_NATIVE)
#include <OLEDDisplay.h>
#ifndef HUB75_BRIGHTNESS_DEFAULT
#define HUB75_BRIGHTNESS_DEFAULT 100 // rpi-rgb-led-matrix brightness is a 1..100 percentage
#endif
namespace rgb_matrix
{
class RGBMatrix;
class FrameCanvas;
} // namespace rgb_matrix
/**
* Native (Raspberry Pi / Portduino) HUB75 RGB-matrix display backend.
*
* Drives the panel with hzeller/rpi-rgb-led-matrix. Like the ESP32 HUB75Display it plugs into the
* BaseUI color path by subclassing OLEDDisplay and expanding the mono framebuffer into RGB via the
* shared TFTColorRegions/TFTPalette helpers. It is a separate class (not shared with the ESP32
* driver) so no rpi-rgb-led-matrix code lives in src/graphics/. All wiring/tuning comes from
* config.yaml (portduino_config.hub75_*); the library owns the GPIO pins.
*
* Bodies live in src/platform/portduino/HUB75Native.cpp (compiled native-only).
*/
class HUB75Native : public OLEDDisplay
{
public:
HUB75Native(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
~HUB75Native();
// Redraw the whole panel from the BaseUI buffer (region-aware color expansion).
void display() override;
void setDisplayBrightness(uint8_t brightness);
protected:
int getBufferOffset(void) override { return 0; }
void sendCommand(uint8_t com) override;
bool connect() override;
private:
rgb_matrix::RGBMatrix *matrix = nullptr;
// Offscreen buffer for tear-free updates: display() draws here, then SwapOnVSync() atomically
// presents it. Without this, writing into the live canvas while the refresh thread scans it
// tears (worse when display() is contended during packet TX/RX).
rgb_matrix::FrameCanvas *offscreen = nullptr;
uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; // 1..100
};
#endif // HAS_HUB75_NATIVE

View File

@@ -20,6 +20,11 @@ build_flags = ${native_base.build_flags}
!pkg-config --libs openssl --silence-errors || :
!pkg-config --cflags --libs sdl2 --silence-errors || :
!pkg-config --cflags --libs libbsd-overlay --silence-errors || :
; Optional HUB75 RGB-matrix support on Raspberry Pi via hzeller/rpi-rgb-led-matrix.
; When the lib is installed (provides rgbmatrix.pc), these add its include/link flags and
; __has_include(<led-matrix.h>) enables HAS_HUB75_NATIVE (see configuration.h). Absent -> no-op.
!pkg-config --cflags rgbmatrix --silence-errors || :
!pkg-config --libs rgbmatrix --silence-errors || :
[env:native-tft]
extends = native_base