В этой серии материалов буду писать онлайн-вариант известной игры Color Lines, или "цветные шарики". Цель у меня практическая – хочу научиться набирать в ней много-много очков, но не получается. Поэтому для обучения мне нужна такая версия игры, где я могу просматривать и анализировать чужие игры.
Есть один сайт с онлайн-версией игры, где можно смотреть, как играют другие, но просмотр работает глючно, как и сама игра. Я ещё никогда не видел, чтобы веб-страница рушила весь браузер. Кроме того, там огромная смердящая куча отвратительной рекламы, как во всех других онлайн-версиях.
Значит, буду делать свои шарики.
С чего начать
Сначала сделаю клиент игры, который будет работать в браузере на JavaScript. Он будет без сервера, чтобы получилась самостоятельная законченная игра, где можно отладить отладить интерфейс и алгоритмы, а затем уже можно будет подключить сервер.
Задачи
Несмотря на видимую простоту, игра Color Lines ставит интересные задачи:
- Поиск последовательностей одинаковых элементов в матрице в горизонтальном, вертикальном и диагональных направлениях.
- Синхронизация анимаций. По сути шарики не нуждаются в анимациях, состояние можно менять мгновенно – что-то убрал, что-то поставил. Но иногда на месте только что убранного шарика возникает точно такой же, и выглядит это так, будто он совсем не убирался, что сбивает с толку. В то же время серьёзных игроков анимации раздражают, так как заставляют ждать. Поэтому я сделаю быстрые, минималистичные анимации, чтобы только дать понять, что происходит. И для них потребуется синхронизация – что-то должно произойти сначала, что-то потом, а игра должна точно знать, в каком состоянии она сейчас находится, и какое будет следующим.
Состояния игры
Перед началом программирования хорошо бы чётко представить логику переходов между состояниями игры. Нужно учесть, что в конечном итоге это будет взаимодействие клиент-сервер, и делать автономный клиент игры с заделом на будущие изменения. Поэтому клиент будет сразу сложнее и избыточнее.
Решать, какое и когда состояние будет в игре, должен сервер. Значит, клиент должен получать от сервера готовое состояние и обрабатывать его.
После первого запроса клиент получает предзаполненное игровое поле и переходит в состояние "ожидание ввода пользователя". Пользователь должен выбрать, какой шарик куда переставить. Это происходит чисто в клиенте, без участия сервера.
Клиент может отрисовывать различные анимации и перемещения шарика, но это всё лишь видимость. Серверу от клиента нужна просто команда: переставить этот шарик вот туда.
Получив такую команду, сервер переставляет шарик в своей копии поля, находит линии, удаляет их если нашлись, добавляет новые шарики в случае необходимости, и высылает клиенту новое игровое поле.
Клиент обновляет у себя поле и опять же может проигрывать различные анимации исчезновения / появления шариков.
После чего опять начинается состояние ожидания ввода пользователя, и всё повторяется, пока сервер не вернёт состояние "конец игры".
Но есть нюанс
Когда появились новые шарики, они могут выстроиться в линию с другими шариками и должны уничтожиться.
Если сервер добавит шарики, затем уничтожит их, и вышлет результат клиенту, этот результат будет правильным, но клиент не сможет правильно нарисовать, какие шарики появились, а какие исчезли.
Значит, сервер должен высылать результат, состоящий из двух частей: в первой части должны быть появившиеся шарики, а в другой исчезнувшие. Клиент же должен сначала анимировать появление шариков, затем исчезновение. Он не может одновременно нарисовать и появление, и исчезновение, так как тогда это будет непонятно уже игроку.
Значит, у клиента в этот момент появляется отдельное состояние, не связанное с сервером, а нужное чисто для синхронизации анимаций.
Представление данных
Игровое поле представлено линейным массивом типа Uint8Array размером 9*9 клеток. Размеры можно сделать любые, они будут приходить в клиент с сервера.
Каждый элемент массива символизирует игровую клетку. Если он равен 0, то клетка пуста, а значения 1-7 означают цвет шарика (цветов тоже можно сделать больше или меньше, и они тоже будут приходить с сервера).
Таким образом, клиенту достаточно получить от сервера массив длиной 81 байт в стандартном варианте игры. И он сможет уже отрисовать игровое поле и организовать пользовательский ввод.
Чтобы клиент получил от сервера результаты сделанного игроком хода, можно передать всё те же 81 байт игрового поля. Клиент просто сравнит каждую клетку своего поля с полученной от сервера. Если они не совпадают, то шарик либо исчез, либо появился, так что можно отрисовать в этом месте нужную анимацию.
Возвращаясь к вышеупомянутому результату из двух частей, требуется посылать полное игровое поле два раза: первое после добавления шариков, второе после удаления. Но так как мы знаем, что шарики всегда прибавляются по 3 штуки (или другая константа), то в первой части можно слать только позиции и цвета добавленных шариков в массиве фиксированной длины.
Шарики могут исчезнуть в двух случаях:
- Игрок переставил шарик и создал линию
- Сервер добавил шарики и создал линию
Как мы узнаем в ответе сервера, какой случай какой?
Если игрок создал линию, то новые шарики не добавляются. Тогда в ответе сервера будет только удаление. А если в ответе сервера содержатся новые шарики, то сначала нужно нарисовать добавление, а затем удаление. Короче говоря, удаление просто всегда рисуется последним.
Итак, рассмотрим схему взаимодействия:
- Клиент: делает запрос к серверу на инициализацию
- Сервер: возвращает первичное игровое поле и его размеры
- Клиент: рисует игровое поле и ждёт ввода пользователя
- Пользователь: тыкает куда-то в игровое поле по шарикам
- Клиент: обрабатывает действия пользователя, рисует анимации и т.д., пока пользователь не кликнул на клетку назначения. Клиент должен самостоятельно определить, доступна ли эта клетка, чтобы не загружать сервер лишними запросами.
- Клиент: самостоятельно рассчитывает кратчайший путь, рисует анимацию перемещения, параллельно отправляет на сервер запрос с параметрами хода (откуда-куда)
- Сервер: принимает параметры хода, также проверяет его корректность, перемещает шарик, находит/не находит линии, добавляет/не добавляет шарики, удаляет/не удаляет шарики
- Сервер: высылает клиенту список добавленных шариков и текущее игровое поле
- Клиент: если в ответе сервера есть добавленные шарики, рисует анимацию добавления. Когда она закончилась, сравнивает каждую клетку своей копии поля с полученной от сервера. Если они не совпадают, обновляет информацию в своей копии и рисует анимацию удаления шарика.
- Переход на ожидание ввода
Структуры данных
Первый ответ сервера будет содержать такие JSON-поля:
- w – ширина игрового поля в клетках
- h – высота игрового поля в клетках
- colors – количество цветов шариков
- tiles – массив размером w * h с предзаполненным содержимым клеток.
в JSON клетки поля пришлось бы передавать в текстовом виде:
tiles: [0, 0, 1, 2, 0, 0, 5, 0, 7, 0, 0, ...]
Теоретически значения можно сжать до 3-х бит, а затем упаковать в строку наподобие BASE64, тогда получилось бы что-то вроде:
tiles: "HOahaerP{5ikg$WHJGAEPGf3"
Но пока не стоит обращать на это внимание, так как сервера ещё нет. Клиент будет обращаться к прокси-компоненту Server, и не будет знать, откуда реально приходят данные. Получать он их будет от компонента в уже JS-нативном виде, т.е. массив tiles будет типа Uint8Array.
Ответ сервера после хода игрока будет содержать такие JSON-поля:
- addedBalls – массив, который содержит данные о добавленных шариках типа { позиция, цвет }. Он может быть пустым.
- tiles – массив размером w * h с текущим содержимым клеток
- continue – признак того, что игру можно продолжать
Сервер в клиенте
Как я говорил выше, сейчас делается клиент, но в этом клиенте придётся эмулировать сервер. Для чего сделаю класс компонента Server. Но сначала сделаю класс Config:
Здесь показаны поля конфигурации и их стандартное заполнение. Теперь сделаем конструктор сервера с конфигом:
Теперь добавим первый метод init(). К нему будет обращаться клиент после начала игры.
В этом методе сервер очищает игровое поле, сбрасывает счёт, и добавляет на поле начальное количество шаров. После чего возвращает клиенту объект InitResponse с нужными полями.
Помните, что Server это прокси? При общении с реальным веб-сервером компонент Server в методе init() сделает AJAX-запрос к серверу, получит ответ, распарсит его и сформирует такой же ответ, так что клиент ничего не заподозрит.
Мы ещё не рассмотрели метод аddBall(), добавляющий шар на поле. Любопытно, что такая задача встречается в других играх, типа Змейки, и решается наивным способом так:
- Выбрать случайные координаты клетки
- Если клетка чем-то занята, перейти на пункт 1
Этот метод допускает несколько повторений, если выбор падает на занятые клетки, и работает более-менее хорошо, когда пустых клеток много, а занятых мало. Но если представить, что на поле заняты все клетки кроме одной (или вообще все), подобный алгоритм может зациклиться надолго.
Поэтому я делаю по-другому. Немного более затратно по памяти, но зато клетка всегда выбирается с первой попытки.
- Собрать координаты свободных клеток в отдельный массив
- Выбрать случайный элемент из этого массива
- Удалить выбранный элемент из массива
Пункт 3 гарантирует, что уже выбранную клетку нельзя будет выбрать ещё раз.
Для удобства работы я сделаю отдельный класс EmptyTilesCache, консолидирующий методы для получения случайных пустых клеток:
Атрибуты данного класса это счётчик оставшихся пустых клеток cnt и массив фиксированной длины positions, содержащий координаты (позиции) клеток.
Сначала получаем массив позиций пустых клеток:
Затем можно получать свободные клетки, пока они остались:
Замечу, что метод getRandomPosition() бросает исключение, когда позиций не осталось. Почему сделано именно так?
Нельзя возвращать 0, так как 0 это тоже позиция. Можно возвращать -1 как индикатор ошибки, но это магическая константа, что нехорошо. Можно возвращать null или false вместо числовой позиции, что позволительно в JS и проверяется через строгое равенство. Но тогда получается полиморфный результат, а я в этом проекте выбрал концепцию без полиморфизма, с псевдо-строгой типизацией :) Можно возвращать объект с атрибутами { error, position }, но это громоздко. В итоге, getRandomPosition() всегда должен возвращать валидный результат, для чего вызывающая сторона должна сначала вызвать метод hasMore() и убедиться, что позиции ещё остались.
Если же кто-то прорвётся без проверки, тогда и будет брошено исключение.
Добавлю в Server атрибут emptyTilesCache:
Добавлю в метод init() инициализацию emptyTilesCache:
И наконец, можно перейти к методу addBall():
Заметим, что метод возвращает true или false, это надо будет учитывать потом для определения конца игры. Но в начале игры добавление начального количества шаров на пустое поле всегда будет успешным.
На этом пока откланяюсь. В следующем выпуске надо будет дописать методы поиска кратчайшего пути и поиска линий.
Читайте дальше: