Внутри: настольные игры, NFC метки, Firebase, ESP 8266, RFID-RC522, Android и щепотка магии.
Меня зовут Оксана и я Android-разработчик в небольшой, но очень классной команде Trinity Digital. Тут я буду рассказывать об опыте создания настольной игрушки на базе Firebase и всяких разных железяк.
Так уж вышло, что желание запилить что-то забавное у нас совпало с необходимостью провести митап по Firebase в формате Google Developer Group в Петрозаводске. Стали мы думать, что бы такое устроить, чтобы и самим интересно, и на митапе показать можно, и на развитие потом работать, а в итоге увлеклись не на шутку и придумали целую интеллектуальную настольную игру.
Идея:
Допустим, есть целая куча игр разной степени “настольности” — MTG, Манчкин, DND, Эволюция, Мафия, Scrabble, тысячи их. Мы очень любим настолки за их атмосферность и “материальность”, то есть за возможность держать в руках красивые карточки/фишки, разглядывать, звучно хлопать ими об стол. И все настолки по-разному хороши, но имеют ряд недостатков, которые мешают погрузиться в игру с головой:
<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.setInt("battles/" + battleId + "/states/" + player + "/hp", 50);
if(firebaseFailed()) return;
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;
}
StaticJsonBuffer<200> jsonBuffer;
JsonObject& turn = jsonBuffer.createObject();
turn["card"] = cardUid;
turn["target"] = player;
Firebase.set("battles/" + battleId + "/turns/" + turnNumber, turn);
if(firebaseFailed()) return 1;
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);
})
})
})
});
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();
});
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) {
// ...
}
});
}
}
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();
}
}
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);
}
}
// ...
});
}
}
К сожалению, не доступен сервер mySQL