Виммельбух, 3, перетяжка
Виммельбух, 3, перетяжка
Виммельбух, 3, перетяжка

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

Аватарка пользователя Яна Лазарева
Отредактировано

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

9К открытий11К показов

Пет-проект — это индивидуальный проект разработчика для реализации совершенно любых идей, он нужен для практики написания кода. Для того, чтобы сесть за проект, необходима идея. Для пет-проекта лучше выбирать самые интересные и сложные задачи, то, чем вы сами и ваши друзья или знакомые стали бы пользоваться. Например, это может быть трекер передвижения любимого кота, обработка и распознавание простейших математических примеров через камеру, игра в пинг-понг через сокеты 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 = container ? createRoot(container) : null;

root?.render(<HelloWorld />);
		

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

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

Теперь подумаем, чего нам не хватает, и пропишем наш сценарий. Не хватает стилей, поддержки .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=Philosopherundefineddisplay=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%;
      overflow: hidden;
  }
  
  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;
  
    undefined: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;
    undefined:hover,
    undefined:focus {
      opacity: 0.75;
    }
  }
  
  .opacity-on {
    opacity: 1;
    transition: opacity 1s ease-out;
  }
  
  .opacity-off {
    opacity: 0;
  }
		

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

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

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

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

Пет-проект: пишем игру на JS&#x2F;TS и развиваем навык работы с кодом 3
Стартовая страница
Пет-проект: пишем игру на JS&#x2F;TS и развиваем навык работы с кодом 4
Таймер на ввод
Пет-проект: пишем игру на JS&#x2F;TS и развиваем навык работы с кодом 5
Конфетти которое поздравляет пользователя с победой
Пет-проект: пишем игру на JS&#x2F;TS и развиваем навык работы с кодом 6
Статус, когда проиграли

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

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

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

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

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

Итак, позаботимся о конфетти 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:

			import React, { FC } from 'react';


const Victory: FC<{ victory: number }> = ({ victory }) => {
  return (
    <>
      {victory > 0 undefinedundefined (
        <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<undefined | boolean>();
  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<HTMLInputElement>) => {
      const { key } = event;
      const underscore = "_";
      const space = " ";


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


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


  useEffect(() => {
    const timer =
      counter > 0 undefinedundefined 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 (
    <>
      <ConfettiSplash confetti={confetti} />
      <div className="section-quote">
        <div className="wrapper">
          {start ? (
            <>
              <div className="timer">Таймер:{counter}</div>
              <h1>{exception}</h1>
              <div className="info-wrapper">
                <LettersCount quoteLetters={quoteLetters} />
                <Victory victory={victory} />
              </div>
            </>
          ) : (
            <Status start={start} setStart={setStart} />
          )}
        </div>
      </div>
    </>
  );
};


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(<Index />);
		

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

Пет-проект: пишем игру на JS&#x2F;TS и развиваем навык работы с кодом 7
Все работает так, как мы и предполагали

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

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

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

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

Следите за новыми постами
Следите за новыми постами по любимым темам
9К открытий11К показов