Добро пожаловать в мир разработки игр с нотками 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'

Спасибо за ваше время. Если вам понравился мой урок, пожалуйста, подпишитесь на мой канал, а также проверьте мои другие публикации.