본문 바로가기

Starters 부트캠프/B - log

유데미 스타터스 유니티 개발자 취업 부트캠프 1기 - 19주차 학습 일지

저번 주에 이어서 멀티플레이 - 포톤 강의가 이어졌다.

저번 주가 포톤에 대한 이해, 기초에 대한 내용이었다면, 이번 주에는 실제로 '어몽어스'라는 게임을 만들어보기로 했다.

'어몽어스'는 한참 유행이었을 때에 나도 친구들과 같이 즐겨 했던 게임인데, 마피아 게임의 확장이라고 보면 된다.

 

 

Save 25% on Among Us on Steam

An online and local party game of teamwork and betrayal for 4-15 players...in space!

store.steampowered.com

(궁금한 분들을 위한 스팀 페이지)

 

우리는 수업에서 크게 세가지를 배우고 '어몽어스'를 만드는 데에 적용했다.

 

1. 포톤

2. Input System 패키지

3. LightShafting - LightCaster

 

GitHub - ckawell/LightShafting: A simple line-of-sight lighting system for 2D Unity Games.

A simple line-of-sight lighting system for 2D Unity Games. - GitHub - ckawell/LightShafting: A simple line-of-sight lighting system for 2D Unity Games.

github.com

 

그 중에 3번은 아직 완전히 이해가 안 되어서 일단 1번과 2번 내용들을 이번 게시글에 적고 추가로 과제로 제출한 프로젝트까지 기술하려고 한다.

 


 

Starters 19주차 -  'Photon - Pun2'

이번주는 어몽어스를 먼저 플레이할 수 있을 정도로 제작하는데 초점이 더 맞추어 있었다.

그래서 서버로 접속하고, 방에 입장하는 정도만 구현했고, 그런고로 포톤보다는 다른 부분이 더 내용이 많았다.

그래서 일단 간단히 포톤에서 배운 내용들을 먼저 정리하고, 다른 내용들을 적어나가려 한다.

 

RoomOption

포톤은 마스터 서버에 접속하고, 로비에 입장한 뒤에 다시 방을 개설하고, 해당 방으로 플레이어가 모여서 게임을 진행한다.

여기서 방을 개설할 때에 따로 방의 옵션을 조절할 수 있다.

 

PhotonNetwork.CreateRoom("Room", new RoomOption());

 

저번 주까지는 위와 같이 아무런 옵션 없이 방을 만들어 왔는데, 이 경우에도 항상 RoomOption을 하나 새로 만들어 왔다.

이 RoomOption 변수에 옵션들을 추가하면 된다.

 

// 방 이름에 들어갈 숫자를 무작위로 만드는 코드 
int roomNumber = Random.Range(0, 1000);

// RoomOption을 생성하고, 설정하는 코드
RoomOptions roomOpt = new RoomOptions()
{
    IsVisible = true,
    IsOpen = true,
    MaxPlayers = (byte)roomSize
};

// 해당 RoomOption으로 방을 만듦
PhotonNetwork.CreateRoom("Room" + roomNumber, roomOpt);

 

이런 식으로 여러가지 설정을 하나 혹은 복수로 생성과 동시에 적용할 수 있다.

여기서 쓰인 옵션들을 살펴보면....

 

IsVisible은 로비에 있는 플레이어가 해당 방을 리스트로 받아올 수 있는지,

IsOpen은 플레이어가 입장할 수 있는 방인지,

MaxPlayers는 방에 입장할 수 있는 최대 플레이어 수로 반드시 byte로 변환해서 넣어야 한다.

 

모든 방 안에 플레이어가 같은 씬으로 이동하게 만들기

정확히는 방장이 씬을 이동시킬 때에 모든 플레이어가 같이 이동하도록 만드는 방법이다.

 

public override void OnConnectedToMaster()
{
    // 모든 클라이언트들이 마스터와 같은 씬으로 로드함
    PhotonNetwork.AutomaticallySyncScene = true;
}

public override void OnJoinedRoom()
{
    // 마스터의 씬을 변경 --> 모든 방의 플레이어가 다 같은 씬으로 이동
    if (PhotonNetwork.IsMasterClient) PhotonNetwork.LoadLevel(2);
}

 

PhotonNetwork는 보통 방 안에 있을 때에 플레이어 리스트를 가져온다던가, 마스터 플레이어인지를 판별한다던지 등 방 안에서의 설정들, 유용한 메서드들을 제공한다.

그 중에서 PhotonNetwork.AutomaticallySyncScene은 마스터 플레이어(방장)가 씬을 이동할 때에 방에 모든 플레이어가 같이 씬을 이동하는지 설정하는 메서드로, 이를 true로 설정하면, 간단하게 스테이지(레벨) 등등을 이동시킬 수 있다.

 

방을 만드는 데, 들어가는 데에 실패할 경우

어쩌면, 별로 생각할 필요 없어보이는 경우라고 생각할 수 있다.

안 되는 경우가 있나 싶기도 했다. 나역시도..

하지만, 만들 방의 이름이 기존에 있던 방 이름과 중복이 되던가 접속할 방이 접속 도중에 없어지던가 등의 생각치 못한 변수들은 멀티플레이 세계에 상당히 많이 존재한다는 것을 알면 얘기가 다르다.

그래서 포톤은 이 경우에 사용할 수 있는 virtual 함수 역시 만들어두었다.

 

public override void OnJoinRandomFailed(short returnCode, string message)
{
    LogText("Fail Join Room");
    CreateRoom();
}

public override void OnCreateRoomFailed(short returnCode, string message)
{
    LogText("Failed Create Room, Try Again.");
    CreateRoom();
}

 

그냥 override하여 함수 안에 코드를 작성하는 것만으로도 해당 상황에서 그 코드가 진행된다.

여기서 두 번째 인자인 message는 디버그로 로그를 띄우면 어떤 문제로 접속, 만들기 실패했는지 알 수도 있다.

 

 

Starters 19주차 -  'Input System Package (1.3.0)'

InputSystem 패키지는 기본으로 제공되는 InputSystem처럼 키를 매핑해서 원하는 때에 가져다 쓰기 좋도록 만드는 패키지이다.

우리는 '어몽어스'를 만드는 데에 여러 키를 매핑해서 이동, 미션, 킬 해야 하기 때문에 이를 사용했다.

 

기본으로 제공되는 InputSystem과 다른 점

기본으로 제공되는 InputSystem의 경우에는 프로젝트 세팅의 탭에서 새로운 키 이름을 적고, 키를 넣어서 만들었다면, 패키지에서는 새롭게 InputSystem 에셋을 만들고, 따로따로 키를 매핑할 수 있다.

이 덕분에 같은 이름도 각각의 InputSystem에서 다른 키로 사용할 수 있고, 여러 상황에 맞게 알맞은 InputSystem을 가져와 쓸 수도 있다.(특히나 게임 속에서 키를 바꾸는 경우 매우 유용할 것 같다.)

 

컴포넌트 상에서 생성, 사용하기

먼저 컴포넌트 내에서 불러서 새로운 InputSystem을 만들고 쓸 수 있다.

 

스크립트에서 만든 InputSystem의 변수를 컴포넌트에서 설정하는 모습

 

이 경우에는 일단, 스크립트에서 변수 선언하듯 InputSystem 타입의 변수를 하나 public(혹은 SerializeField)으로 선언해두고, 오브젝트의 컴포넌트에 넣어 설정하면 된다.

'+' 키를 누르면, 바인딩을 할 수 있는데, 원하는 유형으로 맞춰서 만들 수 있다.

우리의 경우에는 이동이라 4방향의 움직임 키를 만들었다.

 

알맞은 상황에 맞게 그리고 여러 키로 설정이 가능한 모습

 

여기서 키를 여러 개 생성하면, 복수로 키를 등록할 수도 있다.

그리고, 이를 가져와 사용할 때에는 다른 변수와 마찬가지로 해당 movement 변수를 가져다가 움직임을 주는 함수에서 사용하면 된다.

 

// movement inputAction에서 vector2값을 가져옴 

movementInput = movement.ReadValue<Vector2>();
if (movementInput.x != 0)
{
    // 이동 방향에 따라서 2d 스프라이트의 그림 방향이 바뀌도록 스케일을 변경 
    avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1);
}

// 키의 입력 값을 애니메이터의 파라미터의 값으로도 가져다 쓸 수 있음 
anim.SetFloat("Speed", movementInput.magnitude);

 

에셋으로 InputSystem Action 생성하기

먼저, 우클릭으로 InputSystem을 새로 만들고, 더블 클릭으로 열어보면 다음과 같은 창이 뜬다.

 

InputSystem 에셋 에디터 화면

 

먼저, 맨 왼쪽 위에서 디바이스를 나눌 수 있다.

PC, 컨트롤러 등 여러가지 디바이스가 있고, 이 디바이스에 따라서 나눠서 키를 지정할 수 있다.

(위에는 PC로 설정된 모습이다.)

 

그리고, 각각의 액션 맵을 만들고, 그 안에 여러 액션들을 만들면 된다. (마치 클래스와 메서드 관계인 것 같은 느낌이다.)

액션은 보통은 Button으로 되어 있어서 하나의 키로 설정할 수 있다.

이는 아까 컴포넌트처럼 생성, 설정하면 된다.

 

키를 매핑하는 모습

 

액션 타입을 Value로 하면 Vector2의 컨트롤에 대한 키를 설정할 수도 있다.

이 타입으로 상하좌우 컨트롤에 대한 키를 매핑할 수 있다.

 

키 매핑

 

여기서 modifier를 만들면, 단축키처럼 같이 눌러야 하는 키를 설정할 수 있다. (복사 단축키가 ctrl + c 인 것처럼 같이 눌러야 작동하게끔 하고 싶은 경우 사용한다.)

 

modifier를 하나 만든 모습

 

이렇게 키를 다 설정했다면, 가져오는 일만 남았다.

가져오기 위해서는 키를 사용할 오브젝트에 새롭게 Player Input이라는 컴포넌트를 달아주어야 한다.

 

Plsyer Input 컴포넌트

 

여기의 Actions에 방금 만든 InputSystem action 에셋을 등록하면 된다.

그리고, Behavior에서 우리는 두 가지 방법으로 각 액션들을 가져와 함수로 처리햇다.

 

InputSystem Action 가져다쓰기 - "Send Message"

 

첫 번째 방법은 Send Message를 사용하는 방법이다.

이건 진짜 편리한 기능인데, 먼저 아래의 스크립트를 보도록 하자.

Behavior의 옵션들

 

// InputSystem의 Action 이름은 Walk인 상태인데 
// 이 액션 이름에 On을 붙여서 함수를 만들면 된다.
public void OnWalk(InputValue value)
{
    // Vector2로 액션을 만들었으므로, 해당 값을 가져온다.
    movementInput = value.Get<Vector2>();
    if (movementInput.x != 0)
    {
        // 이동 방향에 따라서 스케일을 조작 (좌우를 보도록)
        avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1);
    }

    // 스피드를 적용
    anim.SetFloat("Speed", movementInput.magnitude);
}

 

주석에서 설명한 것처럼 그냥 액션이름에 On을 붙인 함수를 새로 만들어서 사용하면 된다.

(물론, InputValue value라는 매개변수는 이 값들을 가져오기 위해 반드시 필요하다.)

 

다만, 이름이 겹치게 되거나 오타가 난다거나 기존에 유니티가 가지고 있는 함수 등과 겹치면 제대로 실행되지 않을 수 있다는 단점도 있다.

 

InputSystem Action 가져다쓰기 - "Unity Event"

Behavior를 Invoke Unity Event로 하면, 액션 입력을 조건으로 새로운 유니티 이벤트를 만들어 연결할 수 있다.

이 경우는 마치 버튼의 OnClick()을 만드는 것처럼 컴포넌트에서 '+'를 눌러 추가하고, 원하는 함수를 붙여주면 된다.

 

Invoke Unity Event로 각 액션에 이벤트 트리거를 만드는 모습

 

스크립트로 액션과 맵 추가하고 바인드 하는 법

스크립트를 통해서도 새로운 액션, 맵을 생성하고 키를 연결(바인딩)시킬 수 있다.

 

private void Awake()
{
    controllMap = new InputActionMap("ControllMap");
    action = new InputAction("TestAction", binding: "<Keyboard d>/q");
    action.performed += context => OnAction();
    // action.performed += OnAction(); 에러남 
}

private void OnEnable()
{
    action.Enable();
}

private void OnDisable()
{
    action.Disable();
}

public void OnAction()
{
    Debug.Log("Action");
}

 

위에서 InputAction을 변수처럼 생성하고, 선언할 수 있다고 했는데 Map 역시 선언 가능하다.

선언과 동시에 ()안에 이름을 선언하면, 새로운 이름의 맵과 액션을 생성할 수 있고, InputAction은 괄호 안에 binding : 을 쓰고, string으로 키를 적어넣으면 원하는 키를 바인딩까지 바로 할 수 있다.

(저기에 쓰는 string의 형식은 에디터에서 Path를 통해 확인할 수 있다. 물론 이럴거면, 그냥 에디터로 생성하는게 빠를 것 같다.)

 

Path를 적을 때의 형식

 

그리고, OnEnable과 Disable에서 각각의 액션 역시 Enable, Disable하게 되는데 이는 다른 방법을 사용할 때에도 반드시 해주어야 한다.

(사실 위에서 써야했는데 깜빡하고 지금 적었다.)

 

마지막으로, 그냥 액션 없이 InputSystem의 메서드, 변수들을 이용해서 기능을 만들 수도 있다.

 

void Update()
{
    // 누르는 동안에 계속 올라감 
    if (Keyboard.current.qKey.isPressed) Debug.Log("q");

    // 누른 시점에 한 번만 올라감 
    if (Keyboard.current[Key.Q].wasPressedThisFrame) Debug.Log("q");

    // 떼는 시점에 한 번만 올라감 
    if (Keyboard.current[Key.Q].wasReleasedThisFrame) Debug.Log("q");
}

 

만약에 구현해야 하는 기능이 길지 않다면, 이렇게 만드는 것도 좋아보인다.

 

 

과제 프로젝트 - "나만의 캐치 마인드 만들기"

이전 포톤 과제로 나는 '캐치마인드'라는 게임을 만들고 조금 변형했다.

 

 

넷마블 - 캐치마인드

 

cmind.netmarble.net

 

이 과정에서 네 가지 기능을 만드는 데에 큰 시간을 사용했다.

 

1. 마우스의 클릭 위치에 맞게 그림이 그려지도록 하기

2. 턴 매니저 만들고, 턴에 따라 그림 권한 부여하기

3. 그림 그리는 사람과 아닌 사람의 위치, 화면 UI 등을 따로 적용시키기

4. 점수와 점수판 

 

이 기능을 어떻게 만들고 구현했는지 간단하게만 아래에 기술하겠다.

 

마우스의 클릭에 따라서 그림이 그려지도록 하기

마우스의 위치를 알아내는 것은 ScreenToWordPoint를 사용했다.

이 함수는 스크린의 지점을 월드 포지션으로 가져온다.

 

Vector3 mousePos = drawCam.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 1.3f));

 

그림은 LineRenderer를 통해서 그렸다.

LineRenderer에는 LineRend 프지션들이 있는데, 이를 클릭했을 때, 클릭 후 이동했을 때에 각각 RPC로 위의 마우스 포지션의 값을 넣어주었다.

 

void Update()
{
    if (pv.IsMine)
        DrawMouse();
}

void DrawMouse()
{
    // Camera cam = Camera.main;
    Vector3 mousePos = drawCam.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 1.3f));

    if (Input.GetMouseButtonDown(0))
    {
        pv.RPC("RpcCreateLine", RpcTarget.All, mousePos);
    }
    else if (Input.GetMouseButton(0))
    {
        pv.RPC("CmdConnectLine", RpcTarget.All, mousePos);
    }
}

[PunRPC]
void RpcCreateLine(Vector3 mousePos)
{
    // Camera cam = Camera.main;

    GameObject line = Instantiate(linePre, mousePos, Quaternion.identity, drawCam.transform);

    positionCount = 2;

    LineRenderer lineRend = line.GetComponent<LineRenderer>();

    lineRend.startWidth = 0.01f;
    lineRend.endWidth = 0.01f;
    lineRend.numCornerVertices = 5;
    lineRend.numCapVertices = 5;
    lineRend.material = defaultMaterial;
    lineRend.SetPosition(1, mousePos);
    lineRend.SetPosition(0, mousePos);

    // if (hasAuthority)
    curLine = line;
}



[PunRPC]
void CmdConnectLine(Vector3 mousePos)
{
    if (PrevPos != null && Mathf.Abs(Vector3.Distance(PrevPos, mousePos)) >= 0.001f)
    {
        LineRenderer cLine = curLine.GetComponent<LineRenderer>();
        PrevPos = mousePos;
        positionCount++;
        cLine.positionCount = positionCount;
        cLine.SetPosition(positionCount - 1, mousePos);
    }

}

 

그리고, 턴 매니져에 따라 포톤 뷰의 권한을 가지게 된 사람만 그림을 그릴 수 있도록 하였다.

 

턴 매니저와 권한 부여하기

그림의 권한을 주는 것은 간단하게 PhotonView의 IsMine을 해당 턴의 플레이어에게 주는 것으로 해결했다.

이를 위해서 PhotonView의 Ownership 타입을 takeOver로 설정하였는데, 이는 원할 때 어떤 플레이어든 이 권한을 코드로 가져갈 수 있게끔 하는 것이다.

 

턴은 이 권한을 지닌 유저만 time이라는 float 변수를 maxTime부터 deltaTime만큼 지속적으로 빼서 0이 될 때까지로 설정했다.

물론 이 시간 변수는 RPC로 모든 플레이어에게 보내주었고, 그 시간에 따라서 타임바의 fillamount를 조절하게끔 하였다.

 

그리고, 이 시간이 0이 되면, 다음과 같은 함수로 턴을 넘겨주었다.

 

pv.RequestOwnership();
myTurn = true;

 

여담이지만, 이 권한을 주는 데에는 살짝의 딜레이가 있어서 다음 턴으로 가는데 일부러 여유 시간을 주었다. (Invoke, 코루틴 등을 통해서)

 

그림 그리는 사람과 아닌 사람의 위치, 화면 UI 등을 따로 적용시키기

이 역시 턴 메니저 오브젝트의 권한에 따라서 턴이 시작할 때에 setUI라는 함수를 실행해서 바꾸게끔 했다.

 

public void SetUI()
{
    if (pv.IsMine)
    {
        isStart = true;

        playerCam.depth = 1;
        drawLine.drawCam.depth = -1;
        playerCam.rect = new Rect(0.35f, 0.02f, 0.3f, 0.3f);
        drawLine.drawCam.rect = new Rect(0, 0, 1, 1);

        playerCam.tag = "Untagged";
        drawLine.drawCam.tag = "MainCamera";

        turnAnswer.gameObject.SetActive(true);
        inputAnswer.gameObject.SetActive(false);

        DDabongBtnObj.gameObject.SetActive(false);
    }
    else
    {
        playerCam.depth = -1;
        drawLine.drawCam.depth = 1;

        playerCam.rect = new Rect(0, 0, 1, 1);
        drawLine.drawCam.rect = new Rect(0.35f, 0.02f, 0.3f, 0.3f);

        playerCam.tag = "MainCamera";
        drawLine.drawCam.tag = "Untagged";

        turnAnswer.gameObject.SetActive(false);
        inputAnswer.gameObject.SetActive(false);

        DDabongBtnObj.gameObject.SetActive(true);
        DDabongBtnObj.interactable = true;
    }
    DDabongObjs.SetActive(true);
}

 

여기서 그림 그리는 턴을 가지지 않은 사람의 경우에 플레이어 캠이 전체 화면으로 되고, 반대로 그림 그리는 유저는 그림 그리는 화면이 전체 화면이 된다. (그리고, mainCamera를 변경해서 마우스 포지션을 가져오는데 오류가 없게끔 바꾸었다.)

 

위치 역시 photonView의 IsMine에 따라서 턴 시작 시에 순간이동 하게끔 position을 정해주었다.

이 위치는 각각 빈 오브젝트로 만들어서 관리했다.

 

점수와 점수판

점수 역시 스코어 매니저라는 새로운 클래스를 통해서 구현했다.

이는 각각의 플레이어 캐릭터 오브젝트에 달려있어서 자신이 답을 제출한 경우, 그 답이 맞는 경우, 아닌 경우에 따라서 지정된 자기의 점수판의 점수가 올라가게끔 설정하였다.

 

public void SubmitAnswer()
{
    if (inputAnswer.text == turnAnswer.text)
    {
        pv.RPC("GetDDabong", RpcTarget.All);
        // turnManager.InitDDabong(turnManager.photonView.Owner);

        pv.RPC("AddScore", RpcTarget.All, 1, connectManager.myPlayerIndex, PhotonNetwork.LocalPlayer);
        pv.RPC("PrintLog", RpcTarget.All, $"{pv.Owner.NickName}님, '{inputAnswer.text}' 정답입니다!");

        turnManager.TimeInit();
        GetComponent<PlayerMove>().AudioPlay(5);
    }
    else
    {
        pv.RPC("PrintLog", RpcTarget.All, $"{pv.Owner.NickName}님, 틀렸습니다!");
        pv.RPC("ReturnToStartPos", RpcTarget.All, PhotonNetwork.LocalPlayer.NickName);
        GetComponent<PlayerMove>().AudioPlay(6);
    }
}

[PunRPC]
public void PrintLog(string t)
{
    if (turnManager.isStop)
        turnManager.isStop = false;
    waringText.text = t;
    Invoke("InitLog", 2f);
}

public void InitLog()
{
    waringText.text = "";
}

[PunRPC]
public void AddScore(int pScore, int myIndex, Player p)
{
    curScore += pScore;
    Text myScoreBox = connectManager.scoreBoxD[myIndex];
    myScoreBox.text = $"{p.NickName} : {curScore} 점";

    turnManager.DDabong = 0;
}

 

참고로 PrintLog는 화면 상에 메세지를 띄우는 함수이고, 대부분의 함수가 RPC로 실행되어 점수가 바로바로 동기화 되도록 하였다.

 

결과물

쉽지 않은 프로젝트였고, 버그가 엄청 많았지만(물론 많은 버그가 아직 다 수정되지는 않았다) 어떻게든 플레이할 수 있는 정도로 완성하게 되었고, 아래와 같은 결과물을 만들 수 있었다.

 

캐치마인드 프로젝트 플레이 영상

 

이에 대한 깃허브 링크는 더 수정을 거쳐서 다음 게시글에 첨부하도록 하겠다.

 

 

유데미 코리아 바로가기 : 

 

Udemy Korea - 실용적인 온라인 강의, 글로벌 전문가에게 배워보세요. | Udemy Korea

유데미코리아 AI, 파이썬, 리엑트, 자바, 노션, 디자인, UI, UIX, 기획 등 전문가의 온라인 강의를 제공하고 있습니다.

www.udemykorea.com

 

💡 본 포스팅은 유데미-웅진씽크빅 취업 부트캠프 유니티 1기 과정 후기로 작성되었습니다.