Dev_logroome

Three.js 메인 3D 프리뷰 구현 – RoomE #3

2025-03-31
ProjectReact

🛠️ 구현 로직 흐름

(1)ㅤ방 정보 조회

  • 팔로잉한 유저 목록 가져오기ㅤ➡️ㅤgetFollowing() 호출
  • 유저의 방 정보 가져오기ㅤ➡️ㅤgetRoomById(user.id) 호출
  • 가져온 방 정보를 rooms 상태로 저장
jsx
1
useEffect(() => {
  const fetchData = async () => {
    try {
      const followingUsers = await getFollowing();
      const roomData = await Promise.all(
        followingUsers.map((user) => getRoomById(user.id))
      );
      setRooms(roomData);
    } catch (error) {
      console.error('방 정보 로딩 실패', error);
    }
  };
  fetchData();
}, []);

(2) 헥사곤 배치 규칙

  • 중심(0,0,0) 좌표나의 방 배치
  • 6가지 방향으로 첫 번째 링을 배치
  • 이후 기존 배치를 기준으로 왼쪽, 오른쪽 방향으로 확장
  • 중복 좌표 체크하여 중복 배치 방지
  • 헥사곤 좌표계를 사용하여 (q,r,s) 좌표를 2D (x,y,z) 좌표로 변환
jsx
1
const getHexPosition = (index: number) => {
  const row = Math.floor(index / 3);
  const col = index % 3;
  const x = col * 1.5; // 간격 조정
  const z = row * 1.3;
  return [x, 0, z];
};

(3) 방 배치

  • getHexPosition(index) 함수를 활용해 위치 계산
  • Room 컴포넌트를 활용해 방을 배치
jsx
1
<Canvas>
  {rooms.map((room, index) => (
    <Room
      key={room.id}
      modelPath={room.modelPath}
      userId={room.userId}
      position={getHexPosition(index)}
      scale={0.5} // 메인에서는 작게
      onClick={() => navigate(`/room/${room.userId}`)}
    />
  ))}
</Canvas>

📁 로직 분리 및 구현

1. useRooms - 방 데이터 불러오기

jsx
1
export default function useRooms(limit = 30, myUserId: number) {
  const [rooms, setRooms] = useState<Room[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const fetchData = async () => {
      try {
        const houseMateUsers = await housemateAPI.getFollowing(0, limit);
        const roomData = await Promise.all(
          houseMateUsers?.housemates?.map((mate: Housemate) =>
            roomAPI.getRoomById(mate.userId)
          )
        );
 
        const modelRooms = roomData.map((room) => {
          const themeKey = mapThemeKeyToFullThemeKey(room.theme);
          return { ...room, modelPath: FullThemeData[themeKey].modelPath };
        });
 
        setRooms(modelRooms);
      } catch (error) {
       ...에러처리 코드
    };
    fetchData();
  }, [limit, myUserId]);
 
  return { rooms, loading, error };
}

✅ㅤ로직의 흐름

(1) 로그인한 사용자의 방 정보 가져오기

  • roomAPI.getRoomById(myUserId) 호출
  • mapThemeKeyToFullThemeKey 를 이용해 테마 키를 변환 후, modelPath 추가

(2) 팔로잉한 유저들의 방 정보 가져오기

  • housemateAPI.getFollowing(0, limit) 호출
  • 각 유저의 방 정보를 비동기적으로 가져옴

(3) 데이터 저장

  • 나의 방 정보와 팔로잉한 유저들의 방을 하나의 배열로 합쳐 rooms 상태로 업데이트

2. useHexagonGrid - 방을 헥사곤 그리드 형태로 배치

⚙️ㅤ이동 방향 벡터 (좌표 시스템)

jsx
1
const directions: Position[] = [
  [-1, 0, 1], [1, 0, -1], [0, -1, 1],
  [1, -1, 0], [-1, 1, 0], [0, 1, -1],
];

⚙️ㅤ링 확장 규칙을 정의한 배열

jsx
1
const expansions: Expansion[] = [
  { directionIndex: 2, dir: [-1, 0, 1], side: 'left' },
  { directionIndex: 0, dir: [-1, 0, 1], side: 'left' },
  { directionIndex: 4, dir: [-1, 0, 1], side: 'left' },
  { directionIndex: 3, dir: [1, 0, -1], side: 'right' },
  { directionIndex: 1, dir: [1, 0, -1], side: 'right' },
  { directionIndex: 5, dir: [1, 0, -1], side: 'right' },
];
jsx
1
function expandRing(
  expansion: Expansion, // 확장 규칙
  previousRing: number[], // 이전 링에 속한 방의 인덱스 배열
  result: RoomPosition[], // 전체 배치된 방들의 목록
  visited: Set<string>, // 방문한 방 좌표를 기록한 Set
  rooms: Room[], // 배치할 전체 방 목록
  roomIndex: number, // 현재 배치 중인 방의 인덱스
  placedInRing: number, // 현재 링에 배치된 방 개수
  positionsInRing: number // 현재 링에 배치할 수 있는 최대 방 개수
)
jsx
1
// 헥사곤 그리드 확장 함수
function expandRing(...): { roomIndex: number; placedInRing: number } {
  const { directionIndex, dir } = expansion;
  if (placedInRing >= positionsInRing || roomIndex >= rooms.length)
    return { roomIndex, placedInRing };
 
  for (let i = 0; i < previousRing.length; i++) {
    const baseIndex = previousRing[(directionIndex + i) % previousRing.length];
    const basePos = result[baseIndex].position;
    const [dx, dy, dz] = dir;
    const newPos: Position = [basePos[0] + dx, basePos[1] + dy, basePos[2] + dz];
    const posKey = `${newPos[0]},${newPos[1]},${newPos[2]}`;
 
    if (!visited.has(posKey) && newPos[0] + newPos[1] + newPos[2] === 0) {
      visited.add(posKey);
      result.push({ position: newPos, room: rooms[roomIndex] });
      return { roomIndex: roomIndex + 1, placedInRing: placedInRing + 1 };
    }
  }
  return { roomIndex, placedInRing };
}

✅ㅤ로직의 흐름

(1) 배치 종료 조건 체크

  • 현재 링에 배치된 방이 최대 배치 가능 개수를 초과하면 종료
  • 남은 방이 없으면 종료

(2) 이전 링을 기준으로 새로운 방 배치

(3) 새로운 위치 계산

  • 이전 링의 특정 위치를 기준으로 새 방의 좌표를 계산ㅤ➡️ㅤnewPos
  • 새로운 위치를 posKey 문자열로 변환해 중복 체크에 사용

(4) 중복 체크 및 방 배치

  • 이미 배치된 방이면 스킵
  • 헥사곤 좌표계의 특성인 x + y + z = 0 을 만족하는 경우만 추가
  • 방을 추가한 후 roomIndexplacedInRing 값을 증가

📌ㅤ주요 로직 : 헥사곤 그리드 배치

jsx
1
const result: RoomPosition[] = [];
const visited = new Set<string>();
const ringRooms: number[][] = [[]];
  • result : 배치된 방들의 목록
  • visited : 방문한 좌표를 저장하여 중복 방지
  • ringRooms : 링별로 방 인덱스를 저장하는 배열

✅ㅤ로직의 흐름

jsx
1
// 중앙에 첫 번째 방 배치
result.push({ position: [0, 0, 0], room: rooms[0] });
visited.add('0,0,0');
ringRooms[0] = [0];
let roomIndex = 1;
 
// 첫 번째 링 배치 (최대 6개)
if (roomIndex < rooms.length) {
  const firstRingIndices: number[] = [];
  for (let dir = 0; dir < 6 && roomIndex < rooms.length; dir++) {
    const [dx, dy, dz] = directions[dir];
    const posKey = `${dx},${dy},${dz}`;
 
    if (!visited.has(posKey)) {
      visited.add(posKey);
      result.push({ position: [dx, dy, dz], room: rooms[roomIndex] });
      firstRingIndices.push(roomIndex);
      roomIndex++;
    }
  }
  ringRooms.push(firstRingIndices);
}

(1) 초기 방 배치

  • 첫 번째 방(나의 방)을 (0,0,0) 에 배치
  • 이후 6가지 방향을 돌며 첫 번째 링 완성
jsx
1
// 이후 링 확장
let ring = 1;
while (roomIndex < rooms.length) {
  const positionsInRing = Math.min(rooms.length - roomIndex, 6);
  let placedInRing = 0;
  const previousRing = ringRooms[ring];
 
  if (!previousRing) break;
 
  for (const expansion of expansions) {
    const updated = expandRing(
      ...매개변수
    );
    roomIndex = updated.roomIndex;
    placedInRing = updated.placedInRing;
  }
 
  if (placedInRing > 0) {
    ringRooms.push(result.slice(roomIndex - placedInRing, roomIndex).map((_, i) => roomIndex - placedInRing + i));
    ring++;
  } 
  ...

(2) 추가 방 배치

  • 방이 많아지면 링을 확장하여 배치
  • expandRing() 을 사용하여 각 방향에 배치 가능 여부 확인 후 추가
  • 새 링 추가 시, ringRooms 에 추가
jsx
1
// 2D 좌표 변환 (q, r -> x, y 변환)
return result.map(({ position: [q, r], room }) => {
  const x = centerX + width * (q + r / 2);
  const y = centerY - height * (2.98 / 4.2) * r;
  return { room, position: [x, y, r * 0.7] };
});
}, [rooms, centerX, centerY]);

(3) 위치 변환

  • q, r, s 좌표계를 화면 x, y 좌표로 변환ㅤ➡️ㅤ헥사곤 좌표계를 2D 좌표로 변환

3. HiveRooms - 방을 3D 씬에 렌더링

jsx
1
export default function HiveRooms({ myUserId }) {
  const { rooms } = useRooms(30, myUserId);
  const positionedRooms = useHexagonGrid(rooms, 0, 0);
  const [hoveredRoom, setHoveredRoom] = useState<number | null>(null);
 
  return (
    <div className='w-full h-screen relative'>
      <Canvas camera={{ position: [0, 4, 10], fov: 25 }} shadows>
        <RoomLighting />
        {positionedRooms.map(({ room, position }, index) => (
          <group
            key={index}
            position={position}
            onPointerOver={() => setHoveredRoom(index)}
            onPointerOut={() => setHoveredRoom(null)}>
            <HiveRoomModel room={room} />
          </group>
        ))}
      </Canvas>
      {hoveredRoom !== null && (
        <div className='absolute bottom-22 left-1/2 transform -translate-x-1/2'>
          ✊🏻 똑똑! {rooms[hoveredRoom]?.nickname}의 방에 들어가실래요?
        </div>
      )}
    </div>
  );
}

✅ㅤ로직의 흐름

(1) 방 데이터 가져오기

(2) 헥사곤 그리드 위치 계산

(3) Three.js로 방 모델 렌더링

  • Canvas 내부에서 HiveRoomModel 을 반복 렌더링
  • onPointerOver, onPointerOut을 통해 호버 효과 추가

(4) 마우스 이벤트 처리

  • mousedown, mousemove, mouseup 이벤트를 활용해 드래그/클릭 감지
  • 클릭한 방으로 이동하도록 구현

✨ 사용자 경험 개선