프로젝트 기초 작업

클라이언트 분석

  • 현재 클라이언트에서 주고 받는 Payload는 어느정도 분석을 마쳤다!

  • 처음 게임이 시작할 때, 몬스터가 지나갈 경로(road) 정보를 서버가 보내주는 것 같다.

  • 그렇다면 경로를 만들어 클라이언트에게 전달해주어야 하는데,
    클라이언트가 어떤방식으로 이를 사용하는지 분석해 봐야 제대로된 값을 보내줄 수 있을 것 같다!

통신 구조 분석

Image

  • 일단 게임 처음 진입 시, 연결할 서버의 주소와 port를 입력해달라는 창이 뜬다!

  • 우선 이러한 창의 설정 버튼에 OnClickSetting()을 매핑해 연결을 시작하는 것 같다.

public void OnClickSetting()
{
    NetworkManager.instance.Init(inputIp.text, inputPort.text);
    // 연결을 시작해주는 곳
    SocketManager.instance.Init(inputIp.text, int.Parse(inputPort.text)).Connect();
    PlayerPrefs.SetString("ip", inputIp.text);
    PlayerPrefs.SetString("port", inputPort.text);
    HideDirect();
}

// Connect() 메서드 내부를 뜯어보았습니다.
public async void Connect(UnityAction callback = null)
{
    IPHostEntry ipHost = Dns.GetHostEntry(Dns.GetHostName());
    if (!IPAddress.TryParse(ip, out IPAddress ipAddress))
    {
        ipAddress = ipHost.AddressList[0];
    }
    IPEndPoint endPoint = new IPEndPoint(ipAddress, port);
    Debug.Log("Tcp Ip : " + ipAddress.MapToIPv4().ToString() + ", Port : " + port);
    socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        await socket.ConnectAsync(endPoint);
        isConnected = socket.Connected;
        // 이 메서드를 이용해 받은 패킷을 해석하여 payload를 ReceiveQueue에 삽입
        OnReceive();
        StartCoroutine(OnSendQueue());
        // 들어온 Payload 타입에 따라 (payload 이름과 같은)이벤트메서드를 호출해줌
        StartCoroutine(OnReceiveQueue());
        callback?.Invoke();
    }
    catch (Exception e)
    {
        Debug.Log(e.ToString());
    }
}

// 위의 StartCoroutine(OnReceiveQueue())에 사용되는 메서드
IEnumerator OnReceiveQueue()
{
    while (true)
    {
        yield return new WaitUntil(() => receiveQueue.Count > 0);
        var packet = receiveQueue.Dequeue();
        Debug.Log("Receive Packet : " + packet.type.ToString());
        // 메서드를 매핑한 후 실행(Invoke)해 주는 작업
        // _onRecv는 class가 선언될 때 
        // class 내부 메서드를 읽어 이름이 packetType과 같으면 매칭하여 저장한 정보
        _onRecv[packet.type].Invoke(packet.gamePacket);
    }
}

게임시작 분석

  • 이제 게임이 시작될 때 실행되는 메서드를 확인해보겠다!
// 서버에서 받은 통신에 의해 1차로 실행되는 메서드
public void MatchStartNotification(GamePacket gamePacket)
{
    var response = gamePacket.MatchStartNotification;
    UIManager.Get<UIMain>().OnMatchResult(response);
}

// 호출된 OnMatchResult() 메서드(2차)
public void OnMatchResult(S2CMatchStartNotification response)
{
    // GameManger에 받아온 정보 저장 후
    GameManager.instance.playerData = response.PlayerData;
    GameManager.instance.opponentData = response.OpponentData;
    GameManager.instance.initialGameState = response.InitialGameState;
    // Game 씬이 로드되면(Awake) -> GameManger 메서드의 OnGameStart() 실행
    SceneManager.LoadSceneAsync("Game");
}

// GameManger의 OnGameStart() 메서드(3차)
public void OnGameStart()
{
    // 게임 정보 초기화
    isGameStart = true;
    Time.timeScale = 1;
    _homeHp1 = initialGameState.BaseHp;
    _homeHp2 = initialGameState.BaseHp;
    UIManager.Get<UIGame>().InitHpGauge(homeHp1);
    gold = initialGameState.InitialGold;
    topScore = 0;
    score = 0;
    level = 1;
    time = 0;
    roads1.Clear();
    roads2.Clear();
    towers.Clear();
    monsters.Clear();
    // MultiGameLoop()를 비동기 작업으로 실행
    StartCoroutine(MultiGameLoop());
}

AddRoad 분석

  • 위는 게임 시작과 실행에 관련되어있는 정보고 아래부터는 Road(=MonsterPath)를 어떻게 사용하는지에 대한 코드다
// MultiGameLoop 에서 AddRoad 메서드를 호출함
IEnumerator MultiGameLoop()
{
    // gameState / gameObjects 는 상대방과 나를 구별해서 사용하는 용도
    var gameState = i == 0 ? playerData : opponentData;
    var gameObjects = i == 0 ? playerObjects : opponentObjects;
    /* 중간 로직 생략 */
    for (int j = 0; j < gameState.MonsterPath.Count; j++)
    {
        var count = 0;
        // 다음 도로가 마지막이 아닐 때
        if (gameState.MonsterPath.Count > j + 1)
        {   
            // 다음 도로와 현재 도로 간 거리를 확인
            var dist = Vector3.Distance(gameState.MonsterPath[j].ToVector3(), gameState.MonsterPath[j + 1].ToVector3());
            // 이를 30f(아마 도로 이미지 크기?) 으로 나눠 count에 저장
            count = (int)(dist / 30f);
        }

        AddRoad(        
                // 현재 위치 
                gameState.MonsterPath[j],            
                // 다음 위치  
                count > 0 ? gameState.MonsterPath[j+1] : null,  
                // 부모 Transform (LocalPosition 의 기준이 됨)
                gameObjects.roadParent,              
                // 플레이어 구분 
                (ePlayer)i,                           
                // 추가 도로 갯수 
                count                                 
        );
    }
}

// MonsterPath의 구성정보를 확인하기 위해 가져온 값으로 MonsterPath는 Position(x,y) 값을 배열로 갖는다는걸 알 수 있음 
private readonly pbc::RepeatedField<global::Position> monsterPath_ = new pbc::RepeatedField<global::Position>();

// 호출된 AddRoad
public void AddRoad(Position position, Position nextPos, Transform parent, ePlayer player, int count = 0)
{
    // 도로 리스트 선택
    var roads = player == ePlayer.me ? roads1 : roads2;
    // 도로 프리팹 로드
    var roadPrefab = ResourceManager.instance.LoadAsset<SpriteRenderer>("Road");

    // 다음 경로를 부모로 지정하며 roadPrefab 복제 
    var newRoad = Instantiate(roadPrefab, parent);
    // 도로 리스트에 추가
    roads.Add(newRoad.transform);
    // 지정된 위치에 배치
    newRoad.transform.localPosition = new Vector3(position.X, position.Y);

    // 다음 도로까지 이어질 수 있도록 받은 count가 있을 경우 진행
    if (count > 0)
    {
        // 다음 도로까지의 방향(좌표) 벡터 계산
        var normal = (nextPos.ToVector3() - position.ToVector3()).normalized;
        // 각도 계산
        var isUp = nextPos.Y > position.Y;
        // 각도의 절댓값 * 라디안을 도로 변환 * 위/아래 방향으로 각도 부호 설정
        var angle = Mathf.Abs(Mathf.Atan2(normal.y, normal.x) * 180 / Mathf.PI) * (isUp ? 1 : -1);
        // x , y , z 에서 z 축을 기준으로 방향(각도) 벡터 저장
        var eulerAngle = new Vector3(0, 0, angle);
        // 첫 도로 회전설정
        newRoad.transform.localEulerAngles = eulerAngle;
        for (int i = 0; i < count; i++)
        {   
            //count 수 만큼 위의 과정을 반복
            // 복제 -> 위치 추가 -> 방향 계산 -> 배치
            var newRoad2 = Instantiate(roadPrefab, parent);
            roads.Add(newRoad2.transform);
            // 뒤에 [30 * (i+1)] 은 count 계산시 사용한 30f를 이용해 위치(간격)를 정해주는 방법
            newRoad2.transform.localPosition = position.ToVector3() + normal * 30 * (i + 1);
            newRoad2.transform.localEulerAngles = eulerAngle;
             // 콜라이더 비활성화 = 불필요한 콜라이더 충돌 검사 제거
             /* 몬스터 이동에서 도로의 콜라이더를 만나면(충돌) 각도를 바꾸는 로직인데,
             추가로 생성된 도로들은 다음 도로까지 방향이 같으므로 충돌 검사가 필요 없음 */
            newRoad2.GetComponent<CircleCollider2D>().enabled = false;
        }
    }
}

Road 값 생성

  • 위의 분석정보들을 토대로 우리가 줘야하는 정보시작지점에서부터 베이스지점까지의 좌푯값들 이다!

  • 가장 쉬운 방법으로는 시작지점과 베이스지점까지만 주는 방법이 있다만..

  • 그래도 어느 정도의 랜덤성(제한된 환경 속 랜덤값)이 있으면 좋을 것 같다!

Image

  • 일단 클라이언트의 Game Scene에 있는 Object를 확인해본 결과 FieldParent 내부 로컬좌표를 주면될 것 같다!
    (FieldParent -> RoadParent -> Road LocalPosition )

  • 여기서 카메라의 크기에 맞출 수 있도록 좌표를 제한하면 나올 수 있는 좌표범위는 (0, 200) ~ (1500, 400) 인거 같다.

  • 일단 Road의 갯수는 (시작(1) / 중간지점(n)/ 끝(1)) 정도로 x 값은 고정해둔 채로 y값에 랜덤값을 주도록 해줘야 겠다

const makePath = (count) => {
  const paths = [];
  // 만약 count가 2개 이하이면 기본값 직선으로 반환
  if (count <= 2)
    return [
      [0, 350],
      [1500, 350],
    ];
  const weightX = Math.trunc(1500 / (count-1));

  for (let i = 0; i < count; i++) {
    let x = weightX * i;
    if (i === count - 1) x = 1500;
    const y = Math.trunc(Math.random() * 200) + 200;
    paths.push([x, y]);
  }

  return paths;
};

export default makePath;

메인 작업

게임오버 핸들링

  • 게임을 끝내는 조건과 이를 이용해 클라이언트에게 전달해주는 로직을 구현해볼 예정이다.

프로세스 확인

  • 이미 클라이언트 설계가 마쳐져있기에, 이와 통신되는 프로세스를 고려하여 로직을 구성해야한다
  1. 클라이언트는 몬스터가 EndPoint(베이스 지점)에 도달하면 데미지를 서버에 보낸다

  2. 서버는 데미지 계산 후 base 체력이 0이되면 클라이언트에게 게임오버를 알린다

  3. 클라이언트는 받은 통신에 의해 게임의 승리여부와 종료시점을 알게됨

  4. 클라이언트는 게임오버 관련 로직을 실행 후 메인화면으로 이동

구조 설계

  1. 클라이언트의 base 공격 여부를 받아 적용하는 핸들러 구현

  2. 서버의 base class 내부 피격 메서드에 hp가 0이되면 게임오버 알림을 보내도록 설계

Base 피격 핸들러

  • 클라이언트가 Base 피격 패킷을 보내면 이를 찾아 처리해주는 핸들러를 생성해준다
import { roomSession, userSession } from '../../session/session.js';

const attackBaseHandler = (socket, payload) => {
  // 세션<>입력 검증 과정
  const user = userSession.getUser(socket);
  if (!user) return;
  const room = roomSession.getRoom(user.roomId);
  if (!room) return;
  const player = room.getPlayer(user.id);
  if (!player) return;
  // base 피격 적용
  player.base.damaged(room, user.id, payload.damage);
};

export default attackBaseHandler;

게임오버 알림

  • base 클래스에서 damage를 받는 메서드 damaged()에 관련 로직을 구현해준다
damaged(room, userId, damage) {
  if (this.hp - damage > 0) this.hp -= monster.atk;
  else {
    // 게임종료 알림 패킷 생성
    // room 내의 플레이어들에게 전달
    room.players.forEach((player) => {
      let packet
      // player가 자신일 경우 패배, 다를 경우 승리 정보를 반환
      if(player.playerId === userId)
        packet = makePacketBuffer(config.packetType.gameOverNotification, { isWin: false })
      else
        packet = makePacketBuffer(config.packetType.gameOverNotification, { isWin: true })
      player.socket.write(packet)
    });
  }
}

한줄 평 + 개선점

  • 이번 프로젝트는 클라이언트 분석량이 최종 작업량보다 많을 것 같다!

  • 서버 구조도 어느정도 안정되어 기능 구현과 병합에만 신경쓰면 금방 마칠 것 같다