[Перевод] Как написать игру «Змейка» на 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 = 600 || 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%.

© Habrahabr.ru