Реализация управления акселерометром для мобильной игры
Наклон телефона как геймпад — интуитивный способ управления для гоночных игр, игр-шариков, аркад. Разработчики часто добавляют его за день. Потом неделю полируют: сглаживают задержку, борются с дрейфом, настраивают мёртвую зону, делают калибровку. Правильная реализация требует понимания того, как работает sensor fusion и где теряется отзывчивость.
Почему «просто взять акселерометр» не работает
Сырой акселерометр содержит гравитацию. На ровном столе: (x: 0, y: 0, z: -9.81) — это не движение, это гравитация по Z. Если человек держит телефон под углом 45° в игре, вектор гравитации размазывается по осям. При наклоне влево-вправо меняется x, но меняется и z. Это путаница, которая ломает управление.
Правильный источник: Device Motion / Linear Acceleration — данные уже без гравитации. Но они же имеют шум и медленный дрейф гироскопа при длительной сессии.
Реализация на iOS (Unity + CoreMotion native plugin)
Для нативных UIKit/SwiftUI-игр (SpriteKit, SceneKit):
let motionManager = CMMotionManager()
motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
motionManager.startDeviceMotionUpdates(
using: .xArbitraryZVertical, // без магнитометра — меньше задержка
to: OperationQueue.main
) { [weak self] motion, _ in
guard let motion = motion else { return }
self?.applyTilt(
pitch: Float(motion.attitude.pitch),
roll: Float(motion.attitude.roll)
)
}
xArbitraryZVertical не требует магнитометр, что снижает задержку на ~5–10 мс и потребление. Для гоночных игр направление север неважно.
Для Unity используем Input.gyro + Input.acceleration через UnityEngine.InputSystem:
using UnityEngine.InputSystem;
void Update()
{
var attitude = AttitudeSensor.current;
if (attitude == null || !attitude.enabled) return;
Quaternion deviceOrientation = attitude.attitude.ReadValue();
// Компенсируем ориентацию экрана
Quaternion fixedOrientation = Quaternion.Euler(90, 0, 0) * deviceOrientation;
float roll = fixedOrientation.eulerAngles.z;
float pitch = fixedOrientation.eulerAngles.x;
MovePlayer(roll, pitch);
}
AttitudeSensor — новый Input System. Старый Input.gyro.attitude работает, но deprecated.
Реализация на Android
private var baselineAttitude: FloatArray? = null
private val currentRotationMatrix = FloatArray(16)
// В SensorEventListener.onSensorChanged для TYPE_ROTATION_VECTOR:
val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
val orientationAngles = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientationAngles)
val pitch = orientationAngles[1] // наклон вперёд/назад
val roll = orientationAngles[2] // наклон влево/вправо
// Применяем к baseline (калибровка)
val calibratedPitch = pitch - (baselineAttitude?.get(0) ?: 0f)
val calibratedRoll = roll - (baselineAttitude?.get(1) ?: 0f)
gameEngine.setTilt(calibratedPitch, calibratedRoll)
Сглаживание: low-pass фильтр
Сырые данные дёргаются — руки не бывают абсолютно неподвижными. Простой экспоненциальный фильтр:
struct LowPassFilter {
var value: Float = 0
let alpha: Float // 0.1 = сильное сглаживание, 0.8 = почти сырые данные
mutating func update(_ newValue: Float) -> Float {
value = alpha * newValue + (1 - alpha) * value
return value
}
}
// alpha = 0.3 для гоночной игры (баланс между отзывчивостью и плавностью)
var rollFilter = LowPassFilter(alpha: 0.3)
let smoothRoll = rollFilter.update(rawRoll)
Подбор alpha — эмпирически. Правило: чем меньше alpha, тем плавнее, но больше задержка. Для шарика-лабиринта — 0.2, для гонки — 0.3–0.4, для шутера с прицеливанием — 0.6–0.7.
Калибровка «нейтральной» позиции
Пользователи держат телефон по-разному: один под 30°, другой под 60°. «Нейтраль» должна быть там, где телефон при старте, а не строго горизонтально.
fun calibrate() {
baselineAttitude = floatArrayOf(currentPitch, currentRoll)
}
Вызываем при нажатии кнопки «Калибровать» или автоматически через 2 секунды после запуска игры. Сохраняем baseline в SharedPreferences — чтобы при следующем запуске не перекалибровывать.
Мёртвая зона и нелинейная чувствительность
Центральная мёртвая зона ±5° — убирает непреднамеренное движение при удержании:
func applyDeadZone(_ value: Float, threshold: Float = 0.087) -> Float { // 5 градусов в радианах
guard abs(value) > threshold else { return 0 }
let sign: Float = value > 0 ? 1 : -1
return sign * (abs(value) - threshold)
}
Нелинейная чувствительность (степенная функция): малые наклоны — медленное движение, большие — быстрое. Позволяет точно управлять и резко поворачивать:
let normalizedRoll = clamp(calibratedRoll / maxAngle, -1, 1) // нормализуем к [-1, 1]
let curvedInput = sign(normalizedRoll) * pow(abs(normalizedRoll), 1.5)
playerSpeed = curvedInput * maxSpeed
Комбинирование с тачем
Дать пользователю выбор: акселерометр или виртуальный джойстик. Часть аудитории принципиально не любит наклон — особенно в транспорте. Оба режима должны работать без перезапуска, переключение через настройки.
Сроки
Базовое управление наклоном с калибровкой и фильтрацией — 3–5 рабочих дней. С полировкой под конкретный жанр, нелинейной чувствительностью и тестированием на парке устройств — 1–2 недели.







