diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index cf0d56da26..2768080458 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -211,10 +211,18 @@ Input: # JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick ### Map evdev button codes (hex or decimal) to actions. Omit to use built-in ### defaults (select=0x121, cancel=0x122). Actions: select, cancel, back, up, -### down, left, right, user. +### down, left, right, user. Give an action a list of codes to bind several +### physical buttons to it -- e.g. the shoulder buttons alongside the D-pad. +### Games can tell which physical button was pressed even when several share an +### action: in Snake the shoulders turn relative to the snake's heading while +### the D-pad steers absolutely, and Start pauses in play while selecting +### everywhere else. +### The codes below are an example only -- confirm yours with `evtest`. # JoystickButtons: -# select: 0x122 -# cancel: 0x121 +# select: [0x121, 0x123, 0x129] # A, Y and Start +# cancel: [0x122, 0x120] # B and X +# left: [0x124] # L shoulder, alongside the D-pad +# right: [0x125] # R shoulder ### Standard User Button Config # UserButton: 6 diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh index b39f19277a..7cee12d7a8 100755 --- a/bin/test-config-check.sh +++ b/bin/test-config-check.sh @@ -376,6 +376,18 @@ assert "pin value that resolves to -1" 1 pin-unreadable.yaml check \ "Lora.CS is set, but its value could not be read as a pin number" \ "Result: 1 error, 0 warnings" +echo +echo "joystick buttons:" +# Keyed by action rather than by button so that one action can list several codes. +# The clean case is the regression guard on the list form staying accepted. +assert "several buttons bound to one action" 0 joystick-buttons.yaml check \ + "Result: 0 errors, 0 warnings" +assert "joystick mappings that do nothing" 0 joystick-buttons-bad.yaml check \ + "'fire' is not a recognised action" \ + "'BTN_SOUTH' is not an evdev button code" \ + "button 0x121 is mapped to both 'select' and 'cancel'" \ + "Result: 0 errors, 3 warnings" + echo echo "CH341 USB-SPI adapters:" # The Lora pins of a ch341 device are indexes on the adapter, driven by the usermode diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8d9c35ae42..e19400c304 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -2275,18 +2275,8 @@ int Screen::handleInputEvent(const InputEvent *event) // so long as a mesh module isn't using these events for some other purpose if (showingNormalScreen) { - // Ask any MeshModules if they're handling keyboard input right now - bool inputIntercepted = false; - for (MeshModule *module : moduleFrames) { - 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 + // Ask any MeshModules (and the games frame) if they're handling keyboard input right now + const bool inputIntercepted = anyModuleInterceptingInput(); // If no modules are using the input, move between frames if (!inputIntercepted) { @@ -2449,6 +2439,47 @@ bool Screen::isGamesFrameShown() return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games; } +void Screen::showHomeFrame() +{ + if (!ui) + return; + // Home is optional -- setFrames() only adds it when !hiddenFrames.home, leaving the position + // 255. Bouncing to nothing would strand the caller on the frame it wanted to leave, so fall + // back to the messages frame, which setFrames() always adds. + const uint8_t target = + (framesetInfo.positions.home != 255) ? framesetInfo.positions.home : framesetInfo.positions.textMessage; + if (target != 255) + ui->switchToFrame(target); +} + +bool Screen::anyModuleInterceptingInput() +{ + for (MeshModule *module : moduleFrames) { + if (module && module->interceptingKeyboardInput()) + return 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()) + return true; +#endif + return false; +} + +bool Screen::isInteractionBusy() +{ + // Something is holding the D-pad -- the user is mid-interaction. A modal module owns the whole + // screen; an intercepting one owns the keys on its own frame. + if (hasModalModule() || anyModuleInterceptingInput()) + return true; + // An interactive overlay (picker / text entry) is open. Showing a transient banner REPLACES the + // active overlay, so this would silently discard whatever the user was entering. A plain + // text_banner is itself transient, so superseding one of those is fine. + const notificationTypeEnum nt = NotificationRenderer::current_notification_type; + return nt != notificationTypeEnum::none && nt != notificationTypeEnum::text_banner; +} + } // namespace graphics #else diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 7a99c4820b..59548e34ea 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -260,7 +260,7 @@ class Screen : public concurrency::OSThread std::vector indicatorIcons; // Per-frame custom icon pointers #if defined(OLED_COMPACT_UI) - std::vector frameTitles; // Per-frame short labels, parallel to indicatorIcons + std::vector frameTitles; // Per-frame short labels, parallel to indicatorIcons #endif Screen(const Screen &) = delete; Screen &operator=(const Screen &) = delete; @@ -294,6 +294,17 @@ class Screen : public concurrency::OSThread // ignore D-pad input when the player has navigated to a different frame. bool isGamesFrameShown(); + // Jump straight to the home (device-focused) frame. Used to bounce back to a clearly "this is a + // Meshtastic node" screen after a game is left idle. Home is optional, so when it is hidden this + // falls back to the messages frame rather than staying put. + void showHomeFrame(); + + // True when the user is in the middle of something that must not be interrupted: a module (or + // game) is holding the D-pad, or an interactive overlay (picker / text entry) is open. Callers + // that would pop a transient banner should check this first -- a banner both covers the screen + // and REPLACES any interactive overlay, discarding a half-finished entry. + bool isInteractionBusy(); + bool isScreenOn() { return screenOn; } // Stores the last 4 of our hardware ID, to make finding the device for pairing easier @@ -850,6 +861,10 @@ class Screen : public concurrency::OSThread #endif /// UI helper for rendering to frames and switching between them + // True if any module frame -- or the games frame, which is not a moduleFrame -- is currently + // holding the D-pad. Shared by the input router and isInteractionBusy(). + bool anyModuleInterceptingInput(); + OLEDDisplayUi *ui; }; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 45a3e9760f..da723eb6c0 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -121,6 +121,18 @@ const StoredMessage *getNewestMessageForActiveThread() return nullptr; } +// Freetext compose is offered whenever the device can enter text at all: a physical +// keyboard, an on-screen keyboard driven by rotary/trackball/joystick, or a touchscreen +// virtual keyboard. +bool freetextAvailable() +{ +#if defined(USE_VIRTUAL_KEYBOARD) + return true; +#else + return kb_found || osk_found; +#endif +} + void launchReplyForMessage(const StoredMessage &message, bool freetext) { if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) { @@ -156,12 +168,7 @@ uint8_t test_count = 0; void menuHandler::loraMenu() { static const char *optionsArray[] = { - "Back", - "Device Role", - "Radio Preset", - "Frequency Slot", - "LoRa Region", - "Transmit Enabled", + "Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region", "Transmit Enabled", #if HAS_LORA_FEM "FEM LNA", #endif @@ -918,8 +925,8 @@ void menuHandler::replyMenu() optionsArray[options] = "With Preset"; optionsEnumArray[options++] = ReplyPreset; - // Freetext reply (only when keyboard exists) - if (kb_found) { + // Freetext reply (only when the device can enter text) + if (freetextAvailable()) { optionsArray[options] = "With Freetext"; optionsEnumArray[options++] = ReplyFreetext; } @@ -1285,7 +1292,7 @@ void menuHandler::textMessageBaseMenu() int options = 1; optionsArray[options] = "New Preset Msg"; optionsEnumArray[options++] = Preset; - if (kb_found) { + if (freetextAvailable()) { optionsArray[options] = "New Freetext Msg"; optionsEnumArray[options++] = Freetext; } @@ -1408,7 +1415,7 @@ void menuHandler::favoriteBaseMenu() } optionsEnumArray[options++] = Preset; - if (kb_found) { + if (freetextAvailable()) { optionsArray[options] = "New Freetext Msg"; optionsEnumArray[options++] = Freetext; } diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 284bfc6c4f..0790bd2340 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -1215,9 +1215,14 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht screen->setOn(true); } - if (!suppressBanner && !menuShowing && !screen->hasModalModule()) { + // Don't let the banner interrupt whatever the user is in the middle of -- it would cover an + // active module/game, and worse, a transient banner replaces any interactive overlay, so it + // would discard a half-entered picker / text entry (e.g. high-score initials). The message + // is still stored, its thread still selected below, and the unread indicator set, so nothing + // is lost -- the user just sees it once they're done. (isInteractionBusy() subsumes the + // modal-module check this guard used to make.) + if (!screen->isInteractionBusy() && !menuShowing && !suppressBanner) screen->showSimpleBanner(banner, inThread ? 1000 : 3000); - } } // Always focus into the correct conversation thread when a message with real text arrives diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index e30e84ff7b..2d5bdfc1e0 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -50,6 +50,48 @@ enum input_broker_event { #define INPUT_BROKER_MSG_TAB 0x09 #define INPUT_BROKER_MSG_EMOTE_LIST 0x8F +// Which physical joystick/gamepad button produced an event, carried in InputEvent::kbchar +// beside the action it is mapped to. Several buttons may share one action, so this is what +// lets a game tell SELECT-pressed-on-Y from SELECT-pressed-on-B and use more inputs than the +// handful of actions the broker defines. +// +// The value is the button's evdev code offset into a reserved kbchar range. 0x120..0x13f spans +// both the classic joystick codes (BTN_TRIGGER..BTN_DEAD) and the modern gamepad ones +// (BTN_SOUTH..BTN_THUMBR). The range deliberately misses printable ASCII (0x20-0x7e, which +// CannedMessages appends to a message) and every INPUT_BROKER_MSG_ value above: SystemCommands +// switches on kbchar without looking at inputEvent, so a collision there would toggle Bluetooth +// or reboot the node rather than move a paddle. +#define INPUT_BROKER_MSG_JOY_BUTTON_FIRST 0xC0 +#define INPUT_BROKER_MSG_JOY_BUTTON_LAST 0xDF +#define INPUT_BROKER_JOY_CODE_FIRST 0x120 +#define INPUT_BROKER_JOY_CODE_LAST 0x13F + +// evdev button code -> the kbchar that reports it, or 0 for a code outside the encodable range +// (0 is also "no button", which is what every non-joystick source leaves in kbchar). +constexpr unsigned char joyButtonToKbchar(int code) +{ + return (code >= INPUT_BROKER_JOY_CODE_FIRST && code <= INPUT_BROKER_JOY_CODE_LAST) + ? (unsigned char)(INPUT_BROKER_MSG_JOY_BUTTON_FIRST + (code - INPUT_BROKER_JOY_CODE_FIRST)) + : 0; +} + +// True if this kbchar names a gamepad button, i.e. the event came from a real button press +// rather than from a D-pad axis (which has no button and leaves kbchar 0) or a keyboard key. +// Lets a consumer treat "LEFT from a shoulder button" differently from "LEFT from the D-pad" +// without hardcoding one pad's button codes. +constexpr bool isJoyButton(unsigned char kbchar) +{ + return kbchar >= INPUT_BROKER_MSG_JOY_BUTTON_FIRST && kbchar <= INPUT_BROKER_MSG_JOY_BUTTON_LAST; +} + +// The Start button. Map it to "select" like any other button: it selects normally, and a consumer +// that wants Start specifically (GamesModule pauses on it) picks it out of kbchar. 0x129 +// (BTN_BASE4) is Start on the classic 10-button pads, 0x13b (BTN_START) on modern gamepads. +constexpr bool isJoyStartButton(unsigned char kbchar) +{ + return kbchar == joyButtonToKbchar(0x129) || kbchar == joyButtonToKbchar(0x13b); +} + typedef struct _InputEvent { const char *source; input_broker_event inputEvent; diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index d8f99ccec0..826e49ad96 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -79,12 +79,15 @@ static int axisZone(int value) return 0; } -void LinuxJoystick::emitEvent(input_broker_event event) +// kbchar carries which physical button produced the event (0 for the D-pad, which is an axis +// rather than a button). Several buttons can be mapped to one action, so this is how a consumer +// tells them apart -- see joyButtonToKbchar() in InputBroker.h. +void LinuxJoystick::emitEvent(input_broker_event event, unsigned char kbchar) { InputEvent e = {}; e.inputEvent = event; e.source = this->_originName; - e.kbchar = 0; + e.kbchar = kbchar; // LOG_DEBUG("joystick: %s event %d", this->_originName, event); this->notifyObservers(&e); } @@ -158,7 +161,7 @@ int32_t LinuxJoystick::runOnce() // Buttons fire once per press (no auto-repeat). auto mapped = buttonMap.find(code); if (mapped != buttonMap.end()) - emitEvent(mapped->second); + emitEvent(mapped->second, joyButtonToKbchar(code)); } } } diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h index 7e309b9616..7413978d70 100644 --- a/src/input/LinuxJoystick.h +++ b/src/input/LinuxJoystick.h @@ -37,11 +37,15 @@ class LinuxJoystick : public Observable, public concurrency: int heldXZone() const { return heldX; } int heldYZone() const { return heldY; } + // The name this driver stamps into InputEvent::source. Lets a consumer tell an event that came + // from this gamepad from one that came from a keyboard or touchscreen carrying the same action. + const char *originName() const { return _originName; } + protected: virtual int32_t runOnce() override; private: - void emitEvent(input_broker_event event); + void emitEvent(input_broker_event event, unsigned char kbchar = 0); const char *_originName; bool firstTime = true; diff --git a/src/main.cpp b/src/main.cpp index 3f7baa2fdb..1c444c33dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1082,6 +1082,13 @@ void setup() #ifndef HAS_PHYSICAL_KEYBOARD osk_found = true; #endif +#endif +#if ARCH_PORTDUINO && defined(__linux__) + // Same idea for a gamepad: it can drive the on-screen keyboard but cannot type, so without a + // configured keyboard device it is the only way to compose freetext on this host. + if (portduino_config.joystickDevice != "" && portduino_config.keyboardDevice == "") { + osk_found = true; + } #endif // Now that the mesh service is created, create any modules @@ -1188,7 +1195,7 @@ void setup() #ifndef ARCH_PORTDUINO - // Initialize Wifi + // Initialize Wifi #if HAS_WIFI initWifi(); #endif diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index ea6f5ea96e..84694fcc0e 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -115,6 +115,61 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan LOG_TRACE("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); } +// Compose through the on-screen keyboard used by devices without a physical one +// (rotary encoder, trackball, joystick). Returns false when no such keyboard exists, +// so callers can fall back to the plain freetext screen. +bool CannedMessageModule::showOnScreenKeyboard() +{ + if (!osk_found || !screen) + return false; + + char headerBuffer[64]; + if (this->dest == NODENUM_BROADCAST) { + snprintf(headerBuffer, sizeof(headerBuffer), "To: #%s", channels.getName(this->channel)); + } else { + snprintf(headerBuffer, sizeof(headerBuffer), "To: @%s", getNodeName(this->dest)); + } + screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) { + if (!text.empty()) { + this->freetext = text.c_str(); + this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; + updateState(CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE); + currentMessageIndex = -1; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + setIntervalFromNow(500); + return; + } else { + // Don't delete virtual keyboard immediately - it might still be executing + // Instead, just clear the callback and reset banner to stop input processing + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + + // Return to inactive state + this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE); + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + + // Force display update to show normal screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + // Schedule cleanup for next loop iteration to ensure safe deletion + setIntervalFromNow(50); + return; + } + }); + + return true; +} + void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel) { // Do NOT override explicit broadcast replies @@ -130,6 +185,19 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t lastChannel = channel; lastDestSet = true; +#if !defined(USE_VIRTUAL_KEYBOARD) + // The freetext screen needs real key input, so devices without a physical keyboard + // compose on the on-screen keyboard instead. Open it from runOnce() rather than here: + // menus call us from a banner callback, and the banner is torn down as soon as that + // callback returns, which would take the keyboard down with it. + if (!kb_found && osk_found && screen) { + pendingOskLaunch = true; + setIntervalFromNow(0); + LOG_TRACE("[CannedMessage] LaunchFreetextWithDestination (OSK) dest=0x%08x ch=%d", dest, channel); + return; + } +#endif + updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true); UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; @@ -717,65 +785,18 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo } // [Free Text] triggers the free text input (virtual keyboard) -#if defined(USE_VIRTUAL_KEYBOARD) if (strcmp(current, "[-- Free Text --]") == 0) { +#if defined(USE_VIRTUAL_KEYBOARD) updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true); UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); return true; - } #else - if (strcmp(current, "[-- Free Text --]") == 0) { - if (osk_found && screen) { - char headerBuffer[64]; - if (this->dest == NODENUM_BROADCAST) { - snprintf(headerBuffer, sizeof(headerBuffer), "To: #%s", channels.getName(this->channel)); - } else { - snprintf(headerBuffer, sizeof(headerBuffer), "To: @%s", getNodeName(this->dest)); - } - screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) { - if (!text.empty()) { - this->freetext = text.c_str(); - this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; - updateState(CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE); - currentMessageIndex = -1; - - UIFrameEvent e; - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - this->notifyObservers(&e); - screen->forceDisplay(); - - setIntervalFromNow(500); - return; - } else { - // Don't delete virtual keyboard immediately - it might still be executing - // Instead, just clear the callback and reset banner to stop input processing - graphics::NotificationRenderer::textInputCallback = nullptr; - graphics::NotificationRenderer::resetBanner(); - - // Return to inactive state - this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE); - this->currentMessageIndex = -1; - this->freetext = ""; - this->cursor = 0; - - // Force display update to show normal screen - UIFrameEvent e; - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - this->notifyObservers(&e); - screen->forceDisplay(); - - // Schedule cleanup for next loop iteration to ensure safe deletion - setIntervalFromNow(50); - return; - } - }); - + if (showOnScreenKeyboard()) return true; - } - } #endif + } // Normal canned message selection if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE || runState == CANNED_MESSAGE_RUN_STATE_DISABLED) { @@ -1144,6 +1165,14 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha int32_t CannedMessageModule::runOnce() { + // A menu asked to compose freetext on the on-screen keyboard; the menu banner is gone + // by now, so it is safe to bring the keyboard up. + if (this->pendingOskLaunch) { + this->pendingOskLaunch = false; + if (showOnScreenKeyboard()) + return INT32_MAX; // the text input callback drives everything from here + } + if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) { updateDestinationSelectionList(); needsUpdate = false; diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index fe12bd468f..e67d137e89 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -190,6 +190,11 @@ class CannedMessageModule : public SinglePortModule, public Observable // strcmp, for the joystick-source test in handleInput() // =========================================================================== // Pure BreakoutGame logic (no display/FS dependencies; always compiled) @@ -26,11 +27,18 @@ 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; + ballDocked = true; // wait for the player to fire before the ball moves +} + +void BreakoutGame::launchBall() +{ + if (alive) + ballDocked = false; } void BreakoutGame::nextLevel() @@ -48,6 +56,9 @@ void BreakoutGame::reset(uint32_t seed) levelNum = 1; alive = true; ballTick = false; + // A new game starts centred. serveBall() deliberately does NOT re-centre, so the paddle keeps + // the player's position between lives. + paddleLeft = (BOARD_W - PADDLE_W) / 2; buildBricks(); serveBall(); } @@ -78,6 +89,14 @@ bool BreakoutGame::step() if (!alive) return false; + // Waiting to serve: the ball rides the centre of the paddle (so it still aims by sliding) and + // no physics, collisions or life losses happen until the player fires it. + if (ballDocked) { + ballPxX = static_cast(paddleLeft + PADDLE_W / 2) * SUBPX; + ballPxY = static_cast(PADDLE_Y - 2) * SUBPX; + return true; + } + // 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; @@ -224,20 +243,41 @@ bool Breakout::tick() return game.step(); } -void Breakout::handleInput(input_broker_event ev) +void Breakout::handleInput(const InputEvent *event) { -#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 + const input_broker_event ev = event->inputEvent; switch (ev) { case INPUT_BROKER_LEFT: - game.moveLeft(); - break; case INPUT_BROKER_RIGHT: - game.moveRight(); +#if ARCH_PORTDUINO && defined(__linux__) + // While the stick is held, tick() polls heldXZone() and moves the paddle itself, so the + // joystick's own discrete (and slow) repeat events would double-move. Suppress exactly + // those and nothing else. + // + // All three parts are load-bearing: + // source -- the event actually came from this gamepad. Without it, LEFT/RIGHT from the + // keyboard or touchscreen is swallowed too; aLinuxJoystick is constructed on + // every Linux host whether or not a gamepad is configured, so a pointer check + // alone is true even with nothing attached. + // kbchar -- no button produced it, so it is the D-pad axis. A shoulder button mapped to + // left/right is a discrete press the axis poll knows nothing about, and must + // still nudge the paddle. + // heldX -- the axis is what is driving right now, so tick() already has it covered. + if (aLinuxJoystick && event->kbchar == 0 && event->source && aLinuxJoystick->originName() && + strcmp(event->source, aLinuxJoystick->originName()) == 0 && aLinuxJoystick->heldXZone() != 0) + break; +#endif + if (ev == INPUT_BROKER_LEFT) + game.moveLeft(); + else + game.moveRight(); + break; + // Serve: B (CANCEL/BACK, forwarded here via wantsBackButton) or A (SELECT) fires a docked ball. + case INPUT_BROKER_CANCEL: + case INPUT_BROKER_BACK: + case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: + game.launchBall(); break; default: break; @@ -298,6 +338,12 @@ void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) // Ball. display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2); + // Waiting to serve: prompt the player (the ball rides the paddle until then). + if (game.isBallDocked()) { + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + display->getWidth() / 2, y + BreakoutGame::PADDLE_Y - FONT_HEIGHT_SMALL - 4, "B TO SERVE"); + } + #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 diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h index 9c4fe8dfb3..3d4fe6dbd3 100644 --- a/src/modules/games/Breakout.h +++ b/src/modules/games/Breakout.h @@ -52,6 +52,11 @@ class BreakoutGame void moveLeft(); void moveRight(); + /** After every serve the ball rides the paddle until the player fires it. launchBall() releases + * it; while docked the ball tracks the paddle and no collisions or life losses occur. */ + void launchBall(); + bool isBallDocked() const { return ballDocked; } + bool isPlaying() const { return alive; } uint32_t score() const { return points; } uint8_t lives() const { return livesLeft; } @@ -87,7 +92,8 @@ class BreakoutGame 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()) + bool ballTick = false; // ball advances on every other step() (see step()) + bool ballDocked = false; // ball is waiting on the paddle to be launched }; #include "configuration.h" @@ -115,7 +121,10 @@ class Breakout : public Game uint32_t score() const override { return game.score(); } int32_t tickIntervalMs() const override; - void handleInput(input_broker_event ev) override; + void handleInput(const InputEvent *event) override; + // Claim BACK/CANCEL (the gamepad's B) while the ball waits on the paddle, so it serves instead + // of pausing. Released as soon as the ball is in play, so BACK pauses normally again. + bool wantsBackButton() const override { return game.isBallDocked(); } void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp index 4e1a185217..3caba0b7a9 100644 --- a/src/modules/games/ChirpyRunner.cpp +++ b/src/modules/games/ChirpyRunner.cpp @@ -181,9 +181,10 @@ ChirpyRunner::ChirpyRunner() scores_.load(); } -void ChirpyRunner::handleInput(input_broker_event ev) +void ChirpyRunner::handleInput(const InputEvent *event) { // SELECT is the jump (as requested); UP is accepted as a convenient alternate. + const input_broker_event ev = event->inputEvent; if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP) game.jump(); } diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h index b86e1d7757..6d4f1fb986 100644 --- a/src/modules/games/ChirpyRunner.h +++ b/src/modules/games/ChirpyRunner.h @@ -154,7 +154,7 @@ class ChirpyRunner : public Game 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 handleInput(const InputEvent *event) override; void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h index 751b2d6b5c..277252973e 100644 --- a/src/modules/games/Game.h +++ b/src/modules/games/Game.h @@ -35,7 +35,21 @@ class Game 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; + // The whole event, not just the action, because an action alone cannot answer everything a + // game needs to know: + // - kbchar is a keystroke from a keyboard, or the physical gamepad button behind the action + // (INPUT_BROKER_MSG_JOY_BUTTON_FIRST..LAST, see InputBroker.h), or 0 when the source has + // nothing to add. Several buttons can share one action, so this is what lets a game act on + // the button rather than the action, and use more inputs than SELECT and CANCEL. + // - source names the driver that produced it. Two sources can send the same action with the + // same (zero) kbchar, so this is the only thing that tells them apart -- Breakout needs it + // to ignore the joystick's own D-pad repeats without also ignoring the keyboard. + // A game that only cares about the action just reads event->inputEvent. + virtual void handleInput(const InputEvent *event) = 0; + + // While true, the host forwards BACK/CANCEL to handleInput() instead of using it to pause. Lets + // a game briefly claim that button (e.g. Breakout's "press B to serve" before the ball launches). + virtual bool wantsBackButton() const { return false; } // --- 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 diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp index 606d1d5058..2a5ce6dfa9 100644 --- a/src/modules/games/GamesModule.cpp +++ b/src/modules/games/GamesModule.cpp @@ -56,10 +56,19 @@ void GamesModule::startPlaying() active->start(static_cast(random()) ^ millis()); uiState = GAMES_PLAYING; lastAwakeKickMs = millis(); + noteActivity(); kickTick(); requestRedraw(); } +void GamesModule::goHome() +{ + // Left idle too long: drop any game and return to the clearly-Meshtastic home frame. + exitToIdle(); + if (screen) + screen->showHomeFrame(); +} + void GamesModule::enterGameOver() { lastScore = active ? active->score() : 0; @@ -113,7 +122,7 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score) // 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)); + GAMES_HIGH_SCORE_STRING, active->name(), static_cast(score), initials); service->sendToMesh(p); LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(score)); } @@ -151,6 +160,27 @@ void GamesModule::kickTick() int32_t GamesModule::runOnce() { + const uint32_t now = millis(); + + // Whether the games UI is actually in front of the player: a game is active (which forces the + // games frame), or the attract screen is the current frame. When it is, an idle stretch bounces + // back to the home frame so a walked-away device reads as a Meshtastic node. + const bool gamesVisible = (uiState != GAMES_IDLE) || (screen && screen->isGamesFrameShown()); + if (gamesVisible) { + if (screen && screen->isOverlayBannerShowing()) { + // A picker or banner is up (e.g. high-score initials entry, which our handleInputEvent + // never sees). The user is busy with it -- don't time out and yank them away. + lastActivityMs = now; + } else if (now - lastActivityMs >= INACTIVITY_TIMEOUT_MS) { + goHome(); + return disable(); + } + } else { + // Not in front of the player (attract screen is just one of the rotating frames, and the + // player is elsewhere): keep the timer fresh so a later visit starts a full 15 s. + lastActivityMs = now; + } + if (uiState == GAMES_PLAYING && active) { if (!active->tick()) { enterGameOver(); @@ -158,7 +188,6 @@ int32_t GamesModule::runOnce() } // 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; @@ -168,13 +197,18 @@ int32_t GamesModule::runOnce() return active->tickIntervalMs(); } - // Idle: service any game that broadcasts periodically; sleep until the soonest one is due. + // Idle-ish (attract / paused / game-over / high scores): service any periodic mesh broadcast, + // and while the games UI is visible keep a slow poll running so the inactivity timeout fires. int32_t next = -1; for (Game *g : games) { const int32_t due = g->meshTick(*this); if (due >= 0 && (next < 0 || due < next)) next = due; } + if (gamesVisible) { + const int32_t poll = 1000; + return (next >= 0 && next < poll) ? next : poll; + } return next < 0 ? disable() : next; } @@ -191,9 +225,16 @@ int GamesModule::handleInputEvent(const InputEvent *event) if (screen->isOverlayBannerShowing()) return 0; // a menu banner is up; don't steal its input + noteActivity(); // any input on the games frame resets the return-to-home timer + const input_broker_event ev = event->inputEvent; const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK); + // Start is mapped to select like any other button, so it launches games and works the menus. + // Inside a running game it means pause/resume instead, which we can tell only because kbchar + // names the physical button behind the action. + const bool isPauseButton = (ev == INPUT_BROKER_SELECT && isJoyStartButton(event->kbchar)); + switch (uiState) { case GAMES_IDLE: // Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it; @@ -214,12 +255,22 @@ int GamesModule::handleInputEvent(const InputEvent *event) return 0; case GAMES_PLAYING: - if (isBack) { + // Start pauses, and is never forwarded to the game: unlike BACK it has no second meaning + // in play, so a game cannot claim it the way Breakout claims BACK to serve. + if (isPauseButton) { + uiState = GAMES_PAUSED; + disable(); + requestRedraw(); + return 1; + } + // BACK pauses, unless the active game has temporarily claimed that button (see + // Game::wantsBackButton) -- then it is forwarded like any other key. + if (isBack && !(active && active->wantsBackButton())) { uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit disable(); requestRedraw(); } else if (active) { - active->handleInput(ev); + active->handleInput(event); if (!active->isPlaying()) { enterGameOver(); return 1; @@ -232,7 +283,7 @@ int GamesModule::handleInputEvent(const InputEvent *event) 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) { + ev == INPUT_BROKER_RIGHT) { // Start arrives as SELECT, so it resumes too uiState = GAMES_PLAYING; kickTick(); requestRedraw(); @@ -317,6 +368,18 @@ void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/ { display->setColor(WHITE); + // drawFrame runs only while the games frame is the current frame. Several idle-ish states + // (attract / paused / game-over / high-scores) otherwise leave the tick thread asleep, so use + // this render as the trigger to (re)start the slow inactivity poll and reset the timer -- so + // arriving on such a screen gets a fresh 15 s before we bounce back home, and walking away from + // it eventually does. (While a game is PLAYING the thread is already ticking, so !enabled is + // false here and the play-time timer is left to run.) + if (!enabled) { + noteActivity(); + enabled = true; + setIntervalFromNow(1000); + } + switch (uiState) { case GAMES_IDLE: if (!games.empty()) diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h index eb4642f196..f24ec00808 100644 --- a/src/modules/games/GamesModule.h +++ b/src/modules/games/GamesModule.h @@ -67,12 +67,18 @@ class GamesModule : public SinglePortModule, public Observable inputObserver = CallbackObserver(this, &GamesModule::handleInputEvent); + // After this long with no input while the games frame is up, bounce back to the home frame so a + // walked-away device clearly reads as a Meshtastic node rather than sitting on a game screen. + static constexpr uint32_t INACTIVITY_TIMEOUT_MS = 15000; + // === State transitions === void startPlaying(); void enterGameOver(); void exitToIdle(); void requestRedraw(); void kickTick(); + void noteActivity() { lastActivityMs = millis(); } // reset the inactivity timer + void goHome(); // exit any game and switch to the home frame // === Shared game-over / high-score flow === void promptForInitials(); @@ -94,6 +100,7 @@ class GamesModule : public SinglePortModule, public Observable RIGHT -> DOWN -> LEFT. + static constexpr Direction CW[4] = {DIR_RIGHT, DIR_LEFT, DIR_UP, DIR_DOWN}; + static constexpr Direction CCW[4] = {DIR_LEFT, DIR_RIGHT, DIR_DOWN, DIR_UP}; + setDirection(clockwise ? CW[dir] : CCW[dir]); +} + uint32_t SnakeGame::nextRandom() { uint32_t x = rng; @@ -172,8 +181,20 @@ int32_t Snake::tickIntervalMs() const return iv < 70 ? 70 : iv; } -void Snake::handleInput(input_broker_event ev) +void Snake::handleInput(const InputEvent *event) { + const input_broker_event ev = event->inputEvent; + const unsigned char kbchar = event->kbchar; + + // Shoulder-button steering: a gamepad button mapped to left/right turns the snake relative to + // where it is already heading (L counter-clockwise, R clockwise) rather than setting an + // absolute heading. The D-pad and keyboard keep steering absolutely -- the D-pad is an axis, + // so it arrives with no button in kbchar, which is exactly what tells the two apart. + if (isJoyButton(kbchar) && (ev == INPUT_BROKER_LEFT || ev == INPUT_BROKER_RIGHT)) { + game.turn(ev == INPUT_BROKER_RIGHT); + return; + } + switch (ev) { case INPUT_BROKER_UP: game.setDirection(SnakeGame::DIR_UP); diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h index 293707ff4e..c32258d6e8 100644 --- a/src/modules/games/Snake.h +++ b/src/modules/games/Snake.h @@ -47,6 +47,18 @@ class SnakeGame */ bool setDirection(Direction d); + /** + * Steer relative to the current heading: a quarter turn clockwise, or counter-clockwise. + * Clockwise is what the player sees on screen (UP -> RIGHT -> DOWN -> LEFT), remembering that + * y grows downward. A quarter turn from the committed heading can never be a reversal, so + * unlike setDirection() this always takes. + * + * Turning from the committed direction rather than the pending one is deliberate, and matches + * setDirection()'s reasoning: two presses inside one tick then settle on a single quarter + * turn instead of chaining into the 180 that would run the head into the neck. + */ + void turn(bool clockwise); + /** * 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). @@ -128,7 +140,7 @@ class Snake : public Game uint32_t score() const override { return game.score(); } int32_t tickIntervalMs() const override; - void handleInput(input_broker_event ev) override; + void handleInput(const InputEvent *event) override; void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp index 7c24b32699..1a39ca59de 100644 --- a/src/modules/games/Tetris.cpp +++ b/src/modules/games/Tetris.cpp @@ -285,9 +285,9 @@ int32_t Tetris::tickIntervalMs() const return iv < 50 ? 50 : iv; } -void Tetris::handleInput(input_broker_event ev) +void Tetris::handleInput(const InputEvent *event) { - switch (ev) { + switch (event->inputEvent) { case INPUT_BROKER_UP: game.rotate(); break; diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h index 4b429ec2ed..1844769bd2 100644 --- a/src/modules/games/Tetris.h +++ b/src/modules/games/Tetris.h @@ -131,7 +131,7 @@ class Tetris : public Game uint32_t score() const override { return game.score(); } int32_t tickIntervalMs() const override; - void handleInput(input_broker_event ev) override; + void handleInput(const InputEvent *event) override; void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp index d03ca7c09a..950c063b99 100644 --- a/src/platform/portduino/ConfigCheck.cpp +++ b/src/platform/portduino/ConfigCheck.cpp @@ -37,6 +37,9 @@ constexpr int MAX_NODES_SANITY_CEILING = 16000; const std::set kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"}; +// Action names LinuxJoystick understands; anything else leaves the button unmapped. +const std::set kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"}; + const std::map> &schema() { static const std::map> s = { @@ -366,6 +369,61 @@ void checkPinNode(const std::string &file, const std::string &path, const YAML:: } } +// Keyed by action, not by button, so one action can list several codes and have every one of +// those buttons drive it. The value is a single evdev code or a list of them. +void checkJoystickButtons(const std::string &file, const YAML::Node &node, std::vector &findings) +{ + if (!node.IsMap()) { + findings.push_back( + {kError, file, lineOf(node), "Input.JoystickButtons must be a mapping of action name to evdev button code"}); + return; + } + + std::map owner; // code -> the action that claimed it first + for (const auto &entry : node) { + std::string action = entry.first.as(""); + for (auto &c : action) + c = tolower(c); + if (!kJoystickActions.count(action)) { + findings.push_back({kWarn, file, lineOf(entry.first), + "Input.JoystickButtons: '" + action + + "' is not a recognised action, so those buttons do nothing. Valid actions are select, " + "cancel, back, up, down, left, right and user"}); + continue; + } + + std::vector codeNodes; + if (entry.second.IsSequence()) + for (const auto &codeNode : entry.second) + codeNodes.push_back(codeNode); + else + codeNodes.push_back(entry.second); + + for (const auto &codeNode : codeNodes) { + const std::string raw = codeNode.as(""); + int code = 0; + try { + code = std::stoi(raw, nullptr, 0); + } catch (const std::exception &) { + code = 0; + } + if (code == 0) { + findings.push_back({kWarn, file, lineOf(codeNode), + "Input.JoystickButtons." + action + ": '" + raw + + "' is not an evdev button code (hex like 0x121, or decimal), so it is unmapped"}); + continue; + } + // One button cannot do two things: the later action silently replaces the earlier one. + const auto claimed = owner.find(code); + if (claimed != owner.end() && claimed->second != action) + findings.push_back({kWarn, file, lineOf(codeNode), + "Input.JoystickButtons: button " + raw + " is mapped to both '" + claimed->second + + "' and '" + action + "'. Only '" + action + "' takes effect"}); + owner[code] = action; + } + } +} + void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector &findings) { if (!table.IsMap()) { @@ -837,7 +895,7 @@ void checkSection(const std::string &file, const std::string §ion, const YAM for (const auto &pin : value) checkPinNode(file, section + "." + key, pin, findings); } else if (key == "JoystickButtons") { - // Free-form: any action name mapped to an evdev code. + checkJoystickButtons(file, value, findings); } else if ((section == "Lora" && kLoraPinKeys.count(key)) || (section == "Display" && (key == "DC" || key == "CS" || key == "Backlight" || key == "BacklightPWMChannel" || key == "Reset")) || diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 8fe452aa01..9c908a4bcf 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1297,21 +1297,33 @@ bool loadConfig(const char *configPath) portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as(""); portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as(""); if (yamlConfig["Input"]["JoystickButtons"]) { - // action name -> evdev button code (hex like 0x122 or decimal); stored inverted - // as code -> lowercase action name for the driver to look up per keypress. + // action name -> evdev button code (hex like 0x122 or decimal), or a list of codes + // so several physical buttons drive the same action. Stored inverted as + // code -> lowercase action name for the driver to look up per keypress. for (const auto &button : yamlConfig["Input"]["JoystickButtons"]) { std::string action = button.first.as(""); for (auto &c : action) c = tolower(c); - int code = 0; - try { - // base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry. - code = std::stoi(button.second.as(""), nullptr, 0); - } catch (const std::exception &) { - code = 0; + if (action == "") + continue; + // A bare scalar is just a one-entry list. + std::vector codeNodes; + if (button.second.IsSequence()) + for (const auto &codeNode : button.second) + codeNodes.push_back(codeNode); + else + codeNodes.push_back(button.second); + for (const auto &codeNode : codeNodes) { + int code = 0; + try { + // base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry. + code = std::stoi(codeNode.as(""), nullptr, 0); + } catch (const std::exception &) { + code = 0; + } + if (code != 0) + portduino_config.joystickButtons[code] = action; } - if (code != 0 && action != "") - portduino_config.joystickButtons[code] = action; } } diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 3e77894f57..31838d42aa 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -545,9 +545,23 @@ extern struct portduino_config_struct { if (joystickDevice != "") out << YAML::Key << "JoystickDevice" << YAML::Value << joystickDevice; if (!joystickButtons.empty()) { - out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap; + // Stored as code -> action; invert so each action lists every code bound to it. + // Several buttons may share one action, so a multi-code action emits a list. + std::map> codesByAction; for (const auto &button : joystickButtons) - out << YAML::Key << button.second << YAML::Value << button.first; + codesByAction[button.second].push_back(button.first); + out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap; + for (const auto &action : codesByAction) { + out << YAML::Key << action.first << YAML::Value; + if (action.second.size() == 1) { + out << action.second.front(); + } else { + out << YAML::Flow << YAML::BeginSeq; + for (const int code : action.second) + out << code; + out << YAML::EndSeq; + } + } out << YAML::EndMap; } diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md index 729d794fa0..f0e386ed31 100644 --- a/test/fixtures/portduino-config/README.md +++ b/test/fixtures/portduino-config/README.md @@ -161,6 +161,16 @@ if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!". | `mac-malformed.yaml` | `AA:BB:CC` is under 12 hex digits, so it is silently dropped. | | `mac-source-missing.yaml` | Names an interface with no `/sys/class/net//address`. Warning, not an error: it is machine-dependent and may be checked on another host. | +## Joystick buttons + +`Input.JoystickButtons` is keyed by action, not by button, so an action can name a +single evdev code or a list of them and every one of those buttons drives it. + +| File | Expected | +| --------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `joystick-buttons.yaml` | **Clean, and a regression guard** - four actions, two of them with several codes. | +| `joystick-buttons-bad.yaml` | Three silent no-ops: an action name nothing reads, an evdev name where a code belongs, one code claimed twice. | + ## CH341 USB-SPI adapters `spidev: ch341` is a different hardware model, not a variant of the same one. The Lora diff --git a/test/fixtures/portduino-config/joystick-buttons-bad.yaml b/test/fixtures/portduino-config/joystick-buttons-bad.yaml new file mode 100644 index 0000000000..c25aad54cc --- /dev/null +++ b/test/fixtures/portduino-config/joystick-buttons-bad.yaml @@ -0,0 +1,16 @@ +# FAULT: three ways a joystick mapping silently does nothing. 'fire' is not an action +# LinuxJoystick knows, so those buttons are dropped; 'BTN_SOUTH' is a name rather than the +# numeric evdev code the loader parses; and 0x121 appears under two actions, where the +# later one wins and the earlier binding is lost. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Input: + JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick + JoystickButtons: + select: [0x121, BTN_SOUTH] + fire: 0x123 + cancel: [0x122, 0x121] diff --git a/test/fixtures/portduino-config/joystick-buttons.yaml b/test/fixtures/portduino-config/joystick-buttons.yaml new file mode 100644 index 0000000000..d325f6f247 --- /dev/null +++ b/test/fixtures/portduino-config/joystick-buttons.yaml @@ -0,0 +1,17 @@ +# CLEAN. Several physical buttons bound to one action: an action's value may be a single +# evdev code or a list of them, so A and Y both select, B and X both cancel, and the +# shoulder buttons work as left/right alongside the D-pad. Nothing here is a fault -- the +# case exists so the checker cannot start rejecting the list form. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Input: + JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick + JoystickButtons: + select: [0x121, 0x123] + cancel: [0x122, 0x120] + left: 0x124 + right: 0x125 diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp index 1ca2da5e2d..d1b7b8dbe1 100644 --- a/test/test_breakout/test_main.cpp +++ b/test/test_breakout/test_main.cpp @@ -2,11 +2,21 @@ #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. +// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, the ball waiting +// on the paddle until launched, 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; +// The ball docks on the paddle after every serve (including after losing a life), so a test that +// wants continuous play has to fire it whenever it is waiting. +static void stepLaunched(BreakoutGame &game) +{ + if (game.isBallDocked()) + game.launchBall(); + game.step(); +} + void test_reset_initialState() { BreakoutGame game; @@ -21,6 +31,37 @@ void test_reset_initialState() 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); + // The ball waits on the paddle until the player serves it. + TEST_ASSERT_TRUE(game.isBallDocked()); +} + +void test_ball_waitsOnPaddleUntilLaunched() +{ + BreakoutGame game; + game.reset(kSeed); + const int16_t restY = game.ballY(); + + // Stepping without serving must not move the ball vertically, lose a life, or end the run. + for (int i = 0; i < 50; i++) + game.step(); + TEST_ASSERT_TRUE(game.isBallDocked()); + TEST_ASSERT_EQUAL_INT16(restY, game.ballY()); + TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives()); + TEST_ASSERT_TRUE(game.isPlaying()); + + // A docked ball tracks the paddle, so it can still be aimed before serving. + const int16_t beforeX = game.ballX(); + for (int i = 0; i < 5; i++) + game.moveLeft(); + game.step(); + TEST_ASSERT_TRUE(game.ballX() < beforeX); + + // Once launched it is in play and starts climbing toward the bricks. + game.launchBall(); + TEST_ASSERT_FALSE(game.isBallDocked()); + for (int i = 0; i < 4; i++) + game.step(); + TEST_ASSERT_TRUE(game.ballY() < restY); } void test_paddle_clampsToEdges() @@ -39,8 +80,9 @@ 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. + // Once served, the ball travels 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. + game.launchBall(); for (int i = 0; i < 60 && game.bricksRemaining() == static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++) game.step(); @@ -59,7 +101,7 @@ void test_ball_staysInBounds() game.moveLeft(); else game.moveRight(); - game.step(); + stepLaunched(game); TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W); TEST_ASSERT_TRUE(game.ballY() >= 0); } @@ -69,10 +111,15 @@ 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(); + // Serve each ball, then steer the paddle AWAY from it so every ball is missed and all lives + // drain. (The ball re-docks after each loss, so it has to be re-served. Note it now serves from + // the paddle's centre, so simply parking the paddle would let it rally instead of dying.) for (int i = 0; i < 20000 && game.isPlaying(); i++) { - for (int j = 0; j < 40; j++) // hold the paddle pinned left + if (game.isBallDocked()) + game.launchBall(); + else if (game.ballX() < game.paddleX()) + game.moveRight(); + else game.moveLeft(); game.step(); } @@ -92,6 +139,7 @@ void setup() initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_reset_initialState); + RUN_TEST(test_ball_waitsOnPaddleUntilLaunched); RUN_TEST(test_paddle_clampsToEdges); RUN_TEST(test_serve_clearsABrickAndScores); RUN_TEST(test_ball_staysInBounds); diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp index 8668e66a53..5fa576ac63 100644 --- a/test/test_snake/test_main.cpp +++ b/test/test_snake/test_main.cpp @@ -64,6 +64,46 @@ static void test_setDirection_rejectsReversal() TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); } +// turn() is the shoulder-button steering: a quarter turn relative to the current heading rather +// than an absolute direction. A quarter turn is never a reversal, so it always takes. +static void test_turn_cyclesThroughHeadings() +{ + SnakeGame game; + game.reset(kSeed); // heading right + game.placeFoodAt(0, 0); + + // Clockwise on screen (y grows downward): RIGHT -> DOWN -> LEFT -> UP -> RIGHT. + const SnakeGame::Direction cw[] = {SnakeGame::DIR_DOWN, SnakeGame::DIR_LEFT, SnakeGame::DIR_UP, SnakeGame::DIR_RIGHT}; + for (SnakeGame::Direction want : cw) { + game.turn(true); + TEST_ASSERT_TRUE(game.step()); // commit the pending turn + TEST_ASSERT_EQUAL_INT(want, game.direction()); + } + + // Counter-clockwise runs the cycle backwards: RIGHT -> UP -> LEFT -> DOWN -> RIGHT. + const SnakeGame::Direction ccw[] = {SnakeGame::DIR_UP, SnakeGame::DIR_LEFT, SnakeGame::DIR_DOWN, SnakeGame::DIR_RIGHT}; + for (SnakeGame::Direction want : ccw) { + game.turn(false); + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_INT(want, game.direction()); + } +} + +// Two turns inside one tick must not chain into a 180 that runs the head into its own neck -- +// the second turn is taken from the committed heading, not the pending one. +static void test_turn_twiceInOneTickIsNotAReversal() +{ + SnakeGame game; + game.reset(kSeed); // heading right + game.placeFoodAt(0, 0); + + game.turn(true); + game.turn(true); // would be RIGHT -> DOWN -> LEFT if it chained + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_DOWN, game.direction()); + TEST_ASSERT_TRUE(game.isPlaying()); +} + static void test_step_movesAndTailFollows() { SnakeGame game; @@ -168,6 +208,8 @@ void setup() RUN_TEST(test_reset_initialState); RUN_TEST(test_food_isValidAndOffBody); RUN_TEST(test_setDirection_rejectsReversal); + RUN_TEST(test_turn_cyclesThroughHeadings); + RUN_TEST(test_turn_twiceInOneTickIsNotAReversal); RUN_TEST(test_step_movesAndTailFollows); RUN_TEST(test_eat_growsAndScores); RUN_TEST(test_wallCollision_endsGame);