Реалізація управління акселерометром для мобільної гри
Наклон телефону як геймпад — інтуїтивний спосіб управління для гоночних ігор, ігр-шариків, аркад. Розробники часто додають його за день. Потім неделю полірують: сглажують задержку, борються з дрейфом, настраивают мертву зону, робят калибровку. Правильна реалізація вимагає розуміння того, як працює 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 тижні.







