Как мы разрабатывали браузерную игру: взгляд со стороны frontend-архитектора

от автора

Многие компании сегодня всячески пытаются мотивировать и удерживать своих сотрудников. Поэтому все чаще мы слышим о геймификации как о процессе, который позволяет значительно улучшить показатели вовлеченности, повысить продажи, заинтересовать и превратить ежедневную рутину в увлекательный игровой процесс. Мы в SimbirSoft приняли участие в разработке такого игрового приложения. 

Я Антон, руководитель Архитектурного комитета SimbirSoft, и в этой статье я расскажу о полученном опыте с точки зрения технологических особенностей реализации frontend-части Рассмотрим большое количество нестандартных элементов игрового интерфейса и общие требования и ограничения к frontend-части приложения (архитектура, model, service, store и т.д.). Поделюсь, как реализовали:

  • набор визуальных элементов приложения;

  • элементы пагинации;

  • сложный компонент на примере кнопки;

  • составной компонент на примере g-card-list;

  • анимацию.

Начало пути

Исходными данными на старте реализации стали следующие требования:

1. Удобный интерфейс пользователя

Когда мы говорим о том, чтобы интерфейс был удобным, и пользователям было комфортно с ним работать, мы, конечно же, подразумеваем поиск некоторого компромисса между многими взаимоисключающими факторами. 

Что лучше? Заставить пользователя подождать десяток секунд в начале, чтобы загрузить всю основную информацию и инициализировать все объекты, или делать загрузку и инициализацию при открытии определенной страницы или вкладки? Поместить большую часть данных в локальное хранилище или всегда запрашивать эти данные с сервера? Сделать загрузку данных фоновым процессом или всегда работать только с «живыми» данными? 

Ответы на все эти вопросы приходят не сразу. Нужно анализировать, думать, пробовать, ставить эксперименты. Однако для этого с самого начала нужна проработанная архитектура web-приложения, которая бы позволяла гибко управлять данными, применять различные варианты обновления данных, осуществлять простой и консолидированный доступ к общим данным, защищала от нечаянных изменений.

Учитывая всё это, выделим основные требования, которые должны соблюдаться при проектировании архитектуры приложения:

  • Переходы между экранами и загрузка данных не должны занимать много времени (отзывчивость интерфейса)

  • Обновление данных должно осуществляться быстро и незаметно для пользователя

  • Источники обновления информации со стороны backend могут быть как синхронные (через стандартный REST API) — это запросы на обновление при переходе на другую страницу или совершения некоторых действий пользователя, так и асинхронные (через web-сокеты) — при динамических изменениях, например при пересчете рейтингов игроков или появление нового события, о котором нужно уведомить пользователя.

2. Большое количество нестандартных элементов игрового интерфейса

Почти все интерфейсы пользовательской части игры изобилуют большим количеством нестандартных интерактивных и информативных элементов. Поэтому первая задача связана именно с правильной и грамотно построенной стратегией их реализации.

Исходные требования по элементам следующие:

  • Общее количество различных элементов интерфейса около 40 штук

  • Элементы должны визуально соответствовать дизайну игрового стиля, при этом иметь интерактивность и обеспечивать кроссбраузерность, а также поддерживать адаптив

  • В основе большинства элементов лежит работа с SVG-графикой — отдельные части интерактивных элементов состоят из набора SVG-картинок, с которыми необходимо работать во время интерактива

  • Использование анимации для некоторых элементов интерфейса

Для комплексного решения всех указанных задач мы решили выбрать следующий алгоритм действий:

  • Проанализировать все дизайн-макеты, выделить набор типовых элементов и сформулировать требования к их реализации

  • Классифицировать такие элементы по сложности, выделить для них общие свойства и функции для того, чтобы можно было использовать систематический подход для реализации

  • Создать библиотеку типовых игровых элементов интерфейса

  • Создать набор готовых анимаций

В качестве базового UI-фреймворка было решено использовать Vuetify (Version: 2.2.32) благодаря его гибкости, наличию большого числа разнообразных компонентов, а также поддержки кроссбраузерности и адаптивности. Также для реализации был выбран HTML-препроцессор PUG (Version: 3.0.0) и CSS-препроцессор Stylus (Version: 0.54.5).

3. Общие требования и ограничения к frontend-части приложения:

  • Платформа: web-приложение на основе фреймворка VueJS (Version: 2.6.11) + Nuxt.js (Version: 2.0.0, mode: SPA)

  • Корректное отображение в браузерах: Google Chrome, Mozilla FireFox, Opera последних версий

  • Использование возможностей HTML и CSS при реализации без привлечения дополнительных инструментов наподобие WebGL или Blend4Web

Теперь, когда определены ключевые требования и намечен план работ, можно переходить к описанию реализации основных пунктов.

1. Разработка архитектуры приложения

Архитектура frontend-приложения, как и любая другая архитектура в IT, должна прежде всего обеспечивать следующие требования:

  • Гибкость — возможность легкого расширения функциональности

  • Универсальность — возможность выносить базовую функциональность в ядро системы для повышения эффективности повторно используемых компонентов

  • Простота и наглядность — свойство, позволяющее писать простой и понятный код в терминах архитектуры, что в результате приводит к снижению порога вхождения новых разработчиков в проект

Как известно, VueJS основан на компонентах, каждый из которых может содержать в себе и бизнес-логику, и представление, и много других вещей, которые обеспечивают сквозные требования (например, логирование, верификацию входных данных) или отвечают за элементы интерактивного взаимодействия с пользователем (например, изменение данных в результате действия пользователя). Это приводит к тому, что со временем компоненты сильно «раздуваются», что делает их очень сложными для дальнейшего сопровождения и поддержки. Поэтому было принято решение использовать подход, в основе которого лежат следующие  принципы:

  • Разделение на слои — позволяет лучше разделять ответственность между функциями, а код становится проще поддерживать и сопровождать 

  • Слои очерчены не строго — слой может обращаться на нижестоящие слои и на самого себя (сервисы могут обращаться друг к другу)

  • Уход от реализации бизнес-логики в компонентах — упрощает логику, реализуя принцип разделения логики и отображения

  • Использование ООП — позволяет более просто описать отдельные предметные области и повысить процент повторного использования кода

  • Централизованное хранение данных на базе Vuex — все данные хранятся централизованно, что позволяет их обновлять из различных источников и упрощать доступ к ним из любой части приложения.

Общую схему архитектурной модели приложения можно увидеть на Рисунке 1. Она показывает базовые функциональные слои, которые используются для реализации функций различного уровня.

Рисунок 1. Модель архитектуры frontend-приложения

Рисунок 1. Модель архитектуры frontend-приложения

1.1. Модель (model)

Модель представляет собой сущность, которая используется для типизации данных, поступающих извне. В терминах языка JavaScript — это класс, наследуемый от базовой модели (в данном проекте это абстрактный класс, но он может содержать некоторые общие правила преобразования), который принимает данные, осуществляет их mapping и, возможно, элементарные преобразования. Основная задача модели — обеспечить постоянство пространства имен между JSON от бека и объектом, который используется в сервисах или шаблонах компонентов. Кроме этой задачи, на модель можно возложить функцию валидации входных данных в кортеже (например, проверить, что идентификатор пользователя не пустой или количество монет представляет собой положительное число), а также некоторые преобразования (например, создать новые поля в объекте, которые часто используются путем вычисления).

Пример модели показан в Листинге 1.

1 import BaseService from './base' 2 export default class User extends BaseService { 3   constructor (data = {}) { 4    super(data) 5    this.id = data.id 6    this.firstName = data.first_name 7    this.secondName = data.second_name 8    this.avatarUrl = data.avatar_url 9    this.level = data.level 10    this.resources = data.resources || [] 11    this.experience = data.experience 12  } 13 14  getFullName () { 15    return `${this.firstName} ${this.secondName}` 16  } 17 }

Листинг 1. Пример реализации модели для сущности «Пользователь»

В данном примере показаны четыре основных функции модели:

  1. Обеспечивается постоянство пространства имен внутри приложения — если на стороне backend будет принято решение изменить имя поля в теле ответа, то со стороны фронта можно ограничится только изменением модели (строки 5-11)

  2. Задаются правила именования переменных — переход с kebab-case, который используется на беке в camelCase, который применяется на фронте (строки 5-11)

  3. Осуществляется инициализация по умолчанию для массива resources — если такого поля нет в ответе с бека, то инициализируем его как пустой массив (строка 10)

  4. Функция getFullName() выполняет роль геттера, который формирует полное имя пользователя (строки 14-16)

1.2. Сервис (service)

Слой сервисов используется для реализации основной логики приложения. Сервисы разделены на две группы:

  1. Сервисы общего назначения — например, сервис API содержит функции доступа к API через axios или сервис для работы с web-сокетами, кэширования и т.п.

  2. Сервисы сущностей — реализуют бизнес-логику, необходимую для функционирования сущности. Например, сервис users отвечает за работу с пользователем и содержит функции login, logout, get, getUserResources и т.п.

Согласно предложенной архитектуре, сервис сущности содержит в себе ВСЮ бизнес-логику той сущности, которую он реализует. Он представляет собой класс с набором статических функций, каждая из которых реализует требуемую функциональность. Также сервис отвечает за взаимодействие с backend-частью и сохранение данных в Vuex хранилище.

Для унификации правила использования и именования типовых функций в различных сервисах одинаковы, что упрощает использование сервисов в приложении. Набор типовых функций для сервиса:

  • getList — функция для получения списка всех элементов сущности

  • addItem — функция добавления нового элемента

  • deleteItem — функция удаления элемента

  • updateItem — функция обновления

Кроме типовых функций, каждый сервис может содержать специфичные для него, которые являются уникальными. Это тоже хорошо ложится в модель предложенной архитектуры и обеспечивает ее гибкость.

Рассмотрим сервис, который содержит бизнес-логику для сущности «Предметы». Он реализует сразу несколько различных функций:

  • getList — получает список предметов, которые есть у текущего игрока

  • addItem — добавление нового предмета: после того, как игрок купил новый предмет в магазине или получил в качестве награды, вызывается данная функция, которая добавляет полученный предмет в сумку героя

  • deleteItem — удаление предметов: их можно продавать, поэтому данная функция удаляет предмет из сумки героя

  • updateItem — функция обновления информации о предмете: вызывается после того, как прошел бой, и предмет, возможно, был поврежден

  • equip — функция экипировки героя: вызывается, когда герой хочет надеть предмет на себя, то есть перенести его из сумки на себя в свободный слот

  • unEquip — функция разэкипировки героя: вызывается, когда герой снимает предмет экипировки и кладет его обратно в сумку.

 Исходный код сервиса Items показан в Листинге 2:

1 // Подключение моделей и сервисов 2 import BaseService from '~/services/base' 3 // Сервис для работы с API 4 import ApiService from '~/services/api' 5 // Сервис для работы со справочником слотов 6 import SlotsService from '~/services/directories/slots' 7 // Сервис работы с пользователем 8 import UserService from '~/services/user' 9 // Модели для используемых сущностей 10 import ItemModel from '~/models/items/item' 11 export default class Items extends BaseService { 12 // Получение данных с сервера и их сохранение в Vuex 13  static getList (params = {}) { 14    return ApiService.getList('items', params) 15      .then(itemsData => { 16        const Items = { 17          items: [], 18          total: 0, 19          page: 0 20        } 21        itemsData.data.forEach(itemData => { 22          // Каждый элемент "прогоняется" через модель 23          Items.items.push(new ItemModel(itemData)) 24        }) 25        Items.total = itemsData.meta.total 26        Items.page = itemsData.meta.current_page 27        // Сохранение полученных данных в Vuex 28        this.vuex.dispatch('setItems', Items) 29      }) 30  } 31  // Добавление нового элемента данных   32  static addItem (item) { 33    return ApiService.addItem('items', item) 34  } 35  // Удаление элемента данных 36  static deleteItem (id) { 37    return ApiService.deleteItem('items', id) 38  } 39  // Изменение информации о сущности 40  static updateItem (id, item) { 41    return ApiService.updateItem('items', id, item) 42  } 43  // Функция экипировки персонажа 44  static equip (item, equipmentSlot = null) { 45    return this.api.equip(item, equipmentSlot) 46      .then(() => { 47        UserService.getInventory() 48          .then(() => { 49            SlotsService.updateAmuletSlots() 50            SlotsService.updateElixirSlots() 51            UserService.get() 52          }) 53      }) 54      .catch(error => { 55        this.error(error) 56      }) 57  } 58  // Функция снятия экипировки с персонажа 59  static unEquip (item) { 60    return this.api.unEquip(item) 61      .then(() => { 62        UserService.getInventory() 63          .then(() => { 64            SlotsService.updateAmuletSlots() 65            SlotsService.updateElixirSlots() 66            UserService.get() 67          }) 68      }) 69      .catch(error => { 70        this.error(error) 71      }) 72  }

 Листинг 2. Пример реализации сервиса «Предметы (Items)»

В данном примере показаны основные принципы работы сервиса «Предметы». На что следует обратить внимание по исходному коду:

  1. Сервисы могут использовать друг друга. Это относится как к сервисам общего назначения (в данном примере это API сервис и UserService), так и к другим обычным сервисам (здесь показан пример вызова функций сервиса SlotsService в функциях equip и unEquip — строки 49-50 и 64-64)

  2. При получении данных с сервера с помощью функции getList() все полученные данные проходят через модель (строка 23). Это обеспечивает унификацию моделей и приносит все преимущества, которые описаны в разделе 1.1 «Модель»

  3. После получения данных и их типизации через модель данные сохраняются в Vuex. Логику сохранения также обеспечивает сервис (строка 28). 

1.3. Хранилище (Store)

Хранилище в предложенной архитектуре обеспечивает возможность централизованного управления данными и легкий доступ к ним из любых частей приложения. Данные могут использоваться в компоненте или в сервисе. В качестве инструмента реализации используется Vuex.

Здесь используется типовая модель, которая описывает store, actions и mutations. Для удобства store разделено на модули для более простой логической структуры.

Пример реализации набора данных и функций для сущности «Пользователь» приведен в Листингах 3, 4, 5 и 6.

1 import mutations from './mutations' 2 import actions from './actions' 3 import modules from './modules' 4 export default { 5   namespaced: true, 6   state: () => { 7     return { 8       user: null, 9       ... 10     } 11   }, 12   mutations, 13   actions, 14   modules   15 }

 Листинг 3. Файл “index.js” с описанием store

1 export const USER_SET = 'USER_SET' 2 …

Листинг 4.  Файл “mutations-types.js” с описанием типов мутаций

1 import * as types from './mutations-types' 2 export default { 3  [types.USER_SET] (state, user) { 4    state.user = user 5  } 6 … 7 }

Листинг 5.  Файл “mutations.js” с описанием мутаций

1 import * as types from './mutations-types' 2 export default { 3  setUser ({ commit }, user) { 4    return new Promise(function (resolve) { 5      commit(types.USER_SET, user) 6      resolve() 7    }) 8  }, 9  … 10 }

 Листинг 6.  Файл “actions.js” с описанием действий

1.4. Использование сервисов в компонентах

Вся описанная выше архитектура приложения позволяет эффективно использовать предложенную парадигму разделения логики от визуализации для удобного отображения элементов интерфейса и работы с ними.  

Рассмотрим пример страницы «Экипировка пользователя», в которой используется сервис Items. Код страницы приведен в Листинге 7. Я пока намеренно не буду говорить о секциях <template> и <styles>, сосредоточившись на логике. О том, как мы реализовывали отображение, я расскажу во второй части данной статьи.

1 <template lang="pug"> 2     ... 3     g-card-list( 4         :items="items" 5         @equip="equip($event)" 6         @un-equip="unEquip($event)" 7         @sell="sell($event)" 8     ) 9 ... 10 </template> 11 <script> 12 import { mapState } from 'vuex' 13 import ItemsService from '~/services/items' 14 export default { 15   name: 'InventoryPage', 16   layout: 'default', 17   transition: 'slide-fade', 18   data () { 19     return { 20       currentItem: null, 21       sellItem: null, 22       ... 23     } 24   }, 25   computed: { 26     ...mapState({ 27       items: state => state.items 28     }) 29   }, 30   mounted () { 31     ItemsService.getList() 32   }, 33   methods: { 34    equip (item) { 35      this.$wd.show() 36      ItemsService.equip(item) 37         .then(() => { this.$wd.hide() }) 38         .catch((error) => { 39           this.showMessage('Не удалось надеть предмет.') 40           this.error(error) 41         }) 42     }, 43     unEquip (item) { 44       this.$wd.show() 45       ItemsService.unEquip(item) 46         .then(() => { this.$wd.hide() }) 47         .catch((error) => { 48           this.showMessage('Не удалось снять предмет.') 49           this.error(error) 50         }) 51     }, 52     sell () { 53       this.$wd.show() 54       ItemsService.sell(item) 55         .then(() => { 56           this.showConfirmDialog = false 57           this.$wd.hide() 58         }) 59         .catch((error) => { 60           this.showMessage('Не удалось продать предмет.') 61           this.error(error) 62         }) 63    } 64   }} 65 </script>

 Листинг 7.  Файл “actions.js” с описанием действий

Расставим акценты на основных моментах, которые реализованы в данном коде:

  1. В хуке mounted() осуществляется загрузка данных об экипировке героя с помощью функции getList() сервиса ItemsService (строка 31)

  2. После выполнения функции getList() сервис сохраняет данные в Vuex. Эти данные уже типизированы, так как сервис использует model перед сохранением в store

  3. В computed свойстве подключается state, который обеспечивает доступ к items на данной странице с использованием mapState (строка 26)

  4. Отображением содержимого страницы и обработкой событий занимается компонент g-card-list (строки 3-8), которому передается через props массив items. При возникновении различных событий (@equip, @un-equip, @sell) происходит вызов соответствующих методов, которые обеспечивают всю логику работы.

1.5. Обработка событий и локальное кэширование данных

После того как архитектура была собрана, и стали появляться первые данные, мы столкнулись с проблемой оптимизации. Дело в том, что в системе имеется достаточно большое количество разнообразных справочников (более 30). В них хранится информация о различных предметах, категориях, типах и так далее. Таким образом, при инициализации приложения приходилось все эти справочники загружать в систему.

Для того чтобы сэкономить часть времени на загрузку и снизить количество обращений к серверу, мы решили часть справочников сохранять в Localstorage и обращаться к нему, если никаких изменений не было.

Общая модель обработки событий из различных источников изображена на Рисунке 2.

Рисунок 2. Модель работы с данными

Рисунок 2. Модель работы с данными

Источниками событий в системе, которые требуют обновления данных на стороне приложения, являются:

  1. Плагин инициализации, который запускается автоматически при запуске приложения в браузере и обеспечивает загрузку всех справочных данных.

  2. Обновление страницы пользователем или переход на новую страницу приложения

  3. Событие на странице, которое инициирует пользователь, например продажа или покупка предмета

  4. Событие на стороне сервера, например, уведомление о новых заданиях

Предложенная архитектура позволяет просто добавлять необходимые обработчики событий в нужные точки приложения, обеспечивая тем самым удобный способ реализовать все четыре указанные потребности. Неважно, где произошло событие — в плагине инициализации, на странице или в сокете. В ответ на событие вызывается нужный сервис, который складывает данные в Vuex, и они сразу становятся доступными в любом компоненте.

Кэширование данных было реализовано достаточно просто. Для каждого справочника вычисляется hash, как однонаправленная функция. При внесении изменений на беке, информация пересчитывается hash и записывается в таблицу hashes. Фронтенд-приложение анализирует изменения, и если hash не изменился, то данные справочника загружаются из Localstorage. Если же hash изменился, то они запрашиваются с сервера, заново вычисляется hash и данные записываются  локально. При следующей загрузке процесс повторяется.

Исходный код сервиса, который это реализует, показан в Листинге 8.

1 import BaseService from '~/services/base' 2 import ApiService from '~/services/api' 3 import StorageService from '~/services/storage' 4 import HashModel from '~/models/hash' 5 6 export default class Hashes extends BaseService { 7   static getHashes (params = {}) { 8     return ApiService.getList('/hashes', params) 9      .then(hashesData => { 10         const hashes = [] 11        hashesData.forEach(hashData => { 12          hashes.push(new HashModel(hashData)) 13        }) 14         this.vuex.dispatch('setHashes', hashes) 15      }) 16   } 17 18  static getLocalHashByName (name) { 19     const hashes = StorageService.get('hashes') 20     const find = hashes  21      ? hashes.find(hash => hash.name === name)  22        : null 23     return find ? find.hash : null 24   } 25 26  static setLocalHashByName (name, hash) { 27     let hashes = [] 28     hashes = StorageService.get('hashes') || [] 29     const find = hashes  30      ? hashes.find(hash => hash.name === name)  31      : null 32     if (find) { 33       find.hash = hash 34     } else { 35       hashes.push({ name, hash }) 36     } 37     StorageService.set('hashes',hashes) 38   } 39 }

Листинг 8.  Исходный код сервиса проверки hash

Пример использования данного сервиса при  загрузке справочника Criteria приведен в Листинге 9. Он показывает, как при загрузке данных осуществляется определение неизменности hash и происходит загрузка либо с сервера (строки 17-28), либо из localStorage (строка 5). 

1 export default class Criteria extends BaseService { 2   static getList () { 3     const currentHash = this.vuex.state.hashes 4       .find(_hash => _hash.name === 'skills') 5     const localSkills = StorageService.get('skills') 6     const localHash =  7       HashesService.getLocalHashByName('skills') 8     if ( 9       currentHash &&  10       localHash &&  11       localSkills &&  12       currentHash.hash === localHash 13     ) { 14       return  15         this.vuex.dispatch('setCriteria', localSkills) 16     } else { 17       return ApiService.getList('skills') 18         .then(criteriaData => { 19           const criteria = [] 20           criteriaData.forEach(cd =>  21             criteria.push(new CriterionModel(cd))) 22           this.vuex.dispatch('setCriteria', criteria) 23           if (currentHash && currentHash.hash) { 24             HashesService.setLocalHashByName('skills', 25               currentHash.hash) 26           } 27           StorageService.set('skills', criteria) 28         }) 29     } 30   } 31 }

 Листинг 9.  Пример использования сервиса кэширования при загрузке справочника Criteria

После хеширования общее время работы плагина инициализации удалось сократить с 8 секунд до 3 секунд. Результаты приведены в Таблице 1.

 Таблица 1.  Результаты использования плагина кэширования

 Таблица 1.  Результаты использования плагина кэширования

1.6. Итоги реализации по архитектуре проекта

Всего в процессе работы над данным проектом было реализовано 47 моделей, 36 сервисов, 48 страниц, 68 компонентов (о них речь пойдет позже), 2 плагина и store, который содержит данные для всех моделей. Результаты данной реализации показаны на Рисунке 3.

Рисунок 3. Результаты реализации архитектуры приложения

Рисунок 3. Результаты реализации архитектуры приложения

2. Реализация набора визуальных элементов приложения

Первым этапом этой работы стала классификация всех элементов дизайна.  Мы проанализировали все дизайн-макеты с целью выделить типовые элементы интерфейса. Получилось примерно так:

  1. Аватар пользователя

  2. Текущий уровень пользователя

  3. Количество золота

  4. Навигационная панель

  5. Основные навыки персонажа

  6. Таб-панели

  7. Список заданий со скроллом

  8. Прогресс-бар

  9. Предмет

  10. Кнопка

  11. Всплывающая подсказка (ToolTip)

  12. … и так далее по всем экранам, если есть новый элемент, то добавляем его в список

По мере такой работы на каждом следующем экране новых элементов становилось все меньше и меньше. И то, что в начале казалось страшной и непосильной задачей, постепенно свелось к появлению набора типовых элементов и пониманию того, что мы находимся на верном пути.

Первый проход дал около 60 элементов, однако при дальнейшем анализе удалось сократить их число до 40 за счет вынесения различий в свойства компонентов (props) и наличия динамически задаваемых CSS-классов Vue JS.

Результатом этого этапа работ стала следующая классификация игровых элементов:

  • Тип 1: Простые элементы — их внешний вид и функциональность можно обеспечить только за счет изменения CSS свойств стандартного Vuetify компонента  (либо композиции стандартных Vuetify компонентов)

  • Тип 2: Сложные элементы — требуют глубокого переопределения свойств CSS стандартного Vuetify компонента за счет механизма наследования. Могут содержать анимацию

  • Тип 3: Составные элементы — состоят из набора простых и сложных элементов, описанных выше. Как правило, содержат часть логики поведения, заданной с помощью Vue.js + анимацию

Теперь рассмотрим  примеры конкретных реализаций для каждого типа компонентов.

2.1. Реализация простого компонента на примере элемента пагинации

Компонент пагинации используется для переключения номеров страниц в табличном представлении длинных списков. Его хорошая реализация есть в UI FrameWork Vuetify. Стандартный вид этого компонента показан на Рисунке 4.

Рисунок 4. Внешний вид типового элемента пагинации Vuetify

Рисунок 4. Внешний вид типового элемента пагинации Vuetify

Однако, согласно дизайн-макетам, его внешний вид должен быть таким, как показано на Рисунке 5.

Рисунок 5. Внешний вид элемента пагинации для игрового приложения

Рисунок 5. Внешний вид элемента пагинации для игрового приложения

Попробуем его преобразовать. Код, приведенный в Листинге 10, показывает, как можно поменять внешний вид элемента за счет переопределения CSS свойств и создания собственного компонента с набором нужных свойств и нужного внешнего вида. При этом сразу подготовим отдельный однофайловый компонент Vue, который будет не только обеспечивать внешний вид, но и логику интерактивного взаимодействия. 

1 <template lang="pug"> 2 .pagination.d-flex.flex-row 3   v-pagination.justify-start( 4     v-model="currentPage" 5     :length="length" 6     :total-visible="totalVisible" 7    @input="$emit('current-page-change', currentPage)" 8   ) 9 </template> 10 <script> 11 export default { 12   props: { 13     page: { 14       required: true, 15       type: Number 16     }, 17     totalVisible: { 18       required: true, 19       type: Number 20     }, 21     perPage: { 22       required: true, 23       type: Number 24     }, 25     length: { 26       required: true, 27       type: Number 28     } 29   }, 30   data () { 31     return { 32       currentPage: null 33     } 34   }, 35   watch: { 36     page () { 37       this.currentPage = this.page 38     } 39   }, 40   created () { 41     this.currentPage = this.page 42  } 43 } 44 </script> 45  46 <style lang="stylus" scoped> 47   48 @import '~assets/css/variables' 49 .v-pagination 50   51   .v-pagination__navigation, .v-pagination__item 52     background none 53     box-shadow none 54     outline none 55  56   .v-pagination__item, .v-pagination__more 57     font-family 'TT Norms Medium' 58     font-size 8px 59     color $gray-brown-color 60   61   .v-pagination__more 62     padding 0 0 12px 0 63     letter-spacing 2px 64   65   .v-pagination__navigation .v-icon:before 66     color #625E54 67   68   .v-pagination__navigation, .v-pagination__more 69     margin 0 2px 70   71   .v-pagination__item 72     width 38px 73     height 28px 74     display flex 75     align-items center 76     justify-content center 77     position relative 78     z-index 10 79     padding 0 10px 80     margin 0 5px 81   82     &:before 83       content '' 84       width 100% 85       height 100% 86       position absolute 87       left 0 88       transform skew(155deg) 89       z-index -10 90       border 1px solid #625E54 91       transition 0.1s 92  93      &.v-pagination__item--active, &:hover 94        color #101113 95        font-weight bold 96  97        &:before 98           background #C57200 99 </style>

Листинг 10.  Компонент g-pagination

Мы получили простой однофайловый Vue-компонент, который содержит три стандартные секции: template, script и style. Для простых элементов первого типа основной интерес представляет секция style, в которой определяются цвета и внешний вид элемента пагинации (строки 71-98). Свойства props (строки 12-29) данного компонента просто дублируют необходимые свойства стандартного компонента v-pagination:

  • page — текущая выбранная страница

  • length — общее количество элементов списка

  • total-visible — количество отображаемых элементов пагинатора

  • per-page — количество элементов, которое показывается на одной странице

Также компонент g-pagination может эмитировать событие current-page-change (строка 6), которое используется для обработки действия по переключению страниц, если, например, используется серверный вариант пагинации.

В листинге 11  приведен пример использования компонента на любой странице приложения.

1 g-pagination( 2 @current-page-change="switchPage($event)" 3   :page="currentPage" 4   :length="25":total-visible="4" 5   :per-page="10" 6 )

 Листинг 11. Пример использования компонента g-pagination

 2.2. Пример реализации сложного компонента на примере кнопки

Компонент для отображения кнопки — самый повторяемый элемент интерфейса. Согласно дизайн-макетам, он может быть представлен в нескольких вариантах. Эти варианты показаны на Рисунке 6. 

Рисунок 6. Различные виды кнопок для игрового интерфейса

Рисунок 6. Различные виды кнопок для игрового интерфейса

При этом нам желательно сохранить основную функциональность стандартного элемента v-btn, чтобы не пришлось заново переопределять все стандартные события и поведение. Для этого нужно будет использовать механизм наследования, реализованный во VueJS компонентах с помощью конструкции extends и использовать механизм глубоких селекторов.  Исходный код реализации готового компонента показан в листинге 12.

1 <template lang="pug"> 2 .button-container 3   v-btn.button( 4     :disabled="disabled" 5     :class="classes" 6     :width="width" 7     ref="button" 8   ) 9     slot 10 </template> 11 12 <script> 13 import Vue from 'vue' 14 const VBtn = Vue.options.components["VBtn"] 15  16 export default { 17   extends: VBtn, 18  props: { 19     accent: { 20       default: false, 21       type: Boolean 22     }, 23     orange: { 24       default: false, 25       type: Boolean 26     }, 27     long: { 28       default: false, 29       type: Boolean 30     }, 31     yellow: { 32       default: false, 33       type: Boolean 34     }, 35     gold: { 36       default: false, 37       type: Boolean 38     }, 39     width: { 40       default: undefined, 41       type: String 42     } 43   }, 44   computed: { 45    classes () { 46       const classes = { long: this.long } 47       if (!this.yellow && !this.orange &&  48         !this.gold && !this.accent && !this.disabled) { 49         classes['g-btn--gray'] = true 50       } else if (this.orange) { 51        classes['g-btn--orange'] = true 52       } else if (this.yellow) { 53        classes['g-btn--yellow'] = true 54       } else if (this.gold) { 55         classes['g-btn--gold'] = true 56       } else if (this.accent) { 57         classes['g-btn--accent'] = true 58       } else if (this.disabled) { 59         classes['g-btn--disabled'] = true 60       } 61       return classes 62     } 63   } 64 } 65 </script> 66 67 <style lang="stylus" scoped> 68 @import '~assets/css/variables' 69 70 .button-container 71   button.button.v-btn:not(.v-btn--flat):not(.v-btn--text) 72   :not(.v-btn--outlined) 73     background-color transparent !important 74     padding 0 24px 75     box-shadow none 76     margin 0 10px 77 78     &.long 79       padding 0 36px 80 81     &:before, &:after 82       content '' 83       position absolute 84       transform skew(150deg) 85       border-radius initial 86       background-color: transparent; 87       opacity 1 88 89     &:before 90       width 100% 91       height 100% 92  93     &:after 94       width calc(100% - 8px) 95       height calc(100% - 8px) 96       box-shadow none 97  98     ::v-deep .v-btn__content 99      position relative 100       z-index 10 101  102     // GRAY 103     &.g-btn--gray 104       ::v-deep .v-btn__content 105         color $gray-color 106         font-size 8px 107         font-weight 800 108  109       &:before 110         border 2px solid #45433E 111  112       &:hover 113         ::v-deep .v-btn__content 114           color #E0DACA 115  116         &:before 117           border 2px solid #A39D8C 118  119     // ACCENT 120     … 121     // GOLD 122     … 123     // ORANGE 124     … 125     // YELLOW 126     … 127     // DiSABLED 128     … 129   </style>

 Листинг 12. Реализация компонента g-btn

Обратите внимание на строки 14, 15 и 17. Здесь осуществляется импортирование стандартных свойств компонента v-btn. Computed свойство classes (строки 44-60) осуществляет применение CSS класса в зависимости от свойств компонента. Сами CSS свойства для компонента определены в секции style. Здесь приведен пример определения стиля для активной серой кнопки GRAY (строки 102 -117) с помощью свойства “gray”. Остальные свойства для ACCENT, GOLD, ORANGE, YELLOW и DISABLED определяются аналогичным способом. В Листинге 13 приведены примеры использования компонента g-btn на странице приложения.

1 ... 2 g-btn( 3   gold 4 @click.native="$emit('close')" 5 ) Закрыть 6 g-btn( 7   gray 8 @click.native="$emit('cancel')" 9 ) Отмена 10 ...

Листинг 13. Пример использования  компонента g-btn

2.3. Реализация составного компонента на примере g-card-list

Следующим этапом работы стал этап формирования списка композиционных компонентов. Это компоненты, которые состоят из комбинации простых, сложных типов элементов, а также, возможно, других стандартных компонентов Vuetify. Рассмотрим такой элемент на примере реализации компонента g-card-list-item (Рисунок 7).

Рисунок 7. Визуальное представление компонента g-card-list-item («Карточка продукта»)

Рисунок 7. Визуальное представление компонента g-card-list-item («Карточка продукта»)

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

  • Изображения предмета — это рамка + картинка самого предмета

  • Стоимости предмета — стоимость предмета + символ монетки

  • Названия предмета — «Редкий»

  • Уровня предмета — это ромбики в нижней части, число и цвет которых зависят от уровня и типа компонента

Исходный код этого компонента приведен в Листинге 14. Он также оформлен в виде готового компонента Vue для удобства его использования.

1 <template lang="pug"> 2 .d-flex 3   .card-list 4     .item__title {{ item.title }} 5     g-item( 6       :item="item" 7       :active="active" 8       :progress="false" 9     ).item__wrapper 10     g-resource( 11       :value="item.price" 12       icon="gold" 13       color="gold" 14       small 15       reverse 16     ) 17     g-level( 18       :level="item.level" 19     ) 20 </template> 21  22 <script> 23 import BaseService from '~/services/base' 24 const LIST_TYPE_GAME = BaseService.LIST_TYPE_GAME 25  26 export default { 27   props: { 28     item: { 29       required: true, 30       type: Object 31     }, 32     active: { 33       default: false, 34       type: Boolean 35     }, 36     progress: { 37       default: false, 38       type: Boolean 39     }, 40     type: { 41       default: LIST_TYPE_GAME, 42       type: String 43     } 44   }, 45   data () { 46     return { 47       LIST_TYPE_GAME 48     } 49   } 50 } 51 </script> 52  53 <style lang="stylus" scoped> 54 @import '~assets/css/variables' 55  56 .card-list 57   width 120px 58   height 80px 59   position relative 60   background url('~assets/svg/rb.svg') 61  62   .item__wrapper 63     width 40px 64     height 40px 65  66   .item__title 67     font-family 'TT Norms Medium' 68     text-transform uppercase 69     font-size 8px 70     text-align center 71     color $gold-1-color 72 </style>

Листинг 14. Реализация компонента g-card-list-item

Как видно из листинга, данный компонент состоит из набора других компонентов:

  • g-item — реализует центральную часть данной визуализации

  • g-resource — содержит описание и стоимость компонента

  • g-level — выводит информацию об уровне компонента

Такая декомпозиция компонентов на отдельные составляющие в совокупности с универсальностью последних позволяет значительно сократить количество строк кода в приложении и обеспечить возможность повторного использования кода. 

Как было указано выше, несмотря на все разнообразие игровых элементов и большого количества экранов, общее количество реализованных компонентов ограничилось числом 68. Весь интерфейс был построен на использовании этих элементов. 

2.4. Реализация анимации

Все анимации в приложении построены на трех стандартных принципах:

  • Свойствах CSS transitions и animation

  • Использовании компонента-обертки transition VueJS

  • Механизме keyframes

На первых двух принципах особо останавливаться не будем, поскольку это достаточно простые и известные каждому верстальщику вещи.

Пример использования transitions можно увидеть в Листинге 10, строка 91. Более подробную информацию о применении этих свойств можно получить из официальной документации по свойствам CSS, либо найти примеры на различных сайтах.

Пример использования компонента обертки  transition VueJS также можно увидеть в Листинге 7, строка 17. Типовые варианты использования такой анимации могут обеспечивать красивые эффекты при переключении страниц или изменении отдельных компонентов. Информацию по их использованию можно также получить из документации.

Рассмотрим пример реализации анимации с использованием механизма keyframes.  Данный вид анимации был применен для экрана реализации поединка между двумя соперниками.  Исходный код компонента, реализующего этот способ, приведен в Листинге 15. 

1 <template lang="pug"> 2   div.avatar 3     img.avatar-img( 4       src="~assets/images/avatar.png" 5       :class="{ left, right }" 6     ) 7     .damage( 8       v-if="damage" 9       :class="{ left, right }" 10     ) 11       .text {{ damage.text }} 12       .value {{ damage.value | add-sign }} 13  14     .recovery( 15       v-if="recovery" 16       :class="{ left, right }" 17     ) 18       .text {{ recovery.text }} 19       .value {{ recovery.value | add-sign }} 20  21 </template> 22  23 <script> 24 export default { 25   props: { 26     avatar: { 27      type: String, 28       default: () => 'male' 29     }, 30    side: { 31       type: String, 32       default: () => 'left' 33     }, 34     red: { 35       type: Boolean, 36       defalult: () => false 37     }, 38     damage: { 39       type: Object, 40       default: () => (null) 41     }, 42     recovery: { 43       type: Object, 44       default: () => (null) 45     } 46   }, 47   computed: { 48     left () { 49       return this.side === 'left' 50     }, 51     right () { 52      return this.side === 'right' 53    } 54  55   } 56 } 57 </script> 58 <style lang="stylus" scoped> 59 @import '~assets/css/variables' 60 61 .avatar 62   width 450px 63   height 550px 64   padding 92px 0 65  66   &-img 67     height 428px 68  69     &.animated 70       animation hit 0.3s linear 71  72     &.left 73       float right 74  75     &.right 76       float left 77 78   .damage 79     position absolute 80     margin-top 150px 81     width 450px 82  83     &.left 84       text-align left 85  86    &.right 87      text-align right 88 89    .text 90       font-size 16px 91       color $red-color 92  93     .value 94       font-size 52px 95       line-height 56px 96       letter-spacing -0.1px 97       color $red-color 98  99   .recovery 100     position absolute 101     margin-top 150px 102     width 450px 103 104     &.left 105       text-align left 106 107     &.right 108       text-align right 109  110   .text 111       font-size 16px 112       color #72875C 113 114     .value 115       font-size 52px 116       line-height 56px 117       letter-spacing -0.1px 118       color #72875C 119 120 @keyframes hit { 121   0% { transform: scale(1) } 122   50% { transform: scale(0.95) } 123   100% { transform: scale(1) } 124 } 124 </style>

 Листинг 15. Реализация анимации с помощью keyframes

Приведенный выше код показывает пример использования keyframes с именем hit, который применяется в качестве анимации. Определение самой анимации можно увидеть в строках 120-124, а применение данной анимации в строке 70.

Итоги

Разработка данного приложения длилась четыре месяца. В команду входили project-менеджер, аналитик, дизайнер, архитектор, два backend-разработчика, два frontend-разработчика и QA-специалист. 

Что было сделано с технической точки зрения:

  1. Реализовали игровое приложение с помощью Nuxt, которое содержит около 40 типовых компонентов и около 20 анимаций. Работа по классификации и систематизации графических элементов позволила повысить эффективность работы за счет композиции и повторного использования кода.

  2. Спроектировали архитектуру SPA приложения, позволяющую гибко реагировать на изменение данных и сохранять состояние игрового процесса без лишних запросов к backend-части. Разработанная архитектура получилась достаточно универсальной, что позволило ее использовать в других проектах.

  3. Разработали удобный интерфейс, который создает атмосферу игры за счет анимации игровых элементов и переходов между  экранами.

  4. Реализовали конструктор для панели администрирования, который позволяет создавать различные представления данных, а также управлять процессом добавления, редактирования и удаления с помощью конфигурационных файлов. Данный конструктор также показал свою эффективность и применяется в других приложениях.

  5. Написали более 500 тест-кейсов, которые покрывают как пользовательскую часть приложения, так и административную панель.

Спасибо за внимание!

Авторские материалы для разработчиков и архитекторов мы также публикуем в наших соцсетях – ВКонтакте и Telegram.


ссылка на оригинал статьи https://habr.com/ru/articles/726512/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *