user11877679

user11877679

На Пикабу
в топе авторов на 574 месте
106 рейтинг 1 подписчик 0 подписок 4 поста 0 в горячем
3

Телефонный навигатор для Forza Horizon 6. Как это было сделано - часть 3

Если первые две технические серии были про “как заставить это работать”, то эта - про “как заставить это не врать слишком уверенно”.

Самый неприятный враг самодельного навигатора оказался не там, где я ждал. Не 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 {},
}

Reroute тоже нельзя делать истеричным

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

Поэтому появилась липкость. Чуть съехал с линии - навигатор сначала держит маршрут и просит вернуться. Сильно уехал или долго едешь мимо - пересчитывает. Это звучит как мелочь, но именно такие мелочи отличают “работает” от “почему оно дергается каждые две секунды”.

Липкое перестроение маршрута

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.

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

Показать полностью
5

Телефонный навигатор для 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 две дороги могут пересекаться, а в игре одна идет сверху, другая снизу, третья уходит рампой, и граф такой: “ну вроде все рядом, поехали”.

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

Показать полностью 2
7

Телефонный навигатор для Forza Horizon 6. Как это было сделано - часть 1

В первом посте я показал саму идею: выключил HUD в Forza, оставил вид из кабины, а карту вынес на телефон. Получился такой странный автомобильный навигатор для виртуальной машины. Сидишь с рулем, смотришь на дорогу, а телефон рядом показывает, где ты и куда ехать.

Теперь обещанная подкапотка. Начну с места, где проект мог умереть вообще сразу: как получить координаты машины из игры и не превратить все это в сомнительный софт из подвала интернета.

Первая радость: игру не пришлось ломать

Я сначала морально готовился к худшему. Думал, сейчас начнется классика: искать координаты в памяти процесса, смотреть какие-то адреса, проверять, не отвалится ли все после обновления игры, а потом еще объяснять людям, что это не чит и не попытка открыть портал в бан.

Но тут внезапно повезло. В Forza есть Data Out - нормальная телеметрия по UDP. Это та же идея, которой пользуются приборки, симрейсинговые панели и всякие dashboard-приложения. В настройках включаешь Data Out, указываешь IP 127.0.0.1 и порт, у меня это 5700, и игра начинает сама отправлять пакеты.

Самый красивый хак в проекте - это вовремя не хакать то, что уже умеет работать официально.

С этого момента схема стала приземленной: Python-приложение на ПК слушает UDP, принимает байты, распаковывает телеметрию и дальше уже кормит телефон нормальным JSON. Телефон вообще не знает, что где-то внизу летят бинарные пакеты. Для него это просто веб-страница с живым состоянием машины.

UDP-пакет - это не милый JSON

Я бы очень хотел написать, что игра отправляет что-то вроде {speed: 120, x: 123, y: 456}. Но нет. На вход прилетает бинарный пакет, и его нужно читать строго по структуре: где int, где float, где byte, где signed byte.

Из полезного для навигатора там есть почти все, что хотелось: PositionX, PositionY, PositionZ, Speed, VelocityX/Y/Z, Yaw, Gear, CurrentEngineRpm, EngineMaxRpm, Accel, Brake, DistanceTraveled. Скорость приходит в метрах в секунду, yaw - в радианах, педали - байтами 0-255, координаты - в игровых единицах, которые пока вообще не имеют отношения к картинке карты.

Укороченный фрагмент из fh6_live_map_server.py - схема пакета телеметрии

def build_fh6_fields():
fields = []
def add(name, fmt):
fields.append((name, fmt))

add("IsRaceOn", "i")
add("TimestampMS", "I")

for name in [
"EngineMaxRpm", "EngineIdleRpm", "CurrentEngineRpm",
"AccelerationX", "AccelerationY", "AccelerationZ",
"VelocityX", "VelocityY", "VelocityZ",
"Yaw", "Pitch", "Roll",
]:
add(name, "f")

for name in [
"PositionX", "PositionY", "PositionZ",
"Speed", "Power", "Torque", "DistanceTraveled",
]:
add(name, "f")

for name in ["Accel", "Brake", "Clutch", "HandBrake", "Gear"]:
add(name, "B")

return fields

FH6_FIELDS = build_fh6_fields()
FH6_STRUCT_FORMAT = "<" + "".join(fmt for _, fmt in FH6_FIELDS)
FH6_STRUCT_SIZE = struct.calcsize(FH6_STRUCT_FORMAT)

Дальше все не романтично, зато надежно: проверяем длину пакета, распаковываем через struct, складываем значения в словарь. Это тот самый момент, где проект перестает быть красивой фантазией и начинает пахнуть настоящей программой: байты пришли, Python их понял, координаты появились.

UDP listener - скучная часть, без которой магии не будет

def parse_packet(data: bytes):
values = struct.unpack(FH6_STRUCT_FORMAT, data[:FH6_STRUCT_SIZE])
return dict(zip((name for name, _ in FH6_FIELDS), values))


def udp_listener(bind: str, port: int, stop_event: threading.Event):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((bind, port))
sock.settimeout(0.2)

while not stop_event.is_set():
try:
data, _addr = sock.recvfrom(2048)
except socket.timeout:
continue

if len(data) < FH6_STRUCT_SIZE:
STATE.mark_short_packet(len(data))
continue

STATE.update_from_packet(parse_packet(data), len(data))

Вторая радость быстро закончилась: координаты игры не координаты карты

Когда я впервые увидел живые PositionX и PositionZ, было ощущение: ну все, победа. Сейчас нарисую точку на карте и поедем.

А потом мозг такой: подожди, карта у нас - картинка 20000 на 20000 условных пикселей, а игра отдает свои координаты мира. Это не одна и та же система. Просто взять x и z и вставить в CSS нельзя. Получится не навигатор, а зеленая точка, которая уверенно едет в другой вселенной.

Пришлось делать калибровку. Берем несколько точек, где известно соответствие: в игре машина стоит вот здесь, на карте это вот этот пиксель. Потом подбираем аффинное преобразование. В итоге две игровые координаты превращаются в две координаты карты.

Перевод координат Forza в координаты карты

A, B, C = 0.652837, 0.000763, 10387.027
D, E, F = -0.003754, -0.657135, 9846.097


def forza_to_map(position_x: float, position_z: float):
return (
A * position_x + B * position_z + C,
D * position_x + E * position_z + F,
)

Вот это был первый момент, когда я прям физически почувствовал: оно ожило. Не просто цифры бегут в консоли, а машина реально едет по карте примерно там, где должна. После этого уже можно было заниматься интерфейсом. До этого - только гадать над float-ами и делать вид, что все под контролем.

Направление движения: yaw есть, но он не всегда король

Казалось бы, игра отдает Yaw - берем его и поворачиваем стрелку. Но навигатору важнее не то, куда повернута модель машины в данный кадр, а куда она реально движется по карте.

На скорости я использую VelocityX и VelocityZ, перевожу этот вектор в координаты карты и получаю экранный угол. На маленькой скорости вектор начинает шуметь, поэтому там уже можно вернуться к yaw. Это мелочь, которую никто не заметит, пока она работает. Но если она работает плохо, стрелка начинает жить отдельной жизнью, и вся иммерсивность сразу умирает с неловким звуком.

Направление по фактическому движению, а не только по yaw

def compute_screen_heading_deg(yaw_rad, velocity_x, velocity_z, speed_mps):
if speed_mps > 1.5:
vx_map = A * velocity_x + B * velocity_z
vy_map = D * velocity_x + E * velocity_z
return map_vector_to_screen_angle_deg(vx_map, vy_map)

forward_x = math.sin(yaw_rad)
forward_z = math.cos(yaw_rad)
fx_map = A * forward_x + B * forward_z
fy_map = D * forward_x + E * forward_z
return map_vector_to_screen_angle_deg(fx_map, fy_map)

Пауза в игре и телепорт в подвал мироздания

Отдельный прикол: когда игра на паузе, в меню или теряет фокус, телеметрия может стать странной. В интерфейсе это выглядело так, будто машина внезапно исчезла с дороги и решила пожить где-то в координатном аду.

Решение получилось бытовое: если скорость почти нулевая, передача 0, а до этого была нормальная позиция, я держу последнюю хорошую координату. Навигатор не должен нервно прыгать только потому, что я открыл меню.

Защита от прыжка координат при паузе

pause_coordinate_hold = bool(
speed_mps <= 0.20 and gear == 0 and self.last_good_position is not None
)

if pause_coordinate_hold:
pos_x, pos_y, pos_z, map_x, map_y, held_yaw, held_heading = self.last_good_position
effective_yaw_rad = held_yaw
effective_heading_deg = held_heading
else:
pos_x, pos_y, pos_z = raw_pos_x, raw_pos_y, raw_pos_z
map_x, map_y = raw_map_x, raw_map_y
self.last_good_position = (
pos_x, pos_y, pos_z, map_x, map_y,
effective_yaw_rad, effective_heading_deg,
)

Как телефон вообще это видит

На ПК одновременно работают две вещи: UDP listener и локальный HTTP-сервер. Телефон открывает страницу по Wi-Fi, например 192.168.x.x:8766. Страница каждые 120 миллисекунд спрашивает /api/state, а сервер отдает уже нормальный JSON: скорость, передачу, обороты, map_x, map_y, heading, статус LIVE/HOLDING/WAITING.

То есть телефон не подключается к игре. Он подключается к моей локальной программе. И это очень приятная архитектура: игра ничего не знает про телефон, телефон ничего не знает про бинарную телеметрию, а посередине сидит Python и делает вид, что так и было задумано.

Локальный API для телефона

# server side
if path == "/api/state":
self.send_json(STATE.get_snapshot())
return

# browser side
async function pollTelemetry(){
try{
const r = await fetch('/api/state?ts=' + Date.now(), {cache:'no-store'});
telemetry = await r.json();
updateHud();
handleMemeLayerTelemetry(telemetry);
} finally {
setTimeout(pollTelemetry, 120);
}
}

Итог серии

Самая важная часть проекта оказалась не в красивой карте. Самая важная часть - заставить виртуальную машину стабильно существовать в координатах моего интерфейса.

После этого уже можно делать все остальное: поиск точек, маршрут, heading-up, dashboard, мемные звуки, мини-карту в приборке и прочий цирк. Но фундамент простой: UDP -> struct -> координаты -> калибровка -> JSON -> телефон.

В следующей серии будет интересно про карту: как обычная картинка превратилась в дорожный граф, почему прямая линия до цели - это позор, и зачем мне понадобились 14561 узел и 18612 ребер.

Показать полностью
4

Сделал телефонный навигатор для Forza Horizon 6. Да, как в настоящей машине

Играя в новую форзу (у меня руль и кокпит), с видом из салона, выключил спидометр, который и так дублировался функционирующей приборкой.

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

Сделал телефонный навигатор для Forza Horizon 6. Да, как в настоящей машине

Это ж, получается, надо навигатор разработать игровой, но который будет открываться в телефоне, и показывать в режиме реального времени позицию игрока, и маршруты строить, и опционально приборку заменять в стиле ретро-киберпанк Японии, и еще звуков можно мемных на столкновения и прыжки добавить, чтобы играть было не так скучно...

Я что-то делал-делал, и что-то получилось, смотрите в видео ниже

Сейчас прототип умеет:

  • показывать позицию машины на карте в реальном времени;

  • открываться на телефоне по локальной сети;

  • строить маршруты;

  • показывать расстояние до точки;

  • искать ближайшие POI (т.н. точки интереса);

  • работать как отдельный навигатор, пока HUD в игре выключен;

  • принимать телеметрию из игры;

  • потенциально использоваться ещё и как отдельная цифровая приборка;

  • издавать мемные звуки (можно использовать свои) при столкновениях и прыжках (функция отключаемая).

Самое забавное, что начиналось всё с идеи “хочу просто убрать мини-карту”.

А закончилось тем, что написал навигационную систему для виртуальной машины.

Если интересно, могу отдельным постом показать техническую часть: как ловится телеметрия, как переводятся координаты, как делалась карта и готовилась сетка дорог, как строится маршрут и почему игровые развязки - это маленький филиал ада для самодельной навигации.

Показать полностью 1 1
Отличная работа, все прочитано!

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества