Найти в Дзене
Сделай игру

Пишем простую игру на JS: создаём уровень и улучшаем управление

Итак, основу для игрушки мы-таки написали тут, но, очевидно, что этого недостаточно, чтобы игра называлась игрой. Поэтому предлагаю продолжить это увлекательное путешествие. Вообще, как ни странно, в играх самое важное - сюжет. Игра без сюжета становится бессмысленной и быстро надоедает (если, конечно, это не 3-в-ряд и не flappy bird). Но сюжет, всё же, нужен: он помогает установить правила и создать атмосферу игры. Поэтому я позволил себе немного фантазий и получилось так: воришка пробрался в гробницу некого правителя, чтобы обнести его саркофаг, но провалился в ловушку и теперь ему надо поскитаться по комнатам, выбраться обратно и заполучить-таки свою добычу. P.S. внизу ссылка на новую версию Очевидно, что игра, где есть непрогнозируемые противники, да ключи с дверьми - это не то, чтобы сильно увлекательно; поэтому давайте разнообразим наше пространство и добавим ещё несколько типов элементов карты: Но это мы говорим про статичные элементы карты, будут ещё и динамичные - они привяза
Оглавление

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

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

Поэтому я позволил себе немного фантазий и получилось так: воришка пробрался в гробницу некого правителя, чтобы обнести его саркофаг, но провалился в ловушку и теперь ему надо поскитаться по комнатам, выбраться обратно и заполучить-таки свою добычу.

P.S. внизу ссылка на новую версию

Наше будущее подземелье
Наше будущее подземелье

Что будем делать

Очевидно, что игра, где есть непрогнозируемые противники, да ключи с дверьми - это не то, чтобы сильно увлекательно; поэтому давайте разнообразим наше пространство и добавим ещё несколько типов элементов карты:

  • точка возрождения - недоступна для врагов;
  • липкий пол - вдвое замедляет движение;
  • бассейн с кислотой - при попадании туда герой гибнет;
  • исчезающая стена - выглядит как стена, но через неё герой может пройти;

Но это мы говорим про статичные элементы карты, будут ещё и динамичные - они привязаны к карте, но обрабатываются как игровые объекты:

  • дверь, открываемая переключателем;
  • двойная дверь, управляемая переключателем (пока одна открыта, другая закрыта).

Также добавится ещё 2 противника:

  • "бегун" - нападает на героя, если тот оказывается с ним на одной вертикали или горизонтали;
  • "рой" - вылетает из улья при приближении и преследует некоторое время.

Разумеется, вот всё это потребуется увеличить карту, проработать механику триггеров, добавить всем объектам возможность состояния (скажем, противнику режимы ожидания и нападения), а также, вероятно, потребуется прокрутка (она же - скроллинг) экрана.

Как вписаться в поворот

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

В принципе, это решается относительно просто - добавлением дополнительного кода, который помогает входить легче в угол: если расстояние до угла меньше или равно 2-м смещениям по скорости - то можно "подтянуть" персонажа.

almostUp и almostDown - показывают, что можно и "дотянуть" персонажа
almostUp и almostDown - показывают, что можно и "дотянуть" персонажа

В любом случае, "подтяжка" имеет смысл лишь при скоростях меньших некоторого номинала (я взял двойную скорость, этого достаточно); пожалуй 20% длины/ширины ячейки должно быть достаточно.

Создание карты

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

Новый лабиринт
Новый лабиринт

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

Получилось средне, но это я ещё за игровые объекты не брался!
Получилось средне, но это я ещё за игровые объекты не брался!

Однако, карта всё ещё далека, так сказать, от идеала - можно относительно легко пройти сперва к ключу, потом к двери. Нет, так не интересно. Но тут нам уже помогут игровые объекты.

Время оптимизации

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

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

Во-вторых, и это более важный аспект, некоторые игровые объекты необходимо, в ряде случаев, интерпретировать как объекты карты (скажем, открывающаяся дверь: пока переключатель не нажат - она непроходима как стена).

Для начала сведём задачу к единой системе измерений и способу получения по координатам x и y (которые высчитываются из индекса) объекты карты. Это нетрудная задача: мы держим в памяти актуальную хэш таблицу, где к ячейке с указанным номером привязывается ссылка на объект (такой вариант, теоретически, требует больше памяти, хотя это и не точно, чем если хранить номер, зато работает быстрее). Проблемой может стать только случай, когда к одной ячейке привязано сразу несколько объектов. А значит, хранить мы будем не значение, а массив. С одним или несколькими значениями. Для этой задачи отлично подойдёт {}, обычный объект.

Сразу надо сказать, что отрисовка всего "одним проходом" - идея ниже среднего: получится примерно во так.

Мерцания при перемещении
Мерцания при перемещении

Видите это мигание? Оно происходит потому, что следующий квадратик свободного пространства рисуется поверх уже отрисованного игрового объекта. Это из причин, почему отрисовка всегда разбивается на слои, которые отрисовываются снизу вверх. Если говорить про изометрическую проекцию, то объекты, которые перекрывают частично или полностью ранее отрисованные объекты, просто рисуются в последнюю очередь; их несложно определить программно.

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

Проверка пересечения стала проще
Проверка пересечения стала проще

Только тут важно не забывать: логика к логике. В какой-то момент может появиться соблазн проверять события, сравнивая графические значения. Этого делать не стоит: добавится какой-нибудь специальный коэффициент искажения и всё перестанет работать.

Усиливаем карту

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

Для кислотного бассейна надо сделать "допустимый заступ", учитывающий специфику нашей системы координат.

А для липкого пола - изменение характеристик объекта при передвижении по нему, причём лишь для некоторых объектов (не на всех он будет влиять).

Хотя задачи выглядят разными, они похожие: необходимо добавить алгоритм, позволяющий вычислять пересечение игрового объекта с ячейкой карты: объект может находиться на соседней ячейке, но заступать на текущую или находиться на текущей, но быть вне зоны действия.

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

Реализовать это не так сложно, как может показаться. В любой момент времени, игровой объект может находиться на 1, 2 или 4 клетках и принадлежать каждой из клетке на какой-то процент (если считать), либо на сколько-то точек по горизонтали и по вертикали.

Вычисляем пересечения
Вычисляем пересечения

Мы просто фиксируем клетки вокруг (насколько это возможно) и то, сколько логических точек занимает объект на данной точке. Дальше - дело техники. Можно процент перекрытия посчитать, можно площадь. Я же, разумеется, попробую выбрать метод, который будет давать верный результат, но без необходимости производить сложные вычисления - простое перемножение "заступов" и сравнение их с четвертью площади клетки карты.

А тут определяется поведение подвижных игровых объектов при заступе на 1/4 и более
А тут определяется поведение подвижных игровых объектов при заступе на 1/4 и более

Заступил более чем на четверть - добро пожаловать на точку возрождения. Кстати, она теперь есть.

Демонстрация, тут добавился счётчик попыток
Демонстрация, тут добавился счётчик попыток

Тут видно падение скорости на зелёном "липком полу" и потеря попытки на фиолетовом "кислотном бассейне" с последующим возрождением в соответствующей точке.

Возможно вы уже заметили количество попыток в левом верхнем углу. В случае соприкосновения с опасным объектом или ячейкой - счётчик сбрасывается на 1. Если дойдёт до 0 - игра закончится, что не удивительно.

Что же, можно двигаться дальше.

Новые игровые объекты

Под игровыми объектами я подразумеваю не-статичные элементы на карте или элементы, которые так или иначе взаимодействуют с игроком: герой, противники, ключи и прочее. В планах, к слову, есть несколько игровых объектов, которые присутствуют на бумажном носителе, но отсутствуют в игре. Давайте их добавим.

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

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

Для этой задачи (однократного реагирования на событие) существует довольно большое количество решений. Я выберу такой: при активации (первичном заступе) - выполняется обработчик и фиксируется время заступа; далее, пока персонаж стоит на переключателе - время обновляется. Если разница между текущим временем и временем переключателя больше некоторой контрольной величины (скажем, 100 милисекунд), то состояние связанных дверей переключаются. Код довольно прост.

Сперва закэшируем связанные двери, затем - переключим их состояния
Сперва закэшируем связанные двери, затем - переключим их состояния

Теперь вернёмся к самой двери: её надо обрабатывать эффективно, то есть воспринимать как стену, когда она закрыта и как проход, когда открыта. Желательно это делать на уровне обработки карты.

Перегрузка карты наложением

Данный приём можно использовать для многих целей, подойдёт он и для нашей: у нас есть основная карта и карта перегрузки.

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

Дверь превращает проход в стену, бассейн с водой, если он заморожен - в проход и в стену в противном случае. Полагаю, идея понятна.

И, разумеется, одна ячейка может быть занята только одним типом объекта. Поэтому если несколько объектов окажутся привязанными к одной ячейке, то только значение последнего будет записано. За этим надо следить.

Перечёркнутый квадрат - дверь, зелёный - переключатель
Перечёркнутый квадрат - дверь, зелёный - переключатель

Ну, всё, вроде, работает. Давайте обновим карту, расставив двери и переключатели по местам.

Лабиринт сразу стал более лабиринтистым
Лабиринт сразу стал более лабиринтистым

Трудности отрисовки

Вот скажите, что должно быть отрисовано сверху: герой или переключатель? Конечно герой. А у меня получается выключатель. И происходит это по той причине, что нет никаких правил порядка отрисовки. А он важен: необходимо чётко задать, что сверху, а что снизу. Но как это сделать?

Можно задать всем игровым объектам условный z-index, как в css, но оно заставит для каждого элемента проставлять индекс, что не удобно.

Другой - предопределить порядок отрисовки элементов: сперва рисуются выключатели, затем герои и персонажи. Неудобство появится в случае, если какой-то элемент надо будет принудительно "поднять" или "опустить".

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

Вот порядок отрисовки элементов
Вот порядок отрисовки элементов
А тут игровые объекты сортируются в соответствии со своим порядком
А тут игровые объекты сортируются в соответствии со своим порядком

Исчезающая стена

По задумке, некоторые стены, если их преодолеть, должны исчезнуть, а за ними может скрываться какой-нибудь приятный бонус. В принципе, ничего сложного: у стен есть некоторый группирующий фактор; как только одну из стен набора пересекает игрок, все стены той же группы становятся невидимыми. Давайте это сделаем!

По сути, надо доработать код в 3 местах:

Блок отрисовки; добавить ещё одну ячейку
Блок отрисовки; добавить ещё одну ячейку
Перегрузка карты: для врагов псевдостены - настоящие
Перегрузка карты: для врагов псевдостены - настоящие
А герой растворяет своим геройским прикосновением всю группу фальшивых стен
А герой растворяет своим геройским прикосновением всю группу фальшивых стен
Сможете найти все фальшивые стены, не прокручивая обратно статью?
Сможете найти все фальшивые стены, не прокручивая обратно статью?

Смертельный враг

Разумеется, выход из лабиринта должен охранять сильный противник. Или, хотя бы, просто опасный. И у меня есть такой для вас. Знакомьтесь, улей!

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

Скорость у роя такова, что от неё можно убежать. Теоретически.

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

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

А вот рой-треугольник гоняется за героем
А вот рой-треугольник гоняется за героем

Как видите, рой получился довольно простой, но эффективный. Для реализации я использовал ещё один массив - временные объекты. Каждый элемент, добавляемый туда, имеет маркер со временем создания и время жизни: каждый такт все устаревшие объекты с маркером и временем жизни - удаляются. Улей, который является стационарным объектом, также получает тот же самый временной маркер. Каждые 7 секунд маркер сбрасывается для улья и он снова может запустить рой; время жизни роя - 5 секунд.

Рой меняет направление движения каждый такт
Рой меняет направление движения каждый такт
А вот место принятия решения о добавлении нового роя
А вот место принятия решения о добавлении нового роя

Как видите, оказалось всё довольно просто. Хорошо, что я не стал алгоритм кратчайшего пути реализовывать: выглядело бы эффектно, но зачем?

Немного поддержки

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

Заключение

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

Зелёное - ключ, оранжевое - выход. Надо с ключом добраться до выхода. Почти всё остальное убивает игрока и отправляет на точку перерождения. Но в этом вы и сами скоро убедитесь. Управление - стрелочки клавиатуры. Удачи!

P.S. а вот и продолжение.