javascript

«Обратная змейка», часть 2: читаю комментарии, чиню баги и добавляю босса

  • четверг, 17 сентября 2026 г. в 00:00:07
https://habr.com/ru/companies/ruvds/articles/1081436/

Когда я выкладывал первую часть, я, если честно, рассчитывал на десяток лайков и тишину в комментариях: ну кто будет всерьёз разбирать змейку, слеплённую за два вечера. Не угадал — комментариев набралось прилично, и добрая половина оказалась не «мило, годно», а разбором полётов: кто-то поймал баг, кто-то — нечестный спавн змей вплотную к игроку, кто-то сравнил игру то с Pac-Man, то с Asteroids. И вот сижу я, читаю это всё по второму кругу и понимаю: технически игра работала, но местами работала не на 100 процентов своей идеи, а это не то же самое, что работала хорошо. В общем, решил пройтись по всему списку, а не отделаться сердечком в ответ.

Что оказалось реально сломано

Начну с самого стыдного, чего я даже не заметил, когда читал в первый раз. alexs963 прислал текст гейм-овера: «Вас съела жёлтой змея» и свой рекорд. Я подумал, что комментарий обычный (неплохой, просто показать свой максимум), а потом, когда начал читать все комментарии, заметил, что по-русски коряво звучит, и тут я решил проверить: это у него проблемы или у меня? Как оказалось, у меня — субъект должен стоять в именительном падеже, а не в родительном.

Причина очень глупая: в объекте с названиями цветов лежали формы вроде yellow: 'жёлтой', которые потом подставлялись в шаблон Вас съела ${цвет} змея. Работало, если мысленно достроить другое предложение, и калечило, если читать буквально:

const reasons = {
    green: 'Вас съела зелёная змея',
    yellow: 'Вас съела жёлтая змея',
    red: 'Вас съела красная змея'
};

Три минуты работы, но именно такие баги обиднее всего пропускать — их видит буквально каждый, кто хоть раз проиграл.

Второй случай оказался занятнее. Owyn написал, что яблоко при движении:

«мерцает, будто пытается убить эпилепсией»

И что вообще всё происходит быстрее классической змейки, а яблоко:

«будто скользит по льду»

Полез разбираться и нашёл в собственном коде забавную штуку. И очевидно, что дело не в чьём-то мониторе, но одна и та же переменная gridSize в коде отвечала одновременно и за количество клеток на поле, и за размер клетки в пикселях, из-за чего внутреннее разрешение canvas почти никогда не совпадало с тем, как браузер реально растягивал его через CSS. Пиксельный рендеринг плюс нецелый коэффициент масштабирования — ровно то, что даёт дрожание картинки на движении. Развёл эти две сущности по разным углам: разрешение canvas теперь мерится через devicePixelRatio и подгоняется под реальный размер контейнера, число клеток живёт отдельно. Заодно решилась и жалоба на тесноту поля, раз пиксельный размер клетки больше не зависит от их количества — спокойно увеличил поле, не потеряв резкость. Один баг закрыл две жалобы сразу, что, признаю, приятно.

Третья история была самой долгой. gerbert_MX и Metotron0 независимо указали на нечестность: змеи спокойно проходили друг сквозь друга и, по словам gerbert_MX:

«могли создавать закрытые объёмы»

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

const free = perpOptions.find(o =>
    !obstacles.some(ob => ob.x === head.x + o.dx && ob.y === head.y + o.dy)
);
if (free) { this.dx = free.dx; this.dy = free.dy; }

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

if (occupiedByTick[tick].has(this.cellKey(part))) return false;

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

Баланс: слишком много всего сразу

Я и сам ещё в комментариях к первой части признавался, что баланс на счёте 200–300 разваливается: змей уже прилично, скорость фиксированная и высокая с самого начала, поле маленькое. Sfekss сформулировал точнее всех: в оригинальной змейке скорость растёт постепенно вместе со съеденными яблоками, а у меня игра стартует сразу «на реактивной тяге».

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

Формулу очков поменял по мотивам сразу двух комментариев. gerbert_MX предлагал считать результат как «скорость × время», а jouilk23 удачно объяснил зачем:

«это будет мотивировать игроков рисковать ради больших результатов, а не просто прятаться в углах на минималке»

Спорить не с чем, поэтому теперь за каждый тик начисляется не единица, а текущий уровень сложности:

this.score += this.currentStageLevel;

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

Кто или что такое это яблоко

лор яблока
лор яблока

Отдельная ветка комментариев меня искренне повеселила, хотя объективно я сам напросился. Wesha написал:

«Яблоко б на лягушку заменили, а то яблоко, которое само по себе ездит…»

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

«моглиб хоть ноги ему приделать»

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

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

Новый враг: червь

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

Фотография врага — червя — и его путь
Фотография врага — червя — и его путь

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

Способности и лишние жизни

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

Способности
Способности

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

Главное новое — битва с боссом

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

  1. Жетоны. Королева насылает зелёных змей, нужно собрать три золотых жетона, уворачиваясь.

  2. Удар по площади. Зоны атаки: сначала предупреждающая оранжевая заливка, через секунду — уже настоящий удар. Параллельно нужно успевать собирать растущие яблоки — каждое визуально увеличивает игрока.

  3. Корона. Королева роняет корону, вокруг неё нужно обойти восемь меток по кольцу. Ближе к завершению кольца

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

Вместо итога

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

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

Код лежит на GitHub, поиграть можно прямо в браузере: novelros.github.io/reverse-snake.

P.S. Если найдёте очередной баг, придумаете, чего игре не хватает, или просто хочется похвастаться рекордом — пишите в комментариях, как в прошлый раз. Судя по всему, это реально работает.

© 2026 ООО «МТ ФИНАНС»