26 мая 2023

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

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



Теперь подумаем, чего нам не хватает, и пропишем наш сценарий. Не хватает стилей, поддержки .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: #f4f4f4;
    --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;
  }

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



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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Источник: tproger.ru/articles/pet-proekt-pishem-igru-na-js-ts-i-razvivaem-navyk-raboty-s-kodom
В начало

Этот веб-сайт использует файлы cookie, чтобы предоставить вам лучший пользовательский опыт. Вы можете узнать больше о том, какие файлы cookie мы используем, или отключить их использование, нажав «Настройки». Вы можете ознакомиться с Политикой использования файлов Cookie и Политикой ПАО «НЛМК» в области обработки и защиты персональных данных, в которых объясняется, как мы обрабатываем ваши личные данные. Продолжая работать на нашем сайте, вы подтверждаете свое согласие на использование всех файлов cookie. Нажмите кнопку «Разрешить все Cookie», чтобы скрыть это сообщение. Обратите внимание, что вы можете отозвать свое согласие в любое время.