Это продолжение Части 1, которая раскрывает базовый функционал игры.

Чтобы быстро освежить в памяти, вот что мы построили в части 1:

Кажется довольно простым, не так ли? Есть некоторые улучшения, которые могут быть сделаны для улучшения нашего UX:

  • Сделайте неправильное слово красным
  • Сделайте правильное слово зеленым
  • Сделать текущее слово подчеркиванием
  • Сделать уже напечатанные слова зелеными

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

Добавление цветов и стилей к уже напечатанным словам

Чтобы добавить цвета, нам нужно иметь возможность отделять слова, которые были напечатаны, текущее слово и слова, которые нужно ввести. Мы уже знаем текущее слово, поэтому нам нужно построить два других. В index.tsx добавьте два новых состояния с именами alreadyTypedWords и wordsToBeTyped под состоянием wordIdx с помощью useMemo.

// ...
  const [wordIdx, setWordIdx] = useState<number>(0);
  const alreadyTypedWords = useMemo(
    () => quotesSplit.slice(0, wordIdx).join(" "),
    [quotesSplit, wordIdx]
  );
  const wordsToBeTyped = useMemo(
    () => quotesSplit.slice(wordIdx + 1, quotesSplit.length).join(" "),
    [quotesSplit, wordIdx]
  );
// ...

Затем давайте поместим его в абзац font-mono соответственно.

// ...
      <h1 className="mb-4">Typeracer</h1>
      <p className="font-mono">
        <span className="text-green-600">{alreadyTypedWords} </span>
        <span className="text-black underline">{currentWord}</span>
        <span className="text-black"> {wordsToBeTyped}</span>
      </p>
// ...

Не забудьте поставить пробел после уже набранных слов и перед wordsToBeTyped для разделения слов. Вот как выглядит наша игра после этих улучшений:

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

Раскрашивание текущего пользовательского ввода

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

Мы можем вычесть, что:

  • Зеленый текст идет перед красным текстом
  • Красный текст стоит перед текущим набранным словом (подчеркнуто)

Давайте соответствующим образом обновим пользовательский интерфейс, добавив два интервала для каждого цвета:

// ...
        <span className="text-green-600">{alreadyTypedWords} </span>
        <span className="text-green-600"></span> // correct word
        <span className="text-red-700 bg-red-200"></span> // wrong word
        <span className="text-black underline">{currentWord}</span>
        <span className="text-black"> {wordsToBeTyped}</span>
// ...

Вы могли заметить, что есть два text-green-600, которые можно объединить, но мы сделаем это позже.

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

Добавьте этот код ниже состояния alreadyTypedWords:

// ...

const correctGreenWord = useMemo(() => {
    if (currentWord) {
      let i = 0;
      while (i < text.length) {
        if (text[i] != currentWord[i]) {
          break;
        }
        i++;
      }
      return text.slice(0, i); // return only the correct part
    }
    return "";
  }, [currentWord, text]);

const wrongRedWord = useMemo(
  () =>
    currentWord?.slice(
      correctGreenWord.length,
      text.length
    ),
  [correctGreenWord, currentWord, text]
);

// ...

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

Последним состоянием будет currentWordTail, которое отрезает currentWord от text.length до currentWord.length, но поскольку это простая операция, я просто закодирую ее без состояния. Также не забудьте сгруппировать alreadyTypedWords и correctGreenWord для максимальной эффективности. Вот последнее обновление пользовательского интерфейса:

// ...
        <span className="text-green-600">{alreadyTypedWords} {correctGreenWord}</span>
        <span className="text-red-700 bg-red-200">{wrongRedWord}</span>
        <span className="underline">{currentWord?.slice(text.length)}</span>
        <span className="text-black"> {wordsToBeTyped}</span>
// ...

Окончательно! Вот как должно выглядеть наше приложение:

Это хорошо. Но можно сделать небольшое улучшение, которое заключается в том, чтобы сфокусировать панель ввода, когда игрок открывает или обновляет приложение. Есть два способа сделать это: создать ссылку на ввод с помощью useRef или использовать собственный document.getElementById, а затем добавить наш ввод в качестве идентификатора. Поскольку это простая функциональность, я буду использовать подход id. Давайте закодируем это:

// ...imports

const inputId = "typeracer-input";

export default function TyperacerPage() {
  // ...
  <input
    className="w-full border-black border px-4 py-2"
    onChange={(text) => setText(text.target.value)}
    value={text}
    id={inputId}
  />
  // ...
}

Затем, ниже состояния неправильного RedWord, давайте создадим useEffect, который фокусирует наш ввод:

  useEffect(() => {
    document.getElementById(inputId)?.focus();
  }, []);

С этим изменением каждый раз, когда мы обновляем или открываем страницу, она будет фокусироваться на панели ввода. Аккуратно, верно?

Это весь код для index.tsx для части 2:

import { useEffect, useMemo, useState } from "react";
import quotes from "./quotes.json";

type Quote = {
  quote: string;
  movieName: string;
};

const randomQuote = (): Quote =>
  quotes[Math.floor(quotes.length * Math.random())];

const inputId = "typeracer-input";

export default function TyperacerPage() {
  const [quote, setQuote] = useState<Quote>();
  const [text, setText] = useState<string>("");
  const [currentWord, setCurrentWord] = useState<string>();
  const quotesSplit = useMemo(() => quote?.quote.split(" ") ?? [], [quote]);
  const [wordIdx, setWordIdx] = useState<number>(0);
  const alreadyTypedWords = useMemo(
    () => quotesSplit.slice(0, wordIdx).join(" "),
    [quotesSplit, wordIdx]
  );
  const wordsToBeTyped = useMemo(
    () => quotesSplit.slice(wordIdx + 1, quotesSplit.length).join(" "),
    [quotesSplit, wordIdx]
  );
  const correctGreenWord = useMemo(() => {
    if (currentWord) {
      let i = 0;
      console.log(text);
      while (i < text.length) {
        console.log(text[i]);
        console.log(currentWord[i]);
        if (text[i] != currentWord[i]) {
          break;
        }
        i++;
      }
      return text.slice(0, i);
    }
    return "";
  }, [currentWord, text]);

  const wrongRedWord = useMemo(
    () => currentWord?.slice(correctGreenWord.length, text.length),
    [correctGreenWord, currentWord, text]
  );

  useEffect(() => {
    document.getElementById(inputId)?.focus();
  }, []);

  useEffect(() => {
    setWordIdx(0);
    setText("");
  }, [quotesSplit]);

  useEffect(() => {
    setCurrentWord(quotesSplit[wordIdx]);
  }, [wordIdx, quotesSplit]);

  useEffect(() => {
    const latestLetter = text?.charAt(text.length - 1);
    if (latestLetter != " " && wordIdx != quotesSplit.length - 1) return;
    const textWithoutTrailingSpace = text?.replace(/\s*$/, "");
    if (textWithoutTrailingSpace == currentWord) {
      console.log(text);
      setText("");
      setWordIdx(() => wordIdx + 1);
    }
  }, [text, currentWord, wordIdx, quotesSplit]);

  useEffect(() => {
    if (wordIdx == quotesSplit.length) {
      setQuote(randomQuote());
    }
  }, [wordIdx, quotesSplit]);

  return (
    <div className="px-20">
      <h1 className="mb-4">Typeracer</h1>
      <p className="font-mono">
        <span className="text-green-600">{alreadyTypedWords} {correctGreenWord}</span>
        <span className="text-red-700 bg-red-200">{wrongRedWord}</span>
        <span className="underline">{currentWord?.slice(text.length)}</span>
        <span className="text-black"> {wordsToBeTyped}</span>
      </p>
      <input
        className="w-full border-black border px-4 py-2"
        onChange={(text) => setText(text.target.value)}
        value={text}
        id={inputId}
      />
    </div>
  );
}

Запоздалые мысли

Изначально я планировал включить статистику, такую ​​как скорость набора текста, название цитаты и т. д., во вторую часть, но, поскольку эта часть будет слишком длинной, я обновлю статистику и, возможно, некоторые незначительные улучшения в части 3. Следите за обновлениями, чтобы узнать больше. обновления. До встречи!