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

Фигуры

В Go можно представить некий объект как структуру:

type BaseFigure struct {
  	IsWhite         bool
	Type            byte
	CellCoordinates [2]int
}

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

func (figure *BaseFigure) ChangeCoordinates(newCoordinates [2]int) {
	figure.CellCoordinates = newCoordinates
}

Но зачем нужна обезличенная фигура? Почему нельзя сразу создать какую-нибудь пешку?

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

Поэтому структура пешки будет выглядеть так:

type Pawn struct {
	BaseFigure
}

Метод поиска ходов пешки будет реализован для структуры Pawn, а метод для смены координат для структуры BaseFigure и его не нужно прописывать каждый раз для каждого типа фигур. Методы BaseFigure будут просто наследоваться структурами конкретных фигур.

Так что есть фигура у нас в коде? BaseFigure или Pawn? Ни то и ни другое. Это интерфейс:

type Figure interface {
	IsItWhite() bool
	GetType() byte
	GetPossibleMoves(*Game) *TheoryMoves
	ChangeCoordinates([2]int)
	GetCoordinates() [2]int
	Delete()
}

То есть набор методов для вышеописанных структур. Именно такие интерфейсы будут прикреплены к конкретным координатам доски и с их помощью можно выудить всю необходимую информацию о фигуре, а также перемещать или вовсе удалить её.

Как мы видим, тут есть методыBaseFigure (такие как GetType) и методы конкретной фигуры (GetPossibleMoves).

Создаётся фигура следующим образом:

func CreateFigure(_type byte, isWhite bool, coordinates [2]int) Figure {
	var bf = BaseFigure{isWhite, _type, coordinates}

	switch _type {
	case 'p':
		return &Pawn{bf}
    ...
}

Игра

Внимательный читатель мог заметить в интерфейсе Figure нечто Game. Это состояние игры на n-ом ходу:

type Game struct {
	Figures       map[int]*Figure
	IsCheckWhite  IsCheck
	IsCheckBlack  IsCheck
	WhiteCastling Castling
	BlackCastling Castling
	LastPawnMove  *int
	Side          bool
}

Это тот необходимый минимум информации для анализа хода.

  • Figures - это фигуры на доске

  • Структуры IsCheck содержат информацию о том, есть ли шах сейчас на доске

  • Castling о рокировке

  • LastPawnMove о том, где находится пешка, сделавшая двойное перемещение ходом ранее (nil если предыдущий ход был иным)

  • Side — чей сейчас ход (чёрных или белых)

Координаты фигуры сохраняются как число от 0 до 63 (по числу полей на доске). Это число (id поля) при анализе трансформируется в координаты (x,y). Поэтому тут мы видим, что LastPawnMove — это *int, а не *[]int.

Планировалось ими (id полей) и оперировать при анализе. Но оказалось, что это очень неудобно, так как двумерное пространство мы пытаемся представить как одномерное. Как в такой ситуации понять, что id равное 8 это не первое поле во втором ряду, а на самом деле девятое в первом (то есть несуществующее на доске)? Решить эту проблему (не прибегая к координатам) можно, но не нужно.

Аналогично и с Figures, где id поля на доске это ключ, по которому достаётся фигура с этого поля (или nil если фигуры там нет).

Ход и шах

Пройдёмся теперь по реализации двух алгоритмов из прошлой части IsMoveCorrect и IsItCheck

func IsMoveCorrect(gameModel models.Game, board models.Board, from int, to int) ([]int, Game) {
	game := CreateGameStruct(gameModel, board)

	figure := game.GetFigureByIndex(from)

	if !game.IsItYourFigure(figure) {
		return []int{}, Game{}
	}

	possibleMoves := (*figure).GetPossibleMoves(&game)

	isCorrect, indexesToChange := CheckMove(possibleMoves, []int{from, to})
	if !isCorrect {
		return []int{}, Game{}
	}

	return indexesToChange, game
}

На момент вызова этой функции уже известно следующее:

  • пользователь, запрашивающий ход, это один из игроков и сейчас его ход

  • ход не противоречит устройству доски (поля from и to реально существуют)

  • текущее состояние игры — gameModel (информация о рокировках, предыдущем ходе и цвете ходящего игрока)

  • доска — board (массив из пар id поля и id фигуры)

Теперь нужно создать из первых двух аргументов структуру game := CreateGameStruct(gameModel, board)

Вытягиваем из game фигуру, которой будет совершаться ход, и проверяем, что эта фигура не nil (на этом поле есть фигура) и принадлежит игроку (проверка цвета).

Теперь есть всё необходимое для получения possibleMoves — массива возможных ходов для этой фигуры, но пока без проверки на шах ходящему игроку.

В дальнейшем можно будет использовать этот массив для создания дерева возможных ходов. Это позволит сделать систему подсказок, а оценка каждого хода и вовсе добавит возможность сыграть против машины.

CheckMove сверяет запрашиваемый ход с массивом возможных. Массив indexesToChange, который она возвращает, включает всё те же from и to — индексы полей, для которых надо будет совершить перестановку фигуры. Но в случае с рокировкой или взятием на проходе у нас происходят изменения для большего количества полей. Поэтому indexesToChange может нести в себе больше информации, чем при простом ходе.

func IsItCheck(indexesToChange []int, game *Game) bool {
	from := indexesToChange[0]
	to := indexesToChange[1]

	game.ChangeToAndFrom(to, from)

	if len(indexesToChange) > 2 {
		game.DeletePawn(indexesToChange)
		game.ChangeRookField(indexesToChange)
	}

	game.ChangeKingGameId(to)

	if game.Check() {
		return false
	}

	game.ChangeCastlingFlag(to)

	game.ChangeLastPawnMove(from, to)

	return true
}

IsItCheck идёт следом за IsMoveCorrect и проверяет game на состояние шаха. Структура Game уже создана, но она всё ещё соответствует предыдущему ходу.

game.ChangeToAndFrom(to, from) совершает "перестановку" ходящей фигуры. Сама фигура на самом деле не двигается. Просто одна удаляется (при взятии), а у второй (которая ходит) меняются координаты.

Если ход это рокировка или взятие на проходе, то длина indexesToChange больше двух и нужно либо удалить пешку противника, либо "переставить" ладью.

Если ходящая фигура это король, то сохраняем его текущее местоположение в game. Тогда нам не придётся искать его каждый раз при проверке на шах.

game.Check() — проверяем, есть ли шах на доске или нет по алгоритму, описанному в предыдущей статье.

Если ход корректен, то меняем и сохраняем информацию о рокировке и двойном перемещении пешки (если они имели место быть в текущем ходе).

Всё! Теперь можно сохранять в базу результаты работы (или возвращать ошибку, если ход был некорректным).

Что дальше?

Мы разобрались с тем, как реализована на Go основная механика игры. Однако совсем не поговорили о том, как в принципе работает сервер. Как и где сохраняется информация, как выглядит архитектура приложения и т.д.

Об этом в следующий раз)

Ссылки

Ссылка на весь проект

Проект находится в разработке, буду рад новым идеям и тем, кому было бы интересно заняться фронтовой частью.

В данный момент я ищу работу Golang разработчиком, очень жду ваших сообщений мне в тг: t.me/Gekko_Moria

Комментарии (13)


  1. Lev3250
    29.05.2024 10:19
    +2

    На го надо писать сервер для..... го


    1. IvanChernetskiy Автор
      29.05.2024 10:19
      +2

      Так и не полюбилась эта игра(
      Шахматы мне куда ближе. Но на реализацию го на go я бы глянул хотя бы из любопытства)


  1. yeswell
    29.05.2024 10:19

    Какие ещё проблемы возникают при анализе хода, если оперировать координатами фигур в виде чисел от 0 до 63?

    На Хабре есть статья, где анализ проводят именно в таком виде


    1. IvanChernetskiy Автор
      29.05.2024 10:19
      +2

      При анализе возникает неопределённость. Допустим, мы хотим посмотреть, куда может двигаться ладья. Напишем алгоритм, который ищет поля, чьи id соответствуют движению по вертикали и горизонтали. То есть написать какой-нибудь for, который будет имитировать движение по доске (а в реальности по массиву id) Но рано или поздно мы подходим к краю доски. Следовательно, по этому направлению мы должны остановить "движение". В случае с координатами всё просто - x и y меняются от 0 до 7. Но в случае с одномерным массивом мы не можем просто так понять вышли ли мы за пределы доски. Так как фигура просто телепортируется за на противоположный конец поля (например ладья с поля 7 на поле 8).

      Решить эту проблему можно. Например, анализировать не поле 8 на 8, а 12 на 12. То есть создать ''буферную зону" по краям, чьи id будут однозначно говорить, что это поле не игровое. 12 на 12 потому что конь прыгает через два ряда.

      Но зачем столько мороки, когда можно анализировать просто координаты?)


      1. yeswell
        29.05.2024 10:19

        Согласен, использование одномерных координат выглядит для анализа и правда выглядит переусложнённым


      1. GlukKazan
        29.05.2024 10:19
        +3

        Ну там много чего придумано, на самом деле.

        https://www.chessprogramming.org/Garbochess


      1. Zibx
        29.05.2024 10:19
        +1

        Сейчас координаты каждой фигуры занимают 16 байт в памяти, а могли бы занимать 1. Можно использовать не номера ячеек на доске, а паковать XXXXYYYY в один байт. Любой алгоритм анализа будет очень рад если значения будут умещаться в регистры процессора.


        1. IvanChernetskiy Автор
          29.05.2024 10:19
          +1

          Изначально я писал шахматную логику "вслепую", было интересно самому реализовать всё, не подсматривая, и по-спотыкаться о подводные камни. Потом уже стал сравнивать)

          Мне очень понравилась идея записи состояния игры с личесс. Раньше я там просто играл и не пользовался дополнительным функционалом. Можно зайти в 'инструменты' -> 'редактор доски' и увидеть записи вида:

          rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1

          Это состояние соответствует начальной расстановке фигур на доске)


          1. GlukKazan
            29.05.2024 10:19

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


          1. GlukKazan
            29.05.2024 10:19

            rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1

            Очень удобная нотация. И пригодная не для одних только Шахмат.


  1. ALexKud
    29.05.2024 10:19

    Это конечно интересно, но это механика. Гораздо сложнее придумать эвристики оценочной функции для ограничения перебора ходов. По моему убеждению шахматы это на 90% геометрия и на 10% комбинаторика. У меня даже возникла идея разработки движка на SQL/noSQL под это предположение. Доска как таблица, позиция как ячейки-паттерны с узлами различных свойств и весов. А идея такова что нужно определить, возможна ли такая эвристика, которая оценивает позицию на один ход без перебора ходов в процессе игры. Комбинаторика, или перебор ходов включается только тогда когда эвристики скажут что здесь нужно считать. Допустим когда есть предпосылки комбинации или перехода в выигранный эндшпиль и т д и тп. Вот это было бы интересно для меня. В шахматы играю давно, есть победа в сеансе над мастером. Имел когда-то первый разряд. Играю на личесс через смартфон, но больше в процессе перемещения на работу и обратно. Время течёт незаметно тогда. На SQL с логикой в хранимых процедурах разработал несколько систем, одна из них описана в моей статье на этом сайте.


    1. IvanChernetskiy Автор
      29.05.2024 10:19

      О, а можно ссылку на вашу статью? Было бы интересно почитать.
      Про шахматы по дороге это жиза. Только я с телефона на chess.com играю. А на личесс с ноута в комфортных условиях, поэтому там рейтинг выше)


  1. ALexKud
    29.05.2024 10:19

    Тапните по моему Нику в сообщении, на моей странице публикация на хабре. Ссылка вставляется но сообщение не отправляется