Создаем Hamster Kombat почти с нуля. Практика по Vue 3 и Telegram Mini Apps

от автора

Привет, Хабр! В этой статье-инструкции вы узнаете, как с нуля сделать свою собственную Telegram-тапалку на современном стеке. Важный дисклеймер: тапалка, кликер и прочее — это всего лишь форма. Цель статьи — дать всеобъемлющий практикум по современному стеку и деплою проектов в облако.

Внутри статьи — полноценный Serverless-подход, разработка бота на Node и полный цикл создания FE-приложения. А еще комментарии по архитектурным и тактическим решениям, чтобы вы прокачали уровень программирования и насмотренности. Подробности под катом!

Зачем изучать


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

Для более опытных (Junior+). В целом, тот же смысл. А также вы сможете изучить Vue, Telegram SDK, Supabase и Ubuntu setup.

Какое приложение получится по итогу статьи.

На выходе у вас получится на 98% production ready приложение и шаблон, который вы можете адаптировать под свою игру, бизнес-приложение или сервис.

Полный технологический стек, который мы будем использовать: Vue 3, Supabase, Firebase Deploy, Pinia, Docker, виртуальные серверы Selectel, Node.js, Telegram Mini Apps, Lodash, Telegraf.

Функционал, который мы создадим: Single Page Application (SPA) для Telegram на Vue 3, реферальная система, выполнение заданий.

С введением закончили — давайте приступать к действию!

Качаем исходный код со стилями


Для начала подготовим шаблон интерфейса тапалки: загрузим стили, создадим главную страницу и настроим отображение текущего счета. Полный код проекта из предыдущего видео расположен в репозитории на GitHub.

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

​​​​npm install ​​​​ ​​​​npm run dev​​​​ 

Делаем главную страницу и счет


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

Вычисление уровня: store/score.js

// базовый счет первого уровня const baseLevelScore = 25  // формула по вычислению уровней и сколько нужно очков для каждого const levels = new Array(15)   .fill(0)   .map((_, i) => baseLevelScore * Math.pow(2, i))  // вычисляем сумму, сколько нужно суммарно очков на каждый уровень const levelScores = levels.map((_, level) => {   let sum = 0   for (let [index, value] of levels.entries()) {     if (index >= level) {       return sum + value     }     sum += value   }   return sum })  // вычисляем уровень в зависимости от текущего счета function computeLevelByScore(score) {   for (let [index, value] of levelScores.entries()) {     if (score <= value) {       return {         level: index,         value: levels[index],       }     }   } } 

Организация store: store/score.js

export const useScoreStore = defineStore('score', {   state: () => ({     score: 0, // базовый уровень, после будем получать по API   }),   getters: {     level(state) {       return computeLevelByScore(state.score)     },     // этот счет нужен для отображения текущего прогресса     currentScore(state) {        if (this.level.level === 0) {         return state.score       }       return state.score - levelScores[this.level.level - 1]     },   },   actions: {     add(score = 1) {       this.score += score     },     setScore(score) {       this.score = score     },   }, }) 

Главная страница: HomeView.vue

<template>   <div class="game-container">     <ScoreProgress />     <div class="header">       <img src="../assets/coin.png" alt="coin" />       <h2 class="score" id="score">{{ store.score }}</h2>     </div>     <div class="circle">       <img @click="increment" ref="img" id="circle" :src="imgSrc" />     </div>   </div> </template>  <script setup> import { ref, computed } from 'vue' import frog from '../assets/frog.png' import lizzard from '../assets/lizzard.png' import { useScoreStore } from '@/stores/score' import ScoreProgress from '@/components/ScoreProgress.vue'  // получаем текущий счет const store = useScoreStore()  // если счет больше 25 меняем картинку. Первый уровень const imgSrc = computed(() => (store.score > 25 ? lizzard : frog)) const img = ref(null)  function increment(event) {   // при клике увеличиваем счет   store.add(1)    // дальше логика анимации из прошлого ролика   // https://youtu.be/vT-XwvcK2NI   const rect = event.target.getBoundingClientRect()    const offfsetX = event.clientX - rect.left - rect.width / 2   const offfsetY = event.clientY - rect.top - rect.height / 2    const DEG = 40    const tiltX = (offfsetY / rect.height) * DEG   const tiltY = (offfsetX / rect.width) * -DEG    img.value.style.setProperty('--tiltX', `${tiltX}deg`)   img.value.style.setProperty('--tiltY', `${tiltY}deg`)    setTimeout(() => {     img.value.style.setProperty('--tiltX', `0deg`)     img.value.style.setProperty('--tiltY', `0deg`)   }, 300)    const plusOne = document.createElement('div')   plusOne.classList.add('plus-one')   plusOne.textContent = '+1'   plusOne.style.left = `${event.clientX - rect.left}px`   plusOne.style.top = `${event.clientY - rect.top}px`    img.value.parentElement.appendChild(plusOne)    setTimeout(() => plusOne.remove(), 2000) } </script> 

И добавляем визуальное отображение прогресса в ScoreProgress.vue.

components/ScoreProgress.vue

<template>   <div class="progress">     <h4 class="progress-level">       <span>{{ store.currentScore }}/{{ store.level.value }}</span>       <span>{{ store.level.level + 1 }}</span>     </h4>     <div class="progress-container">       <div class="progress-value" :style="{ width: progress + '%' }"></div>     </div>   </div> </template>  <script setup> import { useScoreStore } from '@/stores/score' import { computed } from 'vue'    const store = useScoreStore()  const progress = computed(() => (100 * store.currentScore) / store.level.value) </script> 

Страница задач и друзей-рефералов

Деплоим фронтенд

Прежде зальем приложения на Firebase — в данном случае платформа выступит в роли бесплатного и удобного хостинга для фронтенда.

firebase init firebase deploy 

По итогу получаем url приложения.

Создаем бота на Node.js

Бот нам понадобится для реализации реферальной программы.

1. Получаем API-токен в боте @BotFather через команду ​​​​/newbot​​​​.

2. Далее устанавливаем telegraf и описываем базовую настройку бота в файле bot/app.js:

/bot/app.js import { Telegraf, Markup } from 'telegraf'  const token = 'YOUR TELEGRAM TOKEN' const webAppUrl = 'APP REMOTE URL'  const bot = new Telegraf(token)  bot.command('start', (ctx) => {   ctx.reply(     'Привет! Нажми, чтоб запустить',     Markup.inlineKeyboard([       Markup.button.webApp(         'Открыть мини-приложение',         `${webAppUrl}?ref=${ctx.payload}` // Здесь в параметре ref передаем реферала в мини-приложение       ),     ])   ) })  bot.launch() 

Для запуска бота используем команду:

node app 

Деплой бота на сервер

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

Будем деплоить бота в Docker — для этого добавим два файла:

Dockerfile

FROM node:16-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . ENV PORT=3000 EXPOSE $PORT CMD ["node", "app.js"] 

Makefile

build:         docker build -t tgbot . run:         docker run -d -p 3000:3000 --name tgbot --rm tgbot 

Далее зальем весь проект в GitHub-репозиторий, чтобы можно было легко перенести проект на сервер — без использования FTP и scp. Перейдем к следующему этапу.

1. Переходим в раздел Облачная платформа внутри панели управления.

2. Создаем сервер. Для работы нашего приложения не нужно много мощностей, поэтому достаточно одного ядра vCPU с долей 20% и 512 МБ оперативной памяти. И обязательно добавляем публичный IP-адрес, чтобы к серверу можно подключиться через интернет.

3. Авторизуемся на сервере через консоль посредством команды ssh root@. Публичный адрес виртуальной машины можно посмотреть в разделе Порты.

4. После подключения к серверу обновляем систему и устанавливаем Git:

apt update apt install git 

5. Устанавливаем Node.js — полная инструкция доступна в Академии Selectel.

curl -o- <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh> | bash source ~/.bashrc  nvm install 20  nvm use 20  npm -v  node -v 

6. Устанавливаем на сервере Docker — для этого тоже можно воспользоваться инструкцией.

7. Клонируем код с нашего предварительно созданного репозитория на GitHub:

apt install git git clone REPO_URL 

8. Запускаем проект:

cd PROJECT_NAME make build make run 

Готово — бот c Telegram Mini Apps запущен.

Разработка функционала

Разработка функционала тапалки — многоуровневый процесс, который сложно полностью отразить в текстовом формате. Если у вас на каком-то из шагов возникли вопросы, обратитесь к видео на YouTube — там все показано и разбито на таймкоды.

Шаг 1. Подключение базы данных к клиенту

В качестве базы данных будем для хранения пользовательских баллов будем использовать Supabase. Это open source-аналог решения от Google.

Инициализируем проект и создаем две таблицы: User (список пользователей) и Task (список задач).

Создание таблицы User в Supabase.

Создание таблицы Task в Supabase.

На стороне инициализируем дополнительный сервис services/supabase.js:

const SUPABASE_URL = 'https://yodsvoxtwmjearffyxhj.supabase.co' const SUPABASE_API_KEY = 'SECRET' import { createClient } from '@supabase/supabase-js' const supabase = createClient(SUPABASE_URL, SUPABASE_API_KEY) export default supabase 

Шаг 2. Подключение Telegram-библиотеки

Для того, чтобы соединить веб-приложение с функционалом Telegram Mini Apps, подключим библиотеку:

​​​​<script src="https://telegram.org/js/telegram-web-app.js"></script> 

Чтобы ей было удобней пользоваться, делаем отдельный хук:

services/telegram.js export function useTelegram() {   const tg = window.Telegram.WebApp   return { tg, user: tg.initDataUnsafe?.user } } 

Шаг 3. Создание API-запросов к базе

Опишем все запросы, которые будет отправлять наше веб приложение для взаимодействия с базой данных.

api/app.js import supabase from '@/superbase' import { useTelegram } from '@/telegram' import { useScoreStore } from '../stores/score'  const { user } = useTelegram()  const MY_ID = user?.id ?? 'YOUR DEV ID' // Для разработки. В вебе нет user.id — он появляется только тогда, когда приложение запущено в рамках Telegram  // Авторизация пользователя export async function getOrCreateUser() {   const potentialUser = await supabase     .from('users')     .select()     .eq('telegram', MY_ID)    // Проверяем, существует ли уже текущий пользователь   if (potentialUser.data.length !== 0) {     return potentialUser.data[0]   }      // Если нет, то создаем нового   const newUser = {     telegram: MY_ID,     friends: {},     tasks: {},     score: 0,   }    await supabase.from('users').insert(newUser)   return newUser }  // Добавляем обновление счета у текущего пользователя export async function updateScore(score) {   await supabase.from('users').update({ score }).eq('telegram', MY_ID) }  // Завершаем задачу и начисляем бонусные баллы за ее выполнение export async function completeTask(user, task) {   await supabase     .from('users')     .update({ tasks: { ...user.tasks, [task.id]: true } })     .eq('telegram', MY_ID)    const score = useScoreStore()   const newScore = score.score + task.amount   await updateScore(newScore)   score.setScore(newScore) }  // Регистрируем реферала export async function registerRef(userName, refId) { // Получаем данные пользователя, который поделился своей реферальной ссылкой const { data } = await supabase.from('users').select().eq('telegram', refId)    const refUser = data[0]    // Добавляем нас в список его рефералов, а также начисляем баллы   await supabase     .from('users')     .update({       friends: { ...refUser.friends, [MY_ID]: userName },       score: refUser.score + 50,     })     .eq('telegram', refId) }  // Получаем список всех задач (он статический) export async function getTasks() {   const { data } = await supabase.from('tasks').select('*')    return data } 

Шаг 4. Создание store для приложения

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

stores/app.js import { defineStore } from 'pinia' import {   getOrCreateUser,   completeTask,   registerRef,   getTasks, } from '@/api/app' import { useScoreStore } from './score' import { useTelegram } from '@/services/telegram'  const { user } = useTelegram()  export const useAppStore = defineStore('app', {   state: () => ({     user: {}, // настройки по умолчанию     tasks: [],   }),   actions: {     // С этого метода начинается авторизация и идентификация пользователя     async init(ref) {       // Получаем существующего пользователя либо создаем нового       this.user = await getOrCreateUser()        const score = useScoreStore()       // Задаем данные, которые были в базе у этого пользователя       score.setScore(this.user.score)        // проверяем, является ли он рефералом       if (ref && +ref !== +this.user.telegram) {         await registerRef(user.first_name, ref)       }     },     // Выполнение задачи     async completeTask(task) {       await completeTask(this.user, task)     },     // Получаем список всех задач     async fetchTasks() {       this.tasks = await getTasks()     },   }, }) 

Шаг 5. Запуск приложения

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

<template>   <main class="game" v-if="loaded">     <div class="page">       <RouterView />     </div>     <TheMenu />   </main> </template>  <script setup> import { onMounted, ref } from 'vue' import { RouterView } from 'vue-router' import TheMenu from './components/TheMenu.vue' import { useAppStore } from './stores/app' import { useTelegram } from './telegram'  const { tg } = useTelegram()  const app = useAppStore() const loaded = ref(false)  // Получаем данные, которые нам прокинул бот по рефералам const urlParams = new URLSearchParams(window.location.search)  // Передаем рефку в инициализацию и получаем данные пользователя app.init(urlParams.get('ref')).then(() => {   loaded.value = true })  onMounted(() => {   // Сигнализируем о том, что приложение готово   tg.ready()   // Разворачиваем на весь экран   tg.expand() }) </script> 

Шаг 6. Сохранение счета в базе

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

Для защиты от спама добавим debounce, чтоб не заспамить базу запросами. Задержку в 500 мс. Если на каждый клик отправлять запрос, это не будет эффективным решением.

npm i lodash.debounce 
@/store/score import { defineStore } from 'pinia' import debounce from 'lodash.debounce' import { updateScore } from '@/api/app'  const debouncedSave = debounce(updateScore, 500)  // =======   export const useScoreStore = defineStore('score', {   actions: {     add(score = 1) {       this.score += score       debouncedSave(this.score)     },     setScore(score) {       this.score = score     },   }, }) 

Шаг 7. Создание страницы друзей

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

views/FriendView.vue <template>   <div class="text-content">     <h1>Your Friends</h1>      <div class="center">       <button class="referal" @click="copy">{{ referalText }}</button>     </div>      <h3 v-if="friends.length === 0">No friends yet</h3>      <ul class="list">       <li class="list-item" v-for="friend in friends" :key="friend.id">         {{ friend.name }}         <span class="list-btn done"> 50 </span>       </li>     </ul>   </div> </template>  <script setup> import { useTelegram } from '@/telegram' import { useAppStore } from '@/stores/app' import { ref, computed } from 'vue'  const { user } = useTelegram() const app = useAppStore()  // Удобный формат для вывода в шаблон (объект -> массив) const friends = computed(() => Object.keys(app.user.friends).map((id) => ({   id,   name: app.user.friends[id], })))  const referalText = ref('Your referal')  // Копируем в буфер и меняем текст function copy() {   navigator.clipboard.writeText(     'https://t.me/YOUR_BOT_NAME_IN_TELEGRAM?start=' + user?.id   )   referalText.value = 'Copied!' } </script> 

Страница друзей, результат.

Шаг 8. Создание страницы задач

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

views/TasksView.vue <template>   <div class="text-content">     <h1>Your tasks</h1>     <p v-if="app.tasks.length === 0">Loading tasks...</p>     <ul class="list">       <li class="list-item" v-for="task in app.tasks" :key="task.id">         {{ task.title }}          <span>           <a             @click.prevent="openTask(task)"             target="_blank"             class="list-btn"             :class="{ done: app.user?.tasks?.[task.id] }"           >             {{ task.done ? 'Done' : task.amount }}           </a>         </span>       </li>     </ul>   </div> </template>  <script setup> import { useTelegram } from '@/telegram' import { onMounted } from 'vue' import { useAppStore } from '@/stores/app'  const app = useAppStore() const { tg } = useTelegram()  onMounted(() => {   // Если компонент готов, загружаем его с сервера   app.fetchTasks() })  function openTask(task) {   // Запускаем цикл выполнения задачи   app.completeTask(task)   if (task.url.includes('t.me')) {     // Открываем как внутреннюю ссылку     tg.openTelegramLink(task.url)   } else {     // И как внешнюю     tg.openLink(task.url)   } } </script> 

Страница задач, результат.

Заключение


Готово! У нас есть запущенный фронтенд, Telegram-бот и сама игра. Теперь вы можете использовать шаблон этого приложения для реализации собственных проектов.


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