時間制御を伴うサーバからのクライアントの状態管理の実現方法
※この記事はサーバレスなオンラインじゃんけんアプリを作ってみたのサブ記事です。
今回開発したオンラインじゃんけんアプリのAWS側の構成図はこんな感じになっており、Clientからのリクエストを受け付けるためにAPI Gatewayを使っており、バックエンドにはLambdaを使っています。
今回はサーバから送られたデータを元にClient側の状態管理をどのような仕組みにしたのかを説明します。
やりたかったこと
今回はクライアントとサーバはWebsocketで接続しており、サーバからもデータを送信することができるようにしています。クライアント側はReactで書いており、サーバからのデータに基づいて、画面の切り替えや制限時間管理をできるようにするということが実現したかったことでした。
さらに、オンラインゲームとして対戦している2つのクライアント間で状態を同期させるということもやりたかったことでした。
できたこと
今回作ったプログラムの概要図は以下のとおりです。この図を元にコードについて説明したいと思います。
ソースコードはこちら↓
構成要素
- 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