Урок 4. Последовательный порт. Карточная игра "Дурак" (вторая бета-версия)



  • Цель урока

    Сегодня мы напишем сетевую карточную игру на двух игроков. Какую игру написать? Давайте напишем популярную карточную игру "Дурак", цель которой - избавиться от всех карт. Подробнее о правилах Вы можете узнать здесь:
    https://en.wikipedia.org/wiki/Durak

    Рисунок 1.

    Немного теории

    Последовательный порт - сленговое название интерфейса стандарта RS-232, которым массово оснащались персональные компьютеры, техника специального назначения, в том числе и принтеры. Порт называется "последовательным", так как информация через него передаётся по одному биту, последовательно бит за битом. Обычные персональные компьютеры снабжены RS-232, а микроконтроллеры и M5STACK в том числе использует TTL.
    RS-232 для передачи "0" использует напряжение от -3 до -25 В, а для передачи "1" от +3 до +25 В. В отличии от RS-232 в TTL для передачи "0" используется напряжение близкое к 0 В, а для передачи "1" рабочее напряжение интегральной схемы, как правило 3.3 или 5 В (рис. 2).

    Рисунок 2. Разница напряжений между RS-232 и TTL

    Для обмена данными через последовательный порт M5STACK используют цифровые порты ввод/вывода R0, R2 (RX) и T0, T2 (TX) (рис. 3), а также USB порт. Важно учитывать, что если вы используете функции для работы с последовательным портом, то нельзя одновременно с этим использовать порты 0 и 1 для других целей.


    Рисунок 3. Правило подключения двух устройств через последовательный порт

    Для работы с данным портом используют стандартный набор функций Serial из Arduino IDE https://www.arduino.cc/reference/en/language/functions/communication/serial/

    Ознакомьтесь с понятием таблицы ASCII, с помощью которой кодируются символы в вычислительной технике: https://en.wikipedia.org/wiki/ASCII

    Перечень компонентов для урока

    • M5STACK (2 шт.);
    • кабель USB-C из стандартного набора (2 шт.);
    • цветные провода из стандартного набора.

    Начнём!

    Шаг 1. Приступим с самого главного - логотип

    Давайте нарисуем логотип игры, который будет появляться на дисплее устройства при запуске игры. Для этого используем любой редактор, например MS Office Word и Paint (рис. 4).

    Рисунок 4. Рисуем логотип игры в MS Office Word и Paint :)

    Далее при помощи приложения из урока 1.2.1 http://forum.m5stack.com/topic/49/lesson-1-2-1-lcd-how-to-create-image-array конвертируем изображение в массив пикселей и получим файл logo.c, который подключим к скетчу.

    extern unsigned char logo[];
    

    Шаг 2. Создадим структуру колоды карт

    По правилам игры используется колода, содержащая только 36 карт. По 6 карт выдается каждому игроку. Игровое поле рассчитано на 12 карт. Игрок может взять 10 карт, после чего проиграет. В игре карта имеет своё значение и масть, например 7♥. В программе карта имеет своё место: (в руках у игрока - поле игрока; в игровом поле; в использованной колоде; в неиспользованной колоде) и назначение: свободная карта, недоступная карта, карта козырная, карта игрока, карта противника. Сделаем структуру, которая содержит порядковые номера параметров. Колода карт представляет собой массив структур (рис. 5).

    Рисунок 5. Структура карты и массив колоды карт

    #define playerAmount 6
    #define fullAmount 36
    #define playerFieldAmount 10
    #define gameFieldAmount 6
    
    struct playCard
    {
      int card;
      int suit;
      int color;
      int property = -1; // -2 not avaiable, -1 free, 0 trump, 1 player #1, 2 player #2
      int field; // + player field, - game field
    };
    
    String cards[9] = {"6", "7", "8", "9", "10", "J", "Q", "K", "A"};
    char suits[4] = {3, 4, 5, 6}; // ♥, ♦, ♣, ♠
    int colors[2] = {0xe8e4, 0x00}; // red, black
    playCard deckCards[fullAmount];
    

    Шаг 3. Напишем правила игры

    Атака. Случай 1. Если в игровом поле нет ни одной карты, тогда игрок, который получил первое место в очереди может кинуть совершенно любую карту любой масти (рис. 5.1):

    Рисунок 5.1. Можно сделать ход любой картой

    if ((opponentSteps == playerSteps) && (playerSteps == 0))
    {
      deckCards[selectedCard].field = -1;
      sync();
    }
    

    Атака. Случай 2. Если соперник сделал ход на игрока, то игрок может отбиться картой такой же масти, но большего значения (рис. 5.2) или козырной картой любого значения при условии того, что последняя карта соперника в игровом поле не является козырной:

    Рисунок 5.1. Соперник должен отбить карту игрока

    else if (opponentSteps > playerSteps)
    {
      if ((((deckCards[selectedCard].card > deckCards[pfCardId].card) && (deckCards[selectedCard].suit == deckCards[pfCardId].suit)) || ((deckCards[selectedCard].suit == trumpSuit) && (deckCards[pfCardId].suit != trumpSuit))))
      {
        deckCards[selectedCard].field = -opponentSteps;
        sync();
      }
    }
    

    Атака. Случай 3. Когда соперник отобьёт карту, то количество шагов станет равным и игрок сможет подкинуть ещё карты такого же значения, что имеется в игровом поле:

    else if (opponentSteps == playerSteps)
    {
      for (int i = 0; i < fullAmount; i++)
      {
        if ((deckCards[selectedCard].card == deckCards[i].card) && (deckCards[i].field < 0) && (deckCards[i].property != -2))
        {
          deckCards[selectedCard].field = -(playerSteps + 1);
          sync();
          break;
        }
      }
    }
    

    Бито/взять. Случай 1. Если у игроков одинаковое количество шагов, то игрок может отправить карты в отбитую колоду:

    if (opponentSteps == playerSteps) // бито
    {
    	for (int i = 0; i < fullAmount; i++)
    	{
    		if (deckCards[i].field < 0)
    		{
    			deckCards[i].property = -2;
    		}
    	}
    }
    

    Бито/взять. Случай 2. Если у соперник сделал больше ходов, чем игрок, то игрок может забрать все карты с игрового поля себе:

    else if (opponentSteps > playerSteps) // забрать все карты себе
    {
    	for (int i = 0; i < fullAmount; i++)
    	{
    		if ((deckCards[i].field < 0) && (deckCards[i].property != -2))
    		{
    			addPlayerCard(playerId, i);
    		}
    	}
    }
    

    Шаг 4. Нарисуем карты

    Благодаря прекрасным стандартным функциям для работы со встроенным дисплеем из библиотеки M5STACK, рисование карт займет доли секунд и практически не займёт памяти устройства.

    void drawCard(int x, int y, playCard card) {
    	M5.Lcd.fillRoundRect(x, y, 30, 50, 5, 0xffff);
    	M5.Lcd.drawRoundRect(x, y, 30, 50, 5, 0x00);
    	M5.Lcd.setTextSize(2);
    	M5.Lcd.setTextColor(colors[card.color]);
    	M5.Lcd.setCursor((x + 3), (y + 6));
    	M5.Lcd.print(cards[card.card]);
    	M5.Lcd.setTextSize(3);
    	M5.Lcd.setCursor((x + 8), (y + 24));
    	M5.Lcd.print(suits[card.suit]);
    }
    
    void drawEmptyCard(int x, int y) {
      M5.Lcd.fillRect(x, y, 30, 50, 0x2589);
      M5.Lcd.drawRoundRect(x, y, 30, 50, 5, 0x00);
    }
    

    ♣ Лайфхак для пользователей Windows: попробуйте зайти в любой текстовый редактор и зажать на клавиатуре клавишу Alt и набрать одну из цифр от 3 до 6, после этого отпустить нажатые клавиши.

    Шаг 5. Напишем анимацию движения карт

    void drawSelect(playCard card) {
    	int n = card.field - 1;
    	int x = 10 + (n * 30);
    	int y = (yPlayerField - 10);
    	drawEmptyCard(x, yPlayerField);
    	drawCard(x, y, card);
    }
    
    void clearSelect(playCard card) {
    	int n = card.field - 1;
    	int x = 10 + (n * 30);
    	int y = (yPlayerField - 10);
    	M5.Lcd.fillRect(x, y, 30, 50, 0x2589);
    	drawCard(x, yPlayerField, card);
    }
    

    Шаг 6. Нарисуем игровой стол и меню

    void drawGameTable() {
    	M5.Lcd.fillScreen(0x2589); // green table
    	drawAllFields();
    }
    
    void drawMenu() {
    	M5.Lcd.fillRect(0, 0, 320, 20, 0x00); // score bar
    	M5.Lcd.fillRect(0, 220, 320, 20, 0x00); // menu bar
    	/* menu buttons */
    	M5.Lcd.setTextSize(2);
    	M5.Lcd.setTextColor(0x7bef);
    	M5.Lcd.setCursor(62, 223);
    	M5.Lcd.print("G");
    	M5.Lcd.setCursor(155, 223);
    	M5.Lcd.print("A");
    	M5.Lcd.setCursor(247, 223);
    	M5.Lcd.print("S");
    }
    

    Шаг 7. Обновление графики

    void updateGraphics() {
    	drawGameTable();
    	drawMenu();
    	drawPlayerId();
    	drawRest();
    	for (int i = 0; i < fullAmount; i++)
    	{
    		if (deckCards[i].property == playerId)
    		{
    	  		if (deckCards[i].field > 0) // if in the hands of
    				drawPlayerCard(deckCards[i]); 
    	  		else // if in the playing field
    				drawPlPfCard(deckCards[i]); 
    		}
    		else if (deckCards[i].property == getOpponentId())
    		{
    	  		if (deckCards[i].field < 0)
    				drawOpPfCard(deckCards[i]); // draw opponent's cards in the playing field
    		}
    		else if (deckCards[i].property == 0)
    		{
    	  		drawTrumpCard(deckCards[i]);
    		}
    	}
    }
    

    Дополнительные функции (например, drawPlayerId()) Вы можете посмотреть в полном скетче или написать собственные гораздо лучше ;)

    Шаг 8. Напишем функции для приёма/отправки данных через последовательный порт

    Рисунок 5.3

    Функция отправки строки добавляет в конец строки полученной в качестве аргумента символ новой строки и отправляет её в последовательный порт. Потом пытается принять строку, содержащую в себе символ конца сеанса передачи данных. Если символ пришёл, то функция вернёт true иначе false.

    bool serialWriteStr(String str) {
    	Serial.print(str);
    	Serial.print('\n');
    	String input = serialReadStr(); 
    	if (input != "")
    	{
    		if (input[0] == (char)0x04) return true;
    	}
    	return false;
    }
    

    Функция приёма строки в течении timeout (3000 миллисекунд) пытается принять строку, при этом очищая от мусора в начале до символа начала пакета. В случае неудачи возвращает пустую строку.

    String serialReadStr() {
    	String buf = "";
    	long timeout = 3000;
      	long previousMillis = millis();
      	while (true)
      	{
    		unsigned long currentMillis = millis();
    		if (currentMillis - previousMillis > timeout) break;
    		if (Serial.available() > 0)
    		{
    	  		char chr = (char)Serial.read();
    	  		if ((chr != (char)0x16) && (chr != (char)0x04) && (buf == "")) // clear trash
    	  		{
    				Serial.read();
    	  		}
    	  		else
    	  		{
    				if (chr == '\n')
    	  				break;
    				else
    	  				buf += chr;
    	  		}  
    		}
    	}	
    	if (buf != "")
      	{
    		Serial.print((char)0x04);
    		Serial.print('\n');
      	}
      	return buf;
    }
    

    Шаг 9. Сделаем обработку входящих пакетов данных

    Сделаем так, что любая информация, передаваемая между устройствами упаковывалась в специальные пакеты (рис. 6). Напишем функцию, которая принимает, распаковывает и выполняет пакеты. А также напишем вспомогательную функцию (parseString), которая позволит извлекать определенный участок из строки, заключенный между специальных знаков разделителей (похожа на метод Split из JS).

    Рисунок 6. Структура пакета данных

    bool serialRecivePacket() {
    	String input = serialReadStr(); 
    	if (input.length() > 0)
      	{
    		if ((char)input[0] == (char)0x16) 
    		{
    			input[0] = ' ';
    			char groupType = ((parseString(0, (char)0x1d, input)).toInt()) + '0';
    			String groupData = parseString(1, (char)0x1d, input);
    			int groupDataLenmo = (groupData.length() - 1);
    			if (groupData[groupDataLenmo] == (char)0x03)  
    			{
    				groupData[groupDataLenmo] = ' '; 
    				if (groupType == (char)0x31) updateReciveDeckCards(groupData); 
    				else if (groupType == (char)0x32) playerId = 2; 
    				return true;
    		  	}
    		}
    	return false;
    	}
    }
    
    String parseString(int idSeparator, char separator, String str) { // like split JS
    	String output = "";
    	int separatorCout = 0;
    	for (int i = 0; i < str.length(); i++)
    	{
    		if ((char)str[i] == separator)
    		{
      			separatorCout++;
    		}
    		else
    		{
      			if (separatorCout == idSeparator)
      		{
    			output += (char)str[i];
      		}
      		else if (separatorCout > idSeparator)
      		{
    			break;
      		}
    	}
    	return output;
    }
    

    Пример использования функции parseString:

    parseString(1, ':', "cat:Bob,Jack:dog:Martin,Kelvin"); -----> Bob,Jack

    parseString(0, ',', "Bob,Jack"); -----> Bob

    parseString(1, ',', "Bob,Jack"); -----> Jack

    Шаг 10. Реализуем систему задающую номер игрока

    При включении устройства и соединении кабеля, каждое из устройств выдерживает случайный интервал времени, при этом слушает входящие пакеты, и, отсылает пакет "Я здесь!" (рис. 7). По-умолчанию оба устройства являются первыми игроками. То устройство, которое первым примет послание "Я здесь!" сразу назначит себе номер игрока 2.

    Рисунок 7. Принцип работы рукопожатия для получения номера игрока

    void handshakePlayerId() {
    	long tpid = random(10, 1001) * 10;
    	long previousMillis = millis();
    	while (!serialRecivePacket())
    	{
    		unsigned long currentMillis = millis();
    		if (currentMillis - previousMillis > tpid) break;
    	}
    	while (!serialSendPlayerTId());
    }
    
    bool serialSendPlayerTId() {
    	String str = "";
    	str += (char)0x16; 
    	str += (char)0x32; // type "player id selector"
    	str += (char)0x1d;
    	str += (char)0x03; 
    	return serialWriteStr(str);
    }
    

    Шаг 11. Упакуем и распакуем пакет с картой

    bool serialSendDeckCards() {
    	String str = "";
    	str += (char)0x16; 
    	str += (char)0x31; // type "card transfer flag"
    	str += (char)0x1d; 
    	for (int i = 0; i < fullAmount; i++)
    	{
    		str += (char)0x1e; 
    	    str += deckCards[i].card;
    	    str += (char)0x1f; 
    	    str += deckCards[i].suit;
    	    str += (char)0x1f;
    	    str += deckCards[i].color;
    	    str += (char)0x1f;
    	    str += deckCards[i].property;
    	    str += (char)0x1f;
    	    str += deckCards[i].field;
      	}
      	str += (char)0x03; 
      	return serialWriteStr(str);
    }
    
    void updateReciveDeckCards(String groupData) {
    	for (int i = 0; i < fullAmount; i++)
    	{
    	    /* update new card */
    	    String record = parseString(i, (char)0x1e, groupData);
    	    deckCards[i].card = (int8_t)(parseString(0, (char)0x1f, record).toInt());
    	    deckCards[i].suit = (int8_t)(parseString(1, (char)0x1f, record).toInt());
    	    deckCards[i].color = (int8_t)(parseString(2, (char)0x1f, record).toInt());
    	    deckCards[i].property = (int8_t)(parseString(3, (char)0x1f, record).toInt());
    	    deckCards[i].field = (int8_t)(parseString(4, (char)0x1f, record).toInt());
      	}
    	getTrumpCard();
    	updateGraphics();
    	checkWinner();
    }
    

    Дополнительные функции (например, getTrumpCard()) Вы можете посмотреть в полном скетче или написать собственные гораздо лучше ;)

    Шаг 12. Напишем функцию синхронизации

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

    void sync(bool auto_ = false) {
    	if (queue != playerId) 
    	{
    		while (!serialRecivePacket());
    		queue = playerId;
    	}
    	else
    	{
        	if (!auto_)
        	{
    	      while (!serialSendDeckCards());
    	      updateGraphics();
    	      checkWinner();
    	      queue = getOpponentId();
        	}
      	}
    }
    

    Шаг 13. Кто же выиграл?

    Функция whoWin() возвращает номер игрока, который выиграл или -1, если никто не выиграл.
    По правилам игры считается, что если у игрока не осталось ни одной карты, то он выиграл. Если у игрока 10 (playerFieldAmount) и более карт, то он проиграл.

    int whoWin() {
    	int opponentId = getOpponentId();
    	int playerAmount_ = getAmount(playerId);
    	int opponentAmount = getAmount(opponentId);
    	if ((playerAmount_ == 0) || (opponentAmount >= playerFieldAmount)) return playerId;
    	if ((opponentAmount == 0) || (playerAmount_ >= playerFieldAmount)) return opponentId;
    	return -1; 
    }
    
    void checkWinner() {
    	int winner = whoWin();
    	if (winner != -1) drawWinnerShow(winner);
    }
    

    Шаг 14. Соберём весь код воедино и загрузим его в устройство

    Внимание: это мой первый проект игры, поэтому приведенный код является beta-версией, который может содержать баги и ошибки. Спасибо за понимание!

    Скачать скетч для Arduino IDE можно внизу урока в разделе Загрузки.

    Шаг 15. Запуск

    Соединим устройства при помощи последовательного порта (рис. 5.1). И нажмём красные кнопки на обоих устройствах (рис. 8). Попробуем сыграть! (рис. 8.1).

    Рисунок 8. Первый запуск игры

    Рисунок 8.1. Играем!

    Загрузки

    Скетч можно скачать здесь https://yadi.sk/d/i3fwMYoK3SUEoB