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

Когда я выкладывал первую часть, я, если честно, рассчитывал на десяток лайков и тишину в комментариях: ну кто будет всерьёз разбирать змейку, слеплённую за два вечера. Не угадал — комментариев набралось прилично, и добрая половина оказалась не «мило, годно», а разбором полётов: кто-то поймал баг, кто-то — нечестный спавн змей вплотную к игроку, кто-то сравнил игру то с 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 там же, между жалобой на скользящее по льду яблоко, вставил:
«моглиб хоть ноги ему приделать»
Спорить с логикой было не с чем, идея действительно абсурдная с самого начала, но менять саму суть игры не хотелось — шарм именно в этой нелепости, а не вопреки ей.
Пошёл на компромисс: приделал яблоку ножки, причём не статичную пару, а двухкадровую анимацию шага. Заодно накрутил небольшой ролик на первый запуск — обычное яблоко без глаз и ног стоит на поле, сверху капает что-то зелёное, вспышка — и вот у него уже есть глаз и пара шагающих ножек. Никаких объяснений в правилах, просто маленькая мутация в прямом эфире.
Новый враг: червь
В комментариях предлагали разные способы сделать угрозы разнообразнее, и один вариант — враг, появляющийся не с края поля, а прямо посреди него, откуда его сложнее предвидеть. Сделал червя: сперва на случайной клетке начинает мигать серая метка-предупреждение, через пару секунд оттуда буквально вылезает тело — по сегменту за шаг, не подтягивая хвост, — и, полностью появившись, ползёт по прямой на игрока, пока не упрётся в стену или другую змею (которую, как и остальные, обходит).

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

После потери жизни игрок возрождается в центре и автоматически получает тот же щит на три секунды — иначе респавн ощущался как повторное наказание за одну и ту же ошибку.
Главное новое — битва с боссом
Родилось из простого вопроса: а почему бы не сделать разовое событие вместо бесконечного выживания, да и вообще во всех играх такого типа есть босс, и это уже не что-то новое. И я решил, что в отдельном режиме сверху опускается баннер-босс, первый и пока единственный — это королева змей — большая зелёная, слегка следящая взглядом за игроком, чтобы было ощущение чего-то серьёзного над вами.
Жетоны. Королева насылает зелёных змей, нужно собрать три золотых жетона, уворачиваясь.
Удар по площади. Зоны атаки: сначала предупреждающая оранжевая заливка, через секунду — уже настоящий удар. Параллельно нужно успевать собирать растущие яблоки — каждое визуально увеличивает игрока.
Корона. Королева роняет корону, вокруг неё нужно обойти восемь меток по кольцу. Ближе к завершению кольца
Как кольцо замкнуто — короткая немая сценка: яблоко надевает корону, оставшиеся змеи разворачиваются и разбегаются с поля, и только потом экран победы с честным временем прохождения. Саму сценку в зачёт не включаю, иначе «победить как можно быстрее» перестало бы быть честным.
Вместо итога
На первую версию ушло два вечера, на вторую — кажется, ещё больше, хотя формально код там был готовый и работающий, просто пришлось признать, что «работающий» и «честный» — не одно и то же. Из отложенного — второй босс с другой механикой, достижения и, может, локальный кооп на одном поле, если получится не превратить это в кашу (пока не знаю, как, но идея зудит).
И это ещё не все идеи из комментариев — часть я отложил сознательно, и не потому что они плохие. Все они по-своему прекрасны, но если хвататься за каждую идею из комментариев, вторая и, возможно, третья часть не вышла бы никогда — так и разгребал по одному пункту за раз, а остальное осело в заметках на потом.
Код лежит на GitHub, поиграть можно прямо в браузере: novelros.github.io/reverse-snake.
P.S. Если найдёте очередной баг, придумаете, чего игре не хватает, или просто хочется похвастаться рекордом — пишите в комментариях, как в прошлый раз. Судя по всему, это реально работает.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.