개발/개발로그

[React] 간단한 성냥퍼즐 웹 서비스 만들기 8일차 (게임 타입, 횟수 제한 구현)

seungho-dev 2024. 12. 5. 18:28

오늘은 게임타입에 따라 UI 변경과 횟수를 제한하는 시스템을 만들어 보려고 한다.

이게 끝나면 핵심 기능 구현파트 중 마지막인 정답 검증 시스템만 남았다!

 

자 그럼 오늘도 렛츠고고 ~!

 

개인적인 공부를 위해 작성하는 블로그입니다. 혹시라도 잘못되거나 부족한 부분이 있다면 댓글로 알려주시면 감사하겠습니다.

 

💡 핵심 기능 구현

  • 성냥개비 드래그 & 드롭 (완료)
  • 성냥개비 회전 (완료)
  • Match 컴포넌트 분리, History 기능 (완료)
  • Undo, Redo, Remove (완료)
  • 게임타입 및 횟수 제한 (오늘 할 것)
  • 퍼즐 정답 검증 시스템

💁🏻 횟수 제한 구현

보통 성냥퍼즐에는 크게 옮기는 유형지우는 유형 두 가지가 있는데 유형에 따라 버튼 활성화와 횟수 카운팅 방식을 바꿔야 하기 때문에 전체적인 퍼즐객체에 gameTypelimit을 넣어 사용하기로 했다.

export default function PuzzleCanvas() {
  // 게임 초기 데이터
  const [gameData, setGameData] = useState(null) // JSON 데이터 저장
  const [matchsticks, setMatchsticks] = useState([])
  const [gameType, setGameType] = useState("")
  const [limit, setLimit] = useState(0)
  
  (생략)
  
    // JSON 데이터 로드
  useEffect(()=>{
    async function loadGameData() {
      try{
        const response = await fetch("/gameData.json")
        const data = await response.json()
        setGameData(data)
        setMatchsticks(data.initialState)
        setGameType(data.gameType)
        setLimit(data.limit)
        setHistory([data.matchsticks])
        setCurrentStep(0)
      } catch (error) {
        console.error("Failed to load game data: ", error)
      }
    }
    loadGameData()
  }, [])
  (생략)

 

위와 같이 gameData.json를 불러올 때 gameType과 limit을 가져오고 gameType에 따라 다음과 같이 remove 버튼과 Transformer의 rotateEnabled 및 draggable의 불린 값이 변경되도록 바꿔주었다. 

  return (
    <>
    <div className="flex flex-row gap-2 absolute z-10">
    <button className="bg-slate-200 rounded-md px-1 disabled:opacity-35" onClick={reset} disabled={currentStep == 0}>Reset</button>
      <button className="bg-slate-200 rounded-md px-1 disabled:opacity-35" onClick={undo} disabled={currentStep <= 0}>Undo</button>
      <button className="bg-slate-200 rounded-md px-1 disabled:opacity-35" onClick={redo} disabled={currentStep >= history.length - 1}>Redo</button>
      {gameType !== "move" ? <button className="bg-slate-200 rounded-md px-1 disabled:opacity-35" onClick={remove} disabled={selectedMatchstick == null} >Remove</button> : null}
      <button className="bg-slate-200 rounded-md px-1 disabled:opacity-35" onClick={null} disabled={currentStep === 0}>check</button>
    </div>
    (생략)
    <Layer>
            {matchsticks
              .filter((stick) => !stick.isDeleted)
              .map((stick) => (
              <Matchstick
                key={stick.id}
                stick={stick}
                image={imageRef.current}
                isSelected={stick.id === selectedMatchstick}
                onSelect={handleSelect}
                onDragEnd={handleDragEnd}
                onTransformEnd={handleRotateEnd}
                canMove={gameType === "move"} // move일때 움직임 활성화
              />
            ))}
            {/* Transformer */}
            <Transformer
              ref={transformerRef}
              rotationSnaps={[0, 90, 180, 270]} // 회전 스냅
              anchorSize={10} // 앵커 크기
              anchorCornerRadius={3}
              centeredScaling={true}
              resizeEnabled={false} // 크기 조정 비활성화
              rotateEnabled={gameType === "move"} // move 일때 회전 비활성화
            />
      </Layer>

 

👨🏻‍💻 이동 카운팅 및 상태 복원 구현

현재 히스토리같은 id의 움직임을 분리해서 기록하고 있어서 이동된 성냥을 카운팅 하려면 하나의 성냥개비에 대한 움직임을 알아야 할 필요가 있다. 2가지 방법이 떠올랐는데 첫 번째는 스틱구조체에 isMoved라는 불린 값을 두고 움직였을 때 true로 바꾸어주고 이를 카운팅 하는 방식과 history에서 id값을 set() 자료형에 저장해 하나의 아이디에 움직일 때마다 카운팅 하여 저장하고 전체 length를 통해 성냥개비 이동을 카운팅 하는 방식이다. 그중 우선 2번째 방법으로 해봤다.

우선 moveCounts 라는 set {}을 만들어 주고 undo()일 때 카운터를 확인해 -1을 하고 0이면 delete 되도록 했고 redo()일 때는 기존에 움직인 성냥개비라면 이전 카운팅에 +1을 해주고 없다면 1로 추가해 주었다.

  const [moveCounts, setMoveCounts] = useState({})

 

  const undo = () => {
    if (currentStep >= 0) {
      const { id, before } = history[currentStep]

      setMatchsticks((prev) =>
        prev.map((stick) =>
          stick.id === id ? {...stick , ...before} : stick
        )
      )

      setMoveCounts((prev) => {
        const newCounts = { ...prev };
        newCounts[id] -= 1;
        if (newCounts[id] === 0) {
          delete newCounts[id]; // 카운트가 0이면 삭제
        }
        return newCounts;
      });

      setCurrentStep(currentStep - 1); // 이전 단계로 이동
    }
  }
  const redo = () => {
    if (currentStep < history.length - 1) {
      const { id, after } = history[currentStep + 1]

      setMatchsticks((prev) =>
        prev.map((stick) =>
          stick.id === id ? {...stick, ...after} : stick
        )
      )

      setMoveCounts((prev) => ({
        ...prev,
        [id]: (prev[id] || 0) + 1,
      }));

      setCurrentStep(currentStep + 1)
    }
  }

 

그리고 마찬가지로 회전하거나 움직일 때 saveState 함수에서 redo()와 같은 방식으로 이동 카운트를 업데이트해주었다.

  const saveState = (type, id, before, after) => {
    const newHistory = history.slice(0, currentStep + 1) // 현재 상태 이후의 기록 삭제
    newHistory.push({
      type,
      id,
      before, 
      after,
    }) // 새로운 상태 저장

    // 이동 카운트 업데이트
    const updatedMoveCounts = {
      ...moveCounts,
      [id]: (moveCounts[id] || 0) + 1,
    };

    setMoveCounts(updatedMoveCounts);
    setHistory(newHistory)
    setCurrentStep(newHistory.length - 1) // 현재 상태를 마지막으로 이동
  }

 

이제 handleDragEnd(), handleRoateEnd(), handleRemove()에서 Object.keys(moveCounts). length 가 limit를 넘겼을 때

상태를 복원시키는 함수를 만들었다. remove에서는 필요가 없어 생략했다.

  // 상태 복원 함수
  const restorePreviousPosition = (id) => {
    const findOne =  gameData.initialState.find((stick) =>  stick.id === id)

    setMatchsticks((prev) =>
      prev.map((stick) =>
        stick.id === id ? {id, x: findOne.x, y: findOne.y, angle: findOne.angle } : stick
      )
    )
  }

 

const handleDragEnd = (e, id) => {
    // 드래그 완료 후 위치 업데이트
    if (gameType === "remove") {
      alert("현재 상태에서는 이동이 불가능합니다.");
      return;
    }
    // 정수로 반올림
    const x = Math.round(e.target.x())
    const y = Math.round(e.target.y())
    const newPosition = { x, y }

    const findOne = matchsticks.find((stick) =>  stick.id === id)
    
    const currentMoveCount = Object.keys(moveCounts).length;
    // 이동 제한 확인
    if (currentMoveCount >= limit) {
      if (!moveCounts[id]){
        alert('이동 제한에 도달했습니다.')
        restorePreviousPosition(id)
        return;
      }
    }
    // 상태 업데이트
    setMatchsticks((prev) =>
      prev.map((stick) =>
        stick.id === id ? { ...stick, ...newPosition } : stick
      )
    )
    const before = {x: findOne.x, y: findOne.y }
    const after = { x, y }
    
    saveState("move", id, before, after)
  }

  const handleRotateEnd = (newAngle, id) => {
    // 정수로 반올림
    const roundedAngle = Math.round(newAngle)
    const findOne = matchsticks.find((stick) =>  stick.id === id)
    
    const currentMoveCount = Object.keys(moveCounts).length;
    // 이동 제한 확인
    
    if (currentMoveCount >= limit) {
      if (!moveCounts[id]){
        alert('이동 제한에 도달했습니다.')
        restorePreviousPosition(id)
        return;
      }
    }
    // 회전 완료 후 각도 업데이트
    setMatchsticks((prev) =>
      prev.map((stick) => 
        stick.id === id ? { ...stick, angle: roundedAngle} : stick
      )
    )
    // 상태 저장
    const before = {angle: findOne.angle}
    const after = {angle: newAngle}
    saveState("rotate", id, before, after)
  }

 

 

👩🏻‍🚀 문제 발생: 분명히 상태는 복원되었는데 화면에는 이전 값이 표시됨

다음과 같이 콘솔창에서 확인결과 초기값 좌표로 돌아갔지만 화면에는 그대로 표시되는 문제가 발생했다.

문제의 원인은 konva.js의 내부 상태와 react 상태의 불일치로 보인다. konva가 위치, 회전 값 등을 직접 업데이트하므로 react 상태를 받아와 랜더링해야 하지만 그전에 konva가 업데이트한 값의 이전 값을 유지하여 이러한 문제가 발생한 것 같다. 수동으로 matchstick이 업데이트되었을 때 useEffect()를 사용해 batchDraw를 호출하여 레이어를 강제로 갱신해 주어 문제를 해결하였다.

 

  useEffect(() => {
    matchsticks.forEach((stick) => {
      const node = stageRef.current.findOne(`#${stick.id}`);
      if (node) {
        node.position({ x: stick.x, y: stick.y }) // React 상태에 따라 노드 위치 설정
        node.rotation(stick.angle);
      }
    });
    stageRef.current.batchDraw(); // 전체 레이어 갱신
  }, [matchsticks]);

 

 



 

생각보다 시간이 오래 걸렸다. 코드가 점점 길어져 정리하기가 힘들어지는데 우선 빠르게 정답 검증까지

구현하고 코드를 정리해야겠다.

 

 

++ 추가

따로 useEffect()에서 업데이트하지 않고 생태 복원 함수에서 처리해도 될 것 같아서 해보니 똑같이 동작했다.
정리하면 undo()나 redo()에서 상태변경 이후 화면에 그려지는 것과 다르게 konva에서 제공하는 dragable이벤트로 화면에 실시간으로 포지션을 이동해서 그려주고 있는데 dragEnd 이벤트에서 이전 값으로 복원하였지만 화면상에는 drag이벤트가 마지막으로 업데이트 되어 이전 값으로 화면에 그려진 것으로 보인다.
  // 상태 복원 함수
  const restorePreviousPosition = (id) => {
    const findOne =  gameData.initialState.find((stick) =>  stick.id === id)

    setMatchsticks((prev) =>
      prev.map((stick) =>
        stick.id === id ? {...findOne} : stick
      )
    )
    const node = stageRef.current.findOne(`#${id}`);
    if (node) {
      node.position({ x: findOne.x, y: findOne.y }); // React 상태를 Konva 노드에 반영
      node.rotation(findOne.angle);
      node.getLayer().batchDraw(); // 화면 강제 갱신
    }
  }