В предыдущей части были сформулированы общие для всех фигур свойства и основные алгоритмы, которые позволят нам анализировать ситуацию на доске. Вот только как всё это реализовать в коде?
Фигуры
В 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