Поделиться
Поделиться

Location-based игры и приложения — одна из самых технически насыщенных областей мобильной разработки. Здесь сходятся точность GPS, пространственные базы данных, real-time синхронизация и необходимость защиты от читеров. Разберём каждый уровень.

Слои точности GPS

GPS на смартфоне — это не одна технология, а стек источников геолокации с разной точностью и потреблением энергии.

| Источник | Точность | Время до первого фикса | Потребление | |---|---|---|---| | GPS (спутники) | 3–10 м | 30–90 с (cold start) | Высокое | | AGPS (assisted GPS) | 3–10 м | 1–5 с | Высокое | | Wi-Fi триангуляция | 10–50 м | <1 с | Низкое | | Cell tower | 100–1000 м | <1 с | Очень низкое | | IP-геолокация | 1–50 км | Мгновенно | Нет |

На практике операционная система сначала отдаёт грубую позицию (Cell/Wi-Fi), затем уточняет через GPS. Важно обрабатывать onLocationChanged событие правильно: не игнорировать первые неточные позиции, но и не принимать их за истину.

Фильтрация GPS-шума

Сырые GPS-данные содержат шум — небольшие прыжки координат даже при стоящем на месте устройстве. Для сглаживания применяют фильтр Калмана:

class KalmanFilter {
  constructor(processNoise = 3, measurementNoise = 1) {
    this.processNoise = processNoise;
    this.measurementNoise = measurementNoise;
    this.estimatedError = 1;
    this.lastEstimate = null;
  }

  filter(measurement) {
    if (this.lastEstimate === null) {
      this.lastEstimate = measurement;
      return measurement;
    }
    const kalmanGain = this.estimatedError /
      (this.estimatedError + this.measurementNoise);
    const estimate = this.lastEstimate + kalmanGain * (measurement - this.lastEstimate);
    this.estimatedError = (1 - kalmanGain) * this.estimatedError + 
      Math.abs(this.lastEstimate - estimate) * this.processNoise;
    this.lastEstimate = estimate;
    return estimate;
  }
}

const latFilter = new KalmanFilter();
const lngFilter = new KalmanFilter();

navigator.geolocation.watchPosition((pos) => {
  const smoothLat = latFilter.filter(pos.coords.latitude);
  const smoothLng = lngFilter.filter(pos.coords.longitude);
  // используем smoothLat, smoothLng
});

Haversine vs PostGIS ST_DWithin

Формула Haversine (на клиенте и простых серверах)

Haversine вычисляет расстояние между двумя точками на сфере:

function haversineDistance(lat1, lng1, lat2, lng2) {
  const R = 6371000; // радиус Земли в метрах
  const φ1 = lat1 * Math.PI / 180;
  const φ2 = lat2 * Math.PI / 180;
  const Δφ = (lat2 - lat1) * Math.PI / 180;
  const Δλ = (lng2 - lng1) * Math.PI / 180;

  const a = Math.sin(Δφ/2) ** 2 +
            Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) ** 2;
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

  return R * c; // метры
}

Haversine подходит для проверки на клиенте и простых серверных вычислений, но не масштабируется для поиска ближайших объектов по базе в 100 тысяч+ записей.

PostGIS ST_DWithin (на сервере)

Для геопространственных запросов к базе данных используйте PostGIS. Подробнее об индексах и типах данных читайте в статье PostGIS: пространственные запросы и индексы.

-- Найти все игровые объекты в радиусе 500 м от игрока
SELECT 
  id,
  name,
  type,
  ST_Distance(location::geography, 
    ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_m
FROM game_objects
WHERE ST_DWithin(
  location::geography,
  ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
  500  -- метры (geography тип автоматически работает в метрах)
)
ORDER BY distance_m
LIMIT 50;

Индекс для быстрого поиска:

CREATE INDEX game_objects_location_gist ON game_objects 
  USING GIST (location);

Геофенсинг

Геофенс — определение момента, когда пользователь входит/выходит из зоны. Реализация на сервере надёжнее клиентской (нельзя подделать).

// Node.js: проверка точки внутри полигона через Turf.js
const turf = require('@turf/turf');

function isInsideZone(userLat, userLng, zonePolygon) {
  const point = turf.point([userLng, userLat]);
  const polygon = turf.polygon(zonePolygon.coordinates);
  return turf.booleanPointInPolygon(point, polygon);
}

// Зона событий (GeoJSON Polygon)
const eventZone = {
  type: 'Polygon',
  coordinates: [[
    [37.615, 55.755],
    [37.620, 55.755],
    [37.620, 55.750],
    [37.615, 55.750],
    [37.615, 55.755]
  ]]
};

// При получении обновления от игрока
async function handlePlayerLocation(playerId, lat, lng) {
  const wasInside = await redis.get(`player:${playerId}:in_zone`);
  const isInside = isInsideZone(lat, lng, eventZone);
  
  if (!wasInside && isInside) {
    await emitEvent('zone_enter', playerId);
    await redis.set(`player:${playerId}:in_zone`, '1', 'EX', 3600);
  } else if (wasInside && !isInside) {
    await emitEvent('zone_exit', playerId);
    await redis.del(`player:${playerId}:in_zone`);
  }
}

Кластеризация точек: Supercluster

При отображении тысяч игровых объектов на карте нужна кластеризация. Supercluster — де-факто стандарт для быстрой кластеризации на клиенте.

import Supercluster from 'supercluster';

const cluster = new Supercluster({
  radius: 60,    // радиус кластеризации в пикселях
  maxZoom: 17,   // максимальный уровень зума для кластеризации
  minPoints: 3,  // минимум точек для создания кластера
});

// Загрузка точек (GeoJSON Features)
cluster.load(gameObjects.map(obj => ({
  type: 'Feature',
  geometry: { type: 'Point', coordinates: [obj.lng, obj.lat] },
  properties: { id: obj.id, type: obj.type, name: obj.name }
})));

// Получение кластеров для текущего viewport
function getClustersForViewport(bounds, zoom) {
  const [west, south, east, north] = bounds;
  return cluster.getClusters([west, south, east, north], Math.floor(zoom));
}

// Пример результата — кластер
// {
//   type: 'Feature',
//   geometry: { type: 'Point', coordinates: [37.617, 55.752] },
//   properties: { cluster: true, cluster_id: 42, point_count: 38 }
// }

Для серверной кластеризации (когда данных слишком много для клиента) используйте PostGIS ST_ClusterDBSCAN или ST_ClusterKMeans.

Античит: защита от фейковых координат

Подделка GPS — главная проблема геоигр. Злоумышленники используют mock location приложения или GPS-спуферы. Несколько уровней защиты:

Уровень 1: Детектирование mock location на клиенте

// React Native: проверка mock location
import { Platform, NativeModules } from 'react-native';

async function isMockLocation(position) {
  if (Platform.OS === 'android') {
    // Android API предоставляет isMock с API 31
    return position.coords.isMock === true;
  }
  return false; // iOS не поддерживает mock detection нативно
}

// Дополнительно: проверка через нативный модуль
// isMockLocationEnabled() проверяет режим разработчика

Уровень 2: Серверная проверка скорости перемещения

async function validatePlayerMove(playerId, newLat, newLng, timestamp) {
  const lastPos = await redis.hgetall(`player:${playerId}:last_pos`);
  
  if (lastPos.lat && lastPos.lng) {
    const distance = haversineDistance(
      parseFloat(lastPos.lat), parseFloat(lastPos.lng),
      newLat, newLng
    );
    const timeDelta = (timestamp - parseInt(lastPos.ts)) / 1000; // секунды
    const speedMs = distance / timeDelta; // метры/секунда
    
    const MAX_SPEED_MS = 15; // ~54 км/ч — быстрее бегущего человека
    
    if (speedMs > MAX_SPEED_MS) {
      await flagPlayerSuspicious(playerId, {
        reason: 'speed_violation',
        speed: speedMs,
        distance,
        timeDelta
      });
      return { valid: false, reason: 'speed_too_high' };
    }
  }
  
  await redis.hset(`player:${playerId}:last_pos`, {
    lat: newLat,
    lng: newLng,
    ts: timestamp
  });
  
  return { valid: true };
}

Уровень 3: Статистический анализ паттернов

| Паттерн | Признак читерства | |---|---| | Идеально прямолинейное движение | Телепортация по GPS-треку | | Координаты всегда на целых градусах | Ручной ввод координат | | Нулевая погрешность (accuracy: 0) | Программная эмуляция | | Движение сквозь здания/воду | GPS-спуфинг без учёта карты | | Нет изменения высоты при ходьбе | Отсутствие барометрических данных |

Оптимизация батареи

GPS — главный пожиратель батареи. Стратегии оптимизации:

// React Native: адаптивный режим обновления GPS
import Geolocation from '@react-native-community/geolocation';

class AdaptiveLocationTracker {
  constructor() {
    this.watchId = null;
    this.lastUpdate = null;
    this.isMoving = false;
  }

  start() {
    this.watchId = Geolocation.watchPosition(
      this.handlePosition.bind(this),
      this.handleError.bind(this),
      {
        enableHighAccuracy: this.isMoving,    // точность по ситуации
        distanceFilter: this.isMoving ? 5 : 50, // порог обновления в метрах
        interval: this.isMoving ? 3000 : 30000, // интервал (Android)
        fastestInterval: 1000,
        forceRequestLocation: false,
      }
    );
  }

  handlePosition(position) {
    const speed = position.coords.speed || 0;
    const wasMoving = this.isMoving;
    this.isMoving = speed > 0.5; // > 0.5 м/с — движение

    if (wasMoving !== this.isMoving) {
      // Пересоздаём watcher с новыми параметрами
      Geolocation.clearWatch(this.watchId);
      this.start();
    }
    // ... обработка позиции
  }
}

Дополнительные советы по батарее:

  • Используйте Significant Location Change API (iOS) для фоновых обновлений — потребляет в 10× меньше.
  • Отключайте GPS когда приложение в фоне, если это допустимо по логике игры.
  • Батчуйте отправку координат на сервер (каждые 5–10 позиций, а не каждую).

Real-time синхронизация через WebSocket

Для многопользовательских геоигр нужна real-time синхронизация позиций:

// Сервер: Socket.io с геопространственными комнатами
io.on('connection', (socket) => {
  socket.on('update_position', async (data) => {
    const { lat, lng, playerId } = data;
    
    // Валидация
    const validation = await validatePlayerMove(playerId, lat, lng, Date.now());
    if (!validation.valid) return;
    
    // Обновляем в Redis Geo
    await redis.geoadd('online_players', lng, lat, playerId);
    
    // Рассылаем только игрокам в радиусе 500м
    const nearbyPlayers = await redis.georadius(
      'online_players', lng, lat, 500, 'm', 'WITHCOORD', 'COUNT', 50
    );
    
    nearbyPlayers.forEach(([nearPlayerId]) => {
      if (nearPlayerId !== playerId) {
        const nearSocket = playerSockets.get(nearPlayerId);
        nearSocket?.emit('player_moved', { playerId, lat, lng });
      }
    });
  });
});

Подробнее об архитектуре WebSocket и масштабировании читайте в статье WebSocket и real-time в веб-приложениях.

Итог

Location-based разработка требует понимания нескольких слоёв: точность источников геолокации, пространственные запросы в базе данных, умная кластеризация для UI и многоуровневая защита от читеров. Комбинация клиентской фильтрации, серверной валидации скорости и статистического анализа закрывает большинство сценариев мошенничества.