Привет! Меня зовут Сергей, я руковожу разработкой фронтенда спецпроектов в KTS. Спецпроекты - небольшие рекламные приложения, нередко с довольно сложной механикой. Весной 2020 года мы вместе с командой спецпроектов ВКонтакте придумали концепцию и механику игры к 5-летию Oreo в России.
Задумка игры заключается в том, что пользователь может свайпать по экрану, а за каждый свайп ему начисляются баллы - печеньки Oreo, которые падают друг на друга и образуют высокую башню. Пока пользователь свайпает, он проходит “уровни” - фоны, которые сменяют друг друга. У каждого уровня есть начальный фон и основная часть, поверх которой движется слой с параллаксом, чуть быстрее основного фона. В итоге получается такая красивая бесконечная башня, которая уходит в космос на последнем уровне, где фон зацикливается. В игре предусмотрены дополнительные “бустеры”, “авторост” башни по времени, различные рейтинги, шеринги и тд.
Сама игра была запущена в июне 2020 года, рекламная кампания длилась до осени, сейчас проект выключен. За время размещения игра собрала более полутора миллионов уникальных юзеров. Интерфейс выглядел так:
После получения макетов, мы засетапили проект и сели думать, как реализовать эту бесконечную башню. Основная проблема в том, что игра должна визуально выглядеть красиво, все должно скроллиться плавно даже на слабых телефонах. Оформить игру нужно было в виде VK Mini App - это небольшие веб-приложения, которые запускаются прямо внутри мобильного приложения ВКонтакте.
Мы определили следующие основные задачи:
Добавление большого количества печенек в башню (последний уровень в игре открывается на высоте 200к печенек).
Реализовать плавное прокручивание башни и задних слоёв.
Прокручивать башню можно двумя способами: ведя пальцем по ней или по карте справа
Для удобства я разделю статью на несколько последовательных этапов:
Добавление и падение печенек
Добавление скролла
Виртуализация башни
Разбиение башни на блоки
Фоны с параллаксом
Оптимизации
Этап 1. Добавление и падение печенек
Первое, что нужно было решить, это как добавлять новые печеньки в башню и делать это красиво.
Для всего игрового взаимодействия мы завели 2 MobX-стора: GameStore и GameUI.
GameStore хранит количество полученных очков, печенек и дополнительные параметры игры, например, установленный сейчас бустер или текущий цвет печеньки (да, они могут быть еще и разных цветов). GameStore периодически синхронизирует состояние по вебсокетам с сервером. Все действия (свайпы и покупки бустеров) также проходят по вебсокетам.
GameUI нужен исключительно для отображения башни, он синхронизирует отображение и текущее состояние игры.
Компонент игрового экрана содержит элементы фона, основной информации и башни:
<Wrapper>
<Info />
<Background />
<Tower />
<MiniTower />
</Wrapper>
Чтобы отрисовать печеньки мы решили использовать display: flex с direction: column-reverse. То есть мы переворачиваем башню с помощью простого css, чтобы было удобно индексировать печеньки. Первая печенька будет внизу, последняя - вверху.
При этом каждая следующая печенька должна быть рандомно смещена относительно предыдущей и перекрывать ее. Это легко достигается z-index’ом и генерацией смещения. Мы просто сделали массив из 100 рандомных смещений от -10 до 10px и для каждой печеньки получаем смещение по остатку от деления индекса печеньки на длину массива. То есть паттерн смещений повторяется каждые 100 печенек, но для пользователя это будет абсолютно незаметно.
export const TRANSLATIONS: number[] = Array.from(new Array(100)).map(
() => (Math.random() - 0.5) * 20
);
const translation = TRANSLATIONS[index % TRANSLATIONS.length];
Теперь нужно добавить свайп и падение новой печеньки. Для жестов мы обычно используем библиотеку react-use-gesture. Она предоставляет удобный api с разными параметрами жеста, например, направление, скорость и смещение по осям. Используем хук useDrag, он возвращает обработчик жеста. Биндим такой обработчик на элемент игрового экрана и получаем имитацию свайпа по этому элементу.
const bind = useDrag(
({ last, direction: [, dirY], vxvy: [, vy] }) => {
if (dirY === 1 && vy > 0.2 && last) {
gameStore.click();
}
},
{
axis: 'y',
filterTaps: true,
}
);
Метод click в gameStore обновляет @observable поле swipes (количество печенек), отправляет событие в вебсокет и вызывает update в gameUi. Упрощенно это выглядит так:
// GameStore
@action.bound
click(): void {
this.swipes += this.swipePower; // Количество печенек за один свайп
this.rootStore.wsConnect
.sendMessage(WSSendEvent.swipe, { times: 1 })
this.gameUI.update({ count: this.swipePower });
}
// GameUI
@action
update({ count }: { count: number }): void {
this.uiInteraction = true;
this.newDiffCount = count;
setTimeout(() => {
this.uiInteraction = false;
// Здесь в дальнейшем добавится логика сдвига башни
}, NEW_OREO_ANIMATION_TIME + 100);
}
uiInteraction отвечает за то, что игра обновляется от действия пользователя, а newDiffCount позволяет контролировать, сколько печенек должно “упасть” сверху на башню. Это важно, чтобы контролировать анимации падения печеньки и последующий сдвиг башни вниз.
Само падение реализовано на простом transition + transform. Если при рендере текущая печенька входит в диапазон gameStore.swipes - gameUi.newDiffCount, значит она считается “новой” и мы показываем ее с анимацией:
<Oreo
isNew={
(i + gameUi.newDiffCount >= gameStore.swipes) && gameUi.uiInteraction
}
/>
OreoWrapper = styled.div<{ isNew?: boolean }>`
transition: all ${NEW_OREO_ANIMATION_TIME}ms linear,
opacity ${NEW_OREO_ANIMATION_TIME / 3}ms linear;
opacity: 1;
${(props) =>
props.isNew &&
css`
opacity: 0;
transform: translate(-50%, -200px);
`}
`;
Отлично! Мы получили нужный эффект красивого падения печеньки по свайпу. Теперь нужно добавить скролл башни.
Этап 2. Добавление скролла
На этом этапе начались первые трудности. При скролле нужно решить сразу несколько задач. Во-первых, фон и сама башня скроллятся с разной скоростью для создание эффекта параллакса. Во-вторых, текущая позиция скролла должна быть отражена в мини-башенке справа, чтобы игрок понимал, на какой уровень башни он сейчас смотрит. Ну и в-третьих, при свайпе или добавлении новых печенек (например, по событие из вебсокета по “авторосту”) нужно прокручивать башню автоматически наверх, если игрок сейчас смотрит на ее верхушку.
Пример:
Так как от позиции скролла зависит много элементов, стало ясно, что нужно синхронизировать все это множество через единую точку. И такой точкой как раз стал наш GameUi, в который мы добавили observable поле uiPosition. Это поле будет хранить текущую позицию в пикселях, на которую смотрит пользователь. И от нее можно рассчитывать положения всех элементов. Сам скролл мы имитировали через css - просто смещаем разные слои игры (фон и башни) на нужную позицию с помощью translate.
Снова используем useDrag из react-use-gesture, вешаем его на элемент башни, а при жесте перемещения - вызываем метод у стора GameUi.
Обновляя позицию скролла важно учитывать, чтобы нельзя проскроллить ниже экрана и выше верхушки башни, то есть она всегда должна быть видна даже при максимальном скролле. Отсюда получается min-max в определении uiPosition.
@action
moveGame(deltaY: number): void {
if (Math.abs(deltaY) > 0) {
this.uiPosition = Math.min(
Math.max(0, this.uiPosition + deltaY),
Math.max(
0,
this.game.swipes * OREO_HEIGHT_PX -
windowHeight / 3
)
);
this.trackLastOreo =
this.towerHeight - this.uiPosition < windowHeight;
}
}
trackLastOreo отвечает за то, что пользователь сейчас смотрит на верхушку башни. Это используется, чтобы не скроллить башню наверх при получении обновлений из вебсокета, если игрок смотрит куда-нибудь в середину башни и в других подобных случаях.
Поле uiPosition дальше используется в @computed свойствах GameUi для вычислений позиций разных элементов игрового экрана. В большинстве случаев это вычисление пропорций в зависимости от процента проскролленого экрана. Например, вычислить позицию ползунка на мини-башенке можно так:
get miniTowerPosition(): number {
if (this.towerHeight === 0) {
return 0;
}
const uiPositionPercent = this.uiPosition / this.towerHeight;
return uiPositionPercent * this.miniTowerHeight;
}
Другие элементы, например, размер ползунка в мини-башне или позиция фона текущего уровня, также зависят от размеров башни и текущего уровня прокрутки.
Используются все рассчитанные значения примерно так:
const [{ y }, set] = useSpring(() => ({
y: gameUi.uiPosition,
}));
<Tower
style={{
transform: y.to(
(v: number) => `translate3d(0, ${v}px, 0)`
),
}}
>
react-spring анимирует сами значения, то есть применяет некоторую функцию, которая сглаживает изменение целевой переменной, и затем напрямую выставляет текущее значение в css свойства элемента. Благодаря этому анимации получаются плавные и красивые.
Этап 3. Виртуализация башни
После того, как мы получили основной функционал встала проблема с виртуализацией. Мы сразу понимали, что это будет необходимо сделать, так как печенек в общем случае может быть и несколько миллионов. Может когда-то браузеры смогут без проблем отрисовывать миллионы элементов, но пока это так не работает, приходится прибегать к разным ухищрениям.
Принцип виртуализации простой - вместо того, чтобы рендерить огромный список элементов, нужно рисовать только видимую пользователю часть.
Библиотеки, которые обычно используются для этого в React-приложениях: react-virtualized и react-virtuoso.
Однако в нашем случае нужен контроль над всем происходящим на экране, поэтому было проще сделать свою реализацию этого принципа, тем более, что размеры печенек фиксированы и известны заранее.
В реальности мы оставляем некоторый зазор для скролла, то есть рендерим N печенек на экране + еще немножко сверху и снизу, чтобы не перерисовывать элементы, если юзер начнет скроллить.
Таким образом, браузеру нужно отрисовать не больше фиксированного количества печенек на экране одновременно.
Хранить и обновлять индексы текущих отрендеренных печенек будем в том же GameUI сторе. Индекс минимальной видимой печеньки - это просто позиция скролла, деленная на размер печеньки. Максимальный индекс - это позиция скрола + высота экрана, деленная на размер, но не более текущего количества печенек игрока.
Пересчет индексов будет происходит только в том случае, если пользователь проскроллил больше заданного количества печенек вверх или вниз. Это дает нам возможность не рендерить блок печенек, пока пользователь не достаточно далеко проскроллил текущий видимый блок. В нашем случае значение установлено в 100 печенек и 80% для пересчета индексов:
// GameUi
export const OVERSCAN = 100;
export const OVERSCAN_THRESHOLD = OVERSCAN * 0.8;
@action.bound
_updateVisibleIndexesImmediately = (): void => {
const minVisibleIndex = Math.max(
0,
Math.floor(this.uiPosition / OREO_HEIGHT_PX)
);
const maxVisibleIndex = Math.min(
this.game.swipes,
Math.floor(
(this.uiPosition + this.rootStore.uiStore.windowHeight) / OREO_HEIGHT_PX
)
);
const [cachedMin, cachedMax] = this.cachedVisibleIndexes;
if (
(minVisibleIndex >= 0 &&
minVisibleIndex - OVERSCAN_THRESHOLD < cachedMin) ||
(maxVisibleIndex <= this.game.swipes &&
maxVisibleIndex + OVERSCAN_THRESHOLD > cachedMax)
) {
this.cachedVisibleIndexes = [
Math.max(0, minVisibleIndex - OVERSCAN),
Math.min(maxVisibleIndex + OVERSCAN, this.game.swipes),
];
}
};
Так как скорость скролла может быть очень высокой (например, когда у игрока миллион печенек и он скроллит башню ползунком справа), мы немного дебаунсим пересчет индексов.
В самом компоненте башни остается только отрисовать нужные печеньки:
const [minVisibleIndex, maxVisibleIndex] = gameUi.cachedVisibleIndexes;
const oreosBlock = useMemo(() => {
const oreos = [];
for (let i = minVisibleIndex; i < maxVisibleIndex; i += 1) {
oreos.push(
<Oreo
fillingId={gameStore.getCookieFiling(i)}
isNew={
(i + gameUi.newDiffCount >= gameStore.swipes) && gameUi.uiInteraction
}
index={i}
key={i}
/>
);
}
return oreos;
}, [minVisibleIndex, maxVisibleIndex]);
Отрендеренный “кусок” башни нужно поднимать до уровня видимой части. Мы делаем это с помощью отступа на “невидимую высоту” снизу.
@computed
get invisibleHeight(): number {
const [minIndex] = this.cachedVisibleIndexes;
return minIndex * OREO_HEIGHT_PX;
}
<Tower
style={{
marginBottom: `${gameUi.invisibleHeight}px`,
transform: y.to(
(v: number) => `translate3d(0, ${v}px, 0)`
),
}}
>
Важно, что, благодаря ключам компонентов, равным индексу печеньки в башне, даже при изменении видимых индексов, будут перерендериваться только те компоненты, которых еще не было в DOMе.
Отлично! Теперь наша башня скроллится, а печенек может быть неограниченное количество практически без потери производительности… или не совсем?
Этап 4. Разбиение башни на блоки
Когда мы начали тестить полученную башню на сотнях тысяч печенек, все было довольно хорошо и гладко работало… пока мы не решили проверить ее на миллионах. Вот, что мы увидели:
Башня уперлась в потолок и перестала скроллиться. Все потому, что значение margin-bottom и translate стали максимальными:
margin-bottom: 3.99978e+07px;
transform: translate3d(0px, 3.99998e+07px, 0px);
Нам очень не хотелось переделывать всю логику (да и непонятно, как?), поэтому мы придумали такой хак: если башня ограничивается максимальными значениями css-свойств, то нужно просто разбить ее на блоки, каждый из которых уже будет иметь кратно меньшие значения этих свойств. Казалось, что это сработает.
Чтобы внедрить это и потратить минимум усилий, мы решили сделать рекурсивный компонент одного блока башни. Честно говоря, на своей практике мне не приходилось делать такие компоненты, в плане кода и контроля над ним, мне кажется, это выглядит не очень красиво. Однако это сработало на удивление хорошо и добавить это было очень просто.
В GameUI мы добавили @computed поле, которое будет пересчитывать нужное количество блоков. Так как в экономике игры не предполагается слишком большое количество печенек (вряд ли кто-то наберет больше 1-2 миллионов очков), мы просто делим число очков на миллион и получаем количество блоков:
@computed
get totalUiTowerBlocks(): number {
return Math.ceil(this.game.swipes / 1000000);
}
А компонент башни преобразуется в блок башни, который рекурсивно рендерит сам себя, пока не доходит до финального блока + 1. В этот момент он рендерит сами печеньки. baseHeight - это невидимая часть башни (до текущей позиции скролла), basePosition - сама позиция.
// TowerBlock
if (index === total) {
return oreos; // Рендерим печеньки
}
return (
<Tower
style={{
marginBottom: `${baseHeight / total + (index === 0 ? 50 : 0)}px`,
transform: basePosition.to(
(v: number) => `translate3d(0, ${v / total}px, 0)`
),
}}
>
<TowerBlock
oreos={oreos}
total={total}
index={index + 1}
baseHeight={baseHeight}
basePosition={basePosition}
/>
</TowerWrapper>
);
Теперь marginBottom и translate имеют значения не более, чем общее значение позиции, деленное на количество блоков.
Это решение работает гладко и даже в момент пересчета количества пользователь не замечает “подмены” блоков.
Этап 5. Фоны с параллаксом
Чтобы создать красивый эффект прохождения уровней-локаций, дизайнеры отрисовали каждую локацию тремя картинками:
Начало локации - просто картинка фона со статичными элементами
Основа локации - однотипный длинный фон, идущий после начала
Параллакс слой - слой, который должен двигаться быстрее основы локации, но с той же скоростью, что и начало локации, чтобы создать эффект плавного перехода.
Вот, как выглядит один уровень:
По достижению пользователем определенной высоты, начинается следующий уровень, откручивается начало, потом основа, поверх которой с ускорением крутится параллакс-слой. На последнем уровне параллакс слой зациклен и крутится бесконечно, сколько бы ни было очков у игрока.
Важно отметить, что каждый уровень “длится” разное количество свайпов. Например, первый уровень можно пройти за 500 свайпов, а второй уже за 2000, при этом размеры фонов не связаны пропорционально, то есть размер фонов этих двух уровней может совпадать, или второй может быть даже меньше первого. Это немного усложняет задачу.
Чтобы прокручивать эти фоны, мы используем 3 разных анимации-спринга, которыми манипулируем отдельно. Например, начало должно крутиться быстрее, чем основа, с такой же скоростью, как параллакс-слой, но при этом параллакс-слой больше размером, чем основа. Прокрутка фонов технически сделана по тому же принципу, что и прокрутка башни (изменение свойства translate в зависимости от рассчитанных по позиции скролла значений).
Чтобы это все работало плавно и с правильной “скоростью” скролла, мы просто переводим текущую позицию скролла в измерение печеньками (благо размер печенек фиксирован). А дальше вычисляем текущий уровень, отдельно прогресс начала и отдельно прогресс основы. Скорость скролла вычисляется последовательно по индукции пропорционально размерам фонов и скорости скролла предыдущего уровня.
get currentScreenPx(): number {
const currentPointerSwipes = this.uiPosition / OREO_HEIGHT_PX;
const currentLevel = getCurrentLevelBySwipes(currentPointerSwipes);
const passedSwipes = currentPointerSwipes - currentLevel.swipes;
const startProgress = Math.min(
1,
passedSwipes / currentLevel.startSwipesRequired
);
const mainProgress = Math.max(
0,
(passedSwipes - currentLevel.startSwipesRequired) /
currentLevel.mainSwipesRequired
);
return (
startProgress * currentLevel.startScreenHeight +
mainProgress * currentLevel.mainScreenHeight +
currentLevel.startPositionPx
);
}
В итоге функция выглядит просто и понятно, а главное позиция фонов не зависит от их размеров.
Для того, чтобы добиться эффекта параллакса мы накладываем картинку слоя на основу N + 1 раз и в N раз быстрее откручиваем ее. В итоге за один скролл общего фона верхний слой откручивается несколько раз и создается нужный эффект наложения.
Этап 6. Оптимизации
И вот, когда мы добавили несколько уровней и очень много печенек в башню… интерфейс начал подлагивать. При быстром скролле на очень большом количестве печенек происходило много перерендеров завязанных на стор компонентов. А это все фоны и слои, блоки башни, мини-башня и печеньки. Эти подлагивания были практически незаметны на десктопе и на современных телефонах, но очень хотелось, чтобы наша игра плавно и красиво отображалась на менее производительных устройствах, ведь основное в игре - визуальная составляющая.
Как уже писал выше, react-spring при отрисовке анимированного значения меняет css свойства элемента напрямую. Однако сами анимируемые значения у нас менялись с обновлением компонента. И фактически во многих случаях мы перерендеривали компонент только ради изменения этих анимируемых значений. Слишком большой оверхед, чтобы поменять css-свойства :)
Поэтому мы решили “регистрировать” объекты спрингов в gameUi и обновлять их напрямую в обход жизненного цикла реакт-компонентов.
При такой схеме мы отключили от стора большинство наших визуальных компонентов и они рендерились константное число раз за показ игрового экрана, а дальше положение скролла изменялось напрямую в зарегистрированном для этого визуального объекта спринге.
Конечно, мы потеряли реактивность и удобство MobX и всех наших @computed свойств при обновлении компонентов. Нам пришлось перенести всю логику изменения анимаций в стор, обрабатывать разные граничные случаи, не забывать делать апдейты в нужные моменты и тд. Но это не сравнится с тем приростом производительности, который мы получили. Теперь игра “летала” даже на x6 замедлении в хроме.
Итог
В итоге мы получили красивый и плавный компонент с игровым экраном.
Конечно, можно сказать, что лучше было бы использовать другие средства для визуала, например, рисовать игру на канвасе, чтобы не сталкиваться с проблемами оптимизаций отображения и перерендеров. Возможно это так, но, с другой стороны, мы бы потеряли много удобств компонентов и DOM-элементов, и почти наверняка просто столкнулись бы с кучей других задач и проблем.
В статье мы сконцентрировались на основных моментах и ключевых идеях решения проблем, с которыми столкнулись:
Виртуализация большого числа элементов
Синхронизация анимаций всех визуальных элементов
Оптимизации скорости работы компонентов интерфейса
Надеемся, вам было интересно, спасибо за внимание!