Initial (and hopefully last) commit

This commit is contained in:
mia 2026-05-20 14:06:57 +02:00
commit dea8e8be68
41 changed files with 126867 additions and 0 deletions

900
code/src/main.cpp Normal file
View file

@ -0,0 +1,900 @@
#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <Preferences.h>
#include <cstring>
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R2, /* reset=*/ U8X8_PIN_NONE, /* clock=*/ 9, /* data=*/ 8);
Preferences preferences;
#define BTNU 0
#define BTNR 1
#define BTND 3
#define BTNL 10
#define BUZZER 7
const uint8_t ledcChannel = 0;
const uint16_t SCREEN_W = 128;
const uint16_t SCREEN_H = 64;
const uint8_t HUD_H = 8;
const uint8_t CELL = 4;
const uint8_t GRID_W = SCREEN_W / CELL;
const uint8_t GRID_H = (SCREEN_H - HUD_H) / CELL;
const uint16_t MAX_SNAKE = GRID_W * GRID_H;
const uint16_t BASE_STEP_MS = 170;
const uint16_t MIN_STEP_MS = 85;
const uint16_t SPEEDUP_PER_FOOD_MS = 5;
const uint16_t SPRINT_STEP_MS = 50;
const uint8_t SPRINT_HOLD_FRAMES = 5;
const uint16_t MIN_TONE_FREQ = 500;
const uint16_t MAX_TONE_FREQ = 2000;
const uint16_t MENU_DEBOUNCE_MS = 150;
const uint16_t NAME_ENTRY_DWELL_MS = 350;
const uint8_t LEADERBOARD_SIZE = 5;
const uint8_t NAME_LENGTH = 8;
const uint8_t LEADERBOARD_BOARDS = 3; // Easy, Medium, Hard
// Difficulty presets and custom bounds
const uint16_t DIFF_EASY_MS = 200;
const uint16_t DIFF_MEDIUM_MS = BASE_STEP_MS;
const uint16_t DIFF_HARD_MS = 130;
const uint16_t CUSTOM_MIN_MS = 85;
const uint16_t CUSTOM_MAX_MS = 300;
// Difficulty offsets and preset speedup-per-food values
const int16_t DIFF_OFFSET_EASY = 30;
const int16_t DIFF_OFFSET_MED = 0;
const int16_t DIFF_OFFSET_HARD = -40;
const uint16_t DIFF_SPEEDUP_EASY = 3;
const uint16_t DIFF_SPEEDUP_MED = SPEEDUP_PER_FOOD_MS;
const uint16_t DIFF_SPEEDUP_HARD = 7;
struct Cell {
int8_t x;
int8_t y;
};
enum Direction : uint8_t { UP, RIGHT, DOWN, LEFT };
enum GameState : uint8_t { START, PLAYING, GAME_OVER, SETTINGS, LEADERBOARD, NAME_ENTRY };
struct LeaderboardEntry {
char name[NAME_LENGTH + 1];
uint16_t score;
};
GameState gameState = START;
// toneFreq == 0 means buzzer OFF; otherwise valid range is 500..2000 in 100 Hz steps
uint16_t toneFreq = 1200;
bool isSprinting = false;
uint8_t sprintHoldU = 0, sprintHoldR = 0, sprintHoldD = 0, sprintHoldL = 0;
// settingsMenuIndex removed; using settingsScrollOffset and dynamic list
uint32_t lastSettingsInputMs = 0;
// Settings UI state: scroll offset, total items. Selector stays in middle of visible window.
uint8_t settingsScrollOffset = 0;
const uint8_t SETTINGS_VISIBLE = 3; // visible rows
// Difficulty selection
uint8_t difficultyPreset = 1; // 0=Easy,1=Medium,2=Hard,3=Custom
uint16_t customStepMs = BASE_STEP_MS;
// Sprint enable/disable (settings)
bool sprintAllowed = true;
// Custom advanced params
uint16_t customSpeedupPerFood = SPEEDUP_PER_FOOD_MS;
uint16_t customMinStepMs = MIN_STEP_MS;
uint16_t customMaxStepMs = DIFF_MEDIUM_MS * 2;
// Calculate dynamic settings total depending on whether custom step row is present
uint8_t settingsTotal() {
// If custom difficulty selected, include extra rows: custom start, speedup, min, max
if (difficultyPreset == 3) return 10; // blank, buzzer, sprint, difficulty, customStart, customSpeedup, customMin, customMax, back, blank
return 6; // blank, buzzer, sprint, difficulty, back, blank
}
uint8_t leaderboardBoardIndexForDifficulty(uint8_t difficulty) {
return (difficulty < LEADERBOARD_BOARDS) ? difficulty : 1;
}
uint8_t activeLeaderboardBoardIndex() {
return leaderboardBoardIndexForDifficulty(difficultyPreset);
}
// NAME_ENTRY: require L+R release before next select
bool lrComboReleased = true;
LeaderboardEntry leaderboard[LEADERBOARD_BOARDS][LEADERBOARD_SIZE];
char pendingName[NAME_LENGTH + 1] = {0};
uint16_t pendingScore = 0;
uint8_t pendingLeaderboardBoard = 0;
uint8_t keyboardRow = 0;
uint8_t keyboardCol = 0;
Cell snake[MAX_SNAKE];
uint16_t snakeLen = 0;
Cell food = {0, 0};
Direction currentDir = RIGHT;
Direction nextDir = RIGHT;
uint16_t score = 0;
uint16_t highScore = 0;
uint32_t lastStepMs = 0;
uint32_t stateChangeMs = 0;
uint32_t inputIgnoreUntilMs = 0; // ignore button input until this millis() (used after name-entry OK)
void resetGame();
bool cellEquals(const Cell &first, const Cell &second) {
return first.x == second.x && first.y == second.y;
}
bool isSnakeCell(const Cell &target) {
for (uint16_t index = 0; index < snakeLen; ++index) {
if (cellEquals(snake[index], target)) return true;
}
return false;
}
void initLeaderboardDefaults() {
for (uint8_t board = 0; board < LEADERBOARD_BOARDS; ++board) {
for (uint8_t index = 0; index < LEADERBOARD_SIZE; ++index) {
strncpy(leaderboard[board][index].name, "--------", NAME_LENGTH);
leaderboard[board][index].name[NAME_LENGTH] = '\0';
leaderboard[board][index].score = 0;
}
}
}
void loadLeaderboardBoard(uint8_t board) {
char key[16];
snprintf(key, sizeof(key), "leader%u", board);
const size_t loadedBytes = preferences.getBytes(key, leaderboard[board], sizeof(leaderboard[board]));
if (loadedBytes != sizeof(leaderboard[board])) {
for (uint8_t index = 0; index < LEADERBOARD_SIZE; ++index) {
strncpy(leaderboard[board][index].name, "--------", NAME_LENGTH);
leaderboard[board][index].name[NAME_LENGTH] = '\0';
leaderboard[board][index].score = 0;
}
}
}
void saveLeaderboardBoard(uint8_t board) {
char key[16];
snprintf(key, sizeof(key), "leader%u", board);
preferences.putBytes(key, leaderboard[board], sizeof(leaderboard[board]));
}
void loadHighScore() {
preferences.begin("snake", true);
const bool buzzerEnabledLegacy = preferences.getBool("buzzerEnabled", true);
toneFreq = preferences.getUShort("toneFreq", 1200);
difficultyPreset = preferences.getUShort("diffPreset", 1);
customStepMs = preferences.getUShort("customStepMs", BASE_STEP_MS);
customSpeedupPerFood = preferences.getUShort("customSpeedup", SPEEDUP_PER_FOOD_MS);
customMinStepMs = preferences.getUShort("customMin", MIN_STEP_MS);
customMaxStepMs = preferences.getUShort("customMax", DIFF_MEDIUM_MS * 2);
sprintAllowed = preferences.getBool("sprintAllowed", true);
initLeaderboardDefaults();
for (uint8_t board = 0; board < LEADERBOARD_BOARDS; ++board) {
loadLeaderboardBoard(board);
}
if (toneFreq != 0 && (toneFreq < MIN_TONE_FREQ || toneFreq > MAX_TONE_FREQ || ((toneFreq - MIN_TONE_FREQ) % 100) != 0)) {
toneFreq = 1200;
}
if (!buzzerEnabledLegacy) {
toneFreq = 0;
}
highScore = (difficultyPreset < 3) ? leaderboard[activeLeaderboardBoardIndex()][0].score : 0;
preferences.end();
}
void saveHighScore() {
preferences.begin("snake", false);
preferences.putUShort("toneFreq", toneFreq);
preferences.putUShort("diffPreset", difficultyPreset);
preferences.putUShort("customStepMs", customStepMs);
preferences.putUShort("customSpeedup", customSpeedupPerFood);
preferences.putUShort("customMin", customMinStepMs);
preferences.putUShort("customMax", customMaxStepMs);
preferences.putBool("sprintAllowed", sprintAllowed);
for (uint8_t board = 0; board < LEADERBOARD_BOARDS; ++board) {
saveLeaderboardBoard(board);
}
if (difficultyPreset < 3) {
preferences.putUShort("highScore", leaderboard[activeLeaderboardBoardIndex()][0].score);
} else {
preferences.putUShort("highScore", 0);
}
preferences.end();
}
bool qualifiesForLeaderboard(uint16_t scoreValue) {
if (difficultyPreset >= 3) return false;
const uint8_t board = activeLeaderboardBoardIndex();
return scoreValue > leaderboard[board][LEADERBOARD_SIZE - 1].score ||
leaderboard[board][LEADERBOARD_SIZE - 1].name[0] == '\0' ||
leaderboard[board][LEADERBOARD_SIZE - 1].score == 0;
}
void syncHighScoreFromLeaderboard(uint8_t board) {
highScore = leaderboard[board][0].score;
}
void insertLeaderboardEntry(uint8_t board, const char *name, uint16_t scoreValue) {
LeaderboardEntry entry = {};
strncpy(entry.name, name, NAME_LENGTH);
entry.name[NAME_LENGTH] = '\0';
entry.score = scoreValue;
uint8_t insertAt = LEADERBOARD_SIZE;
for (uint8_t index = 0; index < LEADERBOARD_SIZE; ++index) {
if (scoreValue > leaderboard[board][index].score) {
insertAt = index;
break;
}
}
if (insertAt == LEADERBOARD_SIZE) {
return;
}
for (int index = LEADERBOARD_SIZE - 1; index > static_cast<int>(insertAt); --index) {
leaderboard[board][index] = leaderboard[board][index - 1];
}
leaderboard[board][insertAt] = entry;
syncHighScoreFromLeaderboard(board);
}
void prepareNameEntry(uint16_t scoreValue) {
pendingScore = scoreValue;
memset(pendingName, 0, sizeof(pendingName));
keyboardRow = 0;
keyboardCol = 0;
pendingLeaderboardBoard = activeLeaderboardBoardIndex();
gameState = NAME_ENTRY;
}
void confirmNameEntry() {
if (pendingName[0] == '\0') {
strncpy(pendingName, "PLAYER", NAME_LENGTH);
pendingName[NAME_LENGTH] = '\0';
}
insertLeaderboardEntry(pendingLeaderboardBoard, pendingName, pendingScore);
saveHighScore();
resetGame();
gameState = LEADERBOARD;
// Prevent accidental immediate input if OK was still held: ignore inputs for 500ms
inputIgnoreUntilMs = millis() + 500;
lastSettingsInputMs = millis();
}
const char *keyboardKeyLabel(uint8_t row, uint8_t col) {
static const char *layout[4][7] = {
{"A", "B", "C", "D", "E", "F", "G"},
{"H", "I", "J", "K", "L", "M", "N"},
{"O", "P", "Q", "R", "S", "T", "U"},
{"V", "W", "X", "Y", "Z", "DEL", "OK"}
};
return layout[row][col];
}
void moveKeyboardCursor(int8_t rowDelta, int8_t colDelta) {
keyboardRow = (keyboardRow + 4 + rowDelta) % 4;
keyboardCol = (keyboardCol + 7 + colDelta) % 7;
}
void applyKeyboardSelection() {
const char *label = keyboardKeyLabel(keyboardRow, keyboardCol);
if (strcmp(label, "DEL") == 0) {
if (strlen(pendingName) > 0) {
pendingName[strlen(pendingName) - 1] = '\0';
}
} else if (strcmp(label, "OK") == 0) {
confirmNameEntry();
} else if (strlen(pendingName) < NAME_LENGTH) {
const size_t currentLength = strlen(pendingName);
pendingName[currentLength] = label[0];
pendingName[currentLength + 1] = '\0';
}
}
void toneMs(uint16_t hz, uint16_t durationMs) {
if (toneFreq == 0) return;
ledcWriteTone(ledcChannel, hz);
delay(durationMs);
ledcWriteTone(ledcChannel, 0);
}
uint16_t buzzerCycleUp(uint16_t current) {
if (current == 0) return MIN_TONE_FREQ;
if (current < MAX_TONE_FREQ) return current + 100;
return 0;
}
uint16_t buzzerCycleDown(uint16_t current) {
if (current == 0) return MAX_TONE_FREQ;
if (current > MIN_TONE_FREQ) return current - 100;
return 0;
}
void spawnFood() {
Cell candidate;
do {
candidate.x = random(0, GRID_W);
candidate.y = random(0, GRID_H);
} while (isSnakeCell(candidate));
food = candidate;
}
void resetGame() {
score = 0;
currentDir = RIGHT;
nextDir = RIGHT;
gameState = START;
stateChangeMs = millis();
snakeLen = 3;
const int8_t centerX = GRID_W / 2;
const int8_t centerY = GRID_H / 2;
snake[0] = {centerX, centerY};
snake[1] = {static_cast<int8_t>(centerX - 1), centerY};
snake[2] = {static_cast<int8_t>(centerX - 2), centerY};
spawnFood();
lastStepMs = millis();
}
bool anyButtonPressed() {
return digitalRead(BTNU) == LOW ||
digitalRead(BTNR) == LOW ||
digitalRead(BTND) == LOW ||
digitalRead(BTNL) == LOW;
}
void updateDirectionFromButtons() {
if (digitalRead(BTNU) == LOW && currentDir != DOWN) {
nextDir = UP;
sprintHoldU++;
} else {
sprintHoldU = 0;
}
if (digitalRead(BTNR) == LOW && currentDir != LEFT) {
nextDir = RIGHT;
sprintHoldR++;
} else {
sprintHoldR = 0;
}
if (digitalRead(BTND) == LOW && currentDir != UP) {
nextDir = DOWN;
sprintHoldD++;
} else {
sprintHoldD = 0;
}
if (digitalRead(BTNL) == LOW && currentDir != RIGHT) {
nextDir = LEFT;
sprintHoldL++;
} else {
sprintHoldL = 0;
}
isSprinting = sprintAllowed && ((sprintHoldU >= SPRINT_HOLD_FRAMES) ||
(sprintHoldR >= SPRINT_HOLD_FRAMES) ||
(sprintHoldD >= SPRINT_HOLD_FRAMES) ||
(sprintHoldL >= SPRINT_HOLD_FRAMES));
}
uint16_t stepIntervalMs() {
if (isSprinting) return SPRINT_STEP_MS;
int32_t baseMs = BASE_STEP_MS;
uint16_t curSpeedup = SPEEDUP_PER_FOOD_MS;
uint16_t minStep = MIN_STEP_MS;
uint16_t maxStep = BASE_STEP_MS * 2;
switch (difficultyPreset) {
case 0:
baseMs = BASE_STEP_MS + DIFF_OFFSET_EASY;
curSpeedup = DIFF_SPEEDUP_EASY;
break;
case 1:
baseMs = BASE_STEP_MS + DIFF_OFFSET_MED;
curSpeedup = DIFF_SPEEDUP_MED;
break;
case 2:
baseMs = BASE_STEP_MS + DIFF_OFFSET_HARD;
curSpeedup = DIFF_SPEEDUP_HARD;
break;
case 3:
baseMs = customStepMs;
curSpeedup = customSpeedupPerFood;
minStep = customMinStepMs;
maxStep = customMaxStepMs;
break;
}
if (baseMs < (int32_t)minStep) baseMs = minStep;
if (baseMs > (int32_t)maxStep) baseMs = maxStep;
const uint32_t speedDrop = (uint32_t)score * (uint32_t)curSpeedup;
if ((uint32_t)baseMs <= (uint32_t)minStep + speedDrop) return minStep;
int32_t result = (int32_t)baseMs - (int32_t)speedDrop;
if (result < (int32_t)minStep) return minStep;
return (uint16_t)result;
}
Cell nextHeadCell() {
Cell head = snake[0];
switch (currentDir) {
case UP: --head.y; break;
case RIGHT: ++head.x; break;
case DOWN: ++head.y; break;
case LEFT: --head.x; break;
}
return head;
}
bool outOfBounds(const Cell &cell) {
return cell.x < 0 || cell.x >= GRID_W || cell.y < 0 || cell.y >= GRID_H;
}
bool hitsSnakeBody(const Cell &cell) {
for (uint16_t index = 0; index < snakeLen; ++index) {
if (cellEquals(snake[index], cell)) return true;
}
return false;
}
void runGameStep() {
currentDir = nextDir;
const Cell newHead = nextHeadCell();
if (outOfBounds(newHead) || hitsSnakeBody(newHead)) {
stateChangeMs = millis();
if (qualifiesForLeaderboard(score)) {
prepareNameEntry(score);
} else {
gameState = GAME_OVER;
}
toneMs(330, 120);
toneMs(220, 180);
return;
}
const bool ateFood = cellEquals(newHead, food);
const uint16_t oldLen = snakeLen;
uint16_t newLen = oldLen;
if (ateFood && snakeLen < MAX_SNAKE) newLen = oldLen + 1;
for (uint16_t index = newLen - 1; index > 0; --index) {
snake[index] = snake[index - 1];
}
snake[0] = newHead;
snakeLen = newLen;
if (ateFood) {
const uint16_t pointsEarned = isSprinting ? 2 : 1;
score += pointsEarned;
toneMs(toneFreq, 35);
spawnFood();
}
}
void drawGame() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_5x8_tr);
char hudText[32];
if (isSprinting) {
snprintf(hudText, sizeof(hudText), "S:%u B:%u [SPRINT]", score, highScore);
} else {
snprintf(hudText, sizeof(hudText), "S:%u B:%u", score, highScore);
}
u8g2.drawStr(0, 7, hudText);
for (uint16_t index = 0; index < snakeLen; ++index) {
const int16_t x = snake[index].x * CELL;
const int16_t y = HUD_H + snake[index].y * CELL;
u8g2.drawBox(x, y, CELL, CELL);
}
const int16_t foodX = food.x * CELL;
const int16_t foodY = HUD_H + food.y * CELL;
u8g2.drawFrame(foodX, foodY, CELL, CELL);
u8g2.sendBuffer();
}
void drawStartScreen() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.drawStr(32, 20, "SNAKE");
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(26, 40, "START: UP");
u8g2.drawStr(20, 50, "SETTINGS: RIGHT");
if (difficultyPreset == 3) {
u8g2.drawStr(4, 60, "NOT AVAILABLE IN CUSTOM");
} else {
u8g2.drawStr(14, 60, "LEADERBOARD: LEFT");
}
u8g2.sendBuffer();
}
void drawGameOverScreen() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.drawStr(28, 20, "GAME OVER");
u8g2.setFont(u8g2_font_5x8_tr);
char scoreText[24];
snprintf(scoreText, sizeof(scoreText), "Score: %u", score);
u8g2.drawStr(28, 35, scoreText);
char bestText[24];
snprintf(bestText, sizeof(bestText), "Best: %u", highScore);
u8g2.drawStr(28, 48, bestText);
u8g2.drawStr(18, 62, "Press any button");
u8g2.sendBuffer();
}
void drawLeaderboardScreen() {
const uint8_t board = activeLeaderboardBoardIndex();
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
// char title[28];
const char *difficultyNames[3] = {"EASY", "MED ", "HARD"};
// snprintf(title, sizeof(title), "LEADERBOARD - %s", difficultyNames[board]);
u8g2.drawStr(6, 10, "LEADERBOARD");
char ch[2] = {0, 0}; // Reusable 1-char string buffer
if (board == 1) {
ch[0] = difficultyNames[board][0]; u8g2.drawStr(93, 26, ch);
ch[0] = difficultyNames[board][1]; u8g2.drawStr(98, 36, ch);
ch[0] = difficultyNames[board][2]; u8g2.drawStr(103, 46, ch);
} else {
ch[0] = difficultyNames[board][0]; u8g2.drawStr(90, 22, ch);
ch[0] = difficultyNames[board][1]; u8g2.drawStr(95, 32, ch);
ch[0] = difficultyNames[board][2]; u8g2.drawStr(100, 42, ch);
ch[0] = difficultyNames[board][3]; u8g2.drawStr(105, 52, ch);
};
u8g2.setFont(u8g2_font_5x8_tr);
for (uint8_t index = 0; index < LEADERBOARD_SIZE; ++index) {
char line[24];
snprintf(line, sizeof(line), "%u. %-8s %3u", index + 1, leaderboard[board][index].name, leaderboard[board][index].score);
u8g2.drawStr(4, 20 + index * 8, line);
}
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(85, 64, "Back ----->");
u8g2.sendBuffer();
}
void drawNameEntryScreen() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_5x8_tr);
u8g2.drawStr(2, 8, "NEW RECORD - TYPE NAME");
char scoreLine[24];
snprintf(scoreLine, sizeof(scoreLine), "Score: %u", pendingScore);
u8g2.drawStr(2, 16, scoreLine);
char nameLine[24];
snprintf(nameLine, sizeof(nameLine), "Name: %-8s", pendingName);
u8g2.drawStr(2, 24, nameLine);
u8g2.setFont(u8g2_font_5x8_tr);
const uint8_t gridTop = 30;
const uint8_t cellW = 18;
const uint8_t cellH = 8;
for (uint8_t row = 0; row < 4; ++row) {
for (uint8_t col = 0; col < 7; ++col) {
const uint8_t x = col * cellW;
const uint8_t y = gridTop + row * cellH;
const char *label = keyboardKeyLabel(row, col);
const bool selected = (row == keyboardRow && col == keyboardCol);
if (selected) {
u8g2.drawBox(x + 1, y - 7, cellW - 2, cellH - 1);
u8g2.setDrawColor(0);
}
u8g2.drawStr(x + 5, y, label);
if (selected) {
u8g2.setDrawColor(1);
}
}
}
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(2, 62, "Move: arrows Select: L+R");
u8g2.sendBuffer();
}
void drawSettingsScreen() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.drawStr(26, 16, "SETTINGS");
u8g2.setFont(u8g2_font_5x8_tr);
// Render a small scroll window (3 rows) with selector fixed in the middle
const uint8_t total = settingsTotal();
for (uint8_t row = 0; row < SETTINGS_VISIBLE; ++row) {
const uint8_t itemIndex = settingsScrollOffset + row;
const uint8_t y = 28 + row * 10;
bool highlight = (row == (SETTINGS_VISIBLE / 2));
if (highlight) {
u8g2.drawBox(4, y - 8, 120, 10);
u8g2.setDrawColor(0);
}
char line[40] = {0};
if (itemIndex == 0 || itemIndex == (total - 1)) {
// padding
} else {
if (total == 6) {
// 0 blank,1 buzzer,2 sprint,3 difficulty,4 back,5 blank
if (itemIndex == 1) {
if (toneFreq == 0) snprintf(line, sizeof(line), "Buzzer: OFF");
else snprintf(line, sizeof(line), "Buzzer: %u Hz", toneFreq);
} else if (itemIndex == 2) {
snprintf(line, sizeof(line), "Sprinting: %s", sprintAllowed ? "ON" : "OFF");
} else if (itemIndex == 3) {
const char *names[4] = {"EASY", "MED", "HARD", "CUSTOM"};
snprintf(line, sizeof(line), "Difficulty: %s", names[difficultyPreset]);
} else if (itemIndex == 4) {
snprintf(line, sizeof(line), "Back (save)");
}
} else {
// total == 10 => 0 blank,1 buzzer,2 sprint,3 difficulty,4 customStart,5 customSpeedup,6 customMin,7 customMax,8 back,9 blank
if (itemIndex == 1) {
if (toneFreq == 0) snprintf(line, sizeof(line), "Buzzer: OFF");
else snprintf(line, sizeof(line), "Buzzer: %u Hz", toneFreq);
} else if (itemIndex == 2) {
snprintf(line, sizeof(line), "Sprinting: %s", sprintAllowed ? "ON" : "OFF");
} else if (itemIndex == 3) {
const char *names[4] = {"EASY", "MED", "HARD", "CUSTOM"};
snprintf(line, sizeof(line), "Difficulty: %s", names[difficultyPreset]);
} else if (itemIndex == 4) {
snprintf(line, sizeof(line), " Starting speed: %ums", customStepMs);
} else if (itemIndex == 5) {
snprintf(line, sizeof(line), " Speedup: %u ms/food", customSpeedupPerFood);
} else if (itemIndex == 6) {
snprintf(line, sizeof(line), " Min: %ums", customMinStepMs);
} else if (itemIndex == 7) {
snprintf(line, sizeof(line), " Max: %ums", customMaxStepMs);
} else if (itemIndex == 8) {
snprintf(line, sizeof(line), "Back (save)");
}
}
}
u8g2.drawStr(8, y, line);
if (highlight) {
u8g2.setDrawColor(1);
}
}
// Help line (small)
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(2, 64, "UP/DN: Move L/R: Select/Edit");
u8g2.sendBuffer();
}
void setup() {
pinMode(BTNU, INPUT_PULLUP);
pinMode(BTNR, INPUT_PULLUP);
pinMode(BTND, INPUT_PULLUP);
pinMode(BTNL, INPUT_PULLUP);
pinMode(BUZZER, OUTPUT);
ledcSetup(ledcChannel, 2000, 8);
ledcAttachPin(BUZZER, ledcChannel);
u8g2.begin();
randomSeed(micros());
loadHighScore();
resetGame();
}
void loop() {
updateDirectionFromButtons();
switch (gameState) {
case START: {
const uint32_t now = millis();
if (now >= inputIgnoreUntilMs && digitalRead(BTNL) == LOW && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
if (difficultyPreset < 3) {
lastSettingsInputMs = now;
gameState = LEADERBOARD;
}
} else if (now >= inputIgnoreUntilMs && digitalRead(BTNR) == LOW && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
lastSettingsInputMs = now;
gameState = SETTINGS;
// prepare scroll so the first selectable item (Buzzer) is under the selector
settingsScrollOffset = 0;
} else if (now >= inputIgnoreUntilMs && digitalRead(BTNU) == LOW && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
// Reset the game state before starting to avoid resuming a dead snake
resetGame();
// Now enter playing state
lastSettingsInputMs = now;
delay(60);
isSprinting = false;
sprintHoldU = sprintHoldR = sprintHoldD = sprintHoldL = 0;
gameState = PLAYING;
stateChangeMs = now;
lastStepMs = now;
}
drawStartScreen();
break;
}
case SETTINGS: {
const uint32_t now = millis();
// Debounce: only process input after a short delay
if (now >= inputIgnoreUntilMs && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
if (digitalRead(BTNU) == LOW) {
// UP: scroll up (if possible)
if (settingsScrollOffset > 0) settingsScrollOffset--;
lastSettingsInputMs = now;
} else if (digitalRead(BTND) == LOW) {
// DOWN: scroll down (if possible)
if (settingsScrollOffset + SETTINGS_VISIBLE < settingsTotal()) settingsScrollOffset++;
lastSettingsInputMs = now;
} else if (digitalRead(BTNL) == LOW) {
// LEFT: edit focused item
const uint8_t focused = settingsScrollOffset + (SETTINGS_VISIBLE / 2);
const uint8_t total = settingsTotal();
if (focused == 1) {
// Buzzer cycle backward: OFF <- 500 <- ... <- 2000
toneFreq = buzzerCycleDown(toneFreq);
} else if (focused == 2) {
// Sprint toggle
sprintAllowed = !sprintAllowed;
} else if (focused == 3) {
// Cycle difficulty presets backward
difficultyPreset = (difficultyPreset + 3) % 4;
if (difficultyPreset < 3) {
highScore = leaderboard[activeLeaderboardBoardIndex()][0].score;
} else {
highScore = 0;
}
} else if (focused >= 4 && focused <= 7 && total == 10) {
// Custom fields (decrease)
if (focused == 4) {
if (customStepMs > CUSTOM_MIN_MS) customStepMs -= 10;
} else if (focused == 5) {
if (customSpeedupPerFood > 1) customSpeedupPerFood -= 1;
} else if (focused == 6) {
if (customMinStepMs > CUSTOM_MIN_MS) customMinStepMs -= 5;
} else if (focused == 7) {
if (customMaxStepMs > customMinStepMs + 5) customMaxStepMs -= 5;
}
} else if ((focused == 4 && total == 6) || (focused == 8 && total == 10)) {
// Back
saveHighScore();
gameState = START;
resetGame();
}
lastSettingsInputMs = now;
} else if (digitalRead(BTNR) == LOW) {
// RIGHT: edit focused item
const uint8_t focused = settingsScrollOffset + (SETTINGS_VISIBLE / 2);
const uint8_t total = settingsTotal();
if (focused == 1) {
// Buzzer cycle forward: OFF -> 500 -> ... -> 2000 -> OFF
toneFreq = buzzerCycleUp(toneFreq);
} else if (focused == 2) {
// Sprint toggle (also allow right to toggle)
sprintAllowed = !sprintAllowed;
} else if (focused == 3) {
// Cycle difficulty presets forward
difficultyPreset = (difficultyPreset + 1) % 4;
if (difficultyPreset < 3) {
highScore = leaderboard[activeLeaderboardBoardIndex()][0].score;
} else {
highScore = 0;
}
} else if (focused >= 4 && focused <= 7 && total == 10) {
// Custom fields (increase)
if (focused == 4) {
if (customStepMs < CUSTOM_MAX_MS) customStepMs += 10;
} else if (focused == 5) {
if (customSpeedupPerFood < 255) customSpeedupPerFood += 1;
} else if (focused == 6) {
if (customMinStepMs < customMaxStepMs - 5) customMinStepMs += 5;
} else if (focused == 7) {
if (customMaxStepMs < CUSTOM_MAX_MS) customMaxStepMs += 5;
}
} else if ((focused == 4 && total == 6) || (focused == 8 && total == 10)) {
// Back
saveHighScore();
gameState = START;
resetGame();
}
lastSettingsInputMs = now;
}
}
drawSettingsScreen();
break;
}
case LEADERBOARD: {
const uint32_t now = millis();
if (now >= inputIgnoreUntilMs && digitalRead(BTNR) == LOW && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
lastSettingsInputMs = now;
gameState = START;
settingsScrollOffset = 0;
}
drawLeaderboardScreen();
break;
}
case NAME_ENTRY: {
const uint32_t now = millis();
// Require both L+R to be released before allowing another combo press
if (digitalRead(BTNL) == HIGH && digitalRead(BTNR) == HIGH) {
lrComboReleased = true;
}
// Check L+R combo first (takes priority over movement)
if (now >= inputIgnoreUntilMs && digitalRead(BTNL) == LOW && digitalRead(BTNR) == LOW && lrComboReleased && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
// latch until both released to avoid repeats
applyKeyboardSelection();
lrComboReleased = false;
lastSettingsInputMs = now;
} else if (now >= inputIgnoreUntilMs && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS)) {
// Then check individual movement buttons
if (digitalRead(BTNU) == LOW) {
moveKeyboardCursor(-1, 0);
lastSettingsInputMs = now;
} else if (digitalRead(BTND) == LOW) {
moveKeyboardCursor(1, 0);
lastSettingsInputMs = now;
} else if (digitalRead(BTNL) == LOW) {
moveKeyboardCursor(0, -1);
lastSettingsInputMs = now;
} else if (digitalRead(BTNR) == LOW) {
moveKeyboardCursor(0, 1);
lastSettingsInputMs = now;
}
}
drawNameEntryScreen();
break;
}
case PLAYING: {
const uint32_t now = millis();
if (now - lastStepMs >= stepIntervalMs()) {
lastStepMs = now;
runGameStep();
}
drawGame();
break;
}
case GAME_OVER: {
const uint32_t now = millis();
// Any button press returns to start/reset the game (debounced)
if (now >= inputIgnoreUntilMs && (now - lastSettingsInputMs >= MENU_DEBOUNCE_MS) && anyButtonPressed()) {
lastSettingsInputMs = now;
delay(60);
isSprinting = false;
sprintHoldU = sprintHoldR = sprintHoldD = sprintHoldL = 0;
resetGame();
}
drawGameOverScreen();
break;
}
}
delay(8);
}