무엇을 만들었나
| 항목 | 내용 |
|---|---|
| 엔진 | Unity 2018.3.12f1 |
| 플랫폼 | Android |
| 기여도 | 100% (1인 개발) |
| 네트워크 | UNET (NetworkBehaviour · [Command] · [ClientRpc] · [SyncVar]) |
| 상태 | 완성 |
모바일 환경을 고려한 조작 체계와, 네트워크 상에서 서로 상호작용(공격·피격·리스폰)할 수 있는 멀티플레이 기초 환경을 구성하는 것을 목표로 잡았다. 구현한 요소는 네 덩어리다. UNET 동기화, 가상 조이스틱, 서버 사이드 전투·스폰, 그리고 애드몹 연동.

리소스 대신 로직에 시간을 쓰기
캐릭터와 적, 총알은 유니티 내장 3D 기본 도형(Cube, Capsule 등)만으로 구성했다. 네트워크 동기화 로직 구현에 집중하기 위한 선택이었다.

발사: 요청은 클라이언트, 생성은 서버
isLocalPlayer 로 자기 캐릭터의 입력만 받는다. 이 검사가 없으면 한 클라이언트의 키 입력이 화면에 있는 모든 캐릭터를 움직인다.
void Update()
{
if (!isLocalPlayer) return;
var x = Input.GetAxis("Horizontal") * Time.deltaTime * 150.0f;
var z = Input.GetAxis("Vertical") * Time.deltaTime * 3.0f;
transform.Rotate(0, x, 0);
transform.Translate(0, 0, z);
if (Input.GetKeyDown(KeyCode.Space))
{
CmdFire();
}
}
[Command]
void CmdFire()
{
GameObject bullet = Instantiate(bulletPreFab, bulletSpawn.position, bulletSpawn.rotation);
bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * 6;
NetworkServer.Spawn(bullet); // 서버에서 클라이언트들로 스폰 브로드캐스트
Destroy(bullet, 2.0f);
}
발사는 두 단계로 나뉜다. 클라이언트는 CmdFire() 로 요청만 보내고, 총알을 실제로 만들고 날리는 것은 서버다. NetworkServer.Spawn() 이 서버가 만든 총알을 모든 클라이언트에 브로드캐스트하고, Destroy(bullet, 2.0f) 가 2초 뒤 정리한다.
[Command] 가 붙은 함수는 이름이 Cmd 로, [ClientRpc] 는 Rpc 로 시작해야 한다. 지키지 않으면 컴파일 단계에서 걸린다. 통신 방향을 이름으로 강제하는 셈이다.체력: SyncVar 의 hook 으로 UI까지 따라오게
[SyncVar(hook = "OnChangeHealth")]
public int currentHealth = maxHealth;
public void TakeDamage(int amount)
{
if (!isServer) return;
currentHealth -= amount;
if(currentHealth <= 0)
{
if (destroyOnDeath) {
Destroy(gameObject);
} else {
currentHealth = maxHealth;
RpcRespawn();
}
}
}
void OnChangeHealth(int health)
{
healthSlider.value = health;
}
[ClientRpc]
void RpcRespawn()
{
if (isLocalPlayer)
{
Vector3 spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)].transform.position;
transform.position = spawnPoint;
}
}
TakeDamage 는 !isServer 면 즉시 반환한다. 즉 피격 판정은 서버에서만 일어난다. 사망하면 destroyOnDeath 여부에 따라 오브젝트를 파괴하거나, 체력을 되돌리고 RpcRespawn() 으로 클라이언트에 리스폰을 알린다.
체력은 순간적인 사건이 아니라 계속 유지되는 값이라 [SyncVar] 로 뒀다. hook 으로 지정한 OnChangeHealth 가 값이 바뀐 클라이언트에서 호출되면서 슬라이더까지 함께 따라온다.
| 어트리뷰트 | 방향 | 쓰는 곳 |
|---|---|---|
[Command] | 클라이언트 → 서버 | 발사 요청 등 '하고 싶다' |
[ClientRpc] | 서버 → 모든 클라이언트 | 리스폰 통보 등 '이렇게 됐다' |
[SyncVar] | 서버 → 모든 클라이언트 (값) | 체력처럼 유지되는 상태 |
모바일 조작: 가상 조이스틱
public void OnDrag(PointerEventData eventData)
{
Vector2 value = eventData.position - (Vector2)rect_Background.position;
value = Vector2.ClampMagnitude(value, radius);
rect_Joystick.localPosition = value;
float distance = Vector2.Distance(rect_Background.position, rect_Joystick.position) / radius;
value = value.normalized;
movePosition = new Vector3(value.x * moveSpeed * distance * Time.deltaTime, 0f, value.y * moveSpeed * distance * Time.deltaTime);
}
PointerEventData 로 배경 중심에서의 드래그 벡터를 구하고, ClampMagnitude 로 반경 안에 가둔다. 중심에서 떨어진 거리를 반경으로 나누면 0~1 사이 값이 나오는데, 이걸 정규화한 방향 벡터에 곱해 이동량을 만든다. 스틱을 살짝 밀면 천천히, 끝까지 밀면 최고 속도로 움직이는 이유가 이 distance 항이다.
애드몹 붙이기
private void InitAd()
{
string id = Debug.isDebugBuild ? test_unitID : unitID;
screenAd = new InterstitialAd(id);
AdRequest request = new AdRequest.Builder().Build();
screenAd.LoadAd(request);
}
private IEnumerator ShowScreenAd()
{
while (!screenAd.IsLoaded())
{
yield return null;
}
screenAd.Show();
}
Debug.isDebugBuild 로 테스트 광고 단위와 실제 단위를 가른다. 광고는 로드가 끝나야 띄울 수 있어서, IsLoaded() 가 참이 될 때까지 코루틴으로 기다린 뒤 Show() 한다. 전면 광고와 하단 배너 광고의 노출 제어를 이런 스크립트로 나눠 뒀다.
정리
- 이제는 레거시가 된 UNET 아키텍처지만, 서버와 클라이언트의 권한 분리, RPC, SyncVar 개념을 익히면서 멀티플레이어 게임의 기본적인 네트워크 로직을 직접 체득할 수 있었다.
- 가상 조이스틱 셋업과 애드몹 연동을 통해 모바일 상용화에 필요한 필수 요소를 적용해 볼 수 있었다.
- 기본 도형만 쓴 덕분에 그래픽에 시간을 뺏기지 않고 네트워크 쪽만 반복해서 고칠 수 있었다.
[Command] / [ClientRpc] / [SyncVar] 에 해당하는 개념은 Mirror 나 Netcode for GameObjects 에 이름만 바뀐 채 그대로 남아 있다.