В предыдущей части более-менее закончили с эмуляцией сервера:
Клиент будет сложнее, так как ему нужно обрабатывать пользовательские команды и рисовать интерфейс.
Как бы ни хотелось сделать его побыстрее, необходимо структурно разделить его на следующие части:
1. Рендерер
Он будет заниматься отрисовкой графических примитивов. Суть в том, что рендереры могут быть разные, от алфавитно-цифрового до OpenGL. И любой из них можно подсунуть так, чтобы не менять ничего в игре.
Рендерер, независимо от реализации, должен предоставлять интерфейс для рисования графических примитивов. В нашем случае это будут четыре метода:
- clear() – очищает область рисования
- fillRect() – рисует закрашенный прямоугольник
- drawImage() – рисует картинку
- drawScaledImage() – рисует картинку с изменённым размером
В HTML можно отрисовать прямоугольники и шарики, используя исключительно CSS. Но такая реализация абсолютно костыльная и непереносимая на другие платформы, поэтому я остановлюсь на более традиционном рисовании пикселами на холсте.
В HTML есть элемент <canvas>, получив доступ к которому, можно рисовать привычными примитивами. Соответственно рендерер будет называться CanvasRenderer:
Аргументы w и h задают размеры рабочей области (т.е. холста), а ctx это контекст рисования, через который происходит доступ к холсту.
Реализация методов рендерера это по сути обёртка для встроенных методов контекста:
2. Представление (View)
Представление заведует тем, как всё выглядит. Оно рисует игровое поле, шарики, текущий счёт, эффекты и т.п., используя для этого примитивы из методов рендерера.
Представление конструируется с аргументами размеров рабочей области, рендерера и ассетов. Ассетом в нашем случае будет картинка с шариками и другими графическими элементами:
В своём конструкторе View вычисляет необходимые часто используемые размеры, определяет свою позицию на экране, и кроме того содержит массив эффектов effects, которые мы обсудим, когда до них дойдёт дело.
Метод для отрисовки одной клетки:
На деле используются два метода: drawTileBack() рисует простой прямоугольник, который является задником клетки, а drawTile() рисует клетку с рельефной рамкой, используя светлый и тёмный цвета.
Метод drawField() рисует игровое поле поклеточно:
Немного забегая вперёд, покажу, как выглядит нарисованное поле:
Но вышеуказанные методы рисуют только прямоугольники, а где же рисуются шарики? Дело в том, что шарики на поле появляются уже потом, через соответствующую анимацию, которую я здесь называю эффектами.
Есть следующие эффекты:
- GrowEffect – шарик появляется на поле, вырастая в размерах. Используется при добавлении новых шариков.
- TrailEffect – тень шарика, которая уменьшается и пропадает. Используется при движении шарика для формирования следа.
- BounceEffect – подпрыгивающий на месте шарик, который выбран игроком
- ShakeEffect – трясущийся шарик, который вот-вот исчезнет
- RejectEffect – красный крестик, который появляется на клетке, в которую нельзя попасть
И наконец, специальный эффект ClearEffect. Его задача в том, чтобы после действия какого-то другого эффекта вернуть клетку в обычное состояние.
Как работают эффекты
Сами по себе эффекты ничего не рисуют, а только рассчитывают и возвращают параметры для рисования. Более того, эти параметры однотипны и по сути сводятся к одному: прогрессу от старта до финиша.
Например, эффект растущего шарика в первом кадре вернёт значение 1, во втором кадре 2 и т.д. до 6. Это и есть прогресс от 1 до 6, после чего эффект закончится. Значения можно использовать для того, чтобы уже рисовать шарик с нужным размером от 1 до 6.
Для всех эффектов можно сделать общий класс:
Пераметр delay задаёт задержку между кадрами, а параметр endTick задаёт последний тик, до которого должен идти прогресс. Тик увеличивается при вызове метода update(), с учётом задержки:
Чтобы получать текущее значение эффекта, используется метод getValue():
По умолчанию он возвращает текущий тик, но может быть переопределён у класса-потомка.
Как теперь будет выглядеть класс GrowEffect:
Он просто наследуется от Effect и в конструктор подставляет параметры 1 и 6. Кроме того, эффект имеет собственный атрибут color, чтобы знать, какого цвета должен быть выращиваемый шарик.
Аналогично делается RejectEffect:
и TrailEffect:
От GrowEffect они отличаются лишь параметрами, а между собой и вовсе не отличаются. Однако они должны быть всё равно разными классами, так как хоть параметры и одинаковые, анимации будут разные.
Более интересно выглядит BounceEffect:
У него переопределённый метод getValue(), который циклически меняет возвращаемый параметр от -5 до 5, используя функцию синуса, благодаря чему шарик плавно покачивается.
Кроме того, этот эффект работает бесконечно, пока не будет отменён, и поэтому наследуется от другого класса LoopedEffect:
Логика LoopedEffect более простая, так как ему не надо проверять достижение конечного тика.
У ShakeEffect также переопределённый метод getValue(), который в каждом кадре меняет значение между -2 и 2.
Как View использует эффекты
Каждый эффект действует на какую-то клетку. В одной клетке не может быть более одного эффекта. Также эффекты по понятным причинам часто добавляются и часто удаляются. Поэтому, чтобы не напрягать сборщик мусора, для хранения эффектов используется постоянный массив фиксированного размера, равного игровому полю. Эффект помещается в какую-то позицию массива, и если там уже был эффект, он просто затирается:
Для удаления эффекта можно было просто записать null в позицию массива, но так как клетку нужно восстановить до нормального вида, в позицию помещается ClearEffect:
В каждом кадре View обрабатывает активные эффекты:
Метод проходит по массиву эффектов, пропуская пустые. Встретив ClearEffect, обрабатывает его и сразу же обнуляет. У остальных эффектов вызывает метод update(). Если метод вернул false, значит эффект уже отработал. Тогда вызывается метод removeEffect(). Иначе увеличиваем счётчик активных эффектов effectCnt – он будет нужен, чтобы быстро определить, остались ли активные эффекты. И отправляем эффект на обработку в processEffect():
Первым делом надо отрисовать задник в drawTileBack(), чтобы скрыть следы предыдущих отрисовок. Затем проверяем тип эффекта. Если это BounceEffect, ClearEffect или ShakeEffect, то они влияют только на вертикальную позицию шарика. Используя картинку-ассет, отрисовываем шарик со смещением вертикальной позиции, полученным из эффекта (ClearEffect всегда возвращает 0 и поэтому будет отрисован шарик в стандартном положении).
Если это TrailEffect или RejectEffect, то рисуем нужный кадр из ассета в соответствии с прогрессом.
А вот если это GrowEffect, то рисуем шарик в отмасштабированном виде.
Осталось лишь сделать так, чтобы все эти методы кто-то вызывал. Это будет уже сама игра, но в следующем выпуске.
Читайте дальше: