はじめに
昨年末に iOS と Android で Bouncy Mouse を公開した後、いくつかの重要な教訓を得ました。その中でも、確立された市場に参入するのは難しいという点が重要でした。完全に飽和状態の iPhone 市場では、注目を集めるのは非常に困難でした。Android Marketplace は飽和状態がそれほど進んでいなかったため、進展はしやすくなりましたが、それでも簡単ではありませんでした。この経験から、Chrome ウェブストアで興味深い機会を見つけました。ウェブストアにはコンテンツが充実していますが、質の高い HTML5 ベースのゲームのカタログは、成熟しつつある段階です。新しいアプリ デベロッパーにとって、これはランキング チャートを作成して視認性を高めるのがはるかに簡単になることを意味します。この機会を念頭に、最新のゲームプレイ エクスペリエンスを新しいユーザー層に提供したいとの思いから、Bouncy Mouse を HTML5 に移植することにしました。 このケーススタディでは、Bouncy Mouse を HTML5 に移植する一般的なプロセスについて説明します。その後、興味深い 3 つの分野(音声、パフォーマンス、収益化)について詳しく説明します。
C++ ゲームを HTML5 に移植する
Bouncy Mouse は現在、Android(C++)、iOS(C++)、Windows Phone 7(C#)、Chrome(Javascript)で利用できます。そのため、複数のプラットフォームに簡単に移植できるゲームを作成するにはどうすればよいかという質問がよく寄せられます。手動ポートに頼ることなく、このレベルのポータビリティを実現できる魔法の弾丸を望んでいるように思えます。 残念ながら、そのようなソリューションがまだ存在するかどうかはわかりません(最も近いのは Google の PlayN フレームワークか Unity エンジンですが、どちらも私が興味を持っているすべてのターゲットに合致しません)。私のアプローチは、実際には手動ポートでした。まず、iOS/Android バージョンを C++ で記述し、このコードを新しい各プラットフォームに移植しました。大変な作業のように思えますが、WP7 版と Chrome 版の作成にはそれぞれ 2 週間もかかりませんでした。では、コードベースを簡単に移植できるようにするにはどうすればよいでしょうか。以下に、この作業に役立った方法をいくつかご紹介します。
コードベースを小さくする
当たり前のように思われるかもしれませんが、これが私がゲームを迅速に移植できた主な理由です。Bouncy Mouse のクライアント コードは、C++ で約 7,000 行です。7,000 行のコードは決して少なくありませんが、管理できる程度に小さいです。クライアント コードの C# バージョンと JavaScript バージョンの両方のサイズはほぼ同じになりました。コードベースを小さく保つには、基本的に次の 2 つの重要な方法があります。余分なコードを書かないことと、前処理(ランタイム以外)のコードで可能な限り多くの処理を行うことです。余分なコードを書かないことは当たり前のように思えますが、これは私がいつも自分自身と戦っている点です。ヘルパーに分割できるものはすべて、ヘルパー クラスまたは関数を作成したいという衝動に駆られます。ただし、ヘルパーを実際に複数回使用する予定がない限り、通常はコードが肥大化するだけです。Bouncy Mouse では、少なくとも 3 回使用する予定がない限り、ヘルパーを記述しないように注意しました。ヘルパー クラスを作成する場合、今後のプロジェクトで再利用できるように、クリーンかつポータブルで、再利用できるようにします。一方、Bouncy Mouse 専用のコードを記述する場合は、再利用の可能性は低いため、コードを記述する方法として「最も美しい」方法ではなくても、コード作成タスクをできるだけシンプルかつ迅速に完了することに重点を置きました。コードベースを小さく保つための 2 つ目の、より重要な部分は、できるだけ多くの処理を前処理ステップにプッシュすることでした。ランタイム タスクをプリプロセッシング タスクに移動できる場合、ゲームの実行速度が向上するだけでなく、新しいプラットフォームごとにコードを移植する必要がなくなります。たとえば、私は元々レベル ジオメトリ データをかなり未加工の形式で保存し、実際の OpenGL/WebGL 頂点バッファを実行時にアセンブルしていました。これには、少しの設定と数百行のランタイム コードが必要でした。その後、このコードを前処理ステップに移動し、コンパイル時に完全にパックされた OpenGL/WebGL 頂点バッファを出力しました。実際のコード量はほぼ同じでしたが、数百行のコードが前処理ステップに移動されたため、新しいプラットフォームに移植する必要がなくなりました。Bouncy Mouse には、このような例が大量に含まれています。可能なことはゲームによって異なりますが、実行時に実行する必要がない処理には注意してください。
不要な依存関係を導入しない
Bouncy Mouse を移植しやすいもう 1 つの理由は、依存関係がほとんどないことです。次のチャートは、プラットフォームごとの Bouncy Mouse の主なライブラリ依存関係をまとめたものです。
以上です。すべてのプラットフォームに移植可能な Box2D を除き、大きなサードパーティ ライブラリは使用されていません。グラフィックについては、WebGL と XNA の両方が OpenGL とほぼ 1:1 でマッピングされるため、大きな問題ではありませんでした。実際のライブラリが異なるのはサウンドの部分のみです。ただし、Bouncy Mouse のサウンドコードは小さく(プラットフォーム固有のコードが 100 行ほど)、大きな問題ではありませんでした。Bouncy Mouse に大きなポータブルでないライブラリを含めないことで、ランタイム コードのロジックをバージョン間でほぼ同じに保つことができます(言語が変更された場合でも)。また、ポータブルでないツールチェーンにロックインされることがなくなります。OpenGL/WebGL に対して直接コーディングすると、Cocos2D や Unity などのライブラリを使用する場合と比べて複雑さが増すかどうかという質問をよく受けます(WebGL ヘルパーもいくつかあります)。実際は、その逆だと考えています。ほとんどのモバイル / HTML5 ゲーム(少なくとも Bouncy Mouse のようなゲーム)は非常にシンプルです。ほとんどの場合、ゲームはいくつかのスプライトと、テクスチャ付きのジオメトリを描画するだけです。Bouncy Mouse の OpenGL 固有のコードの合計は 1,000 行未満です。ヘルパー ライブラリを使用すると、この数を実際に減らせるとは考えにくいです。この数を半分に減らしても、500 行のコードを節約するためだけに、新しいライブラリやツールを学習するのにかなりの時間を費やす必要があります。さらに、関心のあるすべてのプラットフォームでポータブルなヘルパー ライブラリを見つけていないため、このような依存関係を導入すると、ポータビリティが大幅に損なわれます。ライトマップ、動的 LOD、スキン付きアニメーションなどを必要とする 3D ゲームを作成している場合は、答えは確実に変わります。この場合、OpenGL に対してエンジン全体を手動でコーディングしようとすると、自転車の再発明になります。つまり、ほとんどのモバイル/HTML5 ゲームは(まだ)このカテゴリに該当しないため、必要になる前に複雑にする必要はありません。
言語間の類似性を過小評価しない
C++ コードベースを新しい言語に移植する際に多くの時間を節約できた最後の秘訣は、コードのほとんどが言語間でほぼ同じであることに気付いたことです。一部の重要な要素は変更される可能性がありますが、変更されない要素の方がはるかに多くなります。実際、多くの関数では、C++ から JavaScript に移行する際に、C++ コードベースでいくつかの正規表現置換を実行するだけで済みました。
移植の結論
移行プロセスは以上です。以降のセクションでは、HTML5 固有の課題についていくつか説明しますが、主なメッセージは、コードをシンプルに保つと、移植は悪夢ではなく、小さな頭痛の種にすぎないということです。
音声
私(と他の人々も)が問題に感じたのは音声でした。iOS と Android では、信頼性の高いオーディオ オプション(OpenSL、OpenAL)がいくつかありますが、HTML5 の世界では状況は厳しいものでした。HTML5 Audio は使用できますが、ゲームで使用すると致命的な問題が発生することがわかりました。最新のブラウザでも、奇妙な動作が頻繁に発生しました。たとえば、Chrome では、作成できる Audio 要素(ソース)の同時数に上限があるようです。また、音声が再生されても、説明できない理由で歪んでしまうことがあります。全体的に、少し心配でした。 オンラインで検索したところ、ほとんどのユーザーが同じ問題を抱えていることがわかりました。最初に思いついたのは、SoundManager2 という API でした。この API は、利用可能な場合は HTML5 Audio を使用し、複雑な状況では Flash にフォールバックします。このソリューションは機能しましたが、バグがあり、予測不可能でした(純粋な HTML5 Audio に比べると、バグは少なかったものの、予測不可能でした)。リリースから 1 週間後、Google の担当者に相談したところ、WebKit の Web Audio API を紹介されました。最初はこの API の使用を検討しましたが、(私にとって)不要な複雑さがあるように思えたため、使用を避けていました。いくつかの音を再生したいだけなら、HTML5 Audio で JavaScript を数行記述するだけで済みます。しかし、Web Audio をざっと見ただけで、その巨大な(70 ページ)仕様、ウェブ上のサンプルの少なさ(新しい API ではよくあることです)、仕様内のどこにも「再生」、「一時停止」、「停止」機能がないことに驚きました。Google から、私の懸念は根拠がないと保証されたので、API をもう一度詳しく調べてみました。いくつかの例を調べて、さらに調査を進めたところ、Google の言うとおり、この API は私のニーズを満たすことができ、他の API にありがちなバグも発生しないことがわかりました。特に役立つのは、Web Audio API スタートガイドの記事です。API について詳しく知りたい場合は、ぜひご覧ください。私にとっての本当の問題は、API を理解して使用した後も、「いくつかの音を再生するだけ」に設計されていない API のように思えることです。この懸念を解消するために、音声の再生、一時停止、停止、状態のクエリを、思い通りに API を使用して実行できる小さなヘルパー クラスを作成しました。このヘルパー クラスは AudioClip という名前にしました。ソース全体は Apache 2.0 ライセンスで GitHub で入手できます。このクラスの詳細については、後述します。最初に、Web Audio API の背景について説明します。
ウェブ オーディオ グラフ
Web Audio API が HTML5 Audio 要素よりも複雑(かつ強力)である理由としてまず挙げられるのは、音声をユーザーに出力する前に処理 / ミックスできる点です。音声再生にはグラフが関与するため、単純なシナリオでは複雑になります。Web Audio API の強みを示すために、次のグラフをご覧ください。
上記の例は Web Audio API の強力さを示していますが、私のシナリオではそのほとんどは必要ありませんでした。音を鳴らしたいだけだった。グラフは必要ですが、非常にシンプルなグラフです。
グラフはシンプルにできる
Web Audio API が HTML5 Audio 要素よりも複雑(かつ強力)である理由としてまず挙げられるのは、音声をユーザーに出力する前に処理 / ミックスできる点です。音声再生にはグラフが関与するため、単純なシナリオでは複雑になります。Web Audio API の強みを示すために、次のグラフをご覧ください。
上記の単純なグラフでは、音声の再生、一時停止、停止に必要なすべての処理を行うことができます。
グラフは気にしないでください
グラフを理解することは重要ですが、音声を再生するたびに確認するのは面倒です。そのため、シンプルなラッパー クラス「AudioClip」を作成しました。このクラスは内部でこのグラフを管理しますが、ユーザー向けの API ははるかにシンプルです。
このクラスは、Web Audio グラフといくつかのヘルパー状態にすぎませんが、各サウンドを再生するために Web Audio グラフを構築するよりもはるかにシンプルなコードを使用できます。
// At startup time
var sound = new AudioClip("ping.wav");
// Later
sound.play();
実装の詳細
ヘルパークラスのコードを確認しましょう。 コンストラクタ - コンストラクタは、XHR を使用して音声データを読み込みます。(例をシンプルにするため)ここでは示していませんが、HTML5 Audio 要素をソースノードとして使用することもできます。これは、サンプル数が大きい場合に特に便利です。Web Audio API では、このデータを「arraybuffer」として取得する必要があります。データが受信されると、このデータから Web Audio バッファが作成されます(元の形式からランタイム PCM 形式にデコードされます)。
/**
* Create a new AudioClip object from a source URL. This object can be played,
* paused, stopped, and resumed, like the HTML5 Audio element.
*
* @constructor
* @param {DOMString} src
* @param {boolean=} opt_autoplay
* @param {boolean=} opt_loop
*/
AudioClip = function(src, opt_autoplay, opt_loop) {
// At construction time, the AudioClip is not playing (stopped),
// and has no offset recorded.
this.playing_ = false;
this.startTime_ = 0;
this.loop_ = opt_loop ? true : false;
// State to handle pause/resume, and some of the intricacies of looping.
this.resetTimout_ = null;
this.pauseTime_ = 0;
// Create an XHR to load the audio data.
var request = new XMLHttpRequest();
request.open("GET", src, true);
request.responseType = "arraybuffer";
var sfx = this;
request.onload = function() {
// When audio data is ready, we create a WebAudio buffer from the data.
// Using decodeAudioData allows for async audio loading, which is useful
// when loading longer audio tracks (music).
AudioClip.context.decodeAudioData(request.response, function(buffer) {
sfx.buffer_ = buffer;
if (opt_autoplay) {
sfx.play();
}
});
}
request.send();
}
再生 - 音声を再生するには、再生グラフを設定し、グラフのソースで「noteOn」のバージョンを呼び出すという 2 つの手順が必要です。ソースは 1 回しか再生できないため、再生するたびにソースまたはグラフを再作成する必要があります。この関数の複雑さのほとんどは、一時停止したクリップを再開するために必要な要件(this.pauseTime_ > 0
)に起因しています。一時停止したクリップの再生を再開するには、noteGrainOn
を使用します。これにより、バッファのサブリージョンを再生できます。残念ながら、noteGrainOn
は、このシナリオで望まれる方法でループを操作しません(バッファ全体ではなくサブリージョンをループします)。そのため、noteGrainOn
でクリップの残りの部分を再生してから、ループを有効にしてクリップを最初から再開することで、この問題を回避する必要があります。
/**
* Recreates the audio graph. Each source can only be played once, so
* we must recreate the source each time we want to play.
* @return {BufferSource}
* @param {boolean=} loop
*/
AudioClip.prototype.createGraph = function(loop) {
var source = AudioClip.context.createBufferSource();
source.buffer = this.buffer_;
source.connect(AudioClip.context.destination);
// Looping is handled by the Web Audio API.
source.loop = loop;
return source;
}
/**
* Plays the given AudioClip. Clips played in this manner can be stopped
* or paused/resumed.
*/
AudioClip.prototype.play = function() {
if (this.buffer_ && !this.isPlaying()) {
// Record the start time so we know how long we've been playing.
this.startTime_ = AudioClip.context.currentTime;
this.playing_ = true;
this.resetTimeout_ = null;
// If the clip is paused, we need to resume it.
if (this.pauseTime_ > 0) {
// We are resuming a clip, so it's current playback time is not correctly
// indicated by startTime_. Correct this by subtracting pauseTime_.
this.startTime_ -= this.pauseTime_;
var remainingTime = this.buffer_.duration - this.pauseTime_;
if (this.loop_) {
// If the clip is paused and looping, we need to resume the clip
// with looping disabled. Once the clip has finished, we will re-start
// the clip from the beginning with looping enabled
this.source_ = this.createGraph(false);
this.source_.noteGrainOn(0, this.pauseTime_, remainingTime)
// Handle restarting the playback once the resumed clip has completed.
// *Note that setTimeout is not the ideal method to use here. A better
// option would be to handle timing in a more predictable manner,
// such as tying the update to the game loop.
var clip = this;
this.resetTimeout_ = setTimeout(function() { clip.stop(); clip.play() },
remainingTime * 1000);
} else {
// Paused non-looping case, just create the graph and play the sub-
// region using noteGrainOn.
this.source_ = this.createGraph(this.loop_);
this.source_.noteGrainOn(0, this.pauseTime_, remainingTime);
}
this.pauseTime_ = 0;
} else {
// Normal case, just creat the graph and play.
this.source_ = this.createGraph(this.loop_);
this.source_.noteOn(0);
}
}
}
効果音として再生 - 上記の再生機能では、オーディオ クリップを重ねて複数回再生することはできません(2 回目の再生は、クリップが終了または停止したときにのみ可能です)。ゲームによっては、再生が完了するまで待たずに音を何度も再生する必要がある場合があります(ゲーム内でコインを収集するなど)。これを有効にするために、AudioClip クラスには playAsSFX()
メソッドがあります。複数の再生が同時に発生する可能性があるため、playAsSFX()
からの再生は AudioClip と 1:1 でバインドされません。そのため、再生を停止、一時停止、ステータスのクエリを行うことはできません。ループ再生も無効になっています。この方法で再生されたループ音を停止する方法がないためです。
/**
* Plays the given AudioClip as a sound effect. Sound Effects cannot be stopped
* or paused/resumed, but can be played multiple times with overlap.
* Additionally, sound effects cannot be looped, as there is no way to stop
* them. This method of playback is best suited to very short, one-off sounds.
*/
AudioClip.prototype.playAsSFX = function() {
if (this.buffer_) {
var source = this.createGraph(false);
source.noteOn(0);
}
}
停止、一時停止、状態のクエリ - 残りの関数は非常に簡単で、説明はほとんど必要ありません。
/**
* Stops an AudioClip , resetting its seek position to 0.
*/
AudioClip.prototype.stop = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.startTime_ = 0;
this.pauseTime_ = 0;
if (this.resetTimeout_ != null) {
clearTimeout(this.resetTimeout_);
}
}
}
/**
* Pauses an AudioClip. The offset into the stream is recorded to allow the
* clip to be resumed later.
*/
AudioClip.prototype.pause = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.pauseTime_ = AudioClip.context.currentTime - this.startTime_;
this.pauseTime_ = this.pauseTime_ % this.buffer_.duration;
this.startTime_ = 0;
if (this.resetTimeout_ != null) {
clearTimeout(this.resetTimeout_);
}
}
}
/**
* Indicates whether the sound is playing.
* @return {boolean}
*/
AudioClip.prototype.isPlaying = function() {
var playTime = this.pauseTime_ +
(AudioClip.context.currentTime - this.startTime_);
return this.playing_ && (this.loop_ || (playTime < this.buffer_.duration));
}
音声の結び
このヘルパー クラスが、私と同じオーディオの問題に苦しんでいるデベロッパーの皆様にとって有用なものとなることを願っています。また、Web Audio API のより強力な機能を追加する必要がある場合でも、このようなクラスから始めるのが妥当なようです。どちらにしても、このソリューションは Bouncy Mouse のニーズを満たし、制約のない真の HTML5 ゲームを実現しました。
パフォーマンス
JavaScript ポートに関して懸念していたもう 1 つの点はパフォーマンスでした。ポートの v1 を完成させた後、クアッドコア デスクトップですべてが正常に動作していることを確認しました。残念ながら、ネットブックや Chromebook では、問題が少し発生しました。この場合、Chrome のプロファイラが、プログラムのすべての時間の消費場所を正確に示してくれたおかげで、問題を解決できました。私の経験から、最適化を行う前にプロファイリングを行うことが重要であることがわかりました。遅延の原因は Box2D 物理エンジンかレンダリング コードのどちらかだろうと予想していましたが、実際にはほとんどの時間が Matrix.clone()
関数で消費されていました。ゲームの性質上、行列の作成やクローンを大量に作成することはわかっていましたが、これがボトルネックになるとは予想していませんでした。最終的に、非常に簡単な変更でゲームの CPU 使用量を 3 倍以上削減できました。デスクトップの CPU 使用率は 6 ~ 7% から 2% に低下しました。これは JavaScript デベロッパーにとっては常識かもしれませんが、C++ デベロッパーとしては驚きの問題でしたので、もう少し詳しく説明します。基本的に、元のマトリックス クラスは 3x3 マトリックス(3 要素の配列で、各要素に 3 要素の配列が含まれている)でした。そのため、行列のクローンを作成する際に、4 つの新しい配列を作成する必要がありました。変更が必要だったのは、このデータを 9 要素の単一配列に移動し、それに応じて計算を更新することだけでした。この 1 つの変更が、CPU 使用量を 3 倍に削減した原因であり、この変更後、すべてのテストデバイスでパフォーマンスが許容範囲内になりました。
その他の最適化
パフォーマンスは許容範囲内でしたが、いくつかの軽微な問題が発生しました。プロファイリングをさらに進めたところ、これは JavaScript のガベージ コレクションが原因であることがわかりました。アプリは 60 fps で実行されていたため、各フレームの描画時間は 16 ms しかありませんでした。残念ながら、低速マシンでガベージ コレクションが開始されると、10 ミリ秒ほど消費されることがあります。ゲームがフルフレームの描画に 16 ミリ秒をほぼすべて必要としていたため、数秒ごとにスタッターが発生していました。なぜこれほど多くのガベージが生成されるのかを詳しく把握するため、Chrome のヒープ プロファイラを使用しました。残念ながら、ゴミの大部分(70% 以上)が Box2D によって生成されていることが判明しました。JavaScript でガベージを排除するのは難しい作業であり、Box2D を書き直すことは考えられませんでした。私は追い詰められていたのです。幸い、古典的な方法のひとつである「60 fps に達しない場合は 30 fps で実行する」という方法がまだありました。安定した 30 fps で実行する方が、ジッターのある 60 fps で実行するよりもはるかに優れていることは、ほぼ共通認識となっています。実際、ゲームが 30 fps で動作するという苦情やコメントは、まだ 1 件も受けていません(2 つのバージョンを並べて比較しない限り、判断するのは非常に困難です)。フレームごとに 16 ms 余裕があるため、ガベージ コレクションが不格好な場合でも、フレームをレンダリングするのに十分な時間があります。30 fps での実行は、私が使用していたタイミング API(WebKit の優れた requestAnimationFrame)では明示的に有効にされていませんが、非常に簡単な方法で実現できます。明示的な API ほどエレガントではありませんが、RequestAnimationFrame の間隔をモニターの VSYNC(通常は 60 fps)に合わせることで、30 fps を実現できます。つまり、他のすべてのコールバックを無視するだけです。基本的に、「RequestAnimationFrame」が呼び出されるたびに呼び出されるコールバック「Tick」がある場合、これは次のように実現できます。
var skip = false;
function Tick() {
skip = !skip;
if (skip) {
return;
}
// OTHER CODE
}
より慎重を期す場合は、パソコンの VSYNC が起動時に 30 fps 未満になっていないことを確認し、この場合はスキップを無効にしてください。ただし、テストしたデスクトップ/ノートパソコンの構成では、この問題は発生していません。
配信と収益化
Bouncy Mouse の Chrome ポートで驚いた点のひとつが収益化です。このプロジェクトに着手するにあたって、HTML5 ゲームは、今後の技術を学ぶための興味深い実験になると考えました。ポートが非常に多くの視聴者にリーチし、収益化の大きな可能性を秘めているとは思っていませんでした。
Bouncy Mouse は 10 月末に Chrome ウェブストアでリリースされました。Chrome ウェブストアでリリースすることで、モバイル プラットフォームで慣れ親しんだ、見つけやすさ、コミュニティとのエンゲージメント、ランキングなどの既存のシステムを活用できました。驚いたのは、ストアのリーチが非常に広いことでした。リリースから 1 か月以内にインストール数は 40 万件近くに達し、コミュニティのエンゲージメント(バグレポート、フィードバック)からすでにメリットを得ていました。ウェブアプリの収益化の可能性にも驚きました。
Bouncy Mouse には、ゲーム コンテンツの横にバナー広告を表示する、シンプルな収益化方法が 1 つあります。しかし、このゲームの幅広いリーチを考えると、このバナー広告でかなりの収益を得られることがわかりました。ピーク時には、最も成功したプラットフォームである Android と同等の収益をアプリで得ていました。これに寄与している要因の一つは、HTML5 バージョンで表示される大きな AdSense 広告は、Android で表示される小さな AdMob 広告よりもインプレッションあたりの収益が大幅に高いことです。さらに、HTML5 版のバナー広告は Android 版よりも目立ちにくく、ゲームプレイを妨げません。全体的に、この結果に非常に満足しています。

ゲームからの収益は予想を大幅に上回りましたが、Chrome ウェブストアのリーチは、Android Market などの成熟したプラットフォームよりもまだ小さい点に注意が必要です。Bouncy Mouse は Chrome ウェブストアで人気ゲームの 9 位にまで急上昇しましたが、初回リリース以降、サイトにアクセスする新規ユーザーの数が大幅に減少しました。とはいえ、このゲームは引き続き着実に成長しており、このプラットフォームが今後どのように発展していくのか楽しみです。
まとめ
Bouncy Mouse を Chrome に移植するのは、予想していたよりもはるかにスムーズでした。音声とパフォーマンスに関する軽微な問題を除けば、Chrome は既存のスマートフォン ゲームに十分なプラットフォームだと感じました。これまでこの機能に取り組んでこなかったデベロッパーの皆様には、ぜひ試していただきたいです。移植プロセスと、HTML5 ゲームによってつながった新しいゲーム ユーザーの両方に非常に満足しています。 ご不明な点がございましたら、お気軽にメールでお問い合わせください。または、以下のコメント欄にコメントを投稿してください。定期的に確認いたします。