🐕

時間制御を伴うサーバからのクライアントの状態管理の実現方法

2023/10/29に公開

※この記事はサーバレスなオンラインじゃんけんアプリを作ってみたのサブ記事です。

今回開発したオンラインじゃんけんアプリのAWS側の構成図はこんな感じになっており、Clientからのリクエストを受け付けるためにAPI Gatewayを使っており、バックエンドにはLambdaを使っています。

今回はサーバから送られたデータを元にClient側の状態管理をどのような仕組みにしたのかを説明します。

やりたかったこと

今回はクライアントとサーバはWebsocketで接続しており、サーバからもデータを送信することができるようにしています。クライアント側はReactで書いており、サーバからのデータに基づいて、画面の切り替えや制限時間管理をできるようにするということが実現したかったことでした。
さらに、オンラインゲームとして対戦している2つのクライアント間で状態を同期させるということもやりたかったことでした。

できたこと

今回作ったプログラムの概要図は以下のとおりです。この図を元にコードについて説明したいと思います。
自作ステートマシン概要図

ソースコードはこちら↓
https://github.com/Kazuki25/janken-app-frontend-3jc9soks/blob/main/src/pages/Game.tsx

構成要素

  • Data: サーバから送られるデータです。ソースコード内ではSignalと名付けています。サーバからの指示や次に遷移する状態、メッセージや制限時間を含んでいます。
  • Queue: Dataを格納するためのキューです。サーバから送られてきたデータをすぐに処理するのではなく、順番に処理するために配置しています。ソースコード内では、stateQueueRefとして作成しています。
  • Timer: 今回は状態ごとに制限時間を設けており、一定時間経過したら次の状態(data)に遷移するという処理にしています。時間制御をするための要素で、setIntervalを使って実装しています。
  • Process on message: サーバからデータを受信したときに実行する処理です。オンラインジャンケンゲームでは、データによってキューに追加するものや、割り込んで処理を行うものがあります。割り込み処理を行う場合は、直接状態を変えるため、2本の矢印を引いています。
  • Process on timer: Timerによって時間起動する処理です。例えば、一定時間表示する必要がある結果画面の制御などはこの処理が一定時間待った後に次のdataを読み込み状態を変えることによって制御しています。
  • State: クライアント側の状態です。今回は、stateという変数で持っています。
  • View: 表示画面です。Stateが変わると画面も変わります。

Dataについて

Dataの中身はこのようになっています。

type Signal = {
    action: string
    nextState: string
    limitTime: number
    message: string
}
  • action: サーバから送られてくるデータの内容を示したもの。マッチング完了や結果などゲームの進行に合わせて送られる。
  • nextState: 次の状態を示すもの。例えば、マッチング完了の次はゲーム開始になるなど、クライアント側の状態遷移を制御する。
  • limitTime: nextStateの状態を維持する時間を示したもの。
  • message: 画面に表示するメッセージ。

クライアント側ではこのDataを処理することで状態管理を行なっています。

Process on messageについて

この処理は、websocketのonmessageハンドラーとして実装しています。サーバからDataを受信したときに実行される処理です。

            // Set message event handler to websocket object.
            wsRef.current.onmessage = async (event) => {
                addLog("サーバから受信: " + event.data);
                const data = JSON.parse(event.data);
                // if data has limitTime, let limitTime = data.limitTime
                // swich processes by event.data.action
                if (data.action === "getconnectionid") {
                    connectionIdRef.current = data.connectionId;
                    addLog("接続IDを取得しました: " + connectionIdRef.current);
                    return;
                }

                if (data.message==="OK") {
                    return;
                }

                // State Queue control
                const limitTime = !data.limitTime ? -1 : Number(data.limitTime);
                stateQueueRef.current.push({
                    action: data.action,
                    nextState: data.nextState,
                    limitTime: limitTime,
                    message: data.message,
                });

                // define processes when reveive data from server
                switch (data.action) {
                    case "entry_done":
                        addLog("エントリーを受け付けました");
                        setState(data.nextState);
                        setMessage(data.message);
                        limitTimeRef.current = limitTime;
                        break;
                    case "matched":
                        addLog("マッチングが成立しました");
                        gameIdRef.current = data.gameId;
                        // delete waiting timer
                        if (stateQueueRef.current.length >= 2 && stateQueueRef.current[0].action !== "matched") {
                            stateQueueRef.current.shift();
                        }
                        timerRef.current = 0; // reset timer
                        limitTimeRef.current = limitTime; // reset limitTime
                        setState(data.nextState);
                        setMessage(data.message);
                        // set users
                        setUsers(data.users);
                        // set opponent
                        setOpponent(data.users.filter((user: User) => user.username !== userName)[0].username);
                        break;
		       case "select_hand":
                        addLog("手を選択してください");
                        break;
			(中略)
                    default:
                        break;
                }

長いので、途中を省きましたが、DataのActionを見て処理内容を変えています。"matched"の場合は、今の状態にかかわらず、強制的に次の状態に遷移するため、タイマーをリセットしたり状態変数を更新したりしていますが、"select_hand"の場合は、前の状態が終わるまで待つため、何もしていません。switch文で書いているので、あまり褒められるようなやり方ではないですが、趣味で作っているので動けばOKということで許してください。

Process on Timerについて

この処理はsetIntervalを使って一定時間ごとに実行される処理として実装しています。デバッグ用のコードも残っていますが、趣味で(以下略)

        const timerId = setInterval(() => {
            // console.log("timer: " + timerRef.current + "[ms]");

            // Debug: show stateQueue's action
            let stateQueueStr = "";
            stateQueueRef.current.forEach((signal, index) => {
                stateQueueStr += "#" + index + ": " + signal.action + ", ";
            });
            addLog("stateQueue: [" + stateQueueStr + "]");

            // if stateQueue is empty, return.
            if (stateQueueRef.current.length === 0) {
                return;
            }
            // if stateQueue is not empty, get first element
            const signal = stateQueueRef.current[0];
            // if signal.limitTime is not -1, check timer
            if (timerRef.current >= signal.limitTime) {
                // define processes when timer is over
                switch (signal.action) {
                    case "entry_done":
                        addLog("60秒経過しました");
                        setMessage("マッチングが成立しませんでした");
                        setState("0");
                        break;
                    case "matched":
                        addLog("マッチ通知完了");
                        // check nest signal and if its nextState is "select_hand", set state to "select_hand"
                        // else set state to "0"
                        if (stateQueueRef.current[1].action === "select_hand") {
                            const nextSignal = stateQueueRef.current[1];
                            setState(nextSignal.nextState);
                            setMessage(nextSignal.message);
                        } else {
                            setState("0");
                            setMessage("エラーが発生しました");
                        }
                        break;
                    case "select_hand":
                        addLog("時間切れです");
                        // if time is over, send ranmdomly selected hand.
                        const hands = ["rock", "scissors", "paper"];
                        const hand = hands[Math.floor(Math.random() * hands.length)];
                        sendHand(hand);
                        break;
                    case "register_hand":
                        addLog("相手からの手の登録がありません");
                        setMessage("エラーが発生しました");
                        setState("0");
                        break;
                    case "result":
                        const nextSignal = stateQueueRef.current[1];
                        setState(nextSignal.nextState);
                        setMessage(nextSignal.message);
                        break;
                    case "game_finished":
                        addLog("ゲームが終了しました");
                        exitGame();
                        break;
                    default:
                        break;
                }
                // delete first element from stateQueue
                stateQueueRef.current.shift();
                // reset timer
                timerRef.current = 0;
                // set next limitTime if next stateQueue is not empty
                if (stateQueueRef.current.length !== 0) {
                    limitTimeRef.current = stateQueueRef.current[0].limitTime;
                }
            }

            timerRef.current = timerRef.current + timeInterval;
        }, timeInterval);

ここでは、時間制御を実現するために幾つかの工夫をしています。
まず、時間は絶対時間ではなく相対時間で制御しています。どういうことかというと、状態が切り替わる度にリセットして、その状態になってから何秒というふうに数えています。それが、timerRefという変数になっています。useRefを使っている理由は、この変数の更新と再レンダリングのタイミングは関係ないので、Viewに影響しないuseRefを使っています。

そして、処理が実行される度にtimerRefがインクリメントされて、それとsignal.limitTimeが比較されることによって、制限時間内なのか、状態遷移が必要なのかということが判断されます。状態遷移が必要となった場合は、今の状態から出るときに必要な処理を実行(switch文)して、キューのメッセージを更新(shift)して、処理を終了します。

難しかったこと

最初は、setIntervalを使わずに実装しようとしていたんですが、websocketのonmessageがメッセージ受信時に実行されるために、全然うまくいきませんでした。サーバ側で送信するタイミングを制御しようかとも考えたのですが、AWSのlambdaでそれをうまくできなかったため、それは諦めました。

メッセージ受信時の処理と、状態遷移制御を切り離す必要があったので、非同期でデータを処理するとしたら、キューかなと思い今回の実装にしました。キュー用の変数とタイマーを作ってからは円滑に進みましたが、受信時の処理とタイマーによる処理が離れているので、デバッグが大変でした。処理は別のところでまとめて定義して、タイマーに登録するというのが綺麗なやり方な気がしますが、このあとさらに作り込む予定もないためそこまではやりませんでした。

しかし、このやり方だとクライアント間の同期はどうしても誤差が発生するので、なんちゃって同期になっています。格闘ゲームとかはフレーム単位で同期をとっているという話を聞いたのでどのように実装しているのか気になるところですが、今回はなんちゃって同期でも十分用を足せることからこのままの実装にしました。

まとめ

時間制御を伴う状態管理を実装してみましたが、当初想定していたよりも難しかったです。また、サーバ側との連携も取る必要があるので、Websocketを使ったアプリ開発の時は、同時並行的に進められるようにすることが必要みたいです。

Discussion