ReactだけでHit&Blow作ってみた
いま学習中のReactを使ってHit&Blowというゲームを作りました。
開発にあたって、今回の実装では、useState
とuseEffect
を活用し、アプリケーションの状態管理と副作用の制御を行う良い練習になったので、Reactを学習中の方にはけっこうオススメです。
Hit&Blowとは
「Hit and Blow」(ヒット・アンド・ブロー)は、マスターマインドまたはヌメロとしても知られる、数学的な推理ゲームです。このゲームは、お題の数字をプレイヤーが当てるゲームです。
ゲームのルールは以下の通りです:
- 1人のプレイヤー(コンピューターまたは他のプレイヤー)がお題の数字を選びます。この数字は通常、4桁の数字です。重複する数字は含まれません。
- 他のプレイヤーは、4桁の数字の推測を行います。これを「guess」と呼びます。
- お題の数字とguessを比較し、2つの情報を返します。
Hitは、数字とその位置が両方一致の結果です。
Blowは、数字は正しいが位置が違う結果です。 - プレイヤーはこれらの情報を元に、新しいguessを提供し、ゲームを続けます。
- ゲームは、プレイヤーがお題の数字を当てると終了です。
実装してみた
コード
HitBlow.jsx
import React from 'react';
import { useEffect, useState } from "react";
import { InputBlock } from "~/shop_src/modules/hitblow/inputBlock";
import { NumberBlock } from "~/shop_src/modules/hitblow/numberBlock";
import { HitAndBlow } from "~/shop_src/modules/hitblow/hitAndBlow";
import { makeAnswer } from "~/shop_src/modules/hitblow/answer";
import styled from 'styled-components';
import { Button } from '@material-ui/core';
const HitBlow = () => {
const [userAnswer, setUserAnswer] = useState(["", "", "", ""]);
const [correctAnswer, setCollectAnswer] = useState([]);
const [activeBlock, setActiveBlock] = useState(0);
const [answerHistories, setAnswerHistories] = useState([]);
const [isGameReset, setIsGameReset] = useState(false); // 新しいゲームを開始したかどうかを追跡
const [blockStatus, setBlockStatus] = useState(Array(10).fill('normal'));
useEffect(() => {
if (isGameReset) {
setBlockStatus(Array(10).fill('normal'))
setCollectAnswer(makeAnswer()); // ゲームリセット時のみ実行
setIsGameReset(false)
}
}, [isGameReset]);
const startNewGame = () => {
setUserAnswer(["", "", "", ""]);
setActiveBlock(0);
setAnswerHistories([]);
setIsGameReset(true);
};
const onInputAnswer = (num) => {
setUserAnswer(
userAnswer.map((answer, index) =>
index === activeBlock ? num : answer
)
);
if (activeBlock < 3) {
setActiveBlock(() => activeBlock + 1);
}
};
const onSetActiveBlock = (num) => {
setActiveBlock(num);
};
const onCheckAnswer = () => {
let hitAndBlow = {
answer: userAnswer.join(""),
hit: 0,
blow: 0
};
let checkAnswer = userAnswer.map(element => element.toString());
let newBlockStatus = Array(10).fill('normal');
userAnswer.forEach((answer, index) => {
checkAnswer = userAnswer.map(element => element.toString());
if (checkAnswer.indexOf(correctAnswer[index]) !== -1) {
if (correctAnswer[index] == answer) {
hitAndBlow.hit++;
newBlockStatus[correctAnswer[index]] = 'hit';
} else {
hitAndBlow.blow++;
newBlockStatus[correctAnswer[index]] = 'blow';
}
}
});
setBlockStatus(newBlockStatus);
setAnswerHistories([hitAndBlow, ...answerHistories]);
setUserAnswer(["", "", "", ""]);
setActiveBlock(0);
};
useEffect(() => {
startNewGame();
}, []);
return (
<StyledWrapper>
<Button
color="primary"
variant="outlined"
className="reset-button"
onClick={startNewGame} >リセットゲーム</Button>
<InputBlock
numbers={userAnswer}
activeBlock={activeBlock}
setActiveBlock={onSetActiveBlock}
checkAnswer={onCheckAnswer}
/>
<div style={{ display: "flex", textAlign: "center", justifyContent: "center", marginLeft: "100px" }}>
<div style={{ width: "30%", marginTop: "5px" }}>
<NumberBlock
onInputNumber={onInputAnswer}
onInputPrev={() => {
onSetActiveBlock(activeBlock - 1);
}}
onInputNext={() => {
onSetActiveBlock(activeBlock + 1);
}}
onInputClear={() => {
setUserAnswer(["", "", "", ""]);
setActiveBlock(0);
}}
userAnswer={userAnswer}
blockStatus={blockStatus}
/>
</div>
<div>
<HitAndBlow
clickNewGame={startNewGame}
answerHistories={answerHistories}
/>
</div>
</div>
</StyledWrapper>
);
}
export default HitBlow;
const StyledWrapper = styled.div`
max-width: 1000px;
margin: 80px auto;
text-align: center;
.reset-game{
text-align: center;
}
`
answer.js
export const makeAnswer = () => {
const fourDigitNumber = [];
console.log(fourDigitNumber)
while (fourDigitNumber.length < 4) {
const number = String(Math.floor(Math.random() * 10));
if (!fourDigitNumber.includes(number)) {
fourDigitNumber.push(number);
}
}
return fourDigitNumber;
};
inputBlock.js
import React from "react";
import { Button } from '@material-ui/core';
export const InputBlock = (props) => {
const numWrapper = {
display: "flex",
justifyContent: "center",
margin: "40px auto"
};
const numBlock = {
width: "40px",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid",
cursor: "pointer"
};
const checkFilledAnswer = () => {
return props.numbers.some((num) => num === "");
};
return (
<div style={numWrapper}>
{props.numbers.map((num, index) => {
return (
<div
style={{
...numBlock,
backgroundColor: props.activeBlock === index ? "lightblue" : "initial"
}}
key={index}
onClick={() => props.setActiveBlock(index)}
>
{num}
</div>
);
})}
{!checkFilledAnswer() && (
<Button
variant="outlined"
className="reset-button"
onClick={props.checkAnswer}
style={{ marginLeft: "10px" }}>
回答!
</Button>
)}
</div>
);
};
numberBlock.js
import React from "react";
export const NumberBlock = (props) => {
const numberAry = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const wrapper = {
display: "flex",
flexWrap: "wrap",
width: "200px"
};
const item = {
width: "50px",
height: "50px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid",
padding: "5px",
cursor: "pointer"
};
const hitItem = {
...item,
backgroundColor: '#B3DE69',
};
const blowItem = {
...item,
backgroundColor: '#FFF27B',
};
const selectedItem = {
...item,
backgroundColor: "gray",
pointerEvents: "none"
};
return (
<div style={wrapper}>
<div style={item} onClick={() => props.onInputPrev()}>
←
</div>
<div style={item} onClick={() => props.onInputNext()}>
→
</div>
<div style={item} onClick={() => props.onInputClear()}>
C
</div>
{numberAry.map((num, index) => {
const blockStyle = props.blockStatus[num] === 'normal' ? item : props.blockStatus[num] === 'hit' ? hitItem : blowItem;
return (
<div
key={index}
style={props.userAnswer.includes(String(num)) ? selectedItem : blockStyle}
onClick={() => {
props.onInputNumber(num);
}}
>
{num}
</div>
);
})}
</div>
);
};
hitandblow.js
import React,{ useEffect, useState } from "react";
import { Button } from '@material-ui/core';
export const HitAndBlow = (props) => {
const [isCorrect, setIsCorrect] = useState(false);
useEffect(() => {
if (props.answerHistories.length > 0) {
setIsCorrect(() => {
if (props.answerHistories[0].hit === 4) {
return true;
}
return false;
});
} else {
setIsCorrect(false);
}
}, [props]);
return (
<>
<div
style={{
width: "200px",
color: "#FA7482",
fontSize: "30px",
fontWeight: "bold",
margin: "10px"
}}
>
{isCorrect && "おめでとう"}
</div>
{isCorrect &&
<Button
color="default"
variant="outlined"
onClick={props.clickNewGame}
style={{margin: "20px auto"}}>
もう一度する</Button>}
<div>
{props.answerHistories.map((history, index) => {
return (
<div key={index}>
Answer: {history.answer} Hit: {history.hit} Blow: {history.blow}
</div>
);
})}
</div>
</>
);
};
実際にやってみました^^
苦戦したところ
最初実装した時に、HitBlow.jsx
内で回答したい数字を選択するたびにお題の生成処理(makeAnswer()
)が走ってしまったため、初回マウント時とゲームリセット時のみ生成処理が走るようにするのに苦戦しました。
const [isGameReset, setIsGameReset] = useState(false);
で新しいゲームを開始したかどうかを監視し、isGameReset
の値がtrueの場合のみmakeAnswer()
を実行することで、初回マウント時とゲームリセット時のみ生成処理が走るようにすることができました。
変更前
const HitBlow = () => {
const [userAnswer, setUserAnswer] = useState(["", "", "", ""]);
const [correctAnswer, setCollectAnswer] = useState(makeAnswer());
const [activeBlock, setActiveBlock] = useState(0);
const [answerHistories, setAnswerHistories] = useState([]);
useEffect(() => {
setCollectAnswer(makeAnswer());
}, []);
const startNewGame = () => {
setUserAnswer(["", "", "", ""]);
setCollectAnswer(makeAnswer());
setActiveBlock(0);
setAnswerHistories([]);
};
変更後
const HitBlow = () => {
const [userAnswer, setUserAnswer] = useState(["", "", "", ""]);
// const [correctAnswer, setCollectAnswer] = useState(makeAnswer());
const [correctAnswer, setCollectAnswer] = useState([]);
const [activeBlock, setActiveBlock] = useState(0);
const [answerHistories, setAnswerHistories] = useState([]);
const [isGameReset, setIsGameReset] = useState(false); // 新しいゲームを開始したかどうかを追跡
useEffect(() => {
if (isGameReset) {
setCollectAnswer(makeAnswer()); // ゲームリセット時のみ実行
setIsGameReset(false)
}
}, [isGameReset]);
const startNewGame = () => {
setUserAnswer(["", "", "", ""]);
setActiveBlock(0);
setAnswerHistories([]);
setIsGameReset(true);
};
まとめ
普段の業務では機能開発やデザインの修正などの開発が多く、このようなゲームを作る機会がなかったため、作っていて面白かったです。今回は数字当てゲームとして作成しましたが、一時期流行ったWordle, ポケモンWordleなどのようなゲームを応用して作ったり、英単語や古文などの自分だけの単語帳クイズとして応用版を作ってもおもしろそうだなと思います。
Discussion
失礼します。
逆に「初期値を設定する」「ボタンを押したことをきっかけに呼び出される処理」を書くのには useEffect は適していません。なので、useEffect を書かずに実装する道を選ぶとラクになると思います。
ほかにも、ja.react.dev には 「React の挙動を "何となく"で済ませず、手に取るように正確に知る」ことができる情報が詰まっているのでオススメです。
Honey32さん
ご丁寧な解説と修正をありがとうございます。
自分でも何か違う気がすると思っていたところなので、いただいた修正を見て納得しました。
ありがとうございます🙇♀️🙇♀️