アイコン

りにあの自由時間

...

< 一覧へ
最高に手作りなオンラインカードゲームを組み立てる #3 通信対戦編
X(旧Twitter)

ゲームルールのこの部分を、「選んだ1枚」じゃなくて、全部もらえるようにした。こうするとプレイヤーのカードが減らなくなっちゃうから、代わりにターン制限を設けて、「一番枚数が多いプレイヤーが勝ち」にする。

というのも、今回のゲームにはこんなコンセプトがある。

  • 影響が重なってカオスな状況ができる
  • 大人数で盛り上がり続ける
  • やめどきがない

この「大人数で盛り上がり続ける」「やめどきがなく延々と遊べる」を満たすには、カードは減らない方が良いって思った。

実はこのゲームには元ネタというかオリジナルがあって、そっちは勝てば場に出てるカードが全部もらえるんだ。それをちょっと改変してたんだけど、実装してみたらやっぱり絵面が微妙だなぁって思って、戻すことにした。

そういえばそろそろゲームタイトル考えなきゃな……ずーーーーーっと、なんなら作り始める前から考えてるんだけど全然パッときてないないんだよな。

本編

サーバーアプリの環境は作ったから、いよいよゲームのマルチプレイ化をしていきたいんだけど
流石に毎回サーバーをビルドしてデプロイ、みたいなことはやってられないから、マルチプレイのデバッグ環境を整えていく。

やり方は簡単で、MPPM(MultiPlayer Play Mode)を使う。これを使うとPlayMode Scenarioっていうのを作ることができて、PlayModeの実行時に別のEditorを起動して同時に実行できる。一時期は壊れてリロードが走らないこともあったみたいだけど、投稿時点では無事に治ってそう。

PlayMode Scenarioはこんな感じに設定。今回はEditorを指定したんだけど、LocalやRemoteにするとPlayModeを実行するたびにビルドが走るので、Editor推奨。

Tagっていうのがあるんだけど、これを指定するとスクリプトから読んで実行を分岐することができる。今回はServerとClientを指定して、処理を分岐する。インスタンスごとにBuild Profileを切り替えることはできないから、Build Profileは MARE_CLIENTMARE_SERVER の両方のコードを含むDevelop用のをActivateしておくよ。

CardGameScene.cs
private void Start()
{
#if UNITY_EDITOR
    // Serverタグが振られていたら、サーバーとして起動
    if (CurrentPlayer.ReadOnlyTags().Contains("Server"))
    {
        StartServer("127.0.0.1", 7777);
    }
#elif MARE_CLIENT
    /* 省略 */
#elif MARE_SERVER
    /* 省略 */
#endif
}

詳しく知りたい人のためにリンクを貼っておくよ。

https://docs.unity3d.com/Packages/[email protected]/manual/play-mode-scenario/play-mode-scenario-create.html

あと、これで起動すると別インスタンス側でCodeAnalyzerの警告がめちゃめちゃ出まくるから、一旦アンインストールした。

通信の実装

今まで作ったものをオンライン化していくわけだけど、まずは Netcode for GameObjects における通信の仕様を把握しないといけない。要するに、NetworkVariableやRPCにおけるデータや順序の整合性がどのくらい保証されるかだ。

例えば変数Aと変数Bの2つを同期しようとしたとき、データや順序の整合性が保たれていないと、

  • 変数Aは同期されたが、変数Bは同期されていない
  • 変数Aを先に更新したのに、変数Bが先に更新された

みたいなことが起こり、変数の更新をハンドリングしてた時におかしな結果になってしまう。それでNetcode for GameObjectsはどうかというと、調べたけどあんまり明記されてなかった。明記されてたのはこれだけ。

https://docs.unity3d.com/Packages/[email protected]/manual/advanced-topics/message-system/reliability.html

Reliable RPCs will be received on the remote end in the same order they are sent, but this in-order guarantee only applies to RPCs on the same NetworkObject. Different NetworkObjects might have reliable RPCs called but executed in different order compared to each other. To put more simply, in-order reliable RPC execution is guaranteed per NetworkObject basis only. If you determine an RPC is being updated often (that is, several times per second), it might be better suited as an unreliable RPC.

要するに、同じNetworkObject内でのRPCだけは順序保証がされる。おそらく多分、これ以外のケースではデータも順序も整合性が保証されないんだろう。


まずはルームの入退室から作っていく。今どこを作ってるのかわかりやすくするために、先に完成図の設計を載せておくよ。

NetworkRoomにルームの同期したい情報(プレイヤーなど)を集約する。NetworkPlayer をPlayerPrefabとして指定し、プレイヤーが入室したらNetworkRoomNetworkList<NetworkBehaviourReference>として持たせ、クライアント間で同期する。プレイヤー間でデータに整合性を持たせる必要はないので、この実装で問題ない。

ゲームの開始はクライアントからRequestStartGameRpc()RPCを行い、サーバー側でNetworkGameに開始の信号を送る。

NetworkRoom.cs
/// <summary>
/// ネットワーク上の部屋
/// 最初からシーンに配置される
/// </summary>
public class NetworkRoom : NetworkBehaviour
{
    private NetworkList<NetworkBehaviourReference> _playerReferences;
    public NetworkRefListProperty<NetworkPlayer> Players { get; private set; }  // 扱いづらいのでラッパークラスを作った
#if MARE_SERVER
    [SerializeField] private NetworkGame _game;
#endif
    private void Awake()
    {
        _playerReferences = new NetworkList<NetworkBehaviourReference>();
        Players = new NetworkRefListProperty<NetworkPlayer>(_playerReferences);
    }

    [Rpc(SendTo.Server)]
    public void RequestStartGameRpc()
    {
#if MARE_SERVER
        _game.Setup(Players, CardDataRegister.Instance.GetAll(), 10);
        OnStartGameRpc();
        _game.Run();
#endif
    }

    [Rpc(SendTo.NotServer)]
    private void OnStartGameRpc()
    {
#if MARE_CLIENT
        Debug.Log("[Client] Game Start");
#endif
    }

#if MARE_SERVER
    public void AddClient(ulong clientId)
    {
        Debug.Log($"Client {clientId} connected.");

        var player = NetworkManager.ConnectedClients[clientId].PlayerObject.GetComponent<NetworkPlayer>();
        Players.Add(player);
        player.transform.SetParent(transform);
    }

    public void RemoveClient(ulong clientId)
    {
        Debug.Log($"Client {clientId} disconnected.");

        var player = Players.FirstOrDefault(x => x.OwnerClientId == clientId);
        if (player != null)
        {
            Players.Remove(player);
            player.NetworkObject.Despawn();
        }
    }
#endif
}


次に作っていくのがゲーム実行部分。全体のコードが見たい人は、コードだけコピーしたリポジトリを用意したので、ここから見てね。

https://github.com/2RiniaR/mare-client-lib/blob/main/CardGame/Network/NetworkGame.cs

NetworkGame(図の真ん中くらいにある)がメイン実行部分となって、全体の制御を持つ。ゲームロジックはサーバーでだけ実行し、何らかの表示更新や演出が必要なタイミングで、クライアントに向けてRPCを行う。

NetworkGame.cs
public class NetworkGame : NetworkBehaviour
{
    private CancellationToken Ct => destroyCancellationToken;

#if MARE_SERVER
    public void Run()
    {
        RunAsync(Ct).Forget();
    }

    private async UniTask RunAsync(CancellationToken ct)
    {
        // ゲームロジック
        // カードの効果発動時に、OnCardEffectRpc を呼ぶ
    }
#endif

    // カードの効果が発動した時に、サーバーからクライアントに対して呼ばれる
    [Rpc(SendTo.NotServer)]
    private void OnCardEffectRpc(NetworkGameState state, uint cardUid, uint syncToken)
    {
#if MARE_CLIENT
        // UIの更新や演出
#endif
    }
}

サーバーからクライアントにRPCを送る時にNetworkGameStateというデータを送っているけど、これにはゲーム内の全ての状態が含まれてる。つまり、なんらか表示を更新してほしいタイミングで、最新の状態を全部送ってるということ。

なぜこうしてるかというと、通信って確実に届くものじゃないので、差分データだけ送るみたいなことをやるとクライアント側で間違った状態が表示されてしまう。アクションゲームとかなら多少の誤差はいいんだけど、カードゲームでそれはまずいので、毎回全部の状態を確実に同期してる。

NetworkGameState.cs
public struct NetworkGameState : INetworkSerializable
{
    public uint Turn;
    public uint LimitTurn;
    public NetworkGamePlayer[] Players;
    public NetworkGameCard[] Cards;

    public uint[] TurnPlayerUids;
    public uint[] DeckCardUids;
    public uint[] FieldCardUids;

    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref Turn);
        serializer.SerializeValue(ref LimitTurn);
        serializer.SerializeValue(ref Players);
        serializer.SerializeValue(ref Cards);
        serializer.SerializeValue(ref TurnPlayerUids);
        serializer.SerializeValue(ref DeckCardUids);
        serializer.SerializeValue(ref FieldCardUids);
    }
}

そしてRPCを呼ぶ時にもう一つ、syncTokenっていう引数を渡してる。これはサーバーがクライアントにRPCを行うたびにカウントアップしていく番号で、以下の役割がある。

  • サーバーがクライアントの処理(主にアニメーション)を待つ
    ┗ クライアントから処理完了時に同じ番号を送り返してもらい、全クライアントが指定の番号になるまで待つ
  • クライアントが通信失敗を検知する
    ┗ 番号が飛んでいれば通信がスキップされているので、アニメーションせずにUIを即時反映する
NetworkGame.cs
public class NetworkGame : NetworkBehaviour
{
#if MARE_SERVER
    private NetworkGameState _serverGame;
    private uint _serverLastSyncToken;
    private Dictionary<ulong, uint> _serverClientSyncTokens;
#endif
#if MARE_CLIENT
    public NetworkGameState ClientGame { get; private set; }
    private uint _clientLastSyncToken;
#endif

#if MARE_SERVER
    private uint CreateSyncToken()
    {
        return ++_serverLastSyncToken;
    }

    // 全クライアントの同期が取れるまで待つ
    private async UniTask SyncClientAsync(CancellationToken ct)
    {
        await UniTask.WaitUntil(
            () => _serverGame.Players
                .Where(x => x.ClientId != NetworkManager.LocalClientId)
                .All(x => _serverClientSyncTokens.TryGetValue(x.ClientId, out var current) &&
                          current == _serverLastSyncToken),
            cancellationToken: ct);
    }
#endif

#if MARE_CLIENT
    private bool ValidateSync(NetworkGameState state, uint syncToken)
    {
        ClientGame = state;

        var isValid = syncToken == _clientLastSyncToken + 1;
        _clientLastSyncToken = syncToken;

        if (isValid == false)
        {
            // syncTokenが連番でなければ、正常に同期できてないとみなし演出アニメーションを行わずに強制反映する
            _screen.SetGameImmediate();
            RequestSyncRpc(syncToken);
        }

        return isValid;
    }
#endif

    [Rpc(SendTo.NotServer)]
    private void OnCardEffectRpc(NetworkGameState state, uint cardUid, uint syncToken)
    {
#if MARE_CLIENT
        if (ValidateSync(state, syncToken) == false) return;
        // カード効果発動のアニメーション
        // 終わったら RequestSyncRpc(syncToken) を呼ぶ
#endif
    }

    [Rpc(SendTo.Server)]
    public void RequestSyncRpc(uint syncToken, RpcParams rpcParams = default)
    {
#if MARE_SERVER
        var clientId = rpcParams.Receive.SenderClientId;
        if (_serverClientSyncTokens.TryGetValue(clientId, out var current) && syncToken <= current) return;
        _serverClientSyncTokens[clientId] = syncToken;
#endif
    }
}

残りのUI表示とかリソース読み込みの部分は特に書くこともないので省略。こんな感じで、マルチプレイでカードゲームが1戦回せるようになった。

シミュレータを回してみる

カードゲームだと先にやっておきたいことがあって、それが通信や表示なしでゲームを実行すること。これができると、プレイヤーの操作が可能かをクライアントだけでチェックできたり、ゲームバランス調整用のシミュレータが作れたりする。

そこで、こんな感じにゲーム実行部分だけをNetworkGameから切り出してみた。

まずゲーム処理の中で、今まで通信を挟んでいた部分をすべてIGameHandlerに記述する。あとは通信以外のゲーム処理をすべてGameに移動し、NetworkGameStateとの相互変換処理を書いたら完了。

IGameHandler.cs
public interface IGameHandler
{
    UniTask OnStartAsync(CancellationToken ct);
    UniTask OnDistributeCardAsync(CancellationToken ct);
    UniTask OnTurnStartAsync(CancellationToken ct);
    UniTask OnSelectStartAsync(CancellationToken ct);
    UniTask OnCardSelectAsync(Player player, Card card, [CanBeNull] Card returnCard, CancellationToken ct);
    UniTask OnSelectEndAsync(CancellationToken ct);
    UniTask OnBattleStartAsync(CancellationToken ct);
    UniTask OnCardEffectAsync(Card card, CancellationToken ct);
    UniTask OnCardKillAsync(Card card, CancellationToken ct);
    UniTask OnCardWinAsync(Card card, CancellationToken ct);
    UniTask OnRewardCardAsync(Player player, IReadOnlyList<Card> cards, CancellationToken ct);
    UniTask OnPlayerDefeatAsync(IReadOnlyList<Player> players, CancellationToken ct);
    UniTask OnEndAsync(CancellationToken ct);
}

次に、「プレイヤーがカードを選択する」みたいなゲームを操作する部分だけど、ちょっと工夫が必要。

このゲームでは「プレイヤー全員がカードを選んだとき、バトルを開始する」という部分があるが、この「特定の状態を待つ」というのは UniTask.WaitUntil()で実装してしまうと、Unityのメインループに依存してしまう。こうなると、シミュレータの実行環境次第で思わぬ実行停止や想定外の挙動が起こり得るので、それを避けるためにpush型のゲーム進行ができるようにしたい。

そこで、ゲームロジックをコマンドに分割し、コマンドキューに格納する形で実行することにした。それに加え、操作の実行タイミングを完全にシミュレータ内で制御できるのも良い。

IGameHandlerを実装したシミュレータを用意し、ログを出力しながらゲームを実行してみた。16枚のカードを仮の性能でプールに加え、4人のダミープレイヤーにランダムなカードを選択させている。

いい感じ。次に、ゲームを10,000回実行してカードの勝率を取ってみる。(メインスレッドで実行するとUnityEditorが固まるので、ThreadPoolに逃した)

「こうげき」が10しかないザッソーの勝率が5%もあるのが面白い。

  • はねかえしミラー(攻0、攻200以上を無効化)
  • グロード(攻300)
  • グラミー(攻270)
  • ザッソー(攻10)

とかが場に出てると、普通に勝つのかも。

次回予告

マルチプレイでゲームが動くようになったので、次回はUIのデザインを本格的に入れていくよ。お楽しみにー!

Rinia/りにあ
Rinia/りにあ
X(旧Twitter)GitHub

ゲームクリエイターをしてます! わくわくする遊びを作るのが好き!

ゲーム以外にもたまに変なものを作ってます。森羅万象の設計が特技。限界開発鯖RineaR所属。

共有お願いします!
X(旧Twitter)