Игра на bash'е с поддержкой мультиплеера, миф или реальность?

image

Истина где-то тут. Разоблачительный текст далее.

Первая статья И. BASH'им в начало
Вторая статья И. BASH'им дальше

Реализация мультиплеера не давала мне покоя. Но я понимал что игра будет тормозить в коопе. Поэтому предстояла большая работа по увеличению производительности. Я повертел спрайты и так и эдак и подумал: «А что если координаты спрайта (управляющие символы разметки \e[${Y};${X}H) разместить непосредственно в спрайте? И выводить вразу весь спрайт целиком одной командой, а не по кусочкам в цикле». Все спрайты пришлось переделать) Теперь спрайт — это спрайт (О_о) и функция вида (на примере чужого):

      alien=('Z___ '
             '(   ) '
             'Z`?? ')
     alienH=${#alien[*]}
     alienW=${#alien[1]}
        CM1=$DIM$BLK
        CM2=$BLD$BLK
alien_color=("$SKY $CM1 $CM2 $CM1 $SKY"
             "$CM1 $red $red $red $CM1 $SKY"
             "$SKY $CM1 $CM1 $CM1 $SKY")

function sprite_alien {
  hight=$alienH
  width=$alienW
  color=("${alien_color[@]}")
 target=("$OX $[$OY+1]" "$[$OX+1] $[$OY+1]")
    CM1=$SKY$DIM$BLK
    CM2=$BLD$BLK
 sprite=("\e[$OY;$[$OX+1]H${CM1}_${CM2}_${CM1}_$SKY "
         "\e[$[$OY+1];${OX}H${CM1}(${red}${small[$L]}${CM1})${SKY} "
         "\e[$[$OY+2];$[$OX+1]H"${CM1}'`?? '${SKY})
sprite2=('Z___ '
         "(${small[$L]}) "
         'Z`?? ')
}

Функция sprite_alien задает переменные: hight — высота спрайта (количество линий), width — ширина спрайта (количество символов в самом широком элементе спрайта) и массив color — посимвольная раскраска, необходимые для посимвольного вывода. В массиве sprite генерируется спрайт для «быстрого» режима, вставляются управляющие символы координат и цветов. Массив target задает координаты коллизий данного спрайта (отсутствует у объектов фона). Sprite2 необходим для «медленного» посимвольного вывода, который реализован функциями:

# нарезка прилетающего спрайта
function cut_in () {
  for ((h=0; h<$hight; h++)); do spr=
    for ((c=0; c<$cuter; c++)); do
      color2=(${color[$h]})
      symbol=${sprite2[$h]:$c:1}
      symbol=${symbol//'\'/'\\'}
      symbol=${symbol//'Z'/"\e[$[$OY+$h];$[$OX+$c+1]H"}
      spr+="${color2[$c]}$symbol"
    done
    sprite[$h]="$SKY\e[$[$OY+$h];${OX}H$spr"
  done
}

# нарезка улетающего спрайта
function cut_out () {
  for ((h=0; h<$hight; h++)); do spr=; stp=1
    for ((w=$[1-$OX]; w<$width; w++)); do ((stp++))
      color2=(${color[$h]})
      symbol="${sprite2[$h]:$w:1}"
      symbol=${symbol//'\'/'\\'}
      symbol=${symbol//'Z'/"\e[$[$OY+$h];${stp}H"}
      spr+="${color2[$w]}$symbol"
    done
    sprite[$h]="\e[$[$OY+$h];1H$spr"
  done
}

Символ «Z» играет роль маски, теперь спрайты могут быть с «дырками» внутри. На выходе у этих функций получается массив sprite. Объекты обрабатываются и рисуются по-прежнему функцией mover которая, однако, претерпела некоторые изменения:

function mover () { timer=$1

# проверка коллизий объектов с самолетом
case  $type:"$HX $HY" in

  # столкнулся с чужим
  'alien':${target[0]}| 'alien':${target[1]}| 'alien':${target[2]})
    erase_obj $i $hight
    ((life--))
    ((frags++))
    ((enumber--))
    OBJ+=("$OX $OY 0 boom")
    return;;

  # взял усилитель ствола
  'gunup':${target[0]}| 'gunup':${target[1]}| 'gunup':${target[2]})
    erase_obj $i $hight; [[ ${G} -lt 5 ]] && ((G++))
    return;;

  # взял патроны
    'ammo':${target[0]}|  'ammo':${target[1]}|  'ammo':${target[2]})
    erase_obj $i $hight; ((ammo+=100))
    return;;

  # взял жизнь
    'life':${target[0]}|  'life':${target[1]}|  'life':${target[2]})
    erase_obj $i $hight; ((life++))
    return;;

  # плюха от Босса
    'bfire':${target[0]}| 'bfire':${target[1]}| 'bfire':${target[2]})
    erase_obj $i $hight; ((life--))
    return;;

  # и сам Босс теперь тоже тут
    'boss':${target[0]}|  'boss':${target[1]}|  'boss':${target[2]})
    ((life--)); ((bhealth-=10))
    return;;
esac

# коллизии чужих (маленьких и больших) с пулями
case $type in 'alien' | 'boss')

  for (( t=0; t<$NP; t++ )); do

   PI=(${PIU[$t]})
   PX=${PI[0]}
   PY=${PI[1]}

   # координаты пули сравниваются с
   case "$PX $PY" in # hit by bullet

   # точками коллизий из массива target
   ${target[0]}|${target[1]}|${target[2]}|${target[3]}|${target[4]}|${target[5]})

     case $type in
      'alien')
        case $[RANDOM % $rnd] in 0)
          OBJ+=("$OX $OY 0 ${bonuses[$[RANDOM % ${#bonuses[@]}]]}");;
        esac # get bonus
        ((enumber--))
        erase_obj $i $hight
        remove_piu $t
        OBJ+=("$OX $OY 0 boom")
        return;;

      'boss' )
        remove_piu $t
        ((bhealth--))
        continue;;
      esac
    esac
  done
esac

# print
[[ $cuter -lt $width ]] && cut_in	# прилетает,  режем
[[    $OX -le 1      ]] && cut_out	# улетает, нарезаем

[[ $OX -le -$width ]] && {  # улетел, удаляем из списка
  remove_obj $i
  case $type in 'alien') ((enumber--));; esac; return
} || printf "${sprite[*]}"		# еще не улетел, рисуем

# прибавляем циферки
case $timer in 0) ((OX--)); ((cuter++)); OBJ[$i]="$OX $OY $cuter $type";; esac
}

Обработка коллизий новым методом тоже положительно сказалась на производительности. В основном цикле обработка объектов выглядит так:

#-{ Move\check\print all flying to hero objects }-----------
NO=${#OBJ[@]}; for (( i=0; i<$NO; i++ )); do

  OI=(${OBJ[$i]})
  OX=${OI[0]}
  OY=${OI[1]}
  cuter=${OI[2]}
  type=${OI[3]}

  case $type in
  #----------+---------------+------------+-----+----------+
  # OBJ type |  sprite maker |sprite mover|timer|  comment |
  #----------+---------------+------------+-----+----------+
  'tree1' )	sprite_tree1 ;    mover      $Q ;; #
  'tree2' )	sprite_tree2 ;    mover      $W ;; # Trees
  'tree3' )	sprite_tree3 ;    mover      $E ;; #

  'cloud1')	sprite_cloud1;    mover      $Q ;; #
  'cloud2')	sprite_cloud2;    mover      $W ;; # Clouds
  'cloud3')	sprite_cloud3;    mover      $E ;; #

  'boss'  )	sprite_boss  ;    mover       1 ;; # Boss
  'alien' )	sprite_alien ;    mover       0 ;; # Aliens

  'bfire' )	sprite_bfire ;    mover       0 ;; # Boss' plasma shot

  'ammo'  )	sprite_ammob ;    mover       0 ;; # Ammo bonus
  'life'  )	sprite_lifep ;    mover       0 ;; # Life bonus
  'gunup' )	sprite_gunup ;    mover       0 ;; # Gun powerup bonus

  # Взрывы
  'boom'  )	sprite_boom;;

esac; done

Что же получилось выжать в итоге? Для сравнения, старый метод:

image

Новый метод:

image

Двойное увеличение фпс, недурно. Кстати, для замера используется функция fps_counter которая вначале выглядела так:

function fps_counter {

  cur_sec=$(date +'%s')
  [[ $cur_sec -gt $sec ]] && {
    FPS=$FPSC
    [[ $FPS -gt $FPSM ]] && FPSM=$FPS
    [[ $FPS -lt $FPSL ]] && FPSL=$FPS
    sec=$cur_sec
    FPSC=0
  } || ((FPSC++))
}

Использовался date, но такой метод замера производительности заметно эту самую производительность уменьшал. Мне подсказали другой вариант, использовать printf:

function fps_counter {

  #Needs bash 4.2
  printf -v cur_sec '%(%s)T\n' -1
  [[ $cur_sec -gt $sec ]] && {
    FPS=$FPSC
    [[ $FPS -gt $FPSM ]] && FPSM=$FPS
    [[ $FPS -lt $FPSL ]] && FPSL=$FPS
    sec=$cur_sec
    FPSC=0
  } || ((FPSC++))
}

Получилось гораздо приятней. Спасибо, Александр! Прирост производительности я решил использовать для майнинга анонимной криптовалюты Monero и сделал соответствующую закладку в игру. Посмотрим как изменился фпс:

image

На уровне старого метода как будто ничего не изменилось, отлично, никто ничего не заметит!

Это шутка, конечно, хотя тренд такой намечается, будьте бдительны. Не остановливаясь на достигнутом, стал я думать и гадать как же еще выше фпс поднять. Я развил первую идею и решил не выводить спрайты по отдельности, а фигачить их в массив screen, а затем рисовать сразу ВСЕ одной командой! Переделывать много не пришлось, в функции mover вывод через printf

  printf "${sprite[*]}"

заменил на апенд массива screen

  screen+=("${sprite[*]}")

Остальные рисовалки в цикле также переключил на screen а в конце цикла добавил

  printf "${screen[*]}"

Ожидая 10-ти кратного прироста производительности, я поскорей запустил новый вариант:

image

Но прирост оказался не таким умопомрачительным, как я ожидал (майнинг то я забыл отключить)), однако, этот скромный шаг в деле увеличения производительности стал огромным скачком на пути к мультиплееру. Этот метод оказался крайне удобным для отрисовки картинки у клиента, клиент получает сразу готовый кадр с сервера и рисует его! Мультиплеер практически готов, практически.

Дополнительные фпсы пошли в дело. Новый движок позволил увеличить количество объектов фона. Деревьев стало больше, а облака осенью закрывают все небо и солнца практически не видно (как в реале).

image

Пора заняться мультиплеером. Что нужно для мультиплеера? Нужно передавать данные между компьютерами участвующими в игре. Какие данные? Можно передавать все изменения туда-сюда, но возникает куча проблем синхронизации всех объектов на клиенте и сервере. Поэтому пересылать нужно только необходимый минимум, выполнять все расчеты на сервере, отдавая клиету готовый результат. Я тут не изобрел велосипед, идея позаимствована из современных игр, большинство из которых работает именно так. У меня клиент отправляет серверу свой адрес и порт, чтобы сервер знал кому отвечать. Параметры конфигурации: символ самолетика и цвет самолетика\символа. Обрабатывает нажатия кнопок WASDP и отправляет координаты самолетика, а также факт нажатия на гашетку. Вот строка клиента:

  "${caddr[0]} $cport $HS $SC $HC $X $Y $PIU"

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

Небольшое лирическое отступление
Netcat вообще очень полезная утилита. Тут была уже куча статей о нем, а вот как я использую его ежедневно:

Host gate # шлюз в выделенную сеть
HostName 192.168.1.1
User user

Host some_host # Очень важный сервер в выделенной сети
HostName 192.168.0.1
User user
ProxyCommand ssh gate nc %h %p

Как часть ProxyCommand'a в конфиге ~/.ssh/config, очень удобно. Мда, в этот раз действительно небольшое. Добавлю пару слов. Наткнувшись где-то в этих ваших интернетах на информацию о изменении приглашения командной строки, я решил сделать что-нибудь свое. Получился вот такой проект:

image

Командная строка по максимуму освобождена а необходимая информация: рабочий каталог, время, дата и прикольные смайлики, которые каждый раз генерятся случайным образом, расположены сверху и отделены линиями. Получилось очень удобно. И еще одна поделка для удобства, я назвал её спинер, тогда это слово еще не было ругательным. Зачем? Эм, меня несколько озадачивало отсутствие, например, у команды cp прогрессбара, выполняешь копирование большого файла и смотришь в пустоту. В черную дыру я бы даже сказал. Получился такой вот скриптик. Он прикручивается при помощи алиаса вот так:

alias cp="~/SCR/spiner cp"

Он запускает в фоне команду cp с указанными аргументами, и пока она выполняется показывает прикольную анимацию:

image

Отдельно интересный момент:

image

Это, эм, глаза, они вот так вот стукаются друг об друга, эм, вот) Не прогрессбар конечно, но тоже интересно.

Вот как выглядит клиент:

while true; do

  PIU=; client_read
  until nc $saddr $sport 2> /dev/null <<<   "${caddr[0]} $cport $HS $SC $HC $X $Y $PIU"; do client_read; done
  client_read
  screen="$(nc -l $cport)"
  case $screen in 'win'| 'lose') client=; game_type='single'; mess $screen;; esac
  printf "$screen"
  client_read

done

Да, это все) Функция client_read это опрос кнопок:

function client_read {

  read -t$spd -n1 input &> /dev/null; input=${input:0:1}; case $input in
    'w'|'W') [[ $Y -gt 1         ]] && ((Y--));;
    'a'|'A') [[ $X -gt 1         ]] && ((X--));;
    's'|'S') [[ $Y -lt $heroendy ]] && ((Y++));;
    'd'|'D') [[ $X -lt $heroendx ]] && ((X++));;
    'p'|'P') PIU="piu";;
  esac
}

Для чего понадобилось выносить read в отдельную функцию и выполнять несколько раз? Опрос выполняется с задержкой 0.0001 секунды, правда, для кооп режима пришлось увеличить время ожидания до 0.001, т.е. read выполняется с параметром -t0.001, передача данных на сервер же происходит значительно дольше. Клиент не знает, открыт на сервере порт или нет, он просто «стучится» пока ему не «откроют». А игрок все это время давит кнопки и орет: «Почему он не летит?! Я же нажимал!!!111....» Получается эфект неработающих кнопок. Поэтому в тело цикла until добавлен опрос и еще несколько раз в основном цикле, чтоб наверняка) Затем клиент ждет результат от сервера, читая в переменную screen, это тоже большая задержка, но ее, к сожалению, никак не разбавить.

Что же происходит на сервере? Функция sprite_hero2 открывает порт и ждет инфу от клиета, тут используется аналог client_read'a, server_read. И на основе полученной информации создается спрайт второго игрока, и добавляются пульки, если надо. Пульки добавляются в общий массив PIU, чтобы не выполнять дополнительных проверок коллизий. Для определения же кому зачислять фраги, запись пульки расширена, добавлен индекс 1 или 2, первый или второй игрок, соответственно. А в mover'e добавилась проверка владельца при попадании пульки в чужого:

case $owner in 1) ((frags++));; 2) ((frags2++));; esac

Функция sprite_hero2:

function sprite_hero2 { server_read

 client=($(nc -l $sport)); server_read #${caddr[0]} $cport $HS $SC $HC $X $Y $PIU
  caddr=${client[0]}
  cport=${client[1]}
    HS2=${client[2]}
    SC2=${client[3]}
    HC2=${client[4]}
     X2=${client[5]}
     Y2=${client[6]}
   PIU2=${client[7]}
    HX2=$[$X2+9] # координаты коллизии
    HY2=$[$Y2+3] # для второго пилота

  [[ $PIU2 ]] && {

    [[ $ammo2 -ge $G2 ]] && { case $G2 in

      1) PIU+=("$HX2 $HY2 2");;

      2) PIU+=("$HX2 $[$HY2+1] 2"
               "$HX2 $[$HY2-1] 2");;

      3) PIU+=("$HX2 $[$HY2+1] 2"
               "$HX2 $[$HY2-1] 2"
               "$[$HX2+1] $HY2 2");;

      4) PIU+=("$[$HX2+1] $[$HY2+1] 2"
               "$[$HX2+1] $[$HY2-1] 2"
               "$HX2 $[$HY2+2] 2"
               "$HX2 $[$HY2-2] 2");;

      5) PIU+=("$[$HX2+1] $[$HY2+1] 2"
               "$[$HX2+1] $[$HY2-1] 2"
               "$HX2 $[$HY2+2] 2"
               "$HX2 $[$HY2-2] 2"
               "$[$HX2+2] $HY2 2");;

    esac; ((ammo2-=$G2)); }
	}

   CM4=$DIM$HC2; CM5=$SKY$HC2; CM6=$BLD$HC2; CM7=$SKY$SC2$HS2$HC2$BLD
   CM8=$DIM$UND; CM9=$SKY$HC2$BLD
sprite=(
      "\e[$Y2;${X2}H"${SKY}'    '
 "\e[$[$Y2+1];${X2}H"${CM5}' __      '${SKY}
 "\e[$[$Y2+2];${X2}H"${CM4}" |${CM7}?${CM5}____  "${SKY}
 "\e[$[$Y2+3];${X2}H"${CM4}"  \_| ${CM6}/${CM8} °${CM9})${blk}${gun[$G2]}${SKY} "
 "\e[$[$Y2+4];${X2}H"${CM4}"    |${BLD}/     "${SKY}
 "\e[$[$Y2+5];${X2}H"${SKY}'       ')

  screen+=("${sprite[*]}")
}

В конце основного цикла сервер рисует картинку у себя и передает ее клиетну.

[[ $life -gt 0 ]]   && printf "${screen[*]}"   || { clear; sprite_lose; printf "${sprite[*]}"; }

[[ $server ]] && {
  [[ $life2 -le 0 ]] && {
    sender 'lose'
    server=
    game_type='single'
    Y2=
    OBJ+=("$[$X2+1] $[$Y2+1] 0 boom")
  }

  [[ $life2 -gt 0 && $bhealth -le 0 ]] && {
    sender 'win'
    server=
    game_type='single'
  }
}

[[ $server ]] && sender "${screen[*]}"

Для обработки гибели одного из игроков добавлены проверки вида:

[[ $life -gt 0 ]] && ...

Если первым погиб клиент, ему отправляется сообщение «lose» вместо картинки, и сервер переходит в сингл режим, а клиент рисует «гейм овер». Поэтому новый Босс пытается сначала убить клиента, для облегчения жизни серверу, простите). Но если первым погибнет сервер, клиенту надо дать шанс, обмен данными продолжается, но сервер вместо картинки рисует у себя гамовер, а картинку передает клиенту. В итоге мы получаем:

image

Позанимавшись немного, я добавил режим дуэли:

image

И исправил косячек с зимними деревьями, оголив их;

image

Ну что же, летим дальше! Надо будет еще поработать над оптимизацией, управление в режиме мультиплеера всеже подлагивает. Но в целом получилось совсем неплохо.

Пиу, пиу, пиу!)

Комментарии (23)


  1. staticlab
    04.10.2017 00:25

    На маке что-то не завелась: выводится главное меню, но никакие кнопки не работают.


    1. staticlab
      04.10.2017 00:31
      +1

      UPD. Решение в предыдущем посте, извиняюсь.


      1. Self_Perfection
        04.10.2017 18:21

        Вот! Это всё потому что github.com/vaniacer/piu-piu-SH/issues/8 не зарезолвен!


  1. shuguroff
    04.10.2017 09:42
    +3

    Самолет летит хвостом вперед — гениально!)))


    1. igorp1024
      04.10.2017 10:44

      Так потому, что это bash'е-лёт. Всё обосновано.


    1. vaniacer Автор
      04.10.2017 10:58
      +1

      Ну не в опу же стрелять)
      Хотя воздушные дуэли так и происходят, залетел в хвост, струльнул.


    1. vaniacer Автор
      06.10.2017 10:35

      Блин, 2-й самолет опой стреляет, эпикфейл)


      1. vaniacer Автор
        06.10.2017 11:32

        Это не баг, это фича)


        1. vaniacer Автор
          06.10.2017 23:40

          Починил)


  1. dnogin
    04.10.2017 10:57
    +1

    Снимаю шляпу! Красота! Удивился, что netcat может работать так шустро.


  1. bolk
    04.10.2017 12:56

    Я тоже, кстати, несколько лет назад делал «мультиплеер» на неткате: github.com/bolknote/shellgames/blob/master/chess.sh, а ещё интересная тема — автообнаружение противников в сети, я делал PoC через маковский «бонжур» (точнее — dns-sd): github.com/bolknote/shellgames/blob/master/bonfile.sh


  1. x67
    04.10.2017 14:53

    Спасибо за статью! Ждем полноценного 3D с физичной симуляцией полета и управлением мыслями.)


    1. vaniacer Автор
      04.10.2017 15:23

      Работаю над этим.


      1. aGGre55or
        04.10.2017 17:59

        А я бы хотел чтобы он крыльями махал. Чтобы при движении самолёта вверх крылья были опущены вниз (как сейчас), а при самолёта движении вниз — наоборот!


        1. vaniacer Автор
          04.10.2017 22:24

          Орнитоптеры не завозили.


          1. vaniacer Автор
            04.10.2017 22:29

            Я подумываю о вертолетике.


      1. bolk
        05.10.2017 09:47

        На Хабре есть чья-то статья, где автор пререндерил трёхмерный лабиринт для баша.


  1. air_squirrel
    04.10.2017 15:40
    +1

    Гениально ))) мы тут всей командой аппладируем. Bash мы явно используем не на полную мощь!)


    1. vaniacer Автор
      04.10.2017 15:43

      Спасибо)


  1. Self_Perfection
    04.10.2017 17:57
    +2

    Переход на printf сразу всего экрана если и не сказался значительно на производительности, принёс другое улучшение — у меня пропало мерцание на обновлениях кадра.


    Ну и годнее же будет, если сторонние утилиты будут использоваться по-минимому. Весьма вероятно, что использование nc можно заменить на встроенную фичу /dev/tcp баша.


    1. vaniacer Автор
      04.10.2017 22:18

      Да, но как открыть порт?


      1. Self_Perfection
        04.10.2017 23:42

        Погуглил. Блин, видимо никак. Ну хотя бы на клиенте можно попробовать nc убрать


      1. khim
        05.10.2017 00:27

        Никак, увы. Клиента можно реализовать на bash'е, сервер — нет…