Давненько не брал я в руки шашку, а всё про высокие материи. Пора, так сказать, приблизиться к реальности. Как-то, ещё в 80-х, на ПК Орион-128 мне попалась игра Knight. Она не была сильно сложной, но какой-то магией от неё веяло. И захотелось мне написать что-то в таком духе; ну не прямо как там, а нечто отдалённо напоминающее.
Сюжет
Есть лабиринт, есть ограничения, которые надо преодолеть (преграды, противники и прочее), по лабиринту разбросаны предметы, позволяющие эти ограничения преодолевать.
Усложняем задачу
Если суметь грамотно отделить логику игры от управления и картинки, можно будет на той же основе сделать, скажем, трёхмерную игру: то же самое, то вид от первого лица. Или графика получше. В общем, идея понятна. Это потребует некоторой сноровки в проектировании кода.
Перед началом работы
Сперва, следует обсудить некоторые базовые вещи, которые нам потребуются.
Во-первых, логическая карта - массив NxM единиц, на основании которого будет строиться карта. Проще говоря, у нас есть таблица с N строк и M столбцов; каждая ячейка - это зона на карте (кирпичная стена, проход или ловушка). При отрисовке, зная индекс массива (если он одномерный) или смещение по обеим осям (если массив двумерный) - можно нарисовать квадратики на экране, которые будут являться картой. Позже покажу, как это работает.
Во-вторых, мы не идём по пути усложнения. Каждая ячейка на карте имеет некоторый размер (скажем, 10 на 10 точек). Это значит, что все объекты карты помещаются либо в этот размер, либо в набор таких вот ячеек (скажем, большой катящийся шар, размером 40 на 40 пикселей - это 4 ячейки). То есть понятно, один объект - одна ячейка. Хотя могут быть исключения - точка (скажем, выстрел пули - это просто точка). Персонажи, кстати, тоже мерятся количеством ячеек от 1 то X.
В-третьих, взаимодействие - решается логической задачей нахождения пересечения 2 ячеек. Если все ячейки одного размера - задача решается проще, нежели если ячейки могут быть разного размера. А мы, как я писал выше, не будем усложнять. Пересечение двух ячеек приводит к
В четвертых, важно определить скорость игры. Это принципиальный момент. Если скорость объектов станет достаточно великой, они начнут, например, проскальзывать через преграды. Но это лишь меньшее из зол. С этой темы и начну. Ну и хорошо, когда этот параметр управляем: плавность важна.
Скорость игры
Наш персонаж движется с определённой скоростью (скажем, x), его противники движутся немного медленней, процентов на 20 (0.8х), а выстрел - вдвое быстрее - 2х.
Скорость обновления картинки, обычно, определяется частотой экрана. У меня это, например, 60 Гц, но бывают мониторы и быстрее. Также скорость обновления картинки зависит от видеокарты, которая может давать просадку в частоте кадров. Мы не будем усложнять, просто примем, что частота обновления составляет 60 Гц (но вообще, частоту кадров можно посчитать, скажем, так).
Зная номер кадра, можно просто каждый N-ый кадр двигать объект: каждый первый, каждый пятый, каждый двадцатый... каждый шестидесятый; решение принимается на основании простого вычисления: если frameNo % rate == 0 - то объект перемещается.
Неприятность такого подхода, что скорости будут различаться слишком сильно и не получается плавно управлять скоростями, что желательно. Но это решаемо. Скорости делятся на несколько (скажем, 10); для каждого объекта - своя скорость (у кого-то 5, у кого-то 3). Затем, в ход вступает формула:
frameNo % 10 <= speed
Это работает так: если у нас 37-й кадр, а скорость объект - 6, то с 30 по 36 кадр объект будет двигаться, а 37, 38 и 39 - нет. На 40-м кадре опять движение восстановится.
Итак, напишем первый скрипт, который будет перемещать:
В целом неплохо, но результат далёк от идеала: объекты движутся несколько рвано.
Как улучшить динамику движения? Очень просто - надо принимать решение о перемещении, вычисляя вещественное смещение:
Не идеально, конечно, но сильно лучше. Код я не оптимизировал (а там много чего есть), но для демо-сцены - хватит.
Повышаем скорость
В настоящий момент скорость довольно низкая и составляет не более 1 точки раз в 10 милисекунд, то есть 6 точек в секунду. А что делать, если нам надо ускориться?
Тут применяется две хитрости. Первая - оценивается место, где скорость надо увеличить. Скажем, в кат-сценах совсем не обязательно соблюдать правила, т.к. там вся логика заскриптована.
Вторая - математическая. Как я писал выше, наша карта - это набор логических клеток. Но, у каждой логической клетки может быть свой внутренний размер (скажем, 10 единиц), а отрисовываемая ячейка - может иметь иной размер (скажем, 50 пикселей), то есть масштаб 1 к 5. Вычисления проводятся для логической карты, что исключает возможность случайно залезть за за границы.
Добавляем коэффициент 5 и смотрим, как изменилось движение:
А для очень высоких скоростей - можно применять много хитростей: превращать линию (например, выстрел лазером) сразу в набор точек (любое пересечение - суть - попадание). Или, например, проверять пересечения героя сразу на всей линии траектории и останавливать движение в случае столкновения рядом с точкой столкновения. Анимацию можно подкрутить для каждого случая отдельно.
Код поменялся не сильно:
Как по мне, скорость увеличилась и, хотя я подкорректировал плавность, есть ещё над чем поработать. Но мы пока это пропускаем: дорабатывать нюансы хорошо, когда проект готов, а то можно растратить все ресурсы в самом начале, но до конца его так и не довести.
Делаем карту
Как было сказано ранее, у нас есть логическая карта и карта - отображаемая. Все вычисления производятся на логической карте; отображение - отдельно.
Если мы пойдём по пути, когда перемещение объектов на карте сразу производятся на одну ячейку (как было в ранних версиях боулдер даш - можете сами посмотреть аналог от Ориона), то требования сильно сокращаются, но мы, конечно, так делать не будем.
Логическая карта - это двумерный массив (я буду использовать одномерный, это не принципиально). Каждый элемент обладает набором качеств внутренних и внешних.
Внутренние качества значит что из себя представляет элемент: пол, стена, болото или шипы. Нам хватит 1 байта на такой элемент.
Внешние качества используются - универсальные, т.е. одинаковые для любого элемента. Они, обычно, определяют его логические размеры (скажем 10 на 10 точек) и предельную скорость перемещения внутри элемента (скажем, 1 точка).
Чтобы сэкономить на вычислениях, можно сделать размер 100 на 100 и скорость смещения делать не 0.8 а просто 8 единиц. Разумеется, скорость не может быть больше 100.
Положение подвижных объектов на карте придётся вычислять по 2-м показателям: номер ячейки и смещение относительно неё. Если реальный размер не соответствует 100 на 100 точек - понадобится проводить пересчёт к размеру. Но мы это чуть позже разберём.
Для начала давайте определимся с тем, какие для начала будут ячейки:
- Пол - по нему можно двигаться;
- Стена - через неё нельзя перемещаться;
- Дверь - требует наличие ключа, чтобы пройти.
Также будут элементы, с картой не связанные, но участвующие в процессе:
- Главный герой;
- Противник;
- Ключ.
Как видите, пока что всё по-минимуму. Но нам сейчас главное начать.
Первые контуры
Я набросал небольшой код и, в общем виде, он выглядит так:
Подробности я пока опущу, добавлю потом. Тут мы сперва рисуем карту, затем отрисовываем персонажей. Получается вот так:
Объясню по цветам:
- красный - стена
- зелёный - ключ
- ярко-голубой - герой
- сиреневый - противник
- оранжевый - выход
- чёрный - открытое пространство.
Получился такой вот код, описывающий общие моменты:
Объясню немного код:
- Размеры карты используются для вычисления положения плитки на экране; технически, высота не обязательна, но пусть будет.
- Логический размер (clWidth, clHeight) ячейки 100x100, что позволяет устанавливать вполне себе удобное перемещение объекта по карте без использования вещественных чисел (для нас это не принципиально, но память экономит значительно, как и скорость вычислений).
- Карта (map) байтовый массив. Я записал его в виде таблицы, но это, фактически, одна строка. Зная ширину карты вычислить положение не предоставляет труда.
- Коэффициенты приведения kMapX, kMapY - позволяют пересчитывать логическое смещение на логической же плитке в реальные координаты на экране (к сожалению, тут мы используем вещественные числа, хотя есть способы обойти это).
Добавляем управление
Надо позволить нашему персонажу перемещаться. Управление будет производиться стрелками. Тут всё просто, считываем код зажатой кнопки, интерпретируем его и перемещаем объект на карте. Но это не совсем правильный подход: у нас обработчик события и перерисовка экрана в таком случае будут рассинхронизированы. Поэтому мы будем сохранять текущее состояние нажатых кнопок, а вычислять позиции будем прямо перед отрисовкой.
Наверное, вы уже заметили, что объект имеет 2 типа координат: номер клетки и смещение на ней. Это потом очень пригодится!
Как можно заметить, стены пока что игнорируются. И это не случайно: в настоящий момент управление отдельно, а логика отдельно. Но это мы исправим.
В чём тут сложность: когда мы вычисляем перемещение, проблем нет. Однако, если на пути героя - стена, нельзя ему позволить двигаться. То есть мы можем изменить параметр положения только после проверки. Существует довольно большое количество способов провести такую проверку, я буду делать так:
- вычислю новые координаты героя;
- определю, с какими элементами карты он пересекается;
- если все элементы пустое пространство - запишу новые значения;
- если с новые координаты завели персонажа в стену - провести корректировку сперва по вертикали, потом по горизонтали (или наоборот) таким образом, чтобы на стену объект не залазил; возможно, сгенерировать событие "упёрся в стену" (но это потребуется уже для врагов, управляемых компьютером).
Всё относительно просто. Ну, за дело!
Система обработки столкновений
Сейчас у нас движение героя выполняется так:
Но, очевидно, что так не правильно. Вообще, у нас должно быть отслеживание двух типов столкновений: блокирующий и неблокирующий.
Блокирующий - это когда упёрся в стену, неблокирующий - когда пересёкся с каким-то объектом (противником, ключом, дверью). Второй тип обрабатывается уникально. Но давайте, пока что, сделаем так, чтобы герой в стены начал упираться. Для этого перепишем код.
Теперь добавились проверки на столкновения, что, само по себе, решает поставленную задачу. Выглядит всё теперь так:
Прямо захотелось сразу усложнить задачу, добавить ячейки, снижающие скорость у героя и повышающие у противника, но пока решил сдержаться. Двигаемся дальше.
Столкновения с игровыми объектами
До сих пор мы решили вопрос столкновений со стенами относительно дёшево: мы просто проверяем, не сталкивается ли герой со стенами "прозванивая" гипотетические ячейки, в которых он должен оказаться. Однако, игровые объекты хотя и привязаны к ячейкам, но имеют ещё и смещение, что подразумевает возможность пересечения даже при "привязки" к разным клеткам и не гарантирует пересечение при "привязке" к одной.
Короче говоря, тут сложность задачи повышается, но не сильно. Обсудим входные условия:
- необходимо определить сам факт пересечения 2-х фигур;
- необходимо понять, с какой стороны произошло пересечение (скажем, на тот случай, если это должно отбросить персонажа).
Решается задача пересечения 2-х прямоугольников очень просто: хотя бы одна из вершин одного прямоугольника должна по координатам быть внутри другого.
RectB_x0 >= RectA_x_i <= RectB_x1
То есть всего 4 проверки (две для x и две для y). Хотя в рамках нашей задачи есть и более простые решения, я покажу его позже.
Проверку надо выполнять для каждого игрового объекта карты в момент перед отрисовкой (возможно, это изменит что-то; ключ, скажем, исчезнет, если главный герой возьмёт его).
Также, сразу добавим немного оптимизации: нет смысла проводить проверку на пересечение каждой фигуры с каждой. Достаточно сравнивать лишь те, которые привязаны к соседним клеткам. То есть если объект находится на клетке [3,3], то проверять на пересечение следует лишь с объектами на клетках: [2,2], [3,2], [4,2], [2,3], [3,3], [4,3], [2,4], [3,4], [4,4]. Если на экране будет 1000 элементов - это даст некоторый прирост к производительности. Код выглядит так:
Обратите внимание, в функции isIntersect производится проверка пересечений объектов простым вычитанием: если расстояние между объектами меньше логического размера ячейки, значит есть пересечение. Выглядит проще, не правда ли?
При пересечении с ключом - ключ будет взят и можно будет выйти в дверь.
Опасный противник
Итак, основная игровая динамика сделана; теперь будет усложнять - добавим опасного противника, который не просто будет стоять, но будет и перемещаться.
Логика для него будет не сложной: он будет просто метаться по лабиринту.
- Выбрать случайное направление;
- Продвинуться по нему на 5 клеток (если возможно);
- При столкновении со стеной - повторить алгоритм.
Как видите, противник хоть и опасен, но туповат. Немного подумав, я добавил ему непредсказуемости - он двигается не на 5 а на случайное число клеток и вдвое быстрее героя. Вышло так:
Код для управления им - весьма прост:
Следующим шагом будет добавить опасности: при встрече с героем игра будет завершаться. Заодно, при выходе в дверь - игра также будет завершаться, но уже победой.
Подведение промежуточных итогов
Итак, у нас есть простенькая, но игра. Посмотреть её можно тут (надеюсь, ссылка проживёт достаточно долго). Только после нажатия кнопки "Run" - кликните мышкой на изображение, чтобы фокус подхватился и кнопки управления стали бы работать. Кнопки стандартные: верх, низ, лево, право.
Безусловно, очень много всего хочется улучшить. Надо добавить звуки, текстуры, побольше врагов. Да и лабиринт усложнить было бы не лишним.
Но это чуть-чуть немного позже. А пока - читайте продолжение.