Photon Fusion for UnityのProjectilesサンプルを改造して弾ビルドゲームを作る

# はじめに

シューター系のゲームを作る場合、ベーシックな銃弾だけではなく、追尾したり跳弾したりと色々な種類の弾を実装したくなるかと思います。

しかしオンラインゲームの場合、オフラインと違い弾自身も一つの同期オブジェクトとなる都合上、弾の特性はその同期方法に大きく依存することになります。

今回は後述するProjectilesサンプルのシューター部分の強化として、前提となる複数ある弾の同期方法についてと、動的に武器性能を変化させるシステムについて紹介します。

### 前提記事

Photon Fusionの基本的な解説は以下の記事で行っています。

https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8

また、以前の記事「Photo Fusion for UnityのサンプルSocial HubとProjectilesをあわせてシューターを作る」では、Social HubとProjectilesという2つの公式サンプルを組み合わせたものをベースに、その１ではシーンの扱い方について、その２では大量の物理オブジェの扱い方について紹介しています。本記事の内容とは直接は関係ありませんが、気になる方はそちらも参照下さい。

https://qiita.com/nimushiki/items/f753196ff9014e080ab6

https://qiita.com/nimushiki/items/02f3376e2ec547fb9719

### 動作確認環境

Windows 11 Home 22H2
Unity 2022.3.2f1
Fusion SDK 1.1.6 F Build 696

# 弾の同期方法について

ここではProjectilesサンプルに実装されている弾（≒同期方法）について紹介します。

https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-projectiles-advanced

弾自身も一つの同期オブジェクトであるため、弾の特性はその同期方法に大きく依存しています。

そのため弾の特性を動的にカスタムするようなシステムを作る場合、同期の品質や通信コストに影響がでたり、同期方法の動的変更のような高度な実装が必要になる可能性があることに注意が必要です。

### Hitscan Projectile

これは瞬間的に着弾するタイプの弾です。

入力があるとLagCompensation.Raycastを用いて即座に着弾処理を行います。表示される弾道や弾は各クライアントで個別に表示されるエフェクトの扱いで、同期が取れているかは保証されません。

### Kinematic Projectile

これは物理挙動をするタイプの弾です。

Physics系のコンポーネントは用いず、自前の簡易的な物理挙動が実装されています。当たり判定は、次のフレームでの移動先を予測しその分だけLagCompensation.Raycastを飛ばして処理をします。

### Laser

これも瞬間的に着弾するタイプの弾ですが、ProjectileとしてではなくWeaponComponent（WeaponBeam）として作られています。

Hitscan Projectile同じくLagCompensation.Raycastを用いて即座に着弾処理を行います。こちらは入力がある間断続的に判定を行い続け、また太さを表現するため複数のRaycastを利用しています。

### Standalone Projectile

他の弾は全てプレイヤーがそのライフサイクルを管理するため、例えばクライアントが発射した弾はそのクライアントが切断された場合一緒にDespawnします。

Standalone Projectileは生成時にサーバーやホストにその権限を移すことにより、ゲーム内に長時間残る弾、例えば大陸間弾道弾のようなものを実現できます。

# サンプルの構造と改造ポイント

公式のProjectilesサンプルはエラーチェックや操作条件判定もしっかり作られており拡張性もありますが、その分規模も大きくなっています。

そのためまずはサンプルの仕組みについて大きく武器側とUI側に分けて解説し、改造例を紹介します。

以下はprojectileサンプルのうち今回関連する部分の大まかな構造と、追加コンポーネントの位置の図です。
![projectileサンプルの構造と追加コンポーネントの位置](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/d3654243-2627-a3ef-9391-fddea815e51b.png)

赤枠で囲った部分が今回追加するシステムの表示部分です。Zキーを押すことで、現在装備している武器のマガジンサイズを拡張することができます。
![完成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/4944ae56-54a5-1397-0e2b-7a44b926c6fb.png)

## 武器側

まずは武器側です。一つの武器に対し、マシンガンとボムといったように１～２つ攻撃方法が用意されています。

大まかに、プレイヤーの操作はPlayerAgentから順番に伝えられていき、操作の結果や現在の武器の状態はそれぞれの武器オブジェクトが保持、といった構造になっています。

- PlayerAgent
    
    武器や体力など、プレイヤーの機能を管理するコンポーネント
    
- Weapons
    
    武器を統括するコンポーネント
    
    登録した武器の初期化、装備中の武器の情報、弾について責任を負う
    
- Weapon
    
    武器ごとに１～２個ある攻撃方法を管理するコンポーネント
    
- WeaponAction
    
    WeaponComponentをまとめて攻撃方法の1つを構成するコンポーネント
    
- WeaponComponent（WeaponMagazineなど）
    
    武器・弾の性質を決めるコンポーネント
    
    WeaponMagazineであれば、武器の装填数、リロード有無、リロード時間などを付与する
    

武器の強化方法には色々な方式が考えられますが、まずは単純に以下の要件で考えてみます。

- 強化対象の武器は現在装備中の武器のメインアクションのみ
- 強化対象の項目はマガジンの装填数
- 強化方法はZキー

WeaponComponentの状態を変更するためのWeaponCustomizerというコンポーネントを作ります。WeaponActionに近い位置づけで、プレイヤーの入力を処理する方法や同一コンポーネントにあるWeaponComponentを探して管理する方法はほぼ同じです。

今回はWeaponComponentを継承したコンポーネントのうちWeaponMagazineに対し数値の変更をし武器の強化を実現します。

```WeaponCustomizer.cs
public class WeaponCustomizer : NetworkBehaviour
{
    [SerializeField, Tooltip("Can be used in special cases where sharing of same component between multiple weapon actions is needed (e.g. shared magazine component")]
    private WeaponComponent[] _sharedComponents;
    private List<WeaponComponent> _components = new(16);
    
    public int AddMaxMagazineNum { get { return _addMaxMagazineNum; } set { } }
    public int MaxMagazineNum { get { return _maxMagazineNum; } set { } }
    public string AddMaxMagazineLabel { get { return "Magazine"; } set { } }
    [SerializeField]
    private int _addMaxMagazineNum;
    [SerializeField]
    private int _maxMagazineNum;

    private void Awake()
    {
        _components.Clear();
        transform.GetComponents(_components);

        for (int i = 0; i < _sharedComponents.Length; i++)
        {
            var component = _sharedComponents[i];

            Assert.Check(_components.Contains(component) == false, $"Component {component.name} is already present in weapon components and should not be present in shared components");
            _components.Add(component);
        }
        _components.Sort((a, b) => b.Priority - a.Priority);
    }

    public void ProcessInput(WeaponContext context, bool weaponIsBusy)
    {
        //zキーがトリガーオンとなった時に処理
        if (context.PressedInput.IsSet(EInputButtons.Z)) AddMaxMagazine(_addMaxMagazineNum);
    }

    public void AddMaxMagazine(int num)
    {
        var component = _components.Find(x => x.GetComponent<WeaponMagazine>() != null);
        if (component == null) return;
        var magazine = component.GetComponent<WeaponMagazine>();
        magazine.MaxMagazineAmmo += num;
        _maxMagazineNum = magazine.MaxMagazineAmmo;
    }
}
```

## UI側

UI側は、SceneContextにはPlayerAgentのデータが保持されており、UIGameplayViewから順に各UIに情報が伝わり、表示アップデートが行われます。

- UIGameplayView
    
    クロスヘア、武器、体力、ダメージなどゲーム中のUIを管理　
    
- UIWeapons
    
    武器スロット全体のUIを管理
    
- UIWeapon
    
    装備中の武器情報を表示
    

強化対象の情報を表示するUIのため、CustomizeオブジェクトとUIWeaponCustomizerコンポーネントを作ります。表示に必要な情報はWeaponsコンポーネントのcurrentWeaponに全て含まれているため、そこから必要な情報を表示します。

```UIWeaponCustomizer.cs
public class UIWeaponCustomizer : UIBehaviour
{
    [SerializeField]
    private TextMeshProUGUI _name;
    [SerializeField]
    private Image _icon;

    [SerializeField]
    private TextMeshProUGUI _maxMagazine;
    [SerializeField]
    private TextMeshProUGUI _addMaxMagazine;

    public void UpdateWeaponCustomizers(Weapon weapon)
    {
        // weapon == currentWeapon
        if (weapon == null || weapon.Object == null)
            return;

        _name.SetTextSafe(weapon.DisplayName);

        if (_icon != null)
        {
            _icon.sprite = weapon.Icon;
            _icon.SetActive(weapon.Icon != null);
        }

        for (int i = 0; i < weapon.WeaponCustomizers.Length; i++)
        {
            _maxMagazine.SetTextSafe(weapon.WeaponCustomizers[i].MaxMagazineNum.ToString());
            _addMaxMagazine.SetTextSafe("+" + weapon.WeaponCustomizers[i].AddMaxMagazineNum);
        }
    }
}
```

## 操作システム

強化を行う際のトリガーにはキーボード入力を利用します。Projectilesサンプルでは基本的にマウスは視点移動で固定されているため、キーボード入力を利用するのが自然です。

少し注意したい点としては、入力処理はPlayreプレハブについているPlayerInputを利用しますが、これはチュートリアルやPhoton Fusionに含まれるサンプルのInputBehaviourPrototypeとは大きく異なるところです。

``` PlayerInput.cs
private void ProcessStandaloneInput()
{
    //前略

    _renderInput.Buttons.Set(EInputButtons.Fire, Input.GetMouseButton(0));
    _renderInput.Buttons.Set(EInputButtons.AltFire, Input.GetMouseButton(1));
    _renderInput.Buttons.Set(EInputButtons.Jump, Input.GetKey(KeyCode.Space));
    _renderInput.Buttons.Set(EInputButtons.Reload, Input.GetKey(KeyCode.R));

    //zキーを有効にするため追加
    _renderInput.Buttons.Set(EInputButtons.Z, Input.GetKey(KeyCode.Z));

    //後略
}
```

```EInputButtons.cs
public enum EInputButtons
{
    Fire     = 0,
    AltFire  = 1,
    Jump     = 2,
    Reload   = 3,
    Z   = 4, //zキーを有効にするため追加
}
```