[로데시] 개발

[대학전쟁3] 양면빙고 웹 개발 09 - UI/UX 개선 및 기능 보완 본문

개인 개발/대학전쟁

[대학전쟁3] 양면빙고 웹 개발 09 - UI/UX 개선 및 기능 보완

로데시 2026. 2. 22. 16:25
반응형

 


 

0️⃣ 지난 번까지..

지난 글에서는 2인이 play할 수 있는 기능을 구현했다.
 
오늘은 이어서 UI/UX 개선 및 게임 로직 보완을 중심으로 프로젝트 전반을 리팩토링할 것이다.
 

 

 
☰   양면빙고 (double-sided-bingo) 개발 과정  
 

 

 

 

1️⃣ 게임 전 - 대기 및 설정 (Setup)

📌 메인 화면 CSS 적용
 
메인 화면
 
메인 화면의 UI 개선을 위해 CSS 스타일을 적용했다.
 

 

 
📌 초대 코드 복사
 
초대코드복사
 
초대코드복사
 
게임방 생성 후 현재 방의 초대 코드가 화면에 표시되도록 구성했다. 사용자가 코드를 클릭하면 자동 복사되며, 복사 되었음을 시각적으로 전달하기 위해 CSS의 @keyframes을 활용하여 초대 코드 크기가 축소 후 다시 되돌아오는 애니메이션을 추가했다. 이를 통해 플레이어 간 빠른 초대가 가능하도록 개선했다.
 

 

 
📌 3초 후 게임 시작
 
3초후게임시작
 
두 플레이어가 모두 입장하면 즉시 게임이 시작되는 대신 게임 시작 전 3초의 카운트다운을 거친 후 게임이 시작되도록 로직을 수정했다. 카운트다운은 3-2-1 숫자가 화면 중앙에 화면에 애니메이션으로 구성해 게임 시작 시점을 직관적으로 인지할 수 있도록 UX를 개선했다.
 

 

 
📌 입장 제한
 
입장제한
 
player 정원을 2명으로 제한해 방의 인원 정원이 초과된 경우에는 추가 입장이 불가하도록 입장 제한 로직을 구현했다.
 

 

 

 

2️⃣ 게임 중 - 진행 단계 (In-Game)

📌 시간 set
 
❓ 타일 리스트에서 뒤집기, 정렬 시 timer reset
 
시간set
 
현재는 playing 상태에서 모든 snapshot마다 타이머가 갱신되고 있다. 그 결과 타일 리스트 정렬 및 뒤집기와 같은 단순 UI 상호작용에도 시간이 갱신되는 문제가 발생했다.
 

 
💡 문제 해결 (Game.js)
 
시간set
 
기존에는 아래 조건문으로 인해 snapshot 변경 시마다 타이머가 초기화되는 문제가 발생했다. Game.js의 함수 applyRoomSnapshot 내부의 타이머 초기화 로직을 제거해 타이머가 턴 변경 시에만 reset되도록 수정했다.
- if (roomDoc.status === "playing" && !roomDoc.winner) {
-   setTimeLeft(TURN_LIMIT);
- } 

 

 

 
📌 turn 변경
 
❓ time out 시 turn 변경 안 됨
 
turn변경
 
현재는 타이머 제한 시간이 종료되어도 턴이 변경되지 않는 문제가 있다. 타임아웃 처리가 로컬 타이머(UI)에서만 수행되어 서버 상태(currentTurn)가 갱신되지 않아 상대 플레이어로 턴이 넘어가지 않았던 것이다.
 

 
💡 문제 해결 (roomService.js, Game.js)
 
turn변경
 
타임아웃 발생 시 서버 트랜잭션을 통해 턴을 명시적으로 넘기도록 수정했다. 이를 위해 서버에서 안전하게 턴을 전환하는 함수를 구현하고 로컬 타이머 종료 시 해당 함수를 호출하도록 구조를 변경했다.
 
ㆍ roomService.js: 서버에서 안전하게 턴 전환 함수 구현
 
ㆍ Game.js: 로컬 타이머 종료 시 서버 함수를 호출하도록 수정하고 중복 호출 방지 로직 추가
 

 
🌟 roomService.js
 
export async function handleTurnTimeout({ roomId, timedOutUid }) {
  if (!roomId) throw new Error("roomId required");
  if (!timedOutUid) throw new Error("timedOutUid required");

  const roomRef = doc(db, "rooms", roomId);

  try {
    const result = await runTransaction(db, async (tx) => {
      const snap = await tx.get(roomRef);
      if (!snap.exists()) return { updated: false, reason: "ROOM_NOT_FOUND" };

      const room = snap.data();

      // 상태/승자 검증
      if (room.status !== "playing") return { updated: false, reason: "NOT_PLAYING" };
      if (room.winner) return { updated: false, reason: "ALREADY_ENDED" };

      // 현재 턴 확인 (중복/경합 방지)
      if (room.currentTurn !== timedOutUid) return { updated: false, reason: "TURN_CHANGED" };

      const players = room.players || {};
      const playerUids = Object.keys(players);
      if (playerUids.length < 1) return { updated: false, reason: "NO_PLAYERS" };

      // turn 넘기기
      const nextUid = getNextTurnUid(players, timedOutUid);
      if (!nextUid) return { updated: false, reason: "NO_OTHER_PLAYER" };

      // DB 업데이트 - turnCount 증가 X
      const updateData = {
        currentTurn: nextUid,
        lastAction: {
          type: "TIMEOUT",
          actorUid: timedOutUid,
          ts: serverTimestamp(),
        },
        lastTimeoutAt: serverTimestamp(),
      };

      tx.update(roomRef, updateData);

      return { updated: true, newTurn: nextUid };
    });

    return result;
  } catch (err) {
    console.error("handleTurnTimeout transaction failed:", err);
    throw err;
  }
}

ㆍ 트랜잭션 내에서 현재 룸 상태 검증:
 
 - 방 존재, status === "playing", winner 없음
 
 - room.currentTurn이 timedOutUid(타임아웃 당한 플레이어)와 동일한지 확인
 
ㆍ currentTurn을 상대 플레이어로 변경
 
ㆍ lastAction에 { type: "TIMEOUT", actorUid: timedOutUid, ts: serverTimestamp() } 기록
 
ㆍ 반환값으로 성공여부와 새 currentTurn 반환
 

 
🌟 Game.js
 
setTimeLeft((prev) => {
  if (prev <= 1) {
  // 타임아웃 시도는 currentTurn이 내 uid일 때만 실행
  if (currentTurn === myUid && !timeoutCallingRef.current) {
    timeoutCallingRef.current = true;
    (async () => {
      try {
        const res = await handleTurnTimeout({ roomId, timedOutUid: currentTurn });     
        setTimeLeft(TURN_LIMIT);
      } catch (err) {
        console.error("handleTurnTimeout failed", err);
        // 실패 시 안전하게 리셋
        setTimeLeft(TURN_LIMIT);
      } finally {
        timeoutCallingRef.current = false;
      }
    })();
  } else {
    setTimeLeft(TURN_LIMIT);
  }
  return TURN_LIMIT; // 현재 20초 제한 (추후 수정)
}
  return prev - 1;
});

ㆍ 로컬 타이머가 0이 되었을 때 오직 현재 턴을 가진 플레이어의 클라이언트만 서버에 handleTurnTimeout 호출을 시도 (동시 호출이 발생하더라도 서버 트랜잭션으로 안전하게 보호됨)
 
ㆍ 중복 호출 방지를 위해 timeoutCallingRef 도입
 
ㆍ 호출 성공/실패 후 timeLeft를 재설정
 

 

 
📌 상대방 나갈 시 게임 종료
 
중도 퇴장 시 게임 종료 및 승패 처리 로직을 구현했다.
 
📌 버튼
 
exit
 
게임 중 플레이어가 '나가기' 버튼으로 세션을 이탈하면 게임을 즉시 종료하고 남은 플레이어에게는 모달창으로 상대의 이탈과 승리 결과를 알리도록 구현했다.
 

 
📌 뒤로 가기
 
exit
 
게임 중 플레이어가 '뒤로 가기' 로 세션을 이탈하면 게임을 즉시 종료하고 남은 플레이어에게는 모달창으로 상대의 이탈과 승리 결과를 알리도록 구현했다.
 

 

 

3️⃣ 게임 후 - 결과 및 피드백 (Outcome)

📌 빙고 완성
 
❓ 둘의 main color가 아닌데 빙고가 완성됨
 
빙고완성
 
player의 main color가 아닌 타일 조합에서도 빙고 라인이 감지되어 게임이 종료되는 문제가 발생했다.
// 승자 set
if (!serverWinner && uidWithColor) setServerWinner(uidWithColor);
// 빙고 line set
setWinningLine(found);

 

 
💡 문제 해결 (Game.js)
 
시간set
 
기존 로직은 특정 줄에서 빙고 라인이 탐지 되면 main color와 무관한 빙고 라인 완성 시에도 승자 설정 로직이 실행되어 게임이 종료되는 문제가 있었다. 이를 해결하기 위해 player의 main color로 빙고가 완성된 경우에만 승자 설정 및 빙고 라인이 표시되도록 로직을 수정했다.
if (uidWithColor) {
  setWinningLine(found); // 빙고 line set
}

 

 

 
📌 rematch 모달창
 
rematch모달
 
게임 종료 시 단순히 'rematch' 버튼만 보여주는 것이 아니라 대화형 리매치 모달을 띄우도록 구현했다. UI/UX 측면에서 요청, 대기, 수신, 응답의 각 상태 전환에 맞춰 모달 메시지가 동적으로 변경되도록 구현해 즉각적인 시각적 피드백을 제공했다. 이를 통해 player가 진행 상황을 명확히 인지할 수 있도록 했으며 단순 대기 시간을 인터랙티브한 경험으로 전환하도록 설계했다.
 

 

 
📌 모달창 승자 표시
 
모달창승자표시
 
게임 종료 시 플레이어가 결과를 즉각적으로 인지할 수 있도록 모달창 상단에 You win / You lose 메시지를 배치해 플레이어가 승패 결과를 한눈에 확인할 수 있는 직관적인 UI를 구현했다.
 

 

 
📌 rematch 시 main color
 
❓ rematch 시 main color가 계속 유지됨
 
rematch
 
rematch 시 이전 게임에서 부여받은 각각의 main color가 그대로 유지되는 문제가 있다.
 

 
💡 문제 해결 (roomService.js)
 
rematch
 
이는 rematch 초기화 과정에서 플레이어 색상 재할당 로직이 제대로 수행되지 않았기 때문이다. 이를 해결하기 위해 rematch 시 기존에 작성한 assignRandomColors 함수를 호출해 main color가 무작위로 다시 배정되도록 수정했다.
const hostUid = room.hostUid || playerUids[0];
const otherUid = playerUids.find(u => u !== hostUid) || hostUid;

const updatedPlayers = assignRandomColors(newPlayers, hostUid, otherUid); // random으로 색 다시 배정

 

 

 

4️⃣ 시스템(System) 및 공통 로직

📌라우팅(Routing) 분리
 
메인화면 게임화면
 
메인 화면과 게임 화면의 역할을 명확히 분리하기 위해 라우팅 구조를 분리했다. 기존에는 하나의 흐름에서 화면 상태로만 전환되던 구조였으나 /home과 /game 경로를 각각 독립적으로 구성해 화면 책임을 분리하고 네비게이션 흐름을 명확히 했다. 이를 통해 진입 로직, 게임 세션 관리, 예외 처리 등을 화면 단위로 관리할 수 있도록 구조를 개선했다.
 

 

 

5️⃣ 앞으로...

이제 자잘한 오류들을 거의 다 잡았다.. 끝이 보이는데 끝이 보이질 않는다. 3월 되기전에는 끝ㄴ내야지
 

 

 

 

반응형