Внутри: настольные игры, NFC метки, Firebase, ESP 8266, RFID-RC522, Android и щепотка магии.
Меня зовут Оксана и я Android-разработчик в небольшой, но очень классной команде Trinity Digital. Тут я буду рассказывать об опыте создания настольной игрушки на базе Firebase и всяких разных железяк.
Так уж вышло, что желание запилить что-то забавное у нас совпало с необходимостью провести митап по Firebase в формате Google Developer Group в Петрозаводске. Стали мы думать, что бы такое устроить, чтобы и самим интересно, и на митапе показать можно, и на развитие потом работать, а в итоге увлеклись не на шутку и придумали целую интеллектуальную настольную игру.
Допустим, есть целая куча игр разной степени “настольности” — MTG, Манчкин, DND, Эволюция, Мафия, Scrabble, тысячи их. Мы очень любим настолки за их атмосферность и “материальность”, то есть за возможность держать в руках красивые карточки/фишки, разглядывать, звучно хлопать ими об стол. И все настолки по-разному хороши, но имеют ряд недостатков, которые мешают погрузиться в игру с головой:
Все эти штуки немного выбивают из колеи, заставляют отвлекаться, снижают динамику. И мы придумали что нужно сделать: засунуть их… в сервер! Базовая идея нашей игрушки такая: пусть все правила, последовательность ходов, подсчет значений, рандомайзер и прочие логические части будут ответственностью некоторой внешней системы. А игроки будут с полной отдачей делать ходы, узнавать закономерности игрового мира, выстраивать стратегии, пробовать новое и эмоционально вовлекаться.
Про концепцию той игрушки, к которой мы хотим прийти в финале, я здесь даже рассказывать не буду: это, конечно, интересно, но зачем делить шкуру неубитого проекта. Я расскажу про демку, которую мы наваяли с целью выяснить, а реально ли вообще сделать то, что задумано.
Proof of concept, так сказать.
Надо сделать маленькую, простенькую игру вроде “магического боя”. Парочка оппонентов швыряет друг в друга заклинаниями, выигрывает тот, кто первым прикончит соперника. У игроков есть некоторые статы, допустим, здоровье и мана. Каждое заклинание — это карта, заклинание стоит сколько-то маны и производит какой-то эффект (лечит, калечит, или еще что-нибудь).

Всякие штуки типа “hello world” про Firebase я освещать не буду, благо материалов на этот счет итак достаточно, в том числе и на хабре. Всякие тонкости модели тоже не буду упоминать, чтобы не загружать деталями. Интереснее, как мы будем читать, записывать и обрабатывать данные.
Так в нашей базе выглядят игровые партии.
”35:74:d6:65” — это id партии
states — это игроки
turns — это последовательность ходов
Кроме информации о самих партиях, нам нужно хранить список карт и какие-то предварительные настройки (например, максимально возможные значения здоровья и маны).

Каждая NFC метка может запоминать немного информации. Так как в качестве карточек мы используем билеты московского метро, в каждом из них уже есть уникальный ключ, а нам того и нужно. Считать эти ключи можно, например, любым приложением под андроид, которое умеет в NFC.
Вот кусок из базы, который ставит в соответствии уникальному ключу карточки ее имя, количество маны, необходимое для каста, и набор эффектов, каждый со своей длительностью (в ходах).

А железки у нас такие: микроконтроллер ESP 8266 и считыватель RFID/NFC RFID-RC522. ESP 8266 в нашем случае хорош тем, что он небольшого размера, кушает мало, есть встроенный WI-FI модуль, а также Arduino совместимость (что позволит писать прошивки в привычной Arduino IDE).
Для прототипа мы взяли плату Node MCU v3, которая сделана на основе ESP 8266. Она позволяет заливать прошивки и питаться прямо через USB, что в рамках прототипирования вообще красота. Писать для нее можно на C и на Lua. Оставив в стороне нашу любовь к скриптовым языкам в целом и к Lua в частности, мы выбрали C, т.к. практически сразу нашли необходимый стек библиотек для реализации нашей идеи.
Ну а RFID-RC522 — это, наверное, самый простой и распространенный считыватель карт. Модуль работает через SPI и имеет следующую распиновку для подключения к ESP 8266:

Talk is cheap, show me the code!
Используется библиотека MFRC522. Взаимодействие со сканером идет через SPI:
Для Firebase есть замечательная библиотека FirebaseArduino, которая из коробки позволяет отправлять данные и отслеживать события. Поддерживает создание и отправку Json запросов.
Взаимодействие с Firebase получилось ну очень простым и вкратце может быть описано двумя строчками:
Где firebaseFailed() это:
Json запрос можно отправить следующим образом:
Вот в принципе и все, что нам нужно было от “железной части”. Мы изначально хотели максимально абстрагироваться от нее и в целом это у нас получилось. С момента написания первой прошивки она менялась только 1 раз, и то незначительно.
Это кусочек базы где хранятся ходы текущей партии. В каждом ходе указывается, что за карта сыграна, и на какого игрока она направлена. Если мы хотим, чтобы при новом ходе что-то происходило, пишем Firebase функцию, которая будет отслеживать изменения на узле “turns”:
Функция playEffects выглядит следующим образом (да, там eval, но мы думаем что в демо-проекте это вполне допустимо):
Каждый из эффектов будет примерно таким:
Тут, наверное, стоит пояснить, что игроки в нашей базе представлены так:

То есть у каждого из них есть имя, здоровье и мана. А если в них что-то прилетит, то появятся еще и эффекты:

Кстати, есть еще одна задача, связанная с эффектами: те, что уже отработали свою длительность, надо убирать. Напишем еще одну функцию:
Например, вот так:

Да, именно так:

Выбираем партию и кого из оппонентов отслеживать и потом наблюдаем свои статы цифрами, а статы оппонента в обобщенном виде (пусть это будет смайлик разной степени веселости).
Вот тут условная схемка приложения, чтобы дальше код легче читался:

С чтением данных из Firebase на Android все достаточно просто: вешаем слушатели на определенные узлы в базе, ловим DataSnapshot`ы и отправляем их в UI. Вот так будем показывать список партий на первом экране (я сильно сокращаю код, чтобы выделить только моменты про получение и отображение данных):
Файлики с разметкой я, пожалуй, приводить не буду — там все достаточно тривиально.
Итак, мы хотим запускать PlayerActivity при клике на какую-то партию:
ChoosePlayerFragment читает узел states для выбранной партии, вытаскивает оттуда двух оппонентов и помещает их имена в кнопки (подробно смотрите в исходниках, ссылки в конце статьи).
На этот моменте стоит еще рассказать про StatsFragment, который отслеживает изменения в статах оппонентов и отображает их:
Вот и все запчасти, из которых мы собирали нашу демо-игрушку. Полный исходный код живет на гитхабе, а дальнейшие идеи живут в нашем воображении. Сейчас мы дорабатываем напильником модель, спотыкаемся о дизайн и плодим контент. И если идея выживет, то она наверняка породит еще несколько статеек.
Меня зовут Оксана и я Android-разработчик в небольшой, но очень классной команде Trinity Digital. Тут я буду рассказывать об опыте создания настольной игрушки на базе Firebase и всяких разных железяк. Так уж вышло, что желание запилить что-то забавное у нас совпало с необходимостью провести митап по Firebase в формате Google Developer Group в Петрозаводске. Стали мы думать, что бы такое устроить, чтобы и самим интересно, и на митапе показать можно, и на развитие потом работать, а в итоге увлеклись не на шутку и придумали целую интеллектуальную настольную игру.
Идея:
Допустим, есть целая куча игр разной степени “настольности” — MTG, Манчкин, DND, Эволюция, Мафия, Scrabble, тысячи их. Мы очень любим настолки за их атмосферность и “материальность”, то есть за возможность держать в руках красивые карточки/фишки, разглядывать, звучно хлопать ими об стол. И все настолки по-разному хороши, но имеют ряд недостатков, которые мешают погрузиться в игру с головой:
- Необходимость запоминать правила: вы должны держать в уме, какие действия корректны, а какие нет, как определяется порядок ходов, какие есть исключения по ходу процесса, в какой момент нужно считать очки еще кучу всего;
- Подсчет значений: а сколько у меня сейчас осталось здоровья? А какой бонус даст мне вот эта карта с учетом всех моих статов? а прошел ли я сейчас проверку скилла при вот этих условиях окружения?
- Трата времени на разбирательства в системе, записи, бросание кубиков…
- Невозможность создать достаточно полную и реалистичную модель, потому что она живет у игроков в головах, а головы ограничены по вместимости;
- Наличие у игроков мета-информации о системе и правилах: вы не сможете наслаждаться познанием системы и открывать для себя новое в игровом мире, потому что должны знать все заранее, ведь вы сами контролируете игровой процесс.
Все эти штуки немного выбивают из колеи, заставляют отвлекаться, снижают динамику. И мы придумали что нужно сделать: засунуть их… в сервер! Базовая идея нашей игрушки такая: пусть все правила, последовательность ходов, подсчет значений, рандомайзер и прочие логические части будут ответственностью некоторой внешней системы. А игроки будут с полной отдачей делать ходы, узнавать закономерности игрового мира, выстраивать стратегии, пробовать новое и эмоционально вовлекаться.
Про концепцию той игрушки, к которой мы хотим прийти в финале, я здесь даже рассказывать не буду: это, конечно, интересно, но зачем делить шкуру неубитого проекта. Я расскажу про демку, которую мы наваяли с целью выяснить, а реально ли вообще сделать то, что задумано.
Proof of concept, так сказать.
Задача:
Надо сделать маленькую, простенькую игру вроде “магического боя”. Парочка оппонентов швыряет друг в друга заклинаниями, выигрывает тот, кто первым прикончит соперника. У игроков есть некоторые статы, допустим, здоровье и мана. Каждое заклинание — это карта, заклинание стоит сколько-то маны и производит какой-то эффект (лечит, калечит, или еще что-нибудь).
Для реализации нам понадобится следующее:
- куча NFC меток чтобы сделать из них карты (привет билеты московского метро!);
- две штуки (для каждого игрока) ESP8266 + RFID-RC522 чтобы их считывать, когда делается ход и слать в сеть;
- Firebase — чтобы хранить данные, обрабатывать ходы и изменять значения в модели в соответствии правилам;
- Android — чтобы отображать все происходящее (свои статы, чужие статы) для игроков.

Всякие штуки типа “hello world” про Firebase я освещать не буду, благо материалов на этот счет итак достаточно, в том числе и на хабре. Всякие тонкости модели тоже не буду упоминать, чтобы не загружать деталями. Интереснее, как мы будем читать, записывать и обрабатывать данные.
Немножко про модель
Так в нашей базе выглядят игровые партии.

”35:74:d6:65” — это id партии
states — это игроки
turns — это последовательность ходов
Кроме информации о самих партиях, нам нужно хранить список карт и какие-то предварительные настройки (например, максимально возможные значения здоровья и маны).

Каждая NFC метка может запоминать немного информации. Так как в качестве карточек мы используем билеты московского метро, в каждом из них уже есть уникальный ключ, а нам того и нужно. Считать эти ключи можно, например, любым приложением под андроид, которое умеет в NFC.
Вот кусок из базы, который ставит в соответствии уникальному ключу карточки ее имя, количество маны, необходимое для каста, и набор эффектов, каждый со своей длительностью (в ходах).
Ход происходит следующим образом:
- игрок выбирает карту, подносит ее к одному из считывателей (смотря к кому он хочет применять эффекты — к себе или к оппоненту);
- тот пишет в Firebase Database — “сыграна карта N на игрока M”;
- Firebase функция видит, что в последовательности ходов появилась новая запись, и обрабатывает ее: отнимает у игрока ману за сыгранную карту, приписывает целевому игроку эффекты с текущей карты, а потом применяет все эффекты, которые уже висят на игроках и уменьшает их длительность на 1;
- ну а Android клиент просто отслеживает изменения в Firebase Database и отображает актуальные статы игроков в удобочитаемом виде.

Плавно продвигаемся к железкам и коду
А железки у нас такие: микроконтроллер ESP 8266 и считыватель RFID/NFC RFID-RC522. ESP 8266 в нашем случае хорош тем, что он небольшого размера, кушает мало, есть встроенный WI-FI модуль, а также Arduino совместимость (что позволит писать прошивки в привычной Arduino IDE).
Для прототипа мы взяли плату Node MCU v3, которая сделана на основе ESP 8266. Она позволяет заливать прошивки и питаться прямо через USB, что в рамках прототипирования вообще красота. Писать для нее можно на C и на Lua. Оставив в стороне нашу любовь к скриптовым языкам в целом и к Lua в частности, мы выбрали C, т.к. практически сразу нашли необходимый стек библиотек для реализации нашей идеи.
Ну а RFID-RC522 — это, наверное, самый простой и распространенный считыватель карт. Модуль работает через SPI и имеет следующую распиновку для подключения к ESP 8266:

Talk is cheap, show me the code!
Задача у нас такая:
- Прочитать карточку;
- Если это карточка-ключ для создания партии, создать в Firebase новую партию;
- Если это игровая карта, то получить карту и заслать ее в Firebase (создать новый ход);
- Помигать лампочкой.
Сканнер
Используется библиотека MFRC522. Взаимодействие со сканером идет через SPI:
<code>void Scanner::init() { SPI.begin(); // включаем шину SPI rc522->PCD_Init(); // инициализируем библиотеку rc522->PCD_SetAntennaGain(rc522->RxGain_max); // задаем максимальную мощность } String Scanner::readCard() { // если прочитали карту if(rc522->PICC_IsNewCardPresent() && rc522->PICC_ReadCardSerial()) { // переводим номер карты в вид XX:XX String uid = ""; int uidSize = rc522->uid.size; for (byte i = 0; i < uidSize; i++) { if(i > 0) uid = uid + ":"; if(rc522->uid.uidByte[i] < 0x10) uid = uid + "0"; uid = uid + String(rc522->uid.uidByte[i], HEX); } return uid; } return ""; }
Firebase
Для Firebase есть замечательная библиотека FirebaseArduino, которая из коробки позволяет отправлять данные и отслеживать события. Поддерживает создание и отправку Json запросов.
Взаимодействие с Firebase получилось ну очень простым и вкратце может быть описано двумя строчками:
Firebase.setInt("battles/" + battleId + "/states/" + player + "/hp", 50); if(firebaseFailed()) return;
Где firebaseFailed() это:
int Cloud::firebaseFailed() { if (Firebase.failed()) { digitalWrite(ERROR_PIN, HIGH); // мигаем лампочкой Serial.print("setting or getting failed:"); Serial.println(Firebase.error()); // печатаем в консоль delay(1000); digitalWrite(ERROR_PIN, LOW); // мигаем лампочкой return 1; } return 0; }
Json запрос можно отправить следующим образом:
StaticJsonBuffer<200> jsonBuffer; JsonObject& turn = jsonBuffer.createObject(); turn["card"] = cardUid; turn["target"] = player; Firebase.set("battles/" + battleId + "/turns/" + turnNumber, turn); if(firebaseFailed()) return 1;
Вот в принципе и все, что нам нужно было от “железной части”. Мы изначально хотели максимально абстрагироваться от нее и в целом это у нас получилось. С момента написания первой прошивки она менялась только 1 раз, и то незначительно.

Теперь про специально обученные Firebase функции
Это кусочек базы где хранятся ходы текущей партии. В каждом ходе указывается, что за карта сыграна, и на какого игрока она направлена. Если мы хотим, чтобы при новом ходе что-то происходило, пишем Firebase функцию, которая будет отслеживать изменения на узле “turns”:
exports.newTurn = functions.database.ref('/battles/{battleId}/turns/{turnId}').onWrite(event => { // нас интересует только создание нового хода, а не обновления if (event.data.previous.val()) return; // читаем ходы admin.database().ref('/battles/' + event.params.battleId + '/turns').once('value') .then(function(snapshot) { // выясняем, кто кастит в этот ход var whoCasts = (snapshot.numChildren() + 1) % 2; // читаем игроков admin.database().ref('/battles/' + event.params.battleId + '/states').once('value') .then(function(snapshot) { var states = snapshot.val(); var castingPlayer = states[whoCasts]; var notCastingPlayer = states[(whoCasts + 1) % 2]; var targetPlayer; if (whoCasts == event.data.current.val().target) targetPlayer = castingPlayer; else targetPlayer = notCastingPlayer; // сколько маны нужно отнять admin.database().ref('/cards/' + event.data.current.val().card).once('value') .then(function(snapshot) { var card = snapshot.val(); // отнимаем castingPlayer.mana -= card.mana; // применяем эффекты с текущей карты var cardEffects = card.effects; if (!targetPlayer.effects) targetPlayer.effects = []; for (var i = 0; i < cardEffects.length; i++) targetPlayer.effects.push(cardEffects[i]); // применяем все эффекты, которые уже есть на игроках playEffects(castingPlayer); playEffects(notCastingPlayer); // обновляем игроков return event.data.adminRef.root.child('battles').child(event.params.battleId) .child('states').update(states); }) }) }) });
Функция playEffects выглядит следующим образом (да, там eval, но мы думаем что в демо-проекте это вполне допустимо):
function playEffects(player) { if (!player.effects) return; for (var i = 0; i < player.effects.length; i++) { var effect = player.effects[i]; if (effect.duration > 0) { eval(effect.id + '(player)'); effect.duration--; } } }
Каждый из эффектов будет примерно таким:
function fire_damage(targetPlayer) { targetPlayer.hp -= getRandomInt(0, 11); }
Тут, наверное, стоит пояснить, что игроки в нашей базе представлены так:

То есть у каждого из них есть имя, здоровье и мана. А если в них что-то прилетит, то появятся еще и эффекты:

Кстати, есть еще одна задача, связанная с эффектами: те, что уже отработали свою длительность, надо убирать. Напишем еще одну функцию:
exports.effectFinished = functions.database.ref('/battles/{battleId}/states/{playerId}/effects/{effectIndex}') .onWrite(event => { effect = event.data.current.val(); if (effect.duration === 0) return event.data.adminRef.root.child('battles').child(event.params.battleId).child('states') .child(event.params.playerId).child('effects').child(event.params.effectIndex).remove(); });
И осталось сделать так, чтобы вся эта красота была видна на экране телефона.
Например, вот так:

Да, именно так:

Выбираем партию и кого из оппонентов отслеживать и потом наблюдаем свои статы цифрами, а статы оппонента в обобщенном виде (пусть это будет смайлик разной степени веселости).
Вот тут условная схемка приложения, чтобы дальше код легче читался:

С чтением данных из Firebase на Android все достаточно просто: вешаем слушатели на определенные узлы в базе, ловим DataSnapshot`ы и отправляем их в UI. Вот так будем показывать список партий на первом экране (я сильно сокращаю код, чтобы выделить только моменты про получение и отображение данных):
public class MainActivity extends AppCompatActivity { // ... @Override protected void onCreate(Bundle savedInstanceState) { // ... FirebaseDatabase database = FirebaseDatabase.getInstance(); // слушатель на узле "battles" нашей базы (он получает данные когда добавлен, // и потом каждый раз когда что-то изменилось в списке партий) database.getReference().child("battles").addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot battles) { final List<String> battleIds = new ArrayList<String>(); for (DataSnapshot battle : battles.getChildren()) battleIds.add(battle.getKey()); ArrayAdapter<String> adapter = new ArrayAdapter<>(MainActivity.this, android.R.layout.simple_list_item_1, battleIds.toArray(new String[battleIds.size()])); battlesList.setAdapter(adapter); battlesList.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) { PlayerActivity.start(MainActivity.this, battleIds.get(i)); } }); } @Override public void onCancelled(DatabaseError databaseError) { // ... } }); } }
Файлики с разметкой я, пожалуй, приводить не буду — там все достаточно тривиально.
Итак, мы хотим запускать PlayerActivity при клике на какую-то партию:
public class PlayerActivity extends AppCompatActivity implements ChoosePlayerFragment.OnPlayerChooseListener { // ... @Override protected void onCreate(Bundle savedInstanceState) { // ... battleId = getIntent().getExtras().getString(EXTRA_BATTLE_ID); // если это первый запуск, то показываем фрагмент с выбором игроков if (savedInstanceState == null) getSupportFragmentManager() .beginTransaction() .replace(R.id.container, ChoosePlayerFragment.newInstance(battleId)) .commit(); } @Override public void onPlayerChoose(String playerId, String opponentId) { // выбран игрок - показываем фрагмент который будет его отображать getSupportFragmentManager() .beginTransaction() .replace(R.id.container, StatsFragment.newInstance(battleId, playerId, opponentId)).addToBackStack(null) .commit(); } }
ChoosePlayerFragment читает узел states для выбранной партии, вытаскивает оттуда двух оппонентов и помещает их имена в кнопки (подробно смотрите в исходниках, ссылки в конце статьи).
На этот моменте стоит еще рассказать про StatsFragment, который отслеживает изменения в статах оппонентов и отображает их:
public class StatsFragment extends Fragment { // ... @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { // ... // здесь нужно вытащить из базы, какие значения здоровья и маны максимально возможны // addSingleValueEventListener не будет отслеживать изменения, // а получит данные только один раз database.getReference().child("settings") .addSingleValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot settings) { maxHp = Integer.parseInt(settings.child("max_hp").getValue().toString()); maxMana = Integer.parseInt(settings.child("max_mana").getValue().toString()); } // ... }); // слушаем изменения в статах игрока и обновляем цифры database.getReference().child("battles").child(battleId).child("states").child(playerId) .addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot player) { hp = player.child("hp").getValue().toString(); mana = player.child("mana").getValue().toString(); hpView.setText("HP: " + hp + "/" + maxHp); manaView.setText("MANA: " + mana + "/" + maxMana); } // ... }); // слушаем изменения в статах оппонента и обновляем смайлик database.getReference().child("battles").child(battleId).child("states").child(opponentId) .addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot opponent) { opponentName.setText(opponent.child("name").getValue().toString()); if (opponent.hasChild("hp") && opponent.hasChild("mana")) { int hp = Integer.parseInt(opponent.child("hp").getValue().toString()); float thidPart = maxHp / 3.0f; if (hp <= 0) { opponentView.setImageResource(R.drawable.grumpy); return; } else if (hp < thidPart) { opponentView.setImageResource(R.drawable.sad); return; } else if (hp < thidPart * 2) { opponentView.setImageResource(R.drawable.neutral); return; } opponentView.setImageResource(R.drawable.smile); } } // ... }); } }
Вот и все запчасти, из которых мы собирали нашу демо-игрушку. Полный исходный код живет на гитхабе, а дальнейшие идеи живут в нашем воображении. Сейчас мы дорабатываем напильником модель, спотыкаемся о дизайн и плодим контент. И если идея выживет, то она наверняка породит еще несколько статеек.

