ThreeJs+React로 마인크래프트 만들기 번역(by Daniel Bark from freeCodeCamp) - TextureSelector, World

설탕유령·2022년 10월 11일
2

react-threeJs-minecraft

목록 보기
6/6
post-custom-banner

ThreeJs, React로 마인크래프트 만들기 글의 마지막 글이다.
숫자 키패드를 사용해 1, 2, 3, 4, 5로 설치할 블록의 질감을 변경하는 기능을 만들어보자
먼저 src/hooks/에 useStore.js를 수정해보자

// src/hooks/useStore.js
import { nanoid } from "nanoid";
import create from "zustand";

export const useStore = create((set) => ({
  texture: "dirt",
  cubes: [],
  addCube: (x, y, z) => {
    set((prev) => ({
      cubes: [
        ...prev.cubes,
        {
          key: nanoid(),
          pos: [x, y, z],
          texture: prev.texture,
        },
      ],
    }));
  },
  removeCube: (x, y, z) => {
    set((prev) => ({
      cubes: prev.cubes.filter((cube) => {
        const [X, Y, Z] = cube.pos;
        return X !== x || Y !== y || Z !== z;
      }),
    }));
  },
  // texture를 인자로 받아 현재 state에 반영시켜주자
  setTexture: (texture) => {
    set(() => ({
      texture,
    }));
  },
  saveWorld: () => {},
  resetWorld: () => {},
}));

기존 useStore에 작은 수정사항이기에 별다른 어려움은 없다.
Texture 선택을 위해서 별도의 파일을 만들자
src/components/ 경로에 TextureSelector.js 파일을 생성하자

// src/components/TextureSelector.js
import { useState, useEffect } from "react";
import { useKeyboard } from "../hooks/useKeyboard";
import { useStore } from "../hooks/useStore";
import { dirtImg, grassImg, glassImg, woodImg, logImg } from "../images/images";

// TextureSelector를 그릴때 내부 구성 Texture만큼 반복하기 위해 선언
const images = {
  dirt: dirtImg,
  grass: grassImg,
  glass: glassImg,
  wood: woodImg,
  log: logImg,
};

export const TextureSelector = () => {
  // TextrueSelector가 보이는지 여부를 관리하는 state
  const [visible, setVisible] = useState(false);
  // 현재 활성화 textrue를 구분하고 선택한 texture를 반영하기 위해 사용
  const [activeTexture, setTexture] = useStore((state) => [
    state.texture,
    state.setTexture,
  ]);
  // 특정 Texture에 해당되는 num(숫자)가 눌렀는지 여부를 담은 상태값
  const { dirt, grass, glass, wood, log } = useKeyboard();

  // 만약 Texture 상태값이 변경(num을 누른 경우)되면 발생하는 Effect
  useEffect(() => {
    const textures = {
      dirt,
      grass,
      glass,
      wood,
      log,
    };
    // textures 오브젝트를 검사해 각 textures 요소 중 값이 true인 경우를 반환
    // 즉, useKeyboard를 통해 input이 발생해 상태가 true가 된 texture가 반환
    const pressedTexture = Object.entries(textures).find(([k, v]) => v);
    if (pressedTexture) {
      // 해당되는(눌려서 true가 된) texture의 key(예: dirt)를 현재 Texture로 설정
      setTexture(pressedTexture[0]);
    }
  }, [setTexture, dirt, grass, glass, wood, log]);

  // num을 통해 Textrue를 선택한 순간 화면에 잠시동안 TextureSelector가 보이게 하기 위한 Effect
  useEffect(() => {
    // 비동기 처리를 이용해 2초후에 visible을 false로 설정한다.
    const visibbilityTimeout = setTimeout(() => {
      setVisible(false);
    }, 2000);
    // visible을 true로 변경시킨다. 2초뒤 setTimeout의 callback이 실행되면, 다시 false가 된다.
    setVisible(true);
    return () => {
      // 2초가 지나기 전에 useEffect가 다시 발생하면 setTimeout이 중첩되어 실행됨
      // 만약 useEffect가 다시 발생하면 clearTimeout을 이용해 setTimeout을 제거해 시간마다 1번의 실행을 보증
      clearTimeout(visibbilityTimeout);
    };
  }, [activeTexture]);
  return (
    // visible 상태에 따라서 TextureSelector를 보이게함
    visible && (
      <div className="absolute centered texture-selector">
      	// images 요소만큼 반복해 Texture 이미지를 가져옴 
        {Object.entries(images).map(([k, src]) => {
          return (
            <img
              key={k}
              src={src}
              alt={k}
              className={`${k === activeTexture ? "active" : ""}`}
            />
          );
        })}
      </div>
    )
  );
};

영상에서는 단계를 따라서 조금씩 코드를 불려나갔지만, 글에서는 한번에 통일해서 설명하고자 최종본으로 설명하기에 내용이 조금 길다고 느낄 수 있다.
중요한 내용은 주석으로 설명됬기에 별도의 설명은 생략한다.
코드 마지막에 보면 별도의 className을 정의해 주었다.
이에 맞춰서 src/index.css에 다음과 같은 내용을 추가해주자

/* 크기가 작기에 확장을 시켜줌 */
.texture-selector {
  transform: scale(5);
}
/* 선택된 이미지에 강조를 하기 위한 css */
.texture-selector img.active {
  border: 2px solid red;
}

css 추가가 되었다면 작업사항을 app.js에 반영해 확인해보자

// src/App.js
import { Canvas } from "@react-three/fiber";
import { Sky } from "@react-three/drei";
import { Physics } from "@react-three/cannon";
import { Ground } from "./components/Ground";
import { Player } from "./components/Player";
import { FPV } from "./components/FPV";
import { Cubes } from "./components/Cubes";
import { TextureSelector } from "./components/TextureSelector"; // + 만든 selector를 가져오자

function App() {
  return (
    <>
      <Canvas>
        <Sky sunPosition={[100, 100, 20]} />
        <ambientLight intensity={0.5} />
        <FPV />
        <Physics>
          <Player />
          <Cubes />
          <Ground />
        </Physics>
      </Canvas>
      <div className="absolute centered cursor">+</div>
      <TextureSelector /> // + textureSelector를 반영해주자
    </>
  );
}

export default App;

이제 숫자 1, 2, 3, 4, 5를 누를 때마다 Texture selector가 잠시 화면에 보이고, 블럭 설치시 선택한 texture가 반영이 된다.

질감을 선택하고 블록을 설치하다보면 한가지 아쉬운 점이 있다.
내가 현재 어떤 블록에 마우스를 가져다대고 있는지 잘 안보인다는 점과 유리의 질감에 투명도가 없다는 것이다.
Cube.js를 수정해 반영을 해보자

// src/components/Cube.js
import { useState } from "react"; // + state 관리를 위해 추가
import { useBox } from "@react-three/cannon";
import { useStore } from "../hooks/useStore";
import * as textures from "../images/textures";
export const Cube = ({ position, texture }) => {
  const [isHovered, setIsHovered] = useState(false); // + 해당 cube가 Hover 중인지 확인용
  const [ref] = useBox(() => ({
    type: "Static",
    position,
  }));

  const [addCube, removeCube] = useStore((state) => [
    state.addCube,
    state.removeCube,
  ]);

  const activeTexture = textures[texture + "Texture"];

  return (
    <mesh
      // 현재 물체에 마우스 Pointer가 움직이는지
      onPointerMove={(e) => {
        e.stopPropagation();
        setIsHovered(true);
      }}
	  // 현재 물체에 마우스 Pointer가 벗어났는지
      onPointerOut={(e) => {
        e.stopPropagation();
        setIsHovered(false);
      }}
      onClick={(e) => {
        e.stopPropagation();
        const clickedFace = Math.floor(e.faceIndex / 2);
        const { x, y, z } = ref.current.position;
        if (e.altKey) {
          removeCube(x, y, z);
          return;
        } else if (clickedFace === 0) {
          addCube(x + 1, y, z);
          return;
        } else if (clickedFace === 1) {
          addCube(x - 1, y, z);
          return;
        } else if (clickedFace === 2) {
          addCube(x, y + 1, z);
          return;
        } else if (clickedFace === 3) {
          addCube(x, y - 1, z);
          return;
        } else if (clickedFace === 4) {
          addCube(x, y, z + 1);
          return;
        } else if (clickedFace === 5) {
          addCube(x, y, z - 1);
          return;
        }
      }}
      ref={ref}
    >
      <boxBufferGeometry attach="geometry" />
      <meshStandardMaterial
		// 만약 물체가 Hover중 이라면, 질감에 색상을 추가한다.
        color={isHovered ? "gray" : "white"}
        map={activeTexture}
        // 투명도 적용을 위해 transparent 속성을 true로 설정
        transparent={true}
        // 만약 cube의 질감이 유리라면, opacity 값을 0.6으로 설정
        opacity={texture === "glass" ? 0.6 : 1}
        attach="material"
      />
    </mesh>
  );
};

몇가지 새로운 개념이 보인다.
먼저 물체(mesh)에 새롭게 이벤트가 바인딩 되었는데, onPointerMoveonPointerOut이다.
두 요소는 react-three-fiberEvent항목에서 찾아 볼 수 있다.

새롭게 질감 부분에서 transparent, opacity 개념이 추가되었다.
transparent는 질감이 투명한지 여부를 정의한다. true로 설정 시 투명해지는 정도는 opacity 속성을 설정해 제어된다.

코드를 반영하면 유리의 질감과 hover시의 색상 반영을 확인 할 수 있다.

이젠 마지막으로 우리가 저장한 block을 저장하고 reset시키는 world 기능을 구현해보자
먼저 useStore.js를 수정해 world 관련 기능을 만들어보자

// src/hooks/useStore.js
import { nanoid } from "nanoid";
import create from "zustand";

// + localStorege를 저장 및 가져오기 위해 선언
const getLocalStorage = (key) => JSON.parse(window.localStorage.getItem(key));
const setLocalStorage = (key, value) =>
  window.localStorage.setItem(key, JSON.stringify(value));

export const useStore = create((set) => ({
  texture: "dirt",
  // 기존 sample 내용은 다 지우고, localStorege에 내용이 존재하면 가져오거나, []을 할당
  cubes: getLocalStorage("cubes") || [],
  addCube: (x, y, z) => {
    set((prev) => ({
      cubes: [
        ...prev.cubes,
        {
          key: nanoid(),
          pos: [x, y, z],
          texture: prev.texture,
        },
      ],
    }));
  },
  removeCube: (x, y, z) => {
    set((prev) => ({
      cubes: prev.cubes.filter((cube) => {
        const [X, Y, Z] = cube.pos;
        return X !== x || Y !== y || Z !== z;
      }),
    }));
  },
  setTexture: (texture) => {
    set(() => ({
      texture,
    }));
  },
  // 저장 시 localStorege에 cubes 내용을 저장
  saveWorld: () => {
    set((prev) => {
      setLocalStorage("cubes", prev.cubes);
    });
  },
  // reset시 현재 state를 초기화
  resetWorld: () => {
    set(() => ({
      cubes: [],
    }));
  },
}));

기능을 만들고 이제 기능을 사용하기 위한 컴포넌트를 만들어보자 src/components/ 경로에 Menu.js 파일을 생성해보자

// src/componenets/Menu.js 
import { useStore } from "../hooks/useStore";

export const Menu = () => {
  const [saveWorld, resetWorld] = useStore((state) => [
    state.saveWorld,
    state.resetWorld,
  ]);

  return (
    <div className="menu absolute">
      <button onClick={() => saveWorld()}>Save</button>
      <button onClick={() => resetWorld()}>Reset</button>
    </div>
  );
};

메뉴 컴포넌트를 App.js에 적용하자

// src/App.js
import { Canvas } from "@react-three/fiber";
import { Sky } from "@react-three/drei";
import { Physics } from "@react-three/cannon";
import { Ground } from "./components/Ground";
import { Player } from "./components/Player";
import { FPV } from "./components/FPV";
import { Cubes } from "./components/Cubes";
import { TextureSelector } from "./components/TextureSelector";
import { Menu } from "./components/Menu"; // + 추가

function App() {
  return (
    <>
      <Canvas>
        <Sky sunPosition={[100, 100, 20]} />
        <ambientLight intensity={0.5} />
        <FPV />
        <Physics>
          <Player />
          <Cubes />
          <Ground />
        </Physics>
      </Canvas>
      <div className="absolute centered cursor">+</div>
      <TextureSelector />
      <Menu /> // + 추가
    </>
  );
}

export default App;

마지막으로 world 관련 버튼의 배치를 위해 index.css에 다음과 같이 추가해주자

.menu {
  top: 0px;
  left: 10px;
}

모든 작업이 끝났다면 왼쪽 상단에 save와 reset버튼이 생성된다.
저장되는 cubes의 포멧은 다음과 같이 localStorege에 저장된다.

❮
[
    {
        "key": "0vMyojwn_UVJBKMHNE7lH",
        "pos": [
            1,
            0,
            10
        ],
        "texture": "glass"
    },
    {
        "key": "FPvfypxgz0vEhWBjPuAde",
        "pos": [
            4,
            0,
            10
        ],
        "texture": "dirt"
    },
    {
        "key": "daMnP8g97tQaFFPEBQbGk",
        "pos": [
            2,
            0,
            10
        ],
        "texture": "grass"
    },
    {
        "key": "eHOYl0k1DqFkiqVmULoZU",
        "pos": [
            0,
            0,
            10
        ],
        "texture": "wood"
    },
    {
        "key": "INtx3L7hJFyyfOzgn6xwE",
        "pos": [
            -1,
            0,
            10
        ],
        "texture": "log"
    },
    {
        "key": "akcT8xv966eXj0w7nzwO4",
        "pos": [
            3,
            0,
            10
        ],
        "texture": "dirt"
    },
    {
        "key": "FR7XWyNYRMsn-Vyd97DtU",
        "pos": [
            3,
            0,
            9
        ],
        "texture": "dirt"
    }
]

이걸로 모든 작업이 끝났고, 우리는 원하는 질감으로 블록을 설치해 집을 만들고, 영원히 저장하고 초기화 할 수 있게 되었다.

글의 마무리로는 처음으로 3D를 접하고 threeJS를 써가면서 글을 쓴 감상을 이야기하고자 한다.
단순히 복사해 사용하는게 아닌 이해하면서 사용하기 위해 글을 써봤지만, 생각보다 복잡하고, 생각보다 오래 걸린다는 것을 깨달았다.

하지만 글을 쓰기위해 공식문서를 참조해가고 라이브러리 소스코드를 열어보는 것은 좋은 경험이 되었다.
누군가의 글이나 강의를 글로 써내리는건 좀더 신중해지겠지만, 처음 한번쯤은 익숙해지기 위해서 해볼만 하다.

profile
달콤살벌
post-custom-banner

0개의 댓글