💣

無限に広がるマルチプレイのマインスイーパーを作りたかった話(有限である程度動くところまで)

2024/11/28に公開

はじめに

こんにちは, hamaguchi です
SocialPLUS では、なにかしら技術に関係する好きなことを発表する場があり、今日はそこで発表した内容に加筆修正を行い、記事にしてみました
結論としては、無限の広さを持つ盤面にはまだできておらず、とりあえず有限の盤面上である程度動くところまでという状況です
記事の前半は「ぼくがかんがえたさいきょうのうぇぶさーびす」を思いついた時にどんなことを考えたのかについて、後半は実際に作ってみた内容について書いています
マインスイーパーのデータ構造なんて簡単じゃん、と思われるかもしれませんが、考えてみると結構色々なことを考える必要がありました
無限の広さを表現するためにどのようなデータ構造にしたら良いだろう?など考えつつ作ってみたので「自分ならどうするかな?」など考えながら読んでいただけると嬉しいです

経緯

  • 数年前: 無限の広さでマルチプレイでマインスイーパーを遊びたいと思い始める
  • 数年前: とりあえず GCP プロジェクト作成、Github リポジトリの作成、ドメインを取得

    〜空白期間〜

  • 数年前: とりあえずとったドメインが失効

    〜空白期間〜

  • 数年前: 再燃したためまたドメインを取る

    〜空白期間〜

  • 2024年夏: マルチプレイのマインスイーパーが作れそうと思いつつ、別件で必要になった Websocket サーバーを作ってみる
  • 2024年08月: 作ってみたWebsocket サーバーについての記事を書く
  • 2024年10月: 社内発表会のネタを何にするか悩み始める
  • 発表2週間前: 発表ネタどうしようどうしようと思いつつ時間が過ぎていく
  • 発表1週間前: マインスイーパーネタで行くと決意
  • 発表当日: なんとか動くところまで実装してしのぎきる
  • 発表後: 加筆修正しつつ記事にしてみる ← 今ココ

「ぼくがかんがえたさいきょうのうぇぶさーびす」を思いついたら

普段の何気ない日常の中で「あ、こんなウェブサービスできたら面白くない?」と思いつくことがありますよね
今回マインスイーパーを作るにあたってどのようなことを考えたかをまとめてみました
思いついたアイディアを形にしようとしている段階ではありますが、一例として参考になれば嬉しいです

思いついたら書いて置ける場所としてスクラップ作ってみました
気が向いたら追加するかもしれません

似たようなのがもうあるか?を調べてみる

欲しい機能を持っているサービスがすでにある場合はそれを使えば幸せになれますが、見つかったけど少し違う、見つからないという場合も往々にしてあるでしょう
そんな時は自分で作ってみましょう
ないなら作ればいい
簡単ですね
ダメだったら消せばいいだけです
へーきへーき

今回のケースでは、「みんなでマインスイーパー」というゲームが見つかりました

https://store.steampowered.com/app/2865580/_/

盤面が有限だったり PC 用だったりといった点は考えていた方針とは違いますが、見た目もオシャレだし、盤面もとても広くて楽しそうです(4800万のマスで1000万の地雷)
もうこれでいいのでは?という心の声が聞こえてきますが、発表会のネタがなくなってしまうとピンチなので作るしかない自分で作りたいという気持ちが強かったので作ることにしました

法律の話

趣味の個人開発で Web サービスを作ろうとした時に注意したい法律が2つあります
場合によっては、自分の個人情報の記載や、届出が必要になる可能性があるため注意が必要です

1つ目は特定商取引法(ユーザーから利用料を受け取りたい場合に関係してきます)
2つ目は電気通信事業法(ユーザー間のメッセージのやり取りなどの通信を媒介する場合に関係してきます)

法律の内容については触れませんが、他にも個人情報の保護なども含め、思いついたアイディアが法律的に大丈夫なのか作る前に調べてみましょう

方針を考える

既存のプロジェクトと請求をまとめたいから、安いから、使ったことがあったから、使ってみたいからなど、理由は様々ですが下記のように方針を考えてみました
安直に決めたものが原因で後から苦労するのはどうせ自分なので、気楽にいきましょう
ダメだったら作り直せばいいわけです

  • サービスについて
    • コンセプト
      • ブラウザ上でマルチプレイで実質無限の広さのマインスイーパーが遊べる
    • 仕様
      • Websocket を使ってリアルタイムで他のユーザーの操作が反映される
      • 見ているところだけ盤面の更新を受け取る
      • PCやスマホなどデバイスによらずに遊べたらいいな
  • 開発について
    • インフラ
      • GCP 上に構築する
      • Firebase でホスティングする
      • Websocket サーバーを立ててサーバーとユーザー間の通信を行う
    • フロントエンド
      • Nuxt で実装する
      • UI コンポーネントは Vuetify を使う
      • socket.io-client で Websocket サーバーと通信する
    • バックエンド
      • Nodejs で実装する
      • Socket.io でフロントエンドとの通信する
  • TODO
    • クリア、ゲームオーバーという概念がないため、成功体験と失敗時のペナルティをどうするか考える必要がある
    • ゲームプレイにおける報酬とペナルティについて独自性が欲しい
    • 飽きさせないためにゲームを遊ぶ上での色々な軸が欲しい(対戦型、協力型など)
    • マネタイズするための仕組みを考えたい

ユーザー数を増やすためにできることを考える

作ったからにはたくさんの人に遊んで欲しいですよね
では、ユーザー数を増やすための施策を考えてみましょう
まず、ユーザーの増加 = ユーザーの流入 - ユーザーの離脱 と言えるので、この2つに対してどのようなアプローチがあるか見てみます

せっかく遊びに来てくれたユーザーも、サービスの出来が悪い場合をはじめ様々な要因ですぐに離脱してしまいます
どのような要因で離脱が起きるのか考えてみると

  • マインスイーパーが嫌い、興味がない
  • ルールがわからない
  • UI が使いにくい、ダサい
  • 飽きた
  • なんとなくやらなくなった
  • べつにこれじゃなくていい
  • 思ったのと違った

ユーザーの認知を得てから継続的にサービスを利用するようになるまでのフェーズを以下のように分けてみました
このような分析はファネル分析というらしく、各フェーズでの離脱率を把握することでどの位置にいるユーザーに対する施策がより効果的なのかを考えるのに役立ちます
どのフェーズでどれだけのユーザーが離脱したかを割合で表すことで、フェーズが進むと徐々にユーザー数が減り、ピラミッドのような形になります

ユーザーのフェーズ

  • 継続的な利用を増やすには
    • 手軽に遊べるようにする
    • ゲームバランスの調整(難易度、ペナルティ、報酬)
    • 機能の拡充(ポイント、ランキング、ミニマップなど)
    • 飽きない工夫(モードの追加、イベントの開催)
  • 一時的な利用を増やすには
    • 手軽に遊べるようにする
    • チュートリアルなどによるルール・操作方法の説明
  • 認知(流入)を増やすには
    • 口コミ
    • プロモーション
    • ゲーム配信依頼
    • 記事依頼
    • 広告
  • 全体に影響
    • UI/UX の改善
    • かわいさ、おしゃれさ

「そもそもマインスイーパーが嫌いだ」という人は仕方ないとして、このような施策を行うと各フェーズでの離脱が少なくなりそうな気がしますね
各改善案が本当に効果があるのかは実際にやってみないとわかりませんが、このように整理することでより効果的なアクションが取りやすくなりそうです

もし一度使ってくれたらほとんどの人が継続してくれるという場合には、認知を増やすことに力を入れることが効果的ですし、流入後に遊びもせずに離脱してしまうユーザーが大半だったという場合には UI/UX の改善やルール説明、チュートリアルの拡充が効果的かもしれません
流入直後の離脱率が高い状態で認知を増やしても水をザルで掬おうとしている状態と言えるため、ある程度の完成度になるまでは認知の拡大に労力をかけるのは勿体無いとも言えるでしょう

広げ過ぎた風呂敷を畳む

理想のイメージがどんどん湧いてくる状況になると「こんな機能もいいかも」「こういう要望があるかも」など本来の最低限の機能から離れたり実装が難しい機能まで思いついてきます
実際に手を動かす前には、ブレスト段階で広げ過ぎた風呂敷を畳み、本当に最初から必要な機能はなんなのかを見つめ直しましょう
特に個人開発の場合は永久に完成が見えないとモチベーションが低下し、放棄するという結果になりがちです

今回は、マインスイーパーとしての基本的な表示や操作ができ、有限の盤面でマルチプレイができるところを目標としました

作ってみる

ダメなら消せばいいのでとりあえず作っていきましょう

流れ

  • Websocket サーバーの構築
  • フロントエンドから Websocket サーバーに接続
  • データ構造を考える
  • マインスイーパーのロジックを実装
  • マインスイーパーの UI を実装

Websocket サーバーの構築〜フロントエンドから接続まで

過去の記事で書いた内容+改変

VM インスタンス上で Certbot, Nginx, Websocket コンテナを起動し、マインスイーパーの盤面の情報の保持や計算、ユーザーとの通信を行うようにしました
Websocket サーバー構築、フロントエンドからの接続については詳しくは以前書いた記事を参照してください

  • certbot
    • 証明書の更新
  • nginx
    • TLS/SSL 対応
    • CORS 対応
    • Websocket サーバーへのリバースプロキシ
  • websocket
    • マインスイーパーの Websocket 側のアプリケーション

上記の記事では Ruby で書いた Websocket サーバーで疎通確認まで行っていますが、今回は Nodejs で作成し socket.io を使って実装しました
ユーザーを room に join させるような機能も最初から入っているので便利でした
Nuxt で作成したフロントエンドからは、socket.io-client を使って接続を行いました
作成元が一緒なため、socket.io と socket.io-client での通信がスムーズに行えてよかったです

データ構造を考える

サーバー上で盤面の情報をどう管理するのか、通信にはどのようなデータを送るのか、描画時にはどのようなデータをもとに表示するのかを考えました

盤面の定義

将来的には無限の広さの盤面を持つ(ついでにサービス上で複数のルームを作れるようにしたい)と考え、どのように盤面を定義するかを考えました
図のように盤面をブロックという単位に分割し、ブロックの中にセルが存在するという形にしました
cells ではセルごとの状況を 1 文字で表現し、1行ごとに文字列としてまとめることでオーバーヘッドを抑えつつ盤面の情報を保持することができるのではと考えました

Board, Block, Cell の構造

無限に広い盤面とする際に、どれくらいの規模で分割するのが適切なのかはまだわかりませんが、とりあえずこのように定義することでだれかがいる場所だけブロックを初期化してデータを保持することや、見えている範囲のブロックの情報だけサーバー側と通信するという仕組みにできそうです

必要に応じてブロックを初期化

board
{
  name: "default",
  width: 10,
  height: 10,
  blocks: block[]
}
block
{
  x: 0,
  y: 0,
  cells: string[]
}

e.g. 盤面が 2x2 のブロックで構成され、各ブロックが 4x4 のセルで構成されている場合のイメージ

example
{
  name: "default",
  width: 2,
  height: 2,
  blocks: [
    {
      x: 0, y: 0,
      cells: ["AAAA", "ABAA", "AACA", "AAAD"]
    },
    {
      x: 0, y: 1,
      cells: ["AAAA", "ABAA", "AACA", "AAAD"]
    },
    {
      x: 1, y: 0,
      cells: ["AAAA", "ABAA", "AACA", "AAAD"]
    },
    {
      x: 1, y: 1,
      cells: ["AAAA", "ABAA", "AACA", "AAAD"]
    }
  ]
}

ブロックとブロックの間の処理をどうするか検討中なため、デモンストレーションで動いているものではブロックは分割せず 1 つのみ、セルは 100x100 で固定しています

地雷の個数の定義

既存のマインスイーパーでは盤面の大きさと地雷の個数が最初に与えられ、規定の個数の地雷を配置するという形になっていると思います
途中で失敗してゲームオーバーになってしまったとしても、残りの地雷が何個まで進めたかを自分の頑張った結果と捉えることができます
しかし、今回作りたいのは無限に広がるものなため、一定の範囲内に存在する地雷やフラッグの個数という数値に意味はありません
むしろブロックの区切りはユーザーには意識してほしくないため、各セルが地雷かどうかは確率で定義して初期化時にセルごとに決定していくことにしました
そのため、ブロックごとの地雷の個数は保証されません
将来的にはブロックごとに地雷の確率を変動させ、山あり谷ありの難易度になれば面白いなと思っています

セルの情報の持ち方

セルの状態は以下のような選択肢があり、フロントエンドに送る際には地雷の場所がバレないように加工する必要があります

  • サーバー上での取り扱い
    • セルの状態
      • 開いている
      • 開いていない
    • マークがついているか
      • なし
      • フラッグ
      • はてな
    • 地雷かどうか
      • 安全
      • 地雷
    • 周囲の地雷の個数
  • フロントエンドでの取り扱い
    • 上記を加工し、開いていないセルでは地雷かどうかを返さないようにする

色々と検討した結果、サーバー上でのセルの取り扱いについては以下のように1文字で表現することにしました
また、周辺の地雷の数を計算するタイミングはセルを開いた時に行うことにしました

周囲に何個地雷があるかを計算するタイミングは初期化時に地雷の位置を決め、同時に数字も割り振る or セルを開いた時に周りの地雷を数えて数字を振るの 2 つの選択肢があります
状況のパターンが少ない方が理解しやすいかなと思ったため、今回は後者を採用しました

どっちみち半角1文字を使うのであれば、Base64 のように64進数で表現することで、0~8, フラッグ、はてなマーク、地雷かどうかで異なる文字を割り当てることも可能そうですが、どちらが良いかは好みによるところもあるかと思います

文字 開いている 地雷 状況
0~8 Yes No 数字は周辺の地雷の数
9 Yes Yes 開いてしまった地雷
A No No 開いていないセル、実は安全
B No Yes 開いていないセル、実は地雷
C No No フラッグマーク、実は安全
D No Yes フラッグマーク、実は地雷
E No No はてなマーク、実は安全
F No Yes はてなマーク、実は地雷

安全なセルと開けると A→0~8 になり、地雷のセルと開けると B→9 になります
0~9 になったセルは再度クリックしても他の状況へ遷移することはありません
地雷ではないセルを右クリックしていくと A→C→E→A→C→E→... と変化していくことになります
同様に、地雷のセルを右クリックしていくと、 B→D→F→B→D→F→... となります

4 bit で表現できるので、バイナリ化してデータ量を削減してもいいかもしれないし、後半の 4 bit 分を別の何かに使うこともできそうです
ネックとしてはセルを開けるタイミングで都度周囲の地雷を数える必要がある点です

将来的にはデータ量の削減や高速化のためにデータの持ち方を変える必要が出てくるかもしれません
メモリ使用量や通信量、処理速度などに影響があるためデータ構造は大切ではありますが、まずは動くものを作ることを優先しました
ベストではないでしょうがまぁまぁ悪くはないのではと思っています

検討した案

いずれにせよ一旦前述の方法でまぁいっかと思っていることと、方法によってはデバッグがしにくくなるため、今回は採用しませんでした

1. 地雷の位置を配列で持っておく

セルの情報としてフロントエンドに渡すものと同じものを持っておき、表示用の情報とは別に地雷の位置を配列で持っておく
クリック時の地雷判定や、周りの地雷の数を計算する際にその配列を使うことで処理が楽になるかも
データ量は増えそうだが、デバッグがしやそうな気もしますね

ただし、100x100 のブロックで地雷が 20% の場合地雷の数は 2000 個になり、座標を数値で持つにせよ文字で持つにせよデータ量が増えてしまうなと思ってしまいます
地雷って意外と多いですね

board
{
  name: "default",
  width: 10,
  height: 10,
  blocks: block[],
}
block
{
  x: 0,
  y: 0,
  cells: string[], // ['ABC123', 'ABC123', ...]
  mines: number[][], // [[x, y], [x, y], ...]
}
2. 先に周りの地雷の個数を振る

セルの状態は、開いている(数字を表示)、開いていない、フラッグ、はてなマークの4種類であることと、周囲の地雷の数を初期化時に計算しておくという方法を取ると、セルの状況は 40 通りで表現できそうです
この値を Base64 などで表現することで、1文字で表現できるようになります

  • 周囲の地雷を最初に数える必要がある
  • セルのクリック時には計算せず結果を返せます
  • フロントエンドに渡す際の加工が面倒かも

都度計算することがボトルネックになる場合はこちらの方が良いかもしれないですね

値(例) 開いている 地雷 状況
0~8 Yes No 開いたセル、数字は周辺の地雷の数
9 Yes Yes 開いてしまった地雷
10~18 No No 開いていないセル、実は安全、周辺の地雷の数を保持
19 No Yes 開いていないセル、実は地雷
20~28 No No フラッグマーク、周辺の地雷の数を保持
29 No Yes フラッグマーク、実は地雷
30~38 No No はてなマーク、周辺の地雷の数を保持
39 No Yes はてなマーク、実は地雷
3. 表示に関する値と実態を別々に管理する

セルの情報を 2 文字で表現することで、表示用の情報と実態を別々に管理することができます

1文字目: 表示用の情報

文字 状況
0~8 地雷の個数
9 開いてしまった地雷
A 開いていないセル
B フラッグ
C はてなマーク

2文字目: 実態

文字 状況
0~8 開いたら表示する地雷の個数
9 地雷

フロントエンドに返す際は1文字目だけにし、セルを開いた際には2文字目を用いて次の処理に繋げるなどの使い方ができそうです
デメリットはデータ量が最初の倍になること

4. バイナリで表現する

データ容量は少なくなりますが、処理のたびにデコードが必要になりデバッグがしにくくなるため、スキップ

データの送受信

送受信するデータを整理すると以下のようになりました

フロントエンドが送信するデータ

  • ボードへ join したいこと
    • ボードの名前
  • ブロックへ join したいこと
    • 対象のボード
    • ブロックの座標
  • クリックの情報
    • 対象のボード、ブロック
    • クリックの種類(左、右、中)
    • クリックしたセルの座標

バックエンドが送信するデータ

  • ボードの情報
    • ブロックのサイズ
    • ブロック内のセルのサイズ
  • ボードへ join した人数の更新
    • 参加者数
  • ブロックの情報
    • ブロックのセル全ての表示用データ
  • セルの情報の更新
    • どのボード、ブロックのどのセルが何になったかの配列

それぞれ適当にオブジェクトとして送受信してみたらデータの送受信ができました
socket.io すごいですね

次に、バックエンドからフロントエンドに送信する際には、フロントエンドに見せて良いものとダメなものがあることについてみていきます
前述のセルの情報の表から、フロントエンドに見せて良いものとダメなものを以下のように分けました
見せてはいけない文字はフロントエンドに送信するデータを作成する際に同種の見せて良い文字に置換することで、地雷の位置がバレないようにしてみました

文字 フロントエンドに見せて良い 開いている 地雷 状況
0~8 Yes Yes No 数字は周辺の地雷の数
9 Yes Yes Yes 開いてしまった地雷
A Yes No No 開いていないセル、実は安全
B No No Yes 開いていないセル、実は地雷
C Yes No No フラッグマーク、実は安全
D No No Yes フラッグマーク、実は地雷
E Yes No No はてなマーク、実は安全
F No No Yes はてなマーク、実は地雷
こんなイメージ
const blockForFrontend = (block) => {
  return {
    x: block.x,
    y: block.y,
    cells: block.cells.map((row) => {
      return row.replace(/B/g, 'A').replace(/D/g, 'C').replace(/F/g, 'E');
    })
  }
}

マインスイーパーのロジックを実装

フロントエンドでは地雷がどこに埋まっているのか知るよしもないため、マインスイーパーのロジックは Websocket サーバー側で実装する必要があります
処理のフローは以下のようにしました
socket.io では room という概念があるため、ボードごと、ブロックごとに room を作成し、必要に応じて join することでみている範囲のブロックに関する情報だけやり取りし、通信量を減らすことができます

接続時

  • フロントエンド: ボード(盤面)へ join したいことを送る
  • バックエンド: ボード用の room へ join させ、ボードの情報を送信する
  • バックエンド: ボード用をプレイ中の人へプレイしている人数を送信する
  • フロントエンド: ボードの情報を受け取る(ブロックのサイズ、上限など)
  • フロントエンド: 今見えているブロックへ join したいと送る
  • バックエンド: ブロック用の room へ join させ、ブロックの情報を送信する(ブロックの座標、ブロックのセル一覧)
  • フロントエンド: 受け取ったブロックのデータを格納、描画する

移動時

  • フロントエンド: 移動した際に新しく見えるブロックへ join したいと送る
  • フロントエンド: 移動した際に見えなくなったブロックから切断する
  • バックエンド: 新しく見えるブロックへ join させ、ブロックの情報を送信する
  • フロントエンド: 受け取ったブロックのデータを格納、描画する

クリック時

  • フロントエンド: クリックの種類(左、右、両)、対象のボード・ブロック、セルの座標を送る
  • バックエンド:
    1. 空の配列を用意し、受け取ったユーザーの操作を Queue として追加する
      • 左クリック: セルを開く
      • 右クリック: フラッグ・はてな・なしの切り替え
      • 両クリック: 周囲の地雷の個数とフラッグの数が同じ場合のみ周囲の開いていないセルを開く
    2. クリックの種類に応じて処理を行い、開いたセルの周囲の地雷が 0 個の場合は周囲のセルを開く処理の Queue を追加する
    3. Queue を全て処理するまで 2 を繰り返す
  • バックエンド: 全ての Queue を処理する過程で更新されたセルの情報を該当のブロックに join している全てのユーザーに送信する
  • フロントエンド: 受け取ったセルの更新内容に従ってデータを更新、描画する

現状では 1x1 ブロック、 100x100 セルの盤面なためブロックの境目の処理は行っていません
今後、無限化するにあたってどのように初期化、処理していくかは実装しつつ検討しつつ進めていく予定です
一応アイディアはいくつかありますが、どうしようかなぁという感じです

マインスイーパーの UI を実装

Vuetify を使って全体の UI を実装し、マインスイーパー自体の領域は Canvas を使って描画しています
規約系やチュートリアルなどはページだけ作っただけで空っぽです

マインスイーパーの盤面の描画

Canvas はほとんど使ったことがなかったのでもっといい方法があるかもしれませんが、とりあえず以下のように描画しています
全てのパターンの各フェーズを並べるのはしんどかったため、イメージがつきやすい程度に描画を途中で止めた画像を並べておきます

描画フローイメージ

  1. 全体をグレーで塗りつぶす
  2. ブロックごとループ
    1. セルごとにループ
      1. セルが開いている場合
        • 地雷な場合
          • 背景を赤く塗る
          • 爆弾の画像を描画する
        • 地雷ではない場合
          • 背景をグレーに塗る
          • 地雷の個数を描画する ( 0 は何も描画しない )
      2. セルが開いていない場合
        1. セルの左上に三角形を描画する
        2. セル内側に平らな部分を描画して立体感を出す
        3. フラッグ・はてなマークを表示する
  3. 移動用の外枠を半透明で描画し、矢印を追加する
  4. マウスをクリックしていたらクリックの種別に応じてセルの立体感を一時的に反転させる
    1. 左クリック: マウス直下のセルを反転
    2. 両クリック: マウス直下のセルとその周囲のセルで開いていないセルを反転

フラッグ・地雷の画像はビットマップをつくるのもファイルとして置くというのも面倒だったので、 OffscreenCanvas 上に描画したものからビットマップデータを作成し、適宜再利用する形にしました
簡単な画像なので fillRectarc で描画してみています

地雷とフラッグの画像

フラッグ・地雷画像詳細
  const offscreenCanvas = new OffscreenCanvas(imageSize, imageSize)
  const context = offscreenCanvas.getContext('2d')

  // フラッグのビットマップを作成
  // 黒い土台と棒
  context.fillStyle = 'black'
  context.fillRect(4, 17, 15, 3) // 土台1段目
  context.fillRect(7, 14, 9, 3) // 土台2段目
  context.fillRect(11, 10, 2, 8)
  // フラッグ
  context.fillStyle = 'red'
  context.stroke()
  context.beginPath()
  context.moveTo(13, 2)
  context.lineTo(3, 7)
  context.lineTo(13, 12)
  context.fill()

  createImageBitmap(offscreenCanvas).then((bitmap) => {
    flagImageBitmap = bitmap
  })

  const offscreenCanvas = new OffscreenCanvas(imageSize, imageSize)
  const context = offscreenCanvas.getContext('2d')

   // 地雷のビットマップを作成
  context.fillStyle = 'black'
  context.beginPath()
  context.arc(11.5, 11.5, 7, 0, Math.PI * 2)
  context.fill()

  // 縦横斜め線
  context.strokeStyle = 'black'
  context.lineWidth = 2
  context.beginPath()
  context.moveTo(1, 11.5)
  context.lineTo(22, 11.5)
  context.moveTo(11.5, 1)
  context.lineTo(11.5, 22)
  context.moveTo(4, 4)
  context.lineTo(19, 19)
  context.moveTo(19, 4)
  context.lineTo(4, 19)
  context.stroke()

  // ハイライト
  context.fillStyle = 'white'
  context.beginPath()
  context.arc(9, 9, 1.5, 0, Math.PI * 2)
  context.fill()

  createImageBitmap(offscreenCanvas).then((bitmap) => {
    mineImageBitmap = bitmap
  })

できたもの

どうでしょうか?
だいぶそれっぽいものができたのではないでしょうか

スクリーンショット

https://minesweper.click/

上記サイトは開発中のものなため、随時変更される可能性があります
データの永続化は行なっていないため、サーバーの再起動などで盤面が初期化されることがあります
あまり動作は保証されませんが、気になる方がいたらみてみてください
誰もいない場合は2窓すると片方の操作がもう片方に反映される様子を見ることができます

操作方法

  • クリック
    • セル
      • 左クリック: セルを開く
      • 右クリック: フラッグ・はてな・なしの切り替え
      • 両クリック: 周囲の地雷の個数とフラッグの数が同じ場合のみ周囲の開いていないセルを全て開く(地雷でも開く)
      • 中クリック: 両クリックと同じ
      • タップ: 左クリックと同じ
      • その他スマホ対応: 未実装
    • 外枠
      • クリック: 上下左右斜め移動
  • スクロール
    • ホイール: 上下移動
    • Shift + ホイール: 左右移動
    • トラックパッド: 上下左右斜め移動
    • その他スマホ対応: 未実装

今後の展望

  • インフラ
    • VM インスタンスがスポットなため、自動的に停止されることのない通常のインスタンスに変更する
  • 無限の盤面にするために
    • ブロックの境目の処理をどうするか検討中
      • 初期化時
      • クリックの伝播時
      • など
  • 機能追加
    • チュートリアル整備
    • データの永続化、古いデータの削除の自動化
    • ミニマップの表示追加
    • ログイン
    • プライベートなボード
      • リセット機能
      • 特殊ルール設定の追加
    • Admin アプリ作成
      • 稼働状況の可視化
      • データの閲覧、削除、エクスポート、インポート
      • メンテナンス表示切り替え
  • 規約系ページをちゃんと書く
    • 規約、ポリシー
    • 配信ガイドライン
    • お問い合わせ
  • ズル防止
    • クリックの速度制限など?未定

まとめ

  • 無限の広さの盤面でマルチプレイできるマインスイーパーにするためにはどのようなデータ構造にしたら良いか考えた
    • 盤面をブロックという単位で区切り、ブロックにセルがあるという構造にすることでなんかとってもいけそうな気がする
  • とりあえずブロックの分割なし、セル数100x100の有限の盤面でマルチプレイできるマインスイーパーを作った
  • フラッグやはてなマーク、クリックや両クリックといった基本的なマインスイーパーの操作に対応した
  • まだまだ未完成なので、モチベーションを保ちながら進めていきたい
  • 次回は無限にしてみたという話を書けたら書きたいという気持ちだけはいっぱいある
GitHubで編集を提案
SocialPLUS Tech Blog

Discussion