💬
React + TypeScriptでWordle作ってみた
動機
最近Reactを勉強し始め、Gatsbyでブログを作成したのですが、
あんまりReactの機能使ってないぞ? + GatsbyにTypeScript導入したい
ということで、勉強がてら巷で話題のWordleを作ってみました。
とりあえず形にはなったので、振り返りの意味も込めて書いていきます。
ちなみに正しい単語かどうかのチェック機能は実装していません。
実際のコード
環境構築
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