Многие компании сегодня всячески пытаются мотивировать и удерживать своих сотрудников. Поэтому все чаще мы слышим о геймификации как о процессе, который позволяет значительно улучшить показатели вовлеченности, повысить продажи, заинтересовать и превратить ежедневную рутину в увлекательный игровой процесс. Мы в 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.

import BaseService from './base'
export default class User extends BaseService {
  constructor (data = {}) {
    super(data)
    this.id = data.id
    this.firstName = data.first_name
    this.secondName = data.second_name
    this.avatarUrl = data.avatar_url
    this.level = data.level
    this.resources = data.resources || []
    this.experience = data.experience
  }

  getFullName () {
    return `${this.firstName} ${this.secondName}`
  }
}

Листинг 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:

// Подключение моделей и сервисов
import BaseService from '~/services/base'
// Сервис для работы с API
import ApiService from '~/services/api'
// Сервис для работы со справочником слотов
import SlotsService from '~/services/directories/slots'
// Сервис работы с пользователем
import UserService from '~/services/user'
// Модели для используемых сущностей
import ItemModel from '~/models/items/item'
export default class Items extends BaseService {
// Получение данных с сервера и их сохранение в Vuex
  static getList (params = {}) {
    return ApiService.getList('items', params)
      .then(itemsData => {
        const Items = {
          items: [],
          total: 0,
          page: 0
        }
        itemsData.data.forEach(itemData => {
          // Каждый элемент "прогоняется" через модель
          Items.items.push(new ItemModel(itemData))
        })
        Items.total = itemsData.meta.total
        Items.page = itemsData.meta.current_page
        // Сохранение полученных данных в Vuex
        this.vuex.dispatch('setItems', Items)
      })
  }
  // Добавление нового элемента данных  
  static addItem (item) {
    return ApiService.addItem('items', item)
  }
  // Удаление элемента данных
  static deleteItem (id) {
    return ApiService.deleteItem('items', id)
  }
  // Изменение информации о сущности
  static updateItem (id, item) {
    return ApiService.updateItem('items', id, item)
  }
  // Функция экипировки персонажа
  static equip (item, equipmentSlot = null) {
    return this.api.equip(item, equipmentSlot)
      .then(() => {
        UserService.getInventory()
          .then(() => {
            SlotsService.updateAmuletSlots()
            SlotsService.updateElixirSlots()
            UserService.get()
          })
      })
      .catch(error => {
        this.error(error)
      })
  }
  // Функция снятия экипировки с персонажа
  static unEquip (item) {
    return this.api.unEquip(item)
      .then(() => {
        UserService.getInventory()
          .then(() => {
            SlotsService.updateAmuletSlots()
            SlotsService.updateElixirSlots()
            UserService.get()
          })
      })
      .catch(error => {
        this.error(error)
      })
  }

 Листинг 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.

import mutations from './mutations'
import actions from './actions'
import modules from './modules'
export default {
  namespaced: true,
  state: () => {
    return {
      user: null,
      ...
    }
  },
  mutations,
  actions,
  modules  
}

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

export const USER_SET = 'USER_SET'
…

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

import * as types from './mutations-types'
export default {
  [types.USER_SET] (state, user) {
    state.user = user
  }
…
}

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

import * as types from './mutations-types'
export default {
  setUser ({ commit }, user) {
    return new Promise(function (resolve) {
      commit(types.USER_SET, user)
      resolve()
    })
  },
  …
}

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

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

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

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

<template lang="pug">
    ...
    g-card-list(
        :items="items"
        @equip="equip($event)"
        @un-equip="unEquip($event)"
        @sell="sell($event)"
    )
...
</template>
<script>
import { mapState } from 'vuex'
import ItemsService from '~/services/items'
export default {
  name: 'InventoryPage',
  layout: 'default',
  transition: 'slide-fade',
  data () {
    return {
      currentItem: null,
      sellItem: null,
      ...
    }
  },
  computed: {
    ...mapState({
      items: state => state.items
    })
  },
  mounted () {
    ItemsService.getList()
  },
  methods: {
    equip (item) {
      this.$wd.show()
      ItemsService.equip(item)
        .then(() => { this.$wd.hide() })
        .catch((error) => {
          this.showMessage('Не удалось надеть предмет.')
          this.error(error)
        })
    },
    unEquip (item) {
      this.$wd.show()
      ItemsService.unEquip(item)
        .then(() => { this.$wd.hide() })
        .catch((error) => {
          this.showMessage('Не удалось снять предмет.')
          this.error(error)
        })
    },
    sell () {
      this.$wd.show()
      ItemsService.sell(item)
        .then(() => {
          this.showConfirmDialog = false
          this.$wd.hide()
        })
        .catch((error) => {
          this.showMessage('Не удалось продать предмет.')
          this.error(error)
        })
    }
  }}
</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.

import BaseService from '~/services/base'
import ApiService from '~/services/api'
import StorageService from '~/services/storage'
import HashModel from '~/models/hash'

export default class Hashes extends BaseService {
  static getHashes (params = {}) {
    return ApiService.getList('/hashes', params)
      .then(hashesData => {
        const hashes = []
        hashesData.forEach(hashData => {
          hashes.push(new HashModel(hashData))
        })
        this.vuex.dispatch('setHashes', hashes)
      })
  }

  static getLocalHashByName (name) {
    const hashes = StorageService.get('hashes')
    const find = hashes 
     ? hashes.find(hash => hash.name === name) 
       : null
    return find ? find.hash : null
  }

  static setLocalHashByName (name, hash) {
    let hashes = []
    hashes = StorageService.get('hashes') || []
    const find = hashes 
     ? hashes.find(hash => hash.name === name) 
     : null
    if (find) {
      find.hash = hash
    } else {
      hashes.push({ name, hash })
    }
    StorageService.set('hashes',hashes)
  }
}

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

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

export default class Criteria extends BaseService {
  static getList () {
    const currentHash = this.vuex.state.hashes
      .find(_hash => _hash.name === 'skills')
    const localSkills = StorageService.get('skills')
    const localHash = 
      HashesService.getLocalHashByName('skills')
    if (
      currentHash && 
      localHash && 
      localSkills && 
      currentHash.hash === localHash
    ) {
      return 
        this.vuex.dispatch('setCriteria', localSkills)
    } else {
      return ApiService.getList('skills')
        .then(criteriaData => {
          const criteria = []
          criteriaData.forEach(cd => 
            criteria.push(new CriterionModel(cd)))
          this.vuex.dispatch('setCriteria', criteria)
          if (currentHash && currentHash.hash) {
            HashesService.setLocalHashByName('skills',
              currentHash.hash)
          }
          StorageService.set('skills', criteria)
        })
    }
  }
}

 Листинг 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, который будет не только обеспечивать внешний вид, но и логику интерактивного взаимодействия. 

<template lang="pug">
.pagination.d-flex.flex-row
  v-pagination.justify-start(
    v-model="currentPage"
    :length="length"
    :total-visible="totalVisible"
    @input="$emit('current-page-change', currentPage)"
  )
</template>
<script>
export default {
  props: {
    page: {
      required: true,
      type: Number
    },
    totalVisible: {
      required: true,
      type: Number
    },
    perPage: {
      required: true,
      type: Number
    },
    length: {
      required: true,
      type: Number
    }
  },
  data () {
    return {
      currentPage: null
    }
  },
  watch: {
    page () {
      this.currentPage = this.page
    }
  },
  created () {
    this.currentPage = this.page
  }
}
</script>

<style lang="stylus" scoped>
 
@import '~assets/css/variables'
.v-pagination
 
  .v-pagination__navigation, .v-pagination__item
    background none
    box-shadow none
    outline none
 
  .v-pagination__item, .v-pagination__more
    font-family 'TT Norms Medium'
    font-size 8px
    color $gray-brown-color
 
  .v-pagination__more
    padding 0 0 12px 0
    letter-spacing 2px
 
  .v-pagination__navigation .v-icon:before
    color #625E54
 
  .v-pagination__navigation, .v-pagination__more
    margin 0 2px
 
  .v-pagination__item
    width 38px
    height 28px
    display flex
    align-items center
    justify-content center
    position relative
    z-index 10
    padding 0 10px
    margin 0 5px
 
    &:before
      content ''
      width 100%
      height 100%
      position absolute
      left 0
      transform skew(155deg)
      z-index -10
      border 1px solid #625E54
      transition 0.1s
 
      &.v-pagination__item--active, &:hover
        color #101113
        font-weight bold
 
        &:before
          background #C57200
</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  приведен пример использования компонента на любой странице приложения.

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

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

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

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

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

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

<template lang="pug">
.button-container
  v-btn.button(
    :disabled="disabled"
    :class="classes"
    :width="width"
    ref="button"
  )
    slot
</template>

<script>
import Vue from 'vue'
const VBtn = Vue.options.components["VBtn"]

export default {
  extends: VBtn,
  props: {
    accent: {
      default: false,
      type: Boolean
    },
    orange: {
      default: false,
      type: Boolean
    },
    long: {
      default: false,
      type: Boolean
    },
    yellow: {
      default: false,
      type: Boolean
    },
    gold: {
      default: false,
      type: Boolean
    },
    width: {
      default: undefined,
      type: String
    }
  },
  computed: {
    classes () {
      const classes = { long: this.long }
      if (!this.yellow && !this.orange && 
        !this.gold && !this.accent && !this.disabled) {
        classes['g-btn--gray'] = true
      } else if (this.orange) {
        classes['g-btn--orange'] = true
      } else if (this.yellow) {
        classes['g-btn--yellow'] = true
      } else if (this.gold) {
        classes['g-btn--gold'] = true
      } else if (this.accent) {
        classes['g-btn--accent'] = true
      } else if (this.disabled) {
        classes['g-btn--disabled'] = true
      }
      return classes
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~assets/css/variables'

.button-container
  button.button.v-btn:not(.v-btn--flat):not(.v-btn--text)
  :not(.v-btn--outlined)
    background-color transparent !important
    padding 0 24px
    box-shadow none
    margin 0 10px

    &.long
      padding 0 36px

    &:before, &:after
      content ''
      position absolute
      transform skew(150deg)
      border-radius initial
      background-color: transparent;
      opacity 1

    &:before
      width 100%
      height 100%

    &:after
      width calc(100% - 8px)
      height calc(100% - 8px)
      box-shadow none

    ::v-deep .v-btn__content
      position relative
      z-index 10

    // GRAY
    &.g-btn--gray
      ::v-deep .v-btn__content
        color $gray-color
        font-size 8px
        font-weight 800

      &:before
        border 2px solid #45433E

      &:hover
        ::v-deep .v-btn__content
          color #E0DACA

        &:before
          border 2px solid #A39D8C

    // ACCENT
    …
    // GOLD
    …
    // ORANGE
    …
    // YELLOW
    …
    // DiSABLED
    …
  </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 на странице приложения.

...
g-btn(
  gold
@click.native="$emit('close')"
) Закрыть
g-btn(
  gray
@click.native="$emit('cancel')"
) Отмена
...

Листинг 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 для удобства его использования.

<template lang="pug">
.d-flex
  .card-list
    .item__title {{ item.title }}
    g-item(
      :item="item"
      :active="active"
      :progress="false"
    ).item__wrapper
    g-resource(
      :value="item.price"
      icon="gold"
      color="gold"
      small
      reverse
    )
    g-level(
      :level="item.level"
    )
</template>

<script>
import BaseService from '~/services/base'
const LIST_TYPE_GAME = BaseService.LIST_TYPE_GAME

export default {
  props: {
    item: {
      required: true,
      type: Object
    },
    active: {
      default: false,
      type: Boolean
    },
    progress: {
      default: false,
      type: Boolean
    },
    type: {
      default: LIST_TYPE_GAME,
      type: String
    }
  },
  data () {
    return {
      LIST_TYPE_GAME
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~assets/css/variables'

.card-list
  width 120px
  height 80px
  position relative
  background url('~assets/svg/rb.svg')

  .item__wrapper
    width 40px
    height 40px

  .item__title
    font-family 'TT Norms Medium'
    text-transform uppercase
    font-size 8px
    text-align center
    color $gold-1-color
</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. 

<template lang="pug">
  div.avatar
    img.avatar-img(
      src="~assets/images/avatar.png"
      :class="{ left, right }"
    )
    .damage(
      v-if="damage"
      :class="{ left, right }"
    )
      .text {{ damage.text }}
      .value {{ damage.value | add-sign }}

    .recovery(
      v-if="recovery"
      :class="{ left, right }"
    )
      .text {{ recovery.text }}
      .value {{ recovery.value | add-sign }}

</template>

<script>
export default {
  props: {
    avatar: {
      type: String,
      default: () => 'male'
    },
    side: {
      type: String,
      default: () => 'left'
    },
    red: {
      type: Boolean,
      defalult: () => false
    },
    damage: {
      type: Object,
      default: () => (null)
    },
    recovery: {
      type: Object,
      default: () => (null)
    }
  },
  computed: {
    left () {
      return this.side === 'left'
    },
    right () {
      return this.side === 'right'
    }

  }
}
</script>
<style lang="stylus" scoped>
@import '~assets/css/variables'

.avatar
  width 450px
  height 550px
  padding 92px 0

  &-img
    height 428px

    &.animated
      animation hit 0.3s linear

    &.left
      float right

    &.right
      float left

  .damage
    position absolute
    margin-top 150px
    width 450px

    &.left
      text-align left

    &.right
      text-align right

    .text
      font-size 16px
      color $red-color

    .value
      font-size 52px
      line-height 56px
      letter-spacing -0.1px
      color $red-color

  .recovery
    position absolute
    margin-top 150px
    width 450px

    &.left
      text-align left

    &.right
      text-align right

    .text
      font-size 16px
      color #72875C

    .value
      font-size 52px
      line-height 56px
      letter-spacing -0.1px
      color #72875C

@keyframes hit {
  0% { transform: scale(1) }
  50% { transform: scale(0.95) }
  100% { transform: scale(1) }
}
</style>

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

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

Итоги

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

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

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

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

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

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

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

Комментарии (8)


  1. gmtd
    03.04.2023 07:29

    Stylus, pug, Vue 2, Vuetify 2, Nuxt 2, Vuex...

    Вы удачно выбрали технологии, большая часть которых сейчас или умерла или деприкейтед или не рекомендуется

    В каком году писали?


    1. little-brother
      03.04.2023 07:29

      Должно быть 1,5 года назад.


      1. gmtd
        03.04.2023 07:29

        1.5 года назад уже точно был и Vue 3, и Pinia, и Stylus не рекомендовали использовать, выявились проблемы с апгрейдом у Vuetify 2 и Nuxt 2 (в отличие от конкурентов Vuetify)


    1. ArchitectSimbirSoft Автор
      03.04.2023 07:29

      Это приложение разрабатывали 2 года назад. На тот момент сочетание указанных технологий было вполне обосновано. К тому же существовали внешние ограничения по выбору инструментов, о которых говориться в разделе 3 данной статьи


  1. anonymous
    03.04.2023 07:29

    НЛО прилетело и опубликовало эту надпись здесь


    1. ArchitectSimbirSoft Автор
      03.04.2023 07:29

      Препроцессоры позволяют более эффективно использовать возможности HTML/CSS и писать меньше кода. Прямой связи с анимационными фреймворками в данном случае нет. Относительно использования WebGL или аналогичных платформ - ограничения не позволяли нам использовать такого рода инструменты. Это было известно до начала разработки и поэтому воспринималось как архитектурное ограничение.


      1. bromzh
        03.04.2023 07:29

        писать меньше кода

        emmet/сниппеты тоже позволяют писать меньше кода, но зато без вендор-лока и с известным большинству синтаксисом


  1. little-brother
    03.04.2023 07:29

    Что в итоге то получилось - где ссылка? :)

    По ходу статьи указывается на номера строк кода - их предполагается высчитывать вручную?