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

  • Игровое поле представляет собой прямоугольник с регулируемой шириной и фиксированной высотой.
  • Каждую секунду у верхнего края поля случайным образом появляется точка.
  • Каждая точка может иметь разный размер и цвет (выбирается случайным образом из списка).
  • Каждая точка падает на X пикселей в секунду.
  • Скорость можно изменить в любой момент во время игрового процесса.
  • Если вы успешно нажмете на точку, она исчезнет, ​​и вы получите баллы.
  • Чем меньше точка - тем больше очков вы получите
  • Когда точка отрывается от доски, она исчезает (очки не набираются).
  • Игру можно приостановить в любой момент, чтобы перестать падать и не появляться точки.
  • Поле Game можно очистить, чтобы полностью сбросить процесс.

Чтобы дать вам небольшой тизер, игровой процесс выглядит так:

Чтобы сделать нашу задачу более интересной, мы будем следовать концепции функционального программирования с современными хуками React и подходом к функциональным компонентам.

Стек технологий

В этом руководстве мы будем использовать простой стек технологий.

  • React JS - для оптимизации повторного рендеринга и компонентной структуры
  • Recoil - как простая библиотека государственного управления. Вы могли увидеть учебник по этой блестящей новой библиотеке в моей предыдущей статье.
  • CSS - для стилизации, здесь никакого волшебства :)
  • create-response-app - для начальной загрузки приложения

Вы можете следить за исходным кодом в репозитории GitHub.

Создание приложения и определение конфигураций

Сначала мы создаем скелетное приложение для реакции со старым добрым

npx create-react-app dots-game

Затем установите новую блестящую библиотеку отдачи, перейдя в папку приложения и запустив:

npm install recoil

После этого вы можете запустить npm start, чтобы увидеть, как работает учебное приложение. Затем очистите файл App.js и переходите к следующему шагу.

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

// color for dots to pick from list
export const COLORS = ['red', 'green', 'blue', 'orange'];
// size vales for dots to pick from list
export const SIZES = [10, 15, 20, 25, 30, 35, 40, 45];
// step to control speed px/sec
export const SPEED_STEP = 10;
// Max value of points - each dot will cost MAX - size points
export const MAX_POINTS = 50;
// Interval to spawn a new dot
export const SPAWN_INTERVAL = 1000;

Государственное устройство

Как я уже упоминал ранее, мы используем Recoil как простую библиотеку управления состоянием для этого руководства. Мы могли бы сделать это с помощью Redux / MobX или даже простых хуков useState, но может быть полезно получить больше опыта с новой библиотекой. Также это дает нам возможность отделить состояние от компонентов без установки огромного дополнительного шаблона (см. Мою мысль о отдаче там). Для этой игры нам понадобится лишь базовый функционал отдачи.

Вот простое государственное устройство, которое я могу предложить:

import  { atom } from 'recoil';
// Control params - if game is launched and current speed
export const controlOptions = atom({
    key: 'controlOptions',
    default: {
        isRunning: false,
        speed: 5,
    },
});
// List of dots in the game - empty by default
export const dotsState = atom({
    key: 'dotsState',
    default: [],
});
// Current score - zero by default
export const scoreState = atom({
    key: 'scoreState',
    default: 0,
});

Не забудьте импортировать RecoilRoot в свой App.js, чтобы иметь доступ к вашему состоянию отдачи во всех компонентах игры.

import React from "react";
import { RecoilRoot } from "recoil";
// import Game from './game/Game';
import './App.css';
function App() {
    return (
      <RecoilRoot>
          {/*<Game />*/}
      </RecoilRoot>
  );
}
export default App;

Основные компоненты и стили

Определим структуру нашего компонента следующим образом:

  • Game.js - основной компонент для рендеринга игрового поля и всех остальных компонентов. Содержит основную логику и хуки, связанные с игровым полем. Мы вернемся к этому компоненту в следующих разделах. А пока сделаем фиктивное игровое поле
const Game = () => {
   return (
        <div className="main">
            <div className="panel">
                {/* <Control/> */}
                {/* <Score /> */ }
            </div>
            <div className="field">
            </div>
        </div>
    );
}
export default Game;
  • Control.js - компонент для обработки элементов управления (Start / Stop / Clear, Speed ​​change). Мы еще вернемся к этому позже.
  • Score.js - компонент для отображения текущего счета - тут никакого волшебства :)
  • Dot.js - компонент для изображения падающей точки.

Хочу подробнее рассказать о последнем. Чтобы точка падала, мы должны перемещать ее по оси Y на нашем поле. Кроме того, каждая точка должна иметь свою координату X, чтобы иметь возможность распространять их по горизонтали. Для представления координат мы можем использовать свойства CSS left и top. Ширина точки должна равняться ее размеру. Для простоты сделаем это с помощью встроенного стиля в React. Нам также необходимо определить обработчик onClick для точки, поскольку это основная цель нашей Игры :)

const Dot = (props) => {
    const {color, x, y, size, index, onClick} = props;    
    const dotStyle = {
        backgroundColor: color,
        height: `${size}px`,
        width: `${size}px`,
        left: `${x}px`,
        top: `${y}px`,
    };
return (
        <div 
            className="dot"
            style={dotStyle}
            onClick={() => onClick(index)}
        />
    );
};
export default Dot;

Я что-то забыл? О, точки должны быть кругами, и наше поле должно уважать свои координаты. Это делается в файле App.css.

/* Generic padding */
.main {
    text-align: center;
    padding: 25px 50px 25px 50px;
}
/* Field params */
.field {
    height: 500px;
    border: 2px solid black;
    position: relative;
    overflow-y: hidden;
}
/* Dot positioning */
.dot {
    border: 1px solid #000;
    border-radius: 50%;
    position: absolute;
}

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

return (
        <div className="main">
            <div className="panel">
                {/*<Control onClear={clear} />*/}
                {/*<Score />*/}
            </div>
            <div className="field">
                <Dot 
                    color="red" 
                    x="100" y="200" 
                    onClick={() => {}} 
                    size="40" 
                />
                <Dot 
                    color="green" 
                    x="200" y="300" 
                    onClick={() => {}} 
                    size="35" />
            </div>
        </div>
    );

Основная логика - манипуляции с точками

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

Чтобы не помещать все в наш игровой компонент, мы определяем набор помощников utils с некоторыми полезными функциями, необходимыми для логики игры.

import { MAX_POINTS, COLORS, SIZES } from './constants';
export const createDot = () => {
    // pick random color and size
    const color = COLORS[Math.floor(Math.random() * COLORS.length)]
    const size = SIZES[Math.floor(Math.random() * SIZES.length)]
    
    let x = Math.floor(Math.random() * 100);
    return {
        color,
        size,
        x,
        y: 0,
      }
};
export const removeDot = (dots, index) => {
    const newDots = [...dots];
    newDots.splice(index, 1);
    return newDots;    
};
export const calculatePoints = (dot) => {
    return MAX_POINTS - dot.size;
};

Первая функция createDot - это заводская функция, возвращающая объект с параметрами точки (цвет, размер, координаты). Следующий метод removeDot - служебный метод для удаления точки по данному индексу из основного списка состояний. Другая служебная функция calculatePoints вернет количество точек, рассчитанное как постоянное значение MAX_POINTS за вычетом размера точки.

Основная логика, связанная с точками, находится в функциях обратного вызова в основном компоненте Game.js.

  • Создать новую точку
const spawnDot = useCallback(() => {
    updateDots((oldDots) => [...oldDots, createDot()]);
}, [updateDots]);
  • Удалите точку и добавьте точки при успешном нажатии
const onDotClick = (index) => {
    setScore(score + calculatePoints(dots[index]));
    updateDots(removeDot(dots, index));
};

Теперь нам нужно подумать о том, как разместить наши точки на поле. У нас есть случайная координата X, выбранная от 1 до 100, но как мы сопоставим ее с фактическим полем, предполагая, что у нас может быть переменная ширина экрана или устройства? Чтобы решить эту проблему, мы используем свойство offsetWidth для вычисления нашего фактического значения X в пикселях на основе X от 1 до 100 и текущего значения offsetWidth, поэтому мы всегда будем иметь X пропорционален текущей ширине. Поскольку нам нужно сослаться на собственное свойство HTML, здесь нам поможет перехватчик React useRef.

const [dots, updateDots] = useRecoilState(dotsState);
const fieldRef = useRef();

в нашем компоненте Game.js для определения Ref, а затем:

<div className="field" ref={fieldRef}>
    {dots.map((dot, index) => {
        const x = (
            fieldRef.current.offsetWidth - dot.size
        ) * dot.x / 100
        return <Dot
            key={`dot-${index}`} 
            {...dot}
            x={x}
            index={index} 
            onClick={onDotClick} 
        />;
     })}
</div>

Эта формула для вычисленного значения X определяет наше реальное значение в пикселях как процент от нашей общей offsetWidth от 1 до 100%, поэтому мы имитируем правильную ось координат. Часть — dot.size гарантирует, что точка не уйдет за правый край, если у нас значение X близко к 100%. В результате мы можем передать значение X на основе offsetWidth, индекса точки и функции обратного вызова (для правильной обработки onClick) и других свойств точки, сохраняя абстрактный компонент Dot. из нашего государства.

Заставьте его двигаться (requestAnimationFrame)

Теперь пора подумать о реальном игровом процессе - как заставить все это двигаться?

Прежде всего, нам нужно создавать новую точку каждую секунду или что-то еще, что определено в файле констант как интервал для создания точек.

import { SPEED_STEP, SPAWN_INTERVAL } from './constants';

Затем в нашем компоненте Game.js мы определяем ref для интервала появления точек.

const intervalRef = useRef();

Этот ref будет содержать наш метод создания точки с тайм-аутом. Затем создайте функцию точки появления, которая будет создавать новые точки.

const spawnDot = useCallback(() => {
        updateDots((oldDots) => [...oldDots, createDot()]);
}, [updateDots]);

Помните нашу функцию createDot? Мы используем его внутри этого обратного вызова.

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

const requestRef = useRef();
const advanceStep = useCallback(() => {
        updateDots((oldDots) => {
            const newDots = [];
            for (let dot of oldDots) {
                const newY = dot.y + SPEED_STEP * controlState.speed / 60;
                if (newY <= fieldRef.current.offsetHeight - dot.size / 2) {
                    newDots.push(
                        {
                            ...dot,
                            y: newY,
                        }
                    );
                }
            }
            return newDots;
        });
        requestRef.current = requestAnimationFrame(advanceStep);
    }, [controlState.speed, updateDots]);

Так для чего эта функция? Прежде всего, он перебирает все существующие точки. Затем он пытается переместить каждую точку вниз по оси Y на SPEED_STEP * current_speed / 60 px, потому что наша константа SPEED_STEP представляет количество пикселей в секунду на единицу скорости, а обратный вызов вызывается 60 раз в секунду.

Если точка опускается ниже текущего offsetHeight поля на половину размера точки или более - мы считаем эту точку пропавшей и удаляем ее (не включаем в newDots множество). Ссылка requestRef содержит обратный вызов с истекшим временем ожидания.

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

useEffect(() => {
    const stop = () => {
        intervalRef.current &&         
            clearInterval(intervalRef.current);
        requestRef.current && 
           cancelAnimationFrame(requestRef.current);
    }
    if (controlState.isRunning) {
        intervalRef.current = setInterval(
            spawnDot, SPAWN_INTERVAL
        );
        requestRef.current = requestAnimationFrame(advanceStep);
    } else {
            stop();
    }
    return () => stop();
}, [controlState.isRunning, advanceStep, spawnDot])

Если флаг isRunning изменен на true, мы запускаем оба метода с тайм-аутом. Мы вызываем функцию stop как эффект очистки для ловушки, а также вызываем ее, если флаг controlState isRunning изменен на false. Обратите внимание, что ранее определенные ссылки очень полезны для остановки игры / очистки наших интервалов.

Наконец, мы определяем функцию clear, передаваемую кнопке управления CLEAR.

const clear = useCallback(() => {
    setControlState({...controlState, isRunning: false, speed: 5});
    updateDots([]);
    setScore(0);
}, [setControlState, setScore, updateDots, controlState]);

Он останавливает выполнение и очищает все точки на поле и очищает текущий счет.

Заставьте Game Control работать

Компонент Control связан с controlState и позволяет запускать / приостанавливать игру или изменять скорость.

Часть рендеринга довольно проста - она ​​включает в себя 2 кнопки и вход диапазона для управления скоростью.

return (
        <div className="control">
            <div className="control__buttons">
                {
                    isRunning ? 
                        (
                            <button onClick={togglePause}>
                                PAUSE
                            </button>
                        ) : (
                            <button onClick={onStart}>
                                START
                            </button>
                        )                        
                }
                <button onClick={onClear}>CLEAR</button>
            </div>
            <div className="control__speed">
                <p>{`Current speed: ${speed}`}</p>
                <input
                    type="range"
                    min="1"
                    max="10"
                    value={speed}
                    onChange={onChangeSpeed}
                />
            </div>
        </div>
    )

Каждая кнопка имеет обработчик обратного вызова для своего действия, за исключением кнопки Очистить - обработчик для нее передается из компонента Game.

const onChangeSpeed = useCallback((event) => {
        setControlState(
             {...controlState, speed: event.target.value}
        );
    }, [setControlState, controlState]);

Обратите внимание на особую логику useEffect. Я заметил одну проблему с производительностью, которая может быть довольно серьезной. Каждый раз, когда игрок переключается на новую вкладку в браузере, игра продолжает работать в фоновом режиме с ошибками (новые точки продолжают появляться, но движения не происходит). Это приводит к нарушению поведения и потенциальной утечке памяти на этой вкладке, потому что точки могут появляться бесконечно. Чтобы предотвратить это, мы хотели бы остановить игру, когда текущая вкладка не отображается с помощью API видимости документа.

useEffect(() => {
    document.addEventListener("visibilitychange", () => {
        setControlState(oldState => {
            return {...oldState, isRunning: false};
        });
     });
     return () => document.removeEventListener("visibilitychange");        
}, [setControlState]);

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

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

import React from 'react';
import {useRecoilValue} from 'recoil';
import {scoreState} from './atom';
const Score = () => {
    const score = useRecoilValue(scoreState);
return (
        <div className="score">
            <p>{`Score: ${score}`}</p>
        </div>
    );
};
export default Score;

Попробуй это!

Чтобы протестировать игру в действии, просто клонируйте репозиторий, перейдите в его корневую папку и выполните команды, которые мы, наверное, все знаем:

npm install
npm start

После этого вы можете перейти к http://localhost:3000 и увидеть, как он работает!

GG WP :)

Что дальше? Дополнительные проблемы, которые следует учитывать

Я надеюсь, что это руководство было полезным или, по крайней мере, вы немного повеселились с этим довольно необычным приложением стека React и Recoil.

Я хотел бы выделить несколько дополнительных мыслей о еде:

  • Мы не учли, что наши круги могут пересекаться. Если мы хотим улучшить общий опыт, имеет смысл разработать более сложный алгоритм создания точек, который учитывал бы «занятые» места и не помещал в них какие-либо точки.
  • Мы можем поиграть с оптимизацией CSS, используя свойства преобразования и некоторые функции смягчения анимации.
  • Наконец, мы можем использовать Canvas вместо рендеринга с помощью React, но в этом случае он не будет действительно учебным пособием по React, потому что мы переместим весь визуальный рендеринг на холст и будем использовать React только как контейнер состояния.

Спасибо, что прочитали эту длинную статью :) Увидимся в моих следующих уроках