Changelevel в Quake и Half-Life

Сообщения
67
Реакции
85
Источник: csm.dev
Автор статьи: Дядя Миша

Changelevel в Quake и Half-Life
Статья из цикла по внутреннему устройству Quake-движков

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

Changelevel в Quake 1

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

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

А как реализовать, допустим сохранение информации обо всей игре, да еще такой большой и разнообразной, какой по тем временам была Quake? Как ни крутись, но все способы оптимизации сохранений относятся именно к вычленению нужной информации и отбрасыванию той, что в сохранении не нуждается. Данный процесс не поддается никакой автоматизации, поэтому решение о том, какие переменные сохранять, а какие нет принимает кодер, пишуший игровой код. Для QuakeC хинт компилятору, указывающий что эту переменную неплохо бы сохранить в сейв носил вид точки, проставленной перед объявлением типа данных. Каким образом QuakeC взаимодействовал с различными типами данных мы подробно здесь рассматривать не будем, оставим это для другой статьи, где я подробно опишу работу виртуальной машинки Quake, а здесь же просто примем как данность, что точка означала принадлежность переменной к структуре entvars_t, которая силой виртуальной машинки и желанием пользователя могла расширяться до бесконечности. И все поля из этой структуры попадали в сейв-файл, а потом, соответственно восстанавливались заново. Сам процесс сохранения в Quake крайне примитивен. Чтобы не усложнять работу над движком выдумыванием очередного формата файлов (для сейвов), разработчики попросту сдамипили содержимое памяти виртуальной машинки. И записили это в виде обычного текстового файлика, на манер .ent файлов. В этом легко убедиться, отрыв .sav файл первого квейка в любом блокноте. Рассмотрим этот формат чуть подробнее.

Вначале идут глобальные переменные для всего уровня:
1. саундтрек
2. название карты
3. 10 уникальных параметров игрока (информация о здоровье, оружии, подобранных предметах и прочем).
4. имя карты (не название, а реальное имя)
5. лайтстили (по одному лайтстилю в строке)
6. Далее у нас дамп всех глобальных параметров VM (хотя вполне возможно, что это только в darkplaces, т.к. у меня нет под рукой чистого Quake)
7. Ну и наконец - описание энтить. Точь в точь как в ent-файле. Вот только параметры - текущие на момент игры.

Использование виртуальной машинки значительно облегчало сериализацию некоторых деликатных моментов, например таких, как сохранение указателей на функции. В текстовом виде мы просто-напросто записывали в это поле имя функции, а VM сама разбиралась какой указатель ей соответствует. Теперь стоит сказать пару слов о восстановлении, поскольку вы скорее всего подумали, что вместо ent-файла, вшитого в карту грузится ent-файл, "вшитый" в .sav-файл. А вот и нет! Не всё так просто. Сначала карта грузилась в штатном режиме, как будто никакого сейва и не было вовсе. Для неё так же вызывалась пара кадров физики, дабы все установленные энтити могли "устаканиться".

А вот дальше начиналось самое интересное: движок удалял ВСЕ энтити с карты и только тогда грузил новые из .sav-файла. К чему такие сложности, спросите вы? Объясню, хотя для этого и придется отойти от основной темы достаточно далеко в сторону. Дело в том, что в оригинальном Quake сетевой код был на редкость примитивен по сегодняшним меркам. Там даже не было дельта-компрессии отосланных пакетов (это когда отсылаются лишь те переменные, которые действительно изменились). И весь этот механизм потреблял довольно приличное кол-во траффика. Конечно для сингла это не имело значения, но многие энтити, в силу своей неинтерактивности (а таковыми являлись например факелы на стенах и прочая аналогичная ерунда), во первых заставляли сервер дополнительно напрягаться, перерасчитывая их видимость для игрока, а во вторых занимали драгоценные слоты серверных эдиктов, коих в первой кваке было всего 600 штук. В качестве оптимизации была предложена функция MAKE_STATIC, которая полностью переносила энтить на клиент, в особые клиентские слоты. Данные слоты имели возможность самостоятельно анимировать модельку, играть звук, а также линковаться в реальные листья статической геометрии уровня, благодаря чему видимость таких объектов считалась автоматически вместе с видимостью уровня. Т.е. если данный кусок уровня виден, значит виден и объект, попавший в данный узел сетки.

К слову сказать в Half-Life2 данная нехитрая система вылилась в чудовищный класс CLeafRenderableSystem и обросла массой дополнительных возможностей (кто изучал SDK, наверняка его видел). Всем хороша данная система, да только вот с сейв-рестором не особо дружит. Поскольку, если эдикт удален с сервера, то его как бы и в сейв сохранить не получится. Вот именно поэтому Quake и грузит изначально уровень, как будто никакого сейва и не было - даёт статик-энтитям время создасться и перейти на клиент. Никаких важных параметров, подлежащих сохранению они не содержат, поэтому для них тупо выполняется респавн, как вы уже поняли, из вышепрочитанного. К слову сказать данная, с учётом тамошнего сейв-рестора не могла быть воспроизведена и потому не использовалась. А починить её удалось лишь в Half-Life2, с введением полноценных клиентских сейвов. Но это - уже совсем другая история.

Итак, принцип сохранения и восстановления игры нам боле-мене понятен. А как быть с чейнджлевелом? В данном случае - очень просто. В Quake игрок не мог возвращаться назад, на те уровни, на которых он уже побывал. Точнее говоря, технически он конечно же мог, но с удивлением бы застал эти уровни такими же, как и в первый раз до прохождения. Вся информация об убитых монстрах, положении дверей и подобранных предметах существовала лишь до тех пор, пока игрок не касался чейнджлевела. А тогда из всех настроек отбирались самые важные, которые должны были перейти с игроком на следующий уровень, а все остальные попросту отбрасывались. К таким настройкам относилась информация о броне, здоровье, подобранных предметах, оружии и самом важном - информация о собранных рунах.
Вся эта информация не сохранялась в какой-то особый файлик, а просто висела в оперативной памяти, дожидаясь, пока игрок не надумает сделать сейв самостоятельно. Вот таким нехитрым образом и реализован changelevel в Quake - несколько параметров, которые применяются к игроку на следующей карте.

Changelevel в Half-Life

Ну и настала пора поговорить о сложном. Настолько сложном, что я боюсь, далеко не все читатели данной статьи поймут как работает данный механизм. Но - буду стараться. Итак, changelevel в Half-Life. Зачем разработчикам понадобилось делать столь сложную и эффективную систему в далёком 98 году я решительно отказываюсь понять. Однако могу привести вам пару любопытных фактов о ней. Первый факт - когда я впервые увидел Half-Life, я не мог отделаться от впечатления, что вот эта надпись на экране - фоновая подгрузка одной громадной карты. Второй факт - данная система вошла в Source с минимальными доработками, без каких либо кардинальных изменений. Минимальные доработки свелись к внедрению полноценного клиентского сейва и возможности сохранять туда клиентские энтити, например те, которые были созданы при помощи MAKE_STATIC, темпэнтити, партиклы, звуки и прочую аналогичную ерунду. Всё это помогло сделать сейв-рестор практически идеальным, сохранив абсолютно все ньюансы, но кардинальным изменением это конечно не назовешь. Да и сам по себе сейв-рестор лишь является малой частью большой и сложной системы changelevel в Half-Life.

Для данной системы было выдвинуто несколько условий, которые она с успехом и обеспечивает:
1. должно сохраняться АБСОЛЮТНО всё на всех уровнях, где уже побывал игрок.
2. игрок может возвращаться на ранее пройденный уровень с любого другого и находить там именно ту обстановку, которую он оставил, покидая уровень
3. Переход между уровнями должен быть незаметным. В идеале игрок и не должен понять, что произошло.
4. Между уровнями переходить может не только игрок но и любые другие предметы, будь то двери, поезда, монстры, вообщем всё что угодно.
5. Все уровни могут быть связаны между собой системой глобальных переменных, которые обеспечивают изменение условий на одном уровне, после того как сами изменения переменных были сделаны на любом другом.

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

Итак, по пунктам:
1. Игра должна сохраняться АБСОЛЮТНО всё на всех уровнях, где уже побывал игрок.
Это пожалуй самый простой пункт. Мы сейвим состояние всех энтить на конкретном уровне, где сейчас находится игрок и называем этот файлик mapname.HL1
При переходе на новый уровень мы ищем аналогичный файлик с названием новой карты. И если он есть - грузим игровую ситуацию именно из него. Ну а если его нету - тоже не расстраиваемся, создаем его заново, когда игрок надумает сохранится. Разумеется при таком подходе встает немало технических проблем, но мы разберемся с ними позже. А пока перейдем ко второму пункту.

2. Игрок может возвращаться на ранее пройденный уровень с любого другого и находить там именно ту обстановку, которую он оставил, покидая уровень
Частично данное требование уже перекрывается первым. Однако, как я уже упоминал определенные технические сложности не позволяют с наскоку реализовать систему, где игрок покидает уровень через один чейнджлевел, а возвращается через другой. Ну например нечто подобное может произойти, когда игрок берет с собой какого-нибудь монстра на другой уровень, а потом сделав круг возвращается на него через третий. В таком разе вполне возможна ситуация, когда данный монстр продублируется на старом уровне, поскольку будет ожидать обновления состояний с того же чейнджлевела, с которого игрок изначально ушел. Чтобы исключить подобные несостыковки Half-Life просматривает ВСЕ чейнджлевелы на уровне и составляет о них подробную информацию, которую также сохраняет в сейв.
Правда информация о номерах этих чейнджлевалах кодируется как верхняя часть поля с обычными флагами и представляет собой битовую маску для двухбайтного слова.

Из чего вытекает принципиальная невозможность поставить на одном уровне больше 16 чейнджлевелов. Впрочем в HL2 подобное ограничение точно также сохраняется.

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

Для начала разберем понятия локального и глобального пространства. Как следует из названия локальное пространство относительно, а глобальное - абсолютно. Глобальные координаты игрока предполагают, что абсолютно на всех картах игрок с одними и теми же координатами очутится в одной и той же точке. Есть ли в этой точке сам уровень или же там пустота зависит лишь от конкретной карты, но сами координаты будут неизменны. Локальная же координата всегда считается относительно другого объекта. Поскольку ротационный компонент в этой системе отсутствует (он бы позволил поворачивать новый уровень относительно исходного на произвольный угол с сохранением плавности перехода, но в свою очередь еще более усложнил бы и без того сложную систему чейнджлевелов, поэтому данной фичи нету даже в HL2 - за ненадобностью), то вычисление локального пространства получается простым вычитанием координат ландмарка из координат игрока (да и не только игрока, а вообще любой энтити). Получившиеся координаты будут РАССТОЯНИЕМ ДО ЛАНДМАРКА. Предполагается что на новой карте движок найдет еще один ландмарк с точно таким же именем (само имя может быть произвольным) и прибавит его координату к локальным координатам игрока, вновь переведя позицию игрока в абсолютные координаты, но! уже с учётом нового уровня. Вот почему для ландмарка так важны оба его доступных параметра - имя и местоположение. В качестве совета мапперам могу сказать, что для плавного перехода следует просто копировать участок старой карты с чейнджлевелом и ландмарком в новую. Тогда переход станет поистине плавным. Но сам trigger_changelevel следует сдвинуть назад, чтобы наш игрок не оказался внутри него на новом уровне. Однако вернемся к теме. Как вы уже наверное догадались этот фокус с локальными координатами можно проделывать не только с игроком, но и вообще, с абсолютно любым предметом на текущем уровне. Что с успехом и выполняется. А заставлять рассчитывать локальные координаты при смене уровня нашу систему сейв-рестора заставляет аттрибут FIELD_POSITION_VECTOR в описании данных, предназначенных для сохранения в сейв.

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

А вот на этом пункте нас и поджидает натуральная засада, во многом связанная со вторым условием. Но и без него хватает проблем. Самой мерзкой проблемой является тот факт, что брашевые модели вкомпилены прямо в текущую карту и никоим образом не могут быть загружены отдельно. Почему - вы уже наверное догадались. Потому что данные модельки ссылаются на оптимизированные данные в карте, которые могут быть использованы несколько раз, некие узлы BSP-деревьев, но даже это в сущности не такая уж большая преграда. Вопрос в другом - как маркировать данную модель? Указать, что впервые она была загружена на карте такой-то с порядковым номером таким-то? Сохранять в отдельный крохотный BSP-файлик? А потом вкомпиливать прямо в сейв? Всё это бы усложнило и без того непростую систему. Поэтому решение проблемы технично перепихнули с больной головы на здоровую - на мапперскую. Маппер на новой карте просто делал копию старой модели и таким образом достигалась связь поколений. Ну а связь между двумя энтитями на разных картах обеспечивалось параметром globalname, который мог иметь абсолютно любое значение, лишь бы две одинаковых энтити на разных картах имели один и тот же глобалнейм. Поскольку брашевые энтити обычно не имеют привычки ходить сквозь уровни, то и сама система их перехода оказалась предельно проста - если брашевая энтить "переходила" на другой уровень (например func_tracktrain), то на текущем перед сменой уровня она засыпала - она лишалась модели, переставала вызывать функцию Think, ей запрещалась отрисовка на клиенте, ставился нулевой размер и нематериальность. Ну а если энтитя вдруг возвращалась обратно, то перенос глобальных параметров с предыдущей карты с легкостью затирал все эти усилия и восстанавливал её в нужном виде.

Поэтому, несмотря на необходимость прописывания globalname перенос этих энтить технически несложен. А вот с энтитями, имеющими модельку в формате mdl всё оказалось куда сложнее и печальнее. Во первых сама по себе реализация энтити, свободно гуляющей между уровнями (между HL1 файлами) не так уж и проста, из-за вышеописанного эффекта возвращения на старую карту третьим-четвертым путём. Во вторых чисто технически случай, когда монстр перешел вслед за игроком, но при этом оказался за пределами уровня бывает сплошь и рядом. Для выполнения этого условия монстру достаточно попасть в PVS игрока на предыдущем уровне и с удивлением обнаружить что на новом уровне то место где он находился уже за пределами уровня.
То есть мы физически не имеем права стирать данные об этом монстре с предыдущего уровня, на случай вот таких вот эксцессов. А как же быть тогда?

Задача действительно не из простых. В Valve предложили решение в виде дополнительно информации - файликов Entity Patch (расширение .HL3). Каждый такой файлик содержит в себе кучу переменных размером в 4 байта. Каждая переменная содержит номер энтити на старом уровне, которую следует удалить - это означает, что энтить действительно перешла на новый уровень и нету нужды хранить информацию о ней на старом. Обратите внимание - ФИЗИЧЕСКИ информация об энтите никуда не исчезает. Просто данная энтить после успешного восстановления стейта будет тут же удалена с карты и в следующий сейв попросту не попадёт. Поскольку формат Entity Patch очень простой его можно записывать, имея в своем распоряжении минимум информации о предидущей карте - достаточно лишь таблички репрезентации эдиктов (ETABLE), которую легко прочитать из предыдущего сейва. Ну и собственно таким образом данная система гарантирует нам, что энтить не исчезнет бесследно за пределами уровня, однажды выпав за него на новой карте.

Данный любопытный процесс Half-Life иллюстрирует сообщением в консоли, по типу:
Suppressing entity <name>.

Если же перенос прошел успешно, то сообщение меняется на
Transsfering entity <name>.

Ну а для брашевых энтить с глобальным именем оно и вовсе выглядит как
Merging changes for global <name>

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

5. Все уровни могут быть связаны между собой системой глобальных переменных.

Ну а данный пункт несмотря на его кажущуюся сложность довольно таки просто в реализации. Дело в том, что все энтити, которые используют глобальные переменные, такие как trigger_auto, multisource или env_global на самом деле не являются носителями этих переменных. Они просто обращаются к поименованным переменным на предмет чтения и записи. Ну а сами переменные без затей пишутся в сейв. Кстати о сейве. Я неоднократно упоминал про файлы с расширением HL1 и HL3, но ведь их удаление не влечет за собой удаление самого сейва! Как же это происходит? Вот об этом-то мы сейчас с вами и поговорим.

Формат сейв-файла в Half-Life

Тело сейва разделено на две части - локальную и глобальную. Глобальная часть включает в себя те самые глобальные переменные, о которых мы говорили и еще три поля. Первое - это имя карты, на которой в данный момент находится игрок. Второе - комментарий для показа в меню (опционально), ну и третье, самое интересное - mapCount. mapCount - это общее число файликов, типа HL1, Hl2 или Hl3 вписанных в тело файла .SAV. Сам формат вписывания опять таки предельно просто и не содержит обращений к сейв-рестор системе:

имя файла с фиксированной длинной (максимум 128 символов)
длина файла (4 байта) тело файла.

Приведенных данных, как вы понимаете вполне достаточно, чтобы успешно скопировать получившийся файл на диск. А чтобы одинаковые файлы из разных сейвов не перемешивались, Half-Life просто и без затей удаляет все HL? файлы по маске перед любым движением, связанным с загрузкой новой игры, карты или началом новой игры. Кроме всего прочего он удаляет эти файлики при выставлении маппером особого поля в worldspawn под названием newunit. Данную настройку следует выставлять, если ваша игра, подобно Quake не предусматривает возвращения на прежние уровни. Тогда вся информация о прежних уровнях будет стёрта и вес .SAV файла значительно уменьшится, и как следствие - грузится он будет быстрее. В 98 году это было вполне себе актуально. Ну, о примерном содержании Hl1 я вам уже рассказал. Добавлю лишь, что в нём так же хранится таблица лайтстилей, таблица эдиктов (ETABLE), локально-глобальные переменные для текущего уровня (локальные с точки зрения всей игры, но глобальные для всего уровня), как правило это настройки для неба. Таблица ADJACENCY - информация о соседних уровнях с минимальным набором данных - именем карты, именем ландмарка, локальным смещением от ландмарка и указателем на его реальную энтить. Ну и конечно же исчерпывающая информация о самих энтитях. К слову сказать информация о том, что собой представляет данная энтить - является ли она глобальной, переносимой иои просто локальной также хранится отдельно в уже упомянутой таблице энтить ETABLE.

Ну и напоследок давайте представим работу всей системы в целом. Начнем с чистого листа, когда никакой информации о ранее сделанных сейвах у нас нету. Для этого при запуске движка все файлики с расширением HL1, HL2, HL3 автоматически удаляются из папки save, если конечно они там имеются. Итак мы заходим на первую карту и делаем сохранение. Движок создает три файла: HL1, HL2, HL3. Эти файлики вместе с вышеописанным хидером записываются в файл .SAV и... остаются лежать в директории save. Для чего - сейчас станет понятно. Наш игрок успешно добирается до чейнджлевела, где движок делает еще один сейв, но в отличие от предидущего не продуцирует файл .SAV. Он просто обновляет ранее упомянутые HL1-3 файлы. А информация о глобальных переменных и вовсе остается висеть в оперативной памяти. Далее движок грузит новый уровень, однако не грузит с него энтити. Вместо этого он пытается найти в папке save файлики HL1-3, соответствующие данному уровню. Нету?! Ну и хрен с ними, значит грузим обычные энтити из карты, а затем читаем сейв с предидущего уровня на предмет того факта, что некоторые энтити нам надо перенести на новый, либо смержить глобальные. Во время этого интересного процесса единственный файлик, который имеет право обновится - это HL3, тот самый Entity Patch. Файлы HL1-3 по прежнему остаются лежать в корне папки save. Игрок на новой карте хочет сохранится вновь.

И вот тут в дело вступает уже накопительный процесс - к ранее сделанным сейвам на предыдущей карте добавляется еще три сейва на текущей. Три - имеется в виду три файлика, с расширениями HL1, HL2, HL3. При очередном чейнджлевеле процесс снова повторяется, разумеется с учётом имени новой карты. Если же игрок захочет загрузится с ранее сделанного сейва, то все файлики HL1-3 будут удалены и распакованы заново из того самого сейва, из которого мы хотим загрузится. Ну вот, примерно так эта система и работает. Осталось еще несколько моментов, о которых хотелось бы упомянуть. Файлик HL2 содержит в себе информацию о клиентском положении дел, но в Half-Life она толком не работает и потому содержит в себе исключительно декали. Которые кстати тоже весьма неохотно переходят между уровнями, главным образом из-за кривой реализации всего этого дела. В HL2 клиентский сейв полностью доведен до ума и там таких проблем нету.

Заключение

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

 
Последнее редактирование:

Пользователи, просматривающие эту тему

Сейчас на форуме нет ни одного пользователя.
Сверху Снизу