반복적으로 사용할 모델들(나무)은 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: [],
});
열쇠 획득 로직
열쇠를 클릭 시 사용자 인벤토리에 열쇠가 들어감
Key 컴포넌트를 조건부 렌더링 시키기
사용자가 인벤토리에 열쇠를 가지고 있을때는 키를 사라지게 하기
if (playerInventory.includes("key")) {
return null;
}
return (
<primitive
onClick={(e) => {
e.stopPropagation();
alert("열쇠를 얻었습니다");
setPlayerInventory((prev) => uniq([...prev, "key"]));
}}
...
>
...
</primitive>
)
획득한 열쇠로 보물상자 열기
보물상자 클릭 시 인벤토리에 키를 사용하고, 퇴근권을 얻음
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>
)
획득한 퇴근권을 좀비에게 가져다 주기
좀비는 좌측 대각선 방향으로 뛰면서 씬을 벗어난다
씬에서 완전히 사라지면 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 하게되어 강아지 앞에 놓인다
고기 줍기
<primitive
onClick={(e) => {
e.stopPropagation();
alert("고기를 얻었습니다!");
setPlayerInventory((prev) => uniq([...prev, "food"]));
if (ref.current) {
ref.current.visible = false;
}
}}
...
>
강아지에게 고기 건네주기
강아지 컴포넌트에서 고기 컴포넌트의 모델을 불러오기 위해 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 의 좌표, 보간값을 기준으로 새로운 좌표 생성
