Всем привет! Я — Катя из Тинькофф Путешествий. Хочу рассказать, как у нас в Тинькофф Путешествиях появился офлайн-режим с классной игрой, в которой нужно управлять самолетом с помощью акселерометра устройства и собирать монетки.
Сейчас в Тинькофф есть красивая заглушка на случай, когда у пользователя пропадает интернет-соединение. Попадая на нее, пользователь с большой вероятностью попробует обновить страницу. Если это не поможет, то он может перейти на другие сайты/к конкурентам. То есть имеется большая доля вероятности, что можно потерять потенциального клиента. И тут мы задумались, а как можно исправить эту ситуацию и что можно придумать для пользователя.
Самым известным примером офлайн режима можно назвать игру с динозавриком в браузере Chrome.
Игра с динозавриком натолкнула на мысль: а почему бы не добавить на нашу офлайн-страницу в Тинькофф Путешествиях что-то подобное и сделать ее более яркой и интересной для пользователя?
Помимо этого было еще несколько целей, которые хотелось достичь с помощью интерактивной офлайн-страницы:
Удержать пользователя на сайте, пока у него нет интернет-соединения.
Добавить классную фичу! Далеко не у всех приложений и тем более сайтов есть интересный офлайн-режим.
Добавление игры с использованием акселерометра может повысить вовлеченность пользователя, а также оказаться хорошим маркетинговым ходом — пользователи будут рассказывать своим знакомым про эту игру.
Как открыть страницу с игрой
Чтобы открыть страницу с игрой, нужно перейти на страницу Тинькофф Путешествий, затем отключить интернет и обновить страницу.
Сейчас игра доступна на всех андроид-устройствах, а также на айфонах с iOS версии 13 и старше. Дальше я поясню, с чем связаны такие ограничения. А пока расскажу про интерфейс.
Интерфейс и основные экраны
Когда пользователь пытается обновить страницу при отсутствии интернета, он попадает на страничку с игрой.
После нажатия на кнопку «Начать игру» появляется подсказка, как играть. Она появилась не сразу: после первого релиза стало понятно, что управление с помощью акселерометра — не самая очевидная для пользователя вещь, у некоторых возникали сложности в начале игры.
По мере игры самолет начинает ускоряться: чем больше собрано монеток, тем выше скорость.
Когда самолет врезается в границы экрана, игра заканчивается. Сбрасывает весь прогресс: скорость самолета, количество собранных монет и время.
Далее можно начать новую игру.
Когда у пользователя восстанавливается интернет-соединение, он видит уведомление об этом. Он сможет сразу вернуться на ту страницу, на которой был до разрыва соединения, либо продолжить игру.
При этом внизу экрана останется кнопка, по нажатию на которую пользователь сможет вернуться к поиску.
Чтобы игра работала и на айфонах, нужно дополнительно запросить у пользователя доступ к данным движения устройства, потому что в iOS начиная с 13-й версии по умолчанию блокируется доступ к данным акселерометра. Тут важно отметить, что такой запрос можно вызвать только при наличии HTTPS-соединения.
В более ранних версиях этот доступ можно разрешить в настройках Safari. Включение доступа в настройках пользователем самостоятельно выглядит более сложным вариантом, поэтому было принято решение в этом случае не отображать игру.
Если пользователь разрешает доступ — игра продолжается, если нет — появится уведомление, что для начала игры нужно разрешить доступ к данным акселерометра.
Devicemotion API
Чтобы получать данные об ориентации устройства, мы используем Devicemotion API.
Событие devicemotion запускается через регулярные интервалы времени и возвращает данные о вращении и ускорении устройства, а также интервал обновления данных.
Объект с данными выглядит так:
const event = {
interval, // интервал обновления данных в миллисекундах
acceleration: {
// ускорение, м/с2
x,
y,
z,
},
accelerationIncludingGravity: {
// ускорение, м/с2 (не компенсируется сила тяжести)
x,
y,
z,
},
rotationRate: {
// угол вращения вокруг каждой из осей в момент поворота
alpha,
beta,
gamma,
}
};
Подробнее про Devicemotion API
На этапе, когда полученные значения будут обрабатываться, важно помнить, что не все браузеры используют одну и ту же систему координат, поэтому при одинаковых условиях они могут возвращать различные значения.
Движение самолета
Что касается движения самолета, то тут нужно рассмотреть его перемещение и поворот.
Движение реализуется достаточно просто. Для получения новых координат достаточно к старому значению координаты добавить величину шага движения:
function getCoord(step, motionCoord, currentCoord) {
return currentCoord + step * motionCoord;
}
Отдельно анимируются координаты X и Y.
С вращением все оказалось несколько сложнее и интереснее.
Для начала нужно посчитать значение синуса угла поворота (для этого используем данные, полученные от devicemotion), а затем по формуле ниже нужно посчитать значение угла поворота:
function getAngleFromSin(sin) {
return oneDecimal(Math.asin(sin) / PI * HALF_CIRCLE) + 90;
}
Также в этой формуле нужно добавить 90 градусов для компенсации разницы в системах координат.
Откуда берутся эти 90 градусов? Дело в том, что есть две системы координат: одна используется в CSS-стилях, а другая (декартова система координат) используется в devicemotion:
Когда мы по предыдущей формуле получим значение угла, например 30 градусов, то значит, самолет будет направлен в сторону указанного вектора, начало отсчета этого угла находится на оси Х.
Когда мы задаем значение угла поворота в стилях фигуры в свойстве TRANSFORM, то считаем, что угол начинает отсчет от оси Y. И, следовательно, чтобы в системе координат CSS задать такое же направление, нужно добавить 90 градусов.
Также анимация вращения должна быть плавной и красивой при любом изменении ориентации телефона. Поэтому важно было учесть, что если пользователь резко вращает телефон, то нужно найти наименьший угол для поворота самолета, чтобы достичь нового направления.
Например, самолет направлен в сторону вектора 1, а после резкого изменения ориентации устройства он должен быть направлен в сторону вектора 2.
Для более красивой анимации он должен совершить поворот на минимальный угол из двух возможных. Это будет поворот против часовой стрелки.
Рассмотрим этот пример в коде.
function getAngle(currentAngle, newAngle, progress) {
// 1
const currentAngleAbs = currentAngle % FULL_CIRCLE; //абсолютное значение
// нового угла (после вычета целых оборотов)
// 2
const delta = newAngle - currentAngleAbs;
const absDeltaAngle = Math.abs(delta);
// 3
if (absDeltaAngle > HALF_CIRCLE) {
if (delta > 0 ) {//поворот по часовой стрелке
return currentAngle - (FULL_CIRCLE - absDeltaAngle) * progress;
} else {
return currentAngle + (FULL_CIRCLE - absDeltaAngle) * progress;
}
}
// 4
return currentAngle + delta * progress;
}
В строке (1) нужно определить, какое абсолютное значение у текущего угла поворота. Так как анимируется самолетик через css и он может совершать несколько поворотов вокруг своей оси, то угол поворота может быть больше 360 градусов.
Затем в строке (2) нужно посчитать, на сколько градусов сделать поворот.
Если эта величина окажется больше, чем половина оборота, нужно вычислить наименьший угол (3), если нет — просто прибавляем разницу к текущему углу (4).
Сбор монет
Самолет не является примитивной фигурой, и довольно сложно каждый раз определять точки пересечения монеты и самолета, чтобы понять, что монетку надо собрать. Но, поскольку самолет всегда находится в движении и в целом пересечение фигур можно определять не с точностью до пикселя, можно его упростить до окружности.
Тогда можно считать, что монетка собрана, если между центром монетки и центром самолета образуется вектор, длина которого меньше, чем COIN_RADIUS + PLANE_RADIUS:
const vector = {
x: coinX - planeX,
y: coinY - planeY,
};
const vectorLength = getVectorLength(vector);
if (vectorLength < radius + INTERSECTION_AREA) {
if (!isIntersected) {
plane.setValue('isIntersected', true);
return true;
}
}
Оптимизация
Поскольку событие devicemotion срабатывает достаточно часто и важно было достичь красивой анимации, то следовало подумать об оптимизации.
Добавили коэффициент чувствительности для фильтрации колебаний. Это нужно для плавной анимации, чтобы самолет не реагировал на микроколебания и летел ровно. Когда мы держим телефон в руке и кажется, что мы держим его ровно, — это не совсем так. Всегда регистрируются небольшие колебания и изменения ориентации устройства, и их не нужно учитывать при движении самолета.
События devicemotion не нужно обрабатывать слишком часто, так как анимация движения реализована также за счет CSS-стилей. Самолет — это svg-фигура, которой заданы стили и свойство transition. За счет этого достигается и плавность анимации.
Используется requestAnimationFrame для изменения стилей и перерендера страницы.
Восстановление интернет-соединения
Чтобы понять, что интернет-соединение восстановилось, нужно подписаться на событие online.
Когда оно срабатывает, выполняется тестовый запрос. Если он успешен, то пользователю отобразится экран об активном интернет-соединении:
function toggleOnline() {
fetch(TEST_API)
.then(response => {
if (response.ok) {
isOnline = true;
renderInfoPage('online');
}
});
}
Интеграция офлайн-страницы
Чтобы страница с игрой была доступна в офлайн-режиме, используется Service Worker. Он позволяет описывать корректное поведение веб-приложения в режиме офлайн. Подробнее почитать про эту технологию можно здесь и здесь.
Отображать нужную страницу в офлайн-режиме очень просто. Для этого нужно:
Сгенерировать HTML-страницу с игрой. На этапе сборки все стили и скрипт для страницы встраиваются в HTML-страницу, чтобы ее вес был минимален.
Положить страницу в кэш приложения на этапе установки сервис-воркера.
Добавить подписку на событие fetch. В данном случае нас интересуют запросы на обновление страницы или переход на другую страницу. Когда очередной fetch-запрос фейлится, сервис-воркер подменяет ответ запроса на офлайн-страницу из кэша.
Вот пример, как это реализовано:
self.addEventListener('fetch', (event) => {
if (isHtmlPage(event.request)) {
event.respondWith(
fetch(event.request).catch(() => {
return caches.open(CACHE_NAME).then((cache) => {
const pagePath = isPhone()
// если мобилка, то показываем страницу с игрой
? OFFLINE_PAGE
// иначе - обычную страницу
: DEFAULT_PAGE;
return cache.match(pagePath);
});
})
);
}
});
Также на этом этапе нужно определить, с какого устройства зашел пользователь, чтобы показать корректную страницу (к слову, на этапе, когда кладем страницу в кэш, тоже нужно определить тип устройства, чтобы в кэше оказалась нужная страница). Для мобильных устройств нужно отобразить страницу с игрой, для остальных — стандартную страницу-заглушку.
Здесь определение типа устройства происходит на основе данных User Agent.
А что дальше?
У нас много планов по развитию офлайн-страницы! Вот некоторые из них:
Формировать список топ-игроков, показывать пользователю этот рейтинг и его место в рейтинге.
Сейчас, чтобы играть, нужно держать телефон горизонтально. Это не совсем удобно, поскольку обычно мы держим телефон в вертикальном положении. Планируем доработать алгоритм калибровки устройства, чтобы можно было играть, держа телефон вертикально.