Найти в Дзене
ZDG

Онлайн-игра Color Lines 2025

В этой серии материалов буду писать онлайн-вариант известной игры Color Lines, или "цветные шарики". Цель у меня практическая – хочу научиться набирать в ней много-много очков, но не получается. Поэтому для обучения мне нужна такая версия игры, где я могу просматривать и анализировать чужие игры. Есть один сайт с онлайн-версией игры, где можно смотреть, как играют другие, но просмотр работает глючно, как и сама игра. Я ещё никогда не видел, чтобы веб-страница рушила весь браузер. Кроме того, там огромная смердящая куча отвратительной рекламы, как во всех других онлайн-версиях. Значит, буду делать свои шарики. Сначала сделаю клиент игры, который будет работать в браузере на JavaScript. Он будет без сервера, чтобы получилась самостоятельная законченная игра, где можно отладить отладить интерфейс и алгоритмы, а затем уже можно будет подключить сервер. Несмотря на видимую простоту, игра Color Lines ставит интересные задачи: Перед началом программирования хорошо бы чётко представить логику
Оглавление

В этой серии материалов буду писать онлайн-вариант известной игры Color Lines, или "цветные шарики". Цель у меня практическая – хочу научиться набирать в ней много-много очков, но не получается. Поэтому для обучения мне нужна такая версия игры, где я могу просматривать и анализировать чужие игры.

Есть один сайт с онлайн-версией игры, где можно смотреть, как играют другие, но просмотр работает глючно, как и сама игра. Я ещё никогда не видел, чтобы веб-страница рушила весь браузер. Кроме того, там огромная смердящая куча отвратительной рекламы, как во всех других онлайн-версиях.

Значит, буду делать свои шарики.

С чего начать

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

Задачи

Несмотря на видимую простоту, игра Color Lines ставит интересные задачи:

  • Поиск кратчайшего пути в лабиринте. Для этого будет использован волновой алгоритм.
  • Поиск последовательностей одинаковых элементов в матрице в горизонтальном, вертикальном и диагональных направлениях.
  • Синхронизация анимаций. По сути шарики не нуждаются в анимациях, состояние можно менять мгновенно – что-то убрал, что-то поставил. Но иногда на месте только что убранного шарика возникает точно такой же, и выглядит это так, будто он совсем не убирался, что сбивает с толку. В то же время серьёзных игроков анимации раздражают, так как заставляют ждать. Поэтому я сделаю быстрые, минималистичные анимации, чтобы только дать понять, что происходит. И для них потребуется синхронизация – что-то должно произойти сначала, что-то потом, а игра должна точно знать, в каком состоянии она сейчас находится, и какое будет следующим.

Состояния игры

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

Решать, какое и когда состояние будет в игре, должен сервер. Значит, клиент должен получать от сервера готовое состояние и обрабатывать его.

После первого запроса клиент получает предзаполненное игровое поле и переходит в состояние "ожидание ввода пользователя". Пользователь должен выбрать, какой шарик куда переставить. Это происходит чисто в клиенте, без участия сервера.

Клиент может отрисовывать различные анимации и перемещения шарика, но это всё лишь видимость. Серверу от клиента нужна просто команда: переставить этот шарик вот туда.

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

Клиент обновляет у себя поле и опять же может проигрывать различные анимации исчезновения / появления шариков.

После чего опять начинается состояние ожидания ввода пользователя, и всё повторяется, пока сервер не вернёт состояние "конец игры".

Но есть нюанс

Когда появились новые шарики, они могут выстроиться в линию с другими шариками и должны уничтожиться.

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

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

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

Представление данных

Игровое поле представлено линейным массивом типа Uint8Array размером 9*9 клеток. Размеры можно сделать любые, они будут приходить в клиент с сервера.

Каждый элемент массива символизирует игровую клетку. Если он равен 0, то клетка пуста, а значения 1-7 означают цвет шарика (цветов тоже можно сделать больше или меньше, и они тоже будут приходить с сервера).

Таким образом, клиенту достаточно получить от сервера массив длиной 81 байт в стандартном варианте игры. И он сможет уже отрисовать игровое поле и организовать пользовательский ввод.

Чтобы клиент получил от сервера результаты сделанного игроком хода, можно передать всё те же 81 байт игрового поля. Клиент просто сравнит каждую клетку своего поля с полученной от сервера. Если они не совпадают, то шарик либо исчез, либо появился, так что можно отрисовать в этом месте нужную анимацию.

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

Шарики могут исчезнуть в двух случаях:

  1. Игрок переставил шарик и создал линию
  2. Сервер добавил шарики и создал линию

Как мы узнаем в ответе сервера, какой случай какой?

Если игрок создал линию, то новые шарики не добавляются. Тогда в ответе сервера будет только удаление. А если в ответе сервера содержатся новые шарики, то сначала нужно нарисовать добавление, а затем удаление. Короче говоря, удаление просто всегда рисуется последним.

Итак, рассмотрим схему взаимодействия:

  1. Клиент: делает запрос к серверу на инициализацию
  2. Сервер: возвращает первичное игровое поле и его размеры
  3. Клиент: рисует игровое поле и ждёт ввода пользователя
  4. Пользователь: тыкает куда-то в игровое поле по шарикам
  5. Клиент: обрабатывает действия пользователя, рисует анимации и т.д., пока пользователь не кликнул на клетку назначения. Клиент должен самостоятельно определить, доступна ли эта клетка, чтобы не загружать сервер лишними запросами.
  6. Клиент: самостоятельно рассчитывает кратчайший путь, рисует анимацию перемещения, параллельно отправляет на сервер запрос с параметрами хода (откуда-куда)
  7. Сервер: принимает параметры хода, также проверяет его корректность, перемещает шарик, находит/не находит линии, добавляет/не добавляет шарики, удаляет/не удаляет шарики
  8. Сервер: высылает клиенту список добавленных шариков и текущее игровое поле
  9. Клиент: если в ответе сервера есть добавленные шарики, рисует анимацию добавления. Когда она закончилась, сравнивает каждую клетку своей копии поля с полученной от сервера. Если они не совпадают, обновляет информацию в своей копии и рисует анимацию удаления шарика.
  10. Переход на ожидание ввода

Структуры данных

Первый ответ сервера будет содержать такие 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:

-2

Здесь показаны поля конфигурации и их стандартное заполнение. Теперь сделаем конструктор сервера с конфигом:

-3

Теперь добавим первый метод init(). К нему будет обращаться клиент после начала игры.

-4

В этом методе сервер очищает игровое поле, сбрасывает счёт, и добавляет на поле начальное количество шаров. После чего возвращает клиенту объект InitResponse с нужными полями.

-5

Помните, что Server это прокси? При общении с реальным веб-сервером компонент Server в методе init() сделает AJAX-запрос к серверу, получит ответ, распарсит его и сформирует такой же ответ, так что клиент ничего не заподозрит.

Мы ещё не рассмотрели метод аddBall(), добавляющий шар на поле. Любопытно, что такая задача встречается в других играх, типа Змейки, и решается наивным способом так:

  1. Выбрать случайные координаты клетки
  2. Если клетка чем-то занята, перейти на пункт 1

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

Поэтому я делаю по-другому. Немного более затратно по памяти, но зато клетка всегда выбирается с первой попытки.

  1. Собрать координаты свободных клеток в отдельный массив
  2. Выбрать случайный элемент из этого массива
  3. Удалить выбранный элемент из массива

Пункт 3 гарантирует, что уже выбранную клетку нельзя будет выбрать ещё раз.

Для удобства работы я сделаю отдельный класс EmptyTilesCache, консолидирующий методы для получения случайных пустых клеток:

-6

Атрибуты данного класса это счётчик оставшихся пустых клеток cnt и массив фиксированной длины positions, содержащий координаты (позиции) клеток.

Сначала получаем массив позиций пустых клеток:

-7

Затем можно получать свободные клетки, пока они остались:

-8

Замечу, что метод getRandomPosition() бросает исключение, когда позиций не осталось. Почему сделано именно так?

Нельзя возвращать 0, так как 0 это тоже позиция. Можно возвращать -1 как индикатор ошибки, но это магическая константа, что нехорошо. Можно возвращать null или false вместо числовой позиции, что позволительно в JS и проверяется через строгое равенство. Но тогда получается полиморфный результат, а я в этом проекте выбрал концепцию без полиморфизма, с псевдо-строгой типизацией :) Можно возвращать объект с атрибутами { error, position }, но это громоздко. В итоге, getRandomPosition() всегда должен возвращать валидный результат, для чего вызывающая сторона должна сначала вызвать метод hasMore() и убедиться, что позиции ещё остались.

-9

Если же кто-то прорвётся без проверки, тогда и будет брошено исключение.

Добавлю в Server атрибут emptyTilesCache:

-10

Добавлю в метод init() инициализацию emptyTilesCache:

-11

И наконец, можно перейти к методу addBall():

-12

Заметим, что метод возвращает true или false, это надо будет учитывать потом для определения конца игры. Но в начале игры добавление начального количества шаров на пустое поле всегда будет успешным.

На этом пока откланяюсь. В следующем выпуске надо будет дописать методы поиска кратчайшего пути и поиска линий.

Читайте дальше: