Летоскопъ - игра с 360° сценами по истории России
Мне всегда, с самого детства нравилось программировать: Basic на "ламповом" ZX Spectrum собранным отцом, куча простых игр и утилит на Delphi и Visual Basic в школьные годы, 5 лет радиотеха с программированием микроконтроллеров на Ассемблере и дипломом на C++ и AHDL на ПЛИС'ах Altera.
Но суровая российская реальность конца нулевых вынудила меня двигаться немного в другом направлении в жизни. С самого начала и по сей день, работа всегда была интересная, связана с высокими технологиями и хорошо оплачивалась, но за более, чем 15 лет после института я почти ничего не написал сам и безнадежно отстал от современных технологий в программировании. Но появление, так называемого вайбкодинга, дало шанс немножко закрыть свой Гештальт.
Об игре
Сразу честно: идею я не придумал. Подсмотрел у проекта WenWare и нагло утащил. Но переделал полностью под историю России и кое-что докрутил.
Игра называется Летоскопъ https://letoskop.ru Суть такая - тебя кидает тебя внутрь исторической сцены. Вокруг панорама на 360 градусов, можно крутиться и разглядывать детали. Внизу линейка лет, сбоку карта. За пару минут надо понять, какой это год и где это происходит. Чем ближе угадал по времени и по месту, тем больше очков. Сейчас в игре 112 сцен, от призвания Рюрика до наших дней. В настройках игры можно выбрать сложность и интересующие эпохи.
Делал я это во многом для дочери. Ей десять, и она реально втянулась: садится, угадывает, попутно узнаёт про ключевые события нашей истории. Да и сам, пока собирал контент, неплохо подтянул знания. Например, с удивлением узнал, что столица Рязанского княжества стояла не там, где современная Рязань, а километрах в шестидесяти. После того как Батый сжёг старую Рязань дотла, город отстроили уже на новом месте.
Как это сделано
Панорамы рисовала нейросеть GPT Image 2, сразу в 4K. Промпты и для панорам, и для карточек события писала отдельно модель GPT 5.5 с доступом в интернет. Она лезла в источники, выясняла детали конкретного года (форма, оружие, архитектура) и старалась добавить в кадр одну узнаваемую деталь, чтобы у игрока был шанс догадаться, а не тыкать вслепую. Сам код игры писал Claude Opus 4.7.
Озвучку карточек прикрутил через Gemini TTS. Вот это оказалось лучшей фичей для детей: диктор зачитывает описание события вслух, и дочь слушает с куда большим интересом, чем читала бы текст с экрана.
Бэкенда у игры нет вообще, она полностью статичная. Но чтобы удобно собирать весь этот контент (генерить панорамы, карточки и озвучку, отбирать удачные варианты, публиковать), пришлось написать отдельную студию. Выглядит примерно так:
Что было самым сложным
Заставить GPT Image выдать правильную плоскую панораму, которая потом бесшовно заворачивается в сферу на 360 градусов. Второй момент - нейроартефакты фигур и лиц, в сценах где присутствует много людей (поэтому в оригинальной WenWare лица в сценах заблюрены). Артефакты были самой большой проблемой (т.к. не хотел блюрить панорамы), частично смог решить специальными рефайн прогонами, но несколько сцен я так и не победил и или не добавил их, или добавил, как есть. Именно на эти эксперименты ушло больше всего денег, потому что каждая попытка это новая генерация в 4K, а их были сотни.
В процессе замахивался на настоящие 3D сцены через Marble Worlds. Там можно не просто крутить панораму, а реально перемещаться внутри, как в Яндекс Панорамах. Результат вышел вполне сносный, но по сравнению с обычной плоской панорамой это сильно дороже и дольше, и не никак не вписывалось в рамки хобби-проекта
Сколько всё это стоило
Времени ушло около пары недель неспешной работы по вечерам, плюс отдельно дебаг и тесты. Денег около 20 тысяч рублей на API токены для картинок и озвучки (доступ к OpenAI и Gemini брал через российские шлюзы, дороже прямого доступа, но можно оплатить российской картой). Сюда не входит подписка на Claude, который писал код (использовал подписку Pro за 100$). Львиная доля бюджета утекла как раз на мучения с панорамами.
Итого
Игра бесплатная, без рекламы и без доната поверх экрана, но если вы хотите поддержать проект, можно сделать донат по ссылке в настройках игры. Что делать с ней дальше, пока честно не придумал. Есть идеи добавить мультиплеер и все-же попробовать сделать сцены в реальном 3d. Или выложить в open source, если найдутся энтузиасты допилить все до ума.
Поиграть: [letoskop.ru](https://letoskop.ru). Открывается и с телефона, и с компа, ставить ничего не надо.
Важная оговорка: сцены в игре являются реконструкцией событий, могут содержать исторические неточности, изображения и тексты не являются научными публикациями. Игра сделана исключительно в развлекательных целях.
Пишите в комментах Ваши мысли и предложения.
Сделал ежедневную «Быки и коровы» в браузере — без рекламы и регистрации
Всем привет! Я сделал Numbun — небольшую ежедневную браузерную головоломку с числами.
Ссылка: https://numbun.com/ru
Идея знакомая: классические «Быки и коровы», но в формате короткой ежедневной игры. Без регистрации, без рекламы, просто открыл — сыграл — поделился результатом, если захотел.
Русский для меня не родной, так что заранее извиняюсь за возможные ошибки в тексте или переводе.
Как играется:
нужно угадать загаданное 4-значное число, в котором цифры не повторяются. После каждой попытки игра показывает подсказки:
`+` — одна цифра стоит на своём месте
`-` — одна цифра есть в числе, но стоит не там
В отличие от Wordle, игра не показывает, какая именно цифра угадана. Видно только общее количество плюсов и минусов, поэтому приходится постепенно выводить ответ по нескольким попыткам.
Сейчас в игре есть ежедневная игра на 4 цифры и 10 попыток, обычный режим, настраиваемая игра, режим «Дело дня», статистика, серии и возможность поделиться результатом без спойлеров. Работает в браузере, можно добавить на экран и играть офлайн.
Буду рад честному фидбеку от пикабушников.
Numbun · Ежедневная #1
🧠 6/10 · ⏱️ 02:33
🟨⬛⬛⬛ -1
⬛⬛🟨⬛ -1
🟩⬛⬛🟩 +2
🟨🟨⬛🟨 -3
⬛⬛🟨🟨 -2
🟩🟩🟩🟩 +4 🎉
Спасибо всем, кто попробует.
Как я разрабатывал боевую систему для хардкор RPG и чуть не сошёл с ума от одной формулы
Привет. Меня зовут Виталий, я инди-разработчик и делаю хардкор фэнтези RPG на кроссплатформ. Сегодня расскажу как я провёл несколько дней в мозговом штурме пытаясь найти идеальную формулу поглощения урона. Спойлер: формула оказалась той ещё засранкой.
С чего всё началось
Я делаю игру в духе хардкор RPG — с настоящей глубокой боевой системой, нестандартной для смартфонов, пошаговыми боями, множеством героев, детальным крафтом снаряжения и боссами которых нужно продумывать а не просто закликивать. Движок Unity, сервер собственный, язык C#.
Когда дошло до боевого ядра — я понял что мне нужно сесть и выстроить всю механику на бумаге прежде чем писать код. Иначе потом переписывать всё с нуля.
Первые пять минут всё было хорошо
Я сопоставил все базовые вещи. 12 раундов на бой. 6 героев на поле плюс 4 в резерве. Передний и задний ряд. Враги сверху, герои снизу — вертикальный экран. Пошаговый бой, полный автомат, герои сами выбирают цели.
Потом начался цирк.
В игре 5 классов: Воин, Страж, Рейнджер, Маг, Жрец. У каждого свои базовые статы. 12 характеристик. Система звёздности, рангов, уровней, снаряжения. Гравировки. Бафы. Дебафы. Провокация. Воскрешение. итд.
Казалось бы, а чем плоха формула из Доты 2?
Поглощение = (0.052 × Броня) / (0.9 + 0.048 × Броня)
Красиво. Проверено годами. Работает в одной из самых сбалансированных игр в мире.
Но нет. У меня не работает.
Почему?
Потому что в Доте броня это маленькие числа — 5, 10, 20 единиц. У меня герои качаются до 80 уровня, имеют 6 рангов рамки, 20 звёзд, и топовое снаряжение даёт до 480% к защите. Цифры улетают в десятки тысяч. Формула Доты при таких числах либо даёт 0.1% поглощения либо 99.9% — никакой середины.
Ладно, сказал я. Придумаем свою.
Следующие несколько часов я подбирал константу
Я ушел от базовой казуальной формулы которая работает по типу АТК = (АТК / (АТК + ЗАЩ) - это вообще "забейте" она будет хорошо смотреться в кликерах но не в хардкор рпг. Затем я сменил тактику, работать от защиты а не атаки(так делают многие топ ММО) и перевести бонус к защите от снаряжение не + процент от базы, а пойти в плоскую величину(это тоже фишка топ ММО но есть свои косяки)
Новая формула выглядела просто:
Поглощение = Защита / (Защита + Константа)
Константа — это число которое определяет при какой защите поглощается ровно 50% урона. Всё логично.
Одна проблема — какое число?
Я перебрал: 300, 500, 1000, 3000, 6000, 7000, 12000, 20000.
При маленькой константе — новичок в серой рамке поглощает 80% урона и игра превращается в детский сад.
При большой константе — прокачанный танк в мифическом снаряжении поглощает 3% и жрец который бафает его броню на 50% выглядит как шарлатан потому что разница между 3% и 4.5% это издевательство а не механика.
В какой-то момент Жрец со своими +50% к броне как будто даёт танку святую воду вместо защиты. :D
Тогда я попробовал гибрид
Плоские цифры плюс проценты. Казалось элегантно — плоское даёт базовую защиту всем, процент усиливает тех у кого высокая база.
Считаем этап 3 — синий шмот, 40 уровень:
Воин: 37% поглощения. Норма!
Но потом смотрю на другие вариации и вижу что Воин, Страж, Маг и Рейнджер на каждом этапе поглощают примерно одинаково. Разница между самым толстым танком и хрупким магом — 5%. Это не хардкор RPG, это мусор.
Плоская часть шмота одинакова для всех классов и просто давит разницу между ними.
Семь лет в ArcheAge спасли проект
Я играл в ArcheAge семь лет почти до закрытия игры в мире(в РФ она все еще функционирует но вроде как отрезана от Кореи). И пока я кружил по одним и тем же граблям — на след. утро я пошёл на работу, погуглил скриншоты шмота из ArcheAge и вспомнил как там всё устроено.
Вернулся и думаю, давай возьмём их подход, ведь не зря это бестселлер 2014.
В ArcheAge шмот всегда давал два стата сразу — физическую защиту и магическое сопротивление. Тип брони определял пропорцию: тяжёлый давал много брони и мало сопры, лёгкий наоборот. И всё это были плоские числа — никаких процентов.
И главное — константу нужно подбирать не с потолка а под конкретную точку баланса. Голый прокачанный Воин на максимальном уровне и ранге без шмота должен поглощать ровно 50% урона. Это точка отсчёта. Под неё считаем базовую защиту Воина, и константа определяется сама.
Воин максимальная прокачка = 5000 единиц защиты
Константа = 5000
Поглощение = 5000 / (5000 + 5000) = 50% - норма
Элегантно. Просто. Работает.
голый | Мид. гейм | Лейт гейм
Воин 10% 32% 77%
Страж 11% 36% 81%
Маг 8% 24% 64%
Рейнджер 9% 26% 68%
Разница между Стражем и Магом на лейте — 19%. Страж реально толще. Маг реально хрупкий. Жрец который бафает танка наконец перестал быть шарлатаном.
Что ещё зафиксировал
Пока искал формулу — попутно выстроил всю систему:
Типы навыков — Вступление, Активная, Пассивная, Фатальная, Реакция. Все с шансом срабатывания.
Система прогрессии — уровень до 80, 6 рангов рамки, 20 звёзд четырёх типов (медь, серебро, золото, алмаз), тир героя от обычного до босса ( этого можно укротить и получить себе)
Механика рядов — автоатака всегда бьёт первый ряд, навыки с шансом 30/70. Провокация перехватывает все атаки на носителя с абсолютным приоритетом.
Крафт снаряжения — три типа брони с разными пропорциями защиты и сопротивления, шесть тиров редкости, гравировки, сет-бонусы за полный комплект.
Штрафы за неподходящую броню — Маг в тяжёлом шмоте теряет 5% маг атаки и HP за каждый предмет. Надеть можно всё что угодно, но цена есть. Это песочница.
Боевое ядро ещё не дописано — осталась механика блока, парирования, уклонения, воскрешения и визуальные эффекты. Потом верстка боевого экрана.
Если интересно — напишу продолжение когда дойду до кода.
А пока главный урок: если делаешь глубокую игру а не казуалку — не берите первую попавшуюся формулу из популярной игры. Контекст имеет значение. Дота это не мобильный хардкор RPG, и формула которая работает там будет выдавать мусор у вас.
Иногда решение приходит от задротства в ArcheAge. :D
Разрабатываю в одиночку. Вертикальный срез в процессе. Если есть вопросы по механикам — спрашивайте в комментариях.
Дневник разработки Silent Throne (v0.13.4)
Привет, Пикабу! На связи соло-разработчик хардкорного 2D-слэшера Silent Throne. Последние недели моя жизнь напоминала симулятор бешеного кодера, но сегодня я наконец-то могу сказать: «Ура, оно работает сполна!»
Я полностью закончил Книгу Навыков главного героя и, кажется, изобрёл идеальный велосипед для обработки боевых состояний.
🟥 Что было сделано (или как не сойти с ума от switch-case):
Изначально в игре была только базовая ветка Силы (привет любителям помахать двуручником). Но в планах стоял монолит из 4 веток прокачки: Сила, Интеллект, Выносливость и Живучесть. По 12 навыков в каждой!
Главная боль инди-разработчика на этом этапе — превратить кодовую базу в кашу. Чтобы этого не произошло, я применил жесткую изоляцию:
Кастеры на бегу: Магия Интеллекта («Огненные шары», прыгающие молнии) работает как Instant Cast. Скрипты автономны — они рождают снаряды, списывают ману, а персонаж остаётся в дефолтном стейте движения. Маг может бежать и спамить.
Берсерки в стойках: Физические комбо Выносливости («Двойной выпад», «Град клинков») жестко фиксируют тело героя. Под них написаны выделенные скрипты-состояния (STATES), которые намертво обрубают WASD-ходьбу, выжимают скорость анимации на 200% и наносят урон строго по кадрам взмаха меча.
🩸 Ультимативный костыль, которым я горжусь
Когда я написал ультимейты Танка («Каменный щит» и «Бастион бессмертия»), возникла проблема. Вся математика урона прописана на стороне скриптов мобов. Переписывать ИИ каждого зомби, чтобы он проверял наличие щита на герое, — это путь к депрессии.
Я написал квантовый перехватчик урона на стороне игрока. Он стоит на самой первой строчке покадрового Step-ивента героя.
Как это работает: если мобы нанесли урон на текущем кадре, игрок ловит дельту здоровья, на микросекунду откатывает здоровье назад, пропускает урон через прочность Каменного щита (или обнуляет Бастионом) и заносит уже порезанный остаток. Раздел проверки смерти получает честный урон, а код мобов остался девственно чистым.
🧟♂️ Бестиарий получил зубы
Чтобы игра не превратилась в избиение младенцев, я распределил всех запланированных монстров (от Гоблинов до будущих Вампиров и Орков) по 6 автономным группам: Crypt, Magic, Rocks, Steel, Toxic, Underworld.
Каждая группа получила по 3 автономных баффа и 3 дебаффа. Теперь Некроманты умеют вешать на героя «Печать безмолвия» (Сало), которая намертво блокирует хотбар магии, а призраки накладывают «Леденящий ужас», замедляя откат скиллов в два раза.
Сейчас я ушёл на генеральные тесты в катакомбы — проверять, как это всё синергирует между собой. Дальше на очереди — вывод тикающих иконок баффов на HUD и прописка полноценных квестовых цепочек.
Буду рад фидбеку и советам в комментариях! Как вам идея с откатом урона для просчёта щитов?
Делаю хоррор-игру про Мурино
Всем привет. Я разрабатываю небольшую хоррор-игру «Мурино: The Fog is Coming».
Идея простая: ты играешь за курьера, который приезжает в Мурино, чтобы доставить заказ. Казалось бы, обычная работа: найти подъезд, подняться на нужный этаж, оставить заказ у двери и уйти домой.
Но проблема в том, что дом начинает вести себя странно.
Этажи повторяются. Лестничная клетка будто не заканчивается. Где-то моргает свет, где-то слышны звуки, которых быть не должно. Чем дольше ты находишься внутри, тем сильнее появляется ощущение, что выйти будет не так просто.
Я стараюсь сделать игру не через дешёвые скримеры, а через атмосферу: пустые подъезды, серые коридоры, странный туман, одиночество и ощущение, что обычная панелька постепенно превращается во что-то неправильное.
Буду рад фидбеку: какие аномалии, события или детали вы бы хотели увидеть в хоррор-игре про Мурино?
СЛЕДИТЬ ЗА РАЗРАБОТКОЙ МОЖНО ТУТ
https://t.me/bySeverusov
Почему большинство инди-RPG никогда не выходят
За последние несколько лет я заметил интересную закономерность.
Практически каждый месяц появляются новые амбициозные проекты: огромные открытые миры, десятки часов сюжета, уникальные механики, революционная графика и планы на AAA-качество силами небольшой команды.
Большинство из них мы больше никогда не увидим.
И проблема далеко не всегда в деньгах.
Где обычно всё заканчивается
Очень часто разработка выглядит примерно так:
придумать мир
написать сюжет
нарисовать концепты
сделать красивые рендеры
а потом внезапно понять, что игру ещё нужно собрать воедино
Оказывается, что один только персонаж — это не моделька. Это анимации, физика, звук, интерфейс, катсцены, оптимизация, сохранения и ещё десятки систем, которые игрок обычно даже не замечает.
Особенно тяжело приходится RPG.
Если платформер можно собрать вокруг одной механики, то RPG — это сразу несколько больших проектов внутри одного:
боевая система
сюжет
квесты
исследование мира
прогрессия персонажа
интерфейс
катсцены
звук и музыка
Каждая из этих систем способна занять месяцы работы.
Почему я это понял довольно быстро
Сейчас я работаю над Tides of Aethra — сюжетной RPG на Unreal Engine 5.
Когда проект только начинался, казалось, что самое сложное — придумать мир и историю.
На практике оказалось наоборот.
Мир придумать можно за несколько недель.
Гораздо сложнее превратить его в работающую игру.
Например, одна только система магии требует:
дизайна способностей
визуальных эффектов
баланса
анимаций
взаимодействия с окружением
работы с противниками
И это только одна механика.
Что помогает не бросить проект
Наверное, главный урок, который я вынес за время подготовки проекта: Нельзя делать игру целиком, нужно делать её по частям. Сначала одна локация, потом один противник. Потом один полноценный игровой цикл.
Поэтому вместо попытки сразу создать огромную RPG мы сейчас концентрируемся на вертикальном срезе проекта — небольшой, но полностью играбельной части будущей игры.
Если этот фундамент получится хорошим, всё остальное уже можно будет постепенно наращивать.
Почему некоторые проекты всё-таки доходят до релиза
На мой взгляд, причина довольно простая.
Они перестают быть мечтой и становятся производством.
Когда вместо фразы:
«Мы сделаем игру мечты»
появляется:
«Вот список задач на эту неделю»
Это звучит гораздо менее романтично, зато работает.
Что дальше
Сейчас мы продолжаем работу над Tides of Aethra и скоро покажем концепты, локации и процесс разработки.
Мне самому интересно проверить, насколько далеко можно зайти с таким подходом и реально ли довести подобный проект до релиза.
Если тема разработки игр вам интересна — буду периодически делиться прогрессом, ошибками, находками и тем, как проект меняется по мере создания.
Телефонный навигатор для Forza Horizon 6. Как это было сделано - часть 2
В прошлой серии телефон научился понимать, где находится машина: Forza шлет телеметрию, Python ее принимает, координаты переводятся на карту, браузер на телефоне получает JSON.
Но точка на карте - это еще не навигатор. Это максимум режим “я знаю, где я заблудился”. Настоящий навигатор начинается в тот момент, когда ты нажимаешь на цель, а он строит дорогу, а не рисует палку через гору, реку и чувство собственного достоинства.
Почему нельзя просто нарисовать линию до цели
Самый быстрый способ сделать “маршрут” - взять координаты машины, координаты цели и провести между ними прямую. Для демо на 10 секунд выглядит отлично. Для реальной езды - стыд.
Проблема очевидная: машина не летает по любому пикселю карты. Ей нужны дороги. Значит, карту надо превратить в дорожный граф: узлы, ребра, длины, веса, классы дорог. По сути, сделать маленькую навигационную систему поверх игровой карты.
Сначала была просто большая карта
В проекте карта живет как мир 20000 на 20000 условных пикселей. Есть тайлы, метаданные, слой POI и координаты маркеров. Фронтенд показывает нужный кусок карты, сервер знает базовые параметры.
Параметры карты в сервере
MAP_ID = 481
DEFAULT_LAYER_ID = 760
MIN_ZOOM, MAX_ZOOM = 12, 18
TILE_SIZE = 256
MAP_WIDTH, MAP_HEIGHT = 20000, 20000
POI - отдельная приятная часть. markers.json хранит точки интереса, а поиск работает по названию, категории и описанию. Если уже есть координаты машины, результаты можно отсортировать по расстоянию. Не нейросеть, не магия, просто нормальная инженерная польза.
Поиск POI с учетом расстояния от машины
def search_markers(query: str, limit: int = 25):
terms = [t for t in query.strip().lower().split() if t]
snap = STATE.get_snapshot()
px, py = snap.get("map_x"), snap.get("map_y")
results = []
for marker in load_markers_data():
hay = " ".join(str(marker.get(k, "")) for k in (
"title", "category", "parent_category", "description", "desc"
)).lower()
if not all(term in hay for term in terms):
continue
item = dict(marker)
if px is not None and py is not None:
item["distance_px"] = round(math.hypot(
float(marker.get("map_x", 0)) - float(px),
float(marker.get("map_y", 0)) - float(py),
), 2)
results.append(item)
return results[:limit]
Дороги пришлось добывать из картинки
Вот тут началась та часть, которую нормальный человек, вероятно, назвал бы “а может, ну его”.
У меня была красивая карта. Но для маршрута нужна не красивая карта, а данные о дорогах. То есть надо было понять, где на изображении дорога, вычистить мусор, убрать ложные пятна, восстановить разрывы, превратить толстые дорожные линии в тонкий скелет и потом уже собрать граф.
На словах это звучит как компьютерное зрение. На практике - как спор с картинкой, которая очень не хочет становиться JSON.
Первый заход был через OpenCV. Я не пытался “понять” карту как человек - я пытался вытащить из изображения схему дорог: белые линии, оранжевые магистрали, пунктир, разрывы. OpenCV делал черновую маску: фильтр по цвету, морфологическая очистка, склейка близких участков, потом скелетизация. Звучит умно, но на практике алгоритм с одинаковой уверенностью находил дорогу, подпись на карте и какой-нибудь декоративный пиксель, которому просто повезло быть нужного цвета.
Поэтому после распознавания пришлось сделать отдельный HTML-редактор карты. Не красивый “редактор уровней”, а рабочую утилиту для ремонта: открыть маску, стереть мусор распознавания, дорисовать недостающие проезды, заштриховать разрывы, сохранить ручные штрихи в JSON и заново собрать road_graph. В этот момент проект окончательно перестал быть “сейчас OpenCV все сам поймет” и стал нормальной инженеркой: автомат нашел основу, человек добил то, что без глаза и здравого смысла не чинится.
Фрагмент из build_road_graph_from_manual_mask.py - выделение дорожного цвета
def classify_pixels(base_rgb: np.ndarray) -> np.ndarray:
hsv = cv2.cvtColor(base_rgb, cv2.COLOR_RGB2HSV)
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
r, g, b = base_rgb[..., 0], base_rgb[..., 1], base_rgb[..., 2]
orange = (
(h >= 2) & (h <= 28) & (s >= 35) & (v >= 70) &
(r.astype("int16") - b.astype("int16") >= 35) &
(r.astype("int16") >= g.astype("int16") - 12)
)
return cv2.dilate(
orange.astype("uint8"), np.ones((5, 5), np.uint8), iterations=1
).astype(bool)
Автоматом это сделать полностью красиво не получилось. Карта содержит подписи, иконки, швы, разные цвета, декоративные элементы. Поэтому появился ручной слой правок: где-то стереть мусор, где-то дорисовать разрыв, где-то поправить место, где алгоритм слишком самоуверенно увидел дорогу.
В итоговом графе в метаданных остался след этой возни: 1387 ручных штрихов, из них 1153 draw, 155 erase, 79 erasePatch. Это не “я нажал одну кнопку и получил навигацию”. Это скорее “я убедил карту сотрудничать”.
Маска дороги -> скелет -> граф
После очистки маска превращается в скелет. Толстая линия дороги становится тонкой ниткой по центру. Потом эта нитка режется на узлы и ребра. Узел - точка на дороге. Ребро - проезд между двумя точками. У ребра есть длина и класс дороги.
Очистка маски и skeletonize
raw_mask = np.asarray(Image.open(args.mask).convert("L")) > 127
count, labels, stats, _ = cv2.connectedComponentsWithStats(
raw_mask.astype("uint8"), 8
)
mask = np.zeros_like(raw_mask, dtype=bool)
for label in range(1, count):
area = int(stats[label, cv2.CC_STAT_AREA])
if area >= args.min_component_area:
mask[labels == label] = True
skeleton = skeletonize(mask)
paths, skeleton_points, key_points = build_skeleton_paths(skeleton)
Узел и ребро - уже не картинка, а данные для навигации
COST = {
"white": 1.00,
"orange": 1.08,
"orange_dashed": 2.60,
"unknown": 9.50,
}
def add_edge(a: int, b: int, length: float, cls: str) -> None:
edges.append({
"from": nodes[a]["id"],
"to": nodes[b]["id"],
"length": round(length, 3),
"cost": round(length * COST.get(cls, COST["unknown"]), 3),
"road_class": cls,
})
Итоговый road_graph.json получился вполне взрослый: 14561 узел, 18612 ребер, шаг сжатия скелета около 6.595 px. Внутри есть классы дорог: white и orange, есть веса, компоненты связности и данные для runtime-достройки разрывов.
То есть когда на телефоне появляется синяя линия маршрута, за ней не CSS и не SVG-фокус. За ней лежит нормальный граф.
A*: потому что маршрут должен думать
Когда граф появился, понадобился поиск пути. Здесь используется A*. Алгоритм не просто ползает по всем дорогам, а идет по графу с учетом уже накопленной стоимости и примерного расстояния до цели.
На бытовом языке: “я еду по реальным дорогам, но не делаю вид, что вся карта одинаково интересна”.
Укороченный A* по дорожному графу
def shortest_graph_path(graph, start_idx, goal_idx, profile=None):
coords = graph["coords"]
adjacency = graph["adjacency"]
gx, gy = coords[goal_idx]
def heuristic(node_idx: int) -> float:
ax, ay = coords[node_idx]
return math.hypot(ax - gx, ay - gy)
heuristic_weight = float((profile or {}).get("heuristic_weight", 1.0))
open_heap = [(heuristic(start_idx) * heuristic_weight, 0.0, start_idx)]
came_from = {start_idx: None}
best_cost = {start_idx: 0.0}
while open_heap:
_priority, cost_so_far, current = heapq.heappop(open_heap)
if current == goal_idx:
path = [current]
while came_from[path[-1]] is not None:
path.append(came_from[path[-1]])
path.reverse()
return path, cost_so_far
for neighbor, _stored_edge_cost in adjacency[current]:
edge_cost = _route_edge_cost(graph, current, neighbor, profile)
new_cost = cost_so_far + edge_cost
if new_cost < best_cost.get(neighbor, float("inf")):
best_cost[neighbor] = new_cost
came_from[neighbor] = current
heapq.heappush(open_heap, (
new_cost + heuristic(neighbor) * heuristic_weight,
new_cost,
neighbor,
))
Почему веса важнее, чем кажется
Если искать просто кратчайший путь, навигатор быстро начинает вести себя как человек, который “знает короткую дорогу”, а потом ты уже едешь через кусты, канаву и психологическую травму.
Поэтому у разных дорог разные множители. Белая дорога - базовая. Оранжевая почти нормальная. Пунктир и неизвестные участки дороже. Синтетические мостики для ремонта разрывов тоже не бесплатные, иначе алгоритм полюбит заплатки больше настоящих дорог.
Стоимость разных типов дорог
ROAD_CLASS_MULTIPLIERS = {
"white": 1.00,
"orange": 1.08,
"orange_dashed": 2.60,
"synthetic_short": 2.40,
"synthetic_medium": 7.00,
"synthetic_bridge": 34.00,
"unknown": 9.50,
}
Итог серии
На этом этапе проект перестал быть “картой с живой точкой” и стал навигатором. Он умеет искать POI, понимать позицию машины, приклеивать старт и цель к дорожной сети и строить маршрут по графу.
Но дальше вскрылся главный враг. Не UDP, не JavaScript, не телефон. Развязки.
Потому что в 2D две дороги могут пересекаться, а в игре одна идет сверху, другая снизу, третья уходит рампой, и граф такой: “ну вроде все рядом, поехали”.
В следующей серии - почему развязки это маленький филиал ада и почему ближайшая дорога не всегда та, по которой ты едешь.













