From 8d1fbbf55f7b9ea8aeb9bef7625407cdc3ebf7bc Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Jul 2026 18:35:33 -0500 Subject: [PATCH] Snake! (#10936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Ixitxachitl Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- bin/config-dist.yaml | 25 +- bin/config.d/display-hub75-64x64.yaml | 37 ++ src/configuration.h | 5 + src/graphics/HUB75Display.cpp | 156 ++++++ src/graphics/HUB75Display.h | 48 ++ src/graphics/Screen.cpp | 100 +++- src/graphics/Screen.h | 13 + src/graphics/TFTColorRegions.h | 5 +- src/graphics/draw/DebugRenderer.cpp | 20 +- src/graphics/draw/NotificationRenderer.cpp | 94 ++++ src/graphics/draw/NotificationRenderer.h | 2 + src/graphics/draw/UIRenderer.cpp | 10 + src/graphics/images.h | 40 ++ src/input/LinuxJoystick.cpp | 59 ++- src/input/LinuxJoystick.h | 22 +- src/modules/Modules.cpp | 8 + src/modules/games/Breakout.cpp | 315 +++++++++++ src/modules/games/Breakout.h | 138 +++++ src/modules/games/ChirpyRunner.cpp | 303 +++++++++++ src/modules/games/ChirpyRunner.h | 177 +++++++ src/modules/games/Game.h | 57 ++ src/modules/games/GamesModule.cpp | 373 +++++++++++++ src/modules/games/GamesModule.h | 101 ++++ src/modules/games/HighScoreTable.h | 176 +++++++ src/modules/games/Snake.cpp | 264 ++++++++++ src/modules/games/Snake.h | 152 ++++++ src/modules/games/Tetris.cpp | 494 ++++++++++++++++++ src/modules/games/Tetris.h | 156 ++++++ src/platform/portduino/HUB75Native.cpp | 163 ++++++ src/platform/portduino/PortduinoGlue.cpp | 33 ++ src/platform/portduino/PortduinoGlue.h | 51 +- src/platform/portduino/architecture.h | 8 + test/native-suite-count | 2 +- test/test_breakout/test_main.cpp | 103 ++++ test/test_chirpy/test_main.cpp | 100 ++++ test/test_snake/test_main.cpp | 180 +++++++ .../esp32s3/visualizer-hub75/platformio.ini | 20 + variants/esp32s3/visualizer-hub75/variant.h | 66 +++ variants/native/portduino.ini | 2 + variants/native/portduino/HUB75Native.h | 57 ++ variants/native/portduino/platformio.ini | 5 + 41 files changed, 4106 insertions(+), 34 deletions(-) create mode 100644 bin/config.d/display-hub75-64x64.yaml create mode 100644 src/graphics/HUB75Display.cpp create mode 100644 src/graphics/HUB75Display.h create mode 100644 src/modules/games/Breakout.cpp create mode 100644 src/modules/games/Breakout.h create mode 100644 src/modules/games/ChirpyRunner.cpp create mode 100644 src/modules/games/ChirpyRunner.h create mode 100644 src/modules/games/Game.h create mode 100644 src/modules/games/GamesModule.cpp create mode 100644 src/modules/games/GamesModule.h create mode 100644 src/modules/games/HighScoreTable.h create mode 100644 src/modules/games/Snake.cpp create mode 100644 src/modules/games/Snake.h create mode 100644 src/modules/games/Tetris.cpp create mode 100644 src/modules/games/Tetris.h create mode 100644 src/platform/portduino/HUB75Native.cpp create mode 100644 test/test_breakout/test_main.cpp create mode 100644 test/test_chirpy/test_main.cpp create mode 100644 test/test_snake/test_main.cpp create mode 100644 variants/esp32s3/visualizer-hub75/platformio.ini create mode 100644 variants/esp32s3/visualizer-hub75/variant.h create mode 100644 variants/native/portduino/HUB75Native.h diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 4b4fe0b826..46a044e8dd 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -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 diff --git a/bin/config.d/display-hub75-64x64.yaml b/bin/config.d/display-hub75-64x64.yaml new file mode 100644 index 0000000000..704a4c6b9b --- /dev/null +++ b/bin/config.d/display-hub75-64x64.yaml @@ -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 diff --git a/src/configuration.h b/src/configuration.h index eb3db7e464..672d8b6f19 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -424,6 +424,11 @@ along with this program. If not, see . #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 diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp new file mode 100644 index 0000000000..36ca64f399 --- /dev/null +++ b/src/graphics/HUB75Display.cpp @@ -0,0 +1,156 @@ +#include "configuration.h" + +#if defined(USE_HUB75) + +#include "HUB75Display.h" +#include "TFTColorRegions.h" +#include "TFTPalette.h" +#include +#include + +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 diff --git a/src/graphics/HUB75Display.h b/src/graphics/HUB75Display.h new file mode 100644 index 0000000000..aa17a5a2a6 --- /dev/null +++ b/src/graphics/HUB75Display.h @@ -0,0 +1,48 @@ +#pragma once + +#include "configuration.h" + +#if defined(USE_HUB75) + +#include + +#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 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 024fa409f2..73bf04b6db 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -29,6 +29,12 @@ along with this program. If not, see . #if HAS_SCREEN #include "EInkParallelDisplay.h" #include +#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 . #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 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(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 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 diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index d728269969..4aeec6ce8d 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -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 bannerCallback); void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, std::function 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 bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function 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; diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h index d0517ad4ee..9de74b85a4 100644 --- a/src/graphics/TFTColorRegions.h +++ b/src/graphics/TFTColorRegions.h @@ -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 diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 243a62932e..b4dd5ab8af 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -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); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 03e53c3bd7..98f229b838 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -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 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(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(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(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; diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 629cf61d80..08f6f74b05 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -26,6 +26,7 @@ class NotificationRenderer static std::function 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 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], diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 2e2a35f189..f38d27d7d7 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -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; diff --git a/src/graphics/images.h b/src/graphics/images.h index 95c0861fd2..86d9efb7ba 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -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 diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index aaff439592..f6848a5908 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -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 } diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h index 6e6d3c398f..7e309b9616 100644 --- a/src/input/LinuxJoystick.h +++ b/src/input/LinuxJoystick.h @@ -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, public concurrency::OSThread { public: @@ -27,10 +31,18 @@ class LinuxJoystick : public Observable, 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, 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 diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 1e93805750..546651c5bc 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -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 diff --git a/src/modules/games/Breakout.cpp b/src/modules/games/Breakout.cpp new file mode 100644 index 0000000000..f60f4d8bf3 --- /dev/null +++ b/src/modules/games/Breakout.cpp @@ -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(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(BOARD_W / 2) * SUBPX; + ballPxY = static_cast(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(ballPxX / SUBPX); + int16_t py = static_cast(ballPxY / SUBPX); + + // Side walls. + if (px <= 0) { + ballPxX = 0; + px = 0; + ballVx = -ballVx; + } else if (px >= BOARD_W - 1) { + ballPxX = static_cast(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(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(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(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(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(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 diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h new file mode 100644 index 0000000000..9c4fe8dfb3 --- /dev/null +++ b/src/modules/games/Breakout.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include + +/** + * 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(ballPxX / SUBPX); } + int16_t ballY() const { return static_cast(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 scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp new file mode 100644 index 0000000000..4e1a185217 --- /dev/null +++ b/src/modules/games/ChirpyRunner.cpp @@ -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(GAP_STEPS_MIN + static_cast(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(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX; + cloud[i].y = static_cast(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(BOARD_W + nextRandom() % 24u) * SUBPX; + cloud[i].y = static_cast(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(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((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(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(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(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(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 diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h new file mode 100644 index 0000000000..b86e1d7757 --- /dev/null +++ b/src/modules/games/ChirpyRunner.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include + +/** + * 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(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(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(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(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 scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h new file mode 100644 index 0000000000..751b2d6b5c --- /dev/null +++ b/src/modules/games/Game.h @@ -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 + +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 diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp new file mode 100644 index 0000000000..1d72ba632a --- /dev/null +++ b/src/modules/games/GamesModule.cpp @@ -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(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(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes), + GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast(score)); + service->sendToMesh(p); + LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(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(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(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(i + 1), scores.nameAt(i), + static_cast(scores.scoreAt(i))); + } else { + snprintf(row, sizeof(row), "%u. ---", static_cast(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(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 diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h new file mode 100644 index 0000000000..eb4642f196 --- /dev/null +++ b/src/modules/games/GamesModule.h @@ -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 + +// 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, 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 *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 inputObserver = + CallbackObserver(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 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 diff --git a/src/modules/games/HighScoreTable.h b/src/modules/games/HighScoreTable.h new file mode 100644 index 0000000000..b833ed05a2 --- /dev/null +++ b/src/modules/games/HighScoreTable.h @@ -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 +#include +#include +#include + +/** + * 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 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(&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(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(&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 diff --git a/src/modules/games/Snake.cpp b/src/modules/games/Snake.cpp new file mode 100644 index 0000000000..13dc616f1a --- /dev/null +++ b/src/modules/games/Snake.cpp @@ -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(cx - (START_LEN - 1) + i); + body[i] = {x, cy}; + setOcc(cellIndex(x, cy)); + len++; + } + headIdx = static_cast(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(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(nextRandom() % free); + for (uint16_t idx = 0; idx < CELL_COUNT; idx++) { + if (!getOcc(idx)) { + if (k == 0) { + foodCell = {static_cast(idx % GRID_W), static_cast(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(nx), static_cast(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((tailIdx + 1) % CAP); + len--; + } + + if (getOcc(nidx)) { + alive = false; + return false; + } + + // Advance the head into the new cell. + headIdx = static_cast((headIdx + 1) % CAP); + body[headIdx] = {static_cast(nx), static_cast(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(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(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(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(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(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 diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h new file mode 100644 index 0000000000..293707ff4e --- /dev/null +++ b/src/modules/games/Snake.h @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include + +/** + * 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(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(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(1u << (idx & 7)); } + void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast(~(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 scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp new file mode 100644 index 0000000000..7c24b32699 --- /dev/null +++ b/src/modules/games/Tetris.cpp @@ -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((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(p.row) + pr; + const int16_t bc = static_cast(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(cur.row) + pr; + const int16_t bc = static_cast(cur.col) + pc; + if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS) + board[br][bc] = static_cast(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(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(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(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(nextRandom() % PIECE_TYPES)); + nxt = spawnPiece(static_cast(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((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(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(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 +#include + +// --------------------------------------------------------------------------- +// 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(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(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(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(col) * CELL_PX, oy + static_cast(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(c), static_cast(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(cur.col + pc); + const int8_t gr = static_cast(ghostR + pr); + if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS) + continue; + display->setPixel(ox + static_cast(gc) * CELL_PX + 1, oy + static_cast(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(cur.col + pc), static_cast(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(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(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(pc) * PREV_PX, nxtPreviewY + static_cast(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(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(c0) * CELL_PX; + const int16_t ry = oy + static_cast(r) * CELL_PX; + const int16_t rw = static_cast(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap + graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast(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 diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h new file mode 100644 index 0000000000..4b429ec2ed --- /dev/null +++ b/src/modules/games/Tetris.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +/** + * 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 ¤t() 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 scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/platform/portduino/HUB75Native.cpp b/src/platform/portduino/HUB75Native.cpp new file mode 100644 index 0000000000..55f0c1415a --- /dev/null +++ b/src/platform/portduino/HUB75Native.cpp @@ -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 + +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 diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 3903da2e87..8da3d085b9 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -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("regular"); + portduino_config.hub75_rows = hub75["Rows"].as(64); + portduino_config.hub75_cols = hub75["Cols"].as(64); + portduino_config.hub75_chain_length = hub75["ChainLength"].as(1); + portduino_config.hub75_parallel = hub75["Parallel"].as(1); + portduino_config.hub75_pwm_bits = hub75["PWMBits"].as(11); + portduino_config.hub75_pwm_lsb_nanoseconds = hub75["PWMLSBNanoseconds"].as(130); + portduino_config.hub75_brightness = hub75["Brightness"].as(100); + portduino_config.hub75_scan_mode = hub75["ScanMode"].as(0); + portduino_config.hub75_row_address_type = hub75["RowAddressType"].as(0); + portduino_config.hub75_multiplexing = hub75["Multiplexing"].as(0); + portduino_config.hub75_disable_hardware_pulsing = hub75["DisableHardwarePulsing"].as(false); + portduino_config.hub75_show_refresh_rate = hub75["ShowRefreshRate"].as(false); + portduino_config.hub75_inverse_colors = hub75["InverseColors"].as(false); + portduino_config.hub75_led_rgb_sequence = hub75["RGBSequence"].as("RGB"); + portduino_config.hub75_pixel_mapper_config = hub75["PixelMapper"].as(""); + portduino_config.hub75_panel_type = hub75["PanelType"].as(""); + portduino_config.hub75_limit_refresh_rate_hz = hub75["LimitRefreshRateHz"].as(0); + portduino_config.hub75_gpio_slowdown = hub75["GPIOSlowdown"].as(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("") == "XPT2046") diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 050aaaf87a..b5a47787e1 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -34,7 +34,7 @@ inline const std::unordered_map 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_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 } diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h index b1698a4eb0..a35cecfb3e 100644 --- a/src/platform/portduino/architecture.h +++ b/src/platform/portduino/architecture.h @@ -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() +#define HAS_HUB75_NATIVE 1 #endif \ No newline at end of file diff --git a/test/native-suite-count b/test/native-suite-count index f5c89552bd..8f92bfdd49 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -32 +35 diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp new file mode 100644 index 0000000000..1ca2da5e2d --- /dev/null +++ b/test/test_breakout/test_main.cpp @@ -0,0 +1,103 @@ +#include "TestUtil.h" +#include "modules/games/Breakout.h" +#include + +// 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(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(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++) + game.step(); + TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast(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() {} +} diff --git a/test/test_chirpy/test_main.cpp b/test/test_chirpy/test_main.cpp new file mode 100644 index 0000000000..487b2830f3 --- /dev/null +++ b/test/test_chirpy/test_main.cpp @@ -0,0 +1,100 @@ +#include "TestUtil.h" +#include "modules/games/ChirpyRunner.h" +#include + +// 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() {} +} diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp new file mode 100644 index 0000000000..8668e66a53 --- /dev/null +++ b/test/test_snake/test_main.cpp @@ -0,0 +1,180 @@ +#include "TestUtil.h" +#include "modules/games/Snake.h" +#include + +// 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() {} +} diff --git a/variants/esp32s3/visualizer-hub75/platformio.ini b/variants/esp32s3/visualizer-hub75/platformio.ini new file mode 100644 index 0000000000..fb321da265 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/platformio.ini @@ -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 diff --git a/variants/esp32s3/visualizer-hub75/variant.h b/variants/esp32s3/visualizer-hub75/variant.h new file mode 100644 index 0000000000..0481024e38 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/variant.h @@ -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 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 00d2a3a94d..044f64166e 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -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} diff --git a/variants/native/portduino/HUB75Native.h b/variants/native/portduino/HUB75Native.h new file mode 100644 index 0000000000..3b9e2728eb --- /dev/null +++ b/variants/native/portduino/HUB75Native.h @@ -0,0 +1,57 @@ +#pragma once + +#include "configuration.h" + +#if defined(HAS_HUB75_NATIVE) + +#include + +#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 diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e71386b670..5694a0d3b5 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -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() 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