«Пятнашки» на bash’е: разбираем архитектуру игры

Пишем игру "Пятнашки" на bash'еЯ уже писал статью о написании игры «2048» на bash, и сегодня хотел бы показать, как просто писать несложные игры на bash. Для этого я выбрал несложную игру, которую наверняка все знают, — игру «Пятнашки». В ней используются примерно те же механизмы, что и в «2048», но в этот раз я не буду оптимизировать ее по размеру так сильно, я постараюсь просто показать, как организуется сама игра, чтобы вы могли написать собственную игру, может быть более сложную и интересную. Ну, приступим.

Структурные элементы игры

В любой игре, в которой выигрыш зависит от результата хода игрока, существуют общие элементы. Эти элементы включают следующее:

  • Подготовка игрового поля
  • Главный игровой цикл
  • Ожидание хода игрока
  • Изменение состояния игры
  • Оценка результатов хода

Давайте посмотрим, как эти элементы реализуются на языке bash для игры «Пятнашки».

Подготовка игрового поля

Прежде всего, подготавливаем карту игрового поля. Это некоторая доска, на которой происходит игра и на которой размещаются элементы игры. После того, как игровое поле подготовлено, мы можем начинать игру.

По окончании игры, перед началом следующей, независимо от того, выиграли вы или проиграли, игровое поле подготавливается заново. Поэтому для размещения игровых элементов желательно использовать случайные значения.

Самое первой, что надо сделать — это инициализация массива, который будет хранить состояние игры.

init_game(){
    M=()
    EMPTY=
    RANDOM=$RANDOM
    for i in {1..15}
    do
        j=$(( RANDOM % 16 ))
        while [[ ${M[j]} != "" ]]
        do
            j=$(( RANDOM % 16 ))
        done
        M[j]=$i
    done
    for i in {0..15}
    do
        [[ ${M[i]} == "" ]] && EMPTY=$i
    done
    draw_board
}

Вот что мы делаем:

  1. Задаем массиву M, который будет хранить состояние игры, пустое значение
  2. Задаем переменной EMPTY пустое значение. Эта переменная будет хранить индекс элемента, в котором хранится «пустое место». В данном конкретном случае ее можно не инициализировать, но лучше это все-таки делать всегда
  3. Заполняем поле фишками случайным образом
  4. Находим «пустое место» на поле и записываем номер ячейки в переменную EMPTY
  5. По окончании отрисовываем игровое поле вызовом функции draw_board

После этого можно написать функцию для вывода на экран игрового поля.

draw_board(){
    clear
    D="-----------------"
    S="%s\n|%3s|%3s|%3s|%3s|\n"
    printf $S $D ${M[0]:-"."} ${M[1]:-"."} ${M[2]:-"."} ${M[3]:-"."}
    printf $S $D ${M[4]:-"."} ${M[5]:-"."} ${M[6]:-"."} ${M[7]:-"."}
    printf $S $D ${M[8]:-"."} ${M[9]:-"."} ${M[10]:-"."} ${M[11]:-"."}
    printf $S $D ${M[12]:-"."} ${M[13]:-"."} ${M[14]:-"."} ${M[15]:-"."}
    echo $D
}

Для перерисовки игрового поля сначала очищаем экран командой clear. Она работает очень быстро и перерисовка не будет заметна, не будет никаких мерцаний.

Главный игровой цикл

После подготовки игрового поля запускаем главный игровой цикл. Это бесконечный цикл, в котором будет приниматься ход игрока, результаты хода будут отражаться на игровом поле, будет оцениваться игровая ситуация и вычисляться, выиграл игрок или проиграл. Вот как выглядит главный цикл:

start_game(){
while :
do
    echo "Use w,a,s,d to move, q for quit"
    read -n 1 -s
    case $REPLY in
        w)
            [ $EMPTY -lt 12 ] && exchange $(( $EMPTY + 4 ))
        ;;
        a)
            COL=$(( $EMPTY % 4 ))
            [ $COL -lt 3 ] && exchange $(( $EMPTY + 1 ))
        ;;
        s)
            [ $EMPTY -gt 3 ] && exchange $(( $EMPTY - 4 ))
        ;;
        d)
            COL=$(( $EMPTY % 4 ))
            [ $COL -gt 0 ] && exchange $(( $EMPTY - 1 ))
        ;;
        q)
            quit_game
        ;;
    esac
    draw_board
    check_win
done
}

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

Ожидание хода игрока

Ожидание хода игрока реализуется как ожидание нажатия какой-то клавиши на клавиатуре, за это отвечает команда

read -n 1 -s

Структура case после этого используется для анализа того, какая клавиша была нажата и какие действия мы должны выполнить, в данном случае используются клавиши w,a,s,d для перемещения фишек и q для выхода из игры.

Изменение состояния игры

После того, как игрок сделал свой ход, необходимо отразить изменения, произошедшие на игровом поле в результате этого хода. Для этого мы храним состояние в некотором массиве, который используется для отрисовки игрового поля. И когда игрок нажимает какую-то клавишу, мы выполняем действие с элементами этого массива. В данном скрипте мы выполняем следующие действия:

  1. Для нажатия w (вверх): Если индекс пустого поля меньше 12, то есть, если «пустое место» находится на 1,2,3 строчке, вызываем функцию exchange со значением индекса «пустого места» увеличенным на 4 в качестве параметра. Почему на 4? Потому что у нас 4 элемента в строке и увеличение на 4 означает его смещение на одну строку вниз
  2. Для нажатия s (вниз): Если индекс пустого поля больше 3, то есть, если пустое поле находится на 2,3,4 строчке, вызываем функцию exchange со значением индекса «пустого места», уменьшенным на 4.
  3. Для нажатия a (влево): Вычисляем номер колонки, для этого делим индекс «пустого места» на 4, и, если номер колонки меньше 3 (колонки нумеруются с нуля), то вызываем функцию exchange с параметром, равным индексу «пустого места», увеличенному на 1.
  4. Для нажатия d (вправо): Вычисляем номер колонки, и если он больше нуля, то вызываем функцию exchange с параметром, равным индексу «пустого места», уменьшенному на 1.

Обратите внимание, что мы перемещаем фишки, а не «пустое место».

Функция exchange выглядит так:

exchange(){
    M[$EMPTY]=${M[$1]}
    M[$1]=""
    EMPTY=$1
}

Она выполняет очень простые операции:

  1. В элемент массива с «пустым местом» записывает значение, индекс которого был передан в качестве параметра функции
  2. В элемент массива с индексом, переданным в качестве параметра функции, записывает пустое значение
  3. Присваивает переменной EMPTY значение, равное новому индексу «пустого места».

Оценка результатов хода

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

check_win(){
    for i in {0..14}
    do
        if [ "${M[i]}" != "$(( $i + 1 ))" ]
        then
            return
        fi
    done
    echo "You won! Want to play another game [y/n]?"
    while :
    do
        read -n 1 -s
        case $REPLY in
            y|Y) 
                init_game
                break
            ;;
            n|N) exit
            ;;
        esac
    done
}
  1. Проверяем, что все фишки стоят на своих местах. Фишка с номером 1 на 1 месте (с индексом 0), фишка с номером 2 на 2 месте (с индексом 1) и так далее.
  2. Если до окончания работы цикла встречается ситуация, когда фишка стоит не на своем месте, то выходим из функции, потому что проверять остальные элементы бессмысленно.
  3. Если после окончания работы цикла мы не вышли из функции, значит все фишки стоят на своих правильных местах, что означает, что игрок выиграл, и мы можем у него спросить, хочет ли он сыграть еще раз
  4. Если игрок хочет сыграть еще раз, вызываем функцию init_game и выходим из функции, если игрок не хочет больше играть, выходим из скрипта, если нажата любая другая клавиша, ничего не делаем, просто запрашиваем нажатие еще раз

Пятнашки полностью

И, собственно, весь скрипт целиком:

#!/bin/bash

draw_board(){
    clear
    D="-----------------"
    S="%s\n|%3s|%3s|%3s|%3s|\n"
    printf $S $D ${M[0]:-"."} ${M[1]:-"."} ${M[2]:-"."} ${M[3]:-"."}
    printf $S $D ${M[4]:-"."} ${M[5]:-"."} ${M[6]:-"."} ${M[7]:-"."}
    printf $S $D ${M[8]:-"."} ${M[9]:-"."} ${M[10]:-"."} ${M[11]:-"."}
    printf $S $D ${M[12]:-"."} ${M[13]:-"."} ${M[14]:-"."} ${M[15]:-"."}
    echo $D
}

init_game(){
    M=()
    EMPTY=
    RANDOM=$RANDOM
    for i in {1..15}
    do
        j=$(( RANDOM % 16 ))
        while [[ ${M[j]} != "" ]]
        do
            j=$(( RANDOM % 16 ))
        done
        M[j]=$i
    done
    for i in {0..15}
    do
        [[ ${M[i]} == "" ]] && EMPTY=$i
    done
    draw_board
}

exchange(){
    M[$EMPTY]=${M[$1]}
    M[$1]=""
    EMPTY=$1
}

quit_game(){
    while :
    do
        read -n 1 -s -p "Do you really want to quit [y/n]?"
        case $REPLY in
            y|Y) exit
            ;;
            n|N) return
            ;;
        esac
    done
}

check_win(){
    for i in {0..14}
    do
        if [ "${M[i]}" != "$(( $i + 1 ))" ]
        then
            return
        fi
    done
    echo "You won! Want to play another game [y/n]?"
    while :
    do
        read -n 1 -s
        case $REPLY in
            y|Y) 
                init_game
                break
            ;;
            n|N) exit
            ;;
        esac
    done
}

start_game(){
while :
do
    echo "Use w,a,s,d to move, q for quit"
    read -n 1 -s
    case $REPLY in
        w)
            [ $EMPTY -lt 12 ] && exchange $(( $EMPTY + 4 ))
        ;;
        a)
            COL=$(( $EMPTY % 4 ))
            [ $COL -lt 3 ] && exchange $(( $EMPTY + 1 ))
        ;;
        s)
            [ $EMPTY -gt 3 ] && exchange $(( $EMPTY - 4 ))
        ;;
        d)
            COL=$(( $EMPTY % 4 ))
            [ $COL -gt 0 ] && exchange $(( $EMPTY - 1 ))
        ;;
        q)
            quit_game
        ;;
    esac
    draw_board
    check_win
done
}

init_game
start_game

Надеюсь, в полной версии скрипта всё понятно, если нет — уточняйте в комментариях.