[Unity 게임 만들기] Player Stats 구현하기 2: 레벨

lighteko·2024년 7월 7일

Unity 게임 만들기

목록 보기
10/11
post-thumbnail

이전 포스트에서 만든 NPC 기체와의 상호작용을 통해 경험치와 다른 자원들을 수집하도록 구현할 예정이다.
또한, 유저와의 상호작용도 구현한다.

1. Probe의 OnKilled 이벤트와 PlayerStats 연결

이전 포스트에서 Probe를 어떻게 만들었는지 떠올려보면, Probe의 체력이 다 떨어졌을 때 Player 네트워크 오브젝트에 접근해서 ProbeDropState에 담긴 보상을 적용해주는 형식이었다.

[ServerRpc]
private void OnKilledServerRpc(ProbeDropState state)
{
	PlayerStats killer = NetworkManager.Singleton.ConnectedClients[state.Killer].PlayerObject.GetComponent<PlayerStats>();
	killer.AddFuelServerRpc(state.Fuel);
	killer.AddAmmoServerRpc(state.Ammo);
	killer.AddExpServerRpc(state.Exp);
	Debug.Log($"Player {state.Killer} killed a probe");
	Debug.Log($"Acquired: \nFuel: {state.Fuel}, \nAmmo: {state.Ammo}, \nExp: {state.Exp}");
}

이렇게 만들었었다.

이제 각각의 AddServerRpc 메서드들을 PlayerStats 클래스에서 구성해줄 차례다.

AddExp 구현

[ServerRpc]
public void AddExpServerRpc(float exp)
{
    AddExpClientRpc(exp);
}
[ClientRpc]
public void AddExpClientRpc(float exp)
{
    AddExp(exp);
}
private void AddExp(float exp)
{
    if (!IsOwner) return;
    Exp.Value += exp;
}

먼저 AddExp는 위와 같이 만들면 된다. 우선 AddExpServerRpc로 서버에 요청을 보내고, 서버에서 다시 클라이언트로 해당 요청을 전파시키는데, 만일 Owner가 아닐 경우는 Write 권한이 없기 때문에 return해주고, Owner일 경우에만 인자로 넘겨받은 양 만큼 exp 값을 변경해주는 방식이다.

private void OnExpChanged(float _, float newExp)
{
    float threshold = 100 * Mathf.Pow(1.25f, Level.Value + 1);
    if (newExp >= threshold)
    {
        Level.Value++;
        Exp.Value = newExp - threshold;
    }
    _levelBar.GetComponent<LevelBar>().SetExp(Level.Value, newExp, threshold);
}

OnExpChanged는 Level 값 변경에 활용된다. 계산된 다음 레벨로 올라가는 임계값을 넘을 경우 Level을 1 증가시키며, Level UI를 업데이트한다.

private void OnLevelChanged(short _, short newLevel)
{
    float threshold = 100 * Mathf.Pow(1.25f, newLevel + 1);
    if (newLevel % 5 == 0)
    {
        // TODO: Special upgrade
    }
    short maxHP = MaxHealth.Value;
    short maxFuel = MaxFuel.Value;
    short atk = AttackPower.Value;
    short def = DefencePower.Value;
    MaxHealth.Value = (short)(maxHP + 50 * newLevel);
    MaxFuel.Value = (short)(maxFuel + 25 * newLevel);
    AttackPower.Value = (short)(atk + 10);
    DefencePower.Value = (short)(def + 10);
    _levelBar.GetComponent<LevelBar>().SetExp(newLevel, Exp.Value, threshold);
}

위와 같이 Level이 변했을 경우 5의 배수면 특수 기능 (무기, 액티브 스킬, 패시브 스킬)을 획득할 수 있고, 그 외에는 기본적인 스탯 증가를 적용해준다.

AddAmmo 구현

다음은 Ammo차례다.
Ammo의 경우 증가와 감소가 모두 있기 때문에 메서드를 두번 만들어주어야 한다.

[ServerRpc]
public void AddAmmoServerRpc(short ammo)
{
    AddAmmoClientRpc(ammo);
}
[ClientRpc]
private void AddAmmoClientRpc(short ammo)
{
    AddAmmo(ammo);
}
private void AddAmmo(short ammo)
{
    if (!IsOwner) return;
    Ammo.Value += ammo;
}

앞선 Exp 예시와 같은 원리로, 서버에서 클라이언트로 전파하는 과정에서 해당 요청을 날린 Owner만 Ammo Value를 증가시킬 수 있다.

[ServerRpc]
public void ConsumeAmmoServerRpc(short ammo)
{
    ConsumeAmmoClientRpc(ammo);
}
[ClientRpc]
private void ConsumeAmmoClientRpc(short ammo)
{
    if (IsOwner) ConsumeAmmo(ammo);
}
private void ConsumeAmmo(short ammo)
{
    if (Ammo.Value < ammo) return;
    Ammo.Value -= ammo;
}

같은 원리다. 다만, 효율성을 따지면 이 방법이 더 효율적일 것이다. 이유는 비효율적인 Function Call 자체가 일어나지 않기 때문이다.

public void OnAmmoChanged(short _, short newAmmo)
{
    _ammoBar.GetComponent<AmmoBar>().SetAmmo(newAmmo, 300);
}

이렇게 OnAmmoChanged 메서드도 구현했다. 딱히 할 것은 없고, 그냥 UI 연동만 진행했다.

AddFuel 구현

[ServerRpc]
public void ConsumeFuelServerRpc(short consumed)
{
    ConsumeFuelClientRpc(consumed);
}
[ClientRpc]
private void ConsumeFuelClientRpc(short consumed)
{
    if (IsOwner) ConsumeFuel(consumed);
}
private void ConsumeFuel(short consumed)
{
    Fuel.Value -= consumed;
}
[ServerRpc]
public void AddFuelServerRpc(short fuel)
{
    AddFuelClientRpc(fuel);
}
[ClientRpc]
private void AddFuelClientRpc(short fuel)
{
    if (IsOwner) AddFuel(fuel);
}
private void AddFuel(short fuel)
{
    Fuel.Value += fuel;
}

이것은 따로 설명하지 않겠다. 위의 예시들과 같다.

private void OnFuelChanged(short _, short newFuel)
{
    _fuelBar.GetComponent<FuelBar>().SetFuel(newFuel, MaxFuel.Value);
    if (Fuel.Value <= 0)
    {
        var state = new PlayerDropState
        {
            Id = OwnerClientId,
            Killer = _lastHit,
            Fuel = Fuel.Value,
            Ammo = Ammo.Value,
            Level = Level.Value,
            Exp = Exp.Value
        };
        DieServerRpc(state);
    }
}

Fuel은 생존에 직결되는 요소로써, 0에 도달할 경우 기체가 파괴된다. 따라서 이렇게 PlayerDropState를 만들었고, 각종 정보가 기록된 보상을 Die 메서드를 통해 상대에게 전달하도록 구성했다.

2. Player의 OnKilled 이벤트와 PlayerStats 연결

DieServerRpc를 호출할 때 DropState를 인자로 넘겨주는데 이 DropState에 담긴 Killer 정보를 읽어서
NetworkObject 중 Killer를 찾고, 해당 오브젝트가 가지는 각종 Add 메서드 (위에서 만든 것)를 원격으로 호출하면 보상을 받게 해줄 수 있다.

private struct PlayerDropState : INetworkSerializable
{
    private short _fuel, _ammo, _level;
    private float _exp;
    private ulong _id;
    private ulong? _killer;
    internal ulong? Killer { readonly get => _killer; set => _killer = value; }
    internal ulong Id { readonly get => _id; set => _id = value; }
    internal short Fuel { readonly get => _fuel; set => _fuel = value; }
    internal short Ammo { readonly get => _ammo; set => _ammo = value; }
    internal short Level { readonly get => _level; set => _level = value; }
    internal float Exp { readonly get => _exp; set => _exp = value; }
    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref _id);
        serializer.SerializeValue(ref _fuel);
        serializer.SerializeValue(ref _ammo);
        serializer.SerializeValue(ref _level);
        serializer.SerializeValue(ref _exp);
    }
}

PlayerDropState는 위와 같이 구성된다.

private void Die(PlayerDropState state)
{
    if (!IsServer) return;
    if (state.Killer != null)
    {
        OnKilledServerRpc(state);
    }
    GetComponent<NetworkObject>().Despawn();
    Destroy(gameObject);
}

이렇게 Die 함수에서 PlayerDropState를 인자로 넘겨받기 때문에 OnKilledServerRpc를 호출할 때 state를 넘겨줄 수 있고,
서버에서 실행되는 OnKilledServerRpc에 클라이언트의 정보를 (DropState) 넘겨주었기 때문에 정상적으로 Killer를 찾고 해당 유저에게 보상을 지급할 수 있어야 한다.

[ServerRpc]
private void OnKilledServerRpc(PlayerDropState data)
{
    Debug.Log("Called");
    if (data.Killer == null) return;
    var killer = NetworkManager.Singleton.ConnectedClients[data.Killer.Value].PlayerObject.GetComponent<PlayerStats>();
    killer.AddExpServerRpc(data.Exp * (1 + data.Level));
    killer.AddFuelServerRpc(data.Fuel);
    killer.AddAmmoServerRpc(data.Ammo);
}

OnKilledServerRpc는 위와 같이 구성했다.

그런데, 플레이어끼리 죽여도 보상이 지급되지 않는 문제점이 있었다.
Probe는 정상적으로 보상이 지급되는 반면 Player 간 상호작용에만 문제가 있는게 이해되지 않아서 알아보는 중이다.

3. Error Handling

1) ServerRpc로 데이터가 넘어가지 않는 현상

State를 만들고 ServerRpc로 넘겨줄 때 Killer만 값이 넘어가지 않는 현상이 발생했다.
디버깅 결과 State를 생성했을 때는 값이 존재하는데, 파라미터로 넘겨주기만 하면 값이 사라져버리는 문제가 있었다.
이를 해결하기 위해 코드를 잘 살펴보았다.

OnChanged의 실행 위치 문제

OnHealthChanged는 모든 클라이언트가 구독하는 이벤트다.
그런데, Owner를 제외한 다른 클라이언트가 해당 이벤트를 구독하는 이유는 체력바 UI 때문이다. 그 외의 다른 목적은 없다.

private void OnHealthChanged(short _, short newHealth)
{
    _healthBar.GetComponent<HealthBar>().SetHealth(newHealth, MaxHealth.Value);
    if (Health.Value <= 0)
    {
        var state = new PlayerDropState
        {
            Id = OwnerClientId,
            Killer = _lastHit,
            Fuel = Fuel.Value,
            Ammo = Ammo.Value,
            Level = Level.Value,
            Exp = Exp.Value
        };
        DieServerRpc(state);
    }
}

그런데, 만약 체력이 0보다 작거나 같을 경우 모든 클라이언트에서 State를 생성하게된다. 그리고, 제일 먼저 생성된 State가 DieServerRpc를 실행할 것이다.

이렇게 생긴 문제다. Owner가 아닌 다른 클라이언트가 만든 State를 ServerRpc 메서드가 넘겨받았기 때문에 제대로 Killer가 기록되지 않았던 것이다. 나머지 field는 전부 NetworkVarible 이기 때문에 문제가 없었다.

이를 해결하기 위해서 if (!IsOwner) return; 구문을 UI 실행 구문 바로 다음에 넣어주면 된다.

private void OnHealthChanged(short _, short newHealth)
{
    _healthBar.GetComponent<HealthBar>().SetHealth(newHealth, MaxHealth.Value);
    if (!IsOwner) return;
    if (Health.Value <= 0)
    {
        var state = new PlayerDropState
        {
            Id = OwnerClientId,
            Killer = _lastHit,
            Fuel = Fuel.Value,
            Ammo = Ammo.Value,
            Level = Level.Value,
            Exp = Exp.Value
        };
        DieServerRpc(state);
    }
}

해결된 모습

2) OnKilled 이벤트가 실행되지 않는 문제

private void Die(PlayerDropState state)
    {

        if (state.Killer != ulong.MinValue)
        {
            OnKilledServerRpc(state);
        }
        GetComponent<NetworkObject>().Despawn();
        Destroy(gameObject);
    }

위에서 따로 설명하지 않았는데, Killer 타입을 ulong? (nullable) 로 설정할 경우 값이 넘어가지 않는 문제가 있었다. 그래서 ulong 타입으로 변경했고, 처음에는 기본값을 ulong.MinValue 로 설정했었다.

ulong.MinValue 는 0이며, 0부터 서버 입장 순서대로 ClientID가 부여되기 때문에 0번째 유저, 즉 Host가 상대를 잡았을 때 OnKilled 이벤트가 실행될 수 없었다.

이를 해결하기 위해서 ulong.MinValue 대신 ulong.MaxValue 로 변경했고, 아무리 유저가 많이 들어와도 MaxValue는 부여받을 수 없기 때문에 (한 세션당 유저 수 제한) 안전한 방법이라고 판단했다.

private void Die(PlayerDropState state)
{
    if (state.Killer != ulong.MaxValue)
    {
        OnKilledServerRpc(state);
    }
    GetComponent<NetworkObject>().Despawn();
    Destroy(gameObject);
}

위처럼 수정되었다.

3) ServerRpc Ownership 문제

Die 메서드는 Server에서 실행되는데, OnKilledServerRpc는 Owner가 가지는 메서드다. 따라서 ServerRpc Ownership 문제가 발생한다. 다른 클라이언트의 ServerRpc메서드를 실행할 수 없기 때문이다.
따라서 함수 헤더를 아래와 같이 수정한다.

[ServerRpc(RequireOwnership = false)]
private void OnKilledServerRpc(PlayerDropState data)
{
    if (data.Killer == ulong.MaxValue) return;
    var killer = NetworkManager.Singleton.ConnectedClients[data.Killer].PlayerObject.GetComponent<PlayerStats>();
    killer.AddExpServerRpc(data.Exp + 50 * (1 + data.Level));
    killer.AddFuelServerRpc(data.Fuel);
    killer.AddAmmoServerRpc(data.Ammo);
}

이렇게 RequireOwnership을 false로 세팅해준다.

0개의 댓글