728x90

들어가며

작업하면서 훅으로 분리한 부분도 있지만, 여전히 굉장히 길고 난잡한 코드입니다. 프로젝트 진행중에 얼만큼의 기능 추가가 될지도 모르고, 로직의 변경이 있을지도 모른다는 핑계로 리팩토링을 미뤄오다가, 해당 작업을 진행하게 되었습니다.

 

시바는 현재 굉장히 많은 상호작용에 관여하고 있습니다.

vscode기준 아래는 180줄에 달하는 코드들입니다. 

난잡한 코드를 읽기전에 각각의 기능에 대해 요약하자면,

1. glb파일에서 시바를 로드해서 랜더링함

2. 충돌체를 설정해서 시바가 충돌할 수 있도록 함

3. 시바가 키보드 입력에 따라 이동을 함

4. 시바가 이동시 카메라가 따라 다님

5. 시바가 이동시 이벤트가 발생하도록 함

6. 이벤트 발동조건에 따라 발생 여부 결정 ( 시바 위치, 시바가 이동중인지, 이벤트 가능한지 == 쿨타임 , 시바가 착지했는지)

7. 점프 시에는 착지하지 않았다고 설정함

8. 비컨위에 올라섰을 때 비컨을 보이지 않게 설정함

9. 로그인 유무에 따라 이벤트 결과 서버(firestore)에 전송하기

10. 이벤트 발생시 모달을 열어 이벤트 결과 보여주기

 

이 중에서 프레임 단위로 실행되어야 할 기능은 useFrame 훅 내부에서 실행하고 있습니다. 

위는 제가 우선 리팩토링 전에 생각한 기능들이고 해당 기능들을 좀 더 잘게 관심사를 분리하여 보려고 합니다.

 

리팩토링 전의 코드

'use client';

import { Group, Mesh, MeshBasicMaterial, Quaternion, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useCompoundBody } from '@react-three/cannon';
import { useFrame } from '@react-three/fiber';

import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaStore } from '@/store/shiba';
import { ShibaLocation } from '@/shared/constants/model';
import {
  EventResultProps,
  SHIBA_EVENT,
  ShibaEvent,
} from '@/shared/constants/shibaEvent';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { checkNewEvent } from '@/remote/shiba';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useShowingProcessStore } from '@/store/showingProcess';
import { useSession } from 'next-auth/react';

type GLTFResult = GLTF & {
  nodes: {
    Group18985_default_0: Mesh;
    Box002_default_0: Mesh;
    Object001_default_0: Mesh;
  };
  materials: {
    ['default']: MeshBasicMaterial;
  };
};

useGLTF.preload('/models/shiba.glb');

export function Shiba() {
  const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
  const worldPosition = useMemo(() => new Vector3(), []);
  const worldDirection = useMemo(() => new Vector3(), []);
  const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
    useShibaStore();

  const { setIsVisible } = useShowingProcessStore();
  const position: [x: number, y: number, z: number] = [0, 1, 0];
  const { left, right, forward, backward, jump } = useInput();
  const isMoving = forward || backward || left || right;
  const width = 0.65;
  const height = 1.2;
  const front = 0.6;
  const mass = 100;
  const { open } = useModalContext();
  const { data } = useSession();

  const chassisBodyArgs = [width, height, front * 2];
  const { eventList, setEventStatus } = useShibaEventStore();

  const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      angularDamping: 0.95,
      onCollide: () => {
        !isLanded && setIsLanded(true);
      },
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef<Group>(null)
  );

  const controlMovement = useMovePosition({
    worldDirection,
    worldPosition,
    chassisApi,
    chassisBody,
  });

  const orbitControlsRef = useRef<OrbitControlsRef>(null);

  const makeFollowCam = () => {
    chassisBody?.current!.getWorldPosition(worldPosition);
    chassisBody?.current!.getWorldDirection(worldDirection);
    if (orbitControlsRef.current) {
      orbitControlsRef.current.target.copy(worldPosition);
    }
  };

  const checkMapType = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );
    let newLocation: ShibaLocation;

    if (y < 1.1) {
      newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
    } else {
      newLocation = x >= 2.5 ? '언덕' : '집';
    }

    if (eventable) {
      blockEvent();
      eventByLocation(newLocation);
    }

    if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
      setIsVisible(true);
    } else {
      setIsVisible(false);
    }
  };

  const eventByLocation = (location: ShibaLocation) => {
    const occurableEvents = SHIBA_EVENT[location];
    const selectedEvent = getRandomEvent(occurableEvents);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    const userId = data?.user?.email;
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

  const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
    const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
    let random = Math.random() * totalWeight;

    for (const event of eventList) {
      const { weight } = event;
      if (random < weight) {
        return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
      }
      random -= event.weight;
    }
    return {
      ...eventList[eventList.length - 1],
      percent:
        Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
    };
  };

  useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
    !jump && isLanded && isMoving && checkMapType();
  });

  return (
    <>
      <group>
        <group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
          <group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
            <mesh
              geometry={nodes.Group18985_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Box002_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Object001_default_0.geometry}
              material={materials['default']}
            />
          </group>
        </group>
      </group>
      <OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
    </>
  );
}

 

리팩토링을 진행하기

우선 값을 먼저 분리하려고 합니다. 상수값들 조차 관심사별로 뭉쳐있지 않으니, 한눈에 파악하기 힘들더군요..(이런건 다음부터 리팩토링 전에도 신경써야 겠습니다..)

 

진행된 내용

1. glb파일에서 시바를 로드해서 랜더링함 

 이미 라이브러리 훅으로 사용하고 있기에 분리가 필요하지 않습니다.

3. 시바가 키보드 입력에 따라 이동을 함   
훅으로 불리했습니다. 다만 훅 내부에서 useInput 훅을 사용하고 있기에 props로 받도록 하여 결합도를 낮췄습니다.

4. 시바가 이동시 카메라가 따라 다님

함수로 분리했기 때문에 추가 작업을 하지 않고, 별도의 파일로 관리할 필요성을 느끼지 못했습니다.

 

7. 점프 시에는 착지하지 않았다고 설정함

useMovePosition 훅 내부에서 설정하였기에 추가 작업을 하진 않았습니다. 

 

위에 언급하지 않은 내용중 2번은 충돌체에 관한 내용이고 나머지는 이벤트 발생에 관련된 내용(5,6,8,9,10)들입니다. 

 

충돌체 관련 분리하기 (2번)

const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
    useShibaStore();
const width = 0.65;
const height = 1.2;
const front = 0.6;
const mass = 100;
const position: [x: number, y: number, z: number] = [0, 1, 0];
const chassisBodyArgs = [width, height, front * 2];


const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      angularDamping: 0.95,
      onCollide: () => {
        !isLanded && setIsLanded(true);
      },
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef<Group>(null)
  );

충돌체와 관련된 기능만 모으면 아래와 같습니다. useShibaStore를 제외한 넓이,높이,길이(앞뒤)와 위치등은 CompoundBody 훅 내부의 관심사 입니다. 딱히 다른 컴퍼넌트들이 알 필요가 없어 보이네요.

따라서 충돌체를 커스텀훅으로 분리하고자 합니다.  이때 isLanded, setIsLanded는 매개변수로 받아주려고 합니다.

import { useCompoundBody } from '@react-three/cannon';
import { useRef } from 'react';
import { Group } from 'three';

type props = {
  collideFn: () => void;
};

export const useShibaBody = ({ collideFn }: props) => {
  const width = 0.65;
  const height = 1.2;
  const front = 0.6;

  const chassisBodyArgs = [width, height, front * 2];

  const [shibaBody, shibaBodyApi] = useCompoundBody(
    () => ({
      position: [0, 1, 0],
      mass: 100,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      angularDamping: 0.95,
      onCollide: collideFn,
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef<Group>(null)
  );

  return [shibaBody, shibaBodyApi] as const;
};

 

 외부 결합도를 가지던 useCompoundBody 내용을 스탬프 결합도로 바꿔서 결합도를 낮췄고, Shiba.tsx 코드 길이또한 184줄에서 162줄로 줄였습니다.

'use client';

import { Group, Mesh, MeshBasicMaterial, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useCompoundBody } from '@react-three/cannon';
import { useFrame } from '@react-three/fiber';

import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaStore } from '@/store/shiba';
import { ShibaLocation } from '@/shared/constants/model';
import {
  EventResultProps,
  SHIBA_EVENT,
  ShibaEvent,
} from '@/shared/constants/shibaEvent';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { checkNewEvent } from '@/remote/shiba';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useShowingProcessStore } from '@/store/showingProcess';
import { useSession } from 'next-auth/react';
import { useShibaBody } from '../../hooks/useShibaBody';

type GLTFResult = GLTF & {
  nodes: {
    Group18985_default_0: Mesh;
    Box002_default_0: Mesh;
    Object001_default_0: Mesh;
  };
  materials: {
    ['default']: MeshBasicMaterial;
  };
};

useGLTF.preload('/models/shiba.glb');

export function Shiba() {
  const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
  const worldPosition = useMemo(() => new Vector3(), []);
  const worldDirection = useMemo(() => new Vector3(), []);
  const { eventable, blockEvent, isLanded, setIsLanded, getEventableState } =
    useShibaStore();

  const { setIsVisible } = useShowingProcessStore();
  const { left, right, forward, backward, jump } = useInput();
  const isMoving = forward || backward || left || right;

  const { open } = useModalContext();
  const { data } = useSession();

  const { eventList, setEventStatus } = useShibaEventStore();

  const [chassisBody, chassisApi] = useShibaBody({
    collideFn: () => {
      !isLanded && setIsLanded(true);
    },
  });

  const controlMovement = useMovePosition({
    worldDirection,
    worldPosition,
    chassisApi,
    chassisBody,
  });

  const orbitControlsRef = useRef<OrbitControlsRef>(null);

  const makeFollowCam = () => {
    chassisBody?.current!.getWorldPosition(worldPosition);
    chassisBody?.current!.getWorldDirection(worldDirection);
    if (orbitControlsRef.current) {
      orbitControlsRef.current.target.copy(worldPosition);
    }
  };

  const checkMapType = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );
    let newLocation: ShibaLocation;

    if (y < 1.1) {
      newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
    } else {
      newLocation = x >= 2.5 ? '언덕' : '집';
    }

    if (eventable) {
      blockEvent();
      eventByLocation(newLocation);
    }

    if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
      setIsVisible(true);
    } else {
      setIsVisible(false);
    }
  };

  const eventByLocation = (location: ShibaLocation) => {
    const occurableEvents = SHIBA_EVENT[location];
    const selectedEvent = getRandomEvent(occurableEvents);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    const userId = data?.user?.email;
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

  const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
    const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
    let random = Math.random() * totalWeight;

    for (const event of eventList) {
      const { weight } = event;
      if (random < weight) {
        return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
      }
      random -= event.weight;
    }
    return {
      ...eventList[eventList.length - 1],
      percent:
        Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
    };
  };

  useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
    !jump && isLanded && isMoving && checkMapType();
  });

  return (
    <>
      <group>
        <group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
          <group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
            <mesh
              geometry={nodes.Group18985_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Box002_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Object001_default_0.geometry}
              material={materials['default']}
            />
          </group>
        </group>
      </group>
      <OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
    </>
  );
}

 

위치 기반 이벤트 리팩토링 하기

1. checkMapType 함수 개선 및 이름 변경하기 -> interactByPosition

  useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
    !jump && isLanded && isMoving && checkMapType(); // 불필요한 호출 방지
  });

useFrame훅 내부에서 카메라, 이동, 이벤트 발생을 매 프레임으로 실행중인데, 카메라와 이동의 경우에는 이미 분리되어 있습니다. 따라서 checkMapType이라는 함수를 확인하고 리팩토링을 진행하려고 합니다.

 

해당 변수명 부터 사실 무슨 내용이 있을지 알기 힘들다는 생각이 되네요.

현재 제 프로젝트에서는 획득 목록을 확인하는 비컨이 있습니다. 시바가 비컨 위에 올라가게 되면 비컨의 상하 이동은 멈추게 되고 팝업창이 열리며 획득 목록을 보여줍니다.

또한 시바가 이동하는 위치에 따라서 각각 산, 언덕, 집으로 맵을 분류하고 있습니다. 

그러기 위해선 시바의 위치를 측정하여 현재 어떤 곳에 있는지를 측정하고 있습니다. 이때 점프중이거나, 착지하지 않았을 때, 그리고 정지 했을 때는 불필요한 위치 측정을 하지 않도록 하고 있습니다. 

 

 

 

const checkMapType = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );
    let newLocation: ShibaLocation;

    if (y < 1.1) {
      newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
    } else {
      newLocation = x >= 2.5 ? '언덕' : '집';
    }

    if (eventable) {
      blockEvent();
      eventByLocation(newLocation);
    }

    if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
      setIsVisible(true);
    } else {
      setIsVisible(false);
    }
  };

 

문제점

 

1. 우선 기능을 설명하다보니 위치에 따른 상호작용을 하는 함수입니다. 이를 기반으로 이름을 수정할 필요성이 보입니다.

2. 함수를 다시 보니, 팝업창이 열렸는데도 이벤트가 발생하겠군요.. 이를 얼리리턴 기법으로 수정할 필요성이 보입니다.

3. 획득목록 확인, 현재 위치 정의, event 실행 이렇게 3가지로 구분지을 수 있어 보입니다.

 

개선과정

Step 1 : 1차원적 분리

 const interactByPosition = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );

    if (Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2) {
      return setIsVisible(true);
    } else {
      setIsVisible(false);
    }

    if (eventable) {
      let newLocation: ShibaLocation;
      if (y < 1.1) {
        newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
      } else {
        newLocation = x >= 2.5 ? '언덕' : '집';
      }
      blockEvent();
      eventByLocation(newLocation);
    }
  };

 

우선 변수명을 위치에 따라 상호작용하기에 interactByPosition으로 바꿨습니다.

그리고 return을 통해서 관측중이지 않을떄만 위치에 따른 이벤트를 실행하도록 분리하였습니다.

 

Step 2 :  획득목록 확인 기능 분리 및 개선

 

하지만 획득목록 확인하는 부분에 대한 추가적인 분리가 필요해보였습니다. 굳이 얼리리턴을 써야하나? setIsVisible은 useShibaStore에서 가져오는데 불필요하게 결합도를 높인 것 같습니다. 팝업창 확인 여부를 Shiba만 관리하는데 차라리 isWatchingProcess로 기능을 변경하는게 어떤가라는 생각이 들었습니다.

 

아래와 같았던 코드를 

import { create } from 'zustand';

interface ShowingProcess {
  isVisible: boolean;
  setIsVisible: (isVisible: boolean) => void;
}

export const useShowingProcessStore = create<ShowingProcess>()((set) => ({
  isVisible: false,
	// 수정할 대상
  setIsVisible: (isVisible: boolean) =>
    set((state) => ({
      ...state,
      isVisible,
    })),
}));

 

보고 있는지 검사하는 함수로 수정하여 선언적으로 사용할 수 있도록 하였습니다.

import { create } from 'zustand';

interface ShowingProcess {
  isVisible: boolean;
  isWatchingProcess: (x: number, y: number, z: number) => boolean;
}

export const useShowingProcessStore = create<ShowingProcess>()((set) => ({
  isVisible: false,
  isWatchingProcess: (x: number, y: number, z: number) => {
    const isWatching = Math.abs(5.5 - x) < 1 && Math.abs(-2.8 - z) < 1 && y < 2;
    set((state) => ({
      ...state,
      isVisible: isWatching,
    }));

    return isWatching;
  },
}));

 

그 결과 interactByPosition 함수는 아래와 같이 개선되었습니다.

위치를 검사해서 획득목록을 보고 있는지 검사, 보고 있지 않고 이벤트 가능하다면 실행하기

 const interactByPosition = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );

    const isWatching = isWatchingProcess(x, y, z);

    if (!isWatching && eventable) {
      let newLocation: ShibaLocation;
      if (y < 1.1) {
        newLocation = x > 10.5 && z > 4 ? '언덕' : '강';
      } else {
        newLocation = x >= 2.5 ? '언덕' : '집';
      }
      blockEvent();
      eventByLocation(newLocation);
    }
  };

 

Step 3 :  현재 지역 정의하는 부분 분리하기

 

폴더의 depth를 늘이지 않기 위해 dotnaming으로 util함수를 관리하는 shiba.util.ts 파일을 만들어줍니다.이후 지역을 정의하는 함수를 생성합니다.

import { ShibaLocation } from '@/shared/constants/model';

const defineLocation = (x: number, y: number, z: number): ShibaLocation => {
  if (y < 1.1) {
    return x > 10.5 && z > 4 ? '언덕' : '강';
  } else {
    return x >= 2.5 ? '언덕' : '집';
  }
};

 

 

  const interactByPosition = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );

    const isWatching = isWatchingProcess(x, y, z);

    if (!isWatching && eventable) {
      const currentLoation = defineLocation(x, y, z);
      blockEvent();
      eventByLocation(currentLoation);
    }
  };

이벤트 이름이 blockEvent보다는 쿨타임을 적용하는 기능이기에 applyEventCooldown로 수정하는게 좋을 거 같네요.

그리고 eventByLocation보다는 이벤트를 발생시킨다는 이름인 triggerEvent 가 더 적합해보이네요. 

 const interactByPosition = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );

    const isWatching = isWatchingProcess(x, y, z);

    if (!isWatching && eventable) {
      const currentLoation = defineLocation(x, y, z);
      applyEventCooldown();
      triggerEvent(currentLoation);
    }
  };

 

2. triggerEvent  함수 개선하기 (구 eventByLocation 함수)

  const triggerEvent = (location: ShibaLocation) => {
    const occurableEvents = SHIBA_EVENT[location];
    const selectedEvent = getRandomEvent(occurableEvents);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    const userId = data?.user?.email;
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

  const getRandomEvent = (eventList: ShibaEvent[]): EventResultProps => {
    const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
    let random = Math.random() * totalWeight;

    for (const event of eventList) {
      const { weight } = event;
      if (random < weight) {
        return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
      }
      random -= event.weight;
    }
    return {
      ...eventList[eventList.length - 1],
      percent:
        Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
    };
  };

 

관련 기능 설명

1. getRandomEvent를 통해 랜덤 이벤트를 얻는다.

2.  획득한 이벤트 내용을 모달을 열며 보여준다.

3.  이미 겪었던 이벤트인지 확인하고, 처음 겪는 이벤트면 이벤트를 체크한다. 이때 로그인되었다면 db에도 반영해준다.

 

 

Step1. triggerEvent에 사용되는 getRandomEvent 함수 shiba.util.ts로 분리하기

발생 가능한 이벤트 목록을 받아오고, 확률에 기반해서 랜덤한 이벤트를 얻습니다(getRandomEvent 함수)

이후 해당 내용을 바탕으로 모달창을 열고, 만약 로그인되어있다면 해당 내용을 fireBase에 반영해줍니다.

 

우선 getRandomEvent의 경우에 제 기준으로 충분히 잘 분리되었다고 생각합니다. 발생 가능한 이벤트 중에서 비중을 따지고, Math.random을 통해 얻은 값을 기반으로 이벤트를 선택해줍니다. 이후 데이터를 db 및 모달 open함수에서 사용하기 좋게 가공하여 리턴해줍니다. 추가적으로 발생가능한 이벤트 내용들도 해당 함수내에서 확인하도록 수정하고 util파일로 이동시키겠습니다. 

export const getRandomEvent = (location: ShibaLocation): EventResultProps => {
  const eventList = SHIBA_EVENT[location];

  const totalWeight = eventList.reduce((sum, event) => sum + event.weight, 0);
  let random = Math.random() * totalWeight;

  for (const event of eventList) {
    const { weight } = event;
    if (random < weight) {
      return { ...event, percent: Math.floor((weight / totalWeight) * 100) };
    }
    random -= event.weight;
  }
  return {
    ...eventList[eventList.length - 1],
    percent:
      Math.floor(eventList[eventList.length - 1].weight / totalWeight) * 100,
  };
};

 

개선된 triggerEvent 함수

 const triggerEvent = (location: ShibaLocation) => {
    const selectedEvent = getRandomEvent(location);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    const userId = data?.user?.email;
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

 

Step2. 획득 내역 갱신 부분 분리하기

  const { data } = useSession();
  const { eventList, setEventStatus } = useShibaEventStore();

const triggerEvent = (location: ShibaLocation) => {
    const selectedEvent = getRandomEvent(location);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    const userId = data?.user?.email;
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

 

불필요한 api호출을 줄이기 위해서 그리고 비로그인시에도 획득목록을 확인할 수 있도록 획득 목록을  zustand를 통해서 전역상태 관리를 하고 있습니다. 

캔버스가 처음 그려질 때, 로그인되었다면 서버에서 해당 내역을 받아오고, 그렇지 않다면 획득한 내용이 없는 상태로 렌더링이 됩니다.

획득하면 가운데 줄을 긋고 투명하게 해서 표시함

 

따라서 커스텀 훅을 통해서 해당 기능을 분리하려고 합니다. 훅 내부에서는 획득 내용을 갱신하는 함수를 반환하여 선언적으로 사용할 수 있게 려고 합니다.

 

갱신 관련 내용이 분리된 useEventProcess 훅

import { checkNewEvent } from '@/remote/shiba';
import { EventResultProps } from '@/shared/constants/shibaEvent';
import { useShibaEventStore } from '@/store/shibaEvent';
import { useSession } from 'next-auth/react';

export const useEventProcess = () => {
  const { data } = useSession();
  const { eventList, setEventStatus } = useShibaEventStore();

  const userId = data?.user?.email;

  const renewProcess = (selectedEvent: EventResultProps) => {
    if (!eventList[selectedEvent.type]) {
      setEventStatus(selectedEvent.type);
      userId &&
        checkNewEvent({
          id: userId,
          type: selectedEvent.type,
        });
    }
  };

  return renewProcess;
};

 

개선된 triggerEvent 함수

  const triggerEvent = (location: ShibaLocation) => {
    const selectedEvent = getRandomEvent(location);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    renewProcess(selectedEvent);
  };

 

 

 

최종 수정 코드 및 느낀점

'use client';

import { Mesh, MeshBasicMaterial, Vector3 } from 'three';
import React, { useMemo, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { GLTF, OrbitControls as OrbitControlsRef } from 'three-stdlib';
import { useFrame } from '@react-three/fiber';
import { useShibaStore } from '@/store/shiba';
import { useShowingProcessStore } from '@/store/showingProcess';
import { ShibaLocation } from '@/shared/constants/model';
import { useModalContext } from '@/shared/components/portal/ModalContext';
import { useMovePosition } from '../../hooks/useMovePosition';
import { useInput } from '../../hooks/useInput';
import { useShibaBody } from '../../hooks/useShibaBody';
import { defineLocation, getRandomEvent } from './shiba.util';
import { useEventProcess } from '../../hooks/useEventProcess';

type GLTFResult = GLTF & {
  nodes: {
    Group18985_default_0: Mesh;
    Box002_default_0: Mesh;
    Object001_default_0: Mesh;
  };
  materials: {
    ['default']: MeshBasicMaterial;
  };
};

useGLTF.preload('/models/shiba.glb');

export function Shiba() {
  const { nodes, materials } = useGLTF('/models/shiba.glb') as GLTFResult;
  const worldPosition = useMemo(() => new Vector3(), []);
  const worldDirection = useMemo(() => new Vector3(), []);
  const {
    eventable,
    applyEventCooldown,
    isLanded,
    setIsLanded,
    getEventableState,
  } = useShibaStore();
  const { isWatchingProcess } = useShowingProcessStore();
  const renewProcess = useEventProcess();
  const { open } = useModalContext();
  const { left, right, forward, backward, jump } = useInput();
  const isMoving = forward || backward || left || right;

  const [chassisBody, chassisApi] = useShibaBody({
    collideFn: () => {
      !isLanded && setIsLanded(true);
    },
  });

  const controlMovement = useMovePosition({
    worldDirection,
    worldPosition,
    chassisApi,
    chassisBody,
    inputState: { left, right, forward, backward, jump },
  });

  const orbitControlsRef = useRef<OrbitControlsRef>(null);
  const makeFollowCam = () => {
    chassisBody?.current!.getWorldPosition(worldPosition);
    chassisBody?.current!.getWorldDirection(worldDirection);
    if (orbitControlsRef.current) {
      orbitControlsRef.current.target.copy(worldPosition);
    }
  };

  const interactByPosition = () => {
    const { x, y, z } = new Vector3().setFromMatrixPosition(
      chassisBody.current!.matrixWorld
    );
    const isWatching = isWatchingProcess(x, y, z);
    if (!isWatching && eventable) {
      const currentLoation = defineLocation(x, y, z);
      applyEventCooldown();
      triggerEvent(currentLoation);
    }
  };

  const triggerEvent = (location: ShibaLocation) => {
    const selectedEvent = getRandomEvent(location);
    open({ type: 'shiba', event: selectedEvent }, getEventableState);
    renewProcess(selectedEvent);
  };

  useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
    !jump && isLanded && isMoving && interactByPosition();
  });

  return (
    <>
      <group>
        <group ref={chassisBody} position={[0, 0.5, 20]} castShadow>
          <group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
            <mesh
              geometry={nodes.Group18985_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Box002_default_0.geometry}
              material={materials['default']}
            />
            <mesh
              geometry={nodes.Object001_default_0.geometry}
              material={materials['default']}
            />
          </group>
        </group>
      </group>
      <OrbitControls ref={orbitControlsRef} minDistance={2} maxDistance={10} />
    </>
  );
}

 

180줄이었던 코드를 관심사 분리를 통해서 120줄로 줄였습니다. 선언적으로 사용하게 하고 결합도를 낮추는 방법에 대해 고민하다 보니 생각보다 작업량이 많았습니다.

 

최근 정처기를 공부하다보니 실질적으로 코드 짜는 양이 평소보다 줄었는데, 관련 내용을 상기하면서 리팩토링을 하니 공부한 보람이 느껴지내요. (결합도)

 

그리고 완벽하진 않더라도 나름  신경써서 코드를 작성했는데 이만큼이나 개선해야해? 라는 생각도 들었습니다.

그래도 좋은 설계에 대해 고민했기 때문에 개선할 수 있었고, 추후에 보면 허접한 코드로 보이더라도 내가 성장했다고 느낄 수 있지 않을까요? 

 

728x90

최근 원티드 프리온보딩에서 설계와 구조에 대해서 배우게 되었고, 현재 사용한 프로젝트를 평가하며  내가 생각하는 좋은 구조에 대해서 언급하려고 합니다. 이후 해당 구조를 프로젝트 받은 내용을 언급하면서 마무리 하려고 합니다.

 

현재 사용한 프로젝트의 폴더 구조

개인 프로젝트에서 Next.js App router를 활용한 프로젝트를 진행했습니다.  이때 fastcampus 최적화 강의 중 youtube 프로젝트에서 사용한 프로젝트 폴더 구조를 참고하며, 왜 그리고 어떻게 하면 좋을까를 생각하며 제 프로젝트에도 비슷한 양식의 폴더구조를 채택했습니다..

 

완성된 폴더구조는 아래와 같습니다.

 

기본적인 구조는 크게 app, features, public, shared , remote, store  6가지로 나뉘어 집니다.

 

1. app은 Next.js의 앱라우터에 의해 관리되고 있기에,  규칙에 따라서 작성했습니다.

 

2. features : 각각의 페이지에 사용되는 하위 컴퍼넌트는 app 이외의 폴더에서 관리할 필요가 있어서 사용하였습니다.

내부에는 페이지별로 폴더를 만든 후, components, hooks, constants, utils를 폴더별로 따로 관리하였습니다.

 

3. public :  asset을 관리하는 데 사용하였습니다.  

내부에서 이미지, 폰트, 3d model(glb파일) 별로 디렉토리를 구분하였습니다.

 

4.  shared : 공유되는  상수,훅, 유틸함수, 스타일(global.css등)을 관리하는데 사용하였습니다.

 

5. remote :  api 통신에 관여하는 함수와 그에 관련된 타입을 관리하는데 사용하였습니다.

 

6. store : 전역 변수 상태 관리를 하기 위해 zustand로 만든 store를 저장하는 폴더입니다.

 

 

 

내가 생각하는 좋은 폴더 구조

좋은 설계 및 구조에 대해서 생각을 할 때 아래 두 가지 내용이 떠올랐습니다.

"최고의 설계는 트레이드 오프다."    - 원티드 7월 프리온보딩
"조직이 설계하는 시스템은 그 조직의 커뮤니케이션 구조를 반영한다."  - 콘웨이의 법칙

 

위 두가지 내용을 바탕으로  제 상황을 고려하여 알맞는 트레이드 오프를 해야 합니다.

 

현재 제 상황은  아래와 같습니다.

1. 혼자 프로젝트를 하며 디자이너와의 협업이 없기에 컴퍼넌트 UI 설계가 명확하지 않다.

2. 아직 레이어를 명확히 나누지 못하고 조금 유동적으로 바뀐다.

3. Next.js 를 활용하여 프로젝트를 진행했다. 

 

명확한 UI 디자인을 구성하고 프로젝트를 진행하고 있지 않았고, 그렇기에 컴퍼넌트의 재사용성을 정의하기가 애매하다고 느꼈습니다. 따라서 FSD 아키텍처를 그대로 사용하기는 어렵다고 판단했습니다.

그리고 아토믹 패턴의 장점 중 하나는 디자인 시스템 기반의 컴퍼넌트를 사용하기에 소통에 강점이 있다고 생각합니다.

하지만 개인 프로젝트에서 명확한 가이드라인이 나오지도 않았었고  명확한 디자인 시스템을 구축하지 않은 상황에서는 유의미 하지 않다고 판단하였습니다.

 

따라서 FSD 및 기존 폴더 구성을 참고하여 layer를 사용하되 느슨하게 사용하고, Next.js의 앱라우터를 생각하여 조금 변형하는게 좋을 것이라고 생각합니다.

 

app 

FSD의 page 레이어의 역할은 app 디렉토리가 하고 있다고 생각합니다. 또한 next.js의 라우팅을 활용하기 위해서는 페이지 기반으로 컴퍼넌트를 분류하는 것이 효율적이라고 생각했습니다.

 

features  

유지 보수성을 고려했을 때  page내부에 컴퍼넌트의 관심사를 분리할 필요가 있다고 생각합니다. 따라서 pages의 하위 컴퍼넌트를 관리할 디렉토리가 필요하다 생각해서 생성했습니다. 이때 강의에서도 features기도 했고,, FSD의 widgets와 entity보다는 더 와닿아서 사용했습니다.

페이지 디렉토리 내부에 compoents, hooks, utils를 다시 세분화하여 사용했습니다.

 

shared 

그리고 FSD의 shared에서는 비즈니스로직에 종속되지 않는 재사용되는 컴퍼넌트를 사용하지만 저는 특정 페이지에 종속되지 않는 컴퍼넌트와 유틸리티를 포함하는 용도로 shared 디렉토리를 사용했습니다.

현재 상황에서는 하위 구조로 components, hooks, styles, constants로 분류하였습니다. 하지만 components의 경우 추후 공유될 컴퍼넌트의 종류가 많아진다면 아토믹 패턴을 통해, 컴퍼넌트의 크기에 따라서 분류하여 재사용하기 수월하게 하면 좋을 것 같다고 생각헀습니다. 

 

remote

Next.js 외부의 영역과 소통하는 코드도 따로 관리할 필요가 있다고 생각했습니다. 어쩌면 shared의 일부가 될 수도 있는데, 어떤 데이터를 어떻게 주고 받는지에 대한 정보는 비즈니스 로직에 좀 더 밀접해 있다고 생각했습니다. 따라서 shared 외부에 존재할 필요가 있다고 생각했습니다.

 

store

전역 변수의 경우 불필요한 props 드릴링을 막으려고 사용한 특정 페이지에 얽메인 값도 있고, 여러 페이지에 공유될 값도 존재할 수 있다고 생각했습니다. 따라서 shared 외부에서 따로 관리하는게 좋을 것이라 판단하였습니다.

 

public

절대 경로가 간편해지는 이점도 있고, 큰 고민 없이 사용했습니다...

 

 

현재 디렉토리에 대한 나의 평가

많은 고민을 했던 부분도 있고 비교적 간단하게 생각하고 사용한 구조도 있지만 비교적으로 만족스러운 구조라고 생각하고 있습니다.. 물론 협업이 늘어나고, 비즈니스가 복잡해지면 아토믹 패턴과 FSD의 장점이 부각되고 상대적으로 허술한 구조가 될 수도 있습니다. 하지만 좋은 구조는 '본인의 상황에 잘 맞는 구조' 라고 생각하고, 그렇기에 저는 현재 디렉토리가 좋은 구조라고 말하고 싶습니다.

 

아쉬운 점은 파일 수가 적어서 아직 괜찮은데 페이지 기반으로만 분리했기 때문에, 컴퍼넌트의 레이어나 크기에 대해서 명확하지 않습니다. 따라서 추후 더 많은 기능이 추가되거나 레이어가 분할된다면, 추가적인 그룹화가 필요할 것 같습니다.

 

components라는 폴더에 모든 page의 하위 컴퍼넌트를 담기에는 계층 관계나 규모가 불명확해서, 규모가 큰 organism은 

features/(페이지명)에 작성하도록 폴더 구조를 옮겼습니다.

 

현재 디렉토리에 대한 멘토님의 피드백

wanted 프리온보딩에서 현재 폴더 구조에 대한 피드백을 받을 수 있었습니다.

멘토님께서 제 프로젝트 뿐만 아니라 다른 3가지 프로젝트도 같이 피드백해주셨는데, 공통적으로 생각하면 좋은 내용으로 

폴더구조를 생각할 때 아래의 5가지를 추가적으로 고려하면 좋겠다고 하셨습니다.

1. flat vs nested  : 펼칠 지 모아둘지
2. colocation vs global : 지역성과 전역성 중 어떤것에 우선순위를 두는지
3. utils vs businesses : 어디서 쓰든 동일하게  사용하면 utils, 특정 페이지나 컴퍼넌트에서 쓰는 기능은 business
4. deep depth : 어느 정도의 폴더 깊이까지 가져갈지 리액트에서는 최대 3~4까지를 권장함                             
                              https://legacy.reactjs.org/docs/faq-structure.html#avoid-too-much-nesting
5. dot naming comp1.types.ts 처럼 .을 붙여 파일명 짓는것

 

 

이를 바탕으로 피드백하면 저는 파일끼리 뭉치고(nested) nested한 구조를 선호합니다.

따라서 컴퍼넌트를 폴더기반으로 뭉치려고 했습니다. (molecules까지의 작은 컴퍼넌트는 components에 organism은 components 밖에 위치하도록 하였습니다.)

하지만 이러다 보니 depth가 너무 깊어졌습니다.

app/features/luckyshiba/components/background/constants/collision.ts

app내부에 페이지단위로 컴퍼넌트를 넣는 features 하위에 luckyshiba페이지의 components중 background에 존재하는 상수 중 충돌체에 관한 값..

6depth네요..  관심사를 모은다는 명목하에 많은 폴더를 만들었고 너무 깊은 depth가 생겼네요.. 이를 dot naming을 활용해서 분류했으면 depth를 줄일 수 있었을 것 같습니다. collision.const.tsx 이런식으로...

storybook이나 test코드의 이름에 사용되던 dot naming을 일반 파일에 적용할 생각을 하진 못했었네요..

 

추가적으로 src가 있는것이 모노레포나 CI/CD를 구축할 때 유리해서 권장한다, remote와 shared의 depth가 같은 것에 대해서도 질문해주셨습니다. 답을 정해주시기 보단 생각을 많이 물어봐주시고 의아하거나 익숙하지 않은 구조에 대한 질문을 해주셨습니다.

피드백에 대해 느낀점

구조라는게 완벽한 정답보다는 취향도 반영되는 느낌이고, 멘토님께서도 답을 정해주시기 보단 질문을 많이 던져주셨습니다. 제 프로젝트뿐만 아니라 다른 프로젝트를 바탕으로 아직 제 경험상으론 좋아보였지만, 멘토님이 본인 경험 바탕으로 안 좋게 될 수 있는 예시들을 설명해주셔서 공감이 갔던 피드백이었습니다. 

 

728x90

마이페이지 현황과 리팩토링 이유

 

현재 마이페이지의 View는 아래와 같습니다.

웹뷰

모바일 뷰

현재 프로젝트가 강화와 시바 관련 2가지 내용 밖에 없기에  2가지 그룹으로 이루어져 있습니다.

Apple의 Compositional Layout을 참고해서 마이페이지를 그룹화 하면 아래와 같습니다.

 section : 노랑, group :파랑 , item : 초록

 

하지만 현재 제 코드는 너무 투박합니다..

import { auth } from '@/auth';
import EnforceRecord from '@/features/mypage/EnforceRecord';
import ShibaRecord from '@/features/mypage/ShibaRecord';
import styles from '@/app/mypage/mypage.module.css';

export default async function Mypage() {
  const session = await auth();
  return (
    <div className={styles.recordWrapper}>
      <div className={styles.container}>
        <EnforceRecord userEmail={session?.user?.email!} />
        <ShibaRecord userEmail={session?.user?.email!} />
      </div>
    </div>
  );
}

그저 EnfoceRecord랑 ShibaRecord가 있구나.. className을 통해서 css를 일부분 공유하고 나름 구조도 통일성 있게 할려했지만 관심사 분리도 되어있지 않고 그저 투박합니다.. 

import { getAndCleanupEnforceRecords } from '@/remote/enforcement';
import { convertTimestampToKoreanDate } from '@/shared/utils/date';
import styles from '@/app/mypage/mypage.module.css';
export default async function EnforceRecord({
  userEmail,
}: {
  userEmail: string;
}) {
  const records = await getAndCleanupEnforceRecords(userEmail);

  return (
    <div className={styles.recordContainer}>
      <h2 className={styles.title}>
        <div>강화 기록</div>
        <div className={styles.statusIndicator}>
          <span className={styles.successBox}></span>성공&nbsp;
          <span className={styles.failureBox}></span>실패
        </div>
      </h2>
      <ul className={styles.recordList}>
        {records.map(({ id, percent, status, date }) => (
          <li
            key={id}
            className={`${styles.recordItem} ${
              status === '성공' ? styles.success : styles.failure
            }`}
          >
            <span>{convertTimestampToKoreanDate(date)}</span>
            <span>
              {percent}%<span className={styles.deskVisible}>{status}</span>
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

 

현재 이 상태로  새로운 페이지가 생긴다면,, 그저 복붙을 하면서 다시 꾸미겠죠.. 확장성이 굉장히 없어보입니다..

 

따라서 최근 읽은 카카오 아티클(프론트엔드와 SOLID 원칙)을 참고하여 리팩토링을 진행하도록 결심하였습니다.

 

리팩토링 진행 과정

우선 공유되는 내용과 아닌 내용을 분리하고 어떻게 리팩토링 할지에 대해서 고민을 시작하였습니다.

공통점

1. grid layout, 반응형 breakpoint 등...

2. header, content 의 group 구조.

3. 좌측 내용, 우측 부가 정보 있는 group구조

 

차이점

1. 왼쪽 제목은 택스트만 바뀌면 되지만 오른쪽에 보면 강화기록의 경우는 성공 실패의 색을 알려주고,

시바획득목록은  현재 수집 개수 /총 개수를 보여주고 있습니다.

2. 아이템의 크기나 space between css를 먹인 것은 비슷하지만, 강화 기록은 색이 있고, 시바 획득 목록에는 색이 없죠..

3. 불러오는 api 데이터

 

추후 컴퍼넌트가 추가되도 section 자체는 비슷할 것입니다. 따라서 우선 공통적으로 사용할 RecordSection을 만들어줍니다.

 

import React from 'react';
import styles from '@/app/mypage/mypage.module.css';

interface RecordSectionProps<T> {
  title: string;
  subInfo: React.ReactNode;
  records: T[];
  content: (record: T) => React.ReactNode;
}

export default function RecordSection<T>({
  title,
  subInfo,
  records,
  content,
}: RecordSectionProps<T>) {
  return (
    <div className={styles.recordContainer}>
      <div className={styles.title}>
        <h2>{title}</h2>
        <div className={styles.subInfo}>{subInfo}</div>
      </div>
      <ul className={styles.recordList}>
        {records.map((record) => content(record))}
      </ul>
    </div>
  );
}

 

1. content부분에는 어떤 데이터가 들어올지 명확하지 않죠.. 따라서 제네릭 타입이 적합합니다..

2. 그리고 title,이나 records 자체가 들어오는 사실은 명확하나  subInfo나 content의 UI자체는 조금씪 달랐습니다. 따라서

ReactNode를 매개변수로 전달받아 좀 더 유연하게 만들었습니다.

 

이제 page.tsx 파일로 이동합니다. 기존에는 각각의 Component를 직접 넣어서 값과 액션이 구분 되지 않았습니다..

이를 분리시키고 map을 통해서 랜더링 시켜줍니다.

import { auth } from '@/auth';
import EnforceRecord from '@/features/mypage/EnforceRecord';
import ShibaRecord from '@/features/mypage/ShibaRecord';
import styles from '@/app/mypage/mypage.module.css';

const records = [
  { type: 'ENFORCE', component: EnforceRecord },
  { type: 'SHIBA', component: ShibaRecord },
];

export default async function Mypage() {
  const session = await auth();
  const userEmail = session?.user?.email!;

  return (
    <div className={styles.recordWrapper}>
      <div className={styles.container}>
        {records.map(({ type, component: Record }) => (
          <Record key={type} userEmail={userEmail} />
        ))}
      </div>
    </div>
  );
}

 

마지막으로 각각의 컴퍼넌트를 리팩토링합니다.

import { getAndCleanupEnforceRecords } from '@/remote/enforcement';
import { convertTimestampToKoreanDate } from '@/shared/utils/date';
import styles from '@/app/mypage/mypage.module.css';
import { RecordProps } from './constant/model';
import { Timestamp } from 'firebase/firestore';
import RecordSection from './RecordSection';

interface EnforceContent {
  id: string;
  percent: number;
  status: string;
  date: Timestamp;
}

export default async function EnforceRecord({ userEmail }: RecordProps) {
  const records = await getAndCleanupEnforceRecords(userEmail);

  const renderItem = ({ id, percent, status, date }: EnforceContent) => (
    <li
      key={id}
      className={`${styles.recordItem} ${
        status === '성공' ? styles.success : styles.failure
      }`}
    >
      <span>{convertTimestampToKoreanDate(date)}</span>
      <span>
        {percent}%<span className={styles.deskVisible}>{status}</span>
      </span>
    </li>
  );
  return (
    <RecordSection
      title="강화 기록"
      subInfo={
        <>
          <span className={styles.successBox}></span>성공&nbsp;
          <span className={styles.failureBox}></span>실패
        </>
      }
      records={records}
      content={renderItem}
    />
  );
}

 

각각의 group과 item별로 값을 RecordSection에 props로 전달합니다.

아까 말했떤 대로 subInfo 와 content의 경우에는 직접 jsx를 작성해서 전달합니다.

 

느낀점

막상 끝나고 보면 별 내용이 아닌거 같네요...  그래도 개선되었고 조금은 더 SOLID 해지지 않았나 생각이 듭니다.

설계에 집중하다 보니, 진행사항이 더딘거 같아서 우선 완성하고 다시 리팩토링해보자는 마음으로 만든게 조금 티가 나네요... 

그리고 금방 리팩토링 할 줄 알았는데, 시간이 꽤 걸렸네요...   그래도 이런 작업을 하다 보면 '어느 순간에는 좋은 설계를 자연스럽게 할 수 있지 않을까.?'라는 기대감을 안고 마칩니다..

 

참고 문헌 

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

 

프론트엔드와 SOLID 원칙 | 카카오엔터테인먼트 FE 기술블로그

임성묵(steve) 판타지, 무협을 좋아하는 개발자입니다. 덕업일치를 위해 카카오페이지로의 이직을 결심했는데 인사팀의 실수로 백엔드에서 FE개발자로 전향하게 되었습니다. 인생소설로는 데로

fe-developers.kakaoent.com

 

728x90

GLB 파일 압축하기

웹에서 클라이언트가 웹서버에 리소스 요청을 할 때,  glb는 굉장히 많은 비용을 발생시킵니다. 이는 분명 성능 저하에도 영향을 준다고 생각이 들어서, 알아보았고 Draco를 통해서 glb파일을 압축할 수 있다는 사실을 알게 되었습니다.

 

Draco는 구글에서 개발한 3d파일 압축 기술이면  어떻게 압축을 효율적으로 하는지는 아래 논문에서 자세히 볼 수 있습니다..

(개인적으로 너무 어려워서 머리에 잘 담기진 않네요 ㅠㅠ)

https://www.kibme.org/resources/journal/20230807171156346.pdf

 

그리고 이를 쉽게 사용할 수 있게 하는 오픈 소스라이브러인 'glTF Pipeline' 을 사용해서 압축하였습니다.

 

이중 내가 사용할 명령어는 -i (변경할 파일)  -o (변경된 파일명)  -d  (드라코 압축 옵션) 입니다.

gltf-pipeline -i model.gltf -o modelDraco.gltf -d
 
이를 바탕으로 압축하였을 때, 아래와 같이 background 같이 큰 파일은 50% 가까이 압축되었고, 그 외에도 10~15프로 정도 압축이 되었습니다.

(기존 파일 명을 2라고 수정하고 변경완료된 파일명을 기존 파일명으로 했습니다.)

 

압축 전 후 비교

 

이제 설레는 마음을 가지고 얼만큼 절약이 되었을지 확인만 하면 됩니다.

 

퍼포먼스 탭을 통해서 성능 비교하기

우선 next.js에 layout에 있는 header가 먼저 렌더링 됩니다.

이후 page.js를 요청하고, 현재 suspense로 캔버스의 loading 처리를 하고 있습니다. 그리고 Shiba에서 shiba glb파일, Background에서 background.glb 파일을 사용하고 있습니다.

export default function LuckyShiba() {
  return (
    <div className="wrapper_3d">
      <Suspense fallback={<Loading />}>
        <SessionProvider>
          <CanvasLayout color={color.bg} camera={{ position: [0, 2, 4] }}>
            <Physics gravity={[0, -9.8, 0]}>
              <ambientLight />
              <directionalLight position={[0, 5, 5]} />
              <Shiba />
              <Walls />
              <Background />
              <TutorialOpener />
            </Physics>
          </CanvasLayout>
        </SessionProvider>
      </Suspense>
    </div>
  );
}

 

비교를 명확하기 위해서 실험은 빠른 3G 환경에서 하였습니다.

 

 

현재 제 사이트에서 새로 고침 후 html을 서버에 요청을 하게 되면 header와 관련된 부분과 page.js를 요청하고 header가 가볍기에 먼저 렌더링이 됩니다. 이후 page.js 요청이 끝나면 loading 처리가 시작되면서 glb파일들을 요청을 합니다.

 

사진이 좀 작아서  값을 표기하면 크기 시간 순으로 background.glb를 압축 전 후 비교하면

압축 후 : 246B , 624 밀리초 

압축 전 : 246B , 651 밀리초

 

생각보다 차이가 나지 않아서 여러번 했었느데, 위 시간과 큰 차이가 나진 않더군요..

분명 git에서 Bin파일이 압축되었음을 확인에도 불구하고 제가 생각한 것 이상의 차이는 않네요..

 

 

좀 더 자세한 비교를 위해서 성능 탭을 보았는데도 유의미한 결과를 호가인하진 못했습니다.

 

 

 

결론

1. 또한 glb파일보다 canvas 가 들어 있는 page.js 파일을 받아오는데 더 많은 리소스를 차지 (약 55배?) 확인할 수 있었습니다.

2. 성능의 차이가 존재하지만, 실제 압축된 용량에 비해서는 성능의 차이가 생각보다 크지 않았다.
--> 왜에 대해서 분석하고 싶은데,, 당장은 결론을 얻진 못헀습니다..

 

 

 

 

728x90

최근 프로젝트의 기능 구현이 마무리가 되었습니다. 하지만 아직 클린코드의 관점에서 미숙한 부분이 많습니다.

이를 값, 계산 , 액션으로 분리하기와  solid원칙을 준수하기를 바탕으로 리팩토링하려고 합니다.

 

코드 원문과 이미지는 아래와 같습니다.

import { useState } from 'react';
import Tutorial1 from './Tutorial1';
import Tutorial2 from './Tutorial2';
import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { MANUAL_SKIP } from '@/shared/contants';

export default function GuidePopUp() {
  const [step, setStep] = useState<number>(0);
  const stepChanger = (direction: 'before' | 'after') => {
    const lastStep = 1;
    if (direction === 'before') {
      if (step > 0) {
        setStep(step - 1);
      }
    } else {
      if (step < lastStep) {
        setStep(step + 1);
      }
    }
  };

  const handleDontShowAgain = () => {
    localStorage.setItem(MANUAL_SKIP, 'true');

    const escEvent = new KeyboardEvent('keydown', { key: 'Escape' });
    document.dispatchEvent(escEvent);
  };

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>
        {step === 0 && <Tutorial1 />}
        {step === 1 && <Tutorial2 />}
      </div>
      <button className={styles.hideButton} onClick={handleDontShowAgain}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

이런 지저분한 코드를 관심사를 분리해서

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { tutorials } from './content';
import { useStep } from './hook/useStep';
import HideButton from './control/HideButton';
import Navigation from './control/Navigation';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>
      <HideButton />
      <Navigation step={step} stepChanger={stepChanger} totalSteps={lastStep} />
    </div>
  );
}

이렇게 만드는 과정입니다.

 

그러기 위해선 우선 화면을 관심사에 따라서 세분화 합니다.

 공통적으로 존재하는 제목 영역, step에 따라 바뀌어야 하는 내용영역, 그리고 공통적으로 존재하는 버튼과 현재 step 영역이 존재합니다.

 

내용영역 분리하기

이 중에서 우선 가운데의 내용부분을 분리하려고 합니다.

 

내용의 경우에는 줄바꿈이나, 강조하고 싶은 색깔이 다르기 때문에 공통적인 요소로 묶기 힘드니, 개별적인 컴퍼넌트로 작성하였습니다. 이때 내용의 경우에는 html과 css를 활용해서 꾸민 '값'입니다.

따라서 Popup 컴퍼넌트에서 값을 분리하였습니다.

 

import Tutorial1 from './Tutorial1';
import Tutorial2 from './Tutorial2';

export const tutorials = [<Tutorial1 key={1} />, <Tutorial2 key={2} />];

 

import { useState } from 'react';

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { hideManualByDefault } from './util';
import { tutorials } from './content';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, setStep] = useState<number>(0);

  const stepChanger = (direction: 'before' | 'after') => {
    if (direction === 'before') {
      if (step > 0) {
        setStep(step - 1);
      }
    } else {
      if (step < lastStep) {
        setStep(step + 1);
      }
    }
  };

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>

      <button className={styles.hideButton} onClick={hideManualByDefault}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

 

이제 Tutorial 페이지가 늘어나도,  GuidePopup의 코드에는 아무런 영향이 가지 않습니다.

또한 기존에는 직접 lastStep을 지정해줬는데, 배열의 길이를 통해 마지막 단계가 어딘지 구하는 로직으로 변경되었습니다.

카카오의 아티클을 보고 다형성 기반으로 개방 폐쇠 법칙도 적용하고 싶었는데, 외부에서 불러오는 데이터도 아니고 단순한 값이기에 그 정도의 깊이 있는 분리가 나오지는 않았네요..

 

커스텀 훅으로 튜토리얼 단계변경 액션 분리하기

import { useState } from 'react';

export const useStep = (lastStep: number) => {
  const [step, setStep] = useState<number>(0);
  const stepChanger = (direction: 'before' | 'after') => {
    setStep((prevStep) => {
      if (direction === 'before' && prevStep > 0) {
        return prevStep - 1;
      }
      if (direction === 'after' && prevStep < lastStep) {
        return prevStep + 1;
      }
      return prevStep;
    });
  };

  return [step, stepChanger] as const;
};

 

해당 함수의 관심사는 이전과 다음 단계로 이동시키는 것입니다.

따라서 이를 useStep이라는 훅으로 분리하고, 튜플형태로 return 시켰습니다.

https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state

 

useState – React

The library for web and native user interfaces

react.dev

에서 set 함수의 경우 콜백함수 형태로 사용시 이전 상태를 사용할 수 있습니다. 따라서 이전 상태를 prevStep으로 명명하고 조건문을 얼리 리턴 기법을 사용하여 가독성을 향상시켰습니다.

 

import { useState } from 'react';

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { hideManualByDefault } from './util';
import { tutorials } from './content';
import { useStep } from './hook/useStep';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>

      <button className={styles.hideButton} onClick={hideManualByDefault}>
        다시 보지 않기
      </button>
      <div className={styles.naviWrapper}>
        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('before');
          }}
        >
          이전
        </button>
        {step + 1 + '/' + 2}

        <button
          className={styles.naviButton}
          onClick={() => {
            stepChanger('after');
          }}
        >
          다음
        </button>
      </div>
    </div>
  );
}

 

이제 마지막으로  버튼 영역을 분리하려고 합니다.

 버튼 영역 분리하기

우선 숨기기 버튼부터 분리하였습니다.

import React from 'react';
import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { MANUAL_SKIP } from '@/shared/contants';
import { closeModalByEvent } from '@/shared/components/portal/util';

export default function HideButton() {
  const hideManualByDefault = () => {
    localStorage.setItem(MANUAL_SKIP, 'true');
    closeModalByEvent();
  };

  return (
    <button className={styles.hideButton} onClick={hideManualByDefault}>
      다시 보지 않기
    </button>
  );
}

기존에는 util 폴더에서 hideManualByDefault 함수를 관리하였는데, 숨기기 버튼이 분리되면서 굳이 따라 관리할 필요성을 느끼지 못했습니다. 따라서 해당 함수를 HideButton 컴퍼넌트와 결합시켰습니다.

 

이후 버튼도 page 변경용도의 버튼들이기에 Navigation이라는 이름으로 분리시켰습니다.

import React from 'react';
import styles from '@/app/luckyshiba/luckyshiba.module.css';

interface NavigationProps {
  step: number;
  stepChanger: (direction: 'before' | 'after') => void;
  totalSteps: number;
}

export default function Navigation({
  step,
  stepChanger,
  totalSteps,
}: NavigationProps) {
  return (
    <div className={styles.naviWrapper}>
      <button
        className={styles.naviButton}
        onClick={() => stepChanger('before')}
      >
        이전
      </button>
      {step + 1 + '/' + totalSteps}
      <button
        className={styles.naviButton}
        onClick={() => stepChanger('after')}
      >
        다음
      </button>
    </div>
  );
}

 

최종적으로 완성된 컴퍼넌트는

import styles from '@/app/luckyshiba/luckyshiba.module.css';
import { tutorials } from './content';
import { useStep } from './hook/useStep';
import HideButton from './control/HideButton';
import Navigation from './control/Navigation';

export default function GuidePopUp() {
  const lastStep = tutorials.length;
  const [step, stepChanger] = useStep(lastStep);

  return (
    <div className={styles.manualWrapper}>
      <div className={styles.manualTitle}>게임 시작 메뉴얼</div>
      <div className={styles.manualContents}>{tutorials[step]}</div>
      <HideButton />
      <Navigation step={step} stepChanger={stepChanger} totalSteps={lastStep} />
    </div>
  );
}

 

느낀점

원티드 프리온보딩에서 멘토님께 배운 값, 계산, 액션으로 분리하고 최근 아티클을 읽은 내용을 바탕으로 solid하게 작성하고자 하였습니다.

 

간단한 메뉴얼 컴퍼넌트였기에 SOLID패턴중에서 아티클에서 읽고 느낀 모든 내용을 반영하지는 못하였습니다.

하지만 해당 아티클에서 말하였듯이 SOLID에서 가장 핵심은 Single Responsibility Principle 이며, 관심사의 분리에 집중하여 리팩토링을 진행하였습니다.

또한 SOLID원칙을 요약하는건 간단하지면 각각의 키워드에 맞게 작성하는 과정은 좀 더 깊이 공부하고 생각할 필요성을 느꼈습니다.

아직 완벽하진 않을 수 있지만, 초기 코드에 비해서는 훨씬 가독성이 향상되었다는 점에서 보람을 느꼈습니다.

 

참고문헌

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

 

프론트엔드와 SOLID 원칙 | 카카오엔터테인먼트 FE 기술블로그

임성묵(steve) 판타지, 무협을 좋아하는 개발자입니다. 덕업일치를 위해 카카오페이지로의 이직을 결심했는데 인사팀의 실수로 백엔드에서 FE개발자로 전향하게 되었습니다. 인생소설로는 데로

fe-developers.kakaoent.com

https://www.perssondennis.com/articles/write-solid-react-hooks

 

Persson Dennis - Write SOLID React Hooks

Write SOLID React Hooks June 2, 2024 (19 minutes reading) SOLID is one of the more commonly used design patterns. It's commonly used in many languages and frameworks, and there are some articles out there how to use it in React as well. Each React article

www.perssondennis.com

 

원티드 프리온보 오종택 멘토님 강의

 

728x90

들어가며

초기에는 간단하게 position을 통해서 전진 후진 회전만 구현하였습니다.

https://ungumungum.tistory.com/121

 

R3F에서 시바를 키보드 입력을 통해 회전시키기(트러블 슈팅)

들어가며좀 더 interactive한 웹을 만들기 위해서 최근 three.js 기반의 R3F 라이브러리를 활용하여, 웹 페이지를 만들고 있습니다.강아지를 활용하여서 이동하게 하려고 하는데, 강아지의 이동 및 회

ungumungum.tistory.com

 

총 4가지의 방법을 도입했었고, 결국 어떤 방법을 도입했는지에 대한 설명을 하고자 합니다.

1. position 기반 이동시키기

2. velocity 기반 이동시키기

3. force 기반 이동시키기

4. chassicBody를 이용한 자동차형으로 이동시키기

 

그리고 R3F에서 위치를 이동시키기 위해선 키보드 이벤트를 감지하여 이동시켜야 합니다. 

이때 useInput 커스텀훅을 사용하여서 wasd space를 감지하였고,, 이를 기반으로 이동을 시키는데, 2가지 방식으로 이동이 가능합니다.

1. useEffect : 상태가 변화했을 때 실행
    2번과 4번같은 경우에는 물체의 속도를 정하고, 바퀴의 회전속도를 정하여서 이동합니다. 따라서 전진 후진등의 버튼의      상태가 변화했을 때 적용시키면 됩니다.

2. useFrame : 매 프레임마다 상태를 확인하여 실행

    1번과 3번의 position을 이동시키거나 힘을 가하는 방식에서는 매 프레임 확인하여 위치를 변경해야합니다.

 

이 중에서 어떤 방식을 왜 기각했고, 결국 왜 position 기반 이동 방식을 도입하게 되었는지에 대해서 작성하고자 합니다.

 

제가 고민 및 도입했던 과정은 1 4 3 2 1 순이며, 1번의 경우 앞선 글에서 설명했으니, 4 3 2 1 순으로 설명하고자 합니다.

 

각각의 방식 도입과 기각 이유

chassicBody를 이용한 자동차형으로 이동

도입배경은 useCannon에서 충돌체가 불안정하기 때문입니다. 참고 : https://ungumungum.tistory.com/122

 

R3F에서 물체가 지나갈 수 있는 언덕 만들기(react-three/cannon)

아래와 같은 환경에서  언덕을 만들어주려고 합니다.cannon-es 공식문서에서 간단하게 Trimesh를 이용해서 삼각형을 만드는 법을 알려주는데,  이를 기반으로 react-three/cannon에서 삼각형 모양의 기

ungumungum.tistory.com

 

box나 cylinder 이외의 다른 충돌체의 경우 충돌이 불안정하였고, 이중 cylinder가 타 충돌체와의 상호작용이 안정적이었습니다.

따라서 다리를 만들어하는 제 입장에서 바퀴가 cylinder인 방식을 도입한다면 좋을 것이라 판단하였습니다.

 

useCannon에서는 기본적으로 자동차 엔진과 유사한 형식으로 작동하는 훅(useRayCastVehicle)을 제공하고 있습니다.

(useCannon은 결국 cannon을 확장해서 만든거라 설명은 cannon-es가 더 자세합니다.)

 

이를 기반으로 바퀴를 조정하는 바퀴 및 이동 물체의 정보를 받아서, 조종하는 훅을 만들었습니다. (관심사 분리가 명확하지 않은점은 죄송합니다 ㅠ.)

//useMoveCar.ts
import { useEffect, useRef } from 'react';
import { useInput } from './useInput';
import {
  PublicApi,
  RaycastVehiclePublicApi,
  useRaycastVehicle,
} from '@react-three/cannon';
import { Group, Object3DEventMap, Quaternion, Vector3 } from 'three';
import { useWheels } from './useWheels';

type MoveCarProps = {
  chassisApi: PublicApi;
  chassisBody: React.RefObject<Group<Object3DEventMap>>;
  worldDirection: Vector3;
  bodyInfo: {
    width: number;
    height: number;
    front: number;
  };
};

export const useMoveCar = ({
  chassisApi,
  worldDirection,
  chassisBody,
  bodyInfo,
}: MoveCarProps) => {
  const { forward, backward, left, right, jump, stand } = useInput();
  const engineForce = 400;
  const velocity = useRef([0, 0, 0]);
  const { width, height, front } = bodyInfo;
  const [wheels, wheelInfos] = useWheels({ width, height, front });

  const [vehicle, vehicleApi] = useRaycastVehicle(
    () => ({
      chassisBody,
      wheelInfos,
      wheels,
    }),
    useRef<Group>(null)
  );

  useEffect(() => {
    chassisApi.velocity.subscribe((v) => (velocity.current = v));

    if (forward) {
      vehicleApi.applyEngineForce(engineForce, 2);
      vehicleApi.applyEngineForce(engineForce, 3);
    } else if (backward) {
      vehicleApi.applyEngineForce(-engineForce, 2);
      vehicleApi.applyEngineForce(-engineForce, 3);
    } else {
      vehicleApi.applyEngineForce(0, 2);
      vehicleApi.applyEngineForce(0, 3);
    }

    if (left) {
      vehicleApi.setSteeringValue(0.35, 2);
      vehicleApi.setSteeringValue(0.35, 3);
      vehicleApi.setSteeringValue(-0.1, 0);
      vehicleApi.setSteeringValue(-0.1, 1);
    } else if (right) {
      vehicleApi.setSteeringValue(-0.35, 2);
      vehicleApi.setSteeringValue(-0.35, 3);
      vehicleApi.setSteeringValue(0.1, 0);
      vehicleApi.setSteeringValue(0.1, 1);
    } else {
      for (let i = 0; i < 4; i++) {
        vehicleApi.setSteeringValue(0, i);
      }
    }
    if (jump) {
      const [x, y, z] = velocity.current;
      chassisApi.velocity.set(x * 1.2, y + 7, z * 1.2);
    }

    if (stand) {
      const rotateQuaternion = new Quaternion().setFromAxisAngle(
        new Vector3(0, 1, 0),
        Math.PI / 2
      );
      const currentQuaternion = new Quaternion().setFromUnitVectors(
        new Vector3(1, 0, 0),
        worldDirection
      );
      currentQuaternion.multiply(rotateQuaternion); // y축 회전 적용
      chassisApi.quaternion.set(
        currentQuaternion.x,
        currentQuaternion.y,
        currentQuaternion.z,
        currentQuaternion.w
      ); // 쿼터니언을 직접 설정
    }
  }, [
    backward,
    stand,
    worldDirection,
    chassisApi,
    forward,
    jump,
    left,
    right,
    vehicleApi,
  ]);

  return { vehicle, vehicleApi };
};

 

기능은 총 4가지로 만들었습니다.  
1. 전후진 , 2. 좌우 바퀴회전 , 3.  점프(바퀴가 공중에 뜨기 때문에 x.z축으로 약간의 가속), 4. 서기(전복 시 사용)

 이동과 trimesh로 만든 다리를 건너는 기능까지는 가능합니다. 

 

문제점

하지만 여러 단점이 발견되었고, 결국 일반적인  Box로 움직이도록 하기로 결정하였습니다. 

1. 하지만 문제점이 언덕에서 잘 미끄러짐.

2. 바퀴의 크기가 작으면 몸체가 충돌하여 언덕 잘 못 올라감

3. 바퀴가 크면 언덕을 올라가기전에 종종 충돌해서 멈춤

4. 시바가 자동차처럼 움직여서 부자연스러움

 

force 를 이용한 이동

  const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      angularDamping: 0.95,
      onCollide: () => {
        !isLanded && setIsLanded(true);
      },
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef<Group>(null)
  );
chassicApi.applyForce([x,y,z])

 

다음과 같이 cannon의 충돌체에는 힘을 가할 수 있는 옵션이 있습니다. 이를 바탕으로 시바가 바라보고 있는 방향으로 힘을 가하여 이동시키는 로직에, 회전의 경우 기존 y축 기준으로 회전시키는 방법을 도입하면 어떤가 했습니다.

 

문제점

기본적으로 -y축 방향으로 9.8의 중력이 가해지고 있습니다. 이때 힘을 가하여 이동시키니 시바가 이동하는게 아닌 구르기 시작했습니다.. 

 

그렇기에 회전시에 마찰력을 존재하게 해서 구르지 못하도록 하는 방식을 도입하려고 했습니다.

위의 코드의 useCompoundBody 훅을 보시면 angularDamping이라는 각회전시 마찰력이 존재합니다. 이를 1로 하면 회전하지 않게 되는데, 이런 경우에는 언덕을 못 올라가는 문제가 발생하였습니다..

언덕을 볼 때는 30도 위를 쳐다봐야하는데, 그러지 않으니 힘을 가해도 언덕에 부딪히기만 하는 문제가 생겼습니다.

 

velocity 를 이용한 이동

chassicApi.velocity.set(x,y,z)

마찬가지로 속력을 가하여 이동시키는 방식을 도입하였는데, 해당 방식의 경우에 이동은 제대로 되었습니다.

언덕도 올라갈 수 있고, 적당한 angularDamping (0.95)를 설정시 구르지도 않게 되었습니다.

 

문제점 

하지만  전진할 때 속력을 부여하는 방식은 점프와 같이 쓰기엔 불안정했습니다.

일반적인 이동에서는 y방향으로의 속력을 0으로 설정합니다. 문제는 공중에 떠 있을 때 입니다.

중력의 영향을 받기에 내려가야하는데 , y축의 속력이 0이 아니기에 낙하하는 속력을 구해서 직접 반영해야합니다.

 

매번 shiba의 y축 속력을 구해서 이동시키는건 너무 비효율적인 방식이어서 최종적으로 position을 기반으로 이동하는 방식을 도입하였습니다.

 

 

position 방식과 코드

추후에 이벤트 발생과도 연계해야했기에, 점프를 하고 있다면 전역변수를 통해 착륙하지 않음(isLanded = false)를 만들었고, 타 물체와 충돌 시에 착륙으로 만드는 로직을 구현하였습니다.

import { Group, Object3DEventMap, Quaternion, Vector3 } from 'three';
import { useInput } from './useInput';
import { PublicApi } from '@react-three/cannon';
import { useMemo } from 'react';
import { useShibaStore } from '@/store/shiba';
type MovePositionProps = {
  worldPosition: Vector3;
  worldDirection: Vector3;
  chassisApi: PublicApi;
  chassisBody: React.RefObject<Group<Object3DEventMap>>;
};

export const useMovePosition = ({
  worldDirection,
  worldPosition,
  chassisApi,
  chassisBody,
}: MovePositionProps) => {
  const { forward, backward, left, right, jump, stand } = useInput();
  const worldQuaternion = useMemo(() => new Quaternion(), []);
  const { isLanded, setIsLanded, blockEvent, eventable } = useShibaStore();
  const controlMovement = (delta: number) => {
    if (forward || backward) {
      const speed = delta * 7;
      let { x, y, z } = worldPosition;
      let { x: rx, y: ry, z: rz } = worldDirection;
      let [newX, newY, newZ] = [x, y, z];

      if (forward) {
        newX += rx * speed;
        newZ += rz * speed;
      }
      if (backward) {
        newX -= rx * speed;
        newZ -= rz * speed;
      }
      chassisApi.position.set(newX, newY, newZ);
    }

    if (right || left) {
      const turnAngle = delta;
      const turnQuaternion = new Quaternion();
      chassisBody?.current!.getWorldPosition(worldPosition);

      if (right) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), -turnAngle);
      }
      if (left) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), turnAngle);
      }
      worldQuaternion.multiplyQuaternions(turnQuaternion, worldQuaternion);
      chassisApi.quaternion.set(
        worldQuaternion.x,
        worldQuaternion.y,
        worldQuaternion.z,
        worldQuaternion.w
      );
    }

    if (jump) {
      if (isLanded) {
        setIsLanded(false);
      }
      chassisApi.velocity.set(0, 5, 0);
    }
  };

  return controlMovement;
};

 

728x90

문제점 및 해결과정

 

프로젝트를 진행하는 도중 아래와 같은 에러가 발생했다. 문제점은 한동안 개발자 도구의 메시지를 선택해놓고 작업을 해서 언제부터 발생했는지 못하는 상황이다..

 

마침 구글링을 해보니 관련 issue가 논의되었어서 해당 이슈를 참고하였다.

https://github.com/vercel/next.js/discussions/22388

 

Warning: Extra attributes from the server: style · vercel next.js · Discussion #22388

I'm not very experienced with Next.js/React, please forgive me if my question is dumb. As a first project, I tried to migrate my existing static website to NextJS. So far everything works, except o...

github.com

 

사람들이 많은 추론을 하고 있었는데 
1. 프토퍼티를 잘못 적었다.

2. 스타일을 잘못했다.

3. ul li 문젠거 같다.

4. 특정 스타일을 지우니 해결됐다. 

5. extension 문제다

 

우선적으로 내 코드에 문제가 있는지 확인하기 위해서 옛날 commit 시점으로 돌아가보았다. 그런데 분명히 해당 에러가 발생하지 않았던 시점에서도   동일한 에러가 발생하고 있었다.

따라서 가장 합리적인 추론은 5번 extension이다.

 

하지만 사람들마다 지웠다는 extension도 다르고,,, 일단 하나씩 확인해보기로 했다.

 

 

내 경우에는 ZeroBlur이었다...

 

해당 extensition을 지우니 에러가 사라졌다.

 

 

느낀점

에러는 굉장히 다양한 원인에서 발생한다.. 그런데 모듈간의 결합에 의한 에러는 굉장히 찾기 힘들고, 아직.. 왜 그런지에 대한 해답을 찾기가 너무 어렵다...

next.js와 R3F drei의 html의 결합에서 생긴 에러도 아직 명확히 해결하지 못했었다..

아직 성장할 길이 많이 남았다는 것을 느꼈다..

728x90

 

아래와 같은 환경에서  언덕을 만들어주려고 합니다.

cannon-es 공식문서에서 간단하게 Trimesh를 이용해서 삼각형을 만드는 법을 알려주는데,  이를 기반으로 react-three/cannon에서 삼각형 모양의 기둥을 만들어주려고 합니다.

https://pmndrs.github.io/cannon-es/docs/classes/Trimesh.html

 

  // 꼭짓점 좌표
  const x = 5;
  const y = 3;
  const z = 10;
  const vertices = [
    0,
    0,
    0, // 꼭짓점 1
    x,
    y,
    0, // 꼭짓점 2
    x,
    0,
    0, // 꼭짓점 3
    0,
    0,
    -z, // 꼭짓점 4
    x,
    y,
    -z, // 꼭짓점 5
    x,
    0,
    -z, // 꼭짓점 6
  ];

  // 삼각형 인덱스
  const indices = [
    0,
    1,
    2, // 앞쪽 삼각형
    3,
    4,
    5, // 뒤쪽 삼각형
    0,
    1,
    4, // 왼쪽 사각형 위
    0,
    4,
    3, // 왼쪽 사각형 아래
    1,
    2,
    5, // 오른쪽 사각형 위
    1,
    5,
    4, // 오른쪽 사각형 아래
    0,
    2,
    5, // 아래쪽 사각형
    0,
    5,
    3, // 아래쪽 사각형
  ];

  const [ref] = useTrimesh(() => ({
    args: [vertices, indices],
    position: [0, 0, 0],
    rotation: [0, Math.PI, 0],
    mass: 100,
    type: 'Static',
    collisionFilterGroup: 3,
  }));

indices는 각각의 꼭지점을 잇는 것이고

해당 번호는 vertices에 있는 3개의 점이 각각 x,y,z축으로 하나의 꼭짓점이 됩니다.

 

하지만 위 사진을 보면 알 수 있듯이 문제점이 발생하였습니다. 시바 충돌체가 언덕안에 들어가 있느 것이죠.. 그런데 언덕이 정작 바닥 위에는 떠있는 상황입니다.

 

문제 상황

Trimesh가 중력에는 영향을 받고, 바닥과는 충돌을 하는데 시바랑은 전혀 충돌을 하고 있지 않습니다.

react-three/cannon 및 cannon-es에서 속성 등,, 공식 문서를 이것 저것 많이 찾고  고생을 했었는데, 아래와 같은 표를 확인하게 됩니다.

2024-06-04일 기준

https://pmndrs.github.io/cannon-es/docs/index.html

 

문제는 바닥과는 충돌하는데 시바랑은 충돌하지 않는 것이었쬬,

시바는 Box , 바닥은 Plane,...  react-three/cannon도 결국 cannon-es를 이용해서 만든 라이브러리이기에 마찬가지 이겠쬬,,,

 

해결 방안 모색

두 가지 해결 방법이 있다고 생각합니다. 

1. Sphere를 이용한다. 

그냥 sphere를 이용하면 시바가 데굴데굴 구르겠죠.. 하지만 언덕에서만 구르도록 박스 안에 구를 넣는 방식으로 구현을 한다면 언덕을 이용할 수 있을 겁니다..

 

2. 언덕을 벽이 아닌 Box로 만든 후 기울인다.

이 방법을 사용하면 결국 언덕과 유사한 효과를 만들 수 있겠군요.

 

3. 바퀴 형식을 도입하여, 움직인다.

바퀴를 sphere로 하고 회전 시키는 경우에 시바는 데굴데굴 구르지 않을 겁니다.

해당 방법을 도입하였지만,, 움직임이 부자연스러워서  기각,,

 

최종 방법은  언덕을 만들어서 기울이기로 하였습니다.

position 관리에 용이하게 하기 위해서 useCompoundBody를 사용하였고,

아래와 같이 값을 분리하여 사용하였습니다

  const [houseBody, _] = useCompoundBody(
    () => ({
      mass: 0,
      rotation: [0, 0, 0],
      collisionFilterGroup: 3,
      type: 'Static',
      shapes: [...HOUSE_GROUND, ...HOUSE_SHAPE, ...HOUSE_STAIR],
    }),
    useRef(null)
  );
export const HOUSE_SHAPE: CompoundShape = [
  {
    //굴뚝
    args: [1.2, 10, 1.2],
    position: [-12.3, 11.9, 0.8],
    type: 'Box',
  },
  {
    //집
    args: [4.2, 5.5, 4],
    position: [-10.3, 9.5, 3],
    type: 'Box',
  },
  {
    args: [5, 5.5, 1],
    position: [-10.3, 12.8, 0.8],
    rotation: [Math.PI / 5, 0, 0],
    type: 'Box',
  },
  {
    args: [5, 5.5, 1],
    position: [-10.3, 12.8, 3.5],
    rotation: [-Math.PI / 5, 0, 0],
    type: 'Box',
  },
  {
    args: [8, 1.5, 5.5],
    position: [-11.3, 10.5, 2.5],
    type: 'Box',
  },
  {
    args: [4, 1.5, 4],
    position: [-13.5, 12.2, 2.5],
    rotation: [0, 0, Math.PI / 3.3],
    type: 'Box',
  },
];

export const HOUSE_STAIR: CompoundShape = [
  {
    args: [4, 1.5, 2],
    position: [-8, 1.9, -9],
    rotation: [0, 0, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 1.5, 2],
    position: [-10, 3, -8.3],
    rotation: [0, Math.PI / 4, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 1.5, 2.4],
    position: [-10.7, 5, -6],
    rotation: [0, Math.PI / 2, -Math.PI / 5.1],
    type: 'Box',
  },
  {
    args: [4, 6, 0.5],
    position: [-12, 3.5, -5.7],
    rotation: [Math.PI / 4, Math.PI / 2, -0.5],
    type: 'Box',
  },
  {
    args: [3, 2, 4],
    position: [-12, 1, -7.7],
    rotation: [-0.2, 0, 0.2],
    type: 'Box',
  },
];

 

힘들었던 점

 

이중에서 useConvexPolyhedron을 사용해봤다. 복잡한 다면체를 구현하기에 효과적인 방법 중 하난데 꼭지점과 꼭지점을 연결하여 면을 만들고, 해당 면의 수직축을 정하여서 충돌체를 만드는 방식이다.

 

다만 cannon-es 에 의해 공식 문서 스펙대로 구현하였으나 (vertices 꼭지점, faces 면, normals 면이바라보는 방향) 충돌이 올바르게 일어나지 않았다. ( 통과할 때도 있고 통과하지 않을때도 있고,, 불안정한 기능)

   const width = 4;
   const length = 17;
   const vertices: Triplet[] = [
     [9.5, 4.5, width],
     [9.5, 4.5, 0],
     [7, 4.5, width],
     [7, 4.5, 0],
     [0, 2.5, width],
     [0, 2.5, 0],
     [length, 2.5, width],
     [length, 2.5, 0],
     [0, 0, width],
     [0, 0, 0],
     [length, 0, width], //10
     [length, 0, 0], //11
     [10, 2.5, width],
     [10, 2.5, 0],
     [7, 2.5, width],
     [7, 2.5, 0],
   ];

   const faces = [
     [0, 1, 2, 3],
     [2, 3, 4, 5],
     [0, 1, 6, 7],
     [4, 5, 8, 9],
     [6, 7, 10, 11],
     [10, 11, 12, 13],
     [8, 9, 14, 15],
     [12, 13, 14, 15],
     [5, 9, 3, 15],
     [2, 4, 8, 14],
     [0, 2, 12, 14],
     [1, 3, 13, 15],
     [0, 12, 6, 10],
     [1, 7, 13, 11],
   ];

   function calculateNormal(v0, v1, v2) {
     const vector1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];

     const vector2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];

     const normal = [
       vector1[1] * vector2[2] - vector1[2] * vector2[1],
       vector1[2] * vector2[0] - vector1[0] * vector2[2],
       vector1[0] * vector2[1] - vector1[1] * vector2[0],
     ];

     const length = Math.sqrt(
       normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]
     );

     return [normal[0] / length, normal[1] / length, normal[2] / length];
   }

   const normals = faces.map((face) => {
     const [v0, v1, v2] = face.map((index) => vertices[index]);
     return calculateNormal(v0, v1, v2);
   });

   console.log(normals, 'normals');

 

따라서 차선으로 다리같은 복잡한 물체도 box의 조합으로 구현하였다.

728x90

들어가며

좀 더 interactive한 웹을 만들기 위해서 최근 three.js 기반의 R3F 라이브러리를 활용하여, 웹 페이지를 만들고 있습니다.

강아지를 활용하여서 이동하게 하려고 하는데, 강아지의 이동 및 회전에 대해서 학습한 점과 트러블슈팅을 기록 및 공유하고자 작성하게 되었습니다.

 

간단한 배경 설명 

SketchFab  에서 얻은 Shiba 모델을 tsx컴퍼넌트화 하였습니다.  해당 모델은 아쉽게도 animation이 없더군요.. 그렇지만 

@react-three/cannon 라이브러리를 통해 만든 물리엔진속에서 강아지가 움직일 수 있도록 하게 하고 싶었습니다.

     <group ref={chassisBody} {...props}>
        <group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
          <mesh
            geometry={nodes.Group18985_default_0.geometry}
            material={materials['default']}
          />
          <mesh
            geometry={nodes.Box002_default_0.geometry}
            material={materials['default']}
          />
          <mesh
            geometry={nodes.Object001_default_0.geometry}
            material={materials['default']}
          />
        </group>
      </group>

 

const position: [x: number, y: number, z: number] = [0, 1, 0];
  const width = 0.65;
  const height = 1.2;
  const front = 0.6;
  const mass = 50;

  const chassisBodyArgs = [width, height, front * 2];

  const [chassisBody, chassisApi] = useCompoundBody(
    () => ({
      position,
      mass: mass,
      rotation: [0, 0, 0],
      collisionFilterGroup: 5,
      shapes: [
        {
          args: chassisBodyArgs,
          position: [0, 0, 0],
          type: 'Box',
        },
      ],
    }),
    useRef(null)
  );

 

useCompoundBody 훅은 충돌체로 지정하기 위해서 사용하는데,(복합체의 경우에 사용하는데 위처럼 하나의 대상인 경우, useBox를 사용해도 됩니다.) 위에 설정한 옵션을 기반으로 대상을 정의하였습니다.

 

이후  키보드 입력을 다룰 useInput 훅을 생성하였습니다.

wasd를 누를 때  해당 값을 boolean형태로 기억하여, forward가 true면 전진 하는 식으로 로직을 구상하려 합니다.

(점프의 경우는 수정할 것 같습니다..)

import { useEffect, useState } from 'react';

type InputState = {
  forward: boolean;
  backward: boolean;
  left: boolean;
  right: boolean;
  jump: boolean;
};

type KeyMap = {
  [key: string]: keyof InputState;
};

export const useInput = (): InputState => {
  const [input, setInput] = useState<InputState>({
    forward: false,
    backward: false,
    left: false,
    right: false,
    jump: false,
  });

  useEffect(() => {
    const keys: KeyMap = {
      KeyW: 'forward',
      KeyS: 'backward',
      KeyA: 'left',
      KeyD: 'right',
      Space: 'jump',
    };

    const findKey = (key: string): keyof InputState | undefined => keys[key];

    const handleKeyDown = (e: KeyboardEvent): void => {
      const key = findKey(e.code);
      if (key) {
        setInput((prevInput) => ({ ...prevInput, [key]: true }));
      }
    };

    const handleKeyUp = (e: KeyboardEvent): void => {
      const key = findKey(e.code);
      if (key) {
        setInput((prevInput) => ({ ...prevInput, [key]: false }));
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('keyup', handleKeyUp);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('keyup', handleKeyUp);
    };
  }, []);

  return input;
};

 

이제 키보드 이벤트가 성공적으로 입력되니 이를 바탕으로 모델을 움직이기만하면 됩니다.

앞서 Shiba의 경우 useComboundBody 훅으로 얻은 튜플값들 (chassisBody, chassisApi)을 활용하여 제어하면 됩니다.

 

기본적으로 @react-three/cannon 라이브러리의 경우에 cannon-es를 react에서 쓸 수 있도록 확장하였습니다.

 

트러브 슈팅 

우선 회전대상을 제어하려면 chassisBody, chassisApi을 활용해야 할 것 입니다.

각각 어떤 속성을 가지고 있는지 확인하기 위해서 콘솔을 우선 찍어 보았습니다.

 

Body
Api

 

quaternion이 무엇인지 잘 몰랐어서,, rotation을 활용하여 회전된 방향으로 이동을 시키면 되지 않을까? 라는 생각을 했습니다. 결론 부터 말하자면 quaternion은 Unitiy에서 주로 쓰는 회전을 다루기 위해서 쓰는 값입니다..

이걸 미리 알았으면 편했을텐데,,,

 

 

1. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴

  useFrame((state, delta) => {
    chassisApi.position.subscribe((pos) => {
      let [x, y, z] = pos;
      chassisApi.rotation.subscribe((rot) => {
        let [rx, ry, rz] = rot;
        if (forward) {
          x += Math.sin(ry) * delta;
          z += Math.cos(ry) * delta;
        }
        if (backward) {
          x -= Math.sin(ry) * delta;
          z -= Math.cos(ry) * delta;
        }
        if (right) {
          ry -= delta;
        }
        if (left) {
          ry += delta;
        }
        chassisApi.position.set(x, y, z);
        chassisApi.rotation.set(rx, ry, rz);
      });
    });
  });

 

콜뱀함수를 통해 실행하다 보니 많은 부하가 걸림. 프레임마다 시켜서 그런 것 같습니다.. 따라서

current에 있는 값을 쓰면 되지 않을까 확인해보니, 해당 값은 초기 설정값만 나오고 중력에 의해서 위치가 변경되어도 똑같은 값이 계속 나왔습니다..

 

 

 따라서 '로직을 변경하면 나아지지 않을까?' 생각하게 되었고, 콜백함수 형식에서 state에 값을 할당하는 방식으로 변경하였습니다.

 

2. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴

  const [chassisPosition, setChassisPosition] =
    useState<[number, number, number]>(position);
  const [chassisRotation, setChassisRotation] = useState<
    [number, number, number]
  >([0, 0, 0]);

  useEffect(() => {
    const unsubscribePosition = chassisApi.position.subscribe((pos) => {
      setChassisPosition(pos as [number, number, number]);
    });

    const unsubscribeRotation = chassisApi.rotation.subscribe((rot) => {
      setChassisRotation(rot as [number, number, number]);
    });

    return () => {
      unsubscribePosition();
      unsubscribeRotation();
    };
  }, [chassisApi]);

  useFrame((state, delta) => {
    let [x, y, z] = chassisPosition;
    let [rx, ry, rz] = chassisRotation;

    if (forward) {
      x += Math.sin(ry) * delta;
      z += Math.cos(ry) * delta;
    }
    if (backward) {
      x -= Math.sin(ry) * delta;
      z -= Math.cos(ry) * delta;
    }
    if (right) {
      ry -= delta;
    }
    if (left) {
      ry += delta;
    }

    chassisApi.position.set(x, y, z);
    chassisApi.rotation.set(rx, ry, rz);
  });

 

하지만 여전히 문제점이 있었습니다.  회전값의 경우에 ry가 왼쪽 90에서 최솟값( - π)오른쪽 90도에서 최댓값( π)

앞 뒤에서는 ry값이 모두 0이 나옵니다.

즉 값을 단순히 더하거나 빼서는 안됩니다.  이때 앞면 뒷면에 따라서 rx의 부호가 바뀌기 때문에 이를 분기 삼아서 처리할 수 있습니다.. 하지만  회전 및 이동하다 보면 버벅거리는 버그가 여전히 생겼습니다..

 

따라서,, 좀 더 공부할 필요성을 느끼고, cannon-es 및 react-three/cannon 의 소스 코드를 참고하였습니다.

 

 

해결 방법

앞서 얘기했듯이 회전을 수월하게 해주 Quaternion을 활용했습니다.

1. 우선 시바의 위치, 회전, 회전축을 기록할 각각의 변수를 생성합니다.

  const worldPosition = useMemo(() => new Vector3(), []);
  const worldDirection = useMemo(() => new Vector3(), []);
  const worldQuaternion = useMemo(() => new Quaternion(), []);

 

2. 시바를 따라다닐 카메라를 설치하였는데, 이 과정에서 chassicBody의 함수인 getWorldPosition을 통해 시바의 위치를 변수에 할당해줍니다.

 const makeFollowCam = () => {
    chassisBody?.current!.getWorldPosition(worldPosition);
    chassisBody?.current!.getWorldDirection(worldDirection);
    pivot.position.lerp(worldPosition, 0.9);
  };

 

3. 해당 값을 활용하여 이동 및 회전을 시킵니다.

const controlMovement = (delta: number) => {
    if (forward || backward) {
      const speed = delta * 2;
      let { x, y, z } = worldPosition;
      let { x: rx, y: ry, z: rz } = worldDirection;

      let [newX, newY, newZ] = [x, y, z];

      if (forward) {
        newX += rx * speed;
        newZ += rz * speed;
      }
      if (backward) {
        newX -= rx * speed;
        newZ -= rz * speed;
      }
      chassisApi.position.set(newX, newY, newZ);
    }

    if (right || left) {
      const turnAngle = delta;
      const turnQuaternion = new Quaternion();

      if (right) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), -turnAngle);
      }
      if (left) {
        turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), turnAngle);
      }
      worldQuaternion.multiplyQuaternions(turnQuaternion, worldQuaternion);
      chassisApi.quaternion.set(
        worldQuaternion.x,
        worldQuaternion.y,
        worldQuaternion.z,
        worldQuaternion.w
      );
    }
  };

 

 

 

이동 로직은 비슷하나 회전 로직을 수정하였습니다.  회전의 경우 left,right의 경우에만 이동의 경우 forward, backward의 경우에만 하므로 조건문을 통해 분기처리 하였습니다.

 

그리고 해당 코드를 useFrame훅에서 실행해줍니다.

 useFrame((_, delta) => {
    makeFollowCam();
    controlMovement(delta);
  });

 

followCam의 경우에 fastcampus 강의에서 배웠떤 것을 사용하였는데, 자동차 이동과 달리 카메라가 부자연스럽네요.. 그래도 시바의 회전이 잘 되고 있습니다.

 

 

느낀점

R3F도 그렇고  react-three/cannon 도 그렇고 기반이 되는 라이브러리에 대한 학습이 우선시 되어야 한다고 느꼈습니다.

또한 아직 관심사의 분리면에서 코드가 불안정해 보여서 코드를 develop할 필요성을 느끼고 있습니다.

보통 중력이나 힘을 이용해서 물체의 이동을 많이 구현하는데, 다른 방법으로 구현하는 과정이 재밌었네요.. 

 

728x90

개선 이유와 방법

업데이트 로직을 바꾸면 효율적으로 되지 않을까 라는 생각이 들어서 시작하게 되었다.
아래는 현재 업데이트 로직이다.

https://ungumungum.tistory.com/95 구현부분
1. 게임 종료되면 업데이트 하지 않음
2. 줄을 지울 수 있는 지를 먼저 확인함

3. 만약 지워야할 줄이 있다면, 수정함

4. 그게 아니라고 현재 블록이 존재하지 않는다면, 랜덤 블럭을 생성해서 현재 블럭으로 저장하고  블럭 생성 가능하다면, 현재을 랜더링함, 그게 아니라면 겜 종료..
5. 만약 현재 블록이 존재한다면, timer를 통해서 블록을 내리고, 아래쪽 이동이 안되는 상황이라면 블럭을 위치시킴.

public update(time: number, delta: number): void {
    if (this.gameEnd) return;

    const { isClear, line } = this.checkForClearableLines();
    if (line !== undefined) {
      this.clearLines(line);
      this.lineDown(line);
    } else {
      if (this.currentTetrisBlock === undefined) {
        const block = this.spawnRandomBlock(
          GameConfig.MainScene.GAME_BOARD_WIDTH_CNT / 2,
          0
        );
        if (this.canSpawnBlock(block)) {
          this.currentTetrisBlock = block;
        } else {
          this.currentTetrisBlock = this.setLastBlockPos(block);
          this.gameEnd = true;
          this.scene.cameras.main.shake(500);
        }
      } else {
        if (this.timerManger.checkBlockDropTime()) {
          if (this.canMoveBlock(0, 1)) {
            this.currentTetrisBlock.move(0, 1);
          } else {
            this.placeBlock();
          }
        }
      }
    }
  }

 

키보드 space를 누를떄도 dropBlock 함수가 발동하면서 내려가는데, 이 경우에도 placeBlock 함수가 최종적으로 실행되는 것을 볼 수 있다.

 // 키보드 상호작용함수
 private handleKeyUp(event: { keyCode: number }) {
    if (this.currentTetrisBlock === undefined || this.gameEnd) return;

    if (
      event.keyCode === Phaser.Input.Keyboard.KeyCodes.LEFT &&
      this.canMoveBlock(-1, 0)
    ) {
      this.currentTetrisBlock?.move(-1, 0);
    } else if (
      event.keyCode === Phaser.Input.Keyboard.KeyCodes.RIGHT &&
      this.canMoveBlock(1, 0)
    ) {
      this.currentTetrisBlock?.move(1, 0);
    } else if (
      event.keyCode === Phaser.Input.Keyboard.KeyCodes.DOWN &&
      this.canMoveBlock(0, 1)
    ) {
      this.currentTetrisBlock?.move(0, 1);
    } else if (
      event.keyCode === Phaser.Input.Keyboard.KeyCodes.UP &&
      this.canRotateBlock()
    ) {
      this.currentTetrisBlock?.rotate();
    } else if (event.keyCode === Phaser.Input.Keyboard.KeyCodes.SPACE) {
      this.dropBlock();
    }
  }

 

 //dropBlock 함수
 private dropBlock() {
    if (this.currentTetrisBlock === undefined) return;

    for (let y = 0; y < GameConfig.MainScene.GAME_BOARD_HEIGHT_CNT; y++) {
      if (!this.canMoveBlock(0, y)) {
        this.currentTetrisBlock.move(0, y - 1);
        break;
      }
    }
    this.placeBlock();
  }

 

 

 

여기서 의문점이 생겼다.

1. 테트리스에서는 언제 줄이 삭제가 될까?  === 한 줄이 가득찰 때

2. 테트리스에서 언제 줄이 가득 찰까?  === 블록이 놓였을 때

즉 update함수는 프레임마다 실행이 되게 되는데, 이럴 경우  매번 아래의 함수가 호출된다.

  private checkForClearableLines() {
    for (let y = GameConfig.MainScene.GAME_BOARD_HEIGHT_CNT - 1; y >= 0; y--) {
      let isClear = true;
      for (let x = 0; x < GameConfig.MainScene.GAME_BOARD_WIDTH_CNT; x++) {
        if (this.board[y][x] === 0) {
          isClear = false;
          break;
        }
      }
      if (isClear) {
        return { isClear, line: y };
      }
    }
    return { isClear: false };
  }

  private clearLines(line: number) {
    for (let x = 0; x < GameConfig.MainScene.GAME_BOARD_WIDTH_CNT; x++) {
      this.board[line][x] = 0;
    }
  }

  private lineDown(line: number) {
    for (let x = 0; x < GameConfig.MainScene.GAME_BOARD_WIDTH_CNT; x++) {
      for (let y = line; y >= 1; y--) {
        this.board[y][x] = this.board[y - 1][x];
        this.board[y - 1][x] = 0;
      }
    }
  }

 

삭제 할 수 있는지 먼저 확인하고, 가능하다면 줄을 제거한다음에, 내려준다( 내린 위치로 board판 수정)


1. 체크를 하게 되면 반복문을 통해서 모든줄에 대한 확인을 실행하는데, 비록 10*20의 블록이기게 오랜 시간이 걸리진 않더라도 불필요한 리소스를 매번 잡아먹는다.

2. 또한 if else문으로 처리됐기 때문에, 줄 삭제가 필요한 경우에는 다음 프레임에서 새 블록이 생성된다.

 

따라서 이를 블록이 놓일때만 수정하도록 개선하고자 한다.

public update(time: number, delta: number): void {
    if (this.gameEnd) return;

    if (this.currentTetrisBlock === undefined) {
      const block = this.spawnRandomBlock(
        GameConfig.MainScene.GAME_BOARD_WIDTH_CNT / 2,
        0
      );
      if (this.canSpawnBlock(block)) {
        this.currentTetrisBlock = block;
      } else {
        this.currentTetrisBlock = this.setLastBlockPos(block);
        this.gameEnd = true;
        this.scene.cameras.main.shake(500);
      }
    } else {
      if (this.timerManger.checkBlockDropTime()) {
        if (this.canMoveBlock(0, 1)) {
          this.currentTetrisBlock.move(0, 1);
        } else {
          this.placeBlock();
        }
      }
    }
  }

 

우선 업데이트 함수에서 check하던 기능을 제거해준다.

 

이후 블록이 놓인 다음에 확인하도록 코드를 수정했다.

private placeBlock() {
    if (this.currentTetrisBlock === undefined) return;

    const renderInfo = this.currentTetrisBlock.getRenderInfo();
    for (let y = renderInfo.startY, y2 = 0; y < renderInfo.endY; y++, y2++) {
      for (let x = renderInfo.startX, x2 = 0; x < renderInfo.endX; x++, x2++) {
        if (renderInfo.tiles[y2][x2] !== 0) {
          // 현재 보드를 board에 계속 저장해야함.
          this.board[y][x] = renderInfo.tiles[y2][x2];
          // 블록이 놓인다음에 제거
          const { line } = this.checkForClearableLines();
          if (line !== undefined) {
            this.clearLines(line);
            this.lineDown(line);
          }
        }
      }
    }

    // 현재 블럭 없애줌
    this.currentTetrisBlock = undefined;
  }

 

 

변경후

잘 삭제 되는 것을 확인할 수 있다.

 

개선 이후 성능 측정

이제 성능을 측정해보려고 한다. 개발자 도구에서 성능 탭을 통해 줄이 삭제되는 시점을 찾았고,

 

해당 시점에서 space바를 통해서 블록을 내렸고 줄이 가득차게 되면서 삭제되게 된다.

그리고 삭제되고 다음 이벤트에서 랜더링이 되는 것을 확인할 수 있다.
(3450에 줄삭제, 3565에 줄삭제된 것이 반영)

측정

 

시간을 측정했을 때는 0.24~0.29밀리초가 update에 걸렸다.

 

개선 후

4876.6초 업데이트

 

 

 

0.24~0.29 9 에서 0.11~0.19거의 절반에 가깝게 update 성능이 개선되었다.

 

 

+ Recent posts