Создатель Mario о джуне: 40-килобайтный платформер без графики и бот-тестировщик уровней

Прокомментировать Просмотры: 7

Всем привет! Я занимаюсь фронтендом, а по вечерам развиваю агрегатор IT-вакансий Workaem. Недавно мне захотелось создать рядом с ним что-то более расслабленное, и за пару выходных родилась аркада в стиле Марио, полностью посвященная карьере в технологической сфере. Проект получил название «Путь джуна». Вместо привычных грибов здесь собирают офферы, черепах заменяет легаси, а в финале замков вместо дракона поджидают собеседования — от коварного тестового задания до финального раунда.

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

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

Что представляет собой игра

Структура классическая: четыре мира, по три уровня в каждом (Джун, Мидл, Сеньор, Лид). Сначала игрока ждет стандартная локация, затем подземный этап или уровень с лифтами в облаках, а в финале — замок с мини-боссом. Успешное прохождение замка повышает грейд: справился с тестовым — стал джуном, и так вплоть до финального собеседования и заветного оффера.

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

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

Мир 4, «Корпорация»: горит прод, слева ползет стена дедлайна.
Мир 4, «Корпорация»: горит прод, слева ползет стена дедлайна.

Почему обошлось без React и тяжелой графики

Проект задумывался как кроссплатформенный: в него можно играть как в обычном браузере, так и прямо в Telegram-чате в качестве Mini App. Пользователи мессенджеров не любят долгие загрузки, поэтому я с самого старта минимизировал сторонние ресурсы. Сборка выполнена на Vite и TypeScript без использования фреймворков. Для Canvas-игры тяжелый React избыточен, поэтому рантайм-зависимостей из npm в проекте попросту нет.

Графических файлов тоже не предусмотрено — абсолютно все спрайты и фоновые изображения генерируются программно. На начальном этапе это были примитивные прямоугольники, но после перехода на кривые Безье и градиенты визуальный стиль перестал напоминать набор геометрических фигур. На текущий момент объем JavaScript-файла составляет 94 КБ, а весь проект целиком (включая HTML и CSS) укладывается в 40 КБ с учетом gzip-сжатия. Из внешних источников подгружаются лишь Telegram SDK и пиксельный шрифт из Google Fonts.

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

Звуковое сопровождение силами кода

Включение в сборку тяжелых аудиофайлов мгновенно раздуло бы ее размер. Решением стал программный синтез всех звуковых эффектов и трех фоновых мелодий (для открытых локаций, подземелий с замками и режима отпуска) с помощью Web Audio API. Каждое звуковое событие описывается набором нот, содержащих частоту, тайминг, длительность и иногда изменение тона:

const PATTERNS: Record = {
  jump: [{ freq: 320, at: 0, dur: 0.14, slideTo: 620 }],
  stomp: [{ freq: 180, at: 0, dur: 0.1, slideTo: 60, type: "sawtooth" }],
  // Две ноты вверх создают классический жизнерадостный джингл сбора монетки.
  coin: [
    { freq: 988, at: 0, dur: 0.07 },
    { freq: 1319, at: 0.06, dur: 0.13 },
  ],
  // ...и еще около десятка звуковых эффектов

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

env.gain.setValueAtTime(0.0001, start);
env.gain.exponentialRampToValueAtTime(peak, start + 0.005);
env.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);

Поскольку современные браузеры блокируют воспроизведение аудио до совершения пользователем каких-либо действий, контекст AudioContext инициализируется «лениво» — в момент первого клика. Если инициализация по какой-то причине срывается, игра продолжает работать в бесшумном режиме, избегая критических сбоев. Стоит отметить, что во время отпуска активируется уникальный трек: в ретро-играх половина атмосферы неуязвимости строится именно на смене музыкального сопровождения, сигнализирующей об изменении правил.

Как на экранах 144 Гц игра ускорялась в 2,4 раза

На этапе прототипа игровая физика рассчитывалась на каждый вызов requestAnimationFrame. На моем мониторе всё выглядело корректно, пока я не протестировал скорость прохождения на разных устройствах. Если при 60 Гц уровень проходился за стандартное время, то на 120 Гц скорость возрастала вдвое, а на 144 Гц — почти в 2,5 раза. О какой-либо честной таблице рекордов в таких условиях не могло быть и речи.

Эта проблема имеет классическое решение: расчеты физики и рендеринг должны быть разделены. Темп фиксируется константой TICKS_PER_SECOND = 75, которая находится как раз посередине между привычными значениями частоты кадров.

function frame(now: number): void {
  let elapsed = now - prevTime;
  prevTime = now;

  // Если вкладку свернули или мобильное устройство усло, 
  // без ограничений накопились бы сотни тиков, 
  // из-за чего персонаж мгновенно пролетел бы полкарты.
  if (elapsed > 250) elapsed = STEP_MS;
  accumulator += elapsed;

  let steps = 0;
  while (accumulator >= STEP_MS && steps < MAX_CATCHUP) {
    accumulator -= STEP_MS;
    steps += 1;
    step();
  }
  // Если железо не справляется, лучше слегка замедлить процесс, чем копить огромный долг вычислений.
  if (steps === MAX_CATCHUP) accumulator = 0;

  renderer.draw(world);
  requestAnimationFrame(frame);
}

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

Процедурная сборка уровней из модульных блоков

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

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

function rng(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Алгоритма mulberry32 хватает с избытком для нужд аркады. Поскольку структура «Стартапа» детерминирована, статистика собирается по конкретным уровням: соревноваться в прохождении определенного этапа можно, а вот суммарный счет за весь забег зависит лишь от того, сколько успел пробежать игрок до первой гибели.

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

Виртуальный композитор сканирует уровень пошагово, подбирая подходящие по сложности сегменты: в начале попроще, ближе к дверям — сложнее. Поверх этого наложено несколько эмпирических правил, появившихся после горького опыта плейтестов. Каждый четвертый блок служит передышкой, предотвращая утомление от монотонного подъема. Каждый третий фрагмент заимствуется из текущего тематического мира — иначе новая фича затерялась бы в общей массе (например, если труб всего 4 из 30, рандом вполне мог собрать уровень вообще без них). Система выбирает случайный элемент из ближайшего пула подходящих по сложности, исключая монотонность, а жесткое правило запрещает ставить два одинаковых фрагмента подряд во избежание эффекта копипаста.

Настоящая раскладка уровня «Легаси»: двадцать кусков, которые композитор выбрал между вступлением и ручной концовкой, пунктир это его цель.
Настоящая раскладка уровня «Легаси»: двадцать кусков, которые композитор выбрал между вступлением и ручной концовкой, пунктир это его цель.

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

«Легаси»: ротации алертов вокруг серверных стоек. Их нельзя растоптать и нельзя сбить тестами, только переждать.
«Легаси»: ротации алертов вокруг серверных стоек. Их нельзя растоптать и нельзя сбить тестами, только переждать.

Формирование кривой сложности

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

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

Числа посчитаны по текущим картам.
Числа посчитаны по текущим картам.

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

Баги, ускользающие от человеческого глаза

При процедурной генерации карт ручной визуальный контроль становится бесполезным — критический дефект может проявиться лишь на одном сиде из сотен. Полагаться на удачу больше не имело смысла, поэтому большинство описанных ниже багов превратились в регулярные тесты внутри команды npm run build.

Яркий пример — «парение» персонажа над землей. Крупный спрайт рендерился с высотой 15 пикселей при фактическом хитбоксе в 22 пикселя. Нижняя граница хитбокса корректно касалась земли, но отрисованный аватар висел на семь пикселей выше. В игре это выглядело как странноватая анимация прыжка, и догадаться искать корень проблемы в рассинхронизации на семь пикселей было непросто. Пришлось править спрайт, поскольку трогать хитбокс нельзя — на этих 22 пикселях завязана вся геометрия платформинга. Теперь тест check:sprites подставляет в рендерер тестовый объект, который перехватывает границы прямоугольников и сверяет итоговый рост с размерами хитбокса.

Другая напасть — дрожание лифтов. У вертикальных платформ был задан отрицательный размах, из-за чего границы перемещения перепутались (например, от 67 до 45). Инверсия направления срабатывала каждый кадр, платформа и стоящий на ней игрок начинали безумно трястись. Текущая сборка проверяет, что каждый лифт успешно преодолевает свой рабочий диапазон.

Вдобавок периодически не засчитывалось прохождение уровней. Зона финиша проверялась как прямоугольная область высотой 24 пикселя от пола, но персонаж, залетевший туда в высоком прыжке, проносился выше. Уровень не завершался, игрок упирался в стену за дверью и зависал намертво. Рядом обнаружился еще один дефект: удары по блокам снизу не регистрировались, так как проверка коллизий выполнялась уже после того, как физический движок выталкивал игрока из зоны ящика.

Эти последние два бага выявил не я, а специальный бот.

Автоматический тестировщик уровней

Игроки этого бота не видят — они управляют персонажем самостоятельно. Программа зашита в проверки сборки: npm run playtest загружает все двенадцать уровней и проходит их, используя ту же физику и логику боссов, что и основной клиент. Никаких упрощенных симуляций — задействуются те же файлы вроде world.ts, только вместо сигналов с клавиатуры решения принимает алгоритм. Если хотя бы один уровень оказывается непроходимым, сборка прерывается.

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

Всего их семь. Шесть используют фиксированную длину прыжка на протяжении всего этапа (короткий, средний, длинный, ранний, поздний и аккуратный). Седьмая стратегия, «по ступенькам», динамически подбирает силу прыжка под следующую опору. Она незаменима на участках, где провалы преодолевают по узким балкам: при максимальном разбеге игрок пролетает мимо и ударяется головой о преграду. Если стандартные подходы пасуют, бот запускает перебор из 60 вариантов со случайной, но детерминированной (по сиду) длиной прыжков для воспроизводимости результатов.

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

Вспомогательные проверки контролируют неочевидные для человека мелочи. check:play гарантирует, что каждого противника можно растоптать, а любой бонус или скилл доступен для сбора. Раньше враги ухитрялись перемещаться под низкими навесами, где их невозможно было достать прыжком. А правило check:rules отловило бонусную комнату, где верхний ряд скиллов висел слишком высоко — досягаемость рассчитывается по верхней точке макушки в пиковой фазе прыжка, а не визуально.

Античит, отвергший создателя

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

Минимально возможное время и верхняя планка очков вычисляются динамически на основе параметров самой карты — ее протяженности, количества бонусов и врагов. Я не хардкодю эти константы вручную: утилита npm run anticheat генерирует их на основе игровых данных. Иначе при добавлении нового уровня старые ограничения в воркере привели бы к тому, что честно победивший игрок был бы заблокирован как читер.

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

Авторизацию пользователей из Telegram я проверяю по подписи initData. Это HMAC-SHA-256, ключ для которого вычисляется на основе токена бота с константой WebAppData, с проверкой времени жизни auth_date и защитой от тайминг-атак. Таблица лидеров развернута на Cloudflare Workers и D1. В планах — перейти на валидацию через воспроизведение потока ввода вместо контроля очков, но до реализации руки пока не дошли.

Геймдизайнерские уроки, подсмотренные у Марио

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

Яркий пример — цепочки прыжков по головам врагов. Прыжок на одного противника, затем на второго и третьего без касания земли приносит прогрессирующую награду: 200, 400, 800, 1000, 2000 и 4000 очков, а после шестого успешного прыжка игрок получает дополнительную жизнь. Это добавляет элемент здорового риска ради ценного бонуса.

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

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

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

Право на ошибку ограничено: потеря всех жизней возвращает игрока к самому первому уровню. Однако успешное прохождение каждого этапа восполняет запас жизней до трех. Экран поражения стилизован под «Выгорание» и демонстрирует количество пройденных этапов и собранных скиллов.

Заставка перед уровнем. Без такой паузы уровни сливались в один длинный забег.
Заставка перед уровнем. Без такой паузы уровни сливались в один длинный забег.

На каждом уровне можно заработать три звезды: за успешное завершение, сбор всех навыков и удержание в рамках норматива времени. Индикаторы на карте мира мотивируют возвращаться и перепроходить знакомые этапы.

Технические грабли в процессе разработки

Черный экран на устаревших моделях iPhone. Вся графика строилась через метод ctx.roundRect, который появился лишь в Safari 16.4. На устройствах без свежих обновлений первый же кадр вызывал исключение TypeError, оставляя пользователя смотреть на пустой экран. Проблему решили внедрением фоллбэка на рисование дуг при отсутствии нативного метода и понижением таргета сборки до es2020. Мобильный Safari обновляется только вместе с ОС, и малейший незнакомый синтаксис в бандле приводит к полной неработоспособности страницы.

Искаженные замеры производительности. После перехода на кривые я измерил стоимость кадра циклом вызовов draw и получил впечатляющие 0,7 мс, причем метрика не зависела от плотности холста. Выяснилось, что Canvas2D лишь ставит команды в очередь выполнения, и замерялось время их постановки, а не фактического рендеринга. Реальную картину показывает джиттер кадров внутри requestAnimationFrame. Опираясь на эти данные, я ограничил множитель плотности холста двойкой: на мобильных GPU производительность в разы ниже, а визуальной разницы между коэффициентами 2 и 3 на Retina-экранах все равно не рассмотреть.

Ошибки деплоя, о которых молчал CI. Скрипт выкладки проверял код ответа сервера, и тот стабильно возвращал статус 200 даже в случаях, когда заливка файлов срывалась, из-за чего пользователи продолжали видеть старую версию. Однажды обрыв соединения с rsync и команда set -e молча завершили скрипт, оставив в продакшене билд со старым багом, который я считал побежденным. Теперь деплой-скрипт сверяет хэш собранного бандла с тем, что фактически отдает сервер.

Замок второго мира: HR-скрининг кидается вопросами.
Замок второго мира: HR-скрининг кидается вопросами.

Заключение

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

Буду рад услышать ваши технические отзывы и предложения по улучшению! Что, по вашему мнению, стоило бы добавить в проект?

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов