こんにちは。技術部平山です。
この記事では、スマホ実機でhttpサーバを動かしてPCその他のブラウザ からアクセスできるようにことで、 開発を効率化できる可能性について考えます。
ただし、今のところ大規模製品への実戦投入はしておりません。 あくまでサンプルとお考えください。
何を作ったか
スマホで動くUnityアプリの中にhttpサーバを実装し、 同じネットワークにいるPCのブラウザからアクセスします。
今回の例では、ファイルのアップローダを用意し、 受信すると同時にアセットを差し変えています。 上の動画では、画像、音声、そして回転速度が書かれたjsonを差し換えています。
動機
簡単に言えば、PC上で何かを変更した時に、 それを最短で手元のスマホに反映させたい、というのが動機です。
前に参加した東京プリズンという製品では、 何らかの変更をスマホの実機に反映させるには 以下のどれかの手順を踏む必要がありました。
- UnityEditorで変更を加え、githubに置いてjenkinsでビルド、スマホに送って実行
- 素材をgithubに置き、アセットバンドル生成をjenkinsで走らせ、これを含むサーバを立て、実機からアクセス
- google spread sheetを書き換え、githubにその変更を取り込んでサーバを立て、実機からアクセス
- google spread sheetを書き換え、実機がそれを見に行って反映
最初のものはコード及び組み込みの素材、次は絵や音、後ろの2つはパラメータや文言の類です。 前3つはgithubとjenkinsを経由しており、手数が多く時間もかかります。 最後のものは直通なので効率は良いのですが、 限られた場所でしかこのフローにはなっていなかったように思いますし、 もし複数人で同じシートを編集したくなると厄介だったかもしれません。
反復速度と、複数人での並列作業のことだけを言えば、 手元で作れるデータは手元で作り、 実機で動かしていい感じになってからgithubに入れる、 という方が良さそうに思えます。 例えばアーティストが絵を直すのであれば、
- 直す
- 保存すると手元のスマホに反映される
というのがおそらく最短です。これを実現するための第一歩として、 まずはアーティストのPCとスマホが直接通信をしてデータを反映できる必要があります。 今回はそこに挑戦してみました。
設計
PCからスマホにデータを送る場合、どちらがサーバをやるかの選択肢があります。
- PCがサーバをやる
- スマホ側からポーリング、あるいは、スマホ側で反映操作。
- スマホがサーバをやる
- PC側で保存した際にアップロード、スマホ側は受け取ったら反映。
前者は実装が楽です。PCで動くhttpサーバはいくらでもあり、 macならば最初からapache が入っています。 普通のスマホゲームでは元々httpでデータを取ってくる仕組みを使っていますから、 ほとんど追加実装はいりません。更新があった素材をダウンロードして persistentData にでも保存し、すでにメモリにロードしていれば破棄してリロードする、 というだけで終わりです。
しかし、どうにも 「アーティストやゲームデザイナーにPCでhttpサーバを動かす設定をしてもらう」 というところで悪い予感がします。説明の手間やトラブル対応が軽く済むとは思えません。 実装の手間は一回だけですが、運用の手間はずっと続きますから、 「使用者のPCでの準備はゼロ」が理想でしょう。
そこで、クライアント側をサーバとすることにしました。
- スマホのクライアントが起動するとhttpサーバが動く
- 画面にIPが出る
- PCのブラウザにそのIPを入れるとサイトが出る
- そこでアップロード等の操作をする
- スマホで反映される
という流れです。PC側では何の準備もいりませんから、 使う人は楽なはずです。 ブラウザ上でアップロード等の操作をしなければならないのが若干手間ですが、 特定のファイルを直しては反映、直しては反映、というフローであれば、 送るファイルの指定は一度で済みます。 「保存する度に送信ボタンを押す」だけなら、まあ許容できるでしょう。
もちろん理想的は「保存すると勝手に送られる」で、 これはphotoshop等のプラグインからスマホにhttpアクセスすれば実現できます。 ただ、使うソフトの数だけ作ることになるので本当に元が取れるかは チームの状況や規模によるかと思います。
実装
Httpサーバ側
C#の標準ライブラリにはHttpListener という便利な機能があります。 これを使ってDebugServerなるクラスを作りました。
public DebugServer(int port) { if (!HttpListener.IsSupported) { return; } listener = new HttpListener(); listener.Prefixes.Add("http://*:" + port + "/"); ...(省略)... listener.Start(); listener.BeginGetContext(OnRequestArrival, this); }
クラス名はDebugServer で、コンストラクタでサーバを立てます。 HttpListenerをnewして、Prefixes.Add() を呼んで受け入れるurlパターンを指定し、 Start() して、BeginGetContext() します。 BeginGetContext()にはコールバック(OnRequestArrival)を渡しておき、 アクセスがあるとそれが呼ばれます。 async/awaitを使えばもっと綺麗に書けますが、 古いUnityで使うことも考慮して古い書き方をしておきました。
そして、そのコールバックはこんな感じです。
void OnRequestArrival(IAsyncResult asyncResult) { var context = listener.EndGetContext(asyncResult); listener.BeginGetContext(OnRequestArrival, this); // 次の受け取り lock (requestContexts) { requestContexts.Enqueue(context); } }
EndGetContext() を呼んでリクエストの中身が入ったHttpListnerContext なるものをもらい、 また次のリクエストに備えてBeginGetContext()を呼んでおきます。
ここで、このコールバックはどこのスレッドで呼ばれるかわからないので、 ユーザ処理をメインスレッドで実行することを保証すべく、一旦Queueに溜めています。 今回は、毎フレームManualUpdate()という関数が手動で呼ばれる前提とし、 そこでキューに溜まったものを処理するようにしました。 手動で呼ぶのが嫌なら、DebugServerをMonoBehaviour 派生クラスとして、 Updateを実装するのが良いでしょう。 私は極力gameObjectを増やさない方が好きなので、こうしています。
httpサーバのインターフェイス
次に、このDebugServerクラスの使い勝手を見てみます。
public class DebugServer : IDisposable { public delegate void OnRequest( out string outputHtml, System.Collections.Specialized.NameValueCollection queryString, System.IO.Stream bodyData); public void RegisterRequestCallback(string path, OnRequest onRequest); public static string GetLanIpAddress(); public DebugServer(int port); public void Dispose(); public void ManualUpdate(); }
publicだけを抜き出したものです。製品ではダミー実装に差し換える、 といったことをするならインターフェイスを設けても良いでしょう。
ポートを指定してコンストラクトしてサーバを立て、 RegisterRequestCallback()でアクセスされるurlごとにコールバックを渡します。 例えばこんな感じです。
debugServer = new DebugServer(port: 8080); debugServer.RegisterRequestCallback("/", OnWebRequestRoot); debugServer.RegisterRequestCallback("/api/file/upload", OnWebRequestUploadFile); debugServer.RegisterRequestCallback("/api/file/delete", OnWebRequestDeleteFile); debugServer.RegisterRequestCallback("/api/file/delete-all", OnWebRequestDeleteAllFile);
仮にスマホのアドレスが192.168.0.3
だった場合、
http://192.168.0.3/
にアクセスすれば、OnWebRequestRoot()が呼ばれ、
http://192.168.0.3/api/file/upload
にアクセスすれば、OnWebRequestUploadFile()
が呼ばれる、といった具合です。
コールバックは以下の形をしています。
public delegate void OnRequest( out string outputHtml, NameValueCollection queryString, Stream bodyData);
javascript側がサーバに送った情報をqueryString及びbodyDataとして受け取って、 アクセスしてきたブラウザに返すhtmlを返す、というインターフェイスです。 所詮デバグ用なので、極力簡単にすべくこのように定義しました。 高機能化に伴って引数が増えていくかもしれません。
ここまでのお話でわかるように、このDebugServerの層では ファイルの置換、アップロード処理、などの具体的な機能は担当しません。 それは上の層になります。
脱線: outか?戻り値か?
ところで、このコールバックのインターフェイスなのですが、 「戻り値をstringにしてhtmlを返させればいいだろ」 とお考えの人もいるかもしません。 私がこうしたのは、C#では戻り値に名前がつけられないからです。
public delegate string OnRequest( NameValueCollection queryString, Stream bodyData);
だと、戻り値がhtmlであることの説明がありません。 コメントを書けば良い、という考えもあるかと思いますが、 私はコメントがないと使い方がわからないコードを書いたら負けだと考えています。
これは名前の問題でして、名前がOnRequest()でなくGenerateOutputHtml()であれば 戻り値が何なのかがわかりやすいでしょう。しかしそうすると、 「リクエストがあった時に何かをする関数」という意味合いが薄れて、 htmlを生成する方に重きが置かれてしまいます。 イマイチしっくりくる名前が思いつかなかったので、outで返すことにしました。 私はこういう葛藤によく悩むのですが、皆さんはどうお考えでしょうか。
C#もgoのように 戻り値に名前がつけられれば良かったのかもしれませんね。
htmlファイル
ブラウザからアクセスしたらhtmlを返さないといけませんが、 これは現状アプリ側で用意する想定です。 今回のサンプルでも、htmlファイルをAssetsの下に用意してあります。 ルートディレクトリにアクセスされた時には、これをそのまま返すだけです。
void OnWebRequestRoot( out string outputHtml, NameValueCollection queryString, Stream bodyData) { outputHtml = debugServerIndexHtml; }
htmlファイルはAssetsの下に置くとTextAssetとして取れるので、 Inspectorでスクリプトにセットし、 ここからテキストを抜いてoutputHtmlにセットします。 もしアクセスされたurlによって異なるhtmlを返す本格的な作りになるならば、 htmlをいくつも書くことになるでしょう。
Javascript側
サーバには、パスとファイルの中身を送信する必要があります。 今回は、パスはquery stringとして送り、 ファイルの中身はmessage bodyとして送ります。
関連コードは以下のような感じです。
var onLoad = function (arrayBuffer) { var path = document.getElementById('path').value; var request = new XMLHttpRequest(); request.onload = function () { log.value = 'アップロード受理\n'; }; request.onerror = function () { log.value = 'アップロード失敗\n'; }; request.open( 'PUT', document.location.origin + '/api/file/upload?path=' + path, true); request.send(new Int8Array(arrayBuffer)); }; var onUpload = function () { var files = document.getElementById('file').files; if (files.length == 0) { return; } var reader = new FileReader(); reader.onload = function (e) { onLoad(e.target.result); }; reader.readAsArrayBuffer(files[0]); }; document.getElementById('upload').addEventListener('click', onUpload, false);
送信に使う機能はXmlHttpRequest です。 onloadとonerrorを用意した後に、 open() でurlとメソッドを指定します。query stringにはパスを含めるので、
request.open( 'PUT', document.location.origin + '/api/file/upload?path=' + path, true);
となります。?path=ほげほげ
をurlの後ろにつけるわけです。
ここで、サーバ(つまりスマホ)のアドレスはdocument.location.origin
で取れます。
メソッドはPUTにしていますが、これは 「何回呼んでも同じ結果になるものはPUT」 という指針があるようなので、それに従いました。
最後に、send()します。
request.send(new Int8Array(arrayBuffer));
引数にはInt8Array のようなArrayBufferView を渡します。
ファイル置換
では、サーバ側、つまりUnity側のファイルのアップロード受け付け処理を見てみましょう。
先程RegisterRequestCallback()で渡したOnWebRequestUploadFile()です。
これはhttp://サーバ名/api/file/upload
にアクセスが来た時に呼ばれます。
void OnWebRequestUploadFile( out string outputHtml, NameValueCollection queryString, Stream bodyData) { outputHtml = null; if (bodyData == null) { outputHtml = "中身が空."; return; } var path = queryString["path"]; if (string.IsNullOrEmpty(path)) { outputHtml = "アップロードしたファイルのパスが空."; return; } DebugServerUtil.SaveOverride(path, bodyData); loadRequested = true; }
queryStringをpath
をキーにして値を取り出せば、そこにパスが入っています。
あとは、DebugServerUtil
というファイル保存や置換用の便利クラスを
用意しておいたので、そのSaveOverride()を呼んで保存します。
Application.persistentDataPath の下に書きこむだけです。 ただし、エディタでは同じアプリの複数バージョンを同時に持ちたいので、 プロジェクトフォルダの下に保存しています。
public static string GetPersistentDataPath() { string ret; #if UNITY_EDITOR // エディタではプロジェクト直下の方が便利 // GetCurrentDirectoryがプロジェクトパスを返すことに依存している。動作が変われば動かなくなる! ret = Path.Combine(Directory.GetCurrentDirectory(), "PersistentData"); #else ret = Application.persistentDataPath; #endif return ret; }
あとは、すでに読んでいるファイルをリロードすれば画面に反映されます。 このサンプルでは画像、音声、jsonの3ファイルを全部ロードしなおしていますが、 実際には変更があったファイルだけをリロードする必要がありますし、 リロードに要するコードを最小にすべく、仕組みを整える必要もあるでしょう。
ロード時の置き換え
今回のサンプルでは、 「StreamingAssetsからロードするファイルは、同名のものがpersistentDataにあれば、 そちらをロードする」という処理をしています。
例えば、StreamingAssets/image/hoge.png
があった時に、
persistentDataPath/Override/image/hoge.png
が存在していれば、
そちらを代わりにロードします。ファイルの存在チェックをする分だけ
ロードが遅くなるので、真面目に実装するなら、
ファイルが存在するかどうかの情報を起動時にDictionaryにしておくのが良いでしょうが、
サンプルではやっていません。
もちろん、製品にする時にはこの機能は無効化し、 また、StreamingAssets以下のファイルを最終版に書き換えるか、 AssetBundle化してゲームサーバに置くことになると思います。
なお、やろうと思えばAssetBundleに関しても同じようにpersistentDataに 置いたものを優先してロードさせるように書くことができます。 標準のDownloadHandlerAssetBundle は使えなくなりますが、 それほどの手間ではないでしょう。 作業する人が自分のPCでAssetBundleを作るフローであれば、 効率化が可能かと思います。
課題と未来
さて今回はほんの手始めでして、これを製品にどう導入して効率化するか? が問題です。 ここでは現状の課題と、今後の使い方について考えてみましょう。
htmlとjavascript書くのが面倒くさい
今回一番面倒くさかったのはhtmlとjavascriptを書く所です。 普段C#で生活しているので、別の言語に頭を切り換えるにはコストがかかります。 まして、javascriptの経験がない人であれば、そもそもやろうとも思わないでしょう。
ですから、C#でコードを書けば、htmlやjavascriptが生成される、というのが理想です。 アップロード用のファイル選択入力、メッセージログ、 テキストボックス、スライダー、ボタン、などのUI要素を C#で指定して、それを元にhtmlを生成する感じです。 所詮開発用ですから美観は無視できるわけで、レイアウト指定みたいな機能を作らなければ それほどの手間にはならないと思います。
実際にはStreamingAssetsにモノを置かない
一定以上の規模のゲームであれば、アプリの本体容量を極力削る必要があり、 StreamingAssetsに物を置くことは滅多にないかと思います。 初回実行時にサーバからダウンロードしてくることでしょう。
そうすると、「元々StremaingAssetsにあったものをロードする時に置き換える」 では使い勝手が良くありません。 サーバからダウンロードする物に関しても同じ置換が働くように拡張するのが良いでしょう。
AssetBundleに入れないものにしか使えない
Unityのゲームは大抵データをAssetBundleに入れます。 しかし、データを作るアーティストやゲームデザイナーが普段Unityで作業をしない のであれば、 AssetBundleのビルドは手元でできません。 手元でできなければ、「手元のPCからスマホに直転送」の意味はなくなってしまいます。
となれば、「元々AssetBundleに入っていたものも置換可能」 な仕掛けにする必要があるでしょう。 例えば、hoge.unity3dに入っているimage.pngを置換するには、 アップロードのパス指定を"hoge.unity3d/image.png"とする、 というようにします。このパスにファイルが存在していれば、 これをAssetBundle内の素材の代わりに用いるわけです。
しかし、実装は結構厄介です。可能かどうかすらわかりません。 AssetBundle内のSpriteが依存しているTextureだけ差し換える、 といったことをどうやるかはまだ考えてもいません。
であるならば「そもそもAssetBundleに入れなくていいものは入れない」 ということも考えられるかとは思います。 テクスチャ圧縮をしなくて良い、と割り切れば、 画像はpngやjpegから直接テクスチャにできますから、 独自にzipに入れておいても読み込むことはできます。
例えばSpineでエフェクトを作っている場合、 Spineから吐かれるものはテクスチャのpngとアニメーション情報が入った独自バイナリです。 これをzipに固めてアップロードするプラグインをspineに用意すれば、 Unityが入っていないマシンでも、 「spineでセーブしたら即座にスマホのゲームに反映される」状態を作れます。
もちろん、アーティストやゲームデザイナーがUnityEditorを使う文化であれば そういった問題はなく、 単にAssetBundleをビルドして送信すれば済みます。 たぶんそれがUnityの本来の使い方でしょう。 ほとんどプログラマだけがUnityEditorを使っている 弊社が特殊な気はします(最近はアーティストでも使う人が増えてきましたが)。 ただし、その場合もボタン一つでAssetBundleをビルドして実機に送信、 といったツールの整備は必要です。 1クリックでも余計な手数を減らすことが、量産効率を上げる上でも、 サポートの手間を減らす上でも重要かと思います。
リロードの実装が案外面倒くさい
リロードの実装は案外面倒です。 もし全ての素材について、アップロードされた瞬間にリロードできるようにしようと思えば、 個別には書かずに済む統一された仕掛けが必要でしょう。
例えば画像の場合、 最終的にUI.ImageなりMaterialなりにセットされて表示されているわけで、 新しくアップロードされた画像がどこのImageやMaterialに差さっているのかを 識別して差し換えに行かねばなりません。 何か素敵な基底クラスを作って、その素敵な基底クラスから派生したクラスを使って キャラ絵や背景画像、アイテム画像を差し換えていれば、 アップロードに対応した置換が起こる、というようなことができると良いのではないかと思います。 実際にどうやってやるかは今後考えますが。
パラメータ調整がgoogle spread sheetなのどうしよう
弊社のフローではアイテムのデータ、ヘルプの文章、敵のデータ、 のようなものはgoogle spread sheet上で設定、調整されます。 そもそもローカルのPCにデータがないので、今回の手法は何も貢献できません。
ローカルのexcelでやっていればcsvを吐いてスマホに 直転送して置換、といったこともできるのですが、 そういうフローの製品がないので貢献しようがないわけです。
小規模なパズルゲームを作る、というような場合であれば、 配置データやパラメータをステージごとにjson化して、 これを書き換えては試す、というフローが良いと思うわけですが、 いわゆるソーシャルゲーム的なものに合うのかはわかりません。 そのあたりは今後ゲームデザイナーと話をしていきたいところです。
なお、小規模なパズルゲームなどであれば、 ブラウザ上で動くステージエディタを作って今回の仕組みに乗せてしまうと良いでしょう。 javascriptのスキルが活きる局面は多いと思います。
妥協
以上のように厄介なことが多数あるので、 少なくとも近い将来における利用に関しては、 以下のような形になるのではないかと思います。
- ファイル置換に関しては、「ロードし直す場面遷移を経れば置換される」で良しとする。
- 例えばタイトル画面に戻ってもう一度その画面に行けば置換される、とか。
- 前の画面に戻る、くらいで済む所も多数あるでしょう。
- スライダーやテキストボックスでゲームパラメータを調整することはやりやすい。
- htmlとjavascriptさえ書けば、例えばエフェクトの再生速度、オフセット、などを調整できたり、ゲーム中の敵の強さを調整できたりするUIをブラウザに作ることはできる。技術的な問題は少ない。
- 単純な情報閲覧だけでも効果はある。
- 実機上にログが出ているとゲームの邪魔になるが、ログをブラウザに送りつけて広い画面で見られるとうれしい。保存できるとなお良い。
- FPS情報、メモリ情報、ゲームオブジェクト数情報、ゲームの進捗状況、などをブラウザに表示するのは結構便利。
- ポーリングを避けたいなら、WebSocketか?ServerSentEventか?
- hierarchyとinspectorをブラウザに出して編集できるのではというアイディアも
終わりに
まだまだ課題は多いのですが、 PCとスマホ実機を直接つなぎ、 スマホの狭い画面をブラウザに拡張できる今回の手法は、 いろいろと使いみちがあるのではないかと思っています。
皆さんからのアイディアをお待ちしております!