Badoo регулярно участвует со стендом в выставках IT-конференций. Поэтому каждый год мы с коллегами — инженерами и деврелами — придумываем, что бы такого айтишного сделать, чтобы не скучать в перерывах между докладами.
Меня зовут Иван, я frontend-разработчик. В этой статье вместе с коллегой и DIY-энтузиастом lilek Юрой Лилековым мы расскажем, как при помощи двух красных кнопок, одного микроконтроллера, кода на React и 250 слов на айти-тематику мы сделали игру «IT-угадайка» и собрали уютную тусовочку на Highload++ и Heisenbug.
Содержание:
Нетривиальное ТЗ
Механика игры и подсчёт баллов
Анимация
Фронтенд
Аппаратная часть: две красные кнопки
Результат
Нетривиальное ТЗ
У IT-конференций своя атмосфера: людей много, поймать и удержать их внимание на стенде непросто. Чтобы в перерывах между докладами они подошли именно к нам, нужно предложить им интересное, но несложное занятие.
Вдохновившись успехом прошлогоднего IT-алиаса, для новой игры мы сформулировали такие требования:
- Она должна быть многопользовательской. Общение участников — главная задача. Мы знаем, что многие приходят в одиночку и не всегда легко заводят разговор с незнакомцами. Игра должна дать повод поговорить друг с другом и с инженерами Badoo.
- Сессии должны быть короткими. На конференции может быть от одной до трёх тысяч человек, и нам хотелось охватить как можно больше участников.
- Игра должна быть зрелищной. Если человек не участвует в игре, то пусть хотя бы за ней наблюдает. Это разжигает интерес, помогает понять суть и решиться на участие.
- Максимально простое управление. Как мы все знаем, если что-то может сломаться, то оно обязательно сломается.
- Без регистрации и СМС!
После мозговых штурмов, размышлений во сне и попыток поймать вдохновение мы придумали «IT-угадайку» — простую игру со словами на IT-тематику.
В чём суть:
- одновременно играют два участника.
- Задача — быстрее противника нажать на кнопку и отгадать зашифрованное слово, показанное на экране.
- Два этапа: простой («Наоборотки»), где слова написаны задом-наперед, и сложный («Перемешалки»), в котором все буквы в словах перемешаны.
- Если ответ правильный, один балл засчитывается отвечающему. Неправильный — балл уходит сопернику.
- Те, кто набрал как минимум N баллов, могут поучаствовать в розыгрыше крутых наушников с шумоподавлением.
Кажется, что все довольно легко и просто, но трудности были впереди.
Механика игры и подсчёт баллов
Из этой гифки примерно понятно, как выглядит процесс раунда:
- Появляется зашифрованное слово.
- Запускается таймер на 10 секунд.
- Если кто-то из игроков нажимает на кнопку, то этот таймер останавливается, и вместо него запускается другой — на 3 секунды. За это время игроку надо дать ответ, иначе балл засчитывается сопернику.
- Если кнопку никто не нажал, то слово автоматически меняется, и весь процесс повторяется заново.
Основная проблема, с которой мы столкнулись — как вести счёт и учёт игроков. Мы хотели сделать игру максимально быстрой и не заставлять участников где-либо регистрироваться. Но при этом нужно было понимать, кто из них набрал определенное количество баллов и может получить главный приз.
Механику подсчёта мы придумали не с первого раза. Только после нескольких испытаний стало понятно, как сделать игру удобной и простой — и для участников, и для ведущих.
Провальные и гениальные идеи
Идея №1: один балл = одна маленькая шоколадка
За каждый правильный ответ мы поначалу решили давать одну шоколадку. В конце участник приносит шоколадки (ну или хотя бы фантики от съеденных «баллов» ), — и всё! Считаем трофеи, узнаем победителя.
Но есть несколько моментов.
Первый: это неудобно. Модерировать игру и одновременно подсчитывать баллы оказалось невозможно, нужен был второй помощник. Это слишком затратно с точки зрения сил и времени ребят на стенде. А с учетом скорости игры пришлось бы буквально кидать шоколадом в участников.
Второй момент — как отличить мои шоколадки от чужих шоколадок? Игроки могли скооперироваться и сложить шоколадки или фантики от них. А значит, схема не работает! Тем более, что конференции двухдневные, и собирать шоколадки можно бесконечно.
Третий момент: нужно слишком много шоколадок. Мы опытным путем проверили, сколько баллов в среднем набирает игрок, и поняли, что шоколадок нужен как минимум вагон. А у нас была только маленькая тележка.
Идея №2: турнирная таблица
Пока одни играют, другие будут записывать себя в турнирную таблицу. В этом случае мы столкнулись бы со всеми возможными проблемами: однофамильцы, тёзки, ошибки в записи, неразборчивый почерк (или необходимость ставить еще один большой монитор и ноутбук для ввода), нежелание оставлять свои контакты и многое другое.
Поэтому мы продолжили искать.
Идея №3: отметки на бейджах участников
Чтобы избавиться от проблемы регистрации игроков, мы использовали готовое решение: бейджи, которые раздавали всем участникам. Это то, что никто не потеряет и что есть у каждого участника. Писать счёт мы решили прямо на них. Участник пришел на подсчёт, посмотрели на бейдж, посчитали очки — определили победителя. Но в этой схеме тоже был подвох: игроки могли «подделать» итоговый счет на их бейджах.
Тогда мы вообще отказались от идеи записывать счет. Пусть те, кто набирает пороговое значение баллов, получают специальную отметку в бейдже — штамп в виде нашего сердечка — о том, что они прошли в круг «избранных». И между этими участниками мы случайным образом разыграем главные призы. А остальные всё равно получат подарки: наши стикеры, блокноты, картхолдеры и приниматели решений.
Конечно, сотрудники Badoo не участвовали в розыгрыше подарков.
Схема получилась такой:
- Гостю на бейдж ставится отметка об участии: черная за достижение 20 баллов или фиолетовая просто за игру. В день можно было сделать три попытки и получить три сердечка.
- Все победители с чёрными отметками собираются в определенное время на стенде, и с помощью теплого лампового лототрона мы разыгрываем между ними наушники.
Протестировав игру на живых людях, мы рассчитали пороговую отметку: набрать в игре 20 баллов было непросто. Но в каждый из двух дней Highload++ победителями были около 15 человек!
Правда, в списке слов было довольно много терминов, понятных прежде всего разработчику, поэтому для QA-конференции Heisenbug мы договорились о пороге в 15 баллов. Возможно, зря: полуфиналистов оказалось в разы больше, а значит, шансов выиграть наушники — меньше.
P. S. На начальном этапе мы решили, что при неправильном ответе участник будет терять балл-шоколадку. Но никто не любит, чтобы у него что-то отнимали. «Это же моя шоколадка, я её заработал!» — возмутились первые тестировщики игры. Поэтому в качестве штрафа мы стали отдавать балл сопернику.
Анимация
Слово для отгадывания должно появляться с какой-нибудь анимацией. Это дает нам целых два плюса:
- Своеобразная «отбивка» между словами. Игрокам гораздо проще переключиться с одного слова на другое, если между ними есть какое-то действие. Вспомните старые файтинги. Каждый новый раунд начинался с “3..2..1..FIGHT!”. Не хотелось делать здесь еще один таймер — у нас их и так уже навалом.
- Визуальное разнообразие. Мы старались сделать игру как можно проще, у нас отсутствовали анимированные фоны и переходы между уровнями. Звук мы тоже не могли использовать, чтобы не мешать другим участникам конференции. Поэтому анимация была необходима.
С ней не все сразу пошло гладко. На тестовых прогонах нашлись хитрые ребята, которые не дожидались конца анимации и нажимали на кнопку раньше. Чтобы проучить хитрецов, мы начали останавливать появление слова, когда один из игроков досрочно нажимал на кнопку. Часть слова, которая еще не успела появится, в этом случае оставалась скрытой за символами. Хотел игрок или нет, ему приходилось отвечать. Угадать слово по нескольким буквам сложно, поэтому балл получал соперник.
Вот как выглядит слово, если нажать на кнопку раньше времени:
Второй сложный момент — длительность анимации. Она всегда длилась одно и то же время — 1,5 секунды. Если «поймать ритм», то можно наловчиться нажимать кнопку быстрее соперника. Для решения проблемы мы добавили случайную величину, которая была от 0 до 500 мс. В таком случае подстроиться под ритм стало гораздо сложнее.
Фронтенд
Расскажу немного про программную часть. Если вам это не интересно, переходите сразу к рассказу о том, как мы искали красные кнопки.
Механика игры получилась довольно простой, и изобретать велосипед не хотелось. Взяли create-react-app
для клиентской части. Но челлендж все же нужен был.
Итак, hooks! Хуки появились на горизонте довольно давно, но их применение в основных продуктах Badoo требовало довольно серьезного переосмысления процесса разработки. Небольшой side-проект был отличным плацдармом для их использования.
No redux! Redux — отличная штука, и мы используем его в нашей работе каждый день. Но для такого небольшого приложения применение redux было не оправдано. К тому же, есть же новый хук useContext
.
const { score, changeScore } = useContext(SessionContext);
const { next } = useContext(QuestionsContext);
const steps = useContext(StepsContext);
Да, вместо глобального стора у нас получилось три контекста, которые совсем не пересекались.
SessionContext
вел подсчет очков.
StepsContext
отвечал за переключение экранов приложения: intro, loop, outro…
QuestionsContext
знал все о вопросах: на какие ответили, какой вопрос будет следующим, сколько еще осталось.
Provider
Каждому контексту нужен провайдер, который будет доставлять данные до конечных компонентов. В качестве примера будем использовать простой провайдер для подсчета очков.
const increment = score => score + 1;
const SessionProvider = props => {
const [leftPlayerScore, changeLeftPlayerScore] = useState(0);
const [rightPlayerScore, changeRightPlayerScore] = useState(0);
const resetScore = useCallback(() => {
changeLeftPlayerScore(0);
changeRightPlayerScore(0);
}, []);
const changeScore = useCallback(player => {
player === 'left'
? changeLeftPlayerScore(increment)
: changeRightPlayerScore(increment);
}, []);
const score = {
left: leftPlayerScore,
right: rightPlayerScore
};
return (
<SessionContext.Provider value={{ score, changeScore, resetScore }}>
{props.children}
</SessionContext.Provider>
);
};
Как видно из кода, мы ведем учет очков независимо. Есть отдельное состояние для «левого» и «правого» игроков. А также функции для управления счетом: сбросить счет и изменить.
API полученного провайдера очень простой. Вообще почти вся логика, связанная с провайдерами, была очень простой, поэтому заострять внимание на ней не будем.
Таймеры
Интересным получился компонент основного раунда игры: в нем были неоднозначные моменты. Компонент одинаковый для обоих режимов (reverse и shuffle — «Наоборотки» и «Перемешалки»).
Из описания раунда понятно, что здесь довольно много взаимодействия со временем.
Во-первых, есть таймер, который отвечает за длительность показа слова на экране: 10 секунд на то, чтобы нажать кнопку. Если кнопку никто так и не нажал, автоматически появляется следующее слово.
Во-вторых, таймер, который запускается, когда один из игроков нажал на кнопку. Дальше у него есть 3 секунды на угадывание слова.
Кажется, что ничего сложного в этом нет, поэтому пишем такой, на первый взгляд, очевидный код:
const [time, nextTick] = useState(0);
useEffect(() => {
let id = setInterval(() => {
nextTick(time + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Но, как вы можете догадаться, это не сработало. Вот прям совсем не сработало!
Таймер доходил до 1 и на этом останавливался. Дело в том, что старое значение time
«замыкалось» внутри функции обработчика. Поэтому с каждым тиком setInterval
ссылался на значение time
из первого рендера.
Был вариант решения проблемы с использование функции вместо прямого значения:
nextTick(currentTime => currentTime + 1);
Да, таким образом у нас всегда было «свежее» значение для таймера. Но мы не могли получить «свежие» props
, например.
Очевидно, что надо было найти другой подход. И самым верным решением было сделать функцию-обработчик мутабельной. Для этого у React есть специальный хук useRef
.
Чаще всего его используют для работы с DOM-элементами, но это не единственное его применение. Мы можем «запомнить» любую переменную в свойство current
и обновлять ее при каждом рендере.
function callback() {
setCount(count + 1);
}
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
Есть хорошая статья Дэна Абрамова про работу с setInterval
и React hooks: Making setInterval Declarative with React Hooks. Он прекрасно описал все подводные камни и все этапы размышлений над реализацией хука useInterval
, который мы и взяли в качестве решения нашей проблемы.
Поскольку стенд был открыт на всем протяжении конференции, непрерывная сессия использования игры была очень большой. Страница не обновлялась вообще (F5), поэтому очень важно было следить за памятью на этапе разработки. Как известно, утечки идут рука об руку с таймерами, а если все это приправить ре-рендерами реакта, то можно было довольно легко написать код, который съест очень-очень много памяти.
Countdown
запускался, останавливался, сбрасывался и заново запускался десятки (а может и сотни) раз за одну игровую сессию. Чтобы не заморачиваться с проверками, которых должно было быть много, мы применили довольно простой «трюк» — добавили key
-пропс этому компоненту.
<QuestionCountdown
key={question.text}
onComplete={nextQuestion}
/>
Останавливаться на этом не будем подробно, исчерпывающее описание процесса сверки реакта есть все у того же Дэна Абрамова: React as a UI Runtime.
Аппаратная часть: две красные кнопки
Итак, мы уже выполнили часть требований к игре: она была многопользовательская, быстрая и с простой механикой. Осталось добавить ей зрелищности. Дальше рассказ поведет Юра Лилеков lilek — наш DIY-энтузиаст, постоянный спикер сообщества, создатель летающего крыла и других сделай-сам-девайсов.
Мы очень хотели найти две большие механические кнопки. И чтобы обязательно красные — как в том меме с Агутиным.
К сожалению, всё, что находили в интернет-магазинах, либо было слишком маленьким (диаметром 5 см), либо кнопок вовсе не было в наличии. Конечно, оставался старый-добрый AliExpress, но ждать доставку времени уже не было.
В итоге мы нашли нужные кнопки на сайте некого креативного агентства. Кнопки где-то в Подмосковье делал Александр (судя по всему, выпускник факультета радиоэлектроники). Мы созвонились, спросили, какой микроконтроллер вшит в коробку, и попросили оставить к нему доступ, потому что нам надо его перепрограммировать.
Александр, мягко говоря, удивился таким вопросам. Когда мы спросили, точно ли кнопки выдержат напор увлеченных программистов, он заверил нас, что такие кнопки стоят в игровых автоматах в «Космике» и с потоком детей справляются отлично. Забегая вперед, скажу, что разгоряченных инженеров кнопки тоже выдержали (только батарейку пришлось однажды поменять).
Начинка
Но, к сожалению, у готового устройства не было всех нужных нам качеств. И если от звуковых эффектов еще можно было избавиться, перерезав провод к динамику, то автоматически определять, какая из кнопок нажата, было не самой простой задачей. Напрашивался простой вариант: как-то подключить это устройство к компьютеру и транслировать нажатия этих кнопок в нажатия клавиш на клавиатуре.
Решение без особых изысков — взять старую USB-клавиатуру и подвести пару кнопок дополнительным проводами от клавиатуры к устройству — отмели сразу же как «колхозное». И подключили силу DIY.
После недолгих раздумий решили воспользоваться платой Arduino Pro Micro на базе микроконтроллера ATmega32u4 семейства AVR от компании Atmel с необходимой обвязкой. На такой плате, помимо всего прочего разведены порты ввода/вывода и MicroUSB. А самое главное — микроконтроллер ATmega32u4 может выступать в качестве HID-девайса, то есть, в нашем случае, эмулировать нажатия клавиш при определенных условиях.
Для программирования этого микроконтроллера нужен лишь обычный MicroUSB провод и среда разработки Arduino IDE.
После установки среды разработки сразу будут доступны простейшие примеры кода.
Например, программа, которая эмулирует набор текста на клавиатуре при нажатии на кнопку (подключенную к порту ввода/вывода), находится здесь:
File->Examples->USB->Keyboard->KeyboardMessage
Эмуляция нажатия клавиш достигается очень просто:
#include "Keyboard.h" // Подключаем библиотеку работы с клавиатурой
void setup() {
Keyboard.begin(); // Инициализируем клавиатуру
}
void loop() {
Keyboard.print("Test"); // Отправляем нажатия 4х клавиш
delay(1000); // Пауза на 1 секунду
}
Чтение состояния порта ввода также без излишеств:
int button_pin = 7; // Задаем вход, к которому подключена кнопка
void setup() {
pinMode(button_pin, INPUT); // Инициализируем порт на ввод
}
void loop() {
if (digitalRead(button_pin)) {
// Кнопка нажата
} else {
// Кнопка НЕ нажата
}
}
Поскольку порт открывается напряжением, а не током, то малейшие наведенные токи в проводах, идущих от кнопки к порту, могут давать ложные срабатывания. Поэтому рекомендуем «подтягивать» входные порты высокоомными резисторами: наведенный ток мгновенно утечет через него в «землю», не дав возникнуть высокой разности напряжения между входом и «землей», и, таким образом, не возникнет ложного срабатывания. Для этих целей вполне достаточно резистора на 5-10 кОм, которыйподключается между входом микроконтроллера и его «землей».
Таким образом у нас получается следующая схема:
Плата Arduino Pro Micro через microUSB подключается к ноутбуку с программной частью игры, две кнопки подключаются к двум входам платы и общему питанию. Также два этих входа подтянуты к земле двумя резисторами.
При нажатии одной из кнопок ток от выхода питания платы через кнопку попадает на соответствующий вход платы — таким образом мы увидим логическую «единицу» на входе.
В программной части мы решили эмулировать нажатия клавиш «стрелка влево» и «стрелка вправо», а также давать задержку в 4 секунды после определения нажатия, дабы избежать повторных нажатий участниками или дребезга контактов в кнопках.
#include <Keyboard.h>
char leftKey = KEY_LEFT_ARROW;
char rightKey = KEY_RIGHT_ARROW;
int btn1pin = 7;
int btn1value = 0;
int btn2pin = 8;
int btn2value = 0;
void setup() {
pinMode(btn1pin, INPUT);
pinMode(btn2pin, INPUT);
Keyboard.begin();
}
void loop() {
btn1value = digitalRead(btn1pin);
btn2value = digitalRead(btn2pin);
if (btn1value == 1 && btn2value == 1) {
// Обе кнопки нажаты = ничего не делаем
} else if (btn1value == 0 && btn2value == 0) {
// Обе кнопки отжаты = ничего не делаем
} else if (btn1value == 1) {
// Нажата первая кнопка = отправляем нажатие клавиши и замираем на 4 секунды
Keyboard.press(leftKey);
delay(100);
Keyboard.releaseAll();
delay(4000);
} else if (btn2value == 1) {
// Нажата вторая кнопка = отправляем нажатие клавиши и замираем на 4 секунды
Keyboard.press(rightKey);
delay(100);
Keyboard.releaseAll();
delay(4000);
}
}
Вот так при помощи нехитрых приспособлений мы научили кнопки определять игрока, нажимающего на них быстрее соперника, и сигнализировать об этом ведущему.
Управление
Полностью ручное и максимально простое:
- Пробел — следующее слово
- Enter — показать правильное слово
- + — верно (1 балл)
- 0 — неверно (балл сопернику)
- Левый Shift — следующий этап
- Правый Shift — предыдущий этап
Результат
Все четыре дня конференций (два на Highload++, два — Heisenbug) на стенде Badoo беспрерывно играли в «IT-угадайку». Все наши надежды оправдались:
- Игра притягивала и участников, и зрителей: на стенде собиралась целая очередь желающих. Особенно приятно, что на второй день конференций количество участников не уменьшалось.
- Кнопки — это топ! Добавляют +100 к азарту. Даже если соперниками были незнакомые друг другу люди, игра вызывала кучу эмоций.
- Идея с отметками на бейдже сработала: никаких сложностей с поиском победителей и сбором контактов. Пара человек попросила оставить их бейджик чистым, так что мы ставили отметки на запястье (они легко стирались).
- Мы раздали 14 пар наушников и несколько сотен маленьких призов. Никто не остался без подарка!
Приниматели решений на магните. Не знаешь, что делать с задачей — покрути магический диск! У нас осталась только пара фотографий, так как мы раздали все:
Для QA-инженеров
Для разработчиков
Печенье с IT-предсказаниями
Предсказания придумывали сами (52 варианта). Сбываются с вероятностью до 100%.