Кость потерялась в третий раз за месяц, и в пятницу я принёс вместо неё коробку с Arduino: шесть светодиодов в ряд, кнопка, скетч на сорок строк. Работало безупречно ровно до второй партии. Племянник, самый въедливый игрок за столом, прищурился на третьем ходу: дядя, а почему он после включения всегда начинает с четвёрки? Я перезапустил плату — четвёрка. Ещё раз — четвёрка. Дальше серия тоже повторялась ход в ход, как заезженная пластинка: пятница была не первой партией этой платы, просто раньше никто не следил.
В этом уроке собираем электронный кубик целиком: кнопка бросает, шкала из шести светодиодов показывает грань от одного до шести, Serial печатает результат. Разберём random() с его знаменитым рубежом — верхняя граница не входит в диапазон, — заведём randomSeed() от шума аналогового входа и добавим анимацию «встряски», чтобы бросок ощущался как бросок, а не как щелчок тумблера. Всё это сразу проверим в симуляторе с настоящей кнопкой и шестью диодами.
random(): границы, зерно и почему кубик «помнит» прошлую партию
random(min, max) возвращает целое от min до max, и вот здесь первый рубеж: min входит в диапазон, max — нет. random(1, 7) даёт 1, 2, 3, 4, 5 или 6 — ровно то, что нужно кубику. А random(1, 6) выдаёт только от 1 до 5, и шестёрка не выпадет никогда — игрок замечает такое за один вечер, я проверял на обиженных родственниках. Вызов random(max) с одним аргументом считает от нуля: random(6) — это 0–5, и для граней пришлось бы прибавлять единицу. Я всегда пишу две границы явно: читателю скетча не нужно помнить, включён ли край.
Второй рубеж объясняет историю с пятницей. Случайность в Arduino — псевдослучайность: следующее число вычисляется формулой из предыдущего, а первому числу в цепочке нужно стартовое значение — зерно. Зерна нет или оно всегда одинаковое — и вся цепочка повторяется от включения к включению ход в ход. randomSeed(число) задаёт стартовое зерно, и на плате его берут от шума непрошенного аналогового входа: randomSeed(analogRead(A0)), где A0 ни к чему не подключён. Плавающий вход читает значения с дрожью от 0 до 1023, и эта дрожь — готовая непредсказуемость. Как analogRead вообще читает вход, мы разбирали в уроке про потенциометр — здесь важно только то, что пустое гнездо шумит.
Честная оговорка про симулятор: свободный вход в движке читается ровным нулём, а random внутри сидит на браузерном генераторе, который и так выдаёт новую серию при каждом запуске. То есть в симе повторяющейся серии, как на плате, не увидеть — randomSeed там остаётся ритуалом для реального железа. На своей Uno можете проверить жёстко: закомментируйте строку с randomSeed, перезалейте скетч три раза и сравните первые броски — они совпадут до последней грани.
const int KNOPKA = 2;const int KOLVO = 6;int ledPins[6] = {7, 8, 9, 10, 11, 12}; // на своей плате — любые 6 цифровыхint lastKnopka = HIGH; void pokazatGran(int gran) { for (int i = 0; i < KOLVO; i = i + 1) { if (i < gran) { digitalWrite(ledPins[i], HIGH); } else { digitalWrite(ledPins[i], LOW); } }} void setup() { for (int i = 0; i < KOLVO; i = i + 1) { pinMode(ledPins[i], OUTPUT); } pinMode(KNOPKA, INPUT_PULLUP); Serial.begin(9600); randomSeed(analogRead(A0)); // A0 пустой: зерно из шума (для реальной платы)} void loop() { int knopka = digitalRead(KNOPKA); if (knopka == LOW && lastKnopka == HIGH) { int gran = random(1, 7); pokazatGran(gran); Serial.print("Gran: "); Serial.println(gran); delay(300); } lastKnopka = knopka;}Смотрите, как мало здесь нового. Кнопка читается по фронту — LOW при прошлом HIGH, ровно как в уроке про кнопку: одно нажатие — один бросок, удержание кнопку не «залипает» серией. Шкала — тот же массив пинов с циклом, на котором мы строили бегущие огни, только вместо позиции огонька здесь порог: диод с индексом меньше грани горит, остальные темны. Грань 1 — первый диод, грань 6 — вся линейка. Никаких отдельных функций на каждую грань: pokazatGran() одна на все шесть случаев, и добавить седьмой диод — значит поправить массив и константу, а не плодить ветки.
Функция pokazatGran(int gran) принимает число-грань и внутри решает, кому гореть: если i < gran — HIGH, иначе LOW. Обратите внимание, что она гасит и лишние диоды, а не только зажигает нужные: без else после броска «шесть» шкала так и осталась бы полностью гореть при следующей единице. Это та же грабля «горят все подряд», на которой я оставил вечер в уроке про шкалу — здесь она обезврежена заранее.
delay(300) после броска — не украшение, а страховка: она держит loop занятым треть секунды, чтобы самый быстрый двойной клик не бросил дважды. В симуляторе кнопки идеальные, и хватило бы фронта, но на реальной плате контакты дребезжат 10–20 мс после замыкания, и без паузы или нормального антидребезга из отдельного урока одно нажатие даст два-три броска. Про честность бросков чуть ниже — там тоже есть что обсудить.
Встряска, кластеры и вопрос «а честный ли он?»
Минимальный кубик из кода выше работает, но ощущается как тумблер: нажал — грань. Настоящий бросок имеет драматургию, и её легко сымитировать: восемь пробных граней подряд с растущей паузой delay(60 + i * 30) — от 60 до 270 мс, всего около 1,3 секунды — и только потом финальный бросок, который остаётся на шкале. Пауза растёт, потому что настоящая тряска затухает, а не наоборот; серия полных версия показана в симуляторе ниже. Первый вариант я делал с одной delay(1000) «на подумать» — с точки зрения кода то же самое, но за столом никто не воспринял один длинный пик как бросок.
А теперь самое важное про честность. random() распределён равномерно на длинной дистанции: на сотне-другой бросков каждая грань набирает свои примерно 16,7 процента. Но на короткой дистанции равномерности нет и быть не должно: три шестёрки подряд — событие с шансом один из 216, и на десятке игроков оно случается каждую партию. Я однажды потратил вечер, «чиня» кубик, который трижды подряд выкинул шестёрку племяннику, — чинить было нечего, это нормальная работа честного генератора. Кластеры и пропуски на двадцати бросках — не признак поломки; проверять генератор глазом по двадцати значениям бессмысленно.
Если захочется всё-таки проверить по-взрослому — считайте, а не смотрите: заведите массив int schet[6] и копите количество выпадений каждой грани за шестьдесят бросков, как мы копили показания в журнале на массивах. Ожидание — десять выпадений на грань, и разброс плюс-минус три-четыре абсолютно нормален. А тем, кому нужна настоящая «морда кости», — классика: семь светодиодов ромбом и четыре побочных паттерна для 2, 3, 4, 5 и 6 точек; название граней в таком варианте удобно держать массивом, как ноты в уроке про мелодии. Шкала проще, но честнее к вашей распиновке.
| Приём | Что даёт | Подводный камень |
|---|---|---|
| random(1, 7) | Грани 1–6, то, что нужно кубику | Верхняя граница не входит: random(1, 6) — это только 1–5 |
| random(6) | Значения 0–5 | Для граней придётся прибавлять единицу — место для путаницы |
| Без randomSeed() | Серия повторяется после каждого сброса | Первый бросок после включения всегда один и тот же |
| randomSeed(analogRead(A0)) | Зерно из шума пустого входа — для реальной платы | В симуляторе вход ровный, а движок и так не повторяет серии |
| Шкала из 6 диодов | Грань = количество горящих сегментов | Настоящая грань кости — 7 диодов ромбом и отдельные паттерны точек |
В симуляторе ниже макетку собирает сам движок: кнопка сядет на D2, шесть светодиодов — на D7–D12. Пины светодиодов назначены симулятором; на своей плате берите любые шесть цифровых — в скетче они перечислены в массиве ledPins, и перекомпоновка занимает одну строку. Кнопка работает через INPUT_PULLUP: нажата — LOW, поэтому в коде бросок ловится фронтом с HIGH на LOW.
Нажимайте кнопку и следите за двумя вещами: за растущими паузами встряски и за строкой Gran: N в Serial — она печатает финальную грань и помогает сверять шкалу с логом. Пустой A0 в симе читается нулём и на случайность не влияет; повторяющихся серий, как на плате без seed, здесь не будет — движок при каждом запуске берёт новую случайность у браузера.
- Сделайте бросок «разгоняющимся»: замените delay(60 + i * 30) на delay(300 - i * 30). Паузы будут сжиматься от 300 до 90 мс — встряска из затухающей станет раскручивающейся, и это другое ощущение броска при том же коде.
- Посчитайте честность по-взрослому: заведите int schet[6] и после финального броска увеличивайте schet[gran - 1]. Через 60 бросков распечатайте массив — ожидайте около десяти на грань с разбросом в три-четыре.
- Добавьте «счастливый режим»: если грань выпала той же, что и в прошлый раз, пискнуть зуммером на D8 раз в 200 мс. Понадобится переменная proshlayaGran и сравнение после броска — проверьте, как часто это случается (спойлер: примерно каждый шестой бросок).
- На реальной плате: закомментируйте randomSeed(analogRead(A0)), перезалейте скетч три раза и сравните первые броски — они совпадут. Верните seed и убедитесь, что серии разъехались. В симуляторе этот эксперимент не показать, движок случайности у него другой.
- random(min, max) включает min и не включает max: грани 1–6 — это random(1, 7), а random(1, 6) навсегда отнимет у игроков шестёрку.
- Без randomSeed() серия чисел повторяется от включения к включению — именно это поймал племянник на второй партии.
- randomSeed(analogRead(A0)) с пустым входом берёт зерно из шума — приём для реальной платы; в симуляторе вход ровный, а движок и так не повторяет серии.
- Анимация броска — восемь пробных граней с растущей паузой delay(60 + i * 30): драматургию даёт затухание, а не одна длинная пауза.
- Кластеры на короткой дистанции — норма честного генератора: три шестёрки подряд случаются с шансом один из 216; равномерность проверяют счётчиками на сотнях бросков.
- 1. Сколько разных значений вернёт random(1, 7)?
- 2. Плата после каждого включения бросает одинаковую серию граней. Какая строка пропущена?
- 3. Зачем в randomSeed(analogRead(A0)) нужен именно ни к чему не подключённый вход?
- 4. Кубик три раза подряд выкинул шестёрку за двадцать бросков. Что это значит?
- 5. pokazatGran(6) вызвали после pokazatGran(1). Сколько диодов шкалы горит?
Частые вопросы по теме урока
- Сколько разных значений вернёт random(1, 7)?
- min входит в диапазон, max — нет: random(1, 7) даёт 1, 2, 3, 4, 5 или 6. Семёрки он не вернёт никогда, поэтому для кубика вызов именно такой. Шесть: от 1 до 6
- Плата после каждого включения бросает одинаковую серию граней. Какая строка пропущена?
- random() без randomSeed() стартует каждый раз с одного и того же зерна и проигрывает одну и ту же цепочку чисел. Лечение — randomSeed(analogRead(A0)) с непрошенным входом. randomSeed() со случайным зерном
- Зачем в randomSeed(analogRead(A0)) нужен именно ни к чему не подключённый вход?
- Плавающий вход ловит наводки и читает дрожащие значения 0–1023 — это готовый источник непредсказуемости. Подключённый датчик вернул бы предсказуемое значение. На плавающем входе шум питания — зерно выходит непредсказуемым
- Кубик три раза подряд выкинул шестёрку за двадцать бросков. Что это значит?
- Три одинаковых броска подряд — вероятность один к 216. На короткой дистанции честный генератор кластерится; равномерность видна только на сотнях бросков. Редкая, но нормальная серия: шанс один из 216
- pokazatGran(6) вызвали после pokazatGran(1). Сколько диодов шкалы горит?
- Грань 6 означает: горят все шесть диодов, потому что каждая ячейка с индексом меньше 6 получает HIGH. Функция и зажигает нужные, и гасит лишние через else. Все шесть