Шахматы на php и javascript: Пешки, вперёд!

В этой части научим ходить пешки. У них самая сложная "бизнес - логика" среди шахматных фигур. Ходят вперёд, бьют наискоcок. Плюс взятие "на проходе", плюс превращение…

Для начала чуть-чуть изменим файл engine/figure.php. В методе makeMove есть такое:

// изменяем счётчик числа полуходов, после последнего взятия фигуры или движения пешки
if ($to_figure !== FG_NONE || Functions::figureType($this->figure) == FG_PAWN) {
    $this->game_state->non_action_semimove_counter = 0;
} else {
    $this->game_state->non_action_semimove_counter++;
}

Обратите внимание на условие, на вторую его часть, после логического "ИЛИ". Я подумал - а зачем в общем классе фигур проверять - не пешка ли это? Можно же будет в классе пешки при совершении хода обнулить счётчик non_action_semimove_counter. В общем, убираем вторую часть условия. Вышеприведённый кусок кода теперь должен выглядеть так:

// изменяем счётчик числа полуходов, после последнего взятия фигуры или движения пешки
if ($to_figure !== FG_NONE) {
    $this->game_state->non_action_semimove_counter = 0;
} else {
    $this->game_state->non_action_semimove_counter++;
}

Все остальные изменения в этой статье будем делать в классе пешки.

Ходы - кандидаты для пешки

Итак, открываем файл engine/figures/pawn.php. Там у нас пока только пустой метод getCandidateMoves. Делаем его таким:

engine/figures/pawn.phppublic function getCandidateMoves() {
    $moves = array();
    if ($this->color == COLOR_WHITE) {
        $direction = -1;
        $first_row = 6;
        $last_row = 0;
    } else {
        $direction = 1;
        $first_row = 1;
        $last_row = 7;
    }
    if ($this->row === $last_row) {
        throw new Exception('Pawn in last row');
    }
    // проверим поле перед пешкой
    $row = $this->row + $direction;
    $to_index = Functions::colRowToPositionIndex($this->col, $row);
    $to_figure = $this->game_state->position[$to_index];
    if ($to_figure === FG_NONE) {
        $moves[] = $to_index;
        if ($this->row === $first_row) {
            // пешка находится на начальной позиции, проверим ещё одно поле "вперёд"
            $row = $row + $direction;
            $to_index = Functions::colRowToPositionIndex($this->col, $row);
            $to_figure = $this->game_state->position[$to_index];
            if ($to_figure === FG_NONE) {
                $moves[] = $to_index;
            }
        }
    }
    // теперь проверим возможность взятий
    $row = $this->row + $direction;
    $col = $this->col - 1;
    if ($col >= 0) {
        $to_index = Functions::colRowToPositionIndex($col, $row);
        $to_figure = $this->game_state->position[$to_index];
        if (Functions::color($to_figure) === $this->enemy_color || $to_index === $this->game_state->crossed_field) {
            $moves[] = $to_index;
        }
    }
    $col = $this->col + 1;
    if ($col < BOARD_SIZE) {
        $to_index = Functions::colRowToPositionIndex($col, $row);
        $to_figure = $this->game_state->position[$to_index];
        if (Functions::color($to_figure) === $this->enemy_color || $to_index === $this->game_state->crossed_field) {
            $moves[] = $to_index;
        }
    }
    return $moves;
}

Как и для коней, сначала объявляем переменную $moves, для накапливания в ней ходов, пишем туда пустой массив, а в конце метода (строка 49) возвращаем.

В строках 3 - 11, в зависимости от цвета фигуры, заполняем три переменные. $direction - "направление", в котором изменяется индекс ряда (горизонтали) при движении пешки. Напомню, как у нас кодируются поля доски:

шахматная доска с пронумерованными полями от 0 до 63 и с номерами рядов

Чёрные фигуры в начале партии стоят в ряду #0, пешки - в ряду #1. И при их движении, номер ряда увеличивается, $direction = 1. Белые пешки стоят в ряду #6, и номер ряда при их движении уменьшается, $direction = -1.

В переменной $first_row записали индекс начального ряда, на котором стоят пешки. В $last_row - индекс последнего ряда (горизонтали), на которой пешка превращается в другую фигуру.

Строки 12 - 14 - проверяем, если вдруг пешка стоит на последней горизонтали, то возбуждаем исключение с фразой 'Pawn in last row'.

Ходы вперёд

Сначала проверяем, может ли пешка пойти вперёд на одно поле. В строке 16 вычисляем горизонталь поля перед пешкой - к текущей горизонтали прибавляем "направление". В строке 17 по колонке (вертикали) на которой стоит пешка, и по горизонтали "перед" пешкой, вычисляем индекс поля, которое находится перед пешкой ($to_index). На следующей строке получаем фигуру, которая находится на поле перед пешкой.

В строке 19 проверяем - если "фигура" перед пешкой - "пустое поле" (FG_NONE), значит на это поле можно пойти, и в строке 20 мы добавляем индекс поля перед пешкой ($to_index) в список ходов - кандидатов $moves.

Тут-же, находясь "под условием" что пешка может пойти на поле вперёд, проверяем (в строке 21), находится ли пешка на своей начальной позиции. Если да, то она может прыгнуть и на два поля вперёд. Надо проверить ещё одно поле перед проверенным. Этим и занимаемся в блоке кода в строках 22 - 28.

В этом блоке всё аналогично коду выше. К индексу горизонтали прибавляем ещё одно "направление" ($direction). Опять вычисляем индекс поля, берём фигуру,которая находится на этом поле, и если это "пустое поле", то пешка может пойти и туда - добавляем индекс этого поля в массив ходов - кандидатов (строка 27).

Как видите, я не боюсь небольших "копипастов". Две строки полностью повторяются (17, 18 и 24, 25), а одна строка перед ними - очень "похожа". Можно было организовать код и без "копипаста", но мне нравится именно так. Так нагляднее. Нет потерь на лишние проверки при организации кода без копипаста.

Взятия

Теперь проверим возможность "взятия". В строке 32 получаем опять, так-же как и ранее, индекс горизонтали перед пешкой. Сначала проверим, может ли пешка "срубить" что-то слева (перед собой и слева). В строке 33 уменьшаем индекс вертикали. В следующей строке проверяем - не вышли ли мы за край доски.

Если нет, то в строках 35, 36 опять, по привычной схеме, получаем индекс поля, которое "бьёт" пешка, и фигуру, которая стоит на этом поле. А вот дальше, на строке 37 интересное условие того, может ли пешка ходить на поле $to_index.

В условии - два логических выражения, объединённых операцией ИЛИ. В первом выражении мы берём цвет фигуры на "битом поле", и сравниваем с цветом фигур оппонента, $this->enemy_color. Если в поле своя фигура - условие не выполнится. Если поле пустое, то тоже условие не выполнится. Вспомните кодировку цвета, и белый и чёрный цвет - не нули. А FG_NONE==0 и метод получения цвета от FG_NONE вернёт 0.

Вторая часть условия - сравниваем на равенство индекс "битого поля" и "пересечённого поля" ($this->game_state->crossed_field). Это поле в "состоянии игры" было специально заведено для обслуживания правила "взятия на проходе". В нём в начале партии, и после каждого хода записывается null … кроме случая, когда пешка ходит на два поля вперёд. После такого хода, индекс поля, которое пешка перепрыгнула, будем записывать как раз в поле crossed_field состояния игры. И уже после следующего хода это поле будет перезаписано. Мы его уже перезаписываем! Посмотрите - в классе фигуры Figure в методе makeMove есть строка

$this->game_state->crossed_field = null;

Вернёмся к условию возможности взятия. Итак, во второй части условия проверяется возможность взятия "на проходе". В итоге, если на "битом поле" (впереди и слева) будет фигура цвета фигур оппонента, или если битое поле - это "пересечённое поле", то пешка может пойти на это поле. Записываем это поле в массив ходов - кандидатов (строка 38).

Мы проверили поле "наискосок" - впереди и слева от пешки. Теперь аналогично проверяем поле впереди и справа - см. код в строках 41 - 48. Как видите, опять, я не боюсь "копипаста" (в разумных пределах). Разница тут только в проверке выхода за пределы доски (строка 42).

Всё! Ходы - кандидаты получены. Надо ещё реализовать метод получения допустимых ходов и метод, делающий ход.

Получение допустимых ходов

Напомню, что мы называем "ходами - кандидатами" и "допустимыми ходами". Ходы - кандидаты содержат поля, куда фигура (пешка в данном случае) может переместиться по правилам своего хода. Но после такого перемещения может образоваться недопустимая позиция, например свой король оказался (или остался) под атакой. Метод получения допустимых ходов берёт ходы - кандидаты, для каждого хода строит позицию, получившуюся после хода, и проверяет её на допустимость.

У нас уже написан метод получения допустимых ходов - getAvailableMoves в классе Figure, от которого наследуются классы конкреных фигур. Этот метод рабочий и использовался нами при ходе коня. Он-же будет использоваться и при ходе слона, ладьи, ферзя. Там логика перемещения простая ушёл с одного поля, встал на другое. А вот с пешкой сложнее. При взятии на проходе она уходит с одного поля, встаёт на другое, а чужая пешка при этом исчезает с третьего поля.

Логику пешки конечно можно было реализовать и в общем классе фигуры, добавить проверки - если это пешка и … Но зачем лишние проверки. Они будут занимать дополнительное время, хотя и малое, но это всё будет выполнятся очень - очень много раз при поиске хода копмпьютером. Перекроем метод в классе пешки. Добавляем в engine/figure/pawn.php:

engine/figures/pawn.phppublic function getAvailableMoves() {
    $moves = array();
    $board_position = new BoardPosition();
    $our_king_positions = $this->game_state->figures[FG_KING + $this->color];
    if (count($our_king_positions) === 0) {
        return $moves;
    }
    $our_king_position = $our_king_positions[0];
    $candidate_moves = $this->getCandidateMoves();
    foreach($candidate_moves as $to_index) {
        $to_position = $this->game_state->position; // копируем позицию
        $to_position[$this->position_index] = FG_NONE; // убираем фигуру из текущей позиции
        $to_position[$to_index] = $this->figure; // перемещаем фигуру на выбранное поле. Если на том поле что-то стояло - оно "убирается"
        if ($to_index === $this->game_state->crossed_field) {
            // это "взятие на проходе". Надо убрать с позиции проскочившую пешку.
            $beat_col = Functions::positionToCol($to_index);
            $beat_cell_index = Functions::colRowToPositionIndex($beat_col, $this->row);
            $to_position[$beat_cell_index] = FG_NONE;
        }
        $board_position->setPosition($to_position);
        if ($board_position->isFieldUnderAttack($our_king_position, $this->enemy_color)) {
            // ход недопустим, т.к. после него наш король оказывается под атакой
            continue;
        }
        $moves[] = $to_index;
    }
    return $moves;
}

Опять секретный приём индусских программистов - "копи-паст" . Метод почти такой-же, как в родительском классе. Разница - в строках 14 - 19. В них проверяем - если поле "куда" (куда встаёт пешка) равно "пересечённому" полю ($this->game_state->crossed_field), то рассматриваемое перемещение - это взятие "на проходе". Ранее в коде (строки 12, 13) мы уже убрали пешку с поля "откуда", переместили в поле "куда". А теперь надо убрать с доски пешку, которую мы взяли этим ходом. Где она находится?

взятие на проходе - чёрная пешка проскоыила поле, атакованное белой пешкой

Она находится на той-же вертикали, что и "пересечённое поле". На картинке красная стрелка показывает на "пересечённое поле". В строке 16 мы получаем вертикаль (колонку) "проскочившей" пешки. А горизонталь совпадает с горизонталью начальной позиции нашей (белой на картинке) пешки. В строке 17 получем индекс поля, на которой находится проскочившая пешка. И в строке 18 мы убираем эту пешку из позиции $to_position.

Остальной код - такой-же, как в перекрытом методе родительского класса.

Делаем ход

Метод makeMove делает ход, т.е. реальное изменение состояния игры. Он тоже уже есть в родительском классе. И тоже он полностью подходит для хода коня, слона, ладьи и ферзя. А вот для пешки его тоже (как и метод получения допустимых ходов) надо перекрыть. В данном случае не будет копи-паста. Существующий родительский метод нам вполне подходит, надо только подкорректировать "пару деталей".

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

Пока не будем реализовывать в интерфейсе выбор фигуры для превращения. Пока будем считать что пешка по умолчанию превращаетсяя в ферзя. Добавим к параметрам метода makeMove третий параметр, в котором будем указывать фигуру, в которую нужно превратиться при достижении последней горизонтали. Добавим значение по умолчанию - ферзь (FG_QUEEN). Итак, добавляем ещё один метод в класс пешки:

engine/figures/pawn.phppublic function makeMove($to_cell_index, $validate_move=true, $to_figure=FG_QUEEN) {
    $row = $this->row;
    $crossed_field = $this->game_state->crossed_field;
    if (!parent::makeMove($to_cell_index, $validate_move)) {
        return false;
    }
    $this->game_state->non_action_semimove_counter = 0;
    $direction = $this->color === COLOR_WHITE ? -1 : 1;
    $to_row = Functions::positionToRow($to_cell_index);
    if ($row + 2*$direction == $to_row) {
        // пешка перескочила поле, пометим его как проходное, т.е. доступное для взятия пешкой оппонента
        $this->game_state->crossed_field = Functions::colRowToPositionIndex($this->col, $row + $direction);
    } elseif ($crossed_field == $to_cell_index) {
        // этот ход - взятие "на проходе", надо убрать взятую пешку
        $beat_col = Functions::positionToCol($to_cell_index);
        $beat_cell_index = Functions::colRowToPositionIndex($beat_col, $row);
        $this->game_state->position[$beat_cell_index] = FG_NONE;
    } elseif (($direction == 1 && $to_row == BOARD_SIZE-1) || ($direction == -1 && $to_row == 0)) {
        // пешка достигла последней горизонтали - она превращается в другую фигуру
        $to_figure = Functions::figureType($to_figure) + $this->color;
        $this->game_state->position[$to_cell_index] = $to_figure;
    }
    return true;
}

Перед вызовом родительского метода, сохраним в локальных переменных то, что нам понадобится, и что метод родительского класса изменяет (может изменить). В строке 2 сохраняем индекс горизонтаи, на которой находится пешка перед ходом, и в строке 3 - "пересекаемое поле".

В строке 4 вызываем родительский метод, и в случае неудачи (метод вернул false), возвращаем false и из перекрытого метода. В родительском методе уже проделана основная работа по совешению хода, и нам остаётся только учесть особенности пешки.

В строке 7 обнуляем счётчик "non-action полу-ходов", т.е. счётчик полуходов, за которые не было взятия фигуры или движения пешек. Напомню, этот счётчик нужен для проверки выполнения условий правил 50 и 75 ходов. Так как в методе мы делаем ход пешкой, то счётчик надо обнулить.

Далее, в строке 8 вычисляем "направление" изменения горизонтали, так-же как и в методе getCandidateMoves. В строке 9 получаем индекс горизонтали, на которой окажется пешка после своего хода - $to_row.

Дальше у нас идёт цепочка if - elseif для обработки трёх взаимоисключающих особых случая для хода пешки. В строке 10 проверяем - если "горизональ назначения" $to_row отстоит от начальной позиции пешки на два шага "направления", т.е. если пешка шагнула на два поля вперёд, то надо записать "пересечённое" (перепрыгнутое) поле в состояние игры по ключу crossed_field. Это делается в сроке 12. Теперь на следующем полу-ходе будет информация о пересечённом поле, и пешку можно будет взять "на проходе".

Следующая проверка - на строке 13. Там мы проверяем, если поле назначения пешки совпадает с "пересечённым полем", значит пешка совершает "взятие на проходе". Обратите внимание - к этому моменту "пересечённое поле" в "состоянии игры" (в $this->game_state) уже обнулено, точнее, там записан null. Это сделано было в родительском методе, который мы вызывали в начале. А сравнение "поля назначения" происходит со значением, хранящимся в локальной переменной $crossed_field, в которую мы сохранили значение из состояния игры перед вызовом родительского метода.

Итак, если наш ход - это взятие на проходе, то надо убрать фигуру (пешку), которая перепрыгнула поле на предыдущем ходу. Это делается в строках 14 - 17. Делаем так-же, как и в методе getAvailableMoves - вычисляем положение перепрыгнувшей фигуры, и в её позицию записываем FG_NONE.

И последняя проверка "особых случаев" - в строке 18. Там мы проверяем, что пешка достигла "последней горизонтали". И, если достигла, то надо превратитьеё в фигуру, переданную во входном параметре $to_figure. А туда может прийти как фигура "без цвета", (например значение по умолчанию и есть "ферзь без цвета" - FG_QUEEN), так и с флагами цвета. Я хочу чтобы метод мог принимать оба этих варианта. Поэтому в строке 20 я сначала вычисляю "тип фигуры", а потом прибавляю цвет, записанный в поле класса.

Ну и в строке 21 записываем в "поле назначение" получившийся код фигуры - пешка "превращена". Всё.

Итоги

Ура! Самая сложная фигура описана. Можно уже играть с компьютером не только конями, но и пешками. Правда пока короли не боятся атак, и их можно даже съесть. Но терпение, не так далеко реализация всех правил шахмат. На скриншоте ниже - пример игры. Там я провёл свою пешку в ферзи, и дал компьютеру провести свою пешку.

фрагмент партии, где белая и чёрная пешки превратились в ферзей

Исходные коды этой части на github.

Демо №9 игры - игра пешек и коней.