Archive for 4月, 2013

== null が激重

Posted 2013.04.28 in 吉里吉里

まずは次のTJSスクリプトを実行してみて欲しい。


function test()
{
  var TEST_NUM = 1000000;
  var begin, end;
  var v = 0;

  Debug.message("Test Num: "+TEST_NUM);

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v == void;
  end = System.getTickCount();
  Debug.message("v(0) ==  void : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v === void;
  end = System.getTickCount();
  Debug.message("v(0) === void : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v == null;
  end = System.getTickCount();
  Debug.message("v(0) ==  null : %d ms".sprintf(end-begin));

  begin = System.getTickCount();
  for(var i=0; i < TEST_NUM; i++) v === null;
  end = System.getTickCount();
  Debug.message("v(0) === null : %d ms".sprintf(end-begin));
}

var win = new Window();
win.visible = true;
Debug.console.visible = true;
test();

TJSの特殊値 void, null の比較の速度を計測している。
これを実行すると次のような結果になる。


Test Num : 1000000
v(0) ==  void : 38 ms
v(0) === void : 33 ms
v(0) ==  null : 11554 ms
v(0) === null : 38 ms

== null が激重!!!

100万回で 10秒近くということは…

1回あたり0.01msもかかっている!

たいしたことないように思えるかもしれないが
60FPSを実現するには17ms以内に1フレーム中の全ての処理を終えないといけないので
たった100回の比較で1ms食うというのは極めて甚大だ。

一方で、void との比較の場合は null のような問題は起きていない。
なぜこのようなことが起こるのか…

void と null の違い

この問題を追求するには、まず void と null の違いを理解する必要がある。
吉里吉里の値に関して詳しくはこちら。
TJS2 リファレンス > 言語リファレンス > 項

void は値が入ってないことを示す特殊値である。
変数に初期値を指定しない時、変数に最初に入っている値がこの void である。
内部実装では tvtVoid型 の値である。

null は何も指し示していないを示すObject型の値である。
内部実装では tvtObject型 の値である。

void と null は一見似たような性質の値に思える。
しかし

void と null は型が異なる

のである。
実はこの違いが決定的な差を生み出している。

== の実装

吉里吉里のソースコードを追いかけて、原因を探ってみよう。
==演算子が行う比較処理の実装は tTJSVariant::NormalCompare にある。
tjsVariant.h
tjsVariant.cpp

実装を見ると、比較する値の型が同一である場合は、なんら問題なく比較が行われている。
問題なのは、比較する値の型が同一でない場合だ。

型が異なる値の比較

型が異なる値を比較する場合、どんな処理が行われるのか。
その実装も tTJSVariant::NormalCompare にある。
型が異なる場合は…

値を実数に変換して比較する

ただしtvtVoid型とtvtString型については特別な実装が施されている。

tvtVoid型を他の型の値を比較する場合、
相手が数値または文字列であれば数値に変換し、voidを0とみなして比較が行われる。
それ以外の型と比較する場合は、常にfalseとなる。

tvtString型を他の型の値を比較する場合、
他方を文字列に変換して比較が行われる。

実数化の中身

実数化の処理はAsRealに記述されている。
tvtObject型の値がどう変換されるのか見ると…


  TJS_CONST_METHOD_DEF(tTVReal, AsReal, ())
  {
    TJSSetFPUE();

    switch(vt)
    {
    case tvtVoid:    return 0;
    case tvtObject:  TJSThrowVariantConvertError(*this, tvtReal);
    case tvtString:  return String->ToReal();
    case tvtInteger: return (tTVReal)Integer;
    case tvtReal:    return Real;
    case tvtOctet:   TJSThrowVariantConvertError(*this, tvtReal);
    }
    return 0.0f;
  }

例外が投げられている!

コレダー!!!
比較の度に例外が投げられていたのでは…
そりゃあー重いはずである。

AsReal から投げられた例外は tTJSVariant::NormalCompare でキャッチされ、falseを返す。

本当の問題

原因は判明したが、よくよく考えるとこの問題は == null に限った問題ではない。
nullでなくても、Object型の値とInt型などの値を比較すれば発生しうる問題だからだ。
またOctetの場合も同様の問題が発生しうることがわかる。

そもそも null と数値を比較するなんてこと自体がおかしいと思うかもしれない。
しかし「数値とオブジェクトの両方が入りうる利便性の高い変数」を作った場合や
「値は設定されているが、不定であることを表す値」として
null を流用しようとした場合などにありえる話である。

対策

問題の原因を理解していれば、この問題を回避するのは簡単だ。

===を使えばいい

===演算子はより厳密な比較を行う比較演算子であり、
異なる型同士の比較の場合は常にfalseを返し、変換は行わない。

ただしObject型同士の比較は == と === とで作用が異なっている点に注意が必要だ。
===演算子はそのオブジェクトのコンテキスト(this)まで比較する。

まとめ

nullと比較する時は == ではなく === を使おう!


めざせ60FPS!

Foooはありがたいことに、とても快適に動作するという評価をいただいている。
今回はその快適さがどのように実現されているかについて紹介しようと思う。

高い要求と過酷な環境

Foooに最初に要求された動作水準は800×600サイズのフルカラー。
それに対し当時の標準的なPCのスペックは Celeron 800MHz 程度だった。
コンピュータが日々進歩を続けているとは言え、その環境は
800×600サイズのフルカラー表示をソフトウェア処理で行うにはまだまだ過酷だった。

どのくらい過酷かというと、800×600サイズの画像を1枚表示させるだけで30FPSなくらい。
言い換えると

画面に背景を表示するだけで30FPS

になってしまうのだ。
他に画像を表示すればあっという間に
20FPS…10FPS…5FPS…

ちなみに、FPSとは1秒の間に画面を再描画する回数(Frame Per Secnod)のことである。
モニタの標準的なFPSは60FPSであるが、
ゲームを快適にプレイできるようにするためには最低でも平均30FPS程度は出したいところだ。
それが、背景を表示するだけで30FPS…
もっと低スペックの環境も当然ありえるが400MHzだと15FPS、166MHzだと5FPS…

5FPSともなると、もはやまともにプレイできるような状態ではない。
400MHZでも最低30FPS、166MHzでも最低15FPSは出るようにしたい。
そのためには800×600で60FPS出るくらいじゃないとお話にならない。

無理じゃね!?

描画処理の最適化

泣き言を言っても始まらない。
FPSが出ないのは、一にも二にも描画に非常に時間がかかっているからである。
そこで最初に行った対策は

とにかく描画を速くする

ことである。
次のような最適化を施した。

・ケースバイケースごとに細かく処理分けして少しでも無駄な処理を省く
・コンパイラに頼らずアセンブラで最適化
・MMXを使う
・SSEを使う
・パイプラインストールを減らす
・メモリアライメントを意識する
・キャッシュヒット率を考える
・キャッシュにプリフェッチする

そんなこんなで~描画処理を1.5倍程度高速化することができた!
16msかかっていた800×600の画像の描画処理が11msになった。

──────しかしFPSにすると、30FPSだったのが40FPSになったくらい。
60FPSにはまるで届いていない。

無効領域処理

描画処理そのものの高速化はもう限界。
そこでもっと別の角度から高速化を行えないかと考えた。
それは…

無駄な描画をしない

ことである。

無駄な描画を省く最適化は一般的にカリング(Culling)と呼ばれる。
Cullingとは「間引き」という意味だ。
Windowsのウィンドウの描画においても、このカリング処理が行われている。

画面の再描画を行うには、画面を背景から何から全て描き直してしまうのが最もシンプルな方法である。
しかしそこには非常に多くの無駄が存在する。

まず画面が何も変化してないのに画面を書き直すのはまったくの無意味だ。
そのような画面の再描画は根本的に省くことができる。
さらに画面の中で前回描画した時から何も変化していない部分を再描画するのも大きな無駄だ。
何も変化していないのだから再描画する必要はない。
要するに

変化がない部分は再描画する必要がない

のである。
実際の最適化の様子を見てみよう。

YouTube Preview Image

赤い箱枠で示されているのが、変化があり再描画が行われている領域である。
箱枠がない部分は再描画の必要のない領域だ。
再描画領域が非常に小さく限定されていることがわかるだろう。

このような再描画の必要のある領域を『無効領域』(Invalid Rect)と呼ぶ。
Update Rect(更新領域), Dirty Rect(汚れた領域)などと呼ばれることもある。

この最適化の効果は絶大だった。
事実、この最適化によって”画面に動きがない部分では”あっさり60FPSを達成できた!

しかしこの最適化を活かすには、演出においてこの最適化を意識することが大切だ。
画面全体を動かすような演出をできるだけ避けるといった工夫である。

無効領域の分割管理

無効領域は効率のために矩形で管理するが、
無効領域は画面の中で飛び飛びに複数発生する可能性がある。
そのような無効領域を矩形で管理しようとすると無駄が発生してしまう。
次の例を見て欲しい。

YouTube Preview Image

雪の粒が画面を埋め尽くしているので、無効領域はほぼ画面サイズと等しくなっている。
しかしこの無効領域には明らかに大きな無駄がある。
再描画の必要のない部分まで無効領域に含めてしまっている。

このような無駄が発生するのは、無効領域を単一の矩形を管理しているせいだ。
この問題を解決するには

無効領域を複数の矩形で管理すればいい

実際の例を見て見よう。

YouTube Preview Image

無効領域が非常に細かく管理され、無駄が省かれている様子がわかるだろう。
けれども無効領域をあまりに細かく管理しすぎると
無効領域の管理の処理負荷が大きくなってしまうため加減が肝心だ。

この最適化により、画面の各所で複数のものが動作する場合でも
非常に高いFPSを出すことができるようになった。

遮蔽される面の除去

描画において大きな無駄がもう1つある。

見えない部分は描画する必要はない

のである。

複数の画像が重なっていて、後ろの画像が手前の画像によって覆い隠されている場合、
後ろの画像はどうせ隠れてしまうので描画する必要がない。
言われて見れば、至極当たり前の最適化である。
この最適化は3D方面ではオクルージョンカリング(Occlusion Culling)と呼ばれる。
Occlusionとは「遮蔽」という意味だ。
実際の動作の様子を見てみよう。

YouTube Preview Image

青い箱枠が遮蔽面、緑の箱枠が遮蔽しない面である。
この箱枠の形に描画処理が行われる。
遮蔽によって、背後の画像の領域が分割されている様子がわかるだろう。

この最適化の効果は思いのほか大きい。
実際の演出では画面の切り替えにフェードイン、フェードアウトが多様されるが
フェード幕で隠れた部品を、幕の背後に残したまま演出を続けるようなことが多々ある。
そのような場合にこの最適化がすごく活きる。

幕の背後の描画を自動的に行わないようになるからだ。
逆にこの最適化がないと、非常に大きな無駄が発生してしまう。
そして、ソレになかなか気がつかない。

Foooの演出において、真四角のコマが多用されているのもこの最適化が関連している。
コマを真四角にすることで、背景を遮蔽しやすくしているのだ。
そのようにすると、コマがたとえ画面に何十枚もある場合でも
背景を1枚描画するのとさほど変わらないコストで画面を描画できるのである。

遮蔽される無効領域の除去

次の最適化は併せ技だ。

見えない部分が変化しても再描画する必要はない

遮蔽されている部分で何かが動いても、見た目に変化は起きないので再描画する必要がない。
要するに「無効領域に対して遮蔽を行う」ということである。
これも実際の動作を見るのがわかりやすいだろう。

YouTube Preview Image

まとめ

全ての最適化を適用したもの。

YouTube Preview Image

このような最適化と、その最適化を意識した演出によって
Foooはソフトウェア処理でありながら高速な(高速に見える)動作を実現しているのである。

以下おまけ。

YouTube Preview Image
YouTube Preview Image


Windows95でも動く!

よく誤解されるのだけれど…

Foooはソフトウェアエンジンである!

グラフィックスのハードウェアアクセラレーション…
いわゆるビデオカードの機能はまったく利用しておらず
ほぼ全ての処理をCPUのみで行っている。

なのでビデオカードを最新のものに買い換えても恩恵はさほどない。
VRAMの容量なんかはパフォーマンスにはほとんど関係ない。
当然ビデオカードの3D機能なんかはまったく使っていない。
前回書いたような3D機能も全てソフトウェア処理で実現されている。

というわけで、いつも動作環境に「3Dアクセラレーション不用」とチラッと書いていたのだけれど
………お気づきになられただろうか(汗)

ハードウェア処理のリスク

ビデオカードが提供するハードウェアアクセラレーションは非常にパワフルな機能である。
またDirectXが提供する強力なフレームワークもプログラマの手間を大きく軽減してくれる。
3Dはすごく簡単に扱えるし、そして何より圧倒的に高速だ。

ハードウェア処理を利用することには極めて大きなメリットがあるが、同時に大きなリスクも孕んでいる。
ハードウェアの機能を利用するということは、
ハードウェアの機能に依存してしまうということと同義だからだ。

ソフトウェア処理のメリット

ハードウェアに依存しないことには、
ハードウェアの恩恵を受けられないハンデを背負ってでも代えがたい
非常に大きなメリットがある。
わかりやすく一言で言えば

FoooはWindows95でも動く!

たぶんWindowsNT4.0でも動く(笑)
え、何の意味があるのかって?
意味なんかない!

動くことが正義である!

というのは半分冗談で、
あらゆる環境で動作するようにと配慮を重ねた結果だ。
Windows95で動けばどんな環境でも動くだろうということで!

今でこそハードウェアアクセラレーションは気軽に使えるものになっているが
Foooが開発された10年前はそんなに気軽に使えるものではなかった。
各社のビデオカードの機能は様々で、動作も様々。

DirectXがそのあたりの差異を適宜ソフトウェア処理で埋めてくれるという謳い文句だったのだが
期待しているような水準にはまったく達しておらず
半透明で描画するように指定したらメッシュ状態で表示したりと散々だった。
確かに透けてるといえば透けてるけど!
そういった中でハードウェアアクセラレーションを利用することはあまりに無謀だったのである。

ソフトウェア処理の強み

その頃の仕様をいまだに引きずってる意味があるのかと言われるとアレだが…
ソフトウェア処理であるということは環境に依存しないこと以外にもメリットがある。
ハードウェアが提供していない処理も自在に行うことができるという点だ。

例えば、昨今では画像を合成する際に様々な合成方法が使われる。
オーバーレイや覆い焼き、焼きこみなどPhotoshopでおなじみだろう。

しかし意外に思うかもしれないが、現在のハードウェア処理では
そういった合成方法を扱うのは非常に難しい。
ソフトウェア処理ならば、比較的低いコストで導入することができる。
これはソフトウェア処理の大きな強みだ。


流動指向とは

流動指向ゲームエンジン『Fooo』と銘打ってはいるが、
「流動指向」とは完全なる造語である。
では「流動指向」とはいったいどういう意味なのか。

それはFoooが、複数のオブジェクトがまるで浮き流れるかのように
同時平行で動き回ることを前提としたシステムであるということを象徴している。
オブジェクトが同時に動くシステムなんて珍しくないと思うかもしれない。
しかしFoooが実現しているその機構は、おそらく他のシステムとは一線を画している。

時間の概念

Foooスクリプトには
言語レベルで時間の概念がある

他の多くのプログラミング言語でも時間を扱うことはできるが、
それらは時間を参照できるとか、時間を元にイベントを起こせるとかいう形である。
Foooがそれらと決定的に違うのは「プログラムが時間に同期して実行される」という部分だ。
言い換えればFoooスクリプトは「実時間同期型の言語」であると言ってもいい。

時間と同期しながらプログラムが実行されるシステムというと
シューティングゲームやアクションゲームのスクリプトシステムを思い出すかもしれない。
それもそのはず、Foooはもともとシューティングゲーム用に作っていたシステムの設計を
転用して作られているのだ。

シューティングゲームやアクションゲームなどのリアルタイム性を要求されるゲームでは、
時間に同期しながらスクリプトを実行するのが一般的だろう。
しかしFoooにはまたそれらの多くのシステムとはおそらく異なる特徴がある。

細かな時間単位

Foooスクリプトは
時間がミリ秒単位である

古典的なリアルタイムゲームシステムでは、
時間の単位が画面の更新フレーム単位であることが多い。
しかしそのようなシステムでは時間がフレームに拘束されてしまい
演出の速度を細かく変更することが難しいという問題がある。

Foooでは時間の単位をミリ秒とすることでフレームに拘束されない柔軟性の高い構造をとっている。
ゲームの進行速度をプレイヤーが自由に変更できるようにするという要件があったためだ。
これにより速度をシームレスに調節するという機構が実現されている。

正確な時間同期

またさらにFoooには
実時間と正確に同期をとる
という大きな特徴がある。

1000msかかる演出を1回やる処理と、
2msかかる演出を500回やる処理と、
1msかかる演出を1000回やる処理が
ぴったり1000msで同時に終わることが完全に保証されている。

1000msかかる演出を実行してから
500ms後に500msかかる演出を実行して
ぴったり1000msで同時に終わることが完全に保証されている。

何を当たり前なことを!
…と思うかもしれないが、当たり前でないことの方がたぶん多いだろう。

多くのプログラミング言語において正確に同期をとるというのは非常に悩ましい問題だ。
複数のプログラムを同時に実行するスレッドやなどは、
プログラマの間では鬼門とされているくらい扱いの難しいしろものだ。

しかしFoooでは、スレッド(正確にはコルーチンであるが)をいとも簡単に扱うことができる。
複数のスレッド、オブジェクトデーカーなどが同時並行で動く場合でも
時間の上で考えて同時になるはずの部分は、想像通り同時になるように動く。
このように時間で同期がとられることが保証されているので、
複数の部品が同時並行的に動作するスクリプトを極めて直感的に記述することができる。

そんなこんなで

───────などとFoooの特徴についてまとめてみたが…
他のシステムについて別段詳しいわけでもないので、
井の中の蛙が勝手に特徴的だと思いこんでるだけかもしれず…
「似たような有名なシステム既にあるよ!」とかあったら教えてください(苦笑)


3Dで雨を降らす

以前に書いた 「雨を降らす」 の記事では、
2Dで雨を降らせていたが、3Dが扱えるということで今回は3Dで雨を降らせてみよう。

2Dの時は雨粒の大きさで距離感を表現していたが、
3Dでは雨粒を3D空間に配置できるので実際に遠くの位置に配置できる。
雨粒を3D空間に配置できるということは、雨粒を別の角度から見ることもできるということだ!

3D空間の見え方を変えるには、視点と注視点の位置を指定する。
ただしパラメータで指定するのではなく、ちょっと変わった方法で指定する。

具体的には3Dステージデーカーの子として”Camera”という名前のデーカーを作ると
そのデーカーの位置が視点となる。
同様に”Target”という名前のデーカーを作るとそのデーカーの位置が注視点となる。
デーカーの位置を基準とするので、デーカーを動かせば視点を操作できるという仕掛けだ。


class 雨クラス
{
  method 雨クラス()
  {
  }

  method OnEnter()
  {
    int $number = 0;
    while(true)
    {
      string $name = "雨粒"+String($number++);
      ThreadCreate(call=@雨粒スレッド(name=$name));
      wait 10;
    }
  }

  method 雨粒スレッド(string $name)
  {
    float $x = Float(rand_range(-5000,5000));
    float $y = 10000.0;
    float $z = Float(rand_range(-5000,5000));
    int $time = 1000;

    Create3DImage(name=$name
      , x=$x, y=$y, z=$z, rz=-90.0, ox="Center", oy="Middle", sx=500%, sy=500%
      , image="雨粒.png", sampling="BieLinear", back=true);
    Enter(to=$name);

    Move3D(to=$name, time=$time, y=0.0);
    WaitDecor(to=$name);
    Delete(to=$name);
  }
}

method Main()
{
  Create3DStage(name="Stage", w=1280, h=720);
  Create3DModel(name="Stage/空", model="空.lwo", render="Color");
  Create3DObject(name="Stage/雨", class=@雨クラス());
  Create3DNode(name="Stage/Target", y=1000.0);
  Create3DNode(name="Stage/Camera", y=0.0, z=-10.0);
  Enter(to="Stage");
  Enter(to="Stage/*");

 // 視点を移動
  Move3D(to="Stage/Camera", time=10000, y=1000.0, z=-2000.0, step="AccSig");
}
YouTube Preview Image

3Dモデルを表示する

Foooの3D機能は、必要だから作ったというより

3D表示もできたらすごくね!?

くらいの軽いノリで作った。
もはやすごくもなんともないけれど…
昔はちょっとしたことだったのだ。

今でこそ3Dベースのゲームエンジンも珍しくはないが
10年ほど前はまだまだ2Dベース全盛の時代。
3Dといえばトランジッションでソレっぽいことがちょっとできる程度が普通。
自由度の高い3D演出ができれば、頭一つ抜きんでられるのは間違いなかった!

そして実際、3D機能を作った!
が…

3D使う機会がぜんぜんない!

悔しいので、サイコロとか魔方陣とか地球儀とか
こまかーいところで地味~に使っていた(苦笑)

というわけで今回はサイコロを表示してみよう。
サイコロのモデルには3Dソフトで作成したものを使う。
3Dモデルを表示する機能を持つのが、3Dモデルデーカーだ。
Create3DModel命令で作成する。


method Main()
{
  CreateColor(name="Back", w=1280, h=720, color=black);
  Create3DStage(name="Stage", w=1280, h=720);
  Create3DModel(name="Stage/サイコロ", model="サイコロ.lwo", z=400.0);
  Enter(to="Back");
  Enter(to="Stage");
  Enter(to="Stage/サイコロ");
  wait 1000;

  Rotate3D(to="Stage/サイコロ", ry=360.0, time=3000, step="AccSig");
  wait 3000;
  Rotate3D(to="Stage/サイコロ", ry=0.0, time=0);
  Rotate3D(to="Stage/サイコロ", rx=360.0, time=3000, step="AccSig");
  wait 3000;
  Rotate3D(to="Stage/サイコロ", rz=360.0, time=3000, step="AccSig");
  wait 3000;
}
YouTube Preview Image

うん、3Dは楽しい。


3Dで画像を表示する

Foooでは3Dを扱うこともできる。
とはいえFoooは基本的に2Dで処理を行っているので、3Dの扱いは少々特別だ。

3Dを扱うためには、3Dの空間を持つ『3Dステージ』を作る必要がある。
Foooではデーカーが配置される空間のことを『ステージ』と呼ぶ。
普段デーカーを配置しているステージは、2Dの空間である。
2Dの空間上では3Dを扱うことができない。
このためまず、3Dの空間を用意する必要があるのである。

なんともややこしそうな話だが、やることは簡単だ。
3Dステージデーカーを作るだけである。


method Main()
{
  Create3DStage(name="Stage", w=1280, h=720);
  Enter(to="Stage");
}

この3Dステージデーカーの子としてデーカーを作成すると、
そのデーカーは3Dステージデーカーが管理する3D空間内に配置される。

3D空間に配置できるデーカーは3D用の特別なものである。
3Dで画像を表示するには、3Dイメージデーカーを使用する。
3Dイメージデーカーを作成するにはCreate3DImage命令を使用する。


method Main()
{
  Create3DStage(name="Stage", w=1280, h=720);
  Create3DImage(name="Stage/画像", image="画像.png", z=800.0);
  Enter(to="Stage");
  Enter(to="Stage/画像");
}

3D用のデーカーを操作する命令も3D用に特殊なものを使う。
3Dのカードを作成して回転させてみよう。


method Main()
{
  Create3DStage(name="Stage", w=1280, h=720);
  Create3DNode(name="Stage/カード", z=800.0);
  Create3DImage(name="Stage/カード/表", image="表.png");
  Create3DImage(name="Stage/カード/裏", image="裏.png", ry=180.0);
  Enter(to="Stage");
  Enter(to="Stage/カード");
  Enter(to="Stage/カード/*");
  wait 1000;
  Rotate3D(to="Stage/カード", ry=360.0, time=3000);
}
YouTube Preview Image

3D用の命令を使ってはいるが、このように2Dの場合とほとんど同じ感覚で
3Dを制御するスクリプトを書くことができる。


路地裏さつき[ヒロイン十二宮編]

Posted 2013.04.01 in 雑記

TYPE-MOONの2013年エイプリルフール企画!
路地裏さつき[ヒロイン十二宮編]
http://www.typemoon.com/

プレイするとわかりますが…

戦闘パートの演出&スクリプトを担当しています。

っていうか…現在TYPE-MOONで働いてますハイ。

エンジンにはノベルスフィアさんのO2 Engineを使用。
吉里吉里のKAG3互換のゲームエンジンです。
http://novelsphere.jp/

iPhoneでもプレイできます。
3G回線だとロードきついですががが。
是非プレイしてやってください!
(追記:公開終了しました)