Эта статья написана по приколу. В ней я за считанные минуты расскажу, как создать игру «Змейка» на 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 < 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
Даже оставив приложение в имеющемся виде, можно при помощи этого кода проверять, есть ли у нас на экране змея и еда для нее:
Очевидно, ничего не меняется, так как кадр не изменился. Если изменится кадр, то изменится и состояние. Если состояние изменится, то изменится и содержимое экрана. Оставаясь внутри конструктора 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/
Добавить комментарий