[Перевод] Как написать игру «Змейка» на Scala
Эта статья написана по приколу. В ней я за считанные минуты расскажу, как создать игру «Змейка» на 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
}
}
}
И у нас получается нечто волшебное:
Координаты начинаются из верхнего левого угла; координата 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
Даже оставив приложение в имеющемся виде, можно при помощи этого кода проверять, есть ли у нас на экране змея и еда для нее:
Очевидно, ничего не меняется, так как кадр не изменился. Если изменится кадр, то изменится и состояние. Если состояние изменится, то изменится и содержимое экрана. Оставаясь внутри конструктора 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%.