[Перевод] Заканчивается 2022 год, а я всё ещё использую Flash

В 2020 году Adobe прибила Flash Player, но я не захотел, чтобы мои Flash-игры пропали навечно.

С разными промежутками я делал игры всю свою жизнь, но людям особенно нравилась серия Hapland, поэтому я решил, что неплохо было бы исправить их для релиза в Steam. Можно нарисовать графику получше, повысить частоту кадров и разрешение, а может, и добавить новых секретов и тому подобного.

ihineqj7-7exym5egp5whf_u3tm.png


Hapland 2

Проблема в том, что игры Hapland по большей части созданы на Flash. Графика нарисована во Flash, код написан во Flash, все анимации выполнены в таймлайне Flash. Технология Flash стала плотью.

Как же мне их улучшить?

Неудачная попытка №1


Первым делом я попробовал экспортировать игры из Flash как исполняемые файлы. Тогда это была бы очень короткая статья, но попытка не удалась, потому что производительность оказалась почти такой же низкой, как в 2005 году. Я хотел сделать качественный продукт, работающий с современной частотой кадров. Мне хотелось освободиться от Flash Player.

Неудачная попытка №2


Потом я потратил кучу времени на эксперименты с Adobe AIR (десктопной средой исполнения Flash) и Starling (библиотекой, отрисовывающей Flash-сцену через GPU).

В конечном итоге я сдался, в том числе и потому, что AIR забагован и ужасен. Кроме того, мне не хотелось, чтобы в конце у меня получилось что-то на основе технологий Adobe; мне нужен был мой собственный проект, с которым я могу делать всё, что пожелаю. Что, если бы я захотел портировать его в Linux? Мне не хотелось полагаться на мнение Adobe о том, могу ли я это сделать.

Поэтому моя задача была очевидной: я должен создать собственный Flash player.

План


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

gidzg_chgtjckkvbjn7tycqxu80.png


Строчные буквы «a» в таймлайне — это действия кадров.

К счастью, файлы .fla — это просто XML. Мне достаточно было распарсить их, экспортировать релевантные данные в какой-нибудь формат, написать плейер для их считывания, отрисовывать сцену, обрабатывать ввод и проигрывать анимации. [У меня было искушение использовать для экспорта обычный SWF, потому что Flash знает, как в него экспортировать, но это мне не подошло бы, потому что SWF — это векторный формат, а я не хотел выполнять растеризацию во время выполнения, потому что это ограничивает возможности работы со скриптами и по другим причинам, которые я уже не помню.] Ещё мне нужно было что-то сделать с ActionScript.

Hapland останется Flash-проектом, написанным и поддерживаемым во Flash editor; я заменю только Flash Player.

Растеризация векторов


В основе Flash лежит векторная графика. Он поддерживает растровые изображения, но на самом деле разработан под вектор. Именно благодаря этому Flash-ролики загружались так быстро даже во времена модемных соединений. Вся графика Hapland векторная.

GPU не любят отрисовывать векторную графику, им нравятся кучи текстурированных треугольников. Поэтому мне нужно было растеризировать эти векторы.

Я решил растеризировать их офлайн и упаковать растровые файлы в игру. Было бы интересно, если бы игра растеризовала их во время выполнения, тогда бы исполняемый файл оказался бы крошечным, но мне не хотелось усложнять систему. Мне нравится, когда максимально большая часть кода работает на моей собственной машине для разработки и я могу её контролировать.

Flash хранит свою векторную графику в формате XML. Вы можете сказать, что XML — это плохой выбор для графических данных, но, увы, вы не были продукт-менеджером в Macromedia. Узрите:

6_itpz3a2gjef7nrwjgsyay1brs.png
Векторные данные из файла .fla

Но я не жалуюсь, на самом деле это упрощает мою работу.

Хотя у меня и не было доступа к спецификациям, растеризация — не такая уж сложная задача. Модель векторной графики на кривых Безье после появления PostScript распространилась повсюду. Все эти API работают одинаково. Немного помучившись с разбором того, что значат все эти !, [ и так далее, я написал программу для парсинга этих описаний фигур и рендеринга их в PNG при помощи библиотеки CoreGraphics на Mac. [!: перемещение в точку. | или /: прямая линия до точки. [: сегмент Безье до точки. S: понятия не имею.]

CoreGraphics оказалась сомнительным выбором. Я решил взять её, потому что работал на Mac, а она уже там есть, и зависимости жёстко заданы. Но из-за этого мне всегда приходилось растеризировать графику на Mac, даже для сборок под Windows. Если бы мне пришлось делать нечто подобное ещё раз, я бы, наверно, выбрал кроссплатформенную библиотеку.

После рендеринга этих PNG программа экспорта собирает их в атласы. Она делает это довольно наивным образом: просто сортирует всё по по высоте и выставляет построчно как текст в документе. Это далеко не оптимально, но вполне приемлемо.

Чтобы не усложнять, атласы имеют размер 2048×2048 пикселей — минимальный размер текстур, который обязаны поддерживать реализации OpenGL 3.2.

drak1xvj_fobzadkgaqdnuntu8u.png


Атлас из Hapland 3

Растеризация фигур выполняется довольно медленно, поэтому чтобы время выполнения сборок было приемлемым, мне нужно было пропускать рендеринг того, что не изменилось. В сжатом zip формате XML, который использует Flash, есть поля с указанием последнего изменения для каждого файла, однако Flash, похоже, использует их неправильно, и на них нельзя полагаться. [И это можно заметить — на сохранение крупных документов Flash требуется целый век, даже если вы внесли лишь небольшое изменение; вероятно, он вообще не отслеживает время последнего изменения.]

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

Используем ассемблер для записи двоичных файлов


Программа экспорта записывает данные в специальный двоичный формат. [Я люблю по возможности использовать в ПО для конечных пользователей двоичные форматы; не считаю, что нужно заставлять компьютеры пользователей пережёвывать кучи XML.] Она просто проходит кадр за кадром по таймлайну и записывает в файл все изменения для каждого кадра.

Здесь я придумал одну идею, которая мне очень нравится: запись не напрямую в двоичный файл, а в текст на ассемблере. В нём нет команд CPU, только данные. Это упрощает отладку, потому что я могу посмотреть в ассемблерном файле, что было сгенерировано, а не изучать байты в hex-редакторе.

fbr9nldiklhqbalwtaz7lz5k3uu.png


Какой из файлов вы бы предпочли отлаживать?

Я мог бы просто сделать так, чтобы программа экспорта записывала байты в один файл и одновременно отдельный текст в другой файл, без использования ассемблера, но не сделал этого, потому что 1) ассемблеры уже существуют, 2) мне не нужно их отлаживать и 3) они поддерживают метки. [При записи двоичных файлов часто бывает нужно записать местоположение одного бита данных в другой бит данных, а эти два местоположения могут находиться в файле в любом порядке. Очень упрощает жизнь возможность всего лишь записать dd mylabel с одного конца и mylabel: с другого, чтобы всем остальным занимался ассемблер.]

Остальная часть программы экспорта по большей мере неинтересна; она просто обходит дерево и выполняет конвертацию таких элементов, как матрицы преобразований, цветовые эффекты и тому подобное. Так что вернёмся к самой программе игры. Я решил писать её на C++, потому что уже знаю его, а новые вещи меня пугают.

Граф сцены


Hapland отлично подходит для графа сцены. Это модель, которую использовал Flash, и на её основе спроектирована серия Hapland, поэтому не было смысла использовать другую модель.

Я храню сцену в памяти в виде привычного дерева узлов (Node), каждый из которых имеет transform, может отрисовывать себя и принимать щелчки мышью. Каждый игровой объект с собственным поведением является экземпляром собственного класса, производного от Node. Сегодня в кругах разработчиков игр не особо модна объектоориентированность, но я работаю с Flash, поэтому меня это не волнует.

В игре сохранились функции Flash, которые использует Hapland, например, преобразования цветов и маскирование, однако вместо реализации произвольного маскирования, как это делал Flash, я просто реализовал усечение прямоугольников и отредактировал всю свою графику так, чтобы все маски были прямоугольниками.

Скрипты кадров


Почти вся логика Hapland — это фрагменты ActionScript, прикреплённые к кадрам таймлайна. Как мне всё это экспортировать? Я не хотел добавлять в игру интерпретатор ActionScript.

cnuylnsg23nmuisjpockic1s1rm.png


Простое действие кадра

В конечном итоге, я решил использовать небольшой хак. Моя программа экспорта считывает ActionScript из каждого кадра и применяет к нему несколько регулярных выражений, чтобы попытаться превратить его в код на C++. Например, crate.lid.play() может превратиться в crate()->lid()->play();. Эти два языка достаточно похожи синтаксически, поэтому это сработало для многих простых действий кадров, однако остался приличный объём поломанного кода, и мне пришлось вручную переписать все оставшиеся действия кадров.

После переноса всех скриптов кадров на C++ они извлекаются во время сборки и становятся методами для подкласса Node каждого символа. Также генерируется метод отправки для их вызова в нужное время. Он выглядит примерно так:

void tick() override {
    switch (currentFrame) {
        case 1: _frame_1(); break;
        case 45: _frame_45(); break;
        case 200: _frame_200(); break;
    }
}


Здесь также стоит отметить, что система скриптинга в конечном итоге оказалась со статической типизацией, и это удобно, потому что ActionScript таким свойством не обладает. [ActionScript 3 начал двигаться в этом направлении, но серия Hapland написана на ActionScript 2.] Выдаваемые программой экспорта игровые объекты выглядят так:

struct BigCrate: Node {
    BigCrateLid *lid() { return (BigCrateLid *)getChild("lid"); }
    BigCrateLabel *label() { return (BigCrateLabel *)getChild("label"); }

    void swingOpen() { ... }
    void snapShut() { ... }
    void burnAway() { ... }
};


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

Соотношения сторон


Ах, эти соотношения сторон. Все, кто преобразует старые медиа в новые форматы, обожают их. Оригиналы серии моих игр предназначались для браузера и не рассчитывались на запуск в полный экран, поэтому в них использовалось любое соотношение сторон, которое мне подходило. В каждой игре оно было своим, но все они были примерно равны 3:2.

Похоже, сегодня самое популярное соотношение сторон — это 16:9, а на ноутбуках ещё популярно и 16:10. Я хотел, чтобы игра выглядела в них хорошо без чёрных полос и растягивания картинки. Два варианта решения этой проблемы: вырезать части оригинала или добавить новые части.

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

ogmyiluh4vg3-lig5pljutcl6dw.png


Границы 16:10 (жёлтые) и 16:9 (голубые) для Hapland 2 в сравнении с исходным 3:2 (зелёные)

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

Кошмар цветовых пространств


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

Если при растеризации векторной графики вам нужен результат со сглаживанием, растеризатор будет выдавать значения альфы, которые являются так называемыми «значениями покрытия». Это значит, что если пиксель наполовину закрыт векторной фигурой, этот пиксель будет выведен с alpha=0.5.

Но если во Flash что-то имеет альфу 0.5, то это значит, что этот элемент воспринимается как среднее между цветами переднего плана и фона.

А это совершенно разные вещи! [Так же работает большинство других редакторов изображений. В некоторых сегодня есть кнопка «сделать правильно», но она редко включена по умолчанию. Если вы хотите глубже изучить эту любопытную тему, то обратите внимание, что большинство шрифтов выглядит тоньше, чем должно, потому что они спроектированы так, чтобы отображаться программами, выполняющими альфа-смешение неправильно.]

Белый пиксель с половинным покрытием, отрисованный поверх непрозрачного чёрного пикселя, не должен восприниматься как на 50% серый. Свет работает иначе, и растеризация векторов тоже выглядит иначе. (Растеризатор не может сказать «этот пиксель должен внешне выглядеть на X% между цветами фона и переднего плана», не зная цвета фона.)

aqa3h3zbld97ovcjuh1h7yau1wo.png


Смешение, выполняемое в пространстве восприятия (sRGB). Сверху: прозрачные белые пиксели на чёрном; посередине: прозрачные чёрные пиксели на белом; снизу: серые цвета

8j1zmh8go706pjmajoamiwzaubk.png


То же самое смешение, выполненное в линейном (физически точном) пространстве. Обратите внимание, что 50% покрытия не выглядит как 50% серого.

Итак, у нас есть сглаженные растеризованные фигуры, использующие одно определение альфы, и экспортированные из Flash альфа-прозрачность, градиенты и цветовые преобразования, использующие другое. Однако в нашем конвейере рендеринга есть только один альфа-канал. Как же рендерер должен интерпретировать значения альфы? Если он будет интерпретировать как коэффициенты смешивания пространства восприятия, то полупрозрачные объекты будут выглядеть правильно, однако сглаженные края всех изображений окажутся неверными. Если он будет интерпретировать их как значения покрытия, всё будет наоборот. Что-нибудь обязательно будет выглядеть неправильно!

Я придумал здесь только два строгих решения: 1) использовать два альфа-канала, один для покрытия, другой для смешения в пространстве восприятия, или 2) растеризовать все фигуры без сглаживания, отрисовать всё в очень большой буфер кадров, а затем уменьшить размер с фильтрацией. [При этом возникает вопрос, как же поступает Flash. Рендерер Flash напрямую работает с векторными данными без отдельного этапа растеризации, поэтому у него есть возможность выполнять сглаживание непосредственно в пространстве восприятия в процессе рендеринга, потому что он может определять цвет фона. А может быть, он просто использует полноэкранный суперсэмплинг.]

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

Чтобы убедиться, что всё остальное сделано правильно, я создал таблицу «цветового теста» с набором цветов разной яркости, эффектами поворота оттенков и тому подобным, отобразил её в игре и сделал так, чтобы она выглядела одинаково и в игре, и во Flash. [Эффекты цветовых преобразований Flash (поворот оттенков, изменение яркости и так далее) выражены в виде матриц 5×4 в пространстве sRGB, и для них достаточно легко решить проблему sRGB/линейного пространства. Простые матрицы достаточно просто преобразовать, а в случае всех остальных можно всего лишь экспортировать матрицу sRGB, чтобы потом игра вычисляла во фрагментном шейдере srgb_to_linear(ctr * linear_to_srgb(c)). Для этого нужны дополнительные такты GPU, но всё работает хорошо.]

9xzxlu2mqvj4bufw7mdnq0widu0.png


Всё совпало!

Частота кадров


Оригиналы игр на Flash работали с номинальной частотой кадров 24FPS, однако на самом деле они работали с той частотой, которая захочется Flash Player. Во Flash можно указать 24FPS, а получить 15FPS, а можно указать 30FPS и внезапно получить 24FPS. Всё это довольно глупо.

Я хотел, чтобы ремейк работал в 60FPS, то есть мне нужно было сделать с тем, что анимации Hapland создавались с расчётом на воспроизведение с частотой примерно 24FPS. (Инструменты анимации Flash используют дискретные кадры, а не непрерывное время.)

Сначала я сделал так, чтобы программа экспорта удвоила все кадры. То есть для каждого кадра таймлайна она экспортировала два кадра. Так я запросто получил 48FPS вместо 24FPS, что по-прежнему меньше 60, поэтому анимации будут работать на 25% быстрее. [Алгоритм удвоения кадров достаточно умён, чтобы интерполировать позиции объектов для кадров, являющихся частью анимаций движения (motion tween), поэтому они оставались плавными.] Как обычно, решать это пришлось кропотливым ручным трудом. Я просто поиграл в игры и вручную добавил кадры в те анимации, которые казались слишком быстрыми.

Теперь у меня имелся неплохой порт игр Hapland на C++, который точно будет работать на современных компьютерах, по крайней мере, десяток-другой лет. Но я не мог избавиться от ощущения, что мне нужно добавить ещё что-то ценное, поэтому я выбрал две вещи. Наряду с перерисовыванием большой части старой графики и анимаций я внёс пару серьёзных изменений.

Сохранение состояний


Я придумал их, чтобы сделать Hapland 3 чуть менее напряжной. Правильный маршрут в игре довольно длинный, можно испортить всё множеством разных способов, после чего придётся начинать сначала. Возможно, в 2006 году, когда мы были подростками, это и казалось интересным, но теперь у нас нет на это времени.

Сохранение состояний есть у эмуляторов. Игрок нажимает «save state», и программа запоминает целиком состояние игры, сбрасывая дамп памяти консоли в файл. Если что-то идёт не так, игрок нажимает «load state» и возвращается к сохранённому состоянию. [Некоторые люди просили меня добавить кнопку «undo», но, допустим, если вы выстрелите снаряд, а затем ударите его чем-то пока он летит и нажмёте на «undo», то что должно произойти? Принимать решения о дизайне пришлось бы в каждом конкретном случае. А таких случаев много. И благодаря сохранению состояний мне не пришлось делать этот выбор.]

Реализовать сохранение состояний в оригинальных играх на Flash было невозможно, поскольку Flash не даёт программисту доступа ко всему состоянию. Но поскольку теперь я использую собственный код, это реализуемо.

Я создал элемент под названием Zone, являющийся аллокатором, распределяющим всю свою память внутри блока фиксированного размера. Все узлы сцены распределяются внутри текущей Zone.

Для реализации сохранения и восстановления у меня есть две Zone — активная зона и отдельная «зона сохранения состояния». Для сохранения состояния я при помощи memcpy копирую активную зону в зону сохранения состояния. Для загрузки состояния я выполняю memcpy в обратную сторону.

Вторые квесты


Игры серии Hapland не особо длинные, поэтому во всех трёх я хотел добавить ещё несколько часов прохождения. Я решил создать в каждой игре «второй квест» — модифицированную версию игры, в которой структура уровней и головоломок слегка отличается. Создание такого второго квеста требует меньше работы, чем создание совершенно новой игры, но всё равно повышает привлекательность игры.

Для создания вторых квестов мне понадобилось впервые за пятнадцать лет снова углубиться в разработку головоломок на Flash, и, честно говоря, мне это вполне понравилось.

Старый UI Flash великолепен. У кнопок есть края. Значки выглядят как предметы. Пространство используется продуманно. Он потрясающий! При работе со старыми UI я ощущаю себя археологом, изучающим забытую древнеримскую технологию. Утраченное искусство дизайна UI. Это очень трогательно.

-_codtha74y40d15wsx1ecdpj-8.png


Что это за колдунство?

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

Чтобы вторые квесты не выглядели слишком похоже на первые, они получили новые фоны, а вся сцена была отзеркалена по горизонтали.

6pn-bka809szcf_d4zh_lyxnnnm.png


Hapland 3

h5gvbe39twg-qvi3nu-wtujcs1m.png


Второй квест Hapland 3

Музыка


Я быстренько написал по одному эмбиент-саундтреку для каждой игры из стоковых звуков и пары записанных мной аудиоклипов. Однажды, когда я был в отпуске в Японии, я взобрался на холм и без какой-либо причины записал аудио; было здорово, что мне удалось его использовать. Я нанял в Интернете музыканта, чтобы он записал музыку для главного экрана, а сам записал несколько аккордов на гитаре для титров в конце, а потом забил их эффектами, чтобы не было понятно, насколько плохо я играю на гитаре.

В зависимости от задач я использую для музыки Logic или Live. Мне кажется, Logic лучше для записи, а Live лучше для саунд-дизайна.

Достижения


У меня сложилось ощущение, что игроки ждут от игр в Steam достижений. Это раздражает, потому что по идее гейм-дизайнер должен сам решать, уместны ли достижения в игре, но особых проблем это не доставило.

Загрузка ачивок в Steam — это боль. Нельзя просто создать список и передать его инструментам командной строки; приходится кропотливо щёлкать мышью в медленном и запутанном болоте кода на PHP, которое представляет собой партнёрский сайт Steam, и одно за другим добавлять достижения.

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

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

ufg-vsrkwczb-4re3cy6l5csqne.png


Достижения в UI Steamworks

Проверка приложения


Хотя в основном я разрабатывал игру на Mac, в процессе разработки Apple изобрела систему под названием «Notarization» — когда вы запускаете любое приложение на новой версии MacOS, оно выполняет сетевой запрос к Apple, спрашивая, заплатил ли разработчик приложения ежегодный взнос Apple. Если разработчик этого не сделал, MacOS откроет диалоговое окно, в котором говорится, что приложение — это вирус, и откажется запускать его.

Поэтому Windows будет первой и, возможно, единственной платформой для релиза этой игры.

Использованные библиотеки


В ПО, которое я создаю для конечных пользователей, я люблю снижать количество зависимостей до минимума, однако с удовольствием пользуюсь несколькими самыми качественными. Наряду с OpenGL и стандартными библиотеками операционной системы исполняемый файл Hapland Trilogy ссылается на следующие библиотеки:

Конец


Вот и всё! Спасибо за то, что прочитали этот пост. Проект был интересным. Если технологически сделать всё правильно, то игроки ничего не заметят, поэтому мне иногда просто хочется сказать: смотрите, вот что я сделал.

Если вы хотите почитать другие написанные мной материалы, то вам не повезло, я не склонен публиковаться онлайн. Если хотите меня поддержать, то можете купить Hapland Trilogy или мою другую игру в Steam 2020 года, Blackshift. Если хотите поиграть в некоторые мои игры бесплатно, то на моём веб-сайте foon.uk есть множество браузерных игр. Самые новые написаны на Javascript или WASM, а самые старые, в том числе оригинальная серия Hapland, на Flash AS2, который довольно неплохо работает благодаря Ruffle. Более поздние Flash-игры написаны на AS3, поэтому больше не работают.

© Habrahabr.ru