Шахматы на php и javascript: Даём королю инстинкт самосохранения

Пока самое большое отличие от "полноценных шахмат" - то, что король не боится шахов, не уходит от атак. Сегодня мы это исправим. Начнём, как обычно, с небольшого рефакторинга. И исправления ошибки.

Небольшой рефакторинг

Ошибка

В файле engine/figures/king.php, в функции getavailableMoves, есть такая строка:

if ($this->board_position->isFieldUnderAttack($to_position, $this->enemy_color)) { 

В метод isFieldUnderAttack первым аргументом должно передаваться целое число - индекс поля доски, для которого мы хотим знать атаковано ли оно. Но я передаю туда переменную $to_position, и приглядевшись к коду, видно, что это на самом деле - массив всех полей доски с фигурами.

Да уж, не очень удачный нейминг я выбрал. "Позиция" где-то понимается как расположение всех фигур, а где-то - как одна клетка на доске, т.е. "позиция фигуры". Эта ошибка никак не влияла на работоспособность кода в предыдущей статье, т.к. в методе isFieldUnderAttack стояла "заглушка". Но когда я стал программировать для этой статьи, ошибка сразу вылезла наружу.

Исправить её очень легко. Надо просто передать нужную переменную - $to_index. Итак, "проблемная" строка должна выглядеть так:

if ($this->board_position->isFieldUnderAttack($to_index, $this->enemy_color)) { 

Убираем повторы

В классе ChessGame (файл engine/chess_game.php) заметил, что три раза встречается одинаковая строчка:

$this->storage->saveGameState($this->game_state);

Причём одно из мест - метод saveGame, который из этой одной строки и состоит! Т.е. я ввёл этот метод, чтобы не дублировать эту длинную строчку, а вот использовать его наверное забыл. Так что ищем эту строку, и в двух местах заменяем её на на:

$this->saveGame();

Смещения хода фигур выносим в константы

В классах конкретных фигур мы использовали допусимые смещения, помещая их в переменные $shifts. При программировании для этой статьи, эти смещения опять понадобились. Так что я решил вынести их в константы классов, чтобы потом использовать из других мест. Заодно так избавимся от "копипаста" смещений для короля - они такие-же, как у ферзя. Т.е. проходим по классам фигур, массивы смещений выносим в константу, и используем эту константу там, где использовалась переменная.

Файл с классом слона будет выглядеть так:

engine/figures/bishop.php<?php

class Bishop extends Figure {
    const SHIFTS = array(array(-1, -1), array(-1, 1), array(1, -1), array(1, 1));

    public function getCandidateMoves() {
        return $this->getLongRangeCandidateMoves(self::SHIFTS);
    }
}

Файл с классом коня:

engine/figures/knight.php<?php

class Knight extends Figure {
    const SHIFTS = array(array(-2, -1), array(-2, 1), array(-1, -2), array(-1, 2), array(1, -2), array(1, 2), array(2, -1), array(2, 1));

    public function getCandidateMoves() {
        return $this->getShortRangeCandidateMoves(self::SHIFTS);
    }
}

Файл с классом ферзя:

engine/figures/queen.php<?php

class Queen extends Figure {
    const SHIFTS = array(
        array(-1, -1), array(-1, 0), array(-1, 1),
        array(0, -1), array(0, 1),
        array(1, -1), array(1, 0), array(1, 1)
    );

    public function getCandidateMoves() {
        return $this->getLongRangeCandidateMoves(self::SHIFTS);
    }
}

В классе короля используем смещения для ферзя. Весь файл приводить не буду, там несколько "длинных методов". Метод получения ходов-кандидатов выглядит так:

engine/figures/king.php// Метод отдаёт только поля простых перемещений. Возможность рокировок будет проверена в методе getAvailableMoves
public function getCandidateMoves() {
    return $this->getShortRangeCandidateMoves(Queen::SHIFTS);
}

В классе ладьи тоже несколько методов, не буду его полностью приводить (полностью его можно посмотреть на github-е). К константам в классе ладьи добавляем смещения:

engine/figures/rook.phpconst SHIFTS = array(array(0, -1), array(0, 1), array(-1, 0), array(1, 0)); 

И метод получения ходов-кандидатов будет выглядеть так:

engine/figures/rook.phppublic function getCandidateMoves() {
    return $this->getLongRangeCandidateMoves(self::SHIFTS);
}

Поле под атакой?

Итак, при генерации ходов, нам надо "отбраковывать" ходы, после которых наш король оказывается под атакой.

"Наш" - это король того цвета, для которого мы ищем возможные ходы.

Хорошая "новость" (это не должно быть новостью, если вы воспроизводили код) - мы уже проверяем допустимость позиции. И даже полностю проверяем возможность рокировки - король не должен быть под атакой, и при рокировке он не должен проходить поля, которые под атакой. Для проверки того, атаковано ли поле, мы вызываем метод isFieldUnderAttack класса BoardPosition.

Правда этот метод сейчас просто возвращает false, т.е. говорит, что поле не под атакой. Поэтому у нас сейчас король такой "смелый". Надо "всего лишь" исправить этот метод.

Как он будет работать? Возьмём поле, для которого надо определить, под атакой оно или нет, и будем смещаться от него по всем возможным направлениям, пока не упрёмся или в край доски или в фигуру. И если фигура "опасная" для данного направления, то поле - под атакой.

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

Итак, открываем файл engine/board_position.php, и переписываем метод isFieldUnderAttack:

engine/board_position.php// Метод возвращает булево значение - атаковано-ли поле с индексом $cell_index фигурами цвета $color
public function isFieldUnderAttack($cell_index, $color) {
    $col = Functions::positionToCol($cell_index);
    $row = Functions::positionToRow($cell_index);
    return $this->underKnightAttack($col, $row, $color) || $this->underPawnAttack($col, $row, $color) || $this->underLinearAttack($col, $row, $color);
}

Как обычно, понавызывали методов, которых ещё нет . В строке 5 возвращаем значение логического выражения, которое "на человеческом" можно перевести так: под атакой коня ИЛИ под атакой пешки ИЛИ под линейной атакой.

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

Поле под атакой коня?

Начнём с метода, определяющего, атаковано ли рассматриваемое поле конём. Метод используется только внутри класса так что сделаем его приватным.

engine/board_position.phpprivate function underKnightAttack($figure_col, $figure_row, $color) {
    foreach(Knight::SHIFTS as $shift) {
        $col = $figure_col + $shift[0];
        if ($col < 0 || $col >= BOARD_SIZE) {
            continue;
        }
        $row = $figure_row + $shift[1];
        if ($row < 0 || $row >= BOARD_SIZE) {
            continue;
        }
        $to_index = Functions::colRowToPositionIndex($col, $row);
        $to_figure = $this->position[$to_index];
        if (FG_KNIGHT + $color == $to_figure) {
            return true;
        }
    }
    return false;
}

Напомню - метод проверяет, атаковано-ли поле с координатами $figure_col, $figure_row, конём цвета $color.

В методе организуем цикл по всем "смещениям коня" - Knight::SHIFTS, не зря в начале статьи мы все смещения фигур вынесли в константы классов.

Внутри цикла - обычная наша схема проверки: для текущего смещения вычисляем вертикаль, горизональ, проверяем что это всё внутри доски. По координатам вычисляем индекс позиции (строка 11. Смотрим что за фигура в этой позиции. Если это конь с цветом оппонента, то наше поле находится под его атакой, и мы сразу возвращаем true из метода.

Если цикл закончен, и мы до сих пор не покинули метод, значит проверяемое поле не под атакой коня, и можно возвратить false.

Поле под атакой пешки?

Добавляем метод для проверки - атаковано-ли рассматриваемое поле пешкой:

engine/board_position.phpprivate function underPawnAttack($figure_col, $figure_row, $color) {
    $direction = ($color == COLOR_WHITE ? 1 : -1);
    $row = $figure_row+ $direction;
    if ($row < 0 || $row >= BOARD_SIZE) {
        return false;
    }
    $col = $figure_col - 1;
    if ($col >= 0) {
        $position_index = Functions::colRowToPositionIndex($col, $row);
        if ($this->position[$position_index] == FG_PAWN + $color) {
            return true;
        }
    }
    $col = $figure_col + 1;
    if ($col < BOARD_SIZE) {
        $position_index = Functions::colRowToPositionIndex($col, $row);
        if ($this->position[$position_index] == FG_PAWN + $color) {
            return true;
        }
    }
    return false;
}

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

иллюстрация проверки - атаковано ли поле пешкой

На картинке нам надо определить, находится ли центральное поле под атакой чёрных пешек. Стрелками показано какие поля надо проверить на наличие этих чёрных пешек.

Поле под линейной атакой?

Добавляем теперь метод для определения "линейных" атак:

engine/board_position.phpprivate function underLinearAttack($figure_col, $figure_row, $color) {
    return (
        $this->underAttackByShifts($figure_col, $figure_row, $color, Bishop::SHIFTS, array(FG_BISHOP, FG_QUEEN)) ||
        $this->underAttackByShifts($figure_col, $figure_row, $color, Rook::SHIFTS, array(FG_ROOK, FG_QUEEN))
    );
}

Здесь я вызываю новый метод (который сейчас напишем) - underAttackByShifts. Он будет определять, находится ли поле под атакой определённых фигур по определённым направлениям. Сейчас опишу передаваемые параметры, и всё станет понятным.

Первые два параметра - координаты проверяемого поля - колонка (вертикаль) и горизонталь. Третий - цвет фигур, угрозу от которых мы проверяем. Четвёртый параметр - массив смещений координат, т.е. фактически направления, по которым мы проверяем угрозу. И последний параметр - массив дальнобойных фигур, которые несут угрозу по рассматриваемым направлениям.

В строке 3 мы передаём в метод underAttackByShifts смещения слона, и в массиве фигур - слона и ферзя. Т.е. мы проверяем - атаковано ли поле по диагоналям со строны слона или ферзя. Аналогично, в строке 4 проверяем с помощью "ладьёвых смещений" атаковано ли поле по вертикалям или горизонтаям со стороны ладьи или ферзя.

А как-же атака со стороны короля? Где будет эта проверка? Это тоже будет в методе underAttackByShifts. Атакованность поля со стороны короля там будем проверять для всех смещений, но будем ограничивать расстояние одной клеткой - король ведь не дальнобойная фигура.

Метод underAttackByShifts

Мы уже описали в общих словах метод underAttackByShifts, добавляем его в класс:

engine/board_position.phpprivate function underAttackByShifts($figure_col, $figure_row, $color, $shifts, $dangerous_figures) {
    foreach($shifts as $shift) {
        list($shift_col, $shift_row) = $shift;
        $continue_shift = true;
        $col = $figure_col;
        $row = $figure_row;
        $distance = 0; // расстояние, на сколько мы отошли от рассматриваемой клетки - нужно для атак короля
        while ($continue_shift) {
            $col = $col + $shift_col;
            if ($col < 0 || $col >= BOARD_SIZE) {
                $continue_shift = false;
                continue;
            }
            $row = $row + $shift_row;
            if ($row < 0 || $row >= BOARD_SIZE) {
                $continue_shift = false;
                continue;
            }
            $distance++;
            $to_index = Functions::colRowToPositionIndex($col, $row);
            $figure = $this->position[$to_index];
            if ($figure == FG_NONE) {
                // поле пустое, можно дальше двигаться вдоль текущего направления
                continue;
            }
            // наткнулись на какую-то фигуру, дальше смещаться по текущему направлению нельзя
            $continue_shift = false;
            $to_figure_color = Functions::color($figure);
            if ($to_figure_color != $color) {
                // наткнулись на свою фигуру
                continue;
            }
            // наткнулись на чужую фигуру, надо узнать, представляет ли она опасность
            $figure_type = Functions::figureType($figure);
            if (in_array($figure_type, $dangerous_figures) || ($distance == 1 && $figure_type == FG_KING)) {
                return true;
            }
        }
    }
    // ни на что "опасное" не наткнулись, возвращаем false, т.е. поле не под атакой
    return false;
}

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

Если "фигура" - FG_NONE, т.е. поле пустое, то идём дальше вдоль смещения. Иначе - не идём, т.е. делаем условие продолжения не-истинным (строка 27). Если фигура - чужая, то проверяем, представлет ли она опасность. Проверка опасности - на строке 35:

if (in_array($figure_type, $dangerous_figures) || ($distance == 1 && $figure_type == FG_KING)) { 

Т.е., на словах это условие такое: (фигура - в списке опасных фигур ($dangerous_figures)) ИЛИ (клетка соседняя И фигура - король)

Мат и пат

Теперь король "боится" нападений, и если напасть на него, то список "допустимых" фигур будет включать только те ходы, которые избавляют короля от нападения. Если допустимых ходов нет, и сейчас ход компьютера, то в строке статуса будет сообщение: "Компьютер не смог найти ход". А что это значит? Если король компьютера под атакой, значит мы поставили ему мат и выиграли. Если он не под атакой, значит - пат, ничья.

Давайте реализуем эту логику. Компьютер записывает сообщение о невозможности найти ход в классе ChessGame, в методе makeComputerMove. Там и сделаем исправление. Вместо строки

$this->game_state->text_state = 'Компьютер не смог найти ход';
пишем:

engine/chess_game.phpif ($this->isKingUnderAttack($this->game_state->current_player_color)) {
    $text_state = 'Мат. Вы выиграли!';
} else {
    $text_state = 'Пат. Ничья.';
}
$this->game_state->text_state = $text_state;
$this->saveGame();

Здесь мы использовали ненаписанный пока метод isKingUnderAttack. Чуть ниже напишем его. Он будет определять, находится ли под атакой король, чей цвет передан в качестве параметра.

И, аналогично, если у человека не осталось возможных ходов, значит ему поставили мат или пат. Когда делать эту проверку? Можно это сделать сразу после хода компьютера, прямо в том-же методе makeComputerMove. После строки

$this->makeMove($move['from'], $move['to'], false);
пишем:

engine/chess_game.php// проверим - есть ли ходы у человека?
$available_moves = $this->generateAvailableMoves();
if (count($available_moves) === 0) {
    if ($this->isKingUnderAttack($this->game_state->current_player_color)) {
        $text_state = 'Мат. Компьютер выиграл';
    } else {
        $text_state = 'Пат. Ничья.';
    }
    $this->game_state->text_state = $text_state;
    $this->saveGame();
}

Т.е. после хода компьютера мы вызываем метод generateAvailableMoves для получения всех возможных ходов "текущего" игрока. А так как это происходит после хода компьютера, то "текущий" игрок - человек. Если ходов нет, то определяем - под ударом ли король "текущего" игрока, т.е. поставлен мат или пат.

Король под атакой?

Напишем теперь упомянутый метод isKingUnderAttack для определения - под атакой король или нет. У нас уже есть метод isFieldUnderAttack в классе BoardPosition, который определяет, под атакой ли поле с переданным индексом. Надо найти поле, на котором находится наш король, и передать в тот метод. Думаю, стоит вынести эту работу в сам класс BoardPosition.

Т.е. в классе BoardPosition добавим метод для проверки - атакован ли король определённого цвета, и воспользуемся им в методе isKingUnderAttack класса ChessGame.

Итак, в класс ChessGame добавляем метод:

engine/chess_game.phpprotected function isKingUnderAttack($color) {
    $board_position = new BoardPosition();
    $board_position->setPosition($this->game_state->position);
    return $board_position->isKingUnderAttack($color);
}

И в класс BoardPosition тоже добавляем одноимённый метод:

engine/board_position.php// Метод возвращает булево значение - атакован ли король цвета $color
public function isKingUnderAttack($color) {
    $cell_index = array_search(FG_KING + $color, $this->position);
    if ($cell_index === false) {
        return false;
    }
    $enemy_color = $color == COLOR_WHITE ? COLOR_BLACK : COLOR_WHITE;
    return $this->isFieldUnderAttack($cell_index, $enemy_color);
}

В строке 3 мы ищем короля цвета $color в позиции, т.е. в массиве $this->position. В строке 7 определяем цвет фигур оппонента, т.е. цвет фигур, угрозу от которых мы будем проверять. Ну и в строке 8 узнаём - находится ли поле $cell_index, на котором расположен наш король, под атакой фигур цвета $enemy_color.

Итоги

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

демо партии #11, король не лезет на атакованные поля

А здесь показан пример, когда мы поставили мат компьютеру:

демо партии #11 - мат компьютеру

Как обычно, исходные коды проекта на данном этапе можно посмотреть на github.

Демо №11 игры