Продолжаю писать игру Color Lines c клиентом и сервером.
Предыдущая часть:
Наконец можно перейти непосредственно к телу клиента. Опишем класс игры:
Здесь мы храним ассеты imgAssets, размеры игрового поля w и h, массив клеток tiles, игровой счёт score, объект сервера server, рендерер renderer, представление view, компонент поиска пути pathFinder, и состояние игры state.
Инициализация компонента:
Игра вызывает метод init() сервера. Сервер возвращает объект InitResponse (если что, всё это описано в предыдущих частях), который содержит размеры игрового поля и само поле с расставленными начальными шариками.
Игра на основе полученных размеров создаёт собственное игровое поле, обнуляет его и рисует. Затем перебирает то, что прислал сервер. Когда встречается шарик, игра ставит его на своё поле с помощью метода addBall():
Помимо установки значения в массив tiles, в позицию шарика добавляется эффект GrowEffect. В результате начальные шарики не появятся сразу вместе с полем, а "вырастут" на нём.
Наконец, игра устанавливает текущее состояние GameStateInput.
Состояния
Игра в веб-браузере работает специфично. Она получает контроль на какое-то время, как правило через какой-то один метод. И в этом методе она должна понять, что ей делать. Ожидать, когда пользователь выберет шарик? А если выбран шарик, ожидать, когда он выберет клетку назначения? А если она уже выбрана, то рисовать анимацию движения?
Всё это требует запоминания состояния в каких-то переменных и проверки этих переменных. Зачастую эти проверки становятся слишком сложными, и в них легко запутаться.
Но набор переменных, запоминающих состояние, это же и есть состояние! Почему тогда не сделать разные состояния в виде разных классов, и изменять не переменные, а сами состояния?
Вот первое состояние GameStateInput:
Оно не занимается никакими проверками, потому что когда оно активно, то понятно, что игра ожидает выбора шарика игроком, и больше ничего.
Сам выбор, то есть клик мышкой на веб-странице, происходит где-то во внешней среде (до этого доберёмся), а для обслуживания этого клика у состояния вызывается метод process(), аргументом которого является уже готовая позиция на игровом поле pos. Дальнейшие действия просты: если в позиции pos пусто, то ничего не делаем. Иначе добавляем в эту позицию BounceEffect, чтобы шарик начал подпрыгивать, и... меняем состояние на GameStateTarget, так как в этом состоянии делать больше нечего.
В следующем игровом цикле будет вызываться уже новое состояние, и снова для обработки клика пользователя:
Оно обрабатывает четыре случая:
- Игрок кликнул на ту же клетку. Тогда шарик должен перестать прыгать (снимаем с него эффект BounceEffect), и состояние возвращается обратно на GameStateInput.
- Игрок кликнул на другой шарик. Тогда со старого шарика надо снять эффект BounceEffect и добавить его новому шарику. И также запомнить новую стартовую позицию srcPos.
- Игрок кликнул на пустую клетку, но она недоступна. Тогда в эту клетку надо добавить эффект RejectEffect для анимации запрета.
- Игрок кликнул на пустую клетку, и она доступна. Тогда надо совершать ход. Сразу переставляем шарик внутри массива. Вызываем метод moveBall(), который передаст данные хода на сервер. И меняем состояние на GameStatePath.
У этого состояния нет метода process(), так как оно не обслуживает какие-то спонтанные внешние действия, а работает автоматически. В каждом игровом цикле у него вызывается метод update(), который кстати есть и у предыдущих состояний, и тоже вызывается в каждом цикле, но ничего не делает. А здесь делает.
В компоненте pathFinder находится уже посчитанный кратчайший путь, и состояние берёт из него очередную позицию. В эту позицию добавляется эффект TrailEffect, который создаст след от перемещения шарика. Сам шарик внутри массива уже переставлен, а здесь просто визуализация.
Когда шарик (точнее, след от него) дойдёт до финальной позиции dstPos, состояние сменяется на GameStateCheck:
Оно проверяет обстановку после хода. Ранее игра отправила на сервер асинхронный запрос с данными хода. Сейчас состояние получает сохранённый ответ через метод game.getServerResponse(). В случае, если ответ ещё не получен, ничего не происходит, но метод update() будет вызван в следующем цикле и всё повторится.
Получив ответ от сервера в виде объекта класса MoveResponse, состояние проверяет, если ли в нём добавленные шарики (массив addedBalls должен cодержать что-то отличное от null). Если есть, то добавляет их себе на поле методом game.addBall().
Следующим шагом надо обновить шарики на поле, так как некоторые из них могли удалиться. Это можно было бы сделать прямо здесь, но не всегда. Если были добавлены шарики, надо дождаться, когда закончится анимация их добавления. Прямо в этом состоянии дожидаться нельзя, так как его метод update() нельзя вызывать более одного раза (иначе он опять получит ответ от сервера и т.д.) А вводить в метод дополнительные условия-барьеры это то, от чего мы хотели уйти с помощью состояний. Поэтому делаю отдельное состояние GamestateUpdateTiles.
Но оно наступит либо сразу, либо с задержкой после появления шариков. Поэтому, если шарики были добавлены, данное состояние заворачивается в другое, специальное состояние GameStatePending:
Его задача – дождаться, пока закончатся все эффекты, и после этого перейти в то состояние, которое в него завёрнуто.
А вот и GameStateUpdateTiles:
Оно последовательно сравнивает клетки своего игрового поля с клетками игрового поля, полученного от сервера. Если с сервера пришёл 0, а у нас в этой клетке есть шарик, его надо убрать. Для чего в клетку добавляется эффект ShakeEffect. Шарик немного потрясётся и пропадёт.
Иначе, если это не 0, но шарики не совпадают, получается, что у нас стоит один шарик, а сервер считает, что другой. Такого в принципе быть не должно, но спишем на какие-то разрывы связи и прочее. Данные сервера имеют приоритет, поэтому заменяем свой шарик на серверный и добавляем ClearEffect, чтобы этот шарик перерисовался.
Наконец, состояние меняется на GameStateInput, и цикл состояний начинается сначала.
Должно быть ещё состояние конца игры GameStateEnd, которое пока не написано.
Игровой цикл
Напишем, наконец, тот самый метод игры, который будет циклически вызываться и осуществлять собственно игру.
Здесь всё просто. Во-первых, вызывается метод update() у представления view. Представление отрисовывает и обновляет эффекты. Затем вызывается метод update() у текущего состояния state, что обрабатывает состояние игры.
Ключевой же ингредиент здесь это вызов window.requestAnimationFrame().
Данный метод (встроенный в браузер) привязывает вызов метода game.update() к следующему обновлению экрана. Экран обновляется с частотой 60 герц (бывают и другие частоты).
Таким образом, когда вызывается метод game.update(), он запрашивает вызов самого себя в следующем кадре. В следующем кадре он будет вызван, и запросит вызов ещё раз, и т.д. Так организуется цикл.
Помимо прочего, такой механизм позволяет игре работать с фиксированной частотой обновления 60 Герц. Но так как частоты экрана могут быть и другие, придётся всё равно внедрять какой-то таймер, чтобы игра на всех экранах работала одинаково. Но это уже потом.
Кик-старт
Игра теперь представляет собой собранный механизм, который ждёт запуска. Для запуска нужно вызвать game.update(), но кто-то это должен сделать извне. Нужно приготовить саму веб-страницу, которая загрузится в браузер и запустит игру.
Её HTML-код вы сможете посмотреть по ссылке, которую я дам, а здесь опишу его частично.
В начало страницы включаем все ранее написанные JS-классы:
Для оптимизации их надо объединить в один файл и минимизировать, но для разработки удобнее держать всё в отдельных файлах.
Тело страницы состоит из одного элемента canvas:
<canvas id="canvas" width="600" height="600"></canvas>
Далее создаём конфиг, получаем доступ к canvas, создаём рендерер, сервер и игру:
Последним действием нужно загрузить картинку-ассет:
Игру можно стартовать после того, как картинка полностью загружена, поэтому вешаем обработчик onAssetsLoaded() на событие onLoad:
Так как мы использовали onAssetsLoaded.bind(game), внутри этой функции сущность this будет являться game. Соответственно, мы вызываем у неё init() – это будет первый запрос к серверу с инциализацией поля. Затем привязываем обработчик для клика на канвасе. И вызываем метод update(), который запустит мотор.
Обработчик клика на канвасе выглядит так:
Сущность this в нём опять-таки является game. Во-первых, обработчик нужен только для состояний GameStateInput и GameStateTarget, поэтому проверяем класс текущего состояния. Далее вычисляем относительные координаты клика внутри View, получаем из них позицию клетки и вызываем state.process() с этой позицией.
Координаты normalizedX и normalizedY используются для того, чтобы клик, который приходится на самый край клетки, не срабатывал. Вычисляется расстояние от центра клетки, которое должно быть не больше чем какой-то предел. Это позволяет избежать случайных кликов не туда.
Собственно, всё! Прототип готов.
Поиграть в игру можно по этой ссылке.
А нажав Ctrl-U, можно посмотреть все исходники.
Также можно посмотреть видео работы:
В игру необходимо добавить состояние окончания GameStateEnd, вывод следующих цветов шариков, вывод набранных очков, ну и наверно по ходу возникнет что-то ещё.
А в следующем выпуске можно уже начинать делать настоящий сервер.
Читайте дальше: