

Unity6 HDRP 기반의 싱글 / 멀티플레이 FPS 게임입니다. HDRP를 활용한 고품질 렌더링과 함께, 싱글·멀티 양쪽 모드를 지원하는 네트워크 구조를 설계·구현했습니다. 네트워킹, DB 연동, PvP 동기화 등 멀티플레이에 필요한 기술을 포괄적으로 적용한 프로젝트입니다.
Orbit은 처음 싱글플레이 구조에서 출발한 뒤 멀티플레이를 확장하면서, 싱글용 로직과 멀티용 로직이 분리되어 유사한 스크립트를 병렬로 유지해야 하는 비효율을 경험했습니다. 이 경험 이후에는 새로운 프로젝트를 설계할 때 입력, 게임 상태, UI 반응, 네트워크 동기화가 직접 결합되지 않도록 이벤트 기반 흐름과 역할 분리를 먼저 고려하고 있습니다. 싱글플레이로 시작하더라도 상태 변화와 실행 주체를 명확히 분리해, 추후 멀티플레이나 서버 권한 구조로 확장할 수 있는 아키텍처를 만드는 것을 개발 기준으로 삼고 있습니다.
멀티플레이는 리슨 서버 방식을 채택했습니다. 1명의 플레이어가 Host(Server + Client)가 되고, 나머지 플레이어가 접속하는 구조입니다.
isLocalPlayer 체크를 통해 원격 플레이어의 Player Input 컴포넌트를 비활성화하여 입력 격리를 구현했습니다.
SyncVar의 index 동기화만으로는 비주얼 업데이트가 불가능하므로, hook을 통해 무기 교체 시 모델·애니메이션까지 연동되도록 처리했습니다.
[SyncVar(hook = nameof(OnWeaponChanged))]
private int _activeWeaponIndex;
private void OnWeaponChanged(int oldIndex, int newIndex)
{
EquipWeapon();
}
NetworkIdentity 기반으로 피격 대상을 정확히 식별하고, 처치자에게만 Kill Log를 표시하기 위해 TargetRpc를 활용했습니다.
[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 변경 시 피격 이펙트와 사망 애니메이션을 트리거합니다.
[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();
}
싱글플레이의 맵은 여러 구역으로 나뉘어져 있습니다. 각 구역에 존재하는 여신상을 통해 인스턴스 던전에 입장할 수 있고, 던전 내의 아레테라는 수정을 파괴하면 해당 구역을 해방할 수 있습니다.
미해방 구역에는 두 종류의 몬스터가 생성됩니다. 각기 다른 ObjectPool에서 소환되며, 두 몬스터는 다른 공격 사거리와 공격력을 가지고 있습니다.
_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 패턴을 활용하여 상태를 기반으로 동작합니다.
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;
}
}
}

플레이어가 장착하는 무기를 교환하는 시스템이 마련되어 있습니다. 필드에 존재하는 몬스터를 처치하여 일정 확률로 획득하거나, 던전의 아레테를 파괴하여 대량으로 획득할 수 있는 온전한 칩을 사용하여 구매 가능합니다.
//좌측 무기리스트 바인딩 코드 조각
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));
}

플레이어의 행동에 따라 업적이 json파일에 기록되어 싱글플레이 데이터 파일에 함께 저장됩니다.
//업적 저장 코드 조각
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();
}

멀티플레이를 통해 상호 처치가 가능하며, 상기 과정을 거쳐 네트워크 데이터가 공유됩니다.
채팅창에서 다른 플레이어의 입퇴장 알림을 받을 수 있으며, 플레이어 닉네임과 채팅 내용의 전송을 통해 실시간 소통이 가능합니다.
모든 채팅 내용은 Command로 서버에 전송하고, ClientRpc로 모든 클라이언트에게 동기화됩니다.
// 메시지를 클라이언트에서 서버로 전송
[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);
}