w99hyun
프로필프로젝트포스트연락처
목록
w99hyun

본 사이트는 개인 포트폴리오 게시용으로 제작되었습니다.

ⓒ 2026. w99hyun(Woohyun Cho) All rights reserved.

GitHub홈페이지STOVE 스토어
게임교내 졸업작품전시회 장려상
개발기간2024. 05. 16. ㅡ 2024. 11. 18.
역할1인 개발
사용 기술
UnityC#MariaDBMirror Network

Orbit

시연 영상

스크린샷

발표 자료

PDF 다운로드

개요

Unity6 HDRP 기반의 싱글 / 멀티플레이 FPS 게임입니다. HDRP를 활용한 고품질 렌더링과 함께, 싱글·멀티 양쪽 모드를 지원하는 네트워크 구조를 설계·구현했습니다. 네트워킹, DB 연동, PvP 동기화 등 멀티플레이에 필요한 기술을 포괄적으로 적용한 프로젝트입니다.

Orbit은 처음 싱글플레이 구조에서 출발한 뒤 멀티플레이를 확장하면서, 싱글용 로직과 멀티용 로직이 분리되어 유사한 스크립트를 병렬로 유지해야 하는 비효율을 경험했습니다. 이 경험 이후에는 새로운 프로젝트를 설계할 때 입력, 게임 상태, UI 반응, 네트워크 동기화가 직접 결합되지 않도록 이벤트 기반 흐름과 역할 분리를 먼저 고려하고 있습니다. 싱글플레이로 시작하더라도 상태 변화와 실행 주체를 명확히 분리해, 추후 멀티플레이나 서버 권한 구조로 확장할 수 있는 아키텍처를 만드는 것을 개발 기준으로 삼고 있습니다.

네트워크 (리슨 서버)

멀티플레이는 리슨 서버 방식을 채택했습니다. 1명의 플레이어가 Host(Server + Client)가 되고, 나머지 플레이어가 접속하는 구조입니다.
Network

네트워크 동기화 설계

입력 분리

isLocalPlayer 체크를 통해 원격 플레이어의 Player Input 컴포넌트를 비활성화하여 입력 격리를 구현했습니다.

무기 상태 동기화

SyncVar의 index 동기화만으로는 비주얼 업데이트가 불가능하므로, hook을 통해 무기 교체 시 모델·애니메이션까지 연동되도록 처리했습니다.

코드 보기코드 닫기
C#
[SyncVar(hook = nameof(OnWeaponChanged))]
private int _activeWeaponIndex;

private void OnWeaponChanged(int oldIndex, int newIndex)
{
    EquipWeapon();
}

PvP 데미지 처리

NetworkIdentity 기반으로 피격 대상을 정확히 식별하고, 처치자에게만 Kill Log를 표시하기 위해 TargetRpc를 활용했습니다.

코드 보기코드 닫기
C#
[Command(requiresAuthority = false)]
public void CmdTakeDamage(int amount, uint attackerId)
{
    if (!isServer) return;

    currentHealth -= amount;
    lastAttackerId = attackerId;

    if (currentHealth <= 0)
    {
        currentHealth = 0;

        // 공격자의 NetworkIdentity 가져오기
        NetworkIdentity attackerIdentity = NetworkServer.spawned[attackerId];
        if (attackerIdentity != null)
        {
            PlayerStats_Multi attackerStats = attackerIdentity.GetComponent<PlayerStats_Multi>();
            if (attackerStats != null)
            {
                attackerStats.TargetShowKillLog(attackerStats.connectionToClient, gameObject.name);
            }
        }
    }
}

[TargetRpc]
public void TargetShowKillLog(NetworkConnection target, string enemyName)
{
    GetComponent<Health_Multi>().KillLog(enemyName);
    GainExperience(GetComponent<Health_Multi>().expPoints);
}

SyncVar hook으로 HP 변경 시 피격 이펙트와 사망 애니메이션을 트리거합니다.

코드 보기코드 닫기
C#
[SyncVar(hook = nameof(OnHealthChanged))]
public int currentHealth;

void OnHealthChanged(int oldHealth, int newHealth)
{
    if (isLocalPlayer && oldHealth > newHealth)
    {
        StartCoroutine(UIManager.Instance.FlashScreen());
        GameManager_Multi.Instance.SaveGamePartial("currentHealth", currentHealth);
    }

    if (oldHealth > newHealth)
    {
        GetComponent<FPSMovement_Multi>().OnHit();
    }

    if (newHealth <= 0)
    {
        currentHealth = 0;
        if (isLocalPlayer)
        {
            GetComponent<FPSMovement_Multi>().OnDie();
            GameManager_Multi.Instance.GameOver();
        }
    }

    UpdateUI();
}

구역 해방 시스템 / 몬스터 (적)

싱글플레이의 맵은 여러 구역으로 나뉘어져 있습니다. 각 구역에 존재하는 여신상을 통해 인스턴스 던전에 입장할 수 있고, 던전 내의 아레테라는 수정을 파괴하면 해당 구역을 해방할 수 있습니다.
Angel

미해방 구역에는 두 종류의 몬스터가 생성됩니다. 각기 다른 ObjectPool에서 소환되며, 두 몬스터는 다른 공격 사거리와 공격력을 가지고 있습니다.

코드 보기코드 닫기
C#
_enemyMemoryPool = new ObjectPool<GameObject>(
  createFunc: () =>
  {
      GameObject enemy = Instantiate(enemyPrefab);

      // 몬스터가 죽을 때 풀로 반환
      enemy.GetComponent<EnemyFSM>().OnDeath += () =>
      {
          _enemyMemoryPool.Release(enemy);
          _currentEnemyCount--; // 현재 활성화 몬스터 수 감소
      };

      return enemy;
  },
  actionOnGet: item =>
  {
      item.SetActive(true);
      item.GetComponent<EnemyFSM>().ResetState(); // 상태 초기화
      item.name = enemyPrefab.name; // 이름 변경
  },
  actionOnRelease: item =>
  {
      item.SetActive(false); // 비활성화 처리
  },
  actionOnDestroy: Destroy,
  collectionCheck: false,
  defaultCapacity: 10,
  maxSize: 30
);

몬스터는 FSM 패턴을 활용하여 상태를 기반으로 동작합니다.

코드 보기코드 닫기
C#
public enum EnemyState
{ 
    NONE = -1, 
    IDLE = 0, 
    WANDER, 
    PURSUIT, 
    ATTACK 
}

//FSM 코드 조각
IEnumerator ATTACK()
{
    while (true)
    {
        if (PlayerStats.Instance.playerState == PlayerState.IDLE || PlayerStats.Instance.playerState == PlayerState.PAUSE)
        {
            navMeshAgent.ResetPath();
            LookRotationToTarget();

            if (Time.time - _lastAttackTime > attackRate)
            {
                _lastAttackTime = Time.time;

                GameObject clone = Instantiate(projectilePrefab, projectileSpawnPoint.position, projectileSpawnPoint.rotation);
                clone.GetComponent<EnemyProjectile>().Setup(target.position);
                EfxManager.Instance.PlayBullet(projectileSpawnPoint.position, projectileSpawnPoint.forward, 40f / 100f);
                PlaySound(shotSound);
            }
            yield return null;
        }
        else
        {
            yield return null;
        }
    }
}

무기 교환 시스템

Weapon
플레이어가 장착하는 무기를 교환하는 시스템이 마련되어 있습니다. 필드에 존재하는 몬스터를 처치하여 일정 확률로 획득하거나, 던전의 아레테를 파괴하여 대량으로 획득할 수 있는 온전한 칩을 사용하여 구매 가능합니다.

코드 보기코드 닫기
C#
//좌측 무기리스트 바인딩 코드 조각
for (int i = 0; i < fpsController._instantiatedWeapons.Count; i++)
{
    FPSItem weapon = fpsController._instantiatedWeapons[i];
    Weapon weaponC = weapon.gameObject.GetComponent<Weapon>();
    GunFire gunFire = weapon.gameObject.GetComponent<GunFire>();

    GameObject weaponButton = Instantiate(weaponButtonPrefab, leftWeaponListParent);

    // 무기 이미지
    Image weaponImage = weaponButton.transform.Find("WeaponImage").GetComponent<Image>();
    weaponImage.sprite = weapon.weaponPreview;

    // 무기 이름
    TMP_Text weaponName = weaponButton.transform.Find("WeaponName").GetComponent<TMP_Text>();
    weaponName.text = weapon.name.Replace("(Clone)", "").Trim();

    // 무기 가격
    TMP_Text cost = weaponButton.transform.Find("Lock/LockText/Chip/ChipCost").GetComponent<TMP_Text>();
    cost.text = weapon.cost.ToString();

    //무기 정보
    TMP_Text weaponInfo = weaponButton.transform.Find("WeaponInfo").GetComponent<TMP_Text>();
    weaponInfo.text = $"연사속도 {weaponC.fireRate} / 데미지 {gunFire.damage}";

    Button purchaseButton = weaponButton.transform.Find("Lock/LockText/PurchaseButton").GetComponent<Button>();
    Button equipButton = weaponButton.transform.Find("EquipButton").GetComponent<Button>();
    GameObject equipWeapon = weaponButton.transform.Find("EquipWeapon").gameObject;

    int currentIndex = i;
    if (inventory.availableWeaponIndices.Contains(currentIndex))  // 구매한 무기
    {
        weaponButton.transform.Find("Lock").gameObject.SetActive(false);
        if (inventory.equippedWeaponIndices.Contains(currentIndex))
        {
            equipButton.gameObject.SetActive(false);
            equipWeapon.SetActive(true);
        }
        else
        {
            equipButton.gameObject.SetActive(true);
            equipWeapon.SetActive(false);
        }
    }
    else  // 구매하지 않은 무기
    {
        weaponButton.transform.Find("Lock").gameObject.SetActive(true);
        equipButton.gameObject.SetActive(false);
        equipWeapon.gameObject.SetActive(false);

        purchaseButton.onClick.AddListener(() => OnPurchaseButtonClicked(currentIndex, weapon.cost));
    }
    equipButton.onClick.AddListener(() => OnEquipButtonClicked(currentIndex));
}

업적 시스템

Achievement
플레이어의 행동에 따라 업적이 json파일에 기록되어 싱글플레이 데이터 파일에 함께 저장됩니다.

코드 보기코드 닫기
C#
//업적 저장 코드 조각
public void UpdateAchievement(string type, int amount)
{
    switch (type)
    {
        case "MonsterKill": achievementData.monsterKills += amount; break;
        case "LootBoxOpen": achievementData.lootBoxOpens += amount; break;
        case "Deaths": achievementData.deaths += amount; break;
        case "ZoneLiberations": achievementData.zoneLiberations += amount; break;
        case "ChipCollection": achievementData.chipCollections += amount; break;
        case "IntactChipCollection": achievementData.intactChipCollections += amount; break;
        case "LevelUp": achievementData.levelUps += amount; break;
        case "ElevatorUse": achievementData.elevatorUses += amount; break;
        case "AutoDoorUse": achievementData.autoDoorUses += amount; break;
        case "WeaponPurchase": achievementData.weaponPurchases += amount; break;
    }
    SaveAchievements();
}

멀티플레이

상호 처치

https://www.youtube.com/watch?v=dh-1X1pwWQI

Multiplay
멀티플레이를 통해 상호 처치가 가능하며, 상기 과정을 거쳐 네트워크 데이터가 공유됩니다.

채팅

https://www.youtube.com/watch?v=Q7MlCx53O00

채팅창에서 다른 플레이어의 입퇴장 알림을 받을 수 있으며, 플레이어 닉네임과 채팅 내용의 전송을 통해 실시간 소통이 가능합니다.
모든 채팅 내용은 Command로 서버에 전송하고, ClientRpc로 모든 클라이언트에게 동기화됩니다.

코드 보기코드 닫기
C#
// 메시지를 클라이언트에서 서버로 전송
[Command(requiresAuthority = false)]
public void CmdSendChatMessage(string playerName, string message)
{
    if (string.IsNullOrEmpty(message))
      return;
    RpcReceiveChatMessage(playerName, message);
}

// 서버에서 모든 클라이언트에 메시지를 전달
[ClientRpc]
private void RpcReceiveChatMessage(string playerName, string message)
{
    ChatSupport.Instance?.AddChatMessage(playerName, message);
}

목차

개요네트워크 (리슨 서버)네트워크 동기화 설계입력 분리무기 상태 동기화PvP 데미지 처리구역 해방 시스템 / 몬스터 (적)무기 교환 시스템업적 시스템멀티플레이상호 처치채팅
이전 프로젝트

Embers

오픈월드 MMORPG 게임

다음 프로젝트

GRIMLINE

2D 플랫포머 게임