Это четвертая часть My Ruby Chess Journey, серии статей о написании шахматной игры из командной строки на чистом Ruby.

«Огнеокая дева дымной войны/ Всех горячих и кровоточащих поднесем им…»

…очень скоро, но не сейчас. Сначала нам нужна армия! Этим мы сегодня и займемся: сейчас строим тела для пешек, коня, ладей, слонов, ферзей и королей. Завтра мы дадим им души.

Нашей следующей задачей будет написать соответствующие классы для каждого типа фигур, дать им красивое графическое представление на доске и научить доску расставлять фигуры до того, как мы начнем игру. Но сначала нам нужно закончить работу, которую мы начали на днях: у нашей платы есть некоторые проблемы, которые нам нужно исправить в первую очередь. Давайте начнем добавлять подсказку для алгебраической записи.

Итак, в основном, для букв (столбцов в нашей матрице) нам нужно напечатать строку букв, прежде чем мы напечатаем саму доску, вот так:

    a       b        c        d        e        f        g        h

Давайте добавим константу в наш класс BoardRenderer:

COLUMN_LETTERS = [*('a'..'h')]

Нам не нужно писать каждую букву благодаря основному классу Range и нашему маленькому другу, оператору знака. Это построит для нас этот массив:

['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

Единственное, что нам сейчас нужно, это создать метод, который будет выполнять эту работу, и вызвать его… догадаетесь, где? Вот как я это сделал:

def render
  print_column_letters # At the top...

  print_floor
  SQUARE_ORDER.times do |row_number|
    SQUARE_HEIGHT.times { print_row(row_number) }
    print_floor
  end
  new_line

  print_column_letters # ...and the bottom.
end

private

def print_column_letters
  COLUMN_LETTERS.each { |letter| print "        #{letter}" }
  new_line(2)
end

# Here is a possible definiton for the new_line helper (very handy!):
def new_line(lines=1)
  lines.times { puts '' }
end

И это результат:

Вы видите, что буквы не совпадают с квадратами? Мы можем либо переместить буквы влево (удалив некоторые пробелы в строке, напечатанной каждой итерацией print_column_letters ), либо переместить доску вправо, добавив небольшое поле. Поскольку нам все еще нужно добавить номера строк, это будет наш лучший вариант.

Как мы можем «переместить» доску? В этом случае под перемещением мы подразумеваем добавление дополнительных пробелов в левой части доски; а добавление дополнительного пробела в левую часть доски здесь означает добавление дополнительного пробела слева от каждой строки, составляющей доску. Нам придется немного изменить наши методы row и print_floor , чтобы добавить это поле. Начнем с добавления константы LEFT_MARGIN со значением… ' ' * 3? Мы всегда можем изменить это значение, если нас не устраивает результат. Давайте посмотрим:

class BoardRenderer
  # ...
  
  LEFT_MARGIN = ' ' * 3

  # ...

  def print_floor
    puts LEFT_MARGIN + FLOOR_0 + FLOOR * (SQUARE_ORDER - 1) 
  end

  # ...

  def row
    LEFT_MARGIN + # We've added this extra line.
      EMPTY_ROW_0_WHITE +
      (EMPTY_ROW + EMPTY_ROW_WHITE) * 3 +
      EMPTY_ROW
  end
end

Но…

Можете ли вы догадаться, почему это происходит? Да, именно… это работает для всех строк… кроме тех, которые мы перевернули! Левые поля перевернутых рядов (или черных начальных рядов) находятся не на левой, а на правой стороне доски! Это все усложняет, потому что теперь нам каким-то образом нужно различать два типа строк: те, которые начинаются с белого квадрата, и те, которые заканчиваются черным квадратом. Если хотите, найдите несколько минут, чтобы подумать, как мы можем это сделать, прежде чем прокрутить вниз, чтобы увидеть, что я сделал. (Подсказка: подумайте о номерах строк).

Я добавил два разных метода для печати двух типов строк, прежде чем немного изменить метод print_row (помните, что параметр number назначается каждому целому числу от 0 до 7, переданному из метода times, вызываемого для SQUARE_ORDER внутри render )

def print_row(number)
  puts number.even? ? white_starting_row : black_starting_row
end

def white_starting_row
  LEFT_MARGIN +
    EMPTY_ROW_0_WHITE +
    (EMPTY_ROW + EMPTY_ROW_WHITE) * 3 +
    EMPTY_ROW
end

def black_starting_row
  LEFT_MARGIN +
    EMPTY_ROW_0 +
    (EMPTY_ROW_WHITE + EMPTY_ROW) * 3 +
    EMPTY_ROW_WHITE
end

Это то, что мы получаем с множителем 3 в LEFT_MARGIN constant. Что, если мы поменяем его на 4?

Теперь наша следующая задача — добавить номера рядов в левую и правую части шахматной доски. Мы хотим, чтобы числа были отцентрованы, четко выровнены с соответствующим квадратом. Как мы собираемся это сделать? Вы видите, что каждая квадратная строка состоит из трех подстрок, верно? По одному сверху, посередине и снизу. Это по-прежнему важно по разным причинам, но сейчас это важно, потому что мы собираемся использовать среднюю строку для отображения номеров строк, прежде чем напечатать саму шахматную доску.

Но поскольку мы сейчас различаем три типа строк, нам нужно немного изменить метод render, так как теперь мы не сможем просто times на SQUARE_HEIGHT печатать три строки:

def render
    print_column_letters
  
    print_floor
    SQUARE_ORDER.times do |row_number|
      print_row(row_number) # Top
      print_row(row_number) # Middle
      print_row(row_number) # Bottom
      print_floor
    end
    new_line
  
    print_column_letters
  end

Как и в случае с буквами столбца, мы должны добавить константу ROW_NUMBERS:

ROW_NUMBERS = [*('1'..'8')].reverse

Помните, что номера строк отображаются от 8 до 1, а доска отображается сверху вниз, поэтому нам нужно перевернуть массив (диапазоны Ruby не допускают обратного ранжирования само по себе) . Как только мы это сделали, остальное должно быть легко. И нам не терпится увидеть кусочки! Как и когда мы можем печатать номера строк? Как всегда, вы можете попробовать реализовать то, что считаете лучшим, именно так я сначала и поступил. Я начал добавлять эту строку в метод render:

def render
    print_column_letters
  
    print_floor
    SQUARE_ORDER.times do |row_number|
      print_row(row_number)
      print ROW_NUMBERS[row_number] # The row_number parameter works as index
      print_row(row_number)
      print_row(row_number)
      print_floor
    end
    new_line
  
    print_column_letters
  end

Но, опять же, это не приносит нам результатов, на которые мы надеялись:

Лучше привыкнуть: в программировании одно исправление тут — там головная боль. Часто бывает так, что когда мы модифицируем или добавляем какую-то реализацию, хотя это и мало, это вызовет волновой эффект в нашей программе, который заставит нас изменить многие другие части нашего кода, которые, как мы даже не могли подумать, будут затронуты. . Это нормально и ожидаемо, но до определенного момента это можно смягчить, и есть несколько способов лучше сохранять спокойствие, когда это происходит. Это очень важно, поверьте. Я выучил это трудным путем:

Не начинайте программировать сразу, как только у вас появится идея. Сначала напишите идею, если можете, от руки, поскольку мозг обрабатывает рукописный ввод лучше, чем печатание. Всегда контролируйте код. Это означает, что не должно быть никаких областей (методов, классов…) вне нашего полного понимания, что на практике означает способность читать любую строку нашего кода и, по сути, быть в состоянии ответить на четыре основных вопроса о КАЖДОМ методе: Что такое вход? Что такое выход? , Есть ли какие-либо побочные эффекты?, Является ли какой-либо объект постоянной мутацией? Чем лучше мы назовем артефакты в нашей программе и запомним карту, ​​тем лучше и быстрее мы сможем ответить на эти вопросы. Чем быстрее мы сможем ответить на эти вопросы, тем лучше мы станем отлаживать. Также важно знать, как стратегически разместить наши binding.irb или binding.pry точек. Никогда не прекращайте экспериментировать и исследовать, что является привязкой в ​​любой точке программы, вы будете удивлены, узнав, как быстро вы улучшите свою способность читать код, если сделаете это правильно. Однако гораздо важнее научиться правильно называть вещи.

Иногда, обычно когда мы программируем какое-то время, возникает ощущение, что программа становится похожей на огромного Бегемота, нечеткую пропасть, невыносимую рутину и т. д. Наша программа работает, и мы успешно добавляем функциональность, но мы есть какое-то ощущение, что код выливается из рук, что мы теряем хватку и, чем больше кодим, тем меньше понимаем. Это усталость от кодирования, и это также нормально и ожидаемо. Но в тот момент, когда вы это почувствуете (а вы поймете, когда почувствуете), остановитесь. Разомните ноги, прогуляйтесь, поговорите с кем-нибудь, приготовьте кофе, что угодно. Хотя бы на 5-10 минут. Затем, когда вы вернетесь к своему компьютеру, приготовьте лист бумаги вместо того, чтобы сразу начинать программировать, и начните рисовать схему программы. Если у вас есть сомнения, пока не кодируйте. Вместо этого найдите время, чтобы прочитать код, установить некоторые привязки, если хотите, и попытаться снова взять ситуацию под контроль. Когда вы сможете нарисовать небольшую диаграмму (это может быть несколько рисунков, работает все, что угодно, если вы знаете, что делаете), вы почувствуете новую мотивацию, и программа покажется вам менее сложной. Когда вы снова почувствуете мотивацию, тогда вам нужно снова начать программировать. Если нет, промойте и повторяйте, пока не сделаете. Другого пути нет. Если вы заставите себя выполнять, не полностью понимая код, вы не только напишете плохой код, но и обожжетесь. И довольно быстро. Будьте немного дзен здесь: не кодирование важнее, чем кодирование. Иногда время, проведенное без программирования, более ценно, чем время, потраченное на кодирование.

Средний ряд: вам суждено стать великим.

Итак, теперь идея состоит в том, чтобы каким-то образом создать специальный метод для специальной строки, средней строки, которая может иметь меньшее левое поле, чем другие строки; таким образом, его можно распечатать совместив с другими, а не с этим уродливым смещением от последнего изображения.

Давайте начнем писать возможный метод print_middle_row

def print_middle_row(row)
  print number.even? ? white_starting_row[1..-1] : black_starting_row[1..-1]

  # It has to be print and not puts, because we still don't want a newline
  # character at the end of the row, but the row number we will print later.
end

…и заменив старое print_row на render :

  def render
    print_column_letters
  
    print_floor
    SQUARE_ORDER.times do |row_number|
      print_row(row_number)
      print ROW_NUMBERS[row_number] 
      print_middle_row(row_number)
      print_row(row_number)
      print_floor
    end
    new_line
  
    print_column_letters
  end

И…

…оно работает! Нам просто нужно добавить числа в правую сторону доски! Это очень просто: будет достаточно простого вызова puts. Не забывайте о RIGHT_MARGIN = ' ' * 3 constant. Кстати, я тестировал это с 3 и 4 пробелами, и я думаю, что 3 выглядит лучше. Вы можете проверить свои собственные идеи и цифры.

  def render
    new_line # I think an extra line first looks good.
    print_column_letters
    print_floor

    SQUARE_ORDER.times do |row_number|
      print_row(row_number)
      print ROW_NUMBERS[row_number]
      print_middle_row(row_number)
      puts RIGHT_MARGIN + ROW_NUMBERS[row_number] # The last touch!
      print_row(row_number)
      print_floor
    end

    new_line
    print_column_letters
  end

Наш первый взгляд на Писленд

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

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

Первый шаг — создать папку pieces внутри lib. Затем внутри папки pieces мы создадим семь .rb файлов, по одному для каждого типа шахматной фигуры, плюс один дополнительный pieces.rb для родительского класса _29, содержащий общие поведения, которые каждый тип фигуры унаследует (или перезапишет). Это должна быть результирующая файловая структура в нашей основной папке:

И затем, так же, как мы сделали с ./lib/board.rb , мы добавим все необходимые относительные файлы в ./lib/pieces.rb :

require_relative 'pieces/piece.rb'
require_relative 'pieces/pawn.rb'
require_relative 'pieces/knight.rb'
require_relative 'pieces/bishop.rb'
require_relative 'pieces/rook.rb'
require_relative 'pieces/queen.rb'
require_relative 'pieces/king.rb'

Таким образом, вместо того, чтобы требовать все эти файлы в других файлах, нам просто нужно будет потребовать ./lib/pieces.rb, чтобы добавить функциональность всех классов частей в другие классы или модули.

На данный момент мы реализуем основы в каждом классе частей, чтобы он мог взаимодействовать с классом Board , и из этого мы будем добавлять больше функций и поведений, таким образом увеличивая сложность. Давайте начнем писать базовый родительский класс для всех частей, Piece, в ./lib/pieces/piece.rb.

class Piece
  attr_reader :color
  attr_accessor :location

  def initialize(location, color)
    @location = location
    @color = color
  end
end

И это довольно много! Мы определяем два наиболее важных атрибута для части с соответствующими геттерами и сеттерами:

  • a location , содержащее текущее положение фигуры на доске в формате [row, column], определенном нами ранее. Это способ реализовать внутреннее сознание фигур о том, где они находятся на доске в конкретный момент игры: Я здесь. Это важно, потому что мы будем использовать эти данные, чтобы узнать, на какие клетки можно будет двигаться за ход. Внутренние данные этой фигуры должны совпадать с квадратом на доске, где находится фигура в любое время: если есть несоответствие, это ошибка, и мы можем использовать это как индикатор ошибок, например, при написании собственных тестов. .
  • a color : самый основной атрибут фигуры. Он будет содержать один из двух символов: :white или :black..

Что теперь? Прежде чем мы начнем писать классы, я должен показать вам кое-что, что, возможно, вы хотели спросить. Да, для шахматных фигур существуют настоящие символы Юникода! И для обоих цветов тоже! Они не супер-причудливые или что-то в этом роде, и вы едва можете различить черные и белые части, но они сделают свою работу.

╔═══════════╦═══════════════════╗
║   Symbol  ║       Name        ║
╚═══════════╩═══════════════════╝
     ♔           White King  
     ♕           White Queen 
     ♗           White Bishop
     ♖           White Rook
     ♘           White Knight      
     ♙           White Pawn     
       
     ♚           Black King              
     ♛           Black Queen              
     ♝           Black Bishop          
     ♖           Black Rook             
     ♞           Black Knight            
     ♟︎           Black Pawn        

Наша следующая задача — добавить к каждому классу соответствующий символ в две константы, одну WHITE и одну BLACK. Вы можете сделать это с помощью одной константы SYMBOL, назначенной, например, хэшу, используя цвета в качестве ключей и символы в качестве значений, но меня это не беспокоит, потому что нам все равно придется использовать условное выражение, как вы увидите. очень скоро, в очень важном методе.

Я приведу вам пару примеров, а потом вы сделаете все остальное, хорошо?

Во-первых, пешка (пока даже не думайте о продвинутых пешках):

class Pawn < Piece # Don't forget to make every piece type a child of Piece!
  WHITE = ♙
  BLACK = ♟︎
end

А рыцарь…

class Knight < Piece
  WHITE = ♘
  BLACK = ♞
end

И так далее, и так далее.

Свет в конце туннеля

Вы помните, что у нас все еще есть двумерный массив, который мы назвали matrix, в духовке, заполненный уродливыми значениями nil, не так ли? Если бы, например, мы хотели узнать, что находится на квадрате [0, 0] (a8 в алгебраической записи), мы могли бы сказать… matrix[0, 0].class и увидеть на экране Rook. Но… как представить, что на этом поле стоит ладья? Мы сделали это с первой буквой класса, но мы просто включили каждый символ типа фигуры в каждый класс. Как сделать так, чтобы символ, простой символ UNICODE, отображался на доске? Следите за мной в этом, потому что, когда я наконец узнал, как это сделать, я подумал, что это действительно захватывающе.

В Ruby есть нечто, называемое «интерполяцией строк». Маленькая приятная синтаксическая функция, которая позволяет печатать любое значение переменной где-нибудь в строке. Например,

a_variable = 2022

puts "Hi, I am a variable and my value is #{a_variable}"
# => "Hi, I am a variable and my value is 2022"

Мы даже можем включить такое выражение, как 1 + 1, или даже более сложные, и мы все равно увидим возвращаемое значение выражения в том месте, где мы добавили синтаксис #{} в любом месте строки. Что происходит под капотом, так это то, что Ruby сначала оценивает выражение, а затем вызывает метод #to_s для результирующего объекта этой оценки. И вы помните, что, по сути, наш BoardRenderer печатает строки… так что, если я скажу вам, что мы можем написать метод экземпляра Piece#to_s, который будет отображать символ каждой части, и что мы добавили специальную строку с визуализатором со всеми квадраты внутри интерполяции строк? Бинго! Руби вызывал метод Piece#to_s для каждого из них, что приводило к печати символов на доске! Сейчас это может показаться немного запутанным, но вам понравится, насколько это просто и эффективно.

Давайте продолжим! Во-первых, конечно, мы должны определить метод экземпляра to_s в классе Piece, от которого будут наследоваться все классы типов:

def to_s
  case color
  when :white then self.class::WHITE
  else self.class::BLACK
  end
end

Мы применяем это на практике с помощью некоторых экспериментов. Создайте файл main.rb file в корневой папке проекта (не lib ) с этим сверху:

require_relative 'lib/board.rb'
require_relative 'lib/pieces.rb'

Если мы попытаемся напечатать любое содержимое в точном квадрате, скажем, [0, 1] (b8), мы ничего не увидим, потому что nil будет напечатано как пустое место:

board = Board.new

puts "This is the content of the [0, 1] square (b8):"
puts "#{board[[0, 1]]}"

Так мы раньше учили доску говорить: «В этой клетке нет фигуры». На данный момент у нас есть просто пустая доска без каких-либо фигур, представленная двумерным массивом, полным nil значений. А вдруг…

board = Board.new

board[[0, 1]] = Knight.new([0, 1], :black)

puts "This is the content of the [0, 1] square (b8):"
puts "#{board[[0, 1]]}"

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

И это все на сегодня! Надеюсь, вам понравилась эта статья. Но следующий, обещаю, будет еще интереснее.