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


Для справки: Нефтехимпром — это 40 автозаправочных станций в Перми и Пермском крае!
Приборка, ретро-киберпанк и мемный штурман: когда навигатор стал companion app
После карты, маршрутов и борьбы с развязками я мог бы остановиться. Навигатор работает, телефон показывает позицию, маршрут строится, HUD в игре можно выключить. Казалось бы, все.
Но телефон уже лежит рядом с рулем. Он уже стал частью кокпита. И тут в голове появляется абсолютно нездоровая, но очень логичная мысль: а почему он должен быть только картой? Пусть будет приборкой. А если есть телеметрия, пусть еще и реагирует на события. Потому что если машина влетела в стену на 180 км/ч, программа имеет моральное право сказать что-нибудь мемное.
В некоторых машинах Forza внутренняя приборка работает нормально. Поэтому экранный спидометр я выключил почти сразу. Но внешний HUD на телефоне дает другое ощущение: не “еще один интерфейс поверх игры”, а отдельный прибор рядом с рулем.
Я хотел не стерильную табличку speed/gear/rpm, а что-то ближе к электронным приборкам авто их 80х в смеси с современными технологиями: скорость, передача, TRIP A, RPM bar, мини-карта, маршрут, темный фон, цифры как на старой электронике. Такое, чтобы выглядело не как админка роутера, а как штука, которую кто-то прикрутил к машине в гараже в 2003 году и почему-то она работает.
Можно было взять шрифт. Но я захотел нормальные семисегментные цифры из отдельных сегментов. В браузере это делается довольно приятно: у каждой цифры есть набор включенных сегментов, а дальше DOM собирает маленький индикатор.
Это не самая сложная часть проекта, зато она очень влияет на настроение. Обычный текст говорит “это веб-страница”. Семисегментный индикатор говорит “добро пожаловать в странный автомобильный прибор”.
Семисегментные цифры приборки
const DASH_SEGMENTS = {
"0":"abcdef", "1":"bc", "2":"abged", "3":"abgcd",
"4":"fgbc", "5":"afgcd", "6":"afgecd", "7":"abc",
"8":"abcdefg", "9":"abfgcd", "-":"g", "N":"ceg", "R":"efgab"
};
function renderDashboardDigits(el, value, small=false){
const text = String(value ?? '0').toUpperCase().slice(0, 4);
el.innerHTML = '';
for(const ch of text){
const on = DASH_SEGMENTS[ch] || DASH_SEGMENTS[' '];
const digit = document.createElement('span');
digit.className = 'dashboardDigit' + (small ? ' small' : '');
for(const seg of ['a','b','c','d','e','f','g']){
const part = document.createElement('span');
part.className = 'dashboardDigitSeg s-' + seg + (on.includes(seg) ? ' on' : '');
digit.appendChild(part);
}
el.appendChild(digit);
}
}
Обороты приходят из телеметрии. Максимальные обороты тоже могут прийти, но на всякий случай интерфейс умеет подстроиться под увиденный максимум. Полоса RPM собрана из 88 сегментов, последние сегменты помечены как red zone.
Здесь я окончательно понял, что приборка - это не про “показать число”. Это про ощущение. Когда шкала оборотов бежит рядом с рулем, мозг почему-то верит ей сильнее, чем сухому тексту.
RPM bar из 88 сегментов
function ensureDashboardRpmSegments(){
const track = document.getElementById('dashboardRpmTrack');
if(!track || dashboardRpmBuilt) return;
track.innerHTML = '';
dashboardRpmSegs = [];
for(let i = 0; i < 88; i++){
const seg = document.createElement('span');
seg.className = 'dashboardRpmSeg' + (i >= 76 ? ' red' : '');
track.appendChild(seg);
dashboardRpmSegs.push(seg);
}
dashboardRpmBuilt = true;
}
const ratio = clamp(rpm / Math.max(1000, dashboardLearnedMaxRpm), 0, 1);
const activeCount = Math.round(ratio * dashboardRpmSegs.length);
TRIP A выглядит как декоративная мелочь. Но именно такие мелочи делают приборку живой. И, конечно, даже она не могла просто работать без вопросов.
Если DistanceTraveled из телеметрии приходит нормально - используем его. Если он не двигается или приходит как бесполезный ноль - считаем расстояние сами, интегрируя скорость по времени. Это типичный момент разработки: хотел маленькую цифру, получил fallback-логику.
TRIP A с fallback-расчетом
function updateDashboardTrip(){
const rawDistance = Number(telemetry && telemetry.distance_traveled_m);
const rawValid = Number.isFinite(rawDistance) && rawDistance > 1;
if(rawValid){
if(dashboardTripRawBaseM === null || rawDistance + 5 < dashboardTripRawLastM){
dashboardTripRawBaseM = rawDistance;
}
dashboardTripRawLastM = rawDistance;
dashboardTripKm = Math.max(0, (rawDistance - dashboardTripRawBaseM) / 1000);
} else {
const dtMs = Math.max(0, Math.min(2500, now - dashboardTripLastAt));
const speedKmh = Math.max(0, Number(telemetry.speed_kmh) || 0);
dashboardTripKm += (speedKmh * dtMs) / 3600000;
}
}
В приборке есть мини-карта. Но ее нельзя было просто нарисовать как полноценную большую карту еще раз. Телефон должен держать нормальный FPS, не греться как кирпич и не превращать езду в презентацию PowerPoint.
Поэтому мини-карта живет на canvas, использует кэш тайлов и подбирает zoom по скорости. Чем быстрее едет машина, тем дальше навигатор должен смотреть вперед. На медленной скорости можно приблизить, на высокой - отодвинуть камеру, чтобы не ехать носом в край экрана.
Автоzoom мини-карты по скорости
function dashboardMiniDesiredZoom(mode, vw, vh, anchor, deg){
const kmh = Math.max(0, Number(telemetry.speed_kmh) || 0);
const lookSeconds = 4.5;
const lookMeters = Math.max(35, (kmh / 3.6) * lookSeconds);
const lookPx = mapPxFromMeters(lookMeters);
const ahead = dashboardForwardLookPoint(lookPx);
for(let z = MAX_ZOOM; z >= MIN_ZOOM; z--){
const v = viewFor(z);
const raw = {x: ahead.map_x * v.s - v.px, y: ahead.map_y * v.s - v.py};
const screen = rotatePointAround(raw.x, raw.y, anchor.x, anchor.y, deg);
if(screen.x >= sideMargin && screen.x <= vw - sideMargin &&
screen.y >= topMargin && screen.y <= vh - bottomMargin){
return z;
}
}
return MIN_ZOOM;
}
Дальше случилось неизбежное. Если программа знает скорость, тормоз, вертикальную скорость и высоту, она может понимать игровые события. А если понимает события, может реагировать звуком.
Так появился мемный слой. Сэмплы лежат в папках samples/collision, samples/mega_fail_crash и samples/jump_takeoff. Сервер отдает список доступных звуков, телефон проигрывает случайный сэмпл при событии. Никакого облака, никакого сервиса, просто свои локальные файлы и браузер.
Настройки событий мемного слоя
MEME_EVENT_DEFAULTS = {
"mega_fail_crash": {
"window_sec": 0.15,
"min_previous_speed_kmh": 120.0,
"max_current_speed_kmh": 15.0,
"min_speed_drop_kmh": 100.0,
},
"collision": {
"window_sec": 0.5,
"min_speed_drop_kmh": 40.0,
"min_previous_speed_kmh": 60.0,
"max_brake_pct": 5.0,
},
"jump_takeoff": {
"detection_mode": "fast_freefall_confirmed",
"min_speed_kmh": 75.0,
"freefall_window_sec": 0.15,
"min_takeoff_vertical_speed_mps": 1.0,
"max_freefall_vertical_speed_mps": -0.25,
},
}
Самое важное для collision - не срабатывать на обычное торможение. Если игрок нажал тормоз, скорость упала, это не авария. Это водитель. Поэтому событие смотрит на падение скорости и одновременно проверяет, что тормоз почти не нажат.
Столкновение как резкое падение скорости без тормоза
if(collisionPast){
const drop = collisionPast.speed - speed;
const maxBrake = Number(collisionCfg.max_brake_pct ?? 5);
const noBrake = (brake <= maxBrake) && (Number(collisionPast.brake || 0) <= maxBrake);
if(noBrake &&
collisionPast.speed >= Number(collisionCfg.min_previous_speed_kmh || 60) &&
drop >= Number(collisionCfg.min_speed_drop_kmh || 40)){
playMemeEvent('collision');
return;
}
}
С прыжком было веселее. Нельзя просто сказать: машина поднялась вверх, значит прыжок. В игре есть холмы, эстакады, подъемы. Поэтому логика ждет не только рост высоты, но и признаки полета: вертикальную скорость, апекс, начало падения, небольшой drop from apex. По сути, нужно поймать момент “машина реально оторвалась”, а не “дорога пошла вверх”.
Прыжок как подтвержденный полет, а не просто подъем
const takeoffOk =
(recentMaxVy >= minTakeoffVy) ||
(verticalSpeedGain >= minVyGain) ||
(recentRise >= minRise && recentMaxVy >= minTakeoffVy * .55);
const fallingOk =
(fallingSamples.length >= minFallSamples || Number(vy) <= fallingLimit) &&
(fallDuration >= minFallDuration || confirmDrop >= minApexDrop * .55) &&
accelOk;
if(takeoffOk && fallingOk && notRoadSlope && dropFromApex >= minApexDrop){
playMemeEvent('jump_takeoff');
}
На этом этапе проект перестал быть “просто картой на телефоне”. Он стал companion app:
- основной слой - позиция машины и карта;
- навигационный слой - route, distance, heading-up, reroute;
- приборный слой - speed, gear, rpm, TRIP A, мини-карта;
- event layer - столкновения, прыжки, mega fail и звуковые реакции;
- будущий слой - Z-логгер для развязок и уточнение 3D-логики маршрутов.
И самое смешное: все это началось с очень простой мысли “хочу убрать мини-карту из игры и не потеряться”.
Я надеюсь, что вдохновение ко мне вернётся, и я сделаю что-то подобное для другой игры (напишите, кстати, какую игру еще можно было попробовать также снабдить навигатором?), или ещё что нибудь. Спасибо, что читали и ставили плюсы, всем добра!
Если первые две технические серии были про “как заставить это работать”, то эта - про “как заставить это не врать слишком уверенно”.
Самый неприятный враг самодельного навигатора оказался не там, где я ждал. Не UDP, не браузер на телефоне, не локальная сеть, не A*. Самый неприятный враг - развязка. Обычная такая игровая развязка, которая выглядит красиво, пока ты человек. А потом ты становишься алгоритмом, и начинается цирк.
Forza отдает PositionX, PositionY и PositionZ. Для обычной карты я использую X и Z, а Y - это высота. Проблема в том, что первый дорожный граф плоский. Он живет в координатах карты, а не в полноценном 3D.
И вот ловушка: если две дороги пересекаются на картинке, это не значит, что между ними есть поворот. Одна может быть сверху, другая снизу. Для человека это очевидно. Для 2D-графа - “о, линии рядом, значит дружим”.
Высота уже сохраняется в live state
raw_pos_x = float(telemetry.get("PositionX", 0.0))
raw_pos_y = float(telemetry.get("PositionY", 0.0)) # высота
raw_pos_z = float(telemetry.get("PositionZ", 0.0))
raw_map_x, raw_map_y = forza_to_map(raw_pos_x, raw_pos_z)
self.snapshot = TelemetrySnapshot(
position_x=pos_x,
position_y=pos_y,
position_z=pos_z,
map_x=map_x,
map_y=map_y,
)
Поэтому высоту я не выбрасываю. Сейчас она в основном диагностическая, но дальше из нее можно сделать слой уточнений для сложных мест: проехать развязку вручную, записать X/Y/Z/heading и потом сказать графу: “вот здесь верхняя дорога, здесь нижняя, тут не соединять, тут рампа”.
Чтобы построить маршрут, нужно сначала приклеить машину к дорожному графу. Это называется snap. Берем текущую позицию, ищем ближайший узел дороги и считаем, что машина на нем.
На прямой дороге все отлично. На развязке это превращается в рулетку. Машина может ехать по эстакаде, а ближайший узел на карте окажется на дороге под ней. Если взять только один ближайший узел - навигатор может начать маршрут из параллельной реальности.
Поэтому я ищу не один узел, а несколько кандидатов вокруг точки.
Не один ближайший узел, а список кандидатов
def nearest_graph_nodes(graph, x, y, limit=10, max_distance=900.0):
coords = graph["coords"]
spatial = graph.get("spatial", {})
cell_size = float(graph.get("spatial_cell_size", 320.0))
cx, cy = int(x // cell_size), int(y // cell_size)
candidates = []
seen = set()
for ring in range(max_ring + 1):
for gx in range(cx - ring, cx + ring + 1):
for gy in range(cy - ring, cy + ring + 1):
for idx in spatial.get((gx, gy), []):
if idx in seen:
continue
seen.add(idx)
nx, ny = coords[idx]
d = math.hypot(nx - x, ny - y)
if d <= max_distance:
candidates.append((d, idx))
candidates.sort(key=lambda item: item[0])
return [(idx, dist) for dist, idx in candidates[:limit]]
После этого стартовые и целевые кандидаты перебираются парами. Пара отбрасывается, если узлы лежат в разных компонентах графа. Еще добавляется штраф за маленькую компоненту, потому что рядом с машиной может быть крошечный ложный фрагмент дороги, который случайно выжил после распознавания.
Это примерно как не доверять подозрительному “короткому пути” через двор, если он на самом деле нарисован одним пикселем и честным словом.
Выбор разумных пар старта и цели
candidate_pairs = []
for start_idx, start_snap in start_candidates:
for goal_idx, goal_snap in goal_candidates:
if component and component[start_idx] != component[goal_idx]:
continue
comp_size = graph.get("component_sizes", [0])[component[start_idx]] if component else 0
component_penalty = 600.0 / math.sqrt(max(1, comp_size))
candidate_pairs.append((
start_snap + goal_snap + component_penalty,
start_idx, goal_idx, start_snap, goal_snap,
))
candidate_pairs.sort(key=lambda item: item[0])
Карта не обязана быть удобной для алгоритма. Дорогу может перекрыть иконка, подпись, декоративный элемент, шов тайла. Человек видит, что дорога продолжается. Граф видит: “связи нет, до свидания”.
Для таких мест появились синтетические мостики. Но тут важный момент: они должны быть дорогими. Если сделать заплатки дешевыми, навигатор быстро превратится в короля телепортов. Поэтому короткая заплатка терпимая, средняя дорогая, длинная почти запретительная.
Заплатка есть, но она не должна стать любимой дорогой
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,
}
def _synthetic_multiplier_for_length(length, profile):
if length <= 70.0:
return float(profile.get("synthetic_short", 34.0))
if length <= 180.0:
return float(profile.get("synthetic_medium", 34.0))
return float(profile.get("synthetic_bridge", 34.0))
Еще выяснилось, что “идеального профиля” нет. Если слишком любить белые дороги, маршрут делает абсурдную петлю, лишь бы не использовать короткий оранжевый соединитель. Если разрешить все подряд, он начинает вести себя как раллист, которому сказали, что физика сегодня выходная.
Поэтому появились профили маршрута. Практичный асфальт, режим выхода из идиотского объезда и shortest sane - кратчайший вариант, но без полной потери совести.
Несколько профилей маршрутизации вместо одного самоуверенного
ROUTING_COST_PROFILES = {
"asphalt_practical": {
"white": 1.00,
"orange": 1.08,
"orange_dashed": 2.60,
"synthetic_short": 2.40,
"synthetic_medium": 7.00,
"synthetic_bridge": 34.00,
"heuristic_weight": 1.45,
},
"detour_escape": {
"white": 1.00,
"orange": 1.02,
"orange_dashed": 1.85,
"synthetic_short": 1.65,
"heuristic_weight": 2.10,
},
"shortest_sane": {
"white": 1.00,
"orange": 1.00,
"orange_dashed": 1.25,
"synthetic_short": 1.25,
"heuristic_weight": 3.00,
},
}
В ранних версиях очень хотелось, чтобы маршрут был всегда. Даже если граф не справился - ну нарисуем прямую линию, пользователь поймет.
Нет, пользователь не поймет. Прямая линия на экране выглядит как маршрут. Если она ведет сквозь все подряд, это уже не fallback, а ложь с хорошим дизайном.
Поэтому сейчас, если граф есть, но путь не найден, сервер не рисует диагональ. Он возвращает ошибку и пустую polyline. Лучше честно сказать “дорожный маршрут не найден”, чем нарисовать уверенную ерунду.
Честный fail вместо фальшивого маршрута
def graph_failed(mode: str, message: str, routing=None):
# Do not silently draw a diagonal when a road graph exists but cannot produce a path.
# A diagonal fallback looked like a real route and made debugging impossible.
return {
"ok": False,
"mode": mode,
"polyline": [],
"message": message,
"routing": routing or {},
}
Навигатор должен перестраивать маршрут, когда ты уехал не туда. Но если он будет перестраивать его при каждом чихе, получится не помощник, а тревожный диспетчер.
Поэтому появилась липкость. Чуть съехал с линии - навигатор сначала держит маршрут и просит вернуться. Сильно уехал или долго едешь мимо - пересчитывает. Это звучит как мелочь, но именно такие мелочи отличают “работает” от “почему оно дергается каждые две секунды”.
Липкое перестроение маршрута
const REROUTE_STICKY_OFF_PX = 170;
const REROUTE_STICKY_MS = 22000;
const REROUTE_HARD_OFF_PX = 360;
const REROUTE_HARD_MS = 9000;
function maybeRerouteNavigation(){
const off = distanceToCurrentRoute(px, py);
if(off <= REROUTE_STICKY_OFF_PX){
offRouteSince = 0;
return;
}
if(!offRouteSince) offRouteSince = now;
const offMs = now - offRouteSince;
const shouldReroute =
(off > REROUTE_HARD_OFF_PX && offMs > REROUTE_HARD_MS) ||
(off > REROUTE_STICKY_OFF_PX && offMs > REROUTE_STICKY_MS);
if(shouldReroute) requestRoute(routeTarget, false);
}
Вот почему развязки - это ад. Не потому, что их сложно красиво нарисовать. А потому что “дорога рядом” и “дорога доступна” - разные вещи.
Текущее решение - это набор защит: несколько кандидатов для snap, компоненты графа, штрафы, дорогие синтетические мостики, разные профили маршрута, честный fail и неистеричный reroute.
Следующий логичный уровень - Z-логгер. Проехать сложные развязки вручную, записать высоту и фактическую траекторию, а потом использовать это как слой уточнения. Игра высоту уже отдает. Осталось заставить ее работать на нас, а не просто лежать красивой цифрой в JSON.
В следующей серии будет менее больно и более красиво: приборка на телефоне, семисегментные цифры, мини-карта, ретро-киберпанк и мемные звуки на столкновения и прыжки.
@Yandex, научите пжл своих программистов разговорному языку и тому, как описывается навигация.
Если мне надо проехать от Библиотеки до Лубянки - это ДВЕ (2, 10 в двоичной системе) остановки.
Ваше описание "1 остановка" вводит в заблуждение.
Мало ли, может у замеров по-другому...
Вечером 8 сентября 1923 года у берегов Калифорнии в густом тумане 14 эсминцев ВМС США шли на юг со скоростью 20 узлов. Это был обычный переход из Сан-Франциско в Сан-Диего. Командовал эскадрой Эдвард Уотсон. Прокладку курса вели только на его флагманском корабле — эсминце «Делфи». Остальные эсминцы просто следовали за ним, полагаясь на расчеты флагманского штурмана.
Отличник навигационной подготовки, капитан Эдвард Уотсон. (Источник)
Для точного определения местоположения кораблей в море вне пределов видимости берега или в плохую погоду на побережье были оборудованы специальные пеленгаторные радиостанции-маяки. Ближайшая из них, на мысе Аргельо, регулярно поддерживала связь с флагманским эсминцем «Делфи». Получаемые с неё данные однозначно показывали, что курс флотилии проложен прямо на мыс Аргельо, то есть буквально прямо на них. Однако ни командир флотилии, ни его штурман не принимали эти сведения в расчёт, будучи уверенными в непогрешимости своих собственных вычислений. Самоуверенность и недоверие к современной на тот момент технике привели к тому, что из-за ошибки в расчётах, не учитывавших влияние ветра и течения, погрешность в определении местоположения составила 20 морских миль — что соответствует примерно 37 километрам. Хорошо так ошиблись, представьте, где у кого дача, например, находится.
На переднем плане — «Ченси», рядом с ним на воде спасательные плоты. «Янг» перевернулся и лежит вверх килем за его кормой. Вдалеке «Вудбери», накренившийся на левый борт. «Фуллер» за островком. (Источник)
При этом по действовавшим тогда правилам станция с мыса Аргельо передавала сведения только флагману — остальные командиры получали данные о местоположении уже от него и не могли самостоятельно сверить своё место. В 20.00 флагман передал на эсминцы свои расчётные координаты, но не потребовал от подчинённых сообщить их собственные данные для сверки. На некоторых кораблях собственная прокладка курса расходилась с флагманской, однако никто из командиров не посмел поспорить с командиром эскадры.
Фото последствий с другого ракурса. (Источник)
И в 21:05, двигаясь через плотный туман, флагманский «Делфи» на полной скорости выскочил на скалы. За ним, даже не сбавив хода, один за другим рядом с ним на скалах оказались ещё 6 эсминцев: «С.П.Ли», «Ченси», «Фуллер», «Вудбери», «Николас» и «Янг». Всего за несколько минут семь кораблей превратились в груды искореженного металла. 23 моряка погибли (большинство — с «Янга», разломившегося пополам). Спасатели из береговой охраны и железнодорожные рабочие вытащили из воды 745 человек. Благо берег был совсем рядом — даже слишком близко, как оказалось.
На переднем плане — «С.П.Ли». Бортовой номер 310, правее — «Николас». (Источник)
Военный трибунал признал Уотсона и его штурмана виновными в «предосудительной халатности». Катастрофа у Хонда-Пойнт остаётся крупнейшей потерей кораблей американского флота в мирное время.
Аэроснимок района катастрофы, вид с запада. (Источник)
Чтобы такое не повторялось, американский флот сделал жёсткие выводы: офицерам предписали обязательно сверяться с данными радиопеленгаторов; командиры каждого корабля теперь обязаны были самостоятельно прокладывать свой курс и определять положение своего корабля, не полагаясь исключительно на расчёты флагмана. Сама культура управления соединением сместилась от единоличного руководства к индивидуальной ответственности каждого командира.
Ставя плюс, вы помогаете другим людям увидеть этот пост. А я буду рад, если вы подпишетесь на мой профиль или сообщество «Взаимосвязи», чтобы не пропустить новые публикации.
Каждый раз, выходя на перрон, особенно преодолев 500 ступенек жд перехода, с полным багажом, в неизвестном городе, в середине состава, теряешься в какую сторону идти к своему вагону. Текущий номер виден только у открытой двери. Почему бы не сделать такие таблички на угол тыльной стороны каждого вагона? С возможностью замены цифры, когда меняется состав. Чтобы облегчить жизнь людям. Так будет видно с любого места, где нумерация идет на убывание, а где на увеличение. А Артемий Лебедев с удовольствием сделает дизайн табличек) Или вы знаете другой способ?