> For the complete documentation index, see [llms.txt](/ko/llms.txt)


# 튜토리얼: 서버에서 HP를 관리하는 적 만들기

C#으로 룸의 룰을 작성하고, Unity에서 여러 플레이어가 같은 적을 공격할 수 있게 하기까지의 절차



이 튜토리얼에서는 룸에 참가한 전원이 같은 적을 공격할 수 있는 작은 룸을 만듭니다.

- 적은 1체이며, HP는 100입니다.
- 플레이어가 공격할 때마다 HP가 25 감소합니다.
- HP가 0이 되면 8초 후에 HP 100으로 부활합니다.

HP 계산은 서버에서 수행합니다. Unity는 「공격한다」는 요청을 전송하고, 서버가 확정한 HP를 표시하기만 합니다. 따라서 여러 플레이어가 동시에 공격하더라도 전원에게 같은 HP가 표시됩니다.

서버 타입은 `stateMachine`을 사용합니다. 서버 타입의 차이는 [구성과 선택 방법]()을 참조해 주세요.

## 작업 흐름

| 순서 | 작업 장소 | 만드는 것 |
| --- | --- | --- |
| [1. 룰을 작성한다](#rules) | 텍스트 에디터 / IDE | 적의 생성·공격·부활을 작성한 C# |
| [2. GSL과 동기화 스키마를 생성한다](#generate) | Unity Editor | `main.gsl`, `EnemyFields.realtimeschema` |
| [3. 정의를 등록한다](#register) | GS2 | StateMachineMaster와 Realtime 네임스페이스 설정 |
| [4. 적의 Prefab을 만든다](#prefab) | Unity Editor | HP를 표시하고 공격을 전송하는 Prefab |
| [5. 룸에 접속한다](#connect) | Unity Editor | 수신한 상태로부터 적을 표시하는 씬 |
| [6. 동작을 확인한다](#verify) | Unity(클라이언트 2개) | — |

GS2 SDK for Unity와, LocalStateMachineKit의 C# 생성 기능을 포함한 버전을 도입한 Unity 프로젝트(Unity 2021.3 이상)에서 작업합니다.

## 1. 룰을 작성한다 {#rules}

Unity 프로젝트의 `Assets` 바깥에 다음 폴더와 파일을 만듭니다. 여기에 작성하는 C#은 Unity에서 실행하는 코드가 아니라, GSL을 생성하기 위한 입력입니다.

```text
GameRules/
└── EnemyRoom/
    ├── project.json
    └── Rules/
        └── EnemyRoom.cs
```

`EnemyRoom.cs`에 룸의 룰을 작성합니다.

```csharp
using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.RoomState, GameRules.RoomResult, GameRules.RoomError>;

namespace GameRules;

// 적의 동기화 항목. Prefab ID 7, 위치와 HP를 클라이언트에 배포한다
[RoomSchema(7, StandardFields = RoomSync.Position)]
public enum EnemyFields
{
    Health = 6,
}

public record RoomState(int EnemyId);
public record RoomResult(bool Finished);
// 거부 이유는 클라이언트의 CommandCompleted에 그대로 전달된다. 시스템 결과 코드와 겹치지 않는 값으로 한다
public enum RoomError { InvalidTarget = 1001, AlreadyDefeated = 1002 }

public static class EnemyRoom
{
    // 룸 시작 시에 한 번만 실행한다
    public static Step Start()
    {
        Room.DefineServerPrefab<EnemyFields>();
        var enemy = Room.Spawn<EnemyFields>(0, 0, 0);
        Room.Set(enemy, EnemyFields.Health, 100);
        return Step.WaitFor(nameof(Interact), new RoomState(enemy))
            .Or(nameof(Timer));
    }

    // 플레이어가 SharedObject.Interact를 호출했을 때
    public static Step Interact(RoomState state, int objectId, int method)
    {
        if (objectId != state.EnemyId || method != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        var hp = Room.Get(objectId, EnemyFields.Health);
        if (hp == 0)
            return Step.Ignore(RoomError.AlreadyDefeated);

        hp = System.Math.Max(0, hp - 25);
        Room.Set(objectId, EnemyFields.Health, hp);
        if (hp == 0)
            Room.After(objectId, 8000, 1); // 8초 후에 Timer(eventId 1)를 받는다
        return Step.Stay(state);
    }

    // Room.After로 예약한 시각이 되었을 때
    public static Step Timer(RoomState state, int objectId, int eventId)
    {
        if (objectId != state.EnemyId || eventId != 1)
            return Step.Ignore(RoomError.InvalidTarget);

        Room.Set(objectId, EnemyFields.Health, 100);
        return Step.Stay(state);
    }
}
```

핵심은 다음 3가지입니다.

- **동기화할 값은 `RoomSchema`로 선언합니다.** `EnemyFields` 선언으로부터 서버의 정의와 Unity의 동기화 스키마가 모두 생성됩니다. enum 값(`Health = 6`)은 통신상의 필드 번호입니다. 위치가 0~2를 사용하므로, 독자적인 값은 6 이상으로 합니다.
- **HP는 공유 오브젝트에 갖게 합니다.** 클라이언트에 배포되는 것은 공유 오브젝트의 값이며, `RoomState`는 배포되지 않습니다. `RoomState`에는 적의 오브젝트 ID처럼 서버만 사용하는 값을 갖게 합니다.
- **공격 가능 여부는 서버가 판단합니다.** 대상이나 횟수의 검증은 `Interact`에 작성합니다. 실제 게임에서는 공격 가능한 거리나 쿨다운도 여기서 검증합니다.

## 2. GSL과 동기화 스키마를 생성한다 {#generate}

`GameRules/EnemyRoom/project.json`에 생성 설정을 저장합니다. 경로는 이 파일을 기준으로 한 상대 경로로 지정합니다. `hostProfile`에 `RealtimeRoomV1`을 지정하면 Realtime 룸에서 실행하는 GSL을 생성합니다.

```json
{
  "format": 1,
  "source": "Rules",
  "output": "../../Assets/Generated/EnemyRoom",
  "hostProfile": "RealtimeRoomV1"
}
```

1. Unity 메뉴에서 **GS2 > Local State Machine > Generate from C#**을 선택합니다.
2. **Existing definition > Project**의 **…**에서 `GameRules/EnemyRoom/project.json`을 선택합니다.
3. **Generate**를 누릅니다.

`Assets/Generated/EnemyRoom`에 다음 파일이 생성됩니다.

| 파일 | 용도 |
| --- | --- |
| `main.gsl` | 순서 3에서 GS2에 등록하는 룰 |
| `package.zip` | `main.gsl`만 넣은 배포용 파일 |
| `unity/EnemyFields.realtimeschema` | 순서 4에서 적의 Prefab에 지정하는 동기화 스키마 |
| `source-map.json` | 생성된 위치로부터 원래 C#을 찾기 위한 개발용 파일 |

생성 오류는 Console에 원래 C#의 파일명과 행 번호와 함께 표시됩니다. 사용할 수 있는 C#의 범위는 [C# 정의 레퍼런스](#language)를 참조해 주세요.

## 3. 정의를 등록한다 {#register}

GS2-StateMachine의 [UpdateStateMachineMaster](#updatestatemachinemaster)로 생성한 룰을 등록합니다.

| 항목 | 지정하는 값 |
| --- | --- |
| `namespaceName` | 룰을 저장하는 GS2-StateMachine의 네임스페이스 |
| `mainStateMachineName` | `EnemyRoom`(룰의 클래스명) |
| `payload` | `main.gsl`의 내용(문자열) |

다음으로, Realtime 네임스페이스에서 룸에서 실행할 룰을 지정합니다.

| 설정 내용 | 설정 항목 | 지정하는 값 |
| --- | --- | --- |
| 서버 타입 | `serverType` | `stateMachine` |
| 룰 저장 위치 | `stateMachineSetting.namespaceId` | 위에서 등록한 GS2-StateMachine의 네임스페이스 |
| 룰 버전 | `stateMachineSetting.version` | 생략(최신을 사용) |

버전을 생략하면 룸은 할당되었을 때 그 시점에 등록되어 있는 최신 룰을 한 번만 읽어 들이고, 룸이 끝날 때까지 그 룰로 동작합니다. 룰을 다시 등록하면 그 이후에 할당되는 룸부터 새로운 룰이 사용됩니다.

특정 버전으로 고정하고 싶을 때는 등록 결과의 버전을 지정합니다. 고정하는 동안에는 룰을 다시 등록해도 룸에 반영되지 않습니다.

## 4. 적의 Prefab을 만든다 {#prefab}

### 동기화 스키마를 가져오기

`Assets/Generated/EnemyRoom/unity/EnemyFields.realtimeschema`는 Unity로 가져오면 동기화 스키마 에셋이 됩니다. 생성 위치가 `Assets` 안이므로 그대로 사용할 수 있습니다.

### Prefab 만들기

1. 적의 외형이 될 Prefab을 만들고, 루트에 다음 컴포넌트를 추가합니다.

   | 컴포넌트 | 설정 |
   | --- | --- |
   | **NetworkEntity** | **Schema**에 `EnemyFields`를 지정 |
   | **NetworkTransform** | **Position Axes**는 `All`, **Rotation Axes**는 `None`(회전은 서버가 배포하지 않으므로) |
   | **SharedObject** | 설정 없음. 서버로 조작을 전송하는 데 사용 |

2. HP를 표시하는 Slider를 자식으로 배치하고, 다음 스크립트를 루트에 추가하여 Slider를 할당합니다. Slider의 **Max Value**는 100으로 합니다.

   ```csharp
   using UnityEngine;
   using UnityEngine.UI;

   public sealed class EnemyHealthView : MonoBehaviour
   {
       [SerializeField] private Slider health;

       public void SetHealth(int value)
       {
           health.value = value;
       }
   }
   ```

3. **NetworkEntity > Property bindings > Add binding**에서 항목을 추가하고, **Field**에서 `Health`를 선택합니다. **Apply to**에 `EnemyHealthView`를 등록하고 **Dynamic int > SetHealth**를 선택합니다. 이것으로 수신한 HP가 Slider에 반영됩니다.

### 공격 전송하기

공격은 `SharedObject.Interact`로 서버에 전송합니다. 다음 스크립트를 Prefab의 루트에 추가하고, 공격 버튼 등에서 `Attack`을 호출합니다.

```csharp
using Gs2.Realtime.Sync;
using UnityEngine;

[RequireComponent(typeof(SharedObject))]
public sealed class EnemyAttack : MonoBehaviour
{
    public void Attack()
    {
        var target = GetComponent<SharedObject>();
        if (target.Object != null)
            target.Interact(1); // 룰의 Interact에 method 1로 전달된다
    }
}
```

`Interact(1)`은 「이 적에게 method 1의 조작을 한다」는 요청입니다. HP를 직접 변경하는 것이 아니라, 서버의 룰이 공격을 검증하여 HP를 갱신하고, 그 결과가 전원에게 배포됩니다.

## 5. 룸에 접속한다 {#connect}

### NetworkWorld 배치하기

1. Project 창의 **Create > GS2 > Realtime > Prefab Catalog**에서 카탈로그를 만듭니다.
2. 카탈로그의 **Prefabs**에 **Id** `7`과 순서 4의 적 Prefab을 등록합니다. `7`은 `RoomSchema(7)`에서 지정한 Prefab ID입니다.
3. 씬에 빈 GameObject를 만들고 **NetworkWorld**를 추가한 후, **Prefab Catalog**에 카탈로그를 지정합니다.

NetworkWorld는 서버로부터 받은 공유 오브젝트를 카탈로그의 Prefab으로부터 생성합니다. Unity의 `Instantiate`로 만든 GameObject는 공유되지 않습니다. 플레이어가 자신의 오브젝트를 만드는 방법은 [플레이어 오브젝트 만들기](#player-objects)를 참조해 주세요.

### 접속 스크립트 추가하기

NetworkWorld와 같은 GameObject에 다음 스크립트를 추가합니다. 룸의 접속 정보(IP 주소·포트·암호화 키)와 액세스 토큰은 [게임 서버 접속 정보 취득](#게임-서버-접속-정보-취득)의 방법으로 취득하여 전달합니다.

```csharp
using System.Collections;
using System.Collections.Generic;
using Google.Protobuf;
using Gs2.Core;
using Gs2.Gs2Realtime.Message;
using Gs2.Realtime.Sync;
using Gs2.Unity.Gs2Realtime;
using UnityEngine;

[RequireComponent(typeof(NetworkWorld))]
public sealed class EnemyRoomConnection : MonoBehaviour
{
    private readonly Queue<BinaryMessage> outgoing = new Queue<BinaryMessage>();
    private RealtimeSession session;

    public IEnumerator Connect(string accessToken, string ipAddress, int port, string encryptionKey)
    {
        var world = GetComponent<NetworkWorld>();
        session = new RealtimeSession(accessToken, ipAddress, port, encryptionKey, ByteString.Empty);
        session.OnClose += _ => world.Detach();

        AsyncResult<bool> result = null;
        yield return session.Connect(null, value => result = value);
        if (result == null || result.Error != null || !session.Connected)
            yield break;

        // 수신 처리를 등록한 후 Dispatch를 시작하여, 참가 직후의 스냅샷을 놓치지 않는다
        world.Attach(session.MyConnectionId, (bytes, count) =>
        {
            if (outgoing.Count >= 32 || !session.Connected)
                return false;
            outgoing.Enqueue(new BinaryMessage { Data = ByteString.CopyFrom(bytes, 0, count) });
            return true;
        });
        // 메타데이터의 서버 시각을 전달하면 world.ServerTime과 시각에 따른 보간을 사용할 수 있다
        session.OnMessageWithMetadata += (message, metadata) => world.Receive(message.Data.ToByteArray(), metadata.LifeTimeMilliSeconds);
        StartCoroutine(session.Dispatch());
        StartCoroutine(SendQueued());
    }

    private IEnumerator SendQueued()
    {
        while (session != null && session.Connected)
        {
            if (outgoing.Count == 0)
            {
                yield return null;
                continue;
            }
            var sent = false;
            yield return session.Send(result => sent = result.Error == null && result.Result, outgoing.Dequeue());
            if (!sent)
            {
                // 전송하지 못한 변경을 버리지 않고, 접속을 끊고 상태를 다시 받는다
                yield return session.Close();
                yield break;
            }
        }
    }
}
```

`Attach`에 전달하는 함수는 전송할 데이터를 받아 전송 큐에 쌓고, 쌓았는지 여부를 반환합니다. NetworkWorld는 변경을 모아서 이 함수로 전송합니다.

접속에 성공하면 서버로부터 현재 룸의 상태(스냅샷)가 도착하고, NetworkWorld가 적을 생성합니다.

## 6. 동작을 확인한다 {#verify}

같은 룸에 2개의 클라이언트에서 접속합니다. 같은 userId로 접속하면 먼저 접속한 쪽이 대체되므로, 서로 다른 userId의 액세스 토큰을 사용해 주세요.

1. 양쪽에 같은 적이 1체 표시되고, HP가 100인 것을 확인합니다.
2. 한쪽에서 공격하여, 양쪽의 표시가 75가 되는 것을 확인합니다.
3. 양쪽에서 공격을 전송하여, 합계 4회로 HP가 0이 되는 것을 확인합니다.
4. HP가 0인 동안에는 공격해도 줄지 않고, 약 8초 후에 양쪽의 표시가 100으로 돌아오는 것을 확인합니다.
5. 한쪽의 접속을 끊고 다시 접속하여, HP가 초기값으로 돌아가지 않고 그 시점의 값으로 표시되는 것을 확인합니다.

공격이 거부되었는지 여부는 `NetworkWorld.Client.CommandCompleted`로 확인할 수 있습니다. HP가 0인 적에 대한 공격에서는 룰에서 지정한 `1002`(`AlreadyDefeated`)가 전달됩니다.

## 다음에 읽을 내용

- [Unity 클라이언트](): 위치 동기화, 조작 권한, RPC, 플레이어 오브젝트, UDP, 재접속 처리.
- [Photon에서의 이전 가이드](): PUN2 API와의 대응표와 이전 절차.
- [룸 정의 레퍼런스](): Room API, 타이머, 변경의 확정과 취소, 상한값.
- [C# 정의 레퍼런스](): 상태·조작·Step 작성 방법.



