Прошивка не заливалась, вечер пятничный, а плата на столе была живая: порт открывался, бортовой светодиод отзывался на Reset. Вместо того чтобы биться головой о кабель, я собрал с ней диалог — самую простую угадайку — и она затянула на полчаса. Игра — самый честный тест общения с платой: ей нужно принять число, сравнить со своим, ответить по делу и не сбить счёт. Из железа тут только зуммер на D8 и светодиод на D12, а вся логика живёт в переписке с монитором порта — ровно тот навык, на котором стоят командные протоколы следующих уроков.
Правила такие: плата загадывает число от 1 до 100 вызовом random(1, 101), вы печатаете варианты, плата отвечает «больше» или «меньше», на угадывание даётся семь попыток. Победа — короткая мелодия через tone() на зуммере и три вспышки светодиода; проигрыш — низкий гудок и раскрытая карта. Почему попыток ровно семь, а не пять и не десять — красивая математика: каждая подсказка делит остаток диапазона пополам, и семь — минимальное число, при котором гарантированно хватит на все сто вариантов.
Разберём три вещи: правила и дихотомию с числами, скетч целиком — с фильтром мусорного ввода и финальными сигналами — и граблю, из-за которой плата после каждого Reset загадывает одно и то же. Механика random() и randomSeed() разобрана в уроке про случайные числа, а превращение ввода в число — в уроке про parseInt; здесь обе берутся как готовые детали.
Правила: сто чисел, подсказки, семь попыток
Стратегия с подсказками называется дихотомией, и у неё жёсткий потолок: если называть середину оставшегося отрезка, каждый ответ платы отрезает половину. Первый вопрос 50 — и диапазон сжался до 50 чисел, второй — до 25, третий — до 13, четвёртый — до 7, пятый — до 4, шестой — до 2, седьмой — до 1. Считаем в степенях двойки: 2 в шестой — это 64, и ста чисел на них не хватает; 2 в седьмой — 128, и уже хватает. Семь попыток — не круглость и не вкус автора, а ceil(log2(100)): минимальное целое, чья степень двойки накрывает диапазон.
Тот же расчёт работает в обратную сторону: задумали диапазон 1-1000 — заложите десять попыток, потому что 2 в десятой — это 1024. Диапазон 1-64 уложится ровно в шесть, без запаса: последний вопрос всегда попадёт в точку. Хотите помучить игрока — урежьте лимит до шести и оставьте ему шанс проиграть честно: вероятность выживания при идеальной игре всё равно упрётся в математику, а не в реакцию.
Отдельная ловушка — границы random(). Вызов random(1, 101) даёт числа от 1 до 100 включительно: правая граница в диапазон не входит, поэтому сотни требует второй аргумент 101. Привычка из других языков — random(1, 100) — урежет диапазон до 99, и игрок, добросовестно перебирающий сотню, никогда не встретит плату на числе 100. Я однажды час искал «пропавшее» число, пока не перечитал справочник: теперь границы random() проверяю с той же привычкой, с какой проверяю полярность питания.
| Диапазон | Чисел | Гарантия победы | Расчёт |
|---|---|---|---|
| 1-10 | 10 | 4 попытки | log2(10) = 3,32, округляем вверх |
| 1-64 | 64 | 6 попыток | ровно log2(64), запаса нет |
| 1-100 | 100 | 7 попыток | 2 в седьмой = 128 > 100, 64 < 100 |
| 1-1000 | 1000 | 10 попыток | 2 в десятой = 1024, впритык |
Скетч: счётчик, два финала, новый раунд
Состояние игры — три глобальные переменные: secret (загаданное), tries (потраченные попытки) и флаг playing. Глобальными они обязаны быть потому, что loop() прогоняется тысячи раз в секунду, а игра между заходами должна помнить всё: локальные переменные умирали бы на каждом проходе. Каждый ход начинается с проверки Serial.available() — без этого забора parseInt() замораживал бы loop() на своём секундном таймауте, и плата выглядела бы зависшей.
Дальше — гигиена ввода из урока про parseInt: после разбора число и хвост живут в буфере раздельно, и невидимый перевод строки, оставленный parseInt, следующей попыткой вернулся бы нулём. Цикл while с Serial.read() вычищает буфер дочиста, а фильтр диапазона if (guess < 1 || guess > 100) ловит то, что осталось: если вместо числа отправили буквы, parseInt пропустит их и вернёт ноль — фильтр вежливо попросит число ещё раз, и попытка не сгорит зря. Буфер UART на 64 байта для такой переписки огромен: строка «73» с переводом строки — три байта, подробности в уроке про буфер.
Финалы разведены по звуку и свету: победа — арпеджио из четырёх нот через tone() с третьим аргументом, который снимает ноту сам, и три вспышки светодиода; проигрыш — один низкий тон 300 Гц и длинная вспышка. Ноты те же, что в уроке про мелодии — 523, 659, 784, 1046, — но без массивов: фраза из четырёх нот живёт честнее в явном виде. После любого финала скетч молча вызывает newRound() — таймеров ожидания не нужно, раунд начинается сразу.
const int BUZZER = 8;const int LED = 12;const int MAX_TRIES = 7; // 2^7 = 128 > 100 int secret = 0;int tries = 0;boolean playing = false; void newRound() { secret = random(1, 101); // 1..100, правая граница не входит tries = 0; playing = true; Serial.println("Zagadal chislo 1-100. Popytok: 7");} void winTune() { tone(BUZZER, 523, 150); delay(180); tone(BUZZER, 659, 150); delay(180); tone(BUZZER, 784, 150); delay(180); tone(BUZZER, 1046, 350); delay(500);} void setup() { pinMode(BUZZER, OUTPUT); pinMode(LED, OUTPUT); Serial.begin(9600); randomSeed(analogRead(0)); // шум неподключенного входа A0 newRound();} void loop() { if (!playing) return; if (Serial.available() > 0) { int guess = Serial.parseInt(); while (Serial.available() > 0) { // доедаем хвост строки Serial.read(); } if (guess < 1 || guess > 100) { Serial.println("Nuzhno chislo 1-100. Popytka ne sgorela."); return; } tries = tries + 1; if (guess == secret) { Serial.print("Ugadal! Eto "); Serial.print(secret); Serial.print(", za "); Serial.print(tries); Serial.println(" popytok."); winTune(); for (int i = 0; i < 3; i = i + 1) { digitalWrite(LED, HIGH); delay(150); digitalWrite(LED, LOW); delay(150); } playing = false; newRound(); } else if (tries >= MAX_TRIES) { Serial.print("Popytki konchilis. Bylo chislo "); Serial.println(secret); tone(BUZZER, 300, 700); delay(900); digitalWrite(LED, HIGH); delay(600); digitalWrite(LED, LOW); playing = false; newRound(); } else if (guess < secret) { Serial.print("Bolshe. Popytka "); Serial.print(tries); Serial.print(" iz 7."); Serial.println(); } else { Serial.print("Menshe. Popytka "); Serial.print(tries); Serial.print(" iz 7."); Serial.println(); } }}Порядок проверок в этом скетче не случайный: сначала победа, потом лимит попыток, и только потом подсказки. Переставите ветки — и проигрыш сработает на седьмой попытке раньше, чем скетч успеет сказать «угадал»: счётчик инкрементируется до сравнения, и обе ветки смотрят на одно и то же свежее значение. Вторая тонкость — return внутри фильтра диапазона: без него попытка с мусорным вводом дошла бы до счётчика и сгорела бы впустую.
Мелодия и вспышки — блокирующие: delay между нотами и внутри паттерна останавливает loop() на полторы секунды. В этой игре спешить некуда — ввод всё равно идёт от человека, а не от датчика. В проектах, где рядом крутится что-то живое, эти полторы секунды пришлось бы разворачивать в планировщик на millis(), как в уроке про millis вместо delay — здесь я сознательно оставил простой вариант, чтобы финал читался с первого взгляда.
Куда играть дальше: счёт партий заводится одной глобальной переменной — инкремент рядом с вызовом winTune(), и к третьей победе у вас уже есть турнирная таблица в терминале. Съезд режимов «сыграем ещё» и «сменить диапазон» превращает игру в пункт меню — ровно та механика собрана в уроке про текстовое меню, где номер пункта приходит из того же parseInt. А диапазон меняется двумя числами: константой MAX_TRIES и второй границей random() — только помните, что правая граница не входит, и что для 1-1000 лимит придётся поднимать до десяти.
Партия в симуляторе
В симуляторе собран весь скетч урока: зуммер на D8 и светодиод на D12 прилагаются. Играйте в дихотомию вслепую: 50, затем 25 или 75, и так до дна — за партию вы увидите, как семи попыток хватает с запасом, если не отступать от середины. Отступите от стратегии и назовите 1 — увидите, как быстро отрезок сужается к краю: подсказки наказывают за жадность точнее любого правила.
Два эксперимента для понимания ввода: отправьте буквы вместо числа — плата попросит число и не спишет попытку; отправьте 150 — получите ту же просьбу. Потом откройте скетч и поменяйте MAX_TRIES на 6: теперь 64 варианта у игрока есть, а 100 — нет, и проигрыш станет нормой, а не происшествием. Именно так протоколы и тюнсятся: константой, без перепрошивки логики.
И последнее про стратегию: семёрка — это потолок для худшего случая. При идеальной игре — всегда середина отрезка — партия в среднем заканчивается между пятой и шестой попыткой, и семь нужна только упрямому невезению. Если партия тянет все семь, вы где-то отступили от середины: проследите за своими вопросами в логе — монитор сохраняет всю партию, и подсвеченные ответы складываются в дерево решений, которое видно целиком.
- random(1, 101) даёт числа 1-100: правая граница не входит, поэтому сотня требует второго аргумента 101
- Семь попыток — математика, а не вкус: 2 в шестой — 64 < 100, 2 в седьмой — 128 > 100; для 1-1000 нужно десять
- parseInt() за забором available(), доещик хвоста while-циклом и фильтр 1-100: фантомный ноль от мусора не сжигает попытку
- Без randomSeed(analogRead(0)) последовательность random() повторяется после каждого Reset — на реальной плате это заметно с первой партии
- Финал — арпеджио 523-659-784-1046 на tone() с третьим аргументом, паттерн вспышек на LED; новый раунд — тот же newRound() без перепрошивки
- 1. Сколько попыток гарантированно хватает для диапазона 1-100 при подсказках «больше/меньше»?
- 2. Какие числа вернёт random(1, 101)?
- 3. Игрок отправил буквы вместо числа, доещик хвоста в скетче есть. Что вернёт parseInt() и что сделает фильтр?
- 4. Зачем в setup() вызывается randomSeed(analogRead(0))?
- 5. Плата ответила «больше» на вашу 50. Какой вопрос с точки зрения дихотомии самый экономный?
Частые вопросы по теме урока
- Сколько попыток гарантированно хватает для диапазона 1-100 при подсказках «больше/меньше»?
- Каждая подсказка делит остаток пополам: 6 попыток накрывают только 64 числа, 7 попыток — уже 128. Формула: ceil(log2(N)). 7 — потому что 2 в седьмой равно 128, а 64 < 100
- Какие числа вернёт random(1, 101)?
- В random(lo, hi) правая граница исключается: диапазон 1-100 требует вызова random(1, 101). С random(1, 100) число 100 не выпадет никогда. от 1 до 100 включительно — правая граница не входит
- Игрок отправил буквы вместо числа, доещик хвоста в скетче есть. Что вернёт parseInt() и что сделает фильтр?
- parseInt() пропускает нецифровые символы, цифр не находит и молча возвращает 0. Фильтр if (guess < 1) ловит этот ноль до счётчика попыток. вернёт 0 — фильтр диапазона попросит число и не спишет попытку
- Зачем в setup() вызывается randomSeed(analogRead(0))?
- Без seed плата после каждой подачи питания загадывает одну и ту же последовательность. Неподключенный A0 даёт шумное зерно — этого достаточно для игры. чтобы последовательность random() не повторялась от Reset к Reset
- Плата ответила «больше» на вашу 50. Какой вопрос с точки зрения дихотомии самый экономный?
- Дихотомия требует называть середину оставшегося отрезка: после «больше» это 75. Шаги по единице удлиняют игру до десятков вопросов, край диапазона — gamble без подстраховки. 75 — середина отрезка 51-100