[React 프로젝트] 숫자야구 프로젝트 - 숫자야구 구현

Hyuk·2023년 1월 22일
0

PlatGround 컴포넌트에는 각각의 기능을 하는 컴포넌트가 있다.

  • 타이머와 목숨을 나타내는 Dashboard 컴포넌트
  • 사용자가 정답을 입력토록 하는 Userpanel 컴포넌트
  • 결과를 화면에 보여주는 Scoredboard 컴포넌트

PlayGround

import React, { useState, useEffect, useCallback } from 'react'
import { useHistory } from 'react-router-dom'
import { useCookies } from 'react-cookie';
import axios from 'axios'
import Dashboard from './Dashboard'
import ScoredBoard from './Scoredboard'
import UserPanel from './UserPanel'
import RoundInfoList from './RoundInfoList'
import './index.css'

const maxLifeTimeSeconds = 59

const PlayGround = (location) => {
  const history = useHistory()
  const [cookies] = useCookies(['data']);
  const [level] = useState(location.location.level)
  const [answer, setAnswer] = useState('')
  const [answerLength] = useState(level)
  const [lifetimeSeconds, setLifetimeSeconds] = useState(maxLifeTimeSeconds)
  const [life, setLife] = useState(15)
  const [strike, setStrike] = useState(0)
  const [ball, setBall] = useState(0)
  const [out, setOut] = useState(0)
  const [roundHistories, setRoundHistories] = useState([])
  const scoreSum = ((level * life) * 10) + 10

  useEffect(() => {
    const countdown = setInterval(() => {
      setLifetimeSeconds(prevState => prevState - 1)
    }, 1000)

    const generatedAnswer = generateAnswer()
    console.log('generated answer:', generatedAnswer)
    setAnswer(generatedAnswer)
    return () => clearInterval(countdown)
    
  }, [])

  useEffect(() => {
    if (lifetimeSeconds <= 0) {
      decreaseLife()
      resetLifeTime()
    }
  }, [lifetimeSeconds])

  useEffect(() => {
    if (life <= 0) {
      if (window.confirm("정답은" + answer + " 입니다! 게임을 한판 더 하시겠습니까?")) {
        history.push('/')
      }
      else {
        history.push({
          pathname: '/End',
          state: {
            answer: answer,
            scoreSum: scoreSum,
          },
          nickname: cookies.data?.nickname
        })
        window.location.reload()
      }
    }
  }, [life])

  useEffect(() => {
    if (strike === level) {
      axios.post('http://localhost:65100/gameEnd', {
        id: cookies.data?.id,
        scoreSum: scoreSum,
        answer: answer,
      })
      alert("정답입니다!!!")
      history.push({
        pathname: '/End',
        state: {
          answer: answer,
          scoreSum: scoreSum,
        },
        nickname: cookies.data?.nickname
      })
      window.location.reload()
    }
  }, [life])

  const generateAnswer = useCallback(() => {
    const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    const answer = []
    for (let answerIndex = 0; answerIndex < answerLength; answerIndex++) {
      let select = Math.floor(Math.random() * arr.length)
      answer[answerIndex] = arr.splice(select, 1)[0]
    }

    return answer.join('')
  }, [])

  const decreaseLife = useCallback(() => {
    setLife(life - 1)
  }, [life])

  const resetLifeTime = useCallback(() => {
    setLifetimeSeconds(maxLifeTimeSeconds)
  }, [])

  const addRoundHistory = useCallback((result) => {
    setRoundHistories(roundHistories.concat(result))
  }, [roundHistories])

  const setScore = useCallback((strike, ball, out) => {
    setStrike(strike)
    setBall(ball)
    setOut(out)
  }, [])

  const getScore = (guess, answer) => {
    let strike = 0, ball = 0, out = 0

    for (let answerIndex = 0; answerIndex < answer.length; answerIndex++) {
      if (answer[answerIndex] === guess[answerIndex]) {
        strike += 1
      }
      else if (guess.includes(answer[answerIndex])) {
        ball += 1
      }
      else {
        out += 1
      }
    }

    return { strike, ball, out }
  }

  return (
    <div className="container flex-row">
      <div className="game-section container">
        <Dashboard
          life={life}
          lifetimeSeconds={lifetimeSeconds}
        />
        <UserPanel
          answer={answer}
          level={level}
          getScore={getScore}
          setScore={setScore}
          decreaseLife={decreaseLife}
          resetLifeTime={resetLifeTime}
          addRoundHistory={addRoundHistory}
        />
        <ScoredBoard strike={strike} ball={ball} out={out} level={level} />
      </div>
      <div className="info-list">
        <div className='info-header'>
          <div className='hed-round'>회차</div>
          <div className='hed-trial'>입력</div>
          <div className='hed-result'>결과</div>
        </div>
        <RoundInfoList rounds={roundHistories} />
      </div>
    </div>
  )
}

export default PlayGround

우선, 해당 컴포넌트에서는 게임을 관장하기 때문에 정답값을 가지고 있고 이를 각 자식 컴포넌트에 넘겨주는 역할을 하고있다. 각 코드의 역할은 주석으로 달아놓았다. 코드는 다음과 같다.

...
const [level] = useState(location.location.level)
const [answer, setAnswer] = useState('')
const [answerLength] = useState(level)
...

...
useEffect(() => {
  ...

  const generatedAnswer = generateAnswer()
  console.log('generated answer:', generatedAnswer)
	// 함수를 통해 랜덤으로 추출한 값을 변수로 지정, 그리고 그걸 정답으로 지정
  setAnswer(generatedAnswer)
  
}, [])
...

...
// 정답을 랜덤으로 추출
const generateAnswer = useMemo (() => () => {
    const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    const answer = []
    for (let answerIndex = 0; answerIndex < answerLength; answerIndex++) {
      let select = Math.floor(Math.random() * arr.length)
      answer[answerIndex] = arr.splice(select, 1)[0]
    }

    return answer.join('')  // 배열의 값들을 .join('')을 통해 문자열로 변경
  }, [])
...

// 부모인 Game 컴포넌트에서 자식컴포넌트에 값을 넘겨주는 코드
...
<UserPanel answer={answer} />
...

해당 컴포넌트에선 타이머 기능을 구현하고 타이머에 따라서 Time이나 Life를 다룬다.

resetLifeTime 함수에서 maxLifeTimeSeconds 라는 변수를 받아서 사용했는데 이는 위의 PlayGround 컴포넌트 전체코드를 참고해보면 컴포넌트 밖에서 선언하고 있다. 이는 maxLifeTimeSeconds가 재선언 대상이 아니여서 그렇다. 컴포넌트 안에서 지정해두면 리렌더링 될때 다시 불러와지기 때문에 최적화에 방해된다.

useEffectsetInterval함수를 사용함을 알 수 있는데 다음 단락에 설명하겠다.

..
useEffect(() => {
  const countdown = setInterval(() => {
    setLifetimeSeconds(prevState => prevState - 1)
  }, 1000)
  return () => clearInterval(countdown)
}, [])

useEffect(() => {
    if (life <= 0) {
        if (window.confirm("정답은" + answer + " 입니다! 게임을 한판 더 하시겠습니까?")) {
          history.go(0)
        }
        else {
          history.push('/End')
        }
    }
}, [life])

useEffect(() => {
  if (lifetimeSeconds <= 0) {
    decreaseLife()
    resetLifeTime()
  }
}, [lifetimeSeconds])

const decreaseLife = useCallback(() => {
  setLife(life - 1)
}, [life])

const resetLifeTime = useCallback(() => {
  setLifetimeSeconds(maxLifeTimeSeconds)
}, [])
...

useEffect

개요

useEffectHooks함수의 일종으로서, 클래스 컴포넌트에서의 componentDidMount, componentDidUpdate, componentWillUnmountuseEffect로 실행된다. 즉 컴포넌트가 마운트 될 때, 업데이트 될 때, 언마운트 될 때 특정작업을 처리하는 방법이다. 특히 업데이트 될때는 deps라는 배열에 값을 넣어줌으로써 관리할수 있다.

deps 배열을 비우게 된다면, 컴포넌트가 처음 나타날때만 useEffect 에 등록된 함수가 호출된다. 하지만 deps 배열에 특정 값을 넣게된다면 첫 마운트 때 호출이 되고, 지정한 값이 변할때에도 호출이 된다. 만약 deps 파라미터를 아예 생략한다면 컴포넌트가 리렌더링 될 때마다 호출이 된다.

참고로 리액트 컴포넌트는 기본적으로 부모 컴포넌트가 리렌더링되면 자식 컴포넌트 또한 리렌더링이 된다. 바뀐 내용이 없더라도,, 그래서 컴포넌트를 최적화 하는 과정에서 useEffectuseCallback 등을 적절히 사용하는 것이 중요하다.

요약

  • 해당 컴포넌트가 렌더링되었을 때, 실행해라.
  • deps (dependencies) 이 변경이 되면 실행해라.
  • useEffectreturn은 해당 컴포넌트가 소멸할 때 실행해라.

setTimeout과 setInterval 그리고 clearInterval

setTimeout은 특정 시간 이후, 단 한번만 특정 함수를 실행할때 사용된다. 이때 특정시간은 2번째 인자에 적혀진 시간을 뜻한다. 단위는 ms이고, 1000일 때 1초, 5000일 때 5초를 뜻한다.

// 5초 후, LifetimeSeconds의 값이 -1 됨.
useEffect(() => {
  const countdown = setTimeout(() => {
    setLifetimeSeconds(prevState => prevState - 1)
  }, 5000)
}, [])

setInterval함수는 특정 시간마다 특정 함수를 실행할때 사용된다. 이때 특정시간은 2번째 인자에 적혀진 시간을 뜻한다.
clearInterval은 현재 진행되고 있는 함수의 진행을 멈추는데 쓰인다. 특히 setInterval함수는 따로 지정해두지 않으면 계속 진행되기 때문에 clearInterval 함수를 통해 멈춰줘야 한다.

useEffect(() => {
    const countdown = setInterval(() => {
      setLifetimeSeconds(prevState => prevState - 1)
    }, 1000)  // 1초씩 LifeTimeSeconds가 -1 됨.
    return () => clearInterval(countdown) 
  }, [])   // return에 clearInterval을 적어줌으로써 해당 컴포넌트가 소멸할 때 시간을 멈추게함.

DashBoard

전체 코드는 다음과 같다.

// src/view/Game/DashBoard/index.js
import React from 'react'
import TimerImage from '../../../assets/Timer.png'
import HeartImage from '../../../assets/red_heart.png'
import './index.css'

const Dashboard = (props) => {

    const { lifetimeSeconds } = props;

    const containerStyles = {
        height: 20,
        width: '128px',
        backgroundColor: "#e0e0de",
        borderRadius: 50,
      }
    
      const fillerStyles = {
        height: '100%',
        width: `calc(100/60*+${lifetimeSeconds}%)`,
        backgroundColor: "#2563EB",
        borderRadius: 'inherit',
        textAlign: 'right'
      }
    
      const labelStyles = {
        padding: 5,
        color: 'black',
        fontSize: '12px'
      }

    return(
        <div className="dashboard-container">   
            <div>
                <div className="dashboard-item" style={{ padding: 4 }}>
                    <img alt={'타이머 이미지'} className="timer-Image" src={TimerImage} />
                    <div style={containerStyles}>
                        <div style={fillerStyles}>
                            <span style={labelStyles}>00:{lifetimeSeconds < 10 ? `0${lifetimeSeconds}` : lifetimeSeconds}</span>
                        </div>
                    </div>
                </div>
                <div className='progress-bar'>
                    <div className='loading-bar bg-blue-600 ...'></div>
                </div>
            </div>
            <div className="dashboard-item">
                <img alt={'하트 이미지'} className="heart-Image" src={HeartImage} />
                <h2>X {props.life}</h2>
            </div>
        </div>
    )
}

export default Dashboard

UserPanel

// src/view/Game/Userpanel/index.js
import axios from 'axios'
import React, { useCallback } from 'react'
import { useCookies } from 'react-cookie';
import './index.css'

const UserPanel = (props) => {

    const { answer, getScore, decreaseLife, resetLifeTime, addRoundHistory, setScore, level } = props
    const [cookies] = useCookies(['data']);

    const handleKeyDown = useCallback((event) => {
        const guess = event.target.value

        const checkIfDuplicateValue = (value) => {
            for (let valueIndex = 0; valueIndex < value.length; valueIndex++) {
                let focus = value[valueIndex]

                for (let randIndex = (valueIndex + 1); randIndex < value.length; randIndex++) {
                    if (value[randIndex].includes(focus)) {
                        return false
                    }
                }
            }

            return true
        }

        const checkIsProperLengthValue = (value) => value.length === answer.length

        if (event.key === 'Enter') {
            try {
                if (!checkIfDuplicateValue(guess)) {
                    throw Error('중복된 값을 제출할 수 없습니다.')
                }

                if (!checkIsProperLengthValue(guess)) {
                    throw Error(`${answer.length}자리의 숫자를 제출해야 합니다.`)
                }
            } catch (error) {
                alert(error)
                event.target.value = ''
                return
            }

            // 게임히스토리에 insert
            if (checkIfDuplicateValue && checkIsProperLengthValue) {
                axios.post('http://localhost:65100/game', {
                    id: cookies.data?.id,
                    guess: guess,
                    answer: answer,
                })
            }
            const { strike, ball, out } = getScore(guess, answer)
            
            setScore(strike, ball, out)
            decreaseLife()
            resetLifeTime()
            addRoundHistory({
                strike, ball, out, value: guess
            })
            event.target.value = ""
        }
    }, [answer, decreaseLife])

    const onUserGuessInput = useCallback((event) => {
        const maxLengthCheck = (event) => {
            if (event.target.value.length > event.target.maxLength) {
                event.target.value = event.target.value.slice(0, event.target.maxLength)
            }
        }
        
        return maxLengthCheck(event)
    }, [])

    return (
        <div className='userpanel-container'>
            <input
                className='user-input-box'
                type="number"
                maxLength={level}
                onKeyDown={handleKeyDown}
                onInput={onUserGuessInput} />
        </div>
    )
}

export default UserPanel

유저의 입력값 제한

정답의 자릿수 이상으로 입력했을 시 제한

우선, 유저의 값을 input태그로 받고 숫자만 입력토록, 그리고 정답의 자릿수만큼 입력할 수 있게하기 위해 input 에 여러 설정을 해놓았다. 먼저 type=number로 설정해 숫자만 입력케 했고, maxLength={level}와 함수를 통해 정답의 자릿수만 입력케 했다. 함수코드는 다음과 같다. 추가적으로 input 태그에 handleKeyDown 함수를 이용해서 값을 입력하게 했다. 이에 대한 자세한 얘기는 후술하겠다.

  • 참고로 inputtype=text로 잡고 maxLength={level} 하면 함수가 굳이 필요 없을 것 같지만, 이렇게 되면 글자 텍스트도 포함돼서 입력되기 때문에 이와같은 방법은 사용하지 않았다.
  • type=numbermaxLength가 적용 안되기때문에 maxLength를 사용하기 위해선 type=text으로 해야한다.
// src/view/Game/Userpanel/index.js
..
const onUserGuessInput = useCallback((event) => {
    const maxLengthCheck = (event) => {
        if (event.target.value.length > event.target.maxLength) {
            event.target.value = event.target.value.slice(0, event.target.maxLength)
        }
    }
    
    return maxLengthCheck(event)
}, [])
..

..
<input
  className='user-input-box'
  type="number"
  maxLength={level}
  onKeyDown={handleKeyDown}
  onInput={onUserGuessInput} 
/>
..

중복된 값을 적거나, 정답의 자릿수 이하로 입력했을 시 제한 - Enter를 눌렀을 때

다음으로는 input 태그에 유저가 입력할 값을 중복이 안되게 그리고 4자리 미만으로 입력했을 때 제한을 주는 함수를 만들어 보았다.

// src/view/Game/Userpanel/index.js
// 중복된 값이 없도록 입력했는지 판단해주는 함수
const checkIfDuplicateValue = (value) => {
    for (let valueIndex = 0; valueIndex < value.length; valueIndex++) {
        let focus = value[valueIndex]

        for (let randIndex = (valueIndex + 1); randIndex < value.length; randIndex++) {
            if (value[randIndex].includes(focus)) {
                return false
            }
        }
    }

    return true
}

// 정답의 자릿수만큼 입력했는지 판단해주는 함수
const checkIsProperLengthValue = (value) => value.length === answer.length

그리고 위의 함수를 통해 반환된 값으로 try ~ catch 문을 이용해 사용자의 입력을 판단했다.


try ~ catch 구문 (+throw, finally)

프로그램이 실행되는 동안 문제가 발생하면 프로그램이 자동으로 중단된다. 이럴 경우 프로그램이 대처할 수 있도록 처리하는 것이 예외처리다. 그중 try ~ catch ~ finally는 자바스크립트의 예외 처리 기법이다.

try문은 처리할 예외가 발생할지도 모를 코드 블록을 정의하고, 그 다음에 올 catch문은 try절에서 예외가 발생할 경우 호출되는 문장블록을, 그다음의 finally문은 예외여부와 관계없이 무조건 실행되는 문장을 의미한다. 그리고 throw문은 예외를 강제로 발생시키는 경우에 쓰인다.

실제 이번 프로젝트에서 쓰였던 코드를 예시로 들자. 정상이라면 try문은 아무런 문제 없이 블록의 시작부터 끝까지 진행된다. 그러다 예외가 발생하거나 예외를 필수적으로 만드는 throw를 만난다면 더이상 내려가지 않고 catch문으로 이동해서 예외를 처리한다. 다음의 코드에선 if문의 조건문에 따라서 throw를 만나고 그 throw는 해당 문구의 에러를 발생시킨다. 그리고 catch에서 alert의 방식으로 에러를 표시하고 차례대로 event.target.value를 처리한다.

..
try {
    if (!checkIfDuplicateValue(guess)) {
        throw Error('중복된 값을 제출할 수 없습니다.')
    }

    if (!checkIsProperLengthValue(guess)) {
        throw Error(`${answer.length}자리의 숫자를 제출해야 합니다.`)
    }
} catch (error) {
    alert(error)
    event.target.value = ''
    return
}
..

ScoredBoard

전체 코드는 다음과 같다.

// src/view/Game/ScoredBoard/index.js
import React from 'react'
import CircularSignal from './CircularSignal'
import ScoreText from './ScoreText'
import './index.css'

const ScoredBoard = (props) => {

    const { strike, ball, out, level } = props

    const scoreComponentMapping = [...Array(level).keys()].map((circularSignalIndex) => 
        <CircularSignal
            key={circularSignalIndex}
            isStrikeLightOn={strike > circularSignalIndex}
            isBallLightOn={ball > circularSignalIndex}
            isOutLightOn={out > circularSignalIndex}
        />)

    return (
        <div className="scored-board">
            <div className='score-text'>
                <ScoreText />
            </div>
            <div className="scroe-mapping-box">
                {scoreComponentMapping}
            </div>
        </div>
    )
}

export default ScoredBoard

map() 를 이용해서 다음과 같이 난이도에 따라 불의 개수를 변하게하고, 또한 유저가 입력한 값과 정답을 비교해서 들어올 불의 개수를 나타냈다.

map함수를 설명하자면, map함수는 일단 배열에 관한 내장함수이고 배열의 숫자(해당 컴포넌트에서는 level)에 따라 각 요소를 한번씩 불러 그 함수의 반환값으로 새로운 배열을 만드는 것이다.

즉, 예시로 [...Array(4).keys()]는 곧 [0,1,2,3]이고 [0,1,2,3].map()을 하면 circularIndex는 0,1,2,3이 순서대로 들어간다.

isStrikeLightOn이라는 값에도 circularIndex와 부모에서 받아온 strike값을 비교하는 식을 만들어주면된다. isStrikeLightOntrue 혹은 false로 반환된다

const { strike, ball, out, level } = props

const scoreComponentMapping = [...Array(level).keys()].map((circularSignalIndex) => 
  <CircularSignal
      key={circularSignalIndex}
      isStrikeLightOn={strike > circularSignalIndex}
      isBallLightOn={ball > circularSignalIndex}
      isOutLightOn={out > circularSignalIndex}
  />)
...

<div className="scroe-mapping-box">
    {scoreComponentMapping}
</div>
profile
프론트엔드 개발자

0개의 댓글