Pixi.js를 사용해서 여러 명의 캐릭터 이동 구현해보기

krkorklo·2022년 10월 29일
5

여러 명의 캐릭터를 움직이기 위해서는 socket.io를 사용해서 실시간 통신이 필요하다.

Socket?

  • 프로토콜, ip주소, port넘버로 떨어져있는 두 host를 연결해주는 도구 - 인터페이스 역할
  • 데이터를 주고받을 수 있는 구조체로 소켓을 통해 데이터 통로가 만들어짐

WebSocket

  • 사용자의 브라우저와 서버 사이의 동적인 양방향 연결 채널을 구성하는 HTML5 프로토콜
  • 서버로 메시지를 보내고 요청 없이 응답을 받아오는 것이 가능
  • HTTP 통신은 단방향 방식으로 연결 유지가 되지 않아 업데이트 유무를 확인하기 위해 polling을 하고 요청을 할때마다 전체 데이터를 불러옴
  • WebSocket은 클라이언트가 특정 주기를 가지고 polling하지 않아도 변경된 사항을 적절하게 전달할 수 있는 양방향 연결 스트림을 만들어주는 기술 → 서버의 데이터를 클라이언트에 즉시 전달할 수 있는 실시간 어플리케이션에 효과적

Socket.io

  • Socket.io는 WebSocket을 기반으로 클라이언트와 서버의 양방향 통신을 가능하게 해주는 라이브러리
  • WebSocket에서 편의 기능이 추가되어 사용하기 쉬움

WebSocket vs Socket.io

  • WebSocket은 양방향 통신을 위한 프로토콜
  • Socket.io는 양방향 통신을 하기 위해 WebSocket 기술을 활용하는 라이브러리

Socket.io 연결하기

해당 글에서는 client 측의 코드만 다룰 것이다.

import { io } from 'socket.io-client';

const socket = io('/socket');

io를 사용해서 socket 객체가 생성이 가능하다. 나는 proxy 설정을 따로 해줘서 네임스페이스(url)는 간략하게 적어두었다. 이렇게 하면 바로 socket 연결이 가능하다.

캐릭터 입장 구현하기

내가 입장했을때는 기존 map에 존재하던 character 들의 정보를 불러와야하고, 내가 입장해있을때 다른 캐릭터가 입장하면 입장한 캐릭터의 정보가 들어와야한다.

function Map() {
  ...
  
  useEffect(() => {
    canvasRef.current?.appendChild(app.view);
    app.start();
    
    socket.emit("userEnter", character, handleSelfEnter);

    return () => {
      app.stop();
    };
  }, [])
  useEffect(() => {
    socket.on('otherEnter', handleOtherEnter);
    
    return () => {
      socket.off('otherEnter');
    };
  }, [characters, character]);
}

첫 렌더링 시 클라이언트에서 character 정보를 담아 userEnter 이벤트를 emit해주면 서버에서는 해당 character의 정보를 otherEnter 이벤트에 담아 broadCast해주고 기존에 map에 있던 character 목록을 리턴해준다.

handleSelfEnter에서 character 목록을 받아 state에 저장하고, handleOtherEnter에서 새로운 character 정보를 받아 characterList에 추가하면 캐릭터 입장은 구현 완료!
(현재 character 위치에 따라 적절하게 위치를 계산해서 stage에 addChild를 해줘야한다)

캐릭터 이동 로직 수정하기

앞선 글에서는 socket의 연결을 고려하지 않고 구현했기 때문에 로직 수정이 필요하다. 여러 명의 캐릭터가 움직이려면 내 캐릭터가 이동할 때마다 서버에 이벤트를 emit해서 다른 캐릭터들이 이동 이벤트를 받을 수 있어야 한다.

const handleKeyDown = (e: KeyboardEvent) => {
  socket.emit('move', e.key, handleSelfMove);
};

key를 down했을 때 위치만 이동시키는게 아니라 emit해서 해당 캐릭터의 이동을 알리는게 필요하다. emit하고 원래 구현했던 move 로직을 호출한다.

const handleSelfMove = (data: ICharacterResponse) => {
  setCharacter({ ...character, direction: data.direction });

  let cnt = 0, requestId;

  const animation = () => {
    setCharacters(
      characters.map((char) => {
        if (char.id === data.id) {
          char.character.texture = Texture.from(data.imgURL);
        } else {
          char.character.x -= MOVE_LENGTH[data.direction].x / 10;
          char.character.y -= MOVE_LENGTH[data.direction].y / 10;
        }

        return char;
      }),
    );
	...
  };
}

여기에 또 하나의 코드가 추가되어있는데, 여러 명의 user를 관리하기 위한 characters state를 추가해주고 여기서는 현재 map에 존재하는 character들의 Sprite object를 저장한다. 내가 움직일때 멈춰있는 배경이 움직이게 되는 것처럼 멈춰있는 다른 사용자들도 움직여야하기 때문에 다음과 같이 character(Sprite object)의 x, y 좌표를 내가 움직이고자하는 방향과 반대로 움직이도록 애니메이션을 부여해주었다.

다른 캐릭터가 이동할때

다른 캐릭터가 이동할때를 위한 event를 on하고 있어야한다.

useEffect(() => {
  socket.on('otherEnter', handleOtherEnter);
  socket.on('otherMove', handleOtherMove);
  return () => {
    socket.off('otherEnter');
    socket.off('otherMove');
  };
}, [characters, character]);

여기서 dependancy를 추가해주지 않으면 가장 처음에 선언된 character, characters 상태만 기억하고 함수를 처리해서 작동이 원하는 대로 되지 않는다.

  const handleOtherMove = (data: ICharacterResponse) => {
    let cnt = 0, requestId;

    const animation = () => {
      setCharacters(
        characters.map((char) => {
          if (char.id === data.id) {
            char.character.texture = Texture.from(data.imgURL);
            char.character.x += MOVE_LENGTH[data.dir].x / 10;
            char.character.y += MOVE_LENGTH[data.dir].y / 10;
          }

          return char;
        }),
      );

      requestId = requestAnimationFrame(animation);
      cnt++;

      if (cnt === 10) cancelAnimationFrame(requestId);
    };

    requestAnimationFrame(animation);
  };

다른 character가 움직이는 로직은 내가 움직이는 로직과 비슷하다. 다만 내가 움직이는게 아니므로 setCharacterPosition, setBackgroundPosition을 해줄 필요가 없고 characters 목록에서 해당 character의 좌표를 수정해주기만 하면 된다.

이렇게 하면,,,

귀여운 캐릭터들이 움직인다🥳

그런데 약간 거슬리는 문제가 있었다. 키보드 방향키를 꾹 누르고 있으면 이벤트가 연속해서 쭈우욱 간다. 비효율적인 호출을 막기 위해서 throttling을 사용했다.

Throttle?

  • 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것
  • 일정한 주기마다 이벤트가 발생하도록 함

Throttle vs Debounce

두 방식 모두 성능을 위해 event를 제어하는 방식

Debounce

  • 연이어 호출되는 함수들 중 마지막 함수나 제일 첫 함수만 호출하도록 하는 것
  • 이벤트를 그룹화하여 하나의 이벤트만 발생하도록 함
    → 입력이 끝날때까지 무한적으로 기다림 (resize할때 많이 사용)

Throttle

→ 입력이 시작되면 일정 주기로 실행

입력이 연속적으로 들어올 때 하나의 이벤트만 처리하는게 아니라 일정 주기로 이벤트를 호출하도록 제어하는게 더 적절할 것 같다고 생각이 들어서 throttle을 적용했다.

  const [throttle, setThrottle] = useState(false);

  const handleKeyDown = (e: KeyboardEvent) => {
    if (throttle) return;
    
    setThrottle(true);
    socket.emit('move', DIRECTIONS[e.key]);
    const timer = setTimeout(() => {
      setThrottle(false);
      clearTimeout(timer);
    }, 100);
  };

다음과 같이 연속적인 event 요청이 들어오면 0.1초에 하나의 호출만 가도록 수정했다.

Nginx로 배포할때 발생한 문제

이전에 작성한 방법으로 Nginx를 사용해서 클라이언트 배포를 했었는데, 자꾸 소켓 연결에서 오류가 발생했다.

찾아보니까 Nginx가 WebSocket을 지원하면서 리버스 프록시 서버가 직면하는 문제가 몇 가지 있다고 했다. WebSocket이 hop-by-hop protocol로 프록시 서버가 클라이언트의 Upgrade 요청을 가로챌때 적절한 헤더를 포함해 서버에 업그레이드 요청을 보내야한다고 한다.

hop-by-hop protocol?

  • hop 사이에서 data가 흐르는 protocol
  • 각 packet이 매 노드(router)를 건너가는 양상을 비유적으로 표현 - layer 3
	...
   location /socket {
      proxy_pass http://SERVER_IP;
      
      proxy_http_version 1.1; 
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "Upgrade";
   }

client/server가 다른 protocol에서 connection될 때 사용되는 설정들

  • proxy_http_version : HTTP 1.1 버전에서만 Upgrade header를 사용할 수 있음
  • proxy_set_header Upgrade : $http_upgrade로 client 요청 header에서 Upgrade: websocket 정보를 읽어옴
  • proxy_set_header Connection : 해당 packet이 upgrade될 패킷임을 알 수 있도록 명시

이렇게 리버스 프록시 설정을 추가해주면 Nginx 배포 환경에서도 socket이 정상적으로 연결된다!!!


참고자료
https://socket.io/docs/v4/
https://d2.naver.com/helloworld/1336
https://dev-gorany.tistory.com/330
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Upgrade
https://shinwusub.tistory.com/111

0개의 댓글