Добро пожаловать в мир разработки игр с нотками Ruby! В этом уроке по программированию мы собираемся отправиться в увлекательное путешествие по созданию классической аркадной игры Breakout с использованием динамичного и выразительного языка программирования Ruby.
Breakout с ее ностальгическим шармом и захватывающим геймплеем на протяжении десятилетий был основным продуктом игрового мира. Разработанный в 1976 году никем иным, как Стивом Возняком, известным соучредителем Apple Inc., быстро стал одним из определений эпохи индустрии аркадных игр конца 70-х годов. Теперь у вас есть возможность не только играть в игру, но и построить ее с нуля.
Ruby, известный своим элегантным синтаксисом и гибкостью, возможно, не первый язык, который приходит на ум при разработке игр, но вы вот-вот обнаружите его скрытый потенциал. Мы воспользуемся преимуществами активного сообщества Ruby и библиотек, которые сделают создание игр одновременно образовательным и приятным.
В этом уроке я буду использовать Ruby2D. Простая в освоении, но очень эффективная библиотека, созданная для этой цели. Если вы с ним не знакомы, предлагаю вам просмотреть документацию или прочитать мою первую часть этой серии, где я подробно объясняю это. Однако не обязательно понимать весь процесс, и вы также можете начать прямо с этого момента.
Чтобы просмотреть полный исходный код, нажмите здесь
Давайте начнем:
Сначала нам нужно создать отдельный каталог для нашего проекта. С Gemfile и файлом Breakout.rb для хранения логики. Перейдите к своему терминалу и введите следующее:
mkdir breakout cd breakout touch Gemfile breakout.rb
Теперь вы можете открыть вновь созданный каталог в своей IDE. Gemfile — это специальная структура Ruby для отслеживания всех необходимых сторонних библиотек (гемов) и выбора их источника. В этом случае нам нужен всего один драгоценный камень, чтобы все заработало. Откройте Gemile и введите:
source 'https://rubygems.org' gem 'ruby2d'
После ввода bundle install в терминале вы должны получить информацию об успешно полученном драгоценном камне. И автоматически сгенерированный Gemfile.lock должен появиться в вашем рабочем каталоге. Теперь перейдите к файлу Breakout.rb и настройте основу нашей игры:
require 'ruby2d' WIDTH = 480 HEIGHT = 600 set width: WIDTH set height: HEIGHT set title: 'breakout' show
Константы WIDTH и HEIGHT будут определять размеры экрана, предоставляемые методом set. В то время как showmethod сообщает, что на самом деле мы хотим, чтобы новое окно появлялось при запуске программы. Сохраните эти изменения и введите Ruby Breakout.rb в терминале. Вывод должен быть таким же, как показано ниже:
У нас есть главное окно. Пришло время сосредоточиться на кодировании реальной логики. Для работы классической игры на прорыв нужны три элемента: контроллер весла, который игрок получает, прыгающий мяч и набор кирпичей, которые нужно разбить.
Начнем с весла. Это будет просто отдельный класс. Ему нужна возможность рисоваться на экране, перемещаться по горизонтали, сохранять текущие координаты и обнаруживать столкновения.
class Paddle def initialize @x = WIDTH / 2 @y = HEIGHT - 10 end def draw @shape = Rectangle.new(x: @x, y: @y, width: 40, height: 10, color: 'blue') end end player = Paddle.new player.draw
Класс выше содержит достаточно информации, чтобы нарисовать синий прямоугольный объект, похожий на весло, в середине экрана с помощью объекта player и его метода draw.
Перемещение в играх — это по сути всего лишь процесс перерисовки объектов с немного другими координатами. На нашем экране весло (и каждый будущий объект) описывается координатами x,y. Мы можем «перемещать» весло по горизонтали, просто увеличивая или уменьшая координату x:
class Paddle def initialize @x = WIDTH / 2 @y = HEIGHT - 10 end def draw @shape = Rectangle.new(x: @x, y: @y, width: 40, height: 10, color: 'blue') end def move_left @x -= 9 unless @x - 9 <= 0 end def move_right @x += 9 unless @x + 49 >= WIDTH end end
Условия «если» не установлены на предотвращение перемещения объекта весла за пределы экрана. Теперь, чтобы обработать ввод с клавиатуры от игрока, чтобы вызвать эти методы и переместить весло в желаемом направлении:
on :key_held do |event| if event.key == 'left' player.move_left elsif event.key == 'right' player.move_right end end
Однако после перезапуска все остается статичным. Даже если значение x меняется, программа сохраняет тот же первоначальный вид. Здесь нам нужно начать наш игровой цикл . Каждаяинструкцияв этом цикле будет повторяться 60 раз в секунду (FPS). Наш метод player.draw перерисует прямоугольный объект достаточно быстро, чтобы обмануть человеческий глаз и создать ощущение движения. Давайте запишем это:
update do # repeats 60 times per second during entire program execution clear # clear entire screen before new frame is drawn player.draw # draw paddle with updated coordinates end
Если мы перезапустим игру, весло должно реагировать на действия игрока и двигаться влево и вправо. Ура!
Теперь давайте посмотрим на мяч. Аналогичным образом нам нужно создать класс Ball с переменными для координат x,y, возможностью рисования и перемещения (изменения координат). Кроме того, нам нужно настроить направления, в которых этот шар будет двигаться, и логику столкновения с другими объектами и стенами:
class Ball attr_reader :x, :y attr_accessor :x_velocity, :y_velocity def initialize @x = WIDTH / 2 @y = HEIGHT - HEIGHT / 2.5 @x_velocity = 0 @y_velocity = 3 end def draw Circle.new(x: @x, y: @y, radius: 5, color: 'white') end def move @x += @x_velocity @y += @y_velocity end end
x_velocity и y_velocity — переменные, предназначенные для хранения текущей скорости мяча в определенном направлении. В этом случае наш шар будет увеличивать значение y на 3 в каждом кадре, а значение x на 0. Просто падает вниз. Чтобы привести его в действие, нам нужно создать экземпляр класса Ball и указать программе, чтобы она рисовала и перемещала его внутри нашего игрового цикла:
ball = Ball.new update do clear player.draw ball.draw ball.move end
Чтобы обнаружить столкновение между объектами, нам нужно написать методы, проверяющие, имеют ли они одинаковые координаты x,y. Ruby2D предоставляет нам очень удобный метод contains для этого. Давайте вернемся к классу Paddle и обновим его дополнительным методом hit_ball?:
def hit_ball?(x,y) @shape.contains?(x,y) end
и обновите игровой цикл новыми условиями:
if player.hit_ball?(ball.x, ball.y) ball.y_velocity *= -1 ball.x_velocity *= -1 end
Теперь мяч будет взаимодействовать с ракеткой и отскакивать назад при ударе. Однако он по-прежнему действует неуклюже. Чтобы это исправить, давайте сначала обновим наш класс Ball и напишем логику для обнаружения столкновений со стенами:
class Ball attr_reader :x, :y attr_accessor :x_velocity, :y_velocity def initialize @x = WIDTH / 2 @y = HEIGHT - HEIGHT / 2.5 @x_velocity = 0 @y_velocity = 3 end def draw Circle.new(x: @x, y: @y, radius: 5, color: 'white') end def move @x += @x_velocity @y += @y_velocity @x_velocity *= -1 if hit_wall? if hit_top? @x_velocity *= -1 @y_velocity *= -1 end end private def hit_wall? @x >= WIDTH || @x <= 0 end def hit_top? @y <= 0 end end
Новые частные методы hit_wall? и hit_top? несут ответственность за проверку совпадения текущих координат мяча с краями экрана. Метод move был обновлен для обработки таких случаев: если шар ударяется о стену, он должен развернуться и двигаться по диагонали (меняя направления x и y). Аналогичным образом, попадание в вершину вызывает отскок назад по тому же вектору (изменяется только y). хит_дно? Публичный метод работает точно так же, но мы будем использовать его в будущем для обработки жизней игроков.
И последнее, что нужно сделать, это сделать удары веслом более естественными. Мяч должен попасть точно в то место ракетки, в которое попал. И отскочить назад под разными углами. Чтобы добиться этого, мы должны использовать простой трюк и измерить расстояние между серединой ракетки и точной точкой, где мяч столкнулся с ней. Чем дальше от середины, тем острее угол. Давайте добавим новый метод в наш класс Paddle:
def mid_position @x + 20 end
При общей ширине 40 пикселей этот метод всегда должен указывать на середину лопасти. И обновите поведение нажатия ракетки в игровом цикле:
if player.hit_ball?(ball.x, ball.y) ball.y_velocity *= -1 ball.x_velocity = (ball.x - player.mid_position) * 0.15 end
Теперь мяч должен реагировать гораздо естественнее!
Очередной кирпич в стене
Основная цель в прорыве - раздавить всю стену из кирпичей с помощью мяча и весла. В этом случае каждый кирпичик будет экземпляром самого базового класса Brick. Содержит только метод рисования, координаты и цвет.
class Brick attr_reader :x, :y, :shape def initialize(x, y, color) @x = x @y = y @color = color end def draw @shape = Rectangle.new(x: @x, y: @y, width: 47 , height: 19, color: @color) end end
Все кирпичные объекты будут храниться в массиве. Как и в оригинальной игре, здесь будет 8 рядов по 4 цвета: красный, оранжевый, желтый и зеленый. В каждом ряду будет 10 кирпичей. Давайте добавим следующую процедуру прямо перед игровым циклом:
bricks = [] 8.times do |i| if (0..1).include? i color = 'yellow' elsif (2..3).include? i color = 'green' elsif (4..5).include? i color = 'orange' elsif (6..7).include? i color = 'red' end (0..WIDTH).step(48).each do |x| brick = Brick.new(x, HEIGHT/2.5 - i * 20, color) bricks << brick end end
и внутри игрового цикла, чтобы перебрать каждый объект в массиве и вызвать для него метод draw:
bricks.each(&:draw)
На этом этапе наша игра должна быть менее похожа на оригинальный прорыв. Поздравляем!
Логика разбивания кирпичей очень похожа на взаимодействие мяча с ракеткой: если в любом кадре мяч имеет общую точку (x,y) с кирпичом, он разбивается. Поскольку любой кирпичик можно разбить в случайном порядке, нам нужно перебрать весь массив кирпичей и отслеживать потенциальные взаимодействия с мячом. Получение результата приводит к тому, что кирпичный объект исключается из массива (исчезает с экрана), и игрок получает счет. Сначала давайте добавим новую переменную вне игрового цикла:
score = 0
и в игровом цикле замените недавно добавленную логику рендеринга кубиков (bricks.each(&:draw) на что-то более комплексное:
bricks.each_with_index do |brick, index| brick.draw if brick.shape.contains?(ball.x, ball.y) bricks = bricks.reject.with_index{|_, i| i == index } score += 10 ball.y_velocity *= -1 end end
Объект массива кирпичей повторяется по индексу. Если какой-либо из кубиков имеет общую точку с мячом, он исключается из массива (с использованием индекса), счет увеличивается на 10, а мяч отскакивает назад. С помощью этой функции мы наконец можем добавить счет в верхней части экрана:
def draw_score(score) Text.new(score, x: 30, y: 30, size: 30, color: 'white') end
и в нашем игровом цикле в начале:
draw_score(score)
Это конец
Наша работа почти закончена. Однако отсутствует только последняя важная часть: если игрок не отбил мяч вовремя, он должен вернуться в исходное положение с потерей жизни. После нескольких поражений игра окончена. Или если все кирпичи разбились и игрок получил информацию о своей победе. Другими словами: нам нужно установить условия выигрыша и проигрыша. Для начала добавим переменную, хранящую информацию о текущем количестве жизней:
lifes = 4
Теперь в самом низу нашего игрового цикла нам нужно добавить еще одно условие, проверяющее, пропустил ли игрок мяч на бегу:
if ball.hit_bottom? ball.reset lifes -= 1 end
Чтобы добавить метод сброса в класс Ball. То есть просто переместите содержимое конструктора в новый метод. Сделайте его доступным вне класса и вызовите из конструктора:
class Ball attr_reader :x, :y attr_accessor :x_velocity, :y_velocity def initialize reset end def reset @x = WIDTH / 2 @y = HEIGHT - HEIGHT / 2.5 @x_velocity = 0 @y_velocity = 3 end ... end
Если количество жизней уменьшилось до нуля, игра должна быть окончательно остановлена и игрок должен получить четкую информацию о потере. С другой стороны, чтобы выяснить, выиграна ли игра, нам нужно отслеживать размер массива кубиков. Когда останется ноль кирпичей, мы можем отобразить текст о победе. В игровом цикле:
if lifes == 0 Text.new('Game Over', x: WIDTH/2 - 160, y: HEIGHT/2, size: 60, color: 'red') next elsif bricks.size == 0 Text.new('You won!', x: WIDTH/2 - 130, y: HEIGHT/4, size: 60, color: 'green') next end
Next является эквивалентом continue в таких языках, как JS или Python. Он не разрывает цикл, а скорее игнорирует остальную оставшуюся логику и возвращается к началу. В нашем случае это фактически останавливает игру навсегда. Имейте в виду, что приведенное выше условие должно быть помещено перед методом ball.move, чтобы остановить выполнение в нужном месте.
Когда игра окончена, игрок не должен иметь возможности перемещать весло. Чтобы предотвратить это, мы можем применить другое условие в логической обработке ввода с клавиатуры:
on :key_held do |event| if lifes > 0 || bricks.size == 0 if event.key == 'left' player.move_left elsif event.key == 'right' player.move_right end end end
Вот и все! Полностью играбельный клон Breakout уже здесь. Если вы хотите еще раз проверить весь код, вот он:
require 'ruby2d' WIDTH = 480 HEIGHT = 600 set width: WIDTH set height: HEIGHT set title: 'breakout' class Brick attr_reader :x, :y, :shape def initialize(x, y, color) @x = x @y = y @color = color end def draw @shape = Rectangle.new(x: @x, y: @y, width: 47 , height: 19, color: @color) end end class Ball attr_accessor :x_velocity, :y_velocity attr_reader :x, :y def initialize reset end def draw Circle.new(x: @x, y: @y, radius: 5, color: 'white') end def move @x += @x_velocity @y += @y_velocity @x_velocity *= -1 if hit_wall? if hit_top? @x_velocity *= -1 @y_velocity *= -1 end end def reset @x = WIDTH / 2 @y = HEIGHT - HEIGHT / 2.5 @x_velocity = 0 @y_velocity = 3 end def hit_bottom? @y >= HEIGHT end private def hit_wall? @x >= WIDTH || @x <= 0 end def hit_top? @y <= 0 end end class Paddle def initialize @x = WIDTH / 2 @y = HEIGHT - 10 end def move_left @x -= 9 unless @x - 9 <= 0 end def move_right @x += 9 unless @x + 49 >= WIDTH end def draw @shape = Rectangle.new(x: @x, y: @y, width: 40, height: 10, color: 'blue') end def mid_position @x + 20 end def hit_ball?(x,y) @shape.contains?(x,y) end end player = Paddle.new ball = Ball.new score = 0 lifes = 4 bricks = [] def draw_score(score) Text.new(score, x: 30, y: 30, size: 30, color: 'white') end 8.times do |i| if (0..1).include? i color = 'yellow' elsif (2..3).include? i color = 'green' elsif (4..5).include? i color = 'orange' elsif (6..7).include? i color = 'red' end (0..WIDTH).step(48).each do |x| brick = Brick.new(x, HEIGHT/2.5 - i * 20, color) bricks << brick end end update do clear ball.draw player.draw draw_score(score) bricks.each_with_index do |brick, index| brick.draw if brick.shape.contains?(ball.x, ball.y) bricks = bricks.reject.with_index{|_, i| i == index } score += 10 ball.y_velocity *= -1 end end if lifes == 0 Text.new('Game Over', x: WIDTH/2 - 160, y: HEIGHT/2, size: 60, color: 'red') next elsif bricks.size == 0 Text.new('You won!', x: WIDTH/2 - 130, y: HEIGHT/4, size: 60, color: 'green') next end ball.move if player.hit_ball?(ball.x, ball.y) ball.y_velocity *= -1 ball.x_velocity = (ball.x - player.mid_position) * 0.15 end if ball.hit_bottom? ball.reset lifes -= 1 end end on :key_held do |event| if lifes > 0 || bricks.size == 0 if event.key == 'left' player.move_left elsif event.key == 'right' player.move_right end end end show
Хотя технически наша работа завершена. Есть еще несколько вещей, которые нужно улучшить. Файл Breakout.rb стал слишком длинным и запутанным. Для рефакторинга мы можем создать дополнительный каталог ./lib и переместить сюда все классы. Каждый класс со своим файлом. В этом сценарии наш основной файл будет содержать в основном игровой цикл и логику рисования, импортируя логику мяча, весла и кирпича следующим образом:
require './lib/paddle' require './lib/ball' require './lib/brick'
Спасибо за ваше время. Если вам понравился мой урок, пожалуйста, подпишитесь на мой канал, а также проверьте мои другие публикации.