Сегодня мы расскажем о проекте, который несколько любящих Rubyists написали для RailsCamp в прошлом месяце. Пол Аннесли ( твиттер ) и Деннис Хотсон ( твиттер ) объединили свои усилия в RailsCamp Australia, и в результате получился фейерверк на основе ASCII.
Во-первых, если вы не знакомы с RailsCamps, вот реклама с их сайта:
Представьте себя и группу единомышленников-рубиновых хакеров в загородном уединении с нулевым интернетом для веселых выходных. Вы будете смеяться, взламывать, учиться, плакать (ну, вы, вероятно, не будете плакать … но вы знаете … это было поэтично), и, скорее всего, вы играете в Guitar Hero.
Смысл RailsCamp, насколько я могу судить, состоит в том, чтобы отключиться от всего, кроме Ruby и других участников. Это фестиваль программирования, где они поощряют хакерство и креативность — две вещи, которые в изобилии живут в сообществе Ruby. Все, кого я знаю, кто присутствовал на RailsCamp, вернулись и сказали, что это был шанс изменить их карьеру. Мне нужно получить один запланированный около Шарлотты.
Проэкт
Проект, на котором остановились Денис и Пол, назывался ROFLBALT. Это рубиновый порт несколько известной игры Canabalt , в которую можно играть онлайн или загрузить на мобильное устройство. Цель Canabalt (и всех игр -бальтового типа) — бегать и прыгать от здания к зданию, как можно дольше. Ваша оценка прямо пропорциональна тому, сколько времени вы пройдете, прежде чем умрете. Это просто кусочек мозгового леденца в простой игровой форме, тот тип конфет, который мой мозг любит больше всего.
Пол и Деннис стремились завершить ROFLBALT, используя менее 500 строк Ruby, и они выполнили его. Посмотрим как.
Как они это сделали
Я собираюсь попытаться разбить код для ROFLBALT. Теперь я предупреждаю вас, что Пол и Деннис, на первый взгляд и неудивительно, намного умнее меня. В некоторых частях этого кода я понятия не имел, как они 1) придумали, что они сделали, или 2) что, черт возьми, делает код. Это ни в коем случае не отражение в коде или проекте, а скорее в отношении автора, который является «особенным».
Вы можете найти код для проекта на github .
ROFLBALT разбит на следующие классы.
- Игра
- экран
- пиксель
- Фон
- WindowColor
- Кадровый буфер
- Мир
- BuildingGenerator
- (Модуль) визуализируемых
- Строительство
- игрок
- Кровь
- Табло
- GameOverBanner
- RoflCoptor
Исполняемый файл (в Game.new.run
bin) просто запускает Game.new.run
, поэтому мы начнем с него.
Игра
SCREEN_WIDTH = 120 | |
SCREEN_HEIGHT = 40 | |
class Game | |
def initialize | |
reset | |
end | |
def reset | |
@run = true | |
@world = World.new(SCREEN_WIDTH) | |
@screen = Screen.new(SCREEN_WIDTH, SCREEN_HEIGHT, @world) | |
end | |
def run | |
Signal.trap(:INT) do | |
@run = false | |
end | |
while @run | |
start_time = Time.new.to_f | |
unless @world.tick | |
reset | |
end | |
render start_time | |
end | |
on_exit | |
end | |
def render start_time | |
@world.buildings.each do |building| | |
@screen.draw(building) | |
end | |
@screen.draw(@world.player) | |
@world.misc.each do |object| | |
@screen.draw(object) | |
end | |
@screen.render start_time | |
end | |
def on_exit | |
@screen.on_exit | |
end | |
end |
Игра, как вы уже догадались, расставляет все биты на месте, чтобы начать игру, а также управляет выходом из игры. Это зависит от мира и экрана. В методах Game все происходит на высоком уровне, так как мы имеем дело с объектом World и объектом Screen, которые являются абстракциями, созданными этим проектом. Игра начинается с веселья, в основном, в методе render
. Он рисует здания, игрока и объекты остального мира. Обратите внимание, что «рисование» объекта означает передачу его методу draw
на экране, что мы рассмотрим, когда попадем на экран. Кроме того, игра прослушивает прерывание сигнала (в данном случае Ctrl-C) и убивает игру, когда это происходит.
Мир
class World | |
def initialize horizon | |
@ticks = 0 | |
@horizon = horizon | |
@building_generator = BuildingGenerator.new(self, WindowColor.new) | |
@background = Background.new(self) | |
@player = Player.new(25, @background) | |
@buildings = [ @building_generator.build(-10, 30, 120) ] | |
@misc = [ Scoreboard.new(self), RoflCopter.new(50, 4, @background) ] | |
@speed = 4 | |
@distance = 0 | |
end | |
attr_reader :buildings, :player, :horizon, :speed, :misc, :ticks, :distance, :background | |
def tick | |
# TODO: this, but less often. | |
if @ticks % 20 == 0 | |
@building_generator.generate_if_necessary | |
@building_generator.destroy_if_necessary | |
end | |
@distance += speed | |
buildings.each do |b| | |
b.move_left speed | |
end | |
if b = building_under_player | |
if player.bottom_y > b.y | |
b.move_left(-speed) | |
@speed = 0 | |
@misc << Blood.new(player.x, player.y) | |
@misc << GameOverBanner.new | |
player.die! | |
end | |
end | |
begin | |
if STDIN.read_nonblock(1) | |
if player.dead? | |
return false | |
else | |
player.jump | |
end | |
end | |
rescue Errno::EAGAIN | |
end | |
player.tick | |
if b = building_under_player | |
player.walk_on_building b if player.bottom_y >= b.y | |
end | |
@ticks += 1 | |
end | |
def building_under_player | |
buildings.detect do |b| | |
b.x <= player.x && b.right_x >= player.right_x | |
end | |
end | |
end |
Мир содержит все элементы рендеринга, которые может содержать игра. Здания, фон, табло и игрок. у него также есть пара невидимых элементов, таких как Генератор зданий и горизонт (ширина экрана). В мире много зависимостей:
- BuildingGenerator
- Фон
- игрок
- Строительство
- Табло
Мир также контролирует скорость игры и пройденное расстояние, как рассчитывается выигрыш. Метод галочки — это каждое «движение» мира. Когда игрок движется вправо (или, более точно, здания двигаются влево), метод тиков проверяет, чтобы убедиться, что здание все еще находится под игроком, в противном случае пришло время RENDER BLOOD и вызвать PLAYER.DIE (Извините, мне кажется, что я следует использовать заглавные буквы) Если вы не мертвы, это добавляет один к галочкам
экран
class Screen | |
OFFSET = —20 | |
def initialize width, height, world | |
@width = width | |
@height = height | |
@world = world | |
@background = world.background | |
create_frame_buffer | |
%x{stty -icanon -echo} | |
print «\033[0m» # reset | |
print «\033[2J» # clear screen | |
print «\x1B[?25l» # disable cursor | |
end | |
attr_reader :width, :height, :world | |
def create_frame_buffer | |
@fb = Framebuffer.new @background | |
end | |
def draw renderable | |
renderable.each_pixel(world.ticks) do |x, y, pixel| | |
@fb.set x, y, pixel | |
end | |
end | |
def render start_time | |
print «\e[H» | |
buffer = » | |
previous_pixel = nil | |
(0…height).each do |y| | |
(OFFSET…(width + OFFSET)).each do |x| | |
pixel = @fb.get(x, y) | |
if Pixel === previous_pixel && Pixel === pixel && pixel.color_equal?(previous_pixel) | |
buffer << pixel.char | |
else | |
buffer << pixel.to_s | |
end | |
previous_pixel = pixel | |
end | |
buffer << «\n« | |
end | |
print «\033[0m» | |
dt = Time.new.to_f — start_time; | |
target_time = 0.04 | |
sleep target_time — dt if dt < target_time | |
print buffer | |
create_frame_buffer | |
end | |
def on_exit | |
print «\033[0m» # reset colours | |
print «\x1B[?25h» # re-enable cursor | |
print «\n« | |
end | |
end |
Экран инициализируется шириной и высотой нашей игровой «канвы», наряду с игровым миром. Если вы посмотрите на метод initialize, он выполнит некоторую stty
магию, чтобы очистить экран и отключить курсор. Именно такие вещи заставляют меня любить читать чужой код. Я всегда вижу и изучаю вещи, которые мне не хватало бы в моей повседневной рутине программирования. Экран отвечает за рендеринг начальной игры «canvas» и рисование Renderables на холсте. Renderable — это классы, включающие модуль Renderable, к которому мы скоро перейдем.
пиксель
class Pixel | |
def initialize char = » «, fg = nil, bg = nil | |
@char = char | |
@fg, @bg = fg, bg | |
end | |
attr_reader :char | |
def fg; @fg || 255 end | |
def bg; @bg || 0 end | |
def to_s | |
«\033[48;5;%dm\033[38;5;%dm%s» % [ bg, fg, @char ] | |
end | |
def color_equal? other | |
fg == other.fg && bg == other.bg | |
end | |
end |
Класс Pixel представляет игровой пиксель, который является символом ASCII в ROLFBALT. Экземпляр Pixel состоит из цвета переднего плана, цвета фона и символа. Классная вещь, которую я узнал из класса Pixel, — как связываться с моими терминальными цветами. Метод to_s
показывает это, используя метод интерполяции необычной строки (http://apidock.com/ruby/String/%25). Я переключал свой терминал с черного на белое на белое на черном и хихикал, когда моя жена проходила мимо и спрашивала, о чем я хихикаю. Все, что я мог сказать, это «умник» и понять, что подобные ситуации отделяют нас от «нормалей». В любом случае, я отвлекся… назад к коду…
Фон
class Background | |
PALETTE = [ 16, 232, 233 ] | |
PERIOD = 16.0 | |
SPEED = 0.5 | |
BLOCKINESS = 10.0 | |
def initialize world | |
@world = world | |
end | |
def pixel x, y, char = » « | |
Pixel.new char, 0, color(x, y) | |
end | |
def color x, y | |
y = (y / BLOCKINESS).round * BLOCKINESS | |
sin = Math.sin((x + @world.distance.to_f * SPEED) / PERIOD + y / PERIOD) | |
PALETTE[(0.9 * sin + 0.9).round] | |
end | |
end |
Фон воспринимает мир как зависимость и в основном отвечает за возвращение цвета за все остальное. Например, когда игрок рендерится, он передает backround.color () в качестве цвета фона пикселям игроков. Милый маленький, сосредоточенный класс. Мне это нравится.
WindowColor
class WindowColor | |
PALETTE = [ 16, 60 ] | |
PERIOD = 6.0 | |
def pixel x, y, char = » « | |
Pixel.new char, 0, color(x, y) | |
end | |
def color x, y | |
sin = Math.sin(x / PERIOD + y / (PERIOD * 0.5)) | |
PALETTE[(0.256 * sin + 0.256).round] | |
end | |
end |
Цвет окон на зданиях. BuildingGenerator использует его. Это все
Кадровый буфер
class Framebuffer | |
def initialize background | |
@pixels = Hash.new { |h, k| h[k] = {} } | |
@background = background | |
end | |
def set x, y, pixel | |
@pixels[x][y] = pixel | |
end | |
def get x, y | |
@pixels[x][y] || @background.pixel(x, y) | |
end | |
def size | |
@pixels.values.reduce(0) { |a, v| a + v.size } | |
end | |
end |
Framebuffer создается классом Screen и принимает фон. Метод initialize напомнил мне о классном способе передачи блока в Hash Intializer, чтобы получить хеш, который имеет значение по умолчанию для любого члена, к которому осуществляется доступ. Рендеринг пикселей подается в кадровый буфер, а затем другие вещи (например, экран) могут захватить их позже. Он также использует фон, передавая фоновые пиксели по умолчанию, когда кадровый буфер не имеет пикселя для запрошенного x, y.
BuildingGenerator
class BuildingGenerator | |
def initialize world, background | |
@world = world | |
@background = background | |
end | |
def destroy_if_necessary | |
while @world.buildings.any? && @world.buildings.first.x < —100 | |
@world.buildings.shift | |
end | |
end | |
def generate_if_necessary | |
while (b = @world.buildings.last).x < @world.horizon | |
@world.buildings << build( | |
b.right_x + minimium_gap + rand(24), | |
next_y(b), | |
rand(40) + 40 | |
) | |
end | |
end | |
def minimium_gap; 16 end | |
def maximum_height_delta; 10 end | |
def minimum_height_clearance; 12; end | |
def next_y previous_building | |
p = previous_building | |
delta = 0 | |
while delta.abs <= 1 | |
delta = maximum_height_delta * —1 + rand(2 * maximum_height_delta + 1) | |
end | |
[25, [previous_building.y — delta, minimum_height_clearance].max].min | |
end | |
def build x, y, width | |
Building.new x, y, width, @background | |
end | |
end |
BuildingGenerator берет мир и фон и, как вы уже догадались, он создает здания. Методы здесь сосредоточены на разрушении строительных объектов (путем изъятия их из мира world.buildings) и создания застройки как мира витки движется. Метод generate_if_necessary
— это цикл, который продолжает создавать здания, в то время как координата x последнего здания меньше, чем горизонт миров (который является шириной экрана, если вы помните). Он вызывает метод сборки, который строит здание. Функция next_y
учитывает предыдущее здание и гарантирует, что новое здание не слишком высоко для нашего игрока, чтобы прыгать. Это проблема домена, которую вы должны решить при создании видеоигры. 🙂
визуализируемых
module Renderable | |
def each_pixel ticks | |
(y…(y + height)).each do |y| | |
(x…(x + width)).each do |x| | |
rx = x — self.x | |
ry = y — self.y | |
yield x, y, pixel(x, y, rx, ry, ticks) | |
end | |
end | |
end | |
def right_x; x + width end | |
end |
Это подводит нас к модулю Renderable, который входит в:
- Строительство
- игрок
- Кровь (хи)
- Табло
- GameOverBanner
- RoflCopter
Он определяет два метода: each_pixel
и right_x
. Кроме того, он ожидает, что включенный класс определит метод pixel
, а также свойства (или методы) x
, y
, height
и width
. each_pixel
говоря, метод each_pixel
перебирает каждый пиксель объекта и возвращает x, y. этого пикселя, вместе с символом, который представляет этот пиксель для данного Renderable. В некотором смысле, он работает так же, как ваш телевизор, сканируя изображения и рендеринг их, пиксель за пикселем. Мне нравится, как Renderable делегирует pixel
метод, заключая четкий контракт с вещами, которые включают Renderable, а также с вещами, которые используют Renderables.
Строительство
class Building | |
include Renderable | |
def initialize x, y, width, background | |
@x, @y = x, y | |
@width = width | |
@background = background | |
@period = rand(4) + 6 | |
@window_width = @period — rand(2) — 1 | |
@color = (235..238).to_a.shuffle.first # Ruby 1.8 | |
@top_color = @color + 4 | |
@left_color = @color + 2 | |
end | |
attr_reader 😡, :y, :width | |
def move_left distance | |
@x -= distance | |
end | |
def height; SCREEN_HEIGHT — @y end | |
def pixel x, y, rx, ry, ticks | |
if ry == 0 | |
if rx == width — 1 | |
Pixel.new » « | |
else | |
Pixel.new «=», 234, @top_color | |
end | |
elsif rx == 0 || rx == 1 | |
Pixel.new «:», @left_color + 1, @left_color | |
elsif rx == 2 | |
Pixel.new «:», 236, 236 | |
elsif rx == width — 1 | |
Pixel.new «:», 236, 236 | |
else | |
if rx % @period >= @period — @window_width && ry % 5 >= 2 | |
Pixel.new(» «, 255, @background.color(rx + x/2, ry)) | |
else | |
Pixel.new(«:», 235, @color) | |
end | |
end | |
end | |
end |
Глядя на наш первый Renderable, Building, он принимает x, y, который является самым левым x и самым большим y, шириной и фоновым объектом. Переместить его влево так же просто, как уменьшить его свойство x. Сложность происходит в пиксельном методе, ожидаемом от Renderable. Цель состоит в том, чтобы выяснить, для данного x / y, какой символ и цвет отображать. Если посмотреть на начало оператора if
, если расстояние между y текущего пикселя и y здания равно нулю, то мы находимся на вершине здания, поэтому нарисуем «=». Остальная логика та же самая, выясняя, какой символ и цвет возвращать. Довольно умный.
игрок
class Player | |
include Renderable | |
def initialize y, background | |
@y = y | |
@background = background | |
@velocity = 1 | |
@walking = false | |
end | |
def x; 0; end | |
def width; 3 end | |
def height; 3 end | |
def pixel x, y, rx, ry, ticks | |
Pixel.new char(rx, ry, ticks), 255, @background.color(x, y) | |
end | |
def char rx, ry, ticks | |
if dead? | |
[ | |
‘ @ ‘, | |
‘\+/’, | |
‘ \\\\’, | |
][ry][rx] | |
elsif !@walking | |
[ | |
‘ O/’, | |
‘/| ‘, | |
‘/ >’, | |
][ry][rx] | |
else | |
[ | |
[ | |
‘ O ‘, | |
‘/|v’, | |
‘/ >’, | |
], | |
[ | |
‘ 0 ‘, | |
‘,|\\’, | |
‘ >\\’, | |
], | |
][ticks / 4 % 2][ry][rx] | |
end | |
end | |
def acceleration | |
if @dead | |
0.05 | |
else | |
0.35 | |
end | |
end | |
def tick | |
@y += @velocity | |
@velocity += acceleration | |
@walking = false | |
end | |
def y; @y.round end | |
def bottom_y; y + height end | |
def walk_on_building b | |
@y = b.y — height | |
@velocity = 0 | |
@walking = true | |
end | |
def jump | |
jump! if @walking | |
end | |
def jump! | |
@velocity = —2.5 | |
end | |
def die! | |
@dead = true | |
@velocity = 0 | |
end | |
def dead? | |
@dead | |
end | |
end |
Player — это еще один рендеринг, но он обрабатывает, какого персонажа рисовать иначе, чем строение. Глядя на код, он использует дельту между текущим x, y и игроком x, y, чтобы выяснить, какой символ возвращать, основываясь на одном из трех состояний: мертв, ходьба или другое (прыжок). Каждое состояние состоит из двумерного массива, который содержит фактические символы, которые вы можете видеть буквально. Код здесь очень умный по своей структуре, он формирует игрока в состоянии, затем использует индексы rx / ry для захвата персонажа. Довольно гениально.
Кровь
class Blood < Struct.new(:x, :y) | |
include Renderable | |
def height; 4 end | |
def width; 2 end | |
def x; super + 2 end | |
def pixel x, y, rx, ry, ticks | |
Pixel.new «:», 124, 52 | |
end | |
end |
Класс Крови всегда использует один и тот же символ и цвет, но мне пришлось выделить для него собственный раздел. Я просто ЛЮБЛЮ, что есть класс Крови.
Табло и GameOverBanner
class Scoreboard | |
include Renderable | |
def initialize world | |
@world = world | |
end | |
def height; 3 end | |
def width; 20 end | |
def x; —18 end | |
def y; 1 end | |
def template | |
[ | |
‘ ‘, | |
‘ Score: %9s ‘ % [ @world.distance], | |
‘ ‘ | |
] | |
end | |
def pixel x, y, rx, ry, ticks | |
Pixel.new template[ry][rx], 244, 234 | |
end | |
end | |
class GameOverBanner | |
FG = 16 | |
BG = 244 | |
include Renderable | |
def x; 28 end | |
def y; 14 end | |
def width; 28 end | |
def height; 3 end | |
def template | |
[ | |
‘ ‘, | |
‘ YOU DIED. LOL. ‘, | |
‘ ‘, | |
] | |
end | |
def pixel x, y, rx, ry, ticks | |
Pixel.new template[ry][rx], FG, BG | |
end | |
end |
Оба эти рендеринга используют шаблонный метод для поиска персонажа для рендеринга. Опять же, структура кода потрясающая, так как она выглядит так, как выглядит в игре. Это позволяет легко понять, что делает код. Если бы вы могли сделать весь код визуально очевидным.
RoflCoptor
Переворот, наш ROFLCopter. Подход здесь, опять же, похож на другие основанные на шаблонах средства визуализации, но здесь у нас есть несколько шаблонов или фреймов. Х и у коптора изменяются в зависимости от текущего времени, используя формулу, которую я не очень понимаю. Я думаю, что мой любимый бит — спасение, где утверждается, что «RoflCopter» время от времени дает сбой.
Время играть
Ну, Пол и Деннис справились. Порт Canabalt для Ruby, который хорошо воспроизводится, а также содержит интересный код. Когда я попросил у джентльменов какие-либо анекдоты об этом, они ответили: «Попросите читателей заставить его работать в Ruby 1.8 как вызов». Итак, кто-нибудь готов к этому? А пока я собираюсь побить свой рекорд в ROFLBALT.