Пишем код - Шахматы
Предыстория: когда-то давно я реализовывал шахматы в рамках лабораторной работы в университете. Целью была практика работы с сетевыми взаимодействиями через сокеты Unix, а также изучение ООП, так как реализация была на C++. Играли через терминал shell, а вместо фигур были буквы. Но все правила были реализованы строго. Сейчас я хочу повторить то же, но уже на Java. Постараюсь соблюдать процесс, которому бы я следовал, если бы это был не искусственный пример, а более-менее полноценная коммерческая разработка.
Пост будет длинным и довольно подробным, но весь объём кода в него не поместится, поэтому предлагаю сразу скачать проект с github и открыть его в IDE. Здесь же буду приводить наиболее важные и показательные отрывки кода, основной уклон делая на ход рассуждений и принципы принятия решений.
Уточнение требований. Общий дизайн приложения.
В начале команда собирается вместе в уютной аудитории с белой доской и маркерами. Созывать видео конференцию хуже, а простой телефонный разговор - это уж совсем скучно и неэффективно. Нужно познакомиться с задачей, обсудить и уточнить требования, прикинуть сроки и возможные риски. В общем, нам требуются простенькие шахматы, работающие в браузере, без регистрации и аутентификации. Таймер решили пока не делать, сложные правила вроде ничьи по требованию игрока при повторении игровой ситуации более двух раз тоже пока не нужны. Интерфейс должен работать быстро, никаких пауз по полсекунды после хода игрока не должно быть. Сценарий начала игры один - тот, кто играет белыми заходит на главную страницу, копирует ссылку и отправляет второму игроку. Когда второй игрок переходит по полученной ссылке, начинается игра. Интерфейс не должен быть совсем деревянным, нужно подсвечивать возможные ходы фигур и не допускать невозможных. Также требуется защита от простейшего взлома - нельзя походить за соперника, влезть в чужую игру или снять фигуру с доски, когда соперник отвернется.
Таковы в общих чертах бизнес требования. Если все согласны, то аналитики, команда тестирования и менеджер могут быть свободны или остаться, если им интересно, а разговор переходит к технической части. Вебсокеты! Html5 Unreal Engine! Kotlin! Kafka! Kubernetes! Смелые и осторожные, рациональные и безумные, традиционные и новаторские - всевозможные идеи, словно лозунги, доносились из разных сторон комнаты. Верите? Конечно, нет. Делаем на стандартном стеке, ничего лишнего, ничего нового. Слушали, постановили:
- Frontend - html + jquery
- Backend - Java 11, Spring Boot (web), Junit
- Никакой сессии или cookie. Закрыл вкладку в браузере - вышел из игры.
- Вебсокеты это слишком, привычный REST подойдет
- На стороне клиента не будет никакой логики. Какой фигурой и на какое поле можно походить сообщает сервер. При этом не должно быть пауз и подгрузок при выборе фигуры.
- У каждого игрока будет свой персональный токен, который будет передаваться с сервера при начале игры. Этот токен будет необходим при каждом ходе и будет передаваться в заголовке запроса.
- Работу над сервером и клиентом начинаем одновременно
Сервер (Back-end)
Задача хоть и понятная, но достаточно объёмная. Ясно, что правила так или иначе мы реализуем. Конь ходит буквой Гэ, если при этом не выйдет за границы доски. И на этом поле нет другой фигуры того же цвета. И если после хода не открывается король. Рокировка возможна, если ни одно из полей, которые пересекает король, не находится под боем и если король и ладья в этой партии ещё не ходили. Взятие на проходе возможно только на следующий ход, после этого право теряется. Когда пешка достигает последней линии, она повышается до одной из четырёх фигур. Короче говоря, работа кропотливая и количество условий и проверок огромно. Наша цель - написать код с минимальным количеством ошибок, хорошей читаемостью и структурой.
Вторая важная часть - жизненный цикл игры. Где хранить состояние, как обрабатывать начало игры, ожидание хода соперника, параллельные игровые сессии? Архитектор сказал, что нужно использовать какой-то "Long polling". Пожалуй, придётся и с многопоточностью поработать.
А что с самим интерфейсом, то есть с форматом данных? Фигуры, цвета, ходы... Фронтенд команда просит побыстрее предоставить первую версию сервиса, чтобы им было с чем работать и не сооружать моки (mocks). С этого тогда и начнём, а в процессе глубже вникнем в задачу и наметим план дальнейших действий.
Попробуем спроектировать сервис, предоставляющий данные о состоянии партии. Понятно, что информация, которую мы должны передавать включает в себя положение всех фигур на доске. Также важно, на какое поле можно переместить каждую фигуру на своём ходу, ведь мы решили, что клиент будет "тонким", то есть без логики. Нужно указать, чей сейчас ход, последний ход соперника. Важно, продолжается ли партия и объявлен ли шах.
Кроме того, какие данные мы включаем в ответ, важен также и формат. Плохой формат необоснованно увеличит размер сообщения и усложнит разработку клиентской части. Также может снизиться эффективность, но для доски размером 8*8 это несущественно, поэтому сделаем акцент на удобстве использования и понятности нашего сервиса.
Все классы, являющиеся телом ответа (@ResponseBody, эта аннотация включена в @RestController), я собрал в отдельном пакете api.model и добавил к их именам постфикс "Dto". Я пользуюсь следующей конвенцией имён - бизнес сущности не имеют никакой специфики. К классам JPA добавляется окончание "Entity", а для REST интерфейса - Dto. Преобразовывать классы разных уровней приходится вручную. На одном из проектов, с которым я работал, все три случая обрабатывались одним классом и мне это показалось очень неудобным и хрупким подходом. В таком классе по умолчанию случайный геттер сразу попадает в JSON, а любое поле Hibernate пытается записать а базу. Когда с этим неизбежно возникает проблема, приходится расставлять повсюду всевозможные @JsonIgnore, transient и так далее. Кроме того, аннотации разных библиотек смешиваются вместе, а любые изменения нужно проверять на всех уровнях.
Но вернёмся к шахматам. Я решил передавать только фигуры, присутствующие на доске. Пустые поля клиент должен определить сам методом исключения. Каждый раз передаётся полное состояние, а не только разница с предыдущим ходом. Позиции фигур будут строкой из двух символов ("e2", "f1") - это не очень удобно в коде сервера, зато очень кратко, естественно и понятно всем. Забегая вперед, скажу, что в самом движке позиция будет представлена классом из двух целых чисел от 1 до 8. Для типов фигур и цвета лучше всего подойдут перечисления (Enum), рассчитываем, что jackson по умолчанию успешно осуществит сериализацию и десериализацию enum в строку. (Для сравнения - JPA по умолчанию превращает enum в число и нужно указывать аннотацию @Enumerated(EnumType.STRING).
Пока что создадим один сервис, с информацией о состоянии игры до первого хода. Вот, что получилось у меня. Это уже финальный результат, но я действительно начал с этого сервиса и изменения в формате данных минимальны. Из существенного - в первой версии я смоделировал позицию отдельной сущностью, но потом заменил на просто строку, чтобы Javascript код, работающий с этими данными был проще.
GameStateDto - корневой класс. В нём список всех фигур, цвет текущего игрока, последний ход оппонента.
Потом были добавлены ещё несколько вспомогательных полей для обработки окончания партии, но сейчас они не важны.
public class GameStateDto {
private List<PieceDto> pieces;
private ColorDto currentPlayer;
private MoveDto lastOpponentMove;
...
}
public class PieceDto {
private String position;
private ColorDto color;
private PieceTypeDto pieceType;
private List<String> validMoves;
...
}
public enum ColorDto {
BLACK, WHITE
}
public enum PieceTypeDto {
BISHOP,
KING,
KNIGHT,
PAWN,
QUEEN,
ROCK;
}
{
"pieces": [
{
"position": "e2",
"color": "WHITE",
"pieceType": "PAWN",
"validMoves": [
"e3",
"e4"
]
},
{
"position": "f7",
"color": "BLACK",
"pieceType": "PAWN",
"validMoves": null
}
],
"currentPlayer": "WHITE",
"lastOpponentMove": null
}
Думаю, что это разумный максимум того, что мы можем предоставить команде, занимающейся клиентом до завершения реализации сервера. Так что теперь приступим к реализации правил и сервисов управления жизненным циклом игры (создание игры, подключение, ход, ожидание хода игрока, завершение партии). Игровой "движок", отвечающий за проверку всех правил игры - более сложная и объёмная составляющая, поэтому начну с него.
Game engine
Реализация правил игры состоит из базовых типов и управляющих конструкций языка. Здесь нет взаимодействия с базой данных, веб-сервисов, многопоточности, масштабируемости, контейнеров и фреймворков. Только несколько классов и функций с проверками, циклами, конструкциями switch. Кроме того, алгоритмы очень простые, не требующие оптимальности ввиду маленького игрового поля, а также не использующие никаких сложных структур данных. Код, который нам нужно написать должен проверять, что слон ходит по диагонали и не перепрыгивает через другие фигуру того же цвета. Ладья - по вертикали и горизонтали. Пешка - на одно или два поля вперёд, а атакует по диагонали. Не забываем про рокировки и взятие на проходе (en passant). В общем, много отдельных случаев, каждый из которых прост, но сложно заставить всё работать правильно сразу. Хочется минимизировать риск ошибок, таких как: для белых проверяем, а для чёрных забыли; правую границу проверяем, а левую нет; пешка ходит не в ту сторону. В общем, всех ошибок, которые возможны когда в длинной цепочке проверок и сравнений нескольких чисел потеряли одну, перепутали меньше-больше или плюс с минусом.
Давайте посмотрим на вот такой код. Что он делает, работает ли он? Сколько времени потребуется, чтобы разобраться с ним тому, кто его не писал?
private List<Position> tricky(int x1, int y1) {
List<Position> result = new LinkedList<>();
for (int x2 = 1; x2 <= 8 ; x2++) {
for (int y2 = 1; y2 <= 8; y2++) {
int mx = Math.abs(x1 - x2);
int my = Math.abs(y1 - y2);
if( (mx + my == 3) && (mx * my == 2)
&& Math.abs(x2 - 4.5) < 4.5
&& Math.abs(y2 - 4.5) < 4.5) {
result.add(Position.of(x2,y2));
}
}
}
return result;
}
x - в границе поля.
1 <= x <= 8, x - целое число, следовательно
0 < x < 9, следовательно,
-4.5 < x - 4.5 < 4.5, то есть
| x - 4.5 | < 4.5
Я постарался в своей реализации минимизировать операции с целыми числами и проверки границ поля. Вся логика приложения "раскручивается" от четырёх операций с объектом класса Position:
up1, down1, left1, right1. Up1 - значит на одно поле вверх. Понятие "вверх" одинаково для игрока за черных и за белых.
public class Position {
//from 1 to 8
private final int x;
private final int y;
...
public Position up1() {
return (y < 8) ? of(x, y + 1) : null;
}
public Position down1() {
return (y > 1) ? of(x, y - 1) : null;
}
...//right1, left1 - аналогично
public Stream<Position> up() {
return Stream.iterate(this, Objects::nonNull, Position::up1).skip(1);
}
Чтобы описать поведение коня, понадобится вспомогательная функция, применяющая несколько одинарных изменений позиции.
По сути применяем move.apply(finalPosition) пока не закончатся элементы в массиве moves или операция не вернёт null.
private Position move(UnaryOperator<Position>... moves) {
Position finalPosition = this;
for (int i = 0; i < moves.length && (finalPosition != null); i++) {
UnaryOperator<Position> move = moves[i];
finalPosition = move.apply(finalPosition);
}
return finalPosition;
}
public List<Position> knight() {
List<Position> moves = new LinkedList<>();
moves.add(move(Position::up1, Position::up1, Position::right1));
moves.add(move(Position::up1, Position::up1, Position::left1));
moves.add(move(Position::down1, Position::down1, Position::right1));
moves.add(move(Position::down1, Position::down1, Position::left1));
moves.add(move(Position::right1, Position::right1, Position::up1));
moves.add(move(Position::right1, Position::right1, Position::down1));
moves.add(move(Position::left1, Position::left1, Position::up1));
moves.add(move(Position::left1, Position::left1, Position::down1));
moves.removeIf(Objects::isNull);
return moves;
}
@Test
void testKnight1() {
Position p = Position.of(1, 1);
List<Position> kn = p.knight();
assertEquals(2, kn.size());
assertTrue(kn.contains(Position.of(3, 2)));
assertTrue(kn.contains(Position.of(2, 3)));
}
@Test
void testKnight2() {
Position p = Position.of(4, 3);
Set<Position> kn = new HashSet<>(p.knight());
assertEquals(8, kn.size());
}
Подобным образом в классе Position реализованы все перемещения фигур, которые не зависят от других фигур на доске или хода партии.
Сейчас мы работаем над тем, чтобы для каждой фигуры определить поля, на которые активный игрок вправе её переместить. Для продолжения нужно
научиться хранить все фигуры. За это отвечает класс Board.
public class Board implements Cloneable {
final Map<Position, Piece> whitePieces = new HashMap<>();
final Map<Position, Piece> blackPieces = new HashMap<>();
...
}
public class ChessGame {
final PieceColor currentPlayer;
final List<Board> previousStates;
final List<PieceMove> previousMoves;
final Board board;
...
}
public abstract class Piece {
final Position position;
final PieceColor pieceColor;
final Type pieceType;
...
public abstract Set<Position> validPieceMoves(ChessGame game);
public final Set<Position> finallyValidMoves(ChessGame game) {
Set<Position> moves = validPieceMoves(game);
//remove target positions where same team pieces are present
moves.removeIf(pos ->
game.at(pos).map(piece -> piece.pieceColor == pieceColor).orElse(false));
//remove target positions where king will be in trouble after move
PieceColor currentPlayer = game.getCurrentPlayer();
moves.removeIf(m -> game.applyMoveNoValidate(new PieceMove(position, m)).kingUnderAttack(currentPlayer));
return moves;
}
}
Код классов Queen, Bishop, Knight, Rock - однотипный и тривиальный:
public class Queen extends Piece {
...
@Override
public Set<Position> validPieceMoves(ChessGame game) {
return position.moveUntilHit(position.queen(), game, pieceColor);
}
...
}
Map<PieceColor, UnaryOperator<Position>> MOVE_FOWARD = Map.of(
PieceColor.WHITE, Position::up1,
PieceColor.BLACK, Position::down1
);
private Optional<Position> enPassant(ChessGame game) {
if (!ENPASSANT_LINE.get(pieceColor).equals(position.getY())
|| game.getPreviousMoves().isEmpty()) {
return Optional.empty();
}
UnaryOperator<Position> moveForward = MOVE_FOWARD.get(pieceColor);
UnaryOperator<Position> moveBackward = MOVE_BACKWARD.get(pieceColor);
Position fl = moveForward.apply(position).left1();
Position fr = moveForward.apply(position).right1();
for (Position forwardAttack : new Position[]{fl, fr}) {
if (forwardAttack != null) {
Position near = moveBackward.apply(forwardAttack);
Position nearFrom = moveForward.apply(forwardAttack);
PieceMove lastOppMove = game.getPreviousMoves().get(game.getPreviousMoves().size() - 1);
if (game.at(near).map(p -> p.getPieceType() == Type.PAWN && p.pieceColor != pieceColor).orElse(false)
&& lastOppMove.getFrom().equals(nearFrom)
&& lastOppMove.getTo().equals(near)) {
return Optional.of(forwardAttack);
}
}
}
return Optional.empty();
}
Так выглядит тест, проверяющий, что единственный возможный ход для белого коня - атаковать чёрного коня, чтобы снять шах с короля.
В тестах много вспомогательных функций, чтобы сделать их более читаемыми.
@Test
void testValidMovesKnight1() {
Piece whiteKing = new King(Position.of(2,2), PieceColor.WHITE);
Piece blackKnight = new Knight(Position.of(4,3), PieceColor.BLACK);
Piece whiteKnight = new Knight(Position.of(5,5), PieceColor.WHITE);
Board board = new Board(Arrays.asList(whiteKing, whiteKnight, blackKnight));
ChessGame chessGame = new ChessGame(PieceColor.WHITE, Collections.emptyList(), Collections.emptyList(), board);
Set<Position> validMoves = whiteKnight.finallyValidMoves(chessGame);
assertEquals(Sets.newSet(blackKnight.getPosition()), validMoves);
}
Возможность для каждой фигуры определить список разрешённых ходов позволяет легко реализовать проверки окончания партии, шаха и мата.
public boolean kingUnderAttack(PieceColor player) {
Set<Position> attackPositions =
board.pieces(player.negate()).stream()
.flatMap(p -> p.validPieceMoves(this).stream())
.collect(Collectors.toSet());
return attackPositions.contains(board.king(player));
}
public void updateGameStatus() {
this.validMovesForCurrentPlayer = validMovesForCurrentPlayer();
boolean kingAttacked = kingUnderAttack(currentPlayer);
boolean canMove = validMovesForCurrentPlayer.values().stream().anyMatch(s -> !s.isEmpty());
if (canMove && kingAttacked) {
status = GameStatus.CHECK;
}
if (!canMove && kingAttacked) {
status = GameStatus.CHECKMATE;
finished = true;
}
if (!canMove && !kingAttacked) {
status = GameStatus.DRAW_STALEMATE;
finished = true;
}
}
Я очень интенсивно использую Optional и Stream, поэтому полагаю, что код можем показаться сложнее, чем если бы те же проверки осуществлялись простыми циклами и проверками if-else. В первом случаем код получается более декларативным, то есть он описывает "что" нужно сделать, а второй - императивный, то есть "как" сделать, указав конкретные шаги. Декларативный подход чаще всего лучше, но к нему нужно привыкнуть.
Когда поведение всех фигур, а также проверки окончания партии реализованы, можно написать тест, проверяющий всё в сборе.
Например, убедимся, что детский мат - действительно приводит к победе в три хода:
@Test
void testMoves() {
ChessGame game = ChessGame.startGame();
List<PieceMove> moves = movesFromString("e2e4 e7e5 d1h5 b8c6 f1c4 g8f6 h5f7").collect(Collectors.toList());
for (PieceMove move : moves) {
game = game.applyMove(move);
}
assertTrue(game.isFinished());
assertEquals(GameStatus.CHECKMATE, game.getStatus());
}
Мы всё ещё не можем быть уверены, что ошибок нет, очень много игровых ситуаций не проверено ни вручную, ни автоматически, хотя с учётом ещё нескольких тестов, которые я не упомянул, покрытие строк кода почти полное. Но покрытие строк далеко от покрытия всех сценариев. Как же быть? Как правило, тестирование проводит отдельная команда, которая собирает и поддерживает достаточный список проверок приложения (все или основные бизнес сценарии). По возможности эти проверки автоматизируются, но это уже не юнит тесты, а проверки на живом окружении, в котором приложение работает в условиях, близких к продакшену. Давайте подумаем, как бы мы могли улучшить тесты, чтобы увеличить уверенность в том, что реализация работает верно. Идею написать больше однотипных тестов оставим без рассмотрения как очевидную. Один из вариантов - если бы существовала гарантированно правильная реализация, то мы могли бы сравнить поведение на большом количестве случайных партий. Это очень удачный случай, но не часто будет такая реализация, которую мы можем назвать эталонной, можем использовать в тесте, но не в коде. Другой вариант - если бы удалось получить архив реальных шахматных партий в текстовом формате - тогда можно проверить, что все ходы и результаты партий согласуются с нашей реализацией. Правда, так мы проверим только заведомо разрешённые ходы. Сейчас же в плане юнит тестов для правил остановимся на достигнутом.
На данный момент приложение умеет:
- Создавать стартовое игровое поле с 16 фигурами
- Определять, на какие поля игрок может переставить каждую фигуру
- Проверять, разрешен ход или нет (используя предыдущий пункт)
- Менять состояние игрового поля, применяя перемещение фигуры
- Определять окончание партии победой игрока или вничью
Однако, весь процесс пока не имеет никакой связи с REST сервисом, который будет использоваться игровым клиентом.
REST
Для начала подумаем какие данные, какому игроку, в какой момент и в каком объёме нужно предоставить. Что касается объёма данных, то, к счастью, оба игрока владеют полной информацией об игровом поле и ходе партии, в отличии, например, от игры в морской бой. Также мы уже условились, что игровое поле передаётся целиком каждый раз, а не только те поля, которые изменили своё состояние. Очевидно, что после завершения хода, то есть когда пользователь кликнет на поле или отпустит кнопку при переносе фигуры, должен быть вызван какой-то сервис для передачи информации о ходе. Ещё нужен способ передать данные от сервера к клиенту, когда оппонент завершит ход. Это несвойственно протоколу HTTP, но необходимо для достижения требований: ход должен передаваться без пауз, а если мы будем периодически опрашивать сервер, даже раз в секунду, то пауз не избежать.
Обсудим этот вопрос подробнее. Мне известны три способа передачи данных от сервера к клиенту-браузеру. По крайней мере, различных статьях сравнивают именно эти три подхода.
- Websockets
- Server sent events
- Long polling
Я остановился на Long Polling, так как раньше он использовался в Facebook (может и сейчас используется) для доставки личных сообщений и обновлений ленты, что очень похоже на нашу задачу по своей природе. Не хочу описывать эти подходы подробно, потому что получится не точно, опыта работы с ними у меня нет. Я понимаю области их применения так: websockets - онлайн игры с интенсивным трафиком, где дорог каждый байт и каждая миллисекунда. Минусы - слишком низкоуровневый протокол, нужно делать собственную надстройку. Может не поддерживаться определёнными браузерами, желательно использовать библиотеки, которые в случае чего переключаются на HTTP, что дополнительно усложнит код и тестирование. Server sent events - хорошо подходит для просмотра графиков биржи с обновлениями в режиме реального времени. Многократные однотипные события через небольшие промежутки времени. Определённым образом ограничивает количество соединений - это требует дополнительного изучения. Long Polling - простой трюк использования протокола HTTP - сервер возвращает данные когда они появляются, а клиент работает как с обычным очень медленным запросом. Главное не попасть на таймауты от прокси сервера, но это решается периодическим повторением запроса. Для нашей задачи, в которой ответ может прийти через секунды, минуты или часы, подходит лучше всего.
Теперь, выбрав Long polling в качестве способа ожидания действия соперника, наш сервис обретает две механики: запросы, которые обрабатываются сразу и запросы, которые обрабатываются неопределённо долго.
Я сравнительно долго подбирал количество сервисов и распределял задачи между ними. Пришлось учитывать то, как будет работать клиент, включая начало игры одним пользователем и последующим подключением второго, после которого сразу должна начаться игра. От интерактивного взаимодействия между игроками решил пока отказаться, то есть нет чата, возможности сдаться или предложить ничью. Всё равно чтобы начать игру один игрок как-то отправляет ссылку второму, значит общий чат у них уже есть.
Так как число одновременных партий никак не ограничено, каждая игра имеет уникальный идентификатор.
Чтобы отличать игроков друг от друга, каждому выдаётся свой уникальный (и секретный) токен, который генерируется классом UUID.
Так решаются две задачи - во-первых, защита партии от неавторизованных вызовов (ведь процесса аутентификации у нас нет), а во-вторых, идентификация и отличие игрока белыми и игрока чёрными.
Напомню, что сейчас информация о партии хранится в классе ChessGame, в котором нет упоминания идентификаторов или токенов.
Для этого введём дополнительный класс ChessGameMetadata.
public class ChessGameMetadata {
private String id;
private Map<PieceColor, String> playerTokens = generateTokens();
private ChessGame chessGame;
private boolean secondPlayerJoined = false;
public class GameConnectionParamsDto {
private String id;
private String token;
private ColorDto playerSide;
private GameStateDto gameState;//состояние игры также передаётся, чтобы не заставлять клиента делать лишний вызов.
...
}
С учётом всего вышесказанного, полный жизненный цикл игры обеспечивается с помощью четырёх методов (endpoint). Все методы изменяют состояние сервера, поэтому используют HTTP метод POST, даже если тело запроса не содержит параметров.
- /host Немедленно создаёт новую игру и возвращает GameConnectionParamsDto с id игры и токеном игрока за белых. Тело запроса пустое. Заголовки запроса отсутствуют. В будущем в тело запроса можно добавить настройки игры: таймер, сторону.
- /{id}/join Находит игру по идентификатору "id" и немедленно возвращает GameConnectionParamsDto с id игры и токеном игрока за чёрных. Тело запроса пустое. Заголовки запроса отсутствуют.
- /{id}/wait-for-my-move Long polling. Если вызвать на своем ходу, то немедленно возвращает GameStateDto. Если на чужом - то запрос "повисает" до тех пор, пока соперник не сделает ход. Таймаут на сервере установлен в 60 минут, после чего запрос завершится со статусом 408. Впрочем, реализация клиента повторяет запрос намного раньше, каждые пять минут. Тело запроса пустое. Заголовок запроса включает токен игрока. Чтобы не создавать отдельный сервис, подключение второго игрока считается первым ходом чёрных.
- /{id}/move Находит игру по id, изменяет её состояние, совершая один ход и немедленно возвращает обновленное состояние в виде GameStateDto. Тело запроса соответствует классу MoveDto, который включает два поля (from, to) и строку promotion, которая заполняется для повышения пешки, достигшей края поля. Заголовок запроса включает токен игрока. Гипотетически, можно было бы объединить c /wait-for-my-move, сразу инициируя ожидание хода коперника. Но тогда клиент должен уметь определить состояние доски после хода, а в случае рокировок и взятий на проходе это не тривиально и потребует логики на клиенте.
Использовать эти сервисы на стороне клиента предполагается следующим образом:
- Первый игрок открывает главную страницу, JS сразу выполняет запрос /host, получает id игры и формирует ссылку для второго игрока, также отправляет запрос /wait-for-my-move
- Второй игрок открывает ссылку. Это та же главная страница, но с параметром запроса ?joinid={id}. JS получает id игры из параметров URL и отправляет запрос /{id}/join. Результат запроса содержит стартовое игровое поле в ожидании хода белых. JS отображает это поле в браузере и отправляет запрос /wait-for-my-move в ожидании хода белых.
- Как только завершается вызов /join, первый запрос /wait-for-my-move возвращает результат, сообщая о том, что второй игрок подключился и партия началась. Первый игрок думает, потом совершает ход, что приводит к вызову /move и следом /wait-for-my-move.
- Как только завершается вызов /move, второй запрос /wait-for-my-move возвращает результат, сообщая о том, что первый игрок совершил ход, и ход переходит к чёрным. Второй игрок думает, потом совершает ход, что приводит к вызову /move и следом /wait-for-my-move.
- Цикл завершается, если в объекте GameStateDto поле gameFinished становится равным true.
Контракт готов, теперь можно приступать к реализации. Отмечу, что в процессе разработки сервисы дорабатывались и перерабатывались, то есть сейчас я описываю финальную версию всех сервисов, но когда я писал код, то итеративно вносил изменения, потому что изначальная структура не получилась достаточно стройной. Например, в первой версии host и join не содержали состояние игрового поля. Также идея рассматривать join как первый ход второго игрока пришла не сразу.
Перейдём к коду. Чтобы не откладывать на потом и не вносить изменения в уже протестированные сервисы, начнём с обработки ошибок.
Для это вводим несколько собственных классов исключений, а с помощью аннотации @ExceptionHandler отдельно от основной реализации размещаем код, который для каждого типа ошибки
устанавливает соответствующий HTTP статус.
@ExceptionHandler(GameNotFoundException.class)
public ResponseEntity<?> handleNotFound() {
return new ResponseEntity<Object>("game not found", new HttpHeaders(), HttpStatus.NOT_FOUND);
}
@ExceptionHandler(InvalidMoveException.class)
public ResponseEntity<?> handleInvalidMove() {
return new ResponseEntity<Object>("bad move", new HttpHeaders(), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<?> handleInvalidToken() {
return new ResponseEntity<Object>("bad token", new HttpHeaders(), HttpStatus.FORBIDDEN);
}
С ошибками разобрались, давайте попробуем реализовать host. Нужно расставить фигуры, сгенерировать токен и где-то сохранить результат для дальнейшего использования.
Чтобы не завязываться на класс контроллера, добавим интерфейс и реализацию репозитория игр. Так мы абстрагируемся от способа хранения игр - в памяти,
в базе данных, в распределённом кэше или на файловой системе - за это будет отвечать реализация, а контроллер об этом знать ничего не будет.
public interface GameRepository {
ChessGameMetadata newGame();
Optional<ChessGameMetadata> find(String id);
void save(ChessGameMetadata chessGameMetadata);
}
@Repository
public class InMemoryGameRepository implements GameRepository {
private Map<String, ChessGameMetadata> metadataMap = new ConcurrentHashMap<>();
@Override
public ChessGameMetadata newGame() {
ChessGame chessGame = ChessGame.startGame();
ChessGameMetadata chessGameMetadata = new ChessGameMetadata();
chessGameMetadata.setChessGame(chessGame);
chessGameMetadata.setId(generateId());
metadataMap.put(chessGameMetadata.getId(), chessGameMetadata);
return chessGameMetadata;
}
@Override
public Optional<ChessGameMetadata> find(String id) {
return Optional.ofNullable(metadataMap.get(id));
}
@Override
public void save(ChessGameMetadata chessGameMetadata) {
metadataMap.put(chessGameMetadata.getId(), chessGameMetadata);
}
...
}
Те сервисы, которые возвращают результат сразу реализованы стандартно через Spring web:
@PostMapping("/host")
public ResponseEntity<?> host() {
ChessGameMetadata metadata = repository.newGame();
String whitePlayerToken = metadata.getPlayerTokens().get(PieceColor.WHITE);
return ResponseEntity.ok(new GameConnectionParamsDto(metadata.getId(), whitePlayerToken,
ColorDto.WHITE, metadata.getGameStateDtoForPlayer(whitePlayerToken)));
}
А вот Long Polling - вещь нетривиальная, и на ней остановимся подробнее.
В первую очередь, чтобы не занимать поток исполнения веб сервера (tomcat) в качестве возвращаемого результата используется
не Dto объект, а специальный DeferredResult:
@PostMapping("{id}/wait-for-my-move")
public DeferredResult<?> waitForMove(@RequestHeader("ptoken") String playerToken,
@PathVariable("id") String id)
Изолируем задачу: в один момент времени создаётся объект deferredResult в одном потоке. Затем после наступления определённого события нужно выполнить действие setResult. Приведём к стандартной модели Publish-Subscribe. Один поток подписывается на событие - завершение хода другим игроком, а второй поток публикует это событие как только вызывается метод сервиса /move.
Какие же варианты реализации Publish-Subscribe существуют? Вообще их безумно много.
- Wait-notify. На собеседованиях иногда предлагают реализовать ping-pong между двумя потоками. Полезно в образовательных целях, в реальных проектах нежелательно, слишком легко допустить ошибку, которая проявится в самый неподходящий момент.
- Блокирующие очереди, например, LinkedBlockingQueue. Метод .take() блокируется и ожидает добавления элементов в очередь. Вполне рабочий способ.
- Способы с использованием библиотек или внешних сервисов: Kafka, JMS, ActiveMQ, Reactor Event Bus. Хорошие, но тяжеловесные решения. Сейчас они излишни, а вот если переносить приложение в облако контейнеров, то придётся к ним вернуться.
- Java 9 Reactive Streams. Самый современный способ. Выберем его, чтобы попрактиковаться и оценить результат. Крайне рекомендую посмотреть пример на Baeldung, иначе будет совсем непонятно.
Логику по работе с Publisher/Subscriber инкапсулируем в отдельном классе GameManager, чтобы не перегружать контроллер.
Для начала создадим структуру для хранения обработчиков. Для каждой пары (игра, игрок) отображение содержит ровно один обработчик,
который в качестве параметра принимает изменённое состояние доски - после хода соперника.
public class GamePlayerDesc {
private final String gameId;
private final String playerToken;
...
}
@Service
public class GameManager {
final ConcurrentMap<GamePlayerDesc, Consumer<ChessGameMetadata>> handlers = new ConcurrentHashMap<>();
...
}
//GameManager
public void awaitOpponentMove(GamePlayerDesc gamePlayerDesc, Consumer<ChessGameMetadata> handler) {
handlers.put(gamePlayerDesc, handler);
}
//GameController
gameManager.awaitOpponentMove(new GamePlayerDesc(id, playerToken), m -> {
deferredResult.setResult(m.getGameStateDtoForPlayer(playerToken));
log.debug("await move completed {} {}", id, playerToken);
});
gameUpdatesPublisher.submit(chessGameMetadata);
class GameUpdatesSubscriber implements Flow.Subscriber<ChessGameMetadata> {
...
@Override
public void onNext(ChessGameMetadata metadata) {
GamePlayerDesc gamePlayerDesc = metadata.currentPlayerDesc();
Optional.ofNullable(handlers.remove(gamePlayerDesc)).ifPresent(h -> {
h.accept(metadata);
});
subscription.request(1);
}
...
}
Сложно, запутанно, непонятно. А код юнит тестов едва ли не сложнее основного. Но с этим особенно ничего не поделаешь, с многопоточностью так всегда, а упрощать задачу, например, заменив long polling на частые вызовы со стороны клиента, не хотелось. Что касается Reactive Streams, то думаю, что буду применять эту технологию и в дальнейшем. В данном случае она решает задачу хорошо, но кроме того обладает уникальной дополнительной функциональностью неблокирующего "обратного давления" (back pressure), за счёт которого можно избегать переполнения очереди подписчика, если он не успевает обрабатывать события. Эта возможность сама собой достигается при синхронных вызовах, но при асинхронных её было довольно трудно достичь.
Так или иначе, сервер практически готов. На все сервисы по ходу реализации я добавлял юнит тесты. Они получились довольно объёмными и в процессе
я нашёл несколько ошибок, но не в коде, а в самих тестах! Например, вот так я отправляю POST запрос:
private <T, R> T post(String subUrl, Optional<String> pheader, R body, Class<T> clazz) {
HttpHeaders httpHeaders = new HttpHeaders();
pheader.ifPresent(h -> {
httpHeaders.add("ptoken", h);
});
HttpEntity<R> request = new HttpEntity<>(body, httpHeaders);
ResponseEntity<T> responseEntity =
restTemplate.postForEntity("http://localhost:" + port + "/api/game/" + subUrl, request, clazz);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
return responseEntity.getBody();
}
private CompletableFuture<GameStateDto> awaitMove(String id, String pheader) {
return CompletableFuture.supplyAsync(() ->
post(id + "/wait-for-my-move", Optional.of(pheader), "", GameStateDto.class), cfPool);
}
...
for (MoveDto move : moves) {
try {
state = awaitMove(id, token).get(TIMEOUT, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
e.printStackTrace();
throw new AssertionFailedError(e.getMessage());
}
assertTrue(state.isMyTurn());
state = move(id, token, move);
assertFalse(state.isMyTurn());
}
Короче говоря, проблема оказалась в том, что в тесте на одну партию запускалось 3-4 метода CompletableFuture.get(...), но каждый из них естественно занимает один поток. Он не занимает процессор, память, сеть или диск, то есть ресурсы не тратятся, всё как положено. Но поток-то занят! Да этих потоков можно создать хоть 32 000, а то и больше, но это если создавать из через new Thread(), забивая планировщик задач. Но во всех механизмах работы с потоками, которые сейчас есть в приложении, потоки не создаются бесконтрольно, используются пулы потоков. Это и потоки самого сервера Tomcat, обрабатывающие HTTP запросы (по умолчанию максимум 200 потоков). Это и потоки, которыми пользуется SubmissionPublisher из Reactive Streams. Это и потоки, которые обрабатывают Completable Future в тесте при запуске параллельных партий.
Пул потоков для Tomcat в расчёт не берем, он живет своей жизнью. А вот все остальные по умолчанию используют встроенный ForkJoinPool.commonPool из восьми потоков.
Очень быстро все потоки уходят в ожидание результата по CompletableFuture.get(...), а для обработки ходов, которые бы привели к результату и окончанию этого ожидания потоков просто нет.
Это вполне можно назвать ситуацией Dead-Lock, потому что цикл разрывается только когда CompletableFuture.get(...) завершается по таймауту, но тогда последовательность
выполнения нарушается и результат оказывается неверный. Решение - использовать отдельные пулы для разных категорий обработчиков и, в первую очередь для CompletableFuture.
За это отвечает последний параметр cfPool:
ExecutorService cfPool = Executors.newFixedThreadPool(12, r -> new Thread(r, "completable future pool"));
...
CompletableFuture<GameStateDto> whiteMoveseq = CompletableFuture.supplyAsync(() ->
moveSequence(white.getId(), white.getToken(), whiteMoves), cfPool);
CompletableFuture<GameStateDto> blackMoveseq = CompletableFuture.supplyAsync(() ->
moveSequence(black.getId(), black.getToken(), blackMoves), cfPool);
И вот реализация сервера практически готова. Можно приступать к использованию всех этих сервисов в клиенте. Если работой над клиентом занимается тот же разработчик или, по крайней мере, java программист, то можно надеяться, что документация не потребуется, либо будет достаточно общего описания. А если нет? Если придётся описывать все сервисы, параметры, коды ошибок, приводить примеры запросов где-нибудь на confluence или вообще в Word документе?
Да и как нам самим проверить приложение? Тесты это прекрасно, но хочется запустить приложение целиком и выполнить несколько HTTP запросов так, как это будет делать клиент.
Можно использовать клиент Postman, но форматы сообщений и пути к сервисам придётся аккуратно формировать по коду приложения.
Чтобы не тратить на всё это время, можно сгенерировать одновременно документацию и клиент с помощью Swagger, при этом затратив минимум усилий.
Добавляем две зависимости в проект:
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
@Configuration
@EnableSwagger2
public class SpringFoxConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build();
}
}
Swagger клиент доступен по адресу http://localhost:8085/swagger-ui.html#/ . Можно изучать сервисы, параметры и формат данных. Удобно сразу указывать параметры POST запросов, включая заголовки. Интерфейс простой и интуитивный. Можно целую шахматную партию сыграть, не выходя из него. Пара скриншотов для наглядности.
Фронтенд (Front-end)
Теперь приступим к реализации интерфейса. Как правило, во внутренних корпоративных системах фронтендом могут заниматься Java разработчики. В таких приложения от сайта не требуется работать во всех браузерах на всех устройствах, иметь красивые современные стили и анимации и так далее. В общем, интерфейс должен быть функциональным и рабочим, но конкурировать ни с кем не приходится. Если же нужен качественный, можно сказать, профессиональный сайт для широкой аудитории, то чаще его разрабатывает отдельная команда.
Для наших шахмат попробуем реализовать простой функциональный интерфейс без изысков, но и не в стиле 90-x, по возможности. Тем не менее, вполне вероятно, что мы распределили задачи по разработке бэкенда и фронтенда между сотрудниками отдела. Поэтому будем исходить из предположения, что сервер ещё не готов, максимум существует REST интерфейс, который поддерживает несколько простых вызовов, чтобы мы могли сориентироваться по формату данных.
Для начала создадим минимальный набор файлов: index.html, main.js, main.css, а также скопируем bootstrap и jquery. Пока что все файлы помещаем в корень директории static. Если файлы будут множиться, создадим отдельные директории для Javascript или CSS файлов. Так же сразу в index.html добавляем минимальный head, пустой body м подключаем css и js.
Теперь подумаем над задачей. Я думаю, что лучше сначала сконцентрироваться на "движке" - отображении шахматной доски и фигур, обработку общения с сервером и совершение ходов. Эту часть работы можно выполнить максимально изолированно, пока остальные требования будут, возможно, уточняться. Также, по первому впечатлению, это самая технически сложная и объёмная часть.
Как же отобразить шахматное поле? Можно воспользоваться canvas - будем рисовать все границы, линии и фигуры самостоятельно. Canvas позволит нарисовать всё, что угодно, но кажется слишком сложно, долго и муторно. Если можно сделать отображение на чистом html - это было бь лучше. Какие возможности интерфейса нам нужны?
- Доска - всегда 8*8, никак не трансформируется
- Ход - клик на одно поле, подсветка полей, на которые можно переместить фигуру и клик на второе поле
- Поле - квадрат фиксированного размера, чёрное или белое, может содержать одну из шести шахматных фигур двух цветов
Кажется, используя дивы или таблицы, вполне можно удовлетворить все требования. Давайте попробуем сформировать такую таблицу, пока что без фигур. Вспомним тех задание:
Так, само поле 8*8, но ещё есть обозначения по краям - то есть заведём таблицу 10*10. Для начала из пары строк:
<table class="table-bordered">
<tr>
<td></td><td>a</td><td>b</td><td>c</td><td>d</td><td>e</td><td>f</td><td>g</td><td>h</td><td></td>
</tr>
<tr>
<td>8</td>
<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td>8</td>
</tr>
</table>
a | b | c | d | e | f | g | h | ||
8 | 8 |
Вроде получается, но сразу понятно, что нам нужно контролировать размеры ячеек и отличать чёрные от белых. Поэтому проставим css классы cell, cell-white, cell-black в таблице и объявим их в main.css с минимальными настройками.
<tr>
<td>8</td>
<td class="cell cell-black"></td>
<td class="cell cell-white"></td>
...
.cell {
width: 60px;
height: 60px;
}
.cell-white {
background-color: #ffff80;
}
.cell-black {
background-color: #a3a264;
}
a | b | c | d | e | f | g | h | ||
8 | 8 |
Буквы не по центру, лишние границы по краям, в остальном движемся верно. Подобные мелочи оставлю за кадром. Давайте ещё добавим фигуры и подсветку полей, на которую можно перемещать фигуру, выбранной фигуры и последнего хода соперника (зелёный, чёрный и красный, соответственно). Изображения возьмём векторные и наличие фигуры на поле будет определяться css стилем или data атрибутами, а не содержимым ячейки таблицы. Так будет проще изменять состояние, потому что не нужно создавать или удалять html элементы. Я выбрал data атрибуты, по сути для подобных целей они идеально подходят.
<td class="cell cell-black" data-piece="bishop-black"></td>
<td class="cell cell-white" data-piece-state="move"></td>
<td class="cell cell-black"></td>
<td class="cell cell-white" data-piece-state="eat"></td>
<td class="cell cell-black"></td>
<td class="cell cell-white" data-piece-state="selected"></td>
td[data-piece='bishop-black']{
background-image: url("/img/posts/chess/bishop.svg");
background-size: contain;
}
td[data-piece-state='selected']{
border: 5px solid black !important;
}
td[data-piece-state='eat']{
border: 5px solid red !important;
}
td[data-piece-state='move']{
border: 5px solid green !important;
}
a | b | c | d | e | f | g | h | ||
8 | 8 |
Выглядит неплохо. Не получается пока обойтись одной svg картинкой для каждой фигуры, вероятно придётся отдельно иметь bishop-black.svg и bishop-white.svg (fill:white почему-то не помогает). Опять же, не зацикливаемся на подобных мелочах.
Таким образом мы определили основные необходимые html элементы и стили и можем заняться динамической манипуляцией этими данными в JS. Работу по улучшению визуальной компоненты можно оставить на потом или передать кому-то. Нужно учитывать ограничения времени и приоритеты задач. Неровные границы или плохо сочетающиеся цвета нам простят и преподаватель на зачёте и пользователи сервиса, если основной функционал будет работать на отлично, но не наоборот.
Клиент будет работать по принципу одностраничного сайта. Html код страницы загружается по первому запросу, но содержит только скелет приложения и заголовки. Наполнение страницы формируется динамически по результатам AJAX запросов с использованием библиотеки JQuery. Нам уже известен формат объекта GameStateDto, который описывает состояние шахматной доски. Остальные сервисы пока в разработке (гипотетически).
Так как сервис возвращает только список фигур на доске, то нам нужно научиться генерировать пустое поле, а потом помещать на него нужные фигуры и удалять те, которые были убраны с поля. Можно было бы просто очищать таблицу и заполнять её заново после каждого изменения, но тогда намного более вероятно, что перерисовки таблицы будут заметны в браузере, а это очень некрасиво.
Итак, чтобы создать пустое поле нужно сгенерировать восемь строк по восемь столбцов, а для чёрных нужно всё инвертировать (перевернуть доску).
Каждое второе поле помечаем белым (cell-white), остальные - чёрным (cell-black). На всех полях требуется идентификатор позиции (data-position='e2'), чтобы потом можно было расставить фигуры.
Каждому полю требуется обработчик onClick, для которого пока заведём пустую функцию. Также не забываем про буквы и цифры по краям таблицы.
Вот что получилось:
function emptyBoard() {
const board = $("#board");
let trs = [];
let rowNums = [1, 2, 3, 4, 5, 6, 7, 8];
let cols = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
let styles = ['cell-black', 'cell-white'];
if (state.isBlack) {
cols.reverse();
} else {
rowNums.reverse();
}
board.append(topTr(cols));
rowNums.forEach((r, i) => {
styles.reverse();
board.append(boardTr(r, cols, styles));
});
board.append(topTr(cols));
}
function topTr(cols) {
let tr = $('<tr class="text-center">');
tr.append($('<td>'));
cols.forEach((c, i) => {
tr.append($('<td>').text(c));
});
tr.append($('<td>'));
return tr;
}
function boardTr(row, cols, styles) {
let tr = $('<tr>');
tr.append($('<td>').text(row));
cols.forEach((c, i) => {
let pos = c + row;
tr.append($('<td>')
.addClass('cell')
.addClass(styles[i % 2])
.attr('data-position', pos)
.click(() => {
handleBoardClick(pos);
}));
});
tr.append($('<td>').text(row));
return tr;
}
Теперь разместим на поле фигуры. Предполагаем, что в глобальной переменной state.gameState находится объект отражающий структуру GameStateDto.
function atPosition(pos) {
return $('#board td[data-position=' + pos + ']');
}
function cleanupPieces() {
foreachPosition(p => {
atPosition(p).attr('data-piece', '');
});
}
function cleanupSelections() {
foreachPosition(p => {
atPosition(p).attr('data-piece-state', '');
});
}
function updateBoardState() {
cleanupPieces();
cleanupSelections();
state.gameState.pieces.forEach((p, i) => {
setPiece(p);
});
}
function setPiece(piece) {
const square = atPosition(piece.position);
const dp = piece.pieceType.toLowerCase() + '-' + piece.color.toLowerCase();
square.attr('data-piece', dp);
square.attr('width', '200px');
}
Напомню, что все фигуры на доске определяются наличием специальных атрибутов (data-piece). Мы не добавляем и удаляем img теги, а только устанавливаем и очищаем атрибуты, а фигуры появляются на поле благодаря css селекторам. То же самое с подсветкой фигур.
По легенде, мы начали работу над клиентом и сервером одновременно, так что от сервера пока что был доступен только простой сервис, который возвращает GameStateDto с несколькими фигурами. Теперь продолжим разработку клиента с предположением, что все сервисы готовы или, по крайней мере, находятся в уже более-менее финальной стадии разработки. То есть мы можем зайти в swagger документацию, изучили каким образом поддерживается жизненный цикл игры, знаем формат данных и какие параметры нужны. Поэтому приступим к полноценной интеграции.
Для начала разберемся с парой вызовов /host и /join.
Ссылку для второго игрока будем отображать в отдельном div поверх основного содержимого:
<div id="hostscreen" class="ontop">
<div class="container">
<div class="row">
<div class="col-lg-2 col-md-2">
</div>
<div class="col-lg-10 col-md-10">
<h3>Отправьте эту ссылку второму игроку:</h3>
<p>
<span id="joinlink"></span>
</p>
<h4>Ожидаем подключения соперника...</h4>
</div>
</div>
</div>
</div>
.ontop {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
padding-top: 200px; /* Location of the box */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(255,255,255); /* Fallback color */
background-color: rgba(255,255,255,0.95); /* Black w/ opacity */
}
Первое, что делаем при открытии страницы - проверяем, есть ли параметр URL joinid:
$(document).ready(function () {
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has('joinid')) {
host();
} else {
join(searchParams.get('joinid'));
}
});
Все наши REST сервисы запрашиваем по общей схеме используя функцию ajax, которая принимает все параметры, заголовки, тело запроса и
функции - обработчики успешного вызова и ошибок.
function host() {
$.ajax({
type: "POST",
url: "/api/game/host",
data: JSON.stringify({}),
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function (data) {
initGameParams(data);
},
error: function (errMsg) {
alert(errMsg);
}
});
}
function initGameParams(params) {
state.gameId = params.id;
state.ptoken = params.token;
state.isBlack = "black" === params.playerSide.toLowerCase();
state.playerSide = params.playerSide;
state.gameState = params.gameState;
emptyBoard();
updateStatusMessage();
updateBoardState();
setJoinLink(state.gameId);
if (!state.isBlack) {
$("#hostscreen").toggle();
}
await(1000 * 60 * 5, 50);
}
Чувствую, что начинаю просто весь javascript код сюда копировать, особо не объясняя. Но здесь самое интересное - это, пожалуй - ожидание действия соперника, а именно метод await.
Он вызывает сервис wait-for-my-move и ожидает 5 минут, после чего повторяется. Я ограничил количество вызовов числом 50, чтобы забытые вкладки в браузере не выполняли код бесконечно.
function await(timeoutms, max) {
if (max === 0) {
return;
}
$.ajax({
type: "POST",
url: "/api/game/" + state.gameId + "/wait-for-my-move",
data: JSON.stringify({}),
contentType: "application/json; charset=utf-8",
headers: {'ptoken': state.ptoken},
dataType: "json",
error: function (err) {
if (err.statusText === 'timeout') {
await(timeoutms, max - 1);
} else {
console.log(err.status + " " + err.statusText);
}
},
success: function (data) {
handleBoardUpdate(data);
},
timeout: timeoutms
});
}
При обработке нажатия на поле мы сначала помечаем соответствующим состоянием (data-piece-state = 'move') поля, на которые можно переместить фигуру.
А если поле уже отмечено, то воспринимаем это как завершение хода и отправляем соответствующий запрос /move и вызываем await().
Последний ход оппонента у нас отмечен красным выделением, обновляем его каждый раз, чтобы этот стиль не терялся при коллизиях.
Каждый раз обрабатывая обновление игрового поля, сохраняем все разрешённые ходы в общую переменную validMoves, чтобы не искать их там потом линейным проходом по списку фигур.
function handleBoardClick(pos) {
if (atPosition(pos).attr('data-piece-state') === 'move') {
handleMove(pos);
} else {
cleanupSelections();
highlightLastOppMove();
if (!state.gameState.gameFinished
&& state.gameState.myTurn
&& atPosition(pos).attr('data-piece')
&& atPosition(pos).attr('data-piece').includes(state.playerSide.toLowerCase())) {
handleMyPieceClickOnMyMove(pos);
}
}
}
function handleMyPieceClickOnMyMove(pos) {
atPosition(pos).attr('data-piece-state', 'selected');
state.selectedPosition = pos;
const validMoves = state.validMoves[pos];
if (validMoves) {
validMoves.forEach((m, i) => {
atPosition(m).attr('data-piece-state', 'move');
});
}
}
В игре есть ещё немного функционала, который я не описал: верхнее меню, отображение активного игрока (белые или чёрные), выбор фигуры, когда пешка доходит до границы поля, а также всякие мелочи. Но ничего принципиально нового или примечательно в реализации этого функционала нет. Что-то не реализовано: шахматный таймер и список ходов, но код и так уже слишком длинный для подобного формата и опять же, ничего принципиально нового для реализации тут не потребуется. Таймер - просто целое число, которое бы присылал сервер, а javascript уменьшал каждую секунду при помощи setInterval. Настраивать начальный таймер можно дополнительными параметрами метода /host.
Отмечу, что мне не очень нравится, как структурирован Javascript код: всё в одном файле, все функции и переменные глобальные. Нужные функции постоянно приходится искать поиском. Кроме того, нет ни единого юнит теста, поэтому при внесении изменений приходится вручную проверять основные сценарии. Для примера и демонстрации отдельных функций сгодится, но для серьезного приложения нужно изучать более продвинутые практики. Например, использовать прототипы и таким образом распределять код на разные классы. Возможно, следует писать на typescript, а не чистом javascript. Frontend разработчики успешно пользуются этими приемами. Однако в простых приложениях это выглядит избыточным.
Долго ли, коротко ли, а наша реализация подошла к концу. Остаётся только открыть две вкладки в браузере и поиграть, чтобы проверить различные сценарии, рокировки, шахи, маты и прочее. Видно, что ход в одной вкладке мгновенно отображается в другой. Также можно уйти на полдня, сделать ход, и игра успешно продолжится. Однако, если случайно обновить вкладку, то игра будет потеряна, вернуться в неё нельзя - но это соответствует изначальному проекту и требованиям. То, что мы избежали сессии и даже cookie - это может быть намного полезнее в отдельных случаях. Жаль только, что у меня нет сейчас под рукой сервера, чтобы захостить эту реализацию, но локально всё можно запустить из github репозитоия (главный класс ChessApplication), никаких дополнительных настроек не требуется.
Заключение
Цель данного повествования - показать ключевые моменты планирования, проектирования и разработки. Даже немного затронута коммуникация между отдельными командами или разработчиками. Полноценным учебником (гайдом, туториалом), по которому можно повторить все шаги и получить в результате рабочий код, это назвать нельзя. Кроме того, если такое приложение действительно выкладывать в общий доступ и привлекать живых пользователей, то предстоит дополнительный и огромный объём работ разработки и тестирования. Текущая версия - это скорее прототип. Однако реализовывать подобные приложения просто в качестве обучения или практики для расширения кругозора или работы с frontend, с которым традиционно небольшой опыт у java разработчиков, очень полезно.