Играючи BASH’им вместе

в 20:45, , рубрики: bash, co-op, game, scroller, КодоБред

Игра на 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]="$SKYe[$[$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
}

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

#-{ Movecheckprint 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)Tn' -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
Ну что же, летим дальше! Надо будет еще поработать над оптимизацией, управление в режиме мультиплеера всеже подлигивает. Но в целом получилось совсем неплохо.

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

Автор: vaniacer

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js