💛

ReactだけでHit&Blow作ってみた

2023/10/23に公開
2

いま学習中のReactを使ってHit&Blowというゲームを作りました。
開発にあたって、今回の実装では、useStateuseEffectを活用し、アプリケーションの状態管理と副作用の制御を行う良い練習になったので、Reactを学習中の方にはけっこうオススメです。

Hit&Blowとは

「Hit and Blow」(ヒット・アンド・ブロー)は、マスターマインドまたはヌメロとしても知られる、数学的な推理ゲームです。このゲームは、お題の数字をプレイヤーが当てるゲームです。
ゲームのルールは以下の通りです:

  1. 1人のプレイヤー(コンピューターまたは他のプレイヤー)がお題の数字を選びます。この数字は通常、4桁の数字です。重複する数字は含まれません。
  2. 他のプレイヤーは、4桁の数字の推測を行います。これを「guess」と呼びます。
  3. お題の数字とguessを比較し、2つの情報を返します。
    Hitは、数字とその位置が両方一致の結果です。
    Blowは、数字は正しいが位置が違う結果です。
  4. プレイヤーはこれらの情報を元に、新しいguessを提供し、ゲームを続けます。
  5. ゲームは、プレイヤーがお題の数字を当てると終了です。

実装してみた

コード

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

Honey32Honey32

失礼します。

逆に「初期値を設定する」「ボタンを押したことをきっかけに呼び出される処理」を書くのには useEffect は適していません。なので、useEffect を書かずに実装する道を選ぶとラクになると思います。

https://ja.react.dev/learn/render-and-commit

ほかにも、ja.react.dev には 「React の挙動を "何となく"で済ませず、手に取るように正確に知る」ことができる情報が詰まっているのでオススメです。

  const HitBlow = () => {
    const [userAnswer, setUserAnswer] = useState(["", "", "", ""]);
-   const [correctAnswer, setCollectAnswer] = useState([]);
+   const [correctAnswer, setCollectAnswer] = useState(() => makeAnswer());
+     // これだけで、初期値がセットできる
    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);
+     // ゲームをリセットする
+     setBlockStatus(Array(10).fill('normal'))
+     setCollectAnswer(makeAnswer());
    };

    // 中略

-   useEffect(() => {
-     startNewGame();
-   }, []);
+  // useState(初期値) ですでに初期化されているので、不要

  // 以下略
mithuamimithuami

Honey32さん

ご丁寧な解説と修正をありがとうございます。
自分でも何か違う気がすると思っていたところなので、いただいた修正を見て納得しました。

ありがとうございます🙇‍♀️🙇‍♀️