mirror of
https://github.com/meshtastic/firmware.git
synced 2026-10-09 06:31:35 -04:00
Games joystick input (#11917)
* fix(games): correct the high-score announcement argument order GAMES_HIGH_SCORE_STRING is "New %s high score %lu by %s!" but the arguments were passed as (name, initials, score): the initials string was formatted through %lu and the score integer through %s. That is a format/argument mismatch, so the announcement printed garbage at best and dereferenced the score as a pointer at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(input): report which physical gamepad button produced an event A joystick event only carried the action it was mapped to, so a consumer could not tell two buttons apart once they shared one action, and games were limited to the handful of actions the broker defines. Carry the originating evdev button code in InputEvent::kbchar, encoded into a reserved 0xC0..0xDF range that misses printable ASCII and every INPUT_BROKER_MSG_ value (SystemCommands switches on kbchar without looking at inputEvent, so a collision there would reboot the node rather than move a paddle). D-pad events are axes, not buttons, and keep leaving kbchar at 0 -- which is exactly what lets a consumer tell stick from button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(portduino): let one joystick action bind several buttons Input.JoystickButtons took a single evdev code per action, so a pad's A and Y could not both select, and the shoulder buttons could not sit alongside the D-pad. Accept a list of codes as well as a bare scalar; the config writer inverts its code->action map back out, emitting a list only where an action has more than one button. ConfigCheck gains a real checker for the section (it was previously waved through as free-form) covering the three ways a mapping silently does nothing: an action name the driver does not know, an evdev name where the numeric code belongs, and one code claimed by two actions. Two fixtures and shell-test cases cover the clean list form and those three faults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(games): use the gamepad's extra buttons, and return home when idle Games now receive the physical button alongside the action, so a pad with more than two usable buttons controls more than two things: - Snake: a shoulder button mapped to left/right turns relative to the snake's heading (L counter-clockwise, R clockwise) while the D-pad keeps steering absolutely. The two are told apart by kbchar, not by hardcoding one pad's codes. - Breakout: the ball now rides the paddle after each serve until the player fires it with B or A, so a life is not lost to a ball already in flight when the player looks up. The paddle also keeps its position between lives. A game can claim BACK for the duration (Game::wantsBackButton) so B serves instead of pausing, and releases it once the ball is live. - Start (BTN_BASE4 / BTN_START) is mapped to select like any other button, so it launches games and drives the menus; inside a running game GamesModule picks it out of kbchar and pauses instead. Separately, the games frame no longer holds a walked-away device hostage: after 15 s with no input it returns to the home frame, so the device still reads as a Meshtastic node. The timer is suspended while a picker or banner is up (e.g. high-score initials entry, which the input handler never sees) so it cannot yank the user out mid-entry. Screen::isInteractionBusy() generalises the old module-intercept check -- modal module, intercepting module, game, or an open interactive overlay -- and MessageRenderer uses it before popping an incoming-message banner. A transient banner REPLACES an active overlay, so an arriving message could otherwise discard a half-entered high score. The message is still stored, its thread still selected, and the unread indicator still set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(ui): compose freetext on the on-screen keyboard from a gamepad A gamepad can drive the on-screen keyboard but cannot type, so on a host with a joystick and no configured keyboard device the OSK is the only way to compose freetext. Set osk_found there, and gate the "Freetext" menu entries on whether the device can enter text at all (physical keyboard, OSK, or touchscreen virtual keyboard) rather than on kb_found alone -- those entries were hidden on exactly the devices that needed them. The OSK prompt that CannedMessageModule already had inline in the message selector becomes showOnScreenKeyboard(), so the menu path can reach it too. Menus call in from a banner callback and the banner is torn down as soon as that callback returns, which would take the keyboard down with it, so the menu path defers the launch to runOnce(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(games): address review on frame fallback and joystick input gating Breakout: the paddle suppression was far too broad. aLinuxJoystick is constructed on every Linux host whether or not a gamepad is configured (InputBroker.cpp), so `aLinuxJoystick && kbchar == 0` was true everywhere and swallowed LEFT/RIGHT from the keyboard, trackball and ExpressLRS -- on a host with no joystick attached at all. Gate on the stick actually driving the paddle instead: LinuxJoystick assigns heldX before it emits and only auto-repeats while heldX is set, so every axis LEFT/RIGHT arrives with a zone held and nothing else does. kbchar == 0 still distinguishes an axis from a shoulder button mapped to left/right, which must keep nudging the paddle. Screen: showHomeFrame() did nothing when the home frame was hidden, since setFrames() only assigns positions.home for !hiddenFrames.home. That stranded the games inactivity bounce on the frame it was trying to leave. Fall back to the messages frame, which setFrames() always adds. Test: rename test_ballWaitsOnPaddleUntilLaunched to test_ball_waitsOnPaddleUntilLaunched, matching the repo convention and its neighbours in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(games): give games the whole InputEvent so Breakout can identify the source Follow-up to review on #11917. The previous narrowing still could not tell sources apart: kbchar == 0 is shared by the joystick's D-pad axis and by every other driver that sends a bare LEFT/RIGHT, so while the D-pad was held a keyboard or touchscreen press was still discarded. heldXZone() proves the axis is driving, not that this particular event came from it. Pass the event itself to Game::handleInput() rather than (ev, kbchar). Games that only care about the action read event->inputEvent; Snake keeps using kbchar for shoulder steering; Breakout now also checks event->source against LinuxJoystick's origin name, so only that driver's own axis repeats are suppressed. Chose the event over a third positional parameter so the signature does not have to grow again the next time a game needs something the event already carries. All three conditions in Breakout are load-bearing: source says it came from this gamepad, kbchar == 0 says it is the axis rather than a shoulder button mapped to left/right, and heldXZone() != 0 says the axis is what is driving right now so tick() already has it covered. LinuxJoystick::originName() exposes the name the driver stamps into InputEvent::source, alongside the existing heldXZone()/heldYZone() accessors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fa87e7d29c
commit
ee02cc3426
31 files changed
+689
-131
No files matched your search
+10
@@ -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/<n>/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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -2,11 +2,21 @@
|
||||
#include "modules/games/Breakout.h"
|
||||
#include <unity.h>
|
||||
|
||||
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
|
||||
// a straight-up serve, and the ball staying within the board. No device globals or display stack.
|
||||
// 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<uint16_t>(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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user