2023 2학기 방과후 Netcode수업 최종 개인 프로젝트 1등
깃허브 : https://github.com/dkdkdsa/Hero_Can_No_Stop_Enemy
다운로드 : https://drive.google.com/file/d/1tjXJC0OH2Pg6YuQ6lY_WQZ0BhYUMSOhv/view?usp=drive_link
영상 : https://www.youtube.com/watch?v=UB6I4Q-wB-s
1. 게임 개요
설명 : 2023 2학기 Netcode 멀티플레이 게임 개발 방과후수업 최종 개인 프로젝트 1등 작품입니다
기획 의도 : 평소에 랜덤 다이스, 벌룬 타워 디펜스 같은 타워 디펜스 게임을 플레이 하며 타워 디펜스 느낌을 최대한 잘 살리며 랜덤 다이스처럼 경쟁 요소를 넣으면 재미있겠다 라는 생각을 많이 하였다 수업에서 Netcode멀티플레이을 배우게 되어서 이번 기회에 한번 만들어 보기 위하여 기획하게 되었다
장르 : 타워 디펜스
개발 전 구상
적을 죽이면 일정 확률로 상대방의 지역에 적이 스폰되도록
여러 특색 있는 타워들
2. 구현
타워
타워 디펜스 게임이니 타워 시스템이 필요하였다 네트워크 동기화가 가능 하도록 레벨업, 타겟 동기화, 위치 설정등을 구현하였다 서버를 처음 만들어 보아서 RPC를 최대한 응용하는 방식으로 개발하여 보면 좋겠다 생각하여 RPC를 최대한 활용하여 개발하였다
using FD.Dev;
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
[System.Serializable]
public class LevelData
{
public float attackPower;
public float attackRange;
public float attackCoolDown;
public int levelUpCost;
}
public delegate void LevelUp();
[RequireComponent(typeof(AreaObject))]
public abstract class TowerRoot : NetworkBehaviour
{
[SerializeField] protected List<LevelData> levelData = new();
[field:SerializeField] public string TowerKey { get; protected set; }
private bool isSetTargetCalled;
private AreaObject area;
protected EnemyRoot target;
protected bool isAttackCoolDown;
protected bool isAttackCalled;
public event LevelUp OnLevelUpEvent;
public AreaObject TowerArea
{
get
{
if(area == null)
{
area = GetComponent<AreaObject>();
}
return area;
}
set { area = value; }
}
public List<LevelData> LvDataList => levelData;
public int CurLv { get; protected set; }
protected virtual void Awake()
{
TowerArea = GetComponent<AreaObject>();
}
protected virtual void Update()
{
if (!IsOwner) return;
if (!isAttackCoolDown)
{
if(target == null && isSetTargetCalled == false)
{
isSetTargetCalled = true;
SetTarget();
}
if(target != null && isAttackCalled == false)
{
isAttackCalled = true;
DoAttack();
AttackServerRPC();
}
}
ChackTarget();
}
protected virtual void SetTarget()
{
var list = DefenseManager.Instance.GetEnemys(OwnerClientId);
float maxWalkValue = float.MinValue;
int idx = -1;
for(int i = 0; i < list.Count; i++)
{
float dist = (transform.position - list[i].transform.position).sqrMagnitude;
if(dist <= Mathf.Pow(levelData[CurLv].attackRange, 2)
&& list[i].MoveValue > maxWalkValue)
{
maxWalkValue = list[i].MoveValue;
idx = i;
}
}
SetTargetServerRPC(idx);
}
protected virtual void ChackTarget()
{
var list = DefenseManager.Instance.GetEnemys(OwnerClientId);
float maxWalkValue = float.MinValue;
int idx = -1;
for (int i = 0; i < list.Count; i++)
{
float dist = (transform.position - list[i].transform.position).sqrMagnitude;
if (dist <= Mathf.Pow(levelData[CurLv].attackRange, 2)
&& list[i].MoveValue > maxWalkValue)
{
maxWalkValue = dist;
idx = i;
}
}
if(idx != -1)
{
if (target != list[idx])
{
isSetTargetCalled = true;
SetTarget();
}
}
}
[ServerRpc]
private void SetTargetServerRPC(int idx)
{
SetTargetClientRPC(idx);
}
[ClientRpc]
private void SetTargetClientRPC(int idx)
{
if (idx == -1) return;
var list = DefenseManager.Instance.GetEnemys(OwnerClientId);
if (idx >= list.Count) return;
target = list[idx];
isSetTargetCalled = false;
}
[ServerRpc]
private void AttackServerRPC()
{
AttackClientRPC();
}
[ClientRpc]
private void AttackClientRPC()
{
if (OwnerClientId == NetworkManager.Singleton.LocalClientId) return;
DoAttack();
}
protected abstract void DoAttack();
[ClientRpc]
public void SetPosClientRPC(Vector2 pos, ulong clientId)
{
var cPos = clientId == NetworkManager.Singleton.LocalClientId ? pos : -pos;
transform.position = cPos;
FAED.TakePool<ParticleSystem>("DustEFT", transform.position).Play();
if(clientId == NetworkManager.Singleton.LocalClientId)
{
FindObjectOfType<PlayerDeckSettingController>().TowerAdd(this);
}
}
protected IEnumerator AttackDelayCo()
{
isAttackCoolDown = true;
yield return new WaitForSeconds(levelData[CurLv].attackCoolDown);
isAttackCoolDown = false;
}
[ServerRpc]
public void LevelUpServerRPC()
{
LevelUpClientRPC();
}
[ClientRpc]
private void LevelUpClientRPC()
{
LevelUp();
}
private void LevelUp()
{
if(CurLv + 1 != levelData.Count)
{
CurLv++;
OnLevelUpEvent?.Invoke();
if (IsOwner)
{
FindObjectOfType<UpgradeUIController>()?.InitData();
}
}
}
public override void OnDestroy()
{
base.OnDestroy();
if (NetworkManager.Singleton == null) return;
if(OwnerClientId == NetworkManager.Singleton.LocalClientId)
{
FindObjectOfType<PlayerDeckSettingController>().TowerRemove(this);
}
}
[ServerRpc]
public void DestroyTowerServerRPC()
{
Destroy(gameObject);
}
private void OnMouseDown()
{
if (IsOwner)
{
UpgradeUIController.Instance.gameObject.SetActive(true);
UpgradeUIController.Instance.SetPanel(this);
}
}
}
로비
UGS의 Lobby를 사용하여 방을 만들고 방에 입장하는 기능을 구현하였다
using System.Collections;
using System.Collections.Generic;
using TMPro;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LobbyUIController : MonoBehaviour
{
[SerializeField] private LobbyPanel lobbyPanelPrefab;
[SerializeField] private TMP_InputField lobbyNameField;
[SerializeField] private Transform content;
[Header("보상")]
[SerializeField] private GameObject rewardBtn;
[SerializeField] private GameObject rewardPanel;
[SerializeField] private TMP_Text rewardText;
[SerializeField] private TMP_Text loginCountText;
[SerializeField] private TMP_InputField pChangeField;
[SerializeField] private GameObject pChangePanel;
private bool isRefreshCoolDown = false;
public GameObject loadingPanel;
private void Awake()
{
Refresh();
CheckReword();
}
public async void Refresh()
{
if (isRefreshCoolDown) return;
isRefreshCoolDown = true;
var childs = content.GetComponentsInChildren<LobbyPanel>();
foreach(var item in childs)
{
Destroy(item.gameObject);
}
var lobbys = await AppController.Instance.GetLobbyList();
foreach(var item in lobbys)
{
Instantiate(lobbyPanelPrefab, content).Set(item);
}
StartCoroutine(RefreshCoolDown());
}
public async void CreateLobby()
{
var result = await AppController.Instance.StartHostAsync(
FirebaseManager.Instance.userData.userName,
lobbyNameField.text);
if (result)
{
NetworkManager.Singleton.SceneManager.LoadScene(SceneList.LobbyScene, LoadSceneMode.Single);
}
else
{
Debug.LogError("로비 생성중 에러");
}
}
private void Update()
{
CheckReword();
}
private void CheckReword()
{
if (FirebaseManager.Instance.IsContinuousLogIn && FirebaseManager.Instance.userData.isRewardGet == false)
{
rewardBtn.SetActive(true);
}
}
public async void Save()
{
await FirebaseManager.Instance.SaveUserData();
}
public async void GetReward()
{
int reward = 100 * FirebaseManager.Instance.userData.loginCount;
FirebaseManager.Instance.userData.coin += reward;
FirebaseManager.Instance.userData.isRewardGet = true;
rewardPanel.SetActive(true);
loginCountText.text = $"연속로그인 {FirebaseManager.Instance.userData.loginCount}일차!";
rewardText.text = $" {reward}코인 획득!";
rewardBtn.SetActive(false);
await FirebaseManager.Instance.SaveUserData();
}
public async void PasswordChange()
{
await FirebaseManager.Instance.ChangePassword(pChangeField.text);
pChangePanel.gameObject.SetActive(false);
}
private IEnumerator RefreshCoolDown()
{
yield return new WaitForSeconds(5f);
isRefreshCoolDown = false;
}
}
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using TMPro;
using Unity.Netcode;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LobbyPanel : MonoBehaviour
{
[SerializeField] private TMP_Text lobbyNameText;
[SerializeField] private TMP_Text humanText;
[SerializeField] private TMP_Text ownerText;
private Lobby lobby;
private LobbyUIController lobbyUIController;
public void Set(Lobby lobby)
{
this.lobby = lobby;
lobbyNameText.text = lobby.Name;
humanText.text = $"({lobby.Players.Count}/{lobby.MaxPlayers})";
ownerText.text = $"";
lobbyUIController = FindObjectOfType<LobbyUIController>();
}
public async void StartGame()
{
lobbyUIController.loadingPanel.SetActive(true);
try
{
Lobby joiningLobby = await Lobbies.Instance.JoinLobbyByIdAsync(lobby.Id);
await AppController.Instance.StartClientAsync(FirebaseManager.Instance.userData.userName, joiningLobby.Data["JoinCode"].Value);
}
catch(System.Exception e)
{
Debug.LogException(e);
}
await Task.Delay(3000);
if (lobbyUIController == null) return;
lobbyUIController.loadingPanel.SetActive(false);
}
}
상점 시스템
Firebase의 실시간 데이터베이스를 이용하여 할인이 가능한 상점 시스템과 구매한 타워가 데이터베이스에 저장되는 시스템을 구현하였다
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using TMPro;
using Unity.Netcode;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LobbyPanel : MonoBehaviour
{
[SerializeField] private TMP_Text lobbyNameText;
[SerializeField] private TMP_Text humanText;
[SerializeField] private TMP_Text ownerText;
private Lobby lobby;
private LobbyUIController lobbyUIController;
public void Set(Lobby lobby)
{
this.lobby = lobby;
lobbyNameText.text = lobby.Name;
humanText.text = $"({lobby.Players.Count}/{lobby.MaxPlayers})";
ownerText.text = $"";
lobbyUIController = FindObjectOfType<LobbyUIController>();
}
public async void StartGame()
{
lobbyUIController.loadingPanel.SetActive(true);
try
{
Lobby joiningLobby = await Lobbies.Instance.JoinLobbyByIdAsync(lobby.Id);
await AppController.Instance.StartClientAsync(FirebaseManager.Instance.userData.userName, joiningLobby.Data["JoinCode"].Value);
}
catch(System.Exception e)
{
Debug.LogException(e);
}
await Task.Delay(3000);
if (lobbyUIController == null) return;
lobbyUIController.loadingPanel.SetActive(false);
}
}
친구 추가
Firebase의 실시간 데이터베이스를 이용하여 친구 요청이 가능한 친구 추가 시스템을 구현하였다
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class CommunityUIController : MonoBehaviour
{
[Header("Data")]
[SerializeField] private TMP_InputField userNameField;
[SerializeField] private AddFriendPanel friendPreafab;
[Header("Parent")]
[SerializeField] private Transform addFirendParent;
[SerializeField] private Transform acceptFirendParent;
[SerializeField] private Transform friendParent;
private void Start()
{
RefreshFriendAccept();
RefreshFriend();
StartCoroutine(RefreshCo());
}
private async void RefreshFriendAccept()
{
var friendReq = await FirebaseManager.Instance.GetFriendReq(FirebaseManager.Instance.CurrentUserId);
var allUser = await FirebaseManager.Instance.GetAllUser();
var friends = await FirebaseManager.Instance.GetFriendData(FirebaseManager.Instance.CurrentUserId);
if (acceptFirendParent == null) return;
var slots = acceptFirendParent.GetComponentsInChildren<AddFriendPanel>();
foreach(var slot in slots)
{
Destroy(slot.gameObject);
}
foreach (var item in friendReq.reqs)
{
var user = allUser.Find(x => x.key == item);
if(user.userData != null && friends.friends.Find(x => x.userId == item) == null)
{
var slot = Instantiate(friendPreafab, acceptFirendParent);
slot.SetPanel(user.userData.userName, true);
slot.SetUserDataAndKey(item, user.userData);
slot.SetBtnText("수락");
slot.OnButtonClick += HandleFriendAccept;
}
}
}
private async void RefreshFriend()
{
var friends = await FirebaseManager.Instance.GetFriendData(FirebaseManager.Instance.CurrentUserId);
if (friendParent == null) return;
var slots = friendParent.GetComponentsInChildren<AddFriendPanel>();
foreach (var slot in slots)
{
Destroy(slot.gameObject);
}
foreach(var friend in friends.friends)
{
var slot = Instantiate(friendPreafab, friendParent);
slot.SetPanel(friend.userName, false);
}
}
public async void Serch()
{
var slots = addFirendParent.GetComponentsInChildren<AddFriendPanel>();
foreach (var slot in slots)
{
Destroy(slot.gameObject);
}
var users = await FirebaseManager.Instance.GetAllUser();
var findUser = users.FindAll(
x => x.userData.userName.Contains(userNameField.text)
&& x.key != FirebaseManager.Instance.CurrentUserId);
if(findUser.Count != 0)
{
foreach (var user in findUser)
{
var reqs = await FirebaseManager.Instance.GetFriendReq(user.key);
var frends = await FirebaseManager.Instance.GetFriendData(user.key);
if (reqs.reqs.Contains(FirebaseManager.Instance.CurrentUserId)
|| frends.friends.Find(x => x.userId == FirebaseManager.Instance.CurrentUserId) != null) continue;
var slot = Instantiate(friendPreafab, addFirendParent);
slot.SetPanel(user.userData.userName, true);
slot.SetUserDataAndKey(user.key, user.userData);
slot.SetBtnText("친구요청");
slot.OnButtonClick += HandleFriendReq;
}
}
}
private async void HandleFriendReq(string userKey, FirebaseUserData userData)
{
await FirebaseManager.Instance.SendFriendReq(userKey);
Serch();
}
private async void HandleFriendAccept(string userKey, FirebaseUserData userData)
{
await FirebaseManager.Instance.AddFriend(userKey,
new FirebaseFriend
{
userId = FirebaseManager.Instance.CurrentUserId,
userName = FirebaseManager.Instance.userData.userName,
});
await FirebaseManager.Instance.AddFriend(FirebaseManager.Instance.CurrentUserId, new FirebaseFriend
{
userId = userKey,
userName = userData.userName,
});
RefreshFriendAccept();
RefreshFriend();
}
public IEnumerator RefreshCo()
{
while (true)
{
yield return new WaitForSeconds(5f);
RefreshFriendAccept();
RefreshFriend();
}
}
}
일일 로그인
Firebase의 실시간 데이터베이스를 이용하여 일일 로그인 기능을 구현하였다
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Firebase;
using Firebase.Auth;
using Firebase.Database;
using System;
using System.Threading.Tasks;
using System.Linq;
public delegate void LoginEvent(bool success, FirebaseUser user);
public delegate void CreateAccountEvent(bool success);
[Serializable]
public class FirebaseUserData
{
public string userName;
public List<string> deck = new();
public List<string> ableTower = new();
public int coin;
public int loginCount;
public string loginTime;
public bool isRewardGet;
}
[Serializable]
public class FirebaseFriendReqData
{
public List<string> reqs = new();
}
[Serializable]
public class FirebaseFriend
{
public string userName;
public string userId;
}
[Serializable]
public class FirebaseFriendData
{
public List<FirebaseFriend> friends = new();
}
public class FirebaseManager : MonoBehaviour
{
private FirebaseAuth auth;
private FirebaseUser user;
private DatabaseReference db;
public FirebaseUserData userData { get; private set; }
public bool IsContinuousLogIn { get; private set; }
public bool IsAuthError { get; private set; }
public static FirebaseManager Instance;
public string CurrentUserId => user.UserId;
public async Task StartAuth()
{
Instance = this;
await FirebaseApp.CheckAndFixDependenciesAsync().ContinueWith(task =>
{
var dependencyStatus = task.Result;
if (dependencyStatus == DependencyStatus.Available)
{
auth = FirebaseAuth.DefaultInstance;
db = FirebaseDatabase.DefaultInstance.RootReference;
}
else
{
Debug.LogError(string.Format(
"Could not resolve all Firebase dependencies: {0}", dependencyStatus));
IsAuthError = true;
}
});
}
public async void Login(string email, string password, LoginEvent loginEvent)
{
try
{
var res = await auth.SignInWithEmailAndPasswordAsync(email, password);
user = res.User;
loginEvent?.Invoke(true, user);
}
catch(Exception ex)
{
Debug.LogError(ex.Message);
loginEvent?.Invoke(false, null);
}
}
public async void CreateAccount(string email, string password, string userName, CreateAccountEvent callback, bool login = true)
{
try
{
var res = await auth.CreateUserWithEmailAndPasswordAsync(email, password);
user = res.User;
if (login)
{
Login(email, password, (success, user) =>
{
if (success)
{
CreateUserData(userName);
}
callback?.Invoke(success);
});
}
else
{
callback?.Invoke(true);
}
}
catch (Exception ex)
{
Debug.LogError(ex.Message);
callback?.Invoke(false);
}
}
public void CreateUserData(string userName)
{
if (user == null) return;
userData = new FirebaseUserData
{
userName = userName,
loginTime = DateTime.Now.ToString("f"),
loginCount = 1,
ableTower = { "Chicken", "Fish", "Flesh" },
coin = 50000
};
IsContinuousLogIn = true;
DeckManager.Instance.AbleTowerLs = userData.ableTower;
db.Child("users").Child(user.UserId).Child("UserData").SetValueAsync(JsonUtility.ToJson(userData));
db.Child("users").Child(user.UserId).Child("FriendReq").SetValueAsync(JsonUtility.ToJson(new FirebaseFriendReqData()));
db.Child("users").Child(user.UserId).Child("Friends").SetValueAsync(JsonUtility.ToJson(new FirebaseFriendData()));
}
public async Task LoadUserdata()
{
if (user == null) return;
var res = await db.Child("users").Child(user.UserId).Child("UserData").GetValueAsync();
if(res != null && res.Value != null)
{
userData = JsonUtility.FromJson<FirebaseUserData>(res.Value.ToString());
if(userData.loginTime == null)
{
userData.loginTime = DateTime.Now.ToString("f");
userData.loginCount = 1;
}
else
{
var t = DateTime.Parse(userData.loginTime);
if ((DateTime.Now - t).TotalDays == 1 && userData.isRewardGet)
{
userData.isRewardGet = false;
IsContinuousLogIn = true;
userData.loginCount++;
}
if((DateTime.Now - t).TotalDays >= 2)
{
userData.isRewardGet = false;
IsContinuousLogIn = true;
userData.loginCount = 1;
}
userData.loginTime = DateTime.Now.ToString("f");
}
DeckManager.Instance.DeckLs = userData.deck;
DeckManager.Instance.AbleTowerLs = userData.ableTower;
await SaveUserData();
}
else
{
#if UNITY_EDITOR
Debug.LogError("데이터 로딩중 에러 발생");
#else
UnityEngine.Diagnostics.Utils.ForceCrash(UnityEngine.Diagnostics.ForcedCrashCategory.FatalError);
#endif
}
}
public async Task SaveUserData()
{
if (user == null) return;
userData.deck = DeckManager.Instance.DeckLs;
userData.ableTower = DeckManager.Instance.AbleTowerLs;
await db.Child("users").Child(user.UserId).Child("UserData").SetValueAsync(JsonUtility.ToJson(userData));
}
public async Task ChangePassword(string password)
{
await user.UpdatePasswordAsync(password);
}
public async Task<List<(string key, FirebaseUserData userData)>> GetAllUser()
{
List<(string key, FirebaseUserData userData)> dataLs = new();
var res = await db.Child("users").GetValueAsync();
foreach(var keys in res.Children)
{
dataLs
.Add(
(keys.Key.ToString(),
JsonUtility.FromJson<FirebaseUserData>
(keys.Child("UserData").Value.ToString())
));
}
return dataLs;
}
public async Task SendFriendReq(string postUserKey)
{
var res = await db.Child("users").Child(postUserKey).Child("FriendReq").GetValueAsync();
var data = JsonUtility.FromJson<FirebaseFriendReqData>(res.Value.ToString());
if(data != null)
{
data.reqs.Add(user.UserId);
}
await db.Child("users").Child(postUserKey).Child("FriendReq").SetValueAsync(JsonUtility.ToJson(data));
}
public async Task<FirebaseFriendReqData> GetFriendReq(string userId)
{
var res = await db.Child("users").Child(userId).Child("FriendReq").GetValueAsync();
if(res == null)
{
return null;
}
return JsonUtility.FromJson<FirebaseFriendReqData>(res.Value.ToString());
}
public async Task<FirebaseFriendData> GetFriendData(string userId)
{
var res = await db.Child("users").Child(userId).Child("Friends").GetValueAsync();
if(res != null)
{
return JsonUtility.FromJson<FirebaseFriendData>(res.Value.ToString());
}
return null;
}
public async Task AddFriend(string addTo, FirebaseFriend friend)
{
var res = await GetFriendData(addTo);
var res_2 = await GetFriendReq(addTo);
res.friends.Add(friend);
res_2.reqs.Remove(friend.userId);
await db.Child("users").Child(addTo).Child("FriendReq").SetValueAsync(JsonUtility.ToJson(res_2));
await db.Child("users").Child(addTo).Child("Friends").SetValueAsync(JsonUtility.ToJson(res));
}
public async Task<List<string>> GetDiscountTower()
{
var res = await db.Child("Discount").GetValueAsync();
Debug.Log(res.Value);
List<string> result = res.Value.ToString().Split(',').ToList();
return result;
}
private async void OnDestroy()
{
if(db != null && DeckManager.Instance != null)
{
await SaveUserData();
}
}
}
4. 개발 중 문제점
멀티 플레이를 개발하는 것은 처음이라 코드가 더러워지고 개발 시간이 너무 지연된 것이 문제점이였다
5. 프로젝트로 느낀점
멀티 플레이를 처음 개발해보며 하나하나가 전부 색다른 경험 이였고 예전부터 개발해보고 싶던 멀티 플레이를 개발해서 재미있었다 그리고 동아리 수업 내 1등 프로젝트로 선정되어서 기분이 좋고 뿌듯했다
'포트폴리오 > 개인 프로젝트' 카테고리의 다른 글
[개인 프로젝트] FAED (0) | 2024.04.29 |
---|---|
[개인 프로젝트] DESERT (2) | 2024.03.05 |
[개인 프로젝트] NEON (2) | 2024.03.05 |