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