0
Обложка: Пет-проект: пишем игру на JS/TS и развиваем навык работы с кодом

Пет-проект: пишем игру на JS/TS и развиваем навык работы с кодом

Олег Рогов
Олег Рогов
Руководитель ЦК Frontend разработки Группы НЛМК

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

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

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

Заходим в консоль и первым делом вводим команду по инициализации package.json нашего проекта. Флаг -y указывает на то, что на все вопросы мы говорим «да».

yarn init -y

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

В качестве стека у меня Webpack 5, React 18 и TypeScript.

yarn add react react-dom
yarn add webpack webpack-cli webpack-dev-server ts-loader typescript html-webpack-plugin @types/react @types/react-dom clean-webpack-plugin handlebars handlebars-loader -D

После установки всех нужных библиотек время приступать к написанию конфига webpack: создадим папку config, внутри нее будет файл config/webpack.common.js. Он нужен, чтобы не повторять себя в development и production.

const path = require('path');
module.exports = {
  module: {
    rules: [
      {
        test: /.(tsx|ts)$/,
        use: 'ts-loader',
        exclude: /node_modules/
      },
      {
        test: /.hbs$/,
        use: ['handlebars-loader']
      }
    ]
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  }
};

Теперь в корне создадим файл webpack.dev.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpackCommon = require('./config/webpack.common');

module.exports = {
  ...webpackCommon,
  entry: './src/App.tsx',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, './dist')
  },
  mode: 'development',
  devtool: 'source-map',
  devServer: {
    static: path.resolve(__dirname, './dist'),
    port: 9000,
    devMiddleware: {
      index: 'index.html',
      writeToDisk: true
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Введите все буквы, за заданное время!',
      template: './src/index.hbs'
    }),
    new CleanWebpackPlugin()
  ]
};

И не забудем про webpack.prod.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpackCommon = require('./config/webpack.common');

module.exports = {
  ...webpackCommon,
  entry: './src/App.tsx',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, './dist')
  },
  mode: 'production',
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 3000
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Введите все буквы, за заданное время!',
      template: './src/index.hbs'
    }),
    new CleanWebpackPlugin()
  ]
};

Если посмотреть по коду, здесь нужен src/index.hbs как основная точка входа для приложения.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{htmlWebpackPlugin.options.title}}</title>    
</head>

<body>
    <div id="root"></div>
</body>

</html>

Добавим src/App.tsx для React-приложения:

import React from 'react';
import { createRoot } from 'react-dom/client';
import HelloWorld from './components/HelloWorld';

const container = document.getElementById('root');
const root = createRoot(container);

root.render();

Добавим тестовый компонент src/components/HelloWorld.tsx:

import React from 'react';

const HelloWorld = () => {
  return (
    <div>
      <h1>Hello World!</h1>
    </div>
  );
};

export default HelloWorld;

Осталось добавить tsconfig.json, и можно запускать:

{
  "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "allowJs": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true
  }
}

Основные скрипты для package.json:

"scripts": {
  "dev": "webpack serve --config webpack.dev.config.js --hot",
  "build": "webpack --config webpack.prod.config.js"
}

В консоли гордо запускаем:

yarn dev

Открываем в браузере http://localhost:9000/ и видим сообщение Hello World! После этого можно сохранить сборку в отдельную ветку, как бойлерплейт, и использовать ее в остальных проектах, чтобы не писать каждый раз заново.

Теперь подумаем, чего нам не хватает, и пропишем наш сценарий. Не хватает стилей, поддержки .scss и красивого шрифта. Для добавления в проект в файле src/index.hbs добавляем:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Philosopher&display=swap" rel="stylesheet">

Шрифт есть. Теперь допишем в config/webpack.common.js новое правило для обработки понимания .scss файлов в rules:

{
  test: /.scss$/,
  use: ['style-loader', 'css-loader', 'sass-loader']
}

Установим нужные зависимости:

yarn add css-loader style-loader sass sass-loader -D

Теперь протестируем наше решение. Создадим файл src/components/index.scss со всеми стилями, которые нам будут нужны:

:root {
  --white: #fff;
  --black: #111;
  --html-bg: rgb(244, 244, 244);
  --wrapper-bg: #fee6e3;
}

* {
  font-family: "Philosopher", sans-serif;
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: geometricPrecision;
  outline: 0;
  -moz-outline-style: none;
  -webkit-tap-highlight-color: transparent;
}

body,
html {
  background-color: var(--html-bg);
  height: 100%;
}

h1 {
  font-size: 2.5em;
}

.section-quote {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.wrapper {
  background-color: var(--wrapper-bg);
  border: 2px solid var(--black);
  border-radius: 8px;
  box-sizing: border-box;
  color: var(--black);
  max-width: 100%;
  padding: 0 25px;
  position: relative;

  &:after {
    background-color: var(--black);
    border-radius: 8px;
    content: "";
    display: block;
    height: 100%;
    left: 0;
    width: 100%;
    position: absolute;
    top: -2px;
    transform: translate(8px, 8px);
    transition: transform 0.2s ease-out;
    z-index: -1;
  }
}

.keyboard {
  display: flex;
  justify-content: center;
  align-items: center;
}

.quote-letters-count {
  margin-bottom: 20px;
}

.badge {
  padding: 4px 8px;
  text-align: center;
  border-radius: 5px;
  margin-left: 4px;
  color: var(--white);
  background-color: var(--black);
}

.info-wrapper {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.start-btn-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
}

.timer {
  margin-top: 20px;
}

button {
  background-color: var(--black);
  border-radius: 4px;
  border-style: none;
  color: var(--white);
  cursor: pointer;
  font-size: 16px;
  font-weight: 700;
  max-width: none;
  min-height: 44px;
  min-width: 10px;
  margin-bottom: 20px;
  outline: none;
  overflow: hidden;
  padding: 9px 20px 8px;
  position: relative;
  text-align: center;
  text-transform: none;
  user-select: none;
  &:hover,
  &:focus {
    opacity: 0.75;
  }
}

.opacity-on {
  opacity: 1;
  transition: opacity 1s ease-out;
}

.opacity-off {
  opacity: 0;
}

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

Теперь, когда у нас есть правила и шрифт, подумаем над сценарием поведения нашего приложения. Я вижу его таким: пользователь заходит на страницу, видит на ней статус с описанием того, что именно от него требуется. Под описанием есть кнопка «Старт», после нажатия которой пользователь видит какую-либо цитату. Сверху виден таймер с отсчетом времени, внизу — количество букв и знаков, которые осталось ввести для победы, а рядом — общее число побед. Если пользователь вводит фразу быстрее таймера, выпускаем конфетти, а если не уложился, пишем, что игра закончена, но всегда можно нажать на старт, чтобы начать заново.

Выглядит это так:

 

Стартовая страница

 

Таймер на ввод

 

Конфетти, которое поздравляет пользователя с победой

Статус, когда проиграли

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

Планирование и написание пользовательского сценария — важный процесс работы над пет-проектом, ведь все решения в нем принимаем мы.

Теперь пропишем компоненты и начнем их создавать.

Приложение состоит из следующих компонентов:

  • Конфетти
  • Счетчик букв и знаков
  • Статус
  • Количество побед

Итак, позаботимся о конфетти src/components/ConfettiSplash/index.tsx:

import React, { FC } from 'react';
import Confetti from 'react-confetti';

const ConfettiSplash: FC<{ confetti: boolean }> = ({ confetti }) => (
  <div className={`opacity-on ${confetti ? '' : 'opacity-off'}`}>
    <Confetti />
  </div>
);

export default ConfettiSplash;

Установим react-confetti. Компонент работает очень просто: показываем конфетти в случае победы, в остальных случаях не показываем.

yarn add react-confetti

Напишем счетчик букв и знаков src/components/LettersCount/index.tsx:

import React, { FC } from 'react';

const LettersCount: FC<{ quoteLetters: string }> = ({
  quoteLetters
}) => (
  <div className="quote-letters-count">
    Букв и знаков осталось:
    <span className="badge">{quoteLetters?.length}</span>
  </div>
);

export default LettersCount;

Напишем статус, который будет встречать нашего пользователя, а также сообщать ему о том, что можно попробовать сыграть заново в случае проигрыша src/components/Status/index.tsx.

import React, { FC } from 'react';

const Status: FC<{
  start: boolean | undefined;
  setStart: (value: boolean) => void;
}> = ({ start, setStart }) => (
  <>
    <h1>
      {typeof start === 'undefined'
        ? `⏱ Цель игры, как можно быстрее напечатать буквы и
                знаки, кроме пробелов, чтобы уложиться в таймер.`
        : `🥺 Вы проиграли.`}
    </h1>
    <div className="start-btn-wrapper">
      <button onClick={() => setStart(true)}>Старт</button>
    </div>
  </>
);

export default Status;

Осталось количество побед src/components/Victory/index.tsx:

Осталось количество побед src/components/Victory/index.tsx :
import React, { FC } from 'react';

const Victory: FC<{ victory: number }> = ({ victory }) => {
  return (
    <>
      {victory > 0 && (
        <div className="quote-letters-count">
          Побед{victory === 1 ? 'а' : 'ы'}:
          <span className="badge">{victory}🏅</span>
        </div>
      )}
    </>
  );
};

export default Victory;

Так у нас есть весь необходимый набор компонентов. Осталось добавить словарь с цитатами. Для этого создадим папку data c файлом src/data/quotes.json:


  "Чем умнее человек, тем легче он признает себя дураком.",
  "Никогда не ошибается тот, кто ничего не делает.",
  "Менее всего просты люди, желающие казаться простыми.",
  "Музыка заводит сердца так, что пляшет и поёт тело. А есть музыка, с которой хочется поделиться всем, что наболело.",
  "Если тебе тяжело, значит ты поднимаешься в гору. Если тебе легко, значит ты летишь в пропасть.",
  "Мой способ шутить – это говорить правду. На свете нет ничего смешнее.",
  "Чем больше любви, мудрости, красоты, доброты вы откроете в самом себе, тем больше вы заметите их в окружающем мире.",
  "Единственный человек, с которым вы должны сравнивать себя, – это вы в прошлом. И единственный человек, лучше которого вы должны быть, – это вы сейчас.",
  "История – самый лучший учитель, у которого самые плохие ученики.",
  "Человечество обладает одним поистине мощным оружием, и это смех.",
  "Тренируйся с теми, кто сильнее. Не сдавайся там, где сдаются другие. И победишь там, где победить нельзя.",
  "Будьте менее любопытны о людях, но более любопытны об идеях.",
  "Мышление – верх блаженства и радость жизни, доблестнейшее занятие человека.",
  "Я серьёзно отношусь к своей работе, а это возможно только при несерьёзном отношении к собственной персоне.",
  "Успех – паршивый учитель. Он заставляет умных людей думать, что они не могут проиграть.",
  "Чемпионами становятся не в тренажёрных залах. Чемпиона рождает то, что у человека внутри: желания, мечты, цели.",
  "Необходимо, чтобы художник, кроме глаза, воспитывал и свою душу.",
  "То, что мы знаем, это капля, а то, что мы не знаем, это океан.",
  "Ни высокий интеллект, ни воображение, ни то и другое вместе не творят гения. Любовь, любовь и любовь – вот в чём сущность гения.",
  "Не оборачивается тот, кто устремлён к звёздам.",
  "Шире открой глаза, живи так жадно, как будто через десять секунд умрёшь. Старайся увидеть мир. Он прекраснее любой мечты, созданной на фабрике и оплаченной деньгами. Не проси гарантий, не ищи покоя – такого зверя нет на свете.",
  "Видите ли, художника отличает то, что в его жизни бывают минуты, когда он ощущает себя больше чем человеком.",
  "Любовь к собственному благу производит в нас любовь к отечеству, а личное самолюбие – гордость народную, которая служит опорою патриотизма."

Добавим для работы с .json в tsconfig.json:

"resolveJsonModule": true

Теперь нам доступен импорт .json файлов. Удаляем файл src/components/HelloWorld.tsx и вместо него добавляем src/components/Index.tsx:

import React, { useEffect, useState, KeyboardEvent, FC } from "react";
import _sample from "lodash/sample";
import _round from "lodash/round";

import ConfettiSplash from "./ConfettiSplash";
import LettersCount from "./LettersCount";
import Status from "./Status";
import Victory from "./Victory";

import quotes from "../data/quotes.json";

import "./index.scss";

const returnQuoteLetters = (quote: string) =>
  quote.replace(/\s/g, "").split("_").join("");
const generateQuote = () => _sample(quotes);

const Index = () => {
  const [confetti, setConfetti] = useState(false);
  const [start, setStart] = useState();
  const [victory, setVictory] = useState(0);
  const [exception, setException] = useState(generateQuote);
  const quoteLetters = returnQuoteLetters(exception);
  const [counter, setCounter] = useState(_round(quoteLetters.length / 2));

  useEffect(() => {
    const keyDownHandler = (event: KeyboardEvent) => {
      const { key } = event;
      const underscore = "_";
      const space = " ";

      if (key !== underscore && key !== space) {
        setException(exception.replace(key, underscore));
      }
    };

    window.addEventListener("keydown", keyDownHandler, false);
    return () => window.removeEventListener("keydown", keyDownHandler, false);
  }, [exception]);

  useEffect(() => {
    const timer =
      counter > 0 && setTimeout(() => setCounter(counter - 1), 1000);

    if (counter === 0) {
      setStart(false);
    }

    return () => clearInterval(timer);
  }, [counter]);

  useEffect(() => {
    if (!quoteLetters) {
      const newQuote = generateQuote();
      setVictory(victory + 1);
      setConfetti(true);
      setException(newQuote);
      setCounter(_round(returnQuoteLetters(newQuote).length / 2));
      setTimeout(() => setConfetti(false), 4000);
    }
  }, [victory, exception]);

  useEffect(() => {
    if (start) {
      const newQuote = generateQuote();
      setVictory(0);
      setException(newQuote);
      setCounter(_round(returnQuoteLetters(newQuote).length / 2));
    }
  }, [start]);

  return (
    <>
{start ? ( <>

Таймер:{counter}

{exception}

</> ) : ( )}


    </>
  );
};

export default Index;

Устанавливаем lodash:

yarn add lodash @types/lodash -D

И добавляем src/global.d.ts, чтобы TS не ругался на window.addEventListener('keydown', keyDownHandler, false);

import { KeyboardEvent } from 'react';

declare global {
  interface WindowEventMap {
    keydown: KeyboardEvent<HTMLInputElement>;
  }
}

Делаем правки в src/App.ts:

import React from "react";
import { createRoot } from "react-dom/client";
import Index from "./components/Index";

const container = document.getElementById("root");
const root = createRoot(container);

root.render();

Запускаем приложение и убеждаемся, что все работает согласно нашему сценарию.

Все работает так, как мы и предполагали

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

  • В файле src/components/index.scss нет миксинов, и по-хорошему мы должны были разграничить стили от общих. Чтобы избежать ада глобальных имен, нам нужно использовать .module.scss или БЭМ-нотацию.
  • В файле src/components/Index.tsx слишком много useState, можно использовать useReducer или новые и трендовые стейт-менеджеры — Effector, Recoil, Jotai, Rematch, Zustand — либо остановиться на React Context. Для опыта и насмотренности можно сделать для каждого решения свою ветку — это даст наглядное понимание, что хорошего в решениях на рынке стейт-менеджеров есть уже сейчас.
  • Вынести в отдельный компонент таймер. Старайтесь писать списки и продумывать, как бы вы стали развивать свой проект, добавлять новые функции.
  • Нет системы уровней, для ускорения таймера.
  • Нет возможности посоревноваться с другом.
  • Можно ли сделать удобным интерфейс для планшетов и мобильных устройств?
  • Нет возможности поделиться результатом.
  • Нет возможности поставить на паузу.
  • Было бы здорово иметь на базе такого компонента виджет, который можно встроить на сторонний ресурс.

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

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