💬

React + TypeScriptでWordle作ってみた

2022/02/05に公開

動機

最近Reactを勉強し始め、Gatsbyでブログを作成したのですが、
あんまりReactの機能使ってないぞ? + GatsbyにTypeScript導入したい
ということで、勉強がてら巷で話題のWordleを作ってみました。

とりあえず形にはなったので、振り返りの意味も込めて書いていきます。
ちなみに正しい単語かどうかのチェック機能は実装していません。

実際のコード
https://codesandbox.io/s/wordle-77128

環境構築

npx create-react-app wordle --template typescript

実装

State管理データ

ここの構成が結構難しかったです。
基本的な方針としてはApp.tsxで全Stateデータの管理を行い、キーボードや文字入力欄にデータを渡していくような構成を取りました。

App.tsx
// 答え
const answerWord: string = 'REACT'

/*
回答文字の管理
文字列と状態(入力済み、正解など)の情報を持っている
初期化を行なって値をセット
*/
type LetterRowState = {
  state: string;
  letterStates: {
    state: string;
    letter: string;
  }[]
}[]
const InitialLetterRowStates: LetterRowState = []
for (let i = 0; i < 6; i++) {
  InitialLetterRowStates.push({
    state: '',
    letterStates: []
  })
  for (let n = 0; n < 5; n++) {
    InitialLetterRowStates[i].letterStates.push({
      state: '',
      letter: '',
    })
  }
}
const [letterRowStates, setLetterRowStates] = useState<LetterRowState>(InitialLetterRowStates)

// 正解かどうかのステータス
const [isClear, setClearStatus] = useState<boolean>(false)

// 答えチェック中ステータス
const [isChecking, setChekingState] = useState<boolean>(false)

// 回答数と回答中の文字数(x,y座標みたいな感じで使う)
const [answeredCount, setAnsweredCount] = useState<number>(0)
const [letterCount, setLetterCount] = useState<number>(0)

// 「正解」、「不正解」、「文字列は使われているけど位置が違う」の文字列を格納した配列。キーボードコンポーネントに渡して、cssを適用させるのに使う。
const [correctLetters, setCorrectLetters] = useState<string[]>([])
const [presentLetters, setPresentLetters] = useState<string[]>([])
const [absentLetters, setAbsentLetters] = useState<string[]>([])

// メッセージカードに表示させるメッセージ
const [message, setMessage] = useState<string>('')

と、なんだか冗長な気もしますが、スタイルとかアニメーションを実装していくと、あれもこれも必要となり、どんどん増えていってしまいました...

LetterRowStateの型には以下のようなデータが入ります

type LetterRowState = {
  state: string;
  letterStates: {
    state: string;
    letter: string;
  }[]
}[]

/*
[
  {
    state: 'shake',  // 入力行にアニメーションクラスを付与するときに使用する
    letterStates: [
      {
        state: 'correct',
	letter: 'R',
      }, {
        state: 'present',
	letter: 'C',
      }, {
        state: 'absent',
	letter: 'V',
      }, {
        state: 'correct',
	letter: 'C'
      }, {
        state: 'correct',
	letter: 'T',
      }
    ]
  }, {
    ...
  }
]
*/

関数

関数も基本的にはApp.tsxで定義を行い、必要に応じて各コンポーネントに渡すようにしました。
キーボード関係の関数が少し複雑になってしまったので、書いていきます。

文字列キーを押したとき

App.tsx
const addLetter = (letter: string) => {
  // 入力文字数が5未満かつ回答数が6未満
  if (letterCount < 5 && answeredCount !== 6) {
    setLetterRowStates((prevState) => {
      // 更新用にコピーを作成
      const copyForUpdate = prevState.slice()
      // 該当箇所を更新
      copyForUpdate[answeredCount].letterStates[letterCount] = {
        letter,
        state: 'inputted'
      }
      return copyForUpdate
    })
    // 入力文字数データを更新する
    setLetterCount(prevState => prevState + 1)
  }
}

setStateを使った連想配列の更新の方法、調べた感じsliceでコピーして更新して、セットするやり方が多く出てきたのですが、なんかダサいのでもっとスマートなやり方あればご教授ください...

デリートキーを押したとき

App.tsx
const deleteLetter = () => {
  if (letterCount > 0) {
    setLetterRowStates((prevState) => {
      const copyForUpdate = prevState.slice()
      copyForUpdate[answeredCount].letterStates[letterCount - 1] = {
        letter: '',
        state: ''
      }
      return copyForUpdate
    })
    setLetterCount(prevState => prevState - 1)
  }
}

文字列キーとほとんど同じです。

エンターキーを押したとき

全体を通じてここが最難関でした。
Wordle本家の、答えを入力したら順従に文字列カードが回転していくアニメーションを実装しようとしたら、こんな感じになってしまいました。

App.tsx
const answer = () => {
  // 回答数が6未満かつクリアしていないとき処理を行う
  if (answeredCount !== 6 && !isClear) {
    // 答え合わせ中は処理しない
    if (!isChecking) {
      // 5文字入力されているとき解答のチェックを行う
      if (letterCount === 5) {
        // 答え合わせ中フラグ
        setChekingState(true)
	// 文字列カードコンポーネントはステータスが渡った時点でアニメーションするようになっている
	// 従順のパラパラアニメーションを実装するためにsetTimeoutで処理をずらす
        const promiseList: Promise<void>[] = []
        for (let i = 0; i < 5; i++) {
          const promise: Promise<void> = new Promise((resolve) => {
            setTimeout(() => {
              let state: string
              const checkLetter = letterRowStates[answeredCount].letterStates[i].letter
              if (checkLetter === answerWord[i]) {
                state = 'correct'
              } else if (answerWord.indexOf(checkLetter) !== -1) {
                state = 'present'
              } else {
                state = 'absent'
              }
              setLetterRowStates((prevState) => {
                const copyForUpdate = prevState.slice()
                copyForUpdate[answeredCount].letterStates[i].state = state
                return copyForUpdate
              })
              resolve()
            }, i * 300);
          })
          promiseList.push(promise)
        }

        // キーボードコンポーネントも同様にステータスが更新されるとスタイルが当たる
	// パラパラ回転アニメーションの後にキーボードのスタイルを更新したいので、Promise.allで上記処理が全て完了した後、処理を行う
        Promise.all(promiseList).then(() => {
          letterRowStates[answeredCount].letterStates.forEach((letterState, i) => {
            if (letterState.letter === answerWord[i]) {
              addCorrectLetters(letterState.letter)
            } else if (answerWord.indexOf(letterState.letter) !== -1) {
              addPresentLetters(letterState.letter)
            } else {
              addAbsentLetters(letterState.letter)
            }
          })

          // 各管理用データの更新
          setAnsweredCount(prevState => prevState + 1)
          setLetterCount(0)
          setChekingState(false)
        })
      } else {
        // 入力文字列が5文字未満の時のアニメーションなり、メッセージなり
        setLetterRowStates((prevState) => {
          const copyForUpdate = prevState.slice()
          copyForUpdate[answeredCount].state = 'shake'
          return copyForUpdate
        })
        setTimeout(() => {
          setLetterRowStates((prevState) => {
            const copyForUpdate = prevState.slice()
            copyForUpdate[answeredCount].state = ''
            return copyForUpdate
          })
        }, 500);
        setMessage('Not enough letters')
      }
    }
  }
}

コンポーネント

最後にコンポーネントです。

ボード(5x6の文字列入力枠)

board.tsx
import LettersRow from './letters-row'

type Props = {
  letterRowStates: {
    state: string;
    letterStates: {
      state: string;
      letter: string;
    }[];
  }[];
}

const Board = (props: Props) => {
  return (
    <div className="board">
      {props.letterRowStates.map((letterRowState, i) =>
        <LettersRow
          key={i}
          state={letterRowState.state}
          letterStates={letterRowState.letterStates}
        />
      )}
    </div>
  )
}

export default Board

文字列行

letters-row.tsx
import LetterTile from './letter-tile'

type Props = {
  state: string
  letterStates: {
    state: string
    letter: string
  }[]
}

const LettersRow = (props: Props) => {
  return (
    // shakeクラスを付与するとブルっと震えるアニメーション
    <div className={`letters-row ${props.state}`}>
      {props.letterStates.map((letterState, i) =>
        <LetterTile key={i} letter={letterState.letter} state={letterState.state} />
      )}
    </div>
  )
}

export default LettersRow

文字タイル

letter-tile.tsx
type Props = {
  letter: string
  state: string
}

const LetterTile = (props: Props) => {
  return (
    <div className={`letter-tile ${props.state}`}>
      {props.letter}
    </div>
  )
}

export default LetterTile

キーボード

keyboard.tsx
import EnterKey from './enter-key'
import DeleteKey from './delete-key'
import LetterKey from './letter-key'

type Props = {
  addLetter: Function
  deleteLetter: Function
  answer: Function
  correctLetters: string[]
  presentLetters: string[]
  absentLetters: string[]
}

const LetterKeyboard = (props: Props) => {
  const keyboardLetters: string[][] = [
    ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
    ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'],
    ['Z', 'X', 'C', 'V', 'B', 'N', 'M'],
  ]

  type LetterWithStatus = {
    status: string
    letter: string
  }[][]

  const keyboardLettersWithStatus: LetterWithStatus = keyboardLetters.map((letters) => {
    return letters.map((letter) => {
      let status: string = ''
      if (props.correctLetters.includes(letter)) {
        status = 'correct'
      } else if (props.presentLetters.includes(letter)) {
        status = 'present'
      } else if (props.absentLetters.includes(letter)) {
        status = 'absent'
      }
      return { letter, status }
    })
  })

  return (
    <div id="keyboard">
      <div className="row">
        {keyboardLettersWithStatus[0].map((letterWithStatus, i) =>
          <LetterKey
            key={i}
            letter={letterWithStatus.letter}
            status={letterWithStatus.status}
            addLetter={props.addLetter}
          />
        )}
      </div>
      <div className="row">
        <div className="spacer half"></div>
        {keyboardLettersWithStatus[1].map((letterWithStatus, i) =>
          <LetterKey
            key={i}
            letter={letterWithStatus.letter}
            status={letterWithStatus.status}
            addLetter={props.addLetter}
          />
        )}
        <div className="spacer half"></div>
      </div>
      <div className="row">
        <EnterKey answer={props.answer} />
        {keyboardLettersWithStatus[2].map((letterWithStatus, i) =>
          <LetterKey
            key={i}
            letter={letterWithStatus.letter}
            status={letterWithStatus.status}
            addLetter={props.addLetter}
          />
        )}
        <DeleteKey deleteLetter={props.deleteLetter} />
      </div>
    </div>
  )
}

export default LetterKeyboard

文字列キー

letter-key.tsx
type Props = {
  letter: string
  status: string
  addLetter: Function
}

const Letterkey = (props: Props) => {
  return (
    <div className={`key ${props.status}`} onClick={() => props.addLetter(props.letter)}>
      { props.letter }
    </div>
  )
}

export default Letterkey

エンターキー

enter-key.tsx
type Props = {
  answer: Function
}

const Enter = (props: Props) => {
  return (
    <div className="one-and-a-half key" onClick={() => props.answer()}>enter</div>
  )
}

export default Enter

デリートキー

delete-key.tsx
import deleteIcon from '../delete-icon.svg'

type Props = {
  deleteLetter: Function
}

const DeleteKey = (props: Props) => {
  return (
    <div className="one-and-a-half key" onClick={ () => props.deleteLetter() }>
      <img src={deleteIcon} alt="delete-key" />
    </div>
  )
}

export default DeleteKey

Discussion