Приборка, ретро-киберпанк и мемный штурман: когда навигатор стал companion app
После карты, маршрутов и борьбы с развязками я мог бы остановиться. Навигатор работает, телефон показывает позицию, маршрут строится, HUD в игре можно выключить. Казалось бы, все.
Но телефон уже лежит рядом с рулем. Он уже стал частью кокпита. И тут в голове появляется абсолютно нездоровая, но очень логичная мысль: а почему он должен быть только картой? Пусть будет приборкой. А если есть телеметрия, пусть еще и реагирует на события. Потому что если машина влетела в стену на 180 км/ч, программа имеет моральное право сказать что-нибудь мемное.
В некоторых машинах Forza внутренняя приборка работает нормально. Поэтому экранный спидометр я выключил почти сразу. Но внешний HUD на телефоне дает другое ощущение: не “еще один интерфейс поверх игры”, а отдельный прибор рядом с рулем.
Я хотел не стерильную табличку speed/gear/rpm, а что-то ближе к электронным приборкам авто их 80х в смеси с современными технологиями: скорость, передача, TRIP A, RPM bar, мини-карта, маршрут, темный фон, цифры как на старой электронике. Такое, чтобы выглядело не как админка роутера, а как штука, которую кто-то прикрутил к машине в гараже в 2003 году и почему-то она работает.
Семисегментные цифры прямо в DOM
Можно было взять шрифт. Но я захотел нормальные семисегментные цифры из отдельных сегментов. В браузере это делается довольно приятно: у каждой цифры есть набор включенных сегментов, а дальше 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 bar: маленькая полоска, много удовольствия
Обороты приходят из телеметрии. Максимальные обороты тоже могут прийти, но на всякий случай интерфейс умеет подстроиться под увиденный максимум. Полоса RPM собрана из 88 сегментов, последние сегменты помечены как red zone.
Здесь я окончательно понял, что приборка - это не про “показать число”. Это про ощущение. Когда шкала оборотов бежит рядом с рулем, мозг почему-то верит ей сильнее, чем сухому тексту.
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: маленькая цифра, которая тоже пыталась сломаться
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;
}
}
Мини-карта в HUD: красиво, но телефон не надо жарить
В приборке есть мини-карта. Но ее нельзя было просто нарисовать как полноценную большую карту еще раз. Телефон должен держать нормальный 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-логики маршрутов.
И самое смешное: все это началось с очень простой мысли “хочу убрать мини-карту из игры и не потеряться”.
Я надеюсь, что вдохновение ко мне вернётся, и я сделаю что-то подобное для другой игры (напишите, кстати, какую игру еще можно было попробовать также снабдить навигатором?), или ещё что нибудь. Спасибо, что читали и ставили плюсы, всем добра!