[SNS 공간 만들기(2)]

JAMEe_·2024년 7월 10일

R3F

목록 보기
22/24

운동장에 물체 놓기

반복적으로 사용할 모델들(나무)은 clone 하여 참조 끊어놓고 사용하기. 나중에 어떤 로직으로 인해 나무에 이벤트를 주면 모든 나무가 다 변하기 때문

const { scene: scene_ } = useGLTF("/models/Tree.glb");
const scene = useMemo(() => {
  return SkeletonUtils.clone(scene_);
}, []);

움직이는 시바견 배치하기

gsap 라이브러리를 통해 모델에 애니메이션을 주어서
특정 좌표로 이동하고 돌아오는 반복된 애니메이션을 구현할 예정

핵심은 gsap 의 onUpdate 콜백함수로 매 애니메이션 동작마다 진행상황을 감지하여 처음 시작점에서는 모델이 오른쪽 방향으로 lookAt 하도록하고, 종료되는 시점에는 모델이 왼쪽 방향을 lookAt 하게 하여 자연스러운 처리

useEffect(() => {
    if (!ref.current) return;

    scene.traverse((mesh) => {
      mesh.castShadow = true;
      mesh.receiveShadow = true;
    });
    let animation;
    actions["Walk"]?.play();
    animation = gsap.to(ref.current.position, {
      duration: 5,
      yoyo: true,
      repeat: -1,
      x: 3,
      ease: "linear",
      onUpdate: () => {
        const progress = animation.progress();
        if (Math.abs(progress) < 0.01) {
          ref.current.lookAt(3, 0, 21);
        } else if (Math.abs(progress) > 0.99) {
          ref.current.lookAt(-1, 0, 21);
        }
      },
    });
    animation.play();

    return () => {
      animation.pause();
    };
 }, [actions, position, scene]);

빛나는 조형물 만들기

rectAreaLight 조명을 이용하여 밑에서 위로 뿜어져 나오는 빛을 구현하기

<rectAreaLight
    args={["yellow", 30, 5, 5]}
    position={[position.x, 0, position.z]}
    rotation-x={Math.PI / 2}
/>

유저 닉네임 보여주기

drei 의 Billboard 를 이용해서 구현하려 했으나, 버전이 바뀌면서 Billboard 의 position 이 바뀌지 않는 문제가 발생하여 Text 자체에 position 을 바꾸는 것으로 변경

// usePlayer.ts
if (nicknameRef.current) {
  nicknameRef.current.position.set(
    playerRef.current.position.x,
    playerRef.current.position.y + 3.5,
    playerRef.current.position.z
  );
  nicknameRef.current.lookAt(10000, 10000, 10000);
}

// NicknameBoard.tsx
<Text
   ref={ref}
   // 빈 문자열에 폰트를 지정하면 오류가 발생할 수 있음
   font="/NotoSansKR-Regular.ttf"
   fontSize={isNpc ? 0.4 : 0.25}
   color={isNpc ? 0xff71c2 : 0x000000}
>
   {text}
</Text>

항상 카메라 방향을 주시하도록 lookAt 을 10000씩 줌


글자 애니메이션

한 글자씩 텍스트가 출력되도록 하는 효과 구현하기

export function useAnimatedText(text, once, callback) {
  const [displayText, setDisplayText] = useState("");
  const [currentIdx, setCurrentIdx] = useState(0);

  useEffect(() => {
    if (currentIdx < text.length) {
      const timeout = setTimeout(() => {
        setDisplayText((prev) => prev + text[currentIdx]);
        setCurrentIdx((prev) => prev + 1);
      }, 150);

      return () => clearTimeout(timeout);
    } else if (!once) {
      setCurrentIdx(0);
      setDisplayText("");
    } else {
      callback?.();
    }
  }, [callback, currentIdx, displayText, once, text]);

  useEffect(() => {
    setCurrentIdx(0);
    setDisplayText("");
  }, []);

  return { displayText };
}

좀비 퇴근 애니메이션

맵 구석에 열쇠를 획득하고 맵 중앙에 보물상자를 열어 퇴근권을 얻은 후 좀비에게 가져다 주면 좀비가 퇴근한다

캐릭터의 인벤토리와 완료된 퀘스트 항목을 저장하기 위해 recoil 사용

export const PlayerCompletedQuestsAtom = atom({
  key: "PlayerCompletedQuestsAtom",
  default: [],
});
export const PlayerInventoryAtom = atom({
  key: "PlayerInventoryAtom",
  default: [],
});
  1. 열쇠 획득 로직
    열쇠를 클릭 시 사용자 인벤토리에 열쇠가 들어감
    Key 컴포넌트를 조건부 렌더링 시키기
    사용자가 인벤토리에 열쇠를 가지고 있을때는 키를 사라지게 하기

    if (playerInventory.includes("key")) {
       return null;
    }
    
    return (
     <primitive
           onClick={(e) => {
             e.stopPropagation();
             alert("열쇠를 얻었습니다");
             setPlayerInventory((prev) => uniq([...prev, "key"]));
           }}
           ...
     >
     ...
     </primitive>
    )
  2. 획득한 열쇠로 보물상자 열기
    보물상자 클릭 시 인벤토리에 키를 사용하고, 퇴근권을 얻음
    WoodChest 컴포넌트 역시 조건부 렌더링 시키기
    사용자가 treasure 퀘스트 완료 시 보물상자 사라지게 하기

    if (playerCompletedQuests.includes("treasure")) {
       return null;
     }
    
    return(
     <primitive
           onClick={(e) => {
             e.stopPropagation();
             if (playerInventory.includes("key")) {
               alert("조기 퇴근권을 획득했습니다");
               setPlayerInventory((prev) => [
                 ...prev.filter((item) => item !== "key"),
                 "ticket",
               ]);
               setPlayerCompletedQuests((prev) => [...prev, "treasure"]);
             } else {
               alert("열쇠가 필요합니다");
             }
           }}
           ...
    >
    ...
    </primitive>
    )
  3. 획득한 퇴근권을 좀비에게 가져다 주기
    좀비는 좌측 대각선 방향으로 뛰면서 씬을 벗어난다
    씬에서 완전히 사라지면 visible false 주기

    if (playerCompletedQuests.includes("zombie")) {
        ref.current.lookAt(-50, 0, -50);
        ref.current.position.x -= 0.02;
        ref.current.position.z -= 0.02;
    
        chatRef.current.position.x -= 0.02;
        chatRef.current.position.z -= 0.02;
    
        nameRef.current.position.x -= 0.02;
        nameRef.current.position.z -= 0.02;
    }
    
    if (ref.current.position.x <= -20 || ref.current.position.z <= -20) {
        ref.current.visible = false;
    }
    
    return (
     <primitive
          onClick={(e) => {
            e.stopPropagation();
            if (playerInventory.includes("ticket")) {
              alert("퇴근 완료");
              setText("으아아 드디어 퇴근이다..!     ");
              setPlayerInventory((prev) => [
                ...prev.filter((item) => item !== "ticket"),
              ]);
              setCurrentAnimation(
                "EnemyArmature|EnemyArmature|EnemyArmature|Run"
              );
              setPlayerCompletedQuests((prev) => [...prev, "zombie"]);
            } else {
              alert("티켓이 필요합니다");
            }
          }}
          ...
    >
    ...
    </primitive>
    )

강아지 먹이 주기

앞서 다룬 좀비 애니메이션과는 다르게 조건부 렌더링이아닌 고기 mesh 를 visible 로 사라지게 해줄 것이다
그리고 강아지에게 고기를 건네주면 visible 하게되어 강아지 앞에 놓인다

  1. 고기 줍기

    <primitive
         onClick={(e) => {
           e.stopPropagation();
           alert("고기를 얻었습니다!");
           setPlayerInventory((prev) => uniq([...prev, "food"]));
           if (ref.current) {
             ref.current.visible = false;
           }
         }}
         ...
    >
  2. 강아지에게 고기 건네주기
    강아지 컴포넌트에서 고기 컴포넌트의 모델을 불러오기 위해 useThree 의 scene 사용.
    useThree 의 scene 을 통해 name 으로 씬 안에 오브젝트를 찾고, 해당 오브젝트의 visible 과 position 변경

    
    const { scene: threeScene } = useThree();
    
    if (playerCompletedQuests.includes("dog")) {
         setText("멍멍! 고마워!     ");
         actions["Walk"]?.stop();
         actions["Eating"]?.play();
    
         ref.current.lookAt(3, 0, 21);
         const steak = threeScene.getObjectByName("ground-steak");
    
         if (steak) {
           steak.visible = true;
           steak.position.set(
             ref.current.position.x + 1,
             0,
             ref.current.position.z
           );
         }
    }
    
    return (
     <primitive
           onClick={(e) => {
             e.stopPropagation();
             if (playerInventory.includes("food")) {
               alert("댕댕이에게 고기를 주었습니다");
               setPlayerInventory((prev) =>
                 prev.filter((item) => item !== "food")
               );
               setPlayerCompletedQuests((prev) => [...prev, "dog"]);
             } else {
               alert("고기가 필요합니다");
             }
           }}
           ...
     />
      ...
     </primitive>
    )

운동장 안에 오브젝트들의 경계선 만들기

useGLTF 로 불러온 scene 에 children 은 mesh 를 나타낸다.
그리고 mesh 의 geometry 의 boundingBox 는 모델의 크기와 위치를 나타내기 위한 3차원 공간의 직육면체이다

운동장의 정글짐을 예로 들어보자
파란점은 boundingBox 의 min 값이고, 빨간점은 max 값이다.
두 점의 좌표를 이용하면 초록색 점의 좌표도 구할 수 있다.
예를 들어 좌측 앞쪽의 초록점은 { x: -4, z: 4 } 이다

이번 예제의 경우 오브젝트에 딱 맞게 경계선을 그리기보다는 조금 넉넉하게 그려주기 위해 max 와 min 을 clone 하고 multiplyScalar 을 통해 기존 scale 의 1.4 배를 해줄 것이다

※ 위 예제는 rotation 영향을 받지않은 모델이라서 이 방법으로 해도 문제가 없지만, rotation 이 변경되어 matrix 를 update 해준 경우 심화 오브젝트편의 예제로 작성해야 한다

max: mesh.geometry.boundingBox.max
                  .clone()
                  .multiplyScalar(scale * 1.4),
min: mesh.geometry.boundingBox.min
                  .clone()
                  .multiplyScalar(scale * 1.4),

이렇게 재정의한 max 와 min 으로 구한 4개의 초록색 점의 좌표로 drei 에서 제공하는 Line 컴포넌트를 통해 경계선을 그리면 된다.

{playGroundStructuresFloorPlaneCorners?.map((corner) => {
  // points 는 [[1,2,3],[2,3,4],[3,4,5],[4,5,6]] 의 형태
  return (
    <Line
      key={corner.name}
      color="red"
      points={corner.corners.map((c) => [c.x, 0, c.z])}
    />
  );
})}

playGroundStructuresFloorPlaneCorners 요소에는 모든 운동장 속 오브젝트들의 초록색 점들의 좌표 값이 들어있다

오브젝트 경계선 안으로 들어갔을 시 이벤트

앞서 정의해준 corners 의 min, max 의 x 축과 z 축으로 유저가 해당 오브젝트 경계선 안으로 들어갔는지 감지할 것이다

예를 들어, x 축으로 감지하기위해선 플레이어의 position x 축이 3,4의 x축보단 커야하며 1,2 의 x축보단 작아야한다
const currentCloseStructure = PlayerGroundStructuresFloorPlaneCorners.find(
  (structure) => {
    const getInRangeX =
      playerRef.current.position.x < structure.corners[0].x &&
      playerRef.current.position.x > structure.corners[2].x;
    const getInRangeZ =
      playerRef.current.position.z < structure.corners[0].z &&
      playerRef.current.position.z > structure.corners[2].z;

    return getInRangeX && getInRangeZ;
  }
);

if (currentCloseStructure) {
  camera.lookAt(currentCloseStructure.position);
  camera.position.set(
    playerRef.current.position.x + 6,
    playerRef.current.position.y + 6,
    playerRef.current.position.z + 6
  );
}

심화 오브젝트편

glb 파일은 BoundingBox 가 보통 존재하지만, 일반적인 geometry 들은 최적화 이슈로 기본적으로 제공되지않음

이럴땐 직접 선언해서 BoundingBox 를 생성해주면된다

const mesh = ref.current.children[0];
const geometry = mesh.geometry;

geometry.computeBoundingBox();

rotation 이 적용된 mesh 는 min, max 의 position 이
rotation 적용되기 전 기준으로 잡히므로 matrix4 를 다시 설정해주어야한다.

const boundingBox = geometry.boundingBox.clone();
mesh.updateMatrixWorld(true);
boundingBox.applyMatrix4(mesh.matrixWorld);

BoundingBox 를 multiplyScalar 해줄 때 중앙점을 기준으로 넓혀져야 한다.

const center = new THREE.Vector3();
boundingBox.getCenter(center);

const scaledMin = new THREE.Vector3().lerpVectors(
        center,
        boundingBox.min,
        1.4
      );
const scaledMax = new THREE.Vector3().lerpVectors(
        center,
        boundingBox.max,
        1.4
      );

getCenter 를 이용해 center 값을 boundingBox center로 초기화
lerpVectors 를 이용해 center 좌표와 boungingBox 의 좌표, 보간값을 기준으로 새로운 좌표 생성

profile
안녕하세요

0개의 댓글