Это пошаговое руководство по моему коду для задания 0 для отличного курса CSCI E-23a, выпущенного Гарвардом весной 2018 года. Обширные конспекты лекций, созданные @AbhirathMahipa lможно найти здесь.

Предпосылки

  • Базовое программирование (переменные, функции и объекты)
  • Love2d установлен
  • Полезно, если вы смотрели лекции. Я не умею объяснять, поэтому и пишу это.

Я всегда любил видеоигры и недавно начал курс разработки игр CSCI E-23a, который доступен бесплатно онлайн. Я разработчик Javascript, и в этом курсе используется фреймворк Lua Love2d, поэтому мне было проще сосредоточиться на концепциях.

После завершения лекции я попытался воссоздать Pong, используя структуру Колтона Огдена (лектора), чтобы закрепить концепции. Большая часть кода похожа на то, что было создано на лекции, но некоторые вещи я организовал по-другому. Вы можете найти репо здесь. Структура проста:

/sounds
Ball.lua
class.lua — библиотека, используемая для создания классов в Lua
font.ttf
main.lua
Paddle.lua
push.lua — библиотека, используемая для упрощения рендеринга

Main.lua — точка входа

Love2d ищет файл с именем main.lua, когда вы запускаете игру. Здесь вы соедините всю свою игровую логику и переопределите методы игрового цикла Love2d. Первое, что нам нужно сделать, это импортировать библиотеки классов и push-уведомлений, а также объявить некоторые переменные. Нам также нужно ввести наши классы Paddle и Ball.

Используя ключевое слово require, мы можем позже ссылаться на push, Class, Paddle и Ball как на переменные. Lua ищет эти файлы в каталоге. Мы определяем виртуальную высоту и ширину вместе с фактической высотой и шириной, потому что мы хотим эмулировать более низкое разрешение. Они, наряду с PADDLE_SPEED, являются глобальными переменными, а scoreNeededToWin доступен только в main.lua. Остальная часть main.lua реализует игровой цикл love2d.

Что такое «игровой цикл»?
После того как игра загружена, необходимо снова и снова выполнять три шага: Сбор любого пользовательского ввода, >Ответить на ввод, обновив состояние игры, а затем рендеринг экрана на основе этого состояния. Love2d предоставляет интерфейс, реализуемый разработчиками, который бесконечно зацикливается при запуске программы. Базовая структура выглядит следующим образом:

love.load() вызывается один раз при запуске, а затем остальные функции вызываются в каждом цикле. Вы заметите, что love.update() принимает параметр с именем dt. Это дельта-время или время, прошедшее с начала последней итерации цикла. Это используется для масштабирования функций обновления, чтобы игра работала с одинаковой скоростью независимо от того, насколько мощный компьютер пользователя. Общее (и гораздо лучшее) объяснение игровых циклов упоминалось в лекции и может быть найдено здесь.

Во-первых, я переопределяю love.load() для инициализации игры и загрузки ресурсов (хелперы находятся в другом месте в реальном файле):

Порядок функций в загрузке может выполняться в любом порядке, и их назначение понятно из их названий. Есть пара любовных функций, используемых для загрузки шрифтов и звуков: love.graphics.newFont(filename) и love.audio.newSource(filename, type).

Здесь же создаются ракетки для игроков и мяч. Позже я подробно расскажу о каждом классе, а сейчас все, что вам нужно знать, это то, что оба конструктора принимают координаты x и y. Класс Paddle использует дополнительную таблицу конфигурации для определения клавиш управления и того, является ли ракетка «компьютерным» игроком или нет.

Наконец, здесь определены несколько глобальных переменных: «gameState» в load() и очки игроков в loadPlayers. Я не большой поклонник глобальных переменных, не говоря уже о том, чтобы определять их глубоко внутри вызовов функций. Было бы намного лучше, если бы ими управлял объект состояния, но я не слишком заморачивался по этому поводу, так как это небольшой проект с одним разработчиком.

После инициализации всех ресурсов и базового состояния игры следующим шагом будет реализация love.update(dt):

Функция handleKeyPress() проста, поскольку делегирует захват нажатий клавиш функции перемещения класса Paddle. Требуется виртуальная высота экрана, чтобы ограничить вертикальное движение лопастей относительно экрана и скорость лопасти. Я также передаю ссылку на экземпляр мяча как быстрое и грязное решение задания «Обновление ИИ». Это глупо, и я объясню это позже.

updatePlayers(dt) проверяет состояние игры, чтобы узнать, выиграл ли кто-либо из игроков, и сбрасывает очки, если кто-то выиграл. Затем он делегирует обновления весла update(dt) каждого экземпляра игрока. Я тоже к ним вернусь.

updateBalls(dt) — самое интересное. Я предполагаю, что здесь может быть какое-то смешивание проблем, но каждая из подфункций изменяет состояние мяча или реагирует на состояние мяча. По крайней мере, это была моя логика для их объединения следующим образом:

  • checkGameState() либо подает мяч, либо сбрасывает его в зависимости от определенных состояний. serveBall() устанавливает для ball.dx и ball.dy случайные числа, а для ball.isMoving — значение true. Вот пример того, как сделать тернарный оператор в Lua: math.random(-1, 1) ‹ 0) и -1 или 1 генерирует число от -1 до 1 и проверяет, является ли оно меньше 0. Если да, возвращается -1. В противном случае 1. Это случайным образом делает ball.dx отрицательным или положительным, чтобы заставить мяч двигаться влево или вправо соответственно. Мяч, кажется, чаще идет правильно, чем нет, поэтому я думаю, что это можно сделать лучше.
  • checkScreenEdgeCollision() проверяет, ударился ли мяч о края экрана. Если он попадает в верхнюю или нижнюю часть, воспроизводится звук и изменяется ball.dy. Если он попадает влево или вправо, счет игрока на противоположной стороне увеличивается, а gameState устанавливается для обозначения того, какой игрок забил. Затем измененное значение gameState подхватывается функцией checkGameState на следующей итерации, и мяч сбрасывается.
  • checkPaddleCollision() ищет столкновения весла. Он откладывает эту проверку на функцию collides(paddle) объекта мяча, которая хранится в двух глобальных переменных. Если происходит столкновение, он переворачивает и увеличивает ball.dx. Это также гарантирует, что позиция x мяча находится за пределами ракетки. См. changeBallHorizontalSpeed(). ball.dy устанавливается на случайную скорость, но продолжается в том же вертикальном направлении.

Все еще со мной? Я просто взял перерыв, чтобы приготовить немного еды и поиграть с нашим котом. Урок в том, что это не должен быть один пост. Я не обижусь, если вы не закончите это за один присест. Готовый? Хорошо, давайте поговорим о love.draw().

Вот суть:

Как вы видели в основном цикле игры, вся наша логика рендеринга находится между push:apply(‘start’) и push:apply(‘end’). Это дает push-уведомление о том, что мы готовы начать рисовать вещи. Я собираюсь пробежаться по функциям здесь, так как довольно очевидно, что происходит, основываясь на именах функций. Каждый экран рисуется на основе gameState, и у каждого есть своя функция. Игроки и мяч всегда нарисованы, независимо от состояния.

Несколько моментов об используемых любовных функциях:

  • love.graphics.clear()очищает экран от всего, что было нарисовано в последнем цикле
  • love.graphics.setFont()выбирает шрифт, ранее загруженный в love.load()
  • love.graphics.printf()выводит на экран в определенной позиции тот шрифт, который последний раз был передан в love.graphics.setFont()

Отрисовка мяча и игрока отложена до соответствующих функций render(). Вы также заметите некоторое смешение проблем, когда я вношу изменения в состояние в нескольких местах. Было бы лучше иметь их в методе update(), потому что это наиболее очевидное место для изменения состояния, но здесь я немного поленился.

И это main.lua в очень больших словах. Вкратце для великанов. Ball.lua и Paddle.lua будут проще.

Ball.lua
Это относительно просто. Класс Ball имеет следующие методы:

  • init() — это конструктор
  • collides() — Проверяет, не столкнулся ли мяч с приближающейся ракеткой. Чуть позже мы поговорим об обнаружении столкновений.
  • reset() — устанавливает текущие координаты мяча по осям x и y в начальную точку, указанную в init().
  • update() — помните, что это было вызвано в main.lua функцией love.update(). Если для ball.isMoving установлено значение true, x и y мяча обновляются с помощью ball.dx и ball.dy, оба масштабируются по dt (дельта-время).
  • render() — Аналогично, это было вызвано love.draw(). Он просто вызывает love.graphics.rectangle()

Вот суть:

Довольно прямолинейно. Пара ключевых моментов: «Ball = Class{}» использует библиотеку class.lua для создания класса, а init() конструирует каждый экземпляр.

Обход обнаружения столкновений
Я обещал вернуться к Ball:collides( весло ), и вот мы здесь. Collides использует простое обнаружение столкновений AABB или «ограничивающую рамку, выровненную по осям». Ну, что это?

Давайте сначала возьмем «Ограничивающие рамки». Обнаружение столкновений может быть интенсивным, если объекты имеют необычную форму или имеют много краев. Проверить, сталкиваются ли два прямоугольника, намного проще, поэтому разработчики будут определять прямоугольник вокруг сложных фигур. Возьмем, к примеру, этот самолет:

Все, что попадает в красную рамку, определяется как столкновение. Как видите, это компромисс между точностью и производительностью. Вот почему на правом изображении больше коробок. Используя эту модель, вам нужно будет выполнить 4 проверки по сравнению с одной, использующей модель слева.

Так что отлично, все окружено коробками. А как насчет части «Выровнено по оси»? Это намного проще. Love2d и другие графические движки используют системы координат, в данном случае с осями x и y. Гораздо проще исключить столкновение, если оба ящика выровнены по одной и той же оси.

И как нам это сделать? Я рад, что вы спросили. Необходимо задать четыре вопроса, и если вы ответите «да» на любой, значит, коллизии не произошло:

  1. Находится ли левый край прямоугольника 1 справа от правого края прямоугольника 2?
  2. Находится ли левый край прямоугольника 2 справа от правого края прямоугольника 1?
  3. Находится ли верхний край прямоугольника 1 ниже нижнего края прямоугольника 2?
  4. Находится ли верхний край прямоугольника 2 ниже нижнего края прямоугольника 1?

В противном случае произошло столкновение. Теперь вы понимаете, почему Pong — такая замечательная первая игра, которую нужно воссоздать. Все является коробкой и имеет не более одной ограничивающей рамки. Взгляните на функцию collides, и вы увидите, как эти вопросы переводятся в Lua.

Взгляните на объяснение MDN здесь, если вы запутались. Есть приятная игровая площадка, где вы можете перемещать прямоугольники и наблюдать, как что-то происходит. Объяснение, из которого я получил диаграмму самолета, также прекрасно, но имейте в виду, что исходная точка находится в нижнем левом углу, хотя концепция та же. Если вы хотите углубиться, ознакомьтесь с объяснением Гамасутры здесь. Теперь поговорим о веслах.

Paddle.lua
Вот суть:

Очень похоже на Ball без обнаружения столкновений, верно? Я не думаю, что после прочтения кода нужно упоминать что-либо, кроме Paddle:move().

Первое, на что должен обратить внимание Paddle:move(), — нажата ли клавиша. Помните, что клавиши движения передаются при создании ракетки, поэтому мы проверяем наличие этих клавиш и перемещаемся вверх или вниз в зависимости от того, какая из них нажата. Для этого мы используем love.keyboard.isDown(), которая возвращает логическое значение true, если клавиша нажата.

В рамках задания 0 простой ИИ должен следовать за мячом и пытаться отбить его. Я сделал это, проверив, является ли весло компьютером, а затем текущее положение мяча. Если мяч находится на определенную величину выше или ниже y ракетки, ракетка движется к нему.

Это глупо и не очень хорошо работает. Большинство реализаций, которые я видел, имитируют невидимый мяч, чтобы предсказать, куда он движется, и заставляют компьютерную ракетку перемещаться в это положение. Вы можете сделать это более или менее точным в зависимости от текущего счета или уровня сложности. Я планирую реализовать это, когда у меня будет больше времени.

Наконец, мы хотим, чтобы наши весла были ограничены нижним и верхним краями экрана, так что это последняя проверка и почему я передаю VIRTUAL_HEIGHT как screenHeight в main.lua.

Заключение
Я несколько раз пытался заняться разработкой игр с тех пор, как принес домой Doom 1 и Quake 1, и никогда не застревал на этом. Мне пока нравится этот курс, и я пытаюсь найти время, чтобы закончить его. Я закончил сеанс Flappy Bird и думаю, что сделаю этот пост больше как серию руководств, а не как одну длинную статью, бегло просматривающую мой код.

Пожалуйста, дайте мне знать, если у вас есть какие-либо вопросы или вы нашли что-то непонятное или неправильное. Я обновлю статью и постараюсь улучшить в следующих постах.