無限に広がるマルチプレイのマインスイーパーを作りたかった話(盤面の拡張、データ層の分離)
はじめに
こんにちは、hamaguchi です
今回は Web アプリとして遊べるマインスイーパーについての2回目の記事です
盤面の拡張と改善、データ層の分離を行いました
前回のまとめ
- 無限の広さの盤面でマルチプレイできるマインスイーパーにするためにはどのようなデータ構造にしたら良いか考えた
- 盤面をブロックという単位で区切り、ブロックにセルがあるという構造を考えた
- とりあえずブロックの分割なし、100x100 セルの盤面でマルチプレイできるマインスイーパーを作った
- フラッグやはてなマーク、クリックや両クリックといった基本的なマインスイーパーの操作に対応した
今回やったこと
- 裏側の仕様の改善
- データ層の分離
- Redis (Dragonfly) を使用して、データ層を分離した
- 盤面の拡張
- ブロックあたり64x64セル、盤面を200x200のブロックに分割することで12,800x12,800の広さの盤面を実現した
- Admin 画面の構築
- データ層の分離
- UX の改善
- 更新のお知らせの表示
- 最後に開いていた場所を記録して、次回起動時にその場所を開くようにした
- ミニマップの追加
- 表示フローの改善
裏側の仕様の改善
データ層の分離
なぜデータ層を分離するのか
1回目ではとりあえず動くものを目指していたため、ウェブソケットアプリケーションのインスタンス上に盤面のデータやユーザーの状態を持っていました
このままではウェブソケットアプリケーションに修正を加えてデプロイすると、コンテナの再起動に伴いデータが消えてしまうという問題がありました
また、アプリケーションのインスタンスが何らかの問題で落ちてしまった場合にもデータは消えてしまいます
そのため、データの永続化には少し届いていませんが、Redis を使用してデータ層を分離することで開発を進めてもプレイデータに影響が出ないようにしました
Redis の導入
なぜ Redis を選んだのかというと、以下の理由があります:
- 非常に高速な読み書きが可能なインメモリデータベースであるため(必要なほどの負荷はまだない)。
- データの永続化が可能で、アプリケーションのインスタンスが落ちてもデータを保持できるため(けどまだやっていない)。
- Dragonfly という Redis の代替プロダクトを試してみたかったため(軽い・早いかどうかわかるほどの負荷がないので効果がわからず)。
導入方法はとても簡単で、以下のように compose ファイルに dragonfly 用の設定を記述するだけでした
dragonfly:
image: 'docker.dragonflydb.io/dragonflydb/dragonfly'
command: ["--maxmemory=256MB", "--proactor_threads=1"]
ports:
- "63790:6379"
volumes:
- dragonflydata:/data
あとはウェブソケットアプリケーションなどの別のサービスから redis://dragonfly:6379
にアクセスすればアプリケーションから Redis が使えるようになります
一応 Volume の設定も行っているので dump されたデータはそこに入るはずという状態にはなっているので今度確認してみようと思います
次に、ウェブソケットアプリケーションで自分のメモリ上に保存していたデータを Redis に保存するように変更することでデータ層の分離ができました
起動オプションについて
今回使用しているインスタンスが e2-micro という 2個の vCPU と 1GB のメモリのとても小さいインスタンスなため、未指定ではメモリが足りずに起動できないというトラブルが発生しました
そのため、--maxmemory=256MB
と --proactor_threads=1
というオプションを指定して起動しています
--maxmemory
はメモリの上限を指定するオプションで、256MB に設定しています
--proactor_threads
はスレッド数を指定するオプションで、1 に設定しています
もっと大きいインスタンスを使用しない限り Redis や Dragonfly の強みは発揮できなさそうですが、将来的にユーザーが増えた際には増強しやすそうですね
TODO データの永続化
Redis 単体でもデータの永続化が可能なようですが設定の調整や確認を行なっていないため、どのようなフローで永続化するのがよいか調査を行い実装を進めたいと思っています
後悔 & RDB じゃあだめだったのか
これまで幾つかのウェブサービスの開発に従事してきましたが、最初は RDB (Relational Database) を使用し、DB だけでは負荷つらくなってきたところで初めて Redis を使い始めるという流れが多い印象でした
Redis を使ってみたいなぁという気持ちでやってみたものの永続化の設定を見ていないため、そこまでやり切るか初めから RDB にしておけばよかったのではないかという疑問が残ります
まーた将来性の話にはなりますが、Websocket サーバーをいくつか並列して動かすような規模になることがあれば Redis を使用して Websocket サーバー同士を連携するということもできるので、その点では将来のインフラ拡張を見越しているとは言えます
みなさんが何か作る際はほんとうにそれ必要?という疑問を大切にして進めると良いのではないかと思いました(自戒)
盤面の拡張
100x100 から 12,800x12,800 へ、そしてその先へ
盤面を拡張するにあたり、ブロックをどう初期化するかという仕様を決める必要がありました
- 全体の初期化時に全てのブロックを初期化する
- 必要になった時にブロックを初期化する
前者では初期化にかなり時間がかかるということと、最初から最大量のデータを抱えることになるため、メモリ使用量が多くなります
ただし、盤面全てのデータを確保済みなため、プレイの途中でメモリが足りないという事態にはならないという利点があります
後者では保有するデータ量は少なくなりますが、都度必要になったタイミングで隣のブロックを初期化していくという処理が必要になるため実装が複雑になります
また、新しくブロックを初期化しようとしたタイミングでメモリが足りないという事態になる可能性があるため、メモリが溢れないように設定値を決めたり溢れた際の処理を考える必要があります
今回は後者で実装を行い、メモリ制限内に収まるようブロックの数に調整しました
盤面全てのセルをクリックしたとしても溢れないように調整した結果、一旦はブロックサイズ64x64、200x200ブロック (合計12,800x12,800 セル) というサイズになりました
盤面全てをカバーする必要はないというスタンスで設定するならば、40,000x40,000ブロックとしてもよいでしょう
その場合は盤面全体では 2,560,000x2,560,000 セルというサイズになりますが、全てのセルをクリックしていこうとすると 初期化済みのブロックが 40,000 ブロックを超えたあたりでメモリが足りなくなります
この仕様にしたときには 12,800x12,800 もあれば十分だよね?と思っていましたが、マインスイーパーが好きな友人にこの話をしたところ「世界の端までいきたい」と言い出して中央から上下左右に攻略をすすめ、どの方向も盤面の端まで辿り着いていました
せっかく頑張って開けてくれたので、是非頑張りを見に行ってみてください
矢印キーで割と高速に移動ができます (中央から端までは方向キーを押しっぱなしで1分ほどかかるらしい)
もっとおおきい盤面が必要になった場合は、
- 盤面全てをカバーするという仕様をやめて、メモリの許す限り進めるようにする
- しばらく使用しないブロックは別途 DB などに保存して Redis から削除する
- TTL で勝手に消えるようにしてキャッシュされていなければ DB をみてそれでもなければブロックを初期化するなども
- より大きいインスタンスに変更して Redis に割り当てるメモリのサイズを大きくする
- フルマネージドな Memcache を使用する
- 最小の Memcache インスタンスにしても 8,000 円ほどかかるはずなのでその価値があるか次第
などの対応を行うことでより大きい盤面で遊ぶことができるようになるという構想は練っています
admin 画面の構築
前回の記事でも書きましたが、ウェブサービスを作るにあたってはプライベートのそんなに大きくないものでも Admin 用のアプリケーションを作成し、サービス運営に関わる設定値を操作したりサービスの状態を見れるようにしておくと便利です
今回は後述するバージョンの更新のお知らせを表示するためのフォームや盤面のリセットを行うためのボタンを作成しました
ログインには Firebase Authentication を使用しました
ID と暗号化したパスワードを Firestore に保存しておき、Firebase Functions で受け取った ID とパスワードを検証して、正しければトークンを発行するという流れで実装しました
将来的には一般のユーザーにもログイン機能を提供し、ポイント制度を導入したり自分のプライベートな盤面を発行して招待した友達と遊べるようになるといいなーと思っています
admin アカウントがあればできること
firestore のデータの操作
firestore では、firestore.rules というルールを設定することで、誰がどのような操作を行えるかを設定することができます
例えば、以下のようなルールを設定することで、
- admin アカウントであれば
/service/info
全てのデータに対して読み書きができる - それ以外のユーザーは
/service/info
のデータを知っているIDのみ読み取ることができる(List は取得できない) - ログイン中のユーザーなら○○ができる
- 自分の持ち物のデータなら○○ができる
といった制約をつけたデータベース操作がフロントエンドのみの実装で可能になります
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isOwner() {
return request.auth.uid == request.resource.data.uid;
}
function isAdmin() {
return request.auth.token.admin == true;
}
match /service/info {
allow get;
allow read, write: if isAdmin();
}
}
}
ここで、 isAdmin()
にある request.auth.token.admin
はログイン時に Firebase Admin SDK を使用してユーザーに付与できるカスタムクレームで判別しています
このカスタムクレームは、Firebase Authentication のトークンを発行する際に付与することができるもので、ユーザーの情報を追加することができます
バックエンドなしにデータの操作を行うことができるというのは Firestore の強みの一つではないでしょうか?
ただし、Firebase への依存度が高くなるため別のプラットフォームへの移行は面倒になります
Websocket アプリケーションの操作
Websocket 側でもリセットできるのは Admin のみという制約を入れたくなりますよね
そこで、Websocket アプリケーションにも Firebase Admin SDK をインストールし、socket 接続時に渡した IdToken を検証することで Admin なのかどうかを判別できるようにしました
これでたとえウェブソケットのイベント名を知っていたとしても一般のユーザーが盤面をリセットすることはできなくなります
同様に、一般ユーザーのログイン機能の実装後にはなりますが、自分の持ち物の盤面であればリセットできるなどの実装も可能になります
また、initializeApp
の引数にはクレデンシャルファイルやファイルパスの指定、serviceAccountId の指定が可能です
今回はサーバー上にクレデンシャルファイルを置きたくないという理由から、serviceAccountId
を指定して Firebase Admin SDK を初期化しました
この場合、トークンの検証は Firebase Authentication 側にリクエストを投げる形になるためインスタンス内部で検証を完結することはできません
レスポンス速度の制約などサービスの仕様がシビアな場合はクレデンシャルファイルを使用してスタンドアローンで検証を行うようにした方が良いかもしれません
import { initializeApp, getApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
initializeApp({
serviceAccountId:
"firebase-adminsdk-xxxxx@{project_id}.iam.gserviceaccount.com",
});
const app = getApp();
const auth = getAuth(app);
io.of("/").on("connection", (socket) => {
const idToken = socket.handshake.auth.token;
if (socket.data.admin) {
// Admin として接続している場合
} else if (idToken) {
// Admin として接続する(初回)場合
auth.verifyIdToken(idToken)
.then((decodedToken) => {
// ID トークンが有効な場合、Admin フラグを socket.data に保存
socket.data.uid = decodedToken.uid;
socket.data.admin = decodedToken.admin || false;
})
.catch((error) => {
// デコードできなかったら切断
socket.disconnect
});
} else {
// 通常のユーザーとして接続する場合
socket.emit("boardConnected");
}
});
UX の改善
ここまでは裏側の仕様の話でしたが、次は UX の改善について話します
更新のお知らせの表示
デスクトップアプリではなくウェブ上で動くアプリケーションとして公開している場合、「ユーザーが古いバージョンのまま使い続けてバグる」ということがあまりないと思っていましたが、実際にウェブサービスを運営してみると時折信じられないほど長い期間ページをリロードせずに使っている人がいるということがわかりました
古いフロントエンドを使い続けられるとデバッグも困難になったり、ユーザーも新機能に気づかなかったりといいことがないので更新のお知らせを表示するようにしました
バージョンの値は Firestore に保存し、前述の Admin 画面から操作します
フロントエンド側では Layout という共通部分で Firestore のサービス情報の更新をリアルタイムに取得できるようにし、Adminからの操作で更新された場合にはバージョン情報の比較・検証を行い、新しいものがある場合は通知を表示しました
強制的にリロードさせるというほど強いものではありませんが、通常はこれで更新してくれるはずです
サービスの仕様上、絶対に更新してもらわなければ困るなどの事情がある場合は操作に制限をかけるなどの追加の実装が必要になるかもしれません
最後に開いていた場所を記録して、次回起動時にその場所を開くようにした
Nuxt3 関連でストレージを使えるのはないかなぁと思って検索したところ nuxt-storage
というモジュールを見つけました
導入も容易で、sessionStorage や localStorage を簡単に扱えるようになります
※ ただし Github を確認したところ結構古く、アーカイブされていたため別の手段を探した方が良さそうでした
import { getData, setData } from 'nuxt-storage/local-storage';
const boardName = 'default'
const data = {
x: position.value.x,
y: position.value.y
}
setData(boardName, data, 7, 'd')
正直直接 localStorage を操作するのと一緒では?という感じですが、せっかく用意されているので使いました
きっとブラウザによる差異などを吸収してくれているのでしょう
デフォルトでは 5 分で expire するようになっているため、7 日に変更しました
ミニマップの追加
盤面が広くなったため、移動しようにもどっちに行ったらどんな状況なのか見えた方が便利だろうと思い、ミニマップを追加しました
ミニマップは今いるブロックとその周囲のブロックの情報を Websocket から取得し、表示・更新するようにしました
ミニマップでは1ドット分が 8x8 セルの開き具合を表していて、開いているセルがなければ黒、開いていくとだんだん白くなり、全て開くと青になっています
ミニマップをクリックしてその場所に移動できてもいいかもしれませんね
表示フローの改善
画面に表示される情報が増えるに従い、無駄な計算や描画を行なっている部分があったため改善しました
具体的には html の canvas 要素を複数用意し、必要な箇所のみ描画することで無駄な処理を削減しました
canvas は以下のように分割しています
- 盤面の描画
- 確定済みのセルの描画
- クリックしたときのエフェクトの描画
- マウス直下のセルの縁取りの色を変える
- 中クリックしたときに周囲のセルを凹ませる
- ミニマップの描画
- フレームの描画
各レイヤーに再描画が必要になるタイミングは以下のようになっています
- 盤面の描画
- 自分/誰かがクリックした結果が反映されたとき
- 盤面を移動したとき
- クリックしたときのエフェクトの描画
- マウスを動かしたとき
- マウスを (左/中) クリックしたとき
- 盤面を移動したとき
- ミニマップの描画
- 自分/誰かがクリックした結果がミニマップに変更があったとき
- 盤面を移動したとき
- フレームの描画
- フレーム上にマウスが乗ったとき/出たとき
まとめ
- 裏側の実装の変更を行い、大幅な盤面の拡大を行った
- インスタンスサイズの変更や仕様の変更を行うことでさらに大きい盤面に対応するための道筋を立てた
- Admin 画面を作成し、サービスの運営に必要な機能を実装した
- UI/UX の改善を行い、より快適に遊べるようにした
おわりに
個人やチームで何かをつくろう!となったときについて回るのがモチベーションの低下問題だと思っています(チームの場合は熱量の差問題も)
例に漏れずこのマインスイーパーでもある程度できた段階で「おーうごくじゃーん」と友達とひとしきり遊んだところで自分の中で結構満足してしまったような感覚がありました
最後に開いていた場所を保存して欲しいという意見はその友達から上がったもので、実装したよと報告したら喜んでくれるはずだと思います
とりあえずはチュートリアルの実装まではやりたいなと思っていますが、ゲーム配信者に遊んでもらったり収益化したりという最初に考えていた着地点とは違うところに落ち着きそうです
あそんでみてね
Discussion