Как написать игру «Змейка» на Scala

от автора

image

Эта статья написана по приколу. В ней я за считанные минуты расскажу, как создать игру «Змейка» на Scala с использованием ScalaFX.

Ранее я выложил эту игру в видеоформате. В этом видео я хотел преодолеть психологический барьер (10 минут) и реализовать игру (почти) с нуля. Так что можете посмотреть следующее видео, если предпочитаете «экшн».

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

Введение

Здесь мы воспользуемся ScalaFX, библиотекой-оберткой, действующей поверх JavaFX для GUI, с некоторыми красивостями Scala. Эту библиотеку нельзя назвать «прежде всего функциональной», но функциональная составляющая добавляет ей выразительности.

Чтобы добавить ScalaFX в наш проект, мы следующим образом внедрим задаваемый по умолчанию build.sbt:

scalaVersion := "2.13.8"  // Добавляем зависимость от библиотеки ScalaFX  libraryDependencies += "org.scalafx" %% "scalafx" % "16.0.0-R25"  // Определяем версию операционной системы для бинарников JavaFX  lazy val osName = System.getProperty("os.name") match {   case n if n.startsWith("Linux")   => "linux"   case n if n.startsWith("Mac")     => "mac"   case n if n.startsWith("Windows") => "win"   case _ => throw new Exception("Unknown platform!") }  // Добавляем зависимость от библиотек JavaFX, с учетом операционной системы lazy val javaFXModules = Seq("base", "controls", "fxml", "graphics", "media", "swing", "web") libraryDependencies ++= javaFXModules.map(m =>   "org.openjfx" % s"javafx-$m" % "16" classifier osName )

Подготовив файл build.sbt, мы еще должны добавить немного шаблонного кода, чтобы у нас получилось простое приложение ScalaFX, которое открывается как окно с белой заливкой:

// все импорты, которые понадобятся нам для целого приложения  // (автоматический импорт сильно помогает, но давайте добавим их здесь, чтобы избежать путаницы) import scalafx.application.{JFXApp3, Platform} import scalafx.beans.property.{IntegerProperty, ObjectProperty} import scalafx.scene.Scene import scalafx.scene.paint.Color import scalafx.scene.paint.Color._ import scalafx.scene.shape.Rectangle  import scala.concurrent.Future import scala.util.Random  object SnakeFx extends JFXApp3 {   override def start(): Unit = {     stage = new JFXApp3.PrimaryStage {       width = 600       height = 600       scene = new Scene {         fill = White       }     }   } }

Отрисовка

Чтобы отрисовать что-либо на экране, нужно изменить поле content в поле scene поля stage в главном приложении. Очень много косвенности. Конкретнее, чтобы отрисовать зеленый прямоугольник длиной 25 в координатах (50, 75), нужно написать примерно такой код:

stage = new JFXApp3.PrimaryStage {   width = 600   height = 600   scene = new Scene {     fill = White     // только что добавлено     content = new Rectangle {       x = 50       y = 75       width = 25       height = 25       fill = Green     }   } }

И у нас получается нечто волшебное:

image

Координаты начинаются из верхнего левого угла; координата x увеличивается вправо, координата y увеличивается вниз.

Отрисовка прямоугольника так полезна, что мы возьмем выражение Rectangle и будем вызывать его из метода:

def square(xr: Double, yr: Double, color: Color) = new Rectangle {     x = xr     y = yr     width = 25     height = 25     fill = color }

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

Переходим к логике.

Логика

Все, что нам требуется в игре «Змейка» — рисовать квадраты на экране. Вопрос в том, где.

В рамках логики этой игры будем рассматривать змейку как список из координат (x,y), которыми затем воспользуемся при отрисовке квадратов нашей волшебной функцией square. Помните, что в сцене есть поле content? Это может быть и не единственный рисунок, а целая коллекция – поэтому можем спокойно использовать наш список квадратов как подходящее значение.

Итак, давайте начнем с исходного набора координат для змейки. Представим змейку из трех квадратов в форме

val initialSnake: List[(Double, Double)] = List(     (250, 200),     (225, 200),     (200, 200)   )

и рассмотрим состояние игры как структуру данных в форме

case class State(snake: List[(Double, Double)], food: (Double, Double)) 

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

def newState(dir: Int): State = ???

Внутри метода newState нам понадобится сделать следующее:
• Зная направление, обновить голову змеи.
• Обновить оставшуюся часть змеи, поставив последние n-1 квадратов на позициях первых n-1 квадратов.
• Проверяем, не выходим ли мы за рамки экрана ИЛИ не кусает ли змея себя за хвост; в любом из двух этих случаев сбрасываем состояние.
• Проверяем, может быть, змея просто ест; в таком случае заново генерируем координаты еды.
Рок-н-ролл. При обновлении змеиной головы нужно учитывать направление; будем считать направления 1, 2, 3, 4 как вверх, вниз, влево, вправо:

 val (x, y) = snake.head   val (newx, newy) = dir match {     case 1 => (x, y - 25) // вверх     case 2 => (x, y + 25) // вниз     case 3 => (x - 25, y) // влево     case 4 => (x + 25, y) // вправо     case _ => (x, y)   }

Если змея врежется в границу сцены, это значит newx < 0 || newx >= 600 || newy < 0 || newy >= 600 (с некоторыми дополнительными константами вместо 600, если вы не хотите ничего жестко программировать). Ситуация, в которой змея кусает себя за хвост, буквально означает, что в snake.tail содержится кортеж, равный только что созданному.

val newSnake: List[(Double, Double)] =         if (newx < 0 || newx >= 600 || newy < 0 || newy >= 600 || snake.tail.contains((newx, newy)))           initialSnake         else ???

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

// (плюс предыдущий фрагмент) else if (food == (newx, newy))   food :: snake else ???

В противном случае змея должна продолжать движение. Ее новая голова уже вычислена как (newx, newy), поэтому мы должны подтянуть остаток змеи:

// (плюс предыдущий фрагмент) else (newx, newy) :: snake.init

Используем snake.init как координаты первых n-1 элементов змеи. Когда первым блоком змеи идет новая голова, длина змеи остается такой же, как и ранее. В данном случае метод init действительно крут.

Чтобы вернуть новый экземпляр State, нам также нужно обновить координаты еды, если она только что была съедена. С учетом этого:

val newFood =         if (food == (newx, newy))           randomFood()         else           food

где randomFood – это метод для создания случайного квадрата где-нибудь в сцене:

  def randomFood(): (Double, Double) =     (Random.nextInt(24) * 25 , Random.nextInt(24) * 25)

Если вы хотите создать сцену другого размера, скажем, L x h, то делаем так:

def randomFood(): (Double, Double) =     (Random.nextInt(L / 25) * 25 , Random.nextInt(h / 25) * 25)

Вернемся к методу newState. Учитывая, что мы только что определили новую змею и новую порцию еды, все, что нам нужно – вернуть State(newSnake, newFood), приводящий главную функцию обновления состояния к виду:

def newState(dir: Int): State = {   val (x, y) = snake.head   val (newx, newy) = dir match {     case 1 => (x, y - 25)     case 2 => (x, y + 25)     case 3 => (x - 25, y)     case 4 => (x + 25, y)     case _ => (x, y)   }    val newSnake: List[(Double, Double)] =     if (newx < 0 || newx >= 600 || newy < 0 || newy >= 600 || snake.tail.contains((newx, newy)))       initialSnake     else if (food == (newx, newy))       food :: snake     else       (newx, newy) :: snake.init    val newFood =     if (food == (newx, newy))       randomFood()     else       food    State(newSnake, newFood) }

Что далее? Нам нужна возможность отобразить это состояние на экране, поэтому нам понадобится метод, который превратил бы Состояние в группу квадратов. Таким образом, добавим в State еще один метод, который превратит food в красный квадрат, а все элементы змеи – в зеленые квадраты:

// внутри класса State  def rectangles: List[Rectangle] = square(food._1, food._2, Red) :: snake.map {   case (x, y) => square(x,y, Green)

Добавляем логику змеи в ScalaFX

На этом работа над собственно игровой логикой завершена, и теперь нам нужна возможность где-нибудь использовать это состояние, выполнять игровой цикл или постоянно обновлять функцию, а также перерисовывать сущности в сцене. Для этого мы создадим 3 «свойства» ScalaFX, в сущности, являющиеся прославленными переменными со слушателями onChange:
• Свойство, описывающее актуальное состояние игры как экземпляр State.
• Свойство, отслеживающее актуальное направление, и это направление можно менять, нажимая клавиши.
• Свойство, в котором содержится актуальный кадр, обновляющийся каждые X миллисекунд.

В самом начале метода start() главного приложения добавим следующее:

    val state = ObjectProperty(State(initialSnake, randomFood()))     val frame = IntegerProperty(0)     val direction = IntegerProperty(4) // 4 = вправо

Известно, что при каждом изменении кадра нам потребуется обновить состояние, учитывая актуальное значение direction, поэтому сейчас давайте добавим

frame.onChange {   state.update(state.value.newState(direction.value)) }

Итак, состояние будет обновляться автоматически при каждом изменении кадра. Поэтому мы должны гарантировать, что будут выполняться три вещи:
• На экране будут отрисовываться квадраты, соответствующие актуальному состоянию.
• Направление движения будет меняться в зависимости от нажатия клавиш.
• Количество кадров будет изменяться/увеличиваться каждые X миллисекунд (чтобы игра шла гладко, выберите 80 или 100).

С пунктом 1 все просто. Нам нужно изменить после content в сцене, чтобы оно было равно

content = state.value.rectangles

Даже оставив приложение в имеющемся виде, можно при помощи этого кода проверять, есть ли у нас на экране змея и еда для нее:

image

Очевидно, ничего не меняется, так как кадр не изменился. Если изменится кадр, то изменится и состояние. Если состояние изменится, то изменится и содержимое экрана. Оставаясь внутри конструктора Scene, мы должны иметь возможность обновить его содержимое, когда состояние изменится:

// завершаем отрисовку поля на данном этапе scene = new Scene {   fill = White   content = state.value.rectangles   state.onChange {     content = state.value.rectangles   } }

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

stage = new JFXApp3.PrimaryStage {   width = 600   height = 600   scene = new Scene {     fill = White     content = state.value.rectangles     // сейчас добавлено     onKeyPressed = key => key.getText match {       case "w" => direction.value = 1       case "s" => direction.value = 2       case "a" => direction.value = 3       case "d" => direction.value = 4     }      state.onChange {       content = state.value.rectangles     }   } }

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

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

import scala.concurrent.ExecutionContext.Implicits.global  def gameLoop(update: () => Unit): Unit =     Future {         update()         Thread.sleep(80)     }.flatMap(_ => Future(gameLoop(update)))

Теперь, все, что нам требуется – инициировать этот игровой цикл функцией, меняющей кадр. Изменение кадра приводит к изменению состояния, а изменение состояния выводит на дисплей новую конфигурацию. Это уже, как минимум, тянет на идею. В самом низу метода start() нашего приложения добавим:

gameLoop(() => frame.update(frame.value + 1))

Запустив этот код, получим ошибку, так как здесь мы блокируем главный поток дисплея, когда обновляем content. Вместо этого нам придется запланировать такое обновление, заменив

state.onChange {   content = state.value.rectangles }

на

state.onChange(Platform.runLater {   content = state.value.rectangles })

что поставит обновление дисплея в очередь действий, которые, как предполагается, должен выполнить главный поток дисплея.

Заключение

Вот и все, ребята, – мы написали полнофункциональную игру «Змейка» на Scala с применением ScalaFX, и нам на это понадобилось всего несколько минут. Полный код игры приведен ниже.

import scalafx.application.{JFXApp3, Platform} import scalafx.beans.property.{IntegerProperty, ObjectProperty} import scalafx.scene.Scene import scalafx.scene.paint.Color import scalafx.scene.paint.Color._ import scalafx.scene.shape.Rectangle  import scala.concurrent.Future import scala.util.Random  object SnakeFx extends JFXApp3 {    val initialSnake: List[(Double, Double)] = List(     (250, 200),     (225, 200),     (200, 200)   )    import scala.concurrent.ExecutionContext.Implicits.global    def gameLoop(update: () => Unit): Unit =     Future {       update()       Thread.sleep(1000 / 25 * 2)     }.flatMap(_ => Future(gameLoop(update)))    case class State(snake: List[(Double, Double)], food: (Double, Double)) {     def newState(dir: Int): State = {       val (x, y) = snake.head       val (newx, newy) = dir match {         case 1 => (x, y - 25)         case 2 => (x, y + 25)         case 3 => (x - 25, y)         case 4 => (x + 25, y)         case _ => (x, y)       }        val newSnake: List[(Double, Double)] =         if (newx < 0 || newx >= 600 || newy < 0 || newy >= 600 || snake.tail.contains((newx, newy)))           initialSnake         else if (food == (newx, newy))           food :: snake         else           (newx, newy) :: snake.init        val newFood =         if (food == (newx, newy))           randomFood()         else           food        State(newSnake, newFood)     }      def rectangles: List[Rectangle] = square(food._1, food._2, Red) :: snake.map {       case (x, y) => square(x, y, Green)     }   }    def randomFood(): (Double, Double) =     (Random.nextInt(24) * 25, Random.nextInt(24) * 25)    def square(xr: Double, yr: Double, color: Color) = new Rectangle {     x = xr     y = yr     width = 25     height = 25     fill = color   }    override def start(): Unit = {     val state = ObjectProperty(State(initialSnake, randomFood()))     val frame = IntegerProperty(0)     val direction = IntegerProperty(4) // вправо       frame.onChange {       state.update(state.value.newState(direction.value))     }      stage = new JFXApp3.PrimaryStage {       width = 600       height = 600       scene = new Scene {         fill = White         content = state.value.rectangles         onKeyPressed = key => key.getText match {           case "w" => direction.value = 1           case "s" => direction.value = 2           case "a" => direction.value = 3           case "d" => direction.value = 4         }          state.onChange(Platform.runLater {           content = state.value.rectangles         })       }     }      gameLoop(() => frame.update(frame.value + 1))   }  }

P.S.
На сайте открыт предзаказ на книгу «Scala. Профессиональное программирование. 5-е изд.».
Также напоминаем, что идет осенняя распродажа, и книги по программированию (и не только) можно приобрести со скидкой до 50%.


ссылка на оригинал статьи https://habr.com/ru/company/piter/blog/695598/