テックブログ2022-08-10

4時間のモブプロでGatherに鬼ごっこ機能をつくった

yuichkun

はじめに

こんにちは、LAPRAS にて業務委託で開発に参画している余湖(よご)と申します。

2022年3月-4月にかけてLAPRAS恒例のモブプロ生配信企画の新シリーズとして、
Gather のAPIを使って、4人で4時間で「鬼ごっこ」を実装する生配信を行いました。



一連のYouTubeのプレイリストはこちら

Gatherってそもそもなに?


参照: Gather公式HP

Gather とは、RPGのような可愛らしいバーチャル空間上で、キャラクターたちを自由に歩き回らせたり、チャットしたり、ビデオ通話したりできるアプリケーションです。

特にコロナのご時世ではリモートワークの需要も急激に増していく中で、
無味乾燥になりがちなオンラインミーティングに隙間の会話を生んでくれるツールだと、僕は思っています。

LAPRASでは半年ほど前から全社的にこのツールを導入し、現在では社内のほとんどの会議がGather上で行われています。

モブプロ生配信・各回ダイジェスト


第1回



Gather APIのデモンストレーション


GatherにはHTTP APIWebsocket API があるのですが、
後者の方がやれることが多いので、今回の配信では、そちらを使うことになりました。

そして、あらかじめ初回配信までに僕が少しだけAPIを触ってみていたので、このAPIを使ってできることをみんなに共有することで、作るもののイメージを膨らませました。


teleport APIを使ってキャラクターをランダムな座標に飛ばしてみている様子

なにを作るかみんなでブレインストーミング


なにを作るか決まったところで、みんなでfigjam上でブレインストーミングし、
最後に投票した結果、「Gather上で鬼ごっこができるようにする」ことが開発テーマに決まりました。



そして、このテーマを実現するための開発を、次回以降の配信からスタートして合計4時間以内に達成する のが私たちの目標となりました。

ちなみに、この時にブレインストーミングに使ったfigjamのボードはこちらから誰でもご覧になることができます。

ユーザーストーリーマッピングの作成




「鬼ごっこをつくる」 と一口に言っても、この時点ではみんなの頭の中にあるものはまだ曖昧でした。

そこで、ユーザーストーリーマッピングを作ることで、

  • 「鬼ごっこ」におけるユーザーの行動の洗い出し
  • ユーザーが最低限できなければいけないこと= MVP (Minimum Viable Product)を定義
  • 余裕があったら開発したいことをバックログに追加


をしました。

第2回


Gather APIのセットアップ


  1. @gathertown/gather-game-clientnpm install する(配信時点では、v34.0.0 ) を使用しました
  2. APIを使える状態になるまで色々試行錯誤(いろいろトラブルがありました)


この時点で、下記のような状態になりました。

import { Game } from '@gathertown/gather-game-client';
global.WebSocket = require("isomorphic-ws");
const game = new Game(undefined, () => Promise.resolve({ apiKey: getApiKey() }));
game.init(getSpaceId());
game.connect();

game.subscribeToConnection(
 (connected) => {
  // 無事に接続できると、このコールバックが呼ばれる
  console.log('is connected: ', connected)
  game.subscribeToEvent('info', console.info)
  game.subscribeToEvent('warn', console.warn)
  game.subscribeToEvent('error', console.error)
 }
)


⚠️いくつかハマりどころ⚠️


  • これはgather側のバグっぽいのですが、(少なくとも配信時点では)グローバルに定義されているWebSocket オブジェクトに暗黙的にライブラリが依存していて、それがないと動かない状態でした。 ソースコードを読んだ結果、isomorphic-wsnpm install し、 global オブジェクトにプロパティを追加することで解消することがわかったので、そのように対応しました。
  • Websocket APIは叩いたメソッドの成功/失敗などを直接呼び出し元のメソッドに返してくれないので、game.subscribeToEvent('error', callback) などで明示的にハンドラーを登録しておく必要がありました


teleport APIを試してみる


setInterval(()=> {
  if (Object.keys(game.players).length) {
   console.log('teleport!!!!')
   game.teleport('rw-6', 10, 20, Object.keys(game.players)[0])
  }
}, 1000)


このようなコードで、game.players の先頭に入っているプレーヤー(おそらくそのマップに最初に入室したプレーヤー)が1秒ごとに x: 10, y: 20 の座標に飛ばされるプログラムが完成しました。



そして、不幸にもこの時その選ばれたプレーヤーになってしまった @to_ryo_endo さんは、蟻地獄にはまった蟻のように抜け出せなくなってしまいましたが、しかし、ひとまず実装初回としては 無事にAPIが叩けるようになったのが大きな成果でした。

第3回



この回からいよいよプロダクトバックログのアイテムに取りかかります。
第1回で計画した通りの順番で、まずは鬼が鬼だとわかるようにすることがこの回の目標となりました。

アバターの着せ替えのやり方を調べる


Gather APIではアバターの着せ替えは以下のメソッドで行うことができます。

game.setOutfitString(outfitString, playerId)




できました🎉

鬼のアバターをつくる


outfitString というのは、アバターの各パーツ(服や髪の色など)の状態を表したjson文字列なのですが、
この outfitString 文字列をコードで書いてつくるのは大変です。

そこで、player.outfitString から現在のoutfitStringを取り出すことができるので、
GatherのGUI上で鬼用のアバターを作った上で、それをコピーして定数に保存しておくことになりました。

そして、完成した鬼がこちら


鬼が隣接する1マスに来たらタッチと判定する


さて、いよいよ 当たり判定 の実装に入ります。

game.subscribeToEvent('playerMoves', callback)


このAPIを使うことで、全てのプレーヤーの移動に対してハンドラーを登録することができるので、
方針としては、

  1. 誰かが移動する
  2. その時鬼になっているプレーヤーから各プレーヤーまでの座標を計算する
  3. もしも、1マス以内にプレーヤーがいる場合は、鬼交代とする

このような感じで実装を目指します。

game.subscribeToEvent('playerMoves', () => {
 if (!Object.keys(game.players).length) return
 // 今いる player の座標を全部出す
 const players = Object.values(game.players)
 const [oni, ...humans] = players
 humans.forEach(human => {
  const xDiff = oni.x - human.x
  const yDiff = oni.y - human.y
  console.log(`${human.name} to oni: ${xDiff} ${yDiff}`)
 })
})


以上のようなコードを実行した状態で、プレーヤーが動くと、



このようにログに各プレーヤーから鬼までのx座標・y座標の差分が出力されました 🎉

そして、この回は、ここで時間切れとなりました。

第4回



当たり判定を実装する


当たり判定を実装するにあたり、
任意の地点Aから任意の地点Bまでの距離を計算するのには、



上図のように、三平方の定理 (懐かしいですね…)を利用して、
(鬼と人のX座標の差分)の2乗 + (鬼と人のY座標の差分)の2乗 を計算し、
その平方根を求めればそれが距離である、ということができそうです。

その上で、この距離が閾値にしたい値(ここでは 1 )よりも小さい時を 当たり とするロジックを組む方向で実装を進めました。

const r = Math.sqrt(Math.pow(xDiff, 2) + Math.pow(yDiff, 2))
if (r <= 1){
 // 当たり!
 game.setOutfitString(oniOutfitString, playerId)
}




これで当たり判定ができました 🎉

ゲームの初期化処理を作る & ゲームの状態を管理する


ここで、そもそも「ゲームの状態(誰が鬼か)を保存する仕組みがまだない」「ゲームはどのようにしてスタートするのか」という問題が浮上しました。
話し合いの結果、「ゲーム内の特定座標を誰かが踏んだら、その瞬間にその人を鬼としてゲームが始まる」 という仕様が新たに決まったので、
GameState という簡単な変数を作り、そこに鬼のプレーヤーの id を保存するような実装を追加しました。

初期化処理については、私たちがテストで使っていたGatherのデフォルトのマップでは、「P」と書いてあるマスがちょうどよさそうな場所だったので、
そこを踏んだら初期化がはじまるように実装をしました。


↑ リセットポイントを踏むと鬼になり、ゲームが始まる様子 🎉

鬼以外を人間にする処理を作る


リセットポイントを踏んだ時に、鬼以外の人のアバターを強制的に人間のアバターに着せ替えさせる処理も追加しました。


第5回



「タッチ返し」を抑制する


前回までの配信で実装した範囲では、全てのプレイヤーの動きにトリガーして、
当たり判定が行われてしまうため、いわゆる「タッチ返し」があまりにもシビアに行われてしまう、
という問題が発生していました。

そこで、「最後に鬼交代が行われた時刻より、一定時間経過していなければタッチ判定をしない」 という仕様が追加されました。
これに伴い、GameState.lastOniChangedAt というプロパティを追加し、鬼変更時刻を保持しておきつつ、

const now = new Date().getTime()
// 鬼交代の後に一定時間の間は鬼交代を無効にする
if (now - gameState.lastOniChangedAt! < 500) return


このような処理を実装しました。

MVP完成🎉


ここで、MVPで目標としていた全ての開発タスクを完了し、
必要最低限の鬼ごっこの機能が完成しました 🎉



第6回



鬼のタッチの精度を向上させる


前回までの実装だと、あくまで「鬼から人の距離」のみを当たりの判定に使っていました。
しかしこれでは、鬼の後ろを通った人すらもタッチされてしまうので、
「鬼が向いている方向に人間がいて、かつ距離が近い時のみタッチにする」仕様が追加されました。

playerオブジェクトには .direction というプロパティがあり、そこに向きを示すenumの値が入っているので、
それを用いて、当たり判定の精度を向上させました。

(そしてこの非常に煩雑な分岐を書くために、30分ほどみんなで頭を悩ませるつらい時間が続きました…)



なにはともあれ、これで「鬼が通っても人の方を向いていなければタッチにならない」を実現することができました 🎉

振り返り


そして、最後にみんなでしばらく完成した鬼ごっこを遊んだあとに、
シリーズを通した振り返りをfigjam上で行いました。

振り返りは「熱気球」というフレームワークに則って、

  • 気球(チーム)が上昇するファクター
  • 気球(チーム)の飛行の妨げになったファクター
  • 気球(チーム)が障害物を避けるためのアイデア

の観点に分けて、それぞれ考えて発表しました。


終わりに


GatherのAPIはまだまだ仕様の変更も多く、ドキュメントに書いていないことも多かったですが、
ソースコードを読み、API実装者の意図を推測しながら目的のAPIを探す作業は、さながら宝探し💎のようで
楽しい体験でした。

ちなみに、今回のモブプロ生配信で作成したプログラムのソースコードはGitHub上に公開されていますので、
「我が社もリモートワークに遊び心を導入したい」という会社さまはご自由にお使い頂いてかまいません。

このエントリーをはてなブックマークに追加