Как написать игру «Змейка» на 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 < 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%.
На графике%2c Полный набор решений задачи X в квадрате плюс Y в квадрате равно постоянному значению — ответы на кроссворды
Подсказка кроссворда На графике полный набор решений x в квадрате плюс у в квадрате равно постоянному значению с 6 буквами, последний раз встречался 03 сентября 2017 г. . Мы думаем, что наиболее вероятным ответом на эту подсказку будет CIRCLE . Ниже приведены все возможные ответы на эту подсказку, упорядоченные по рангу. Вы можете легко улучшить поиск, указав количество букв в ответе.
Ранг | Слово | Подсказка |
---|---|---|
93% | КРУГ | На графике полный набор решений x в квадрате плюс y в квадрате равен постоянной величине. |
62% | ДЕВЯТЬ | Три в квадрате |
53% | ОБЛАСТЬ | Pi r в квадрате, для круга |
51% | SACEAREA | Четыре пи в квадрате для сферы |
51% | ДАЖЕ | в квадрате |
51% | РАСЧЕТ | в квадрате |
51% | ЭВЕНЕДУП | в квадрате |
51% | СЕКУНД СИНАНЧАС | в квадрате |
51% | ЦЕЗАРСКCLXXXIX | в квадрате |
51% | САМЫЙ ВЫСОКИЙ РЕЗУЛЬТАТ | в квадрате |
51% | ВЕЧНЫЙ | в квадрате |
49% | ПАРАБОЛА | Кривая, такая как y = x в квадрате |
47% | ТЭН | Значение римской цифры X |
47% | ОСЬ | X или Y на графике |
47% | ОСИ | x и y на графике |
45% | СТО | X в квадрате в делении |
43% | СТО | Десять в квадрате |
43% | МММДК | LX в квадрате |
43% | ОДИН | я возвел в квадрат, затем снова возвел в квадрат |
43% | АШЛАР | Квадратный камень |
Уточните результаты поиска, указав количество букв. Если какие-то буквы уже известны, вы можете предоставить их в виде шаблона: «CA????».
- Мессия христианства, в Италии Кроссворд
- Кроссворд с общими знаниями Может найти в Интернете интересные факты об обычных вещах Кроссворд
- Космическая инженерная дисциплина, неформально кроссворд
- «Введение в исчисление» или «Искусство публичного выступления»? Кроссворд
- Нефтехранилище в Северном море занято Гринпис в 1995, Кампания против намерения Shell потопить его в море Кроссворд
- Кроссворд La Forge из «Звездного пути: Следующее поколение»
- Любая из ежегодных наград, присуждаемых Американским театральным крылом (Нью-Йорк) Кроссворд
- Кто-то вроде Казановы, Байрона или Фрэнка Харриса Кроссворд
- Сайты сортировки, для краткого кроссворда
- Причина отзыва продукта, возможно, ключ к кроссворду
- Подавляет, как подсказка кроссворда плохих новостей
- Мраморная столешница или разделочный блок? Кроссворд
- С 66 поперек, Пол Анка Хит, чье название переводится как «Этот поцелуй», кроссворд
- «Это идеально, верно?» Кроссворд
- Последняя часть, возможно, разгадка кроссворда
- Старейшина: разгадка кроссворда римского историка
- Побежденный, Как подсказка кроссворда дракона
- Статуя Эмми или Кубок Стэнли? Кроссворд
- Обувь Christian Louboutin или сумка Fendi? Кроссворд
- Ариана Гранде «Спасибо,» Кроссворд
- Свадьба или слияние? Кроссворд
- Соус из авокадо, для краткого кроссворда
- Учтена сумка, скажи кроссворд
- «Что за?»: Сленг «Что дает?» Кроссворд
- Сфинкс, Частично Кроссворд
- Рихтер или Моос? Кроссворд
- «Пожалуйста, позволь мне?» Кроссворд
- Джордан, сценарист/режиссер, Кроссворд «Прочь»
- Totally Rocked, как разгадка тестового кроссворда
- Разгадка кроссворда умбры, охры или охры
- Доставка Usps, Упреждающий кроссворд
- Tax Whiz, для краткого кроссворда
- Многие аспиранты, для краткого кроссворда
- Кроссворд местных лидеров
- Самая маленькая монета? Кроссворд
- Полоса взлета? Кроссворд
- Описание Trompe L’oeil и Op Art Crossword Clue
- Альтернативы дровяным горелкам, не нуждающиеся в дымоходах.
- Джеймс IV был последним британским монархом, погибшим в бою на поле боя в 1513 году.
- Неофициально, британская территория с овцой на флаге Кроссворд
- Неофициально утверждение, что ситуация является просто подсказкой кроссворда
- «Полночь! Форпост наступающего дня! / Пограничный город и ночь!» (Лонгфелло, Две реки) Кроссворд
- Французский философ, которого считают отцом социологии.
- Gibson Flying V или Fender Stratocaster? Кроссворд
- И, например, в веб-кодировании
Htmltags <Тр>Нет Нет <Тр>Crosswordgiant.Com/Crossword Clue/3804329/Очаровательная мелодия»>Очаровательная мелодия Песня сирены <Тр>Насекомые на пасеке Пчелы <Тр>Apt Anagram Of «Aye» Да <Тр>Охлаждение Onice <Тр>Crosswordgiant.Com/Crossword Clue/611666/Brain Scan Briefly»>Brain Scan Briefly ЭЭГ <Тр>Короткое совещание? Сеш <Тр>Определенный поставщик услуг мобильной связи? Чехол для iPhone <Тр>Cheer In Mexico Com/Crossword Answer/211/Ole»>Оле <Тр>Команда для щенка Сидеть <Тр>Полный спектр Гамма <Тр>Страна, граничащая с Чадом Нигер <Тр>Скрытые оперативники Crosswordgiant.Com/Crossword Answer/23953/Шпионы»>Шпионы <Тр>Штат Дейтона Огайо <Тр>Гольдман, активист начала 20 века Эмма <Тр>Электрический блок с бесшумной буквой H Ом <Тр>Вера, запрещающая сплетни Crosswordgiant.Com/Crossword Answer/36000/Bahai»>Бахай <Тр>Огненный порошок Ясень <Тр>Жидкость, наполненная цветами, используемая в рахат-лукумах Розовая вода <Тр>Game Pieces In Azul Or Scrabble Плитки <Тр>Crosswordgiant.Com/Crossword Clue/48985/Сверкающий камень»>Сверкающий камень Geode <Тр>Племя Великих равнин Otoe <Тр>Компонент костюма на Хэллоуин Маска <Тр>He/ Pronouns Они <Тр>Crosswordgiant.Com/Crossword Clue/3400340/He Pronouns»>He/ Pronouns Его <Тр>Устройство подключения к Интернету Модем <Тр>Дневная организация Джеки Робинсона Mlb <Тр>Последняя греческая буква Омега <Тр>Crosswordgiant.Com/Crossword Clue/3804354/Список Lax Landing»>Список Lax Landing эта <Тр>Как мокрая подпись Inink Таблица><Раздел> <Стиль> .Кроссворд Гигантская головоломка Средний Отзывчивый { Ширина: 300 пикселей; Высота: 250 пикселей; } @Media(Min Width: 768px) { .Crossword Giant Puzzle Middle Responsive { Ширина: 728px; Высота: 90 пикселей; } } Стиль>