
이제 플레이어의 레벨, 체력, 각종 능력치를 주관하는 PlayerStats 클래스를 만들 차례다.
우선, 플레이어를 구분하던 ID 필드가 PlayerStats로 옮겨 왔다.
그리고, Bullet 클래스의 명칭이 Projectile로 변경되면서, 투사체는 모두 Projectile 클래스를 상속하는 방식으로 작동할 예정이고, 필드 변수로 Damage를 추가했다.
또한, 기존에는 SerializeField로 이동 속도를 받고, 계산에 활용했었는데, 이제는 PlayerStats에 저장된 NetworkVariable값을 불러온다.
void FixedUpdate()
{
_worldMousePosition = _playerCam.ScreenToWorldPoint(Input.mousePosition); ;
_direction = (Vector3)_worldMousePosition - transform.position;
Vector3 normal = _direction.normalized;
_angle = (short)(Mathf.Atan2(normal.y, normal.x) * Mathf.Rad2Deg);
MoveTowardMouse(transform.position, normal, _playerStats.MovementSpeed.Value * 0.1f, _angle);
}
이렇게 MovementSpeed를 PlayerStats에서 불러온다.
NetworkVariable이 자꾸 헷갈려서 먼저 어떻게 동작해야 할지 생각해보았다.
우선, NetworkVariable을 선언하고, 세션에 플레이어가 스폰될 때 NetworkVariable 값이 초기화되어야 한다.
그리고, 다른 유저들은 체력 NetworkVariable의 값을 받을 수는 있지만, 수정할 수는 없어야 한다.
값이 변경될 때에는 해당 플레이어와 다른 플레이어 모두 그 이벤트를 체크해야 한다.
투사체에 피격될 때는 체력을 감소시켜야 한다.
체력은 최대 체력을 넘어설 수 없고, 체력이 0이 되면 플레이어는 파괴된다.
private NetworkVariable<short> _health, _maxHealth;
private NetworkVariable<short> _atk, _def;
메모리를 절약하기 위해 short 타입으로 선언한다.
atk와 def는 각각 공격력과 방어력으로, 투사체를 발사하거나 투사체에 피격될 때 같이 연산된다.
public NetworkVariable<short> Health { get => _health; set => _health = value; }
public NetworkVariable<short> MaxHealth { get => _maxHealth; set => _maxHealth = value; }
public NetworkVariable<short> AttackPower { get => _atk; set => _atk = value; }
public NetworkVariable<short> DefencePower { get => _def; set => _def = value; }
encapsulate 해준다.
아래 두 코드블럭은 모두 Awake 함수 안에서 작성되었다.
var writePerm = _usingServerAuth ? NetworkVariableWritePermission.Server : NetworkVariableWritePermission.Owner;
var readPerm = NetworkVariableReadPermission.Everyone;
읽기 권한은 모두가 가지지만, 쓰기 권한은 그럴 수 없으므로 나눠서 작업한다.
_health = new(readPerm: readPerm, writePerm: writePerm);
_atk = new(readPerm: readPerm, writePerm: writePerm);
_def = new(readPerm: readPerm, writePerm: writePerm);
_maxHealth = new(readPerm: readPerm, writePerm: writePerm);
위 네 NetworkVariable들은 모두 읽기 권한은 전원에게 부여하고 쓰기 권한은 서버가 가진다.
public override void OnNetworkSpawn()
{
Health.OnValueChanged += OnHealthChanged;
if (!IsOwner) return;
SendIdServerRpc($"Player {OwnerClientId}");
InitializeStats();
}
위와 같이 OnNetworkSpawn 메서드에 Initialize 코드를 작성해준다.
OnHealthChanged는 모든 클라이언트와 서버가 구독해야하는 이벤트이므로 Owner 여부와 관게 없이 할당한다.
하지만, 초기화는 플레이어가 해야한다.
private void InitializeStats()
{
Health.Value = MaxHealth.Value = 100;
Fuel.Value = MaxFuel.Value = 100;
Ammo.Value = 20;
AttackPower.Value = 10;
DefencePower.Value = 5;
MovementSpeed.Value = 0.2f;
HealthRegen.Value = 0.8f;
FuelEfficiency.Value = 0.2f;
Exp.Value = 0;
Level.Value = 0;
}
이렇게 모든 네트워크 변수를 초기화한다.
private void OnHealthChanged(short _, short newHealth)
{
_healthBar.GetComponent<HealthBar>().SetHealth(newHealth, MaxHealth.Value);
if (Health.Value <= 0) DieServerRpc();
}
OnHealthChanged의 역할은 단순하다, 체력바의 UI를 조절하고, 체력이 0이 되면 스스로 파괴하면 된다.
체력바는 아직 다룰 내용이 아니기 때문에, DieServerRpc로 넘어간다.
플레이어 파괴는 서버만이 할 수 있는 행위다. 따라서 플레이어를 파괴하는 함수를 서버에서 실행해야 한다.
ServerRpc를 이용하면 된다.
[ServerRpc]
private void DieServerRpc()
{
Die();
}
private void Die()
{
if (!IsServer) return;
GetComponent<NetworkObject>().Despawn();
Destroy(gameObject);
}
이렇게 DieServerRpc는 Die 함수를 호출한다.
Die는 서버에서만 실행되어야 하므로 IsServer 조건을 걸고, NetworkObject를 Despawn 한 후 해당 game object를 Destroy 한다.
private void OnTriggerEnter2D(Collider2D collider)
{
if (!IsOwner) return;
Transform obj = collider.transform;
if (obj.CompareTag("Projectile") && obj.GetComponent<Projectile>().Shooter != transform)
{
short damage = obj.GetComponent<Projectile>().Damage;
short actualDamage = (short)(damage - DefencePower.Value);
if (damage < DefencePower.Value) return;
RequestDamageServerRpc(actualDamage);
TakeDamage(actualDamage);
}
}
OnCollisionEnter 말고 OnTriggerEnter 메서드를 사용하는 이유는 연산량이 적어서 효율적이기 때문이다.
위 코드는 간단하게 투사체와 충돌했을 경우, Damage 값을 받아와서 방어력을 거친 후의 true damage를 계산한다.
계산된 damage가 음수가 아니면 체력 감소를 실행하는데, 이것도 Server 쪽에서 먼저 실행되어야 하기 때문에 ServerRpc를 이용한다.
[ServerRpc]
public void RequestDamageServerRpc(short damage)
{
TakeDamageClientRpc(damage);
}
[ClientRpc]
public void TakeDamageClientRpc(short damage)
{
if (!IsOwner) TakeDamage(damage);
}
private void TakeDamage(short damage)
{
if (!IsOwner) return;
Health.Value -= damage;
}
ServerRpc를 사용해서 Client에 Damage Propagation을 호출하는데, Client에서는 Owner만이 Health의 수정 권한이 있기 때문에 Health의 값을 Owner에서 수정하면, 다시 다른 Client들은 OnChanged 이벤트에 트리거된다.
위와 같은 Health Bar의 UI는 간단하게 구현할 수 있다.
먼저 링크를 참고해서 Border radius를 조절할 수 있는 패키지를 다운받는다.
그러고 나면 RoundedCorners 컴포넌트를 적용할 수 있게 되는데, 마스크에 적용이 가능하기 때문에 UnityMask에 해당 컴포넌트를 올린 후 하위 항목으로 배경 UI와 체력바 UI를 놓는다. 체력바 UI의 앵커 포인트는 왼쪽 끝으로 설정한다.
그리고, 코드로 x 방향 scale을 조절하면 체력바를 조절하는 것 처럼 보이게 만들 수 있다.
using UnityEngine;
using UnityEngine.UI;
using static ColorUtils;
public class HealthBar : MonoBehaviour
{
private RectTransform _health;
void Awake()
{
_health = transform.GetChild(0).transform.GetChild(1).GetComponent<RectTransform>();
}
public void SetHealth(short health, short maxHealth)
{
if (health > maxHealth) return;
_health.localScale = new Vector3((float)health / maxHealth, 1, 1);
float leftHealth = (float)health / maxHealth;
var image = _health.GetComponent<Image>();
if (leftHealth <= 0.2f) image.color = HexToColor("950000");
else if (leftHealth <= 0.4f) image.color = HexToColor("C15200");
else if (leftHealth <= 0.6f) image.color = HexToColor("ECD400");
else if (leftHealth <= 0.8f) image.color = HexToColor("7E9300");
}
}
이러한 코드를 작성한 후 Mask의 부모 오브젝트 (Prefab으로 설정한 HealthBar)에게 할당하면 정상적으로 작동한다.
using UnityEngine;
public class ColorUtils {
public static Color HexToColor(string hex) {
byte r = byte.Parse(hex[..2], System.Globalization.NumberStyles.HexNumber);
byte g = byte.Parse(hex.Substring(2, 2), System.Globalization.NumberStyles.HexNumber);
byte b = byte.Parse(hex.Substring(4, 2), System.Globalization.NumberStyles.HexNumber);
return new Color32(r, g, b, 255);
}
}
색상이 비직관적이라서 Hex값을 바로 Color로 바꾸는 함수를 제작해 활용했다.