第6回 「複数の目標の扱い」 | エドさんのAndroid AR講座

第6回 「複数の目標の扱い」


前回のアニメーションのアプリを拡張して、今回は複数のターゲットを同時に攻撃できるようにします。修正点は次の 3 つです:


 (1) ターゲットに ID を付与し、複数のターゲットを互いに区別可能にする。
 (2) 複数のターゲットを指定できる U/I を実装する。
 (3) 複数のターゲットのそれぞれに対するライフサイクルを描画する。


まず (1) の ID の付与に関して、ターゲットクラスの変更を見てみましょう。


Target.java

package com.artiscc.aimnshoot;

import java.util.Timer;
. . .

class Target
{
private AnimationTimerTask atTask = null;
private Timer at = null;
private Context main = null;
public int id;
public int x;
public int y;
public int tick;
public Target(Context ctx, int id, int x, int y)
{
this.main = ctx;
this.id = id;
this.x = x;
this.y = y;
this.tick = -1;
}
public void setPosition(int x, int y)
{
this.x = x;
this.y = y;
}
public void startTimer()
{
this.tick = 0;
this.atTask = new AnimationTimerTask(this.id);
this.at = new Timer(true);
this.at.schedule(atTask, 50L, 50L);
}
public void cancelTimer()
{
if(this.at != null){
this.at.cancel();
this.at = null;
}
}

class AnimationTimerTask extends TimerTask
{
private int tarId;
public AnimationTimerTask(int id)
{
this.tarId = id;
}
@Override
public void run()
{
((AimnShoot)main).tickTimer(this.tarId);
}
}
}


まず、クラス変数に id を追加し、コンストラクタの中で設定しています。ID の数値はメインの中で決定され、コンストラクタに渡されるものとします。また、startTimer() の中では、この id をタイマータスクに渡しています。これにより、複数のターゲットごとに別々のタイマーが生成され、発火時には id で区別されてタイマータスクが呼び出されることになります。


タイマータスクの中では、コンストラクタの中で受け取った id をクラス変数に設定しています。これは、タイマー発火時に呼ばれる run() の中で、メインの tickTimer() の呼び出し時に引数として与えられます。ターゲットクラスの変更点は以上です。


次にメインのアクティビティをパートごとに見ていきましょう。まず、ジェスチャ認識部です。


AimnShoot.java (1)

   /******************************************************************************
* Gesture Detection
******************************************************************************/
...
class OnGestureListener extends GestureDetector.SimpleOnGestureListener
{
@Override
public boolean onSingleTapUp(MotionEvent ev)
{
screenTapped((int)ev.getX(), (int)ev.getY());
return super.onSingleTapUp(ev);
}
@Override
public void onLongPress(MotionEvent ev)
{
addTarget((int)ev.getX(), (int)ev.getY());
super.onLongPress(ev);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
{
clearAllTargets();
return super.onFling(e1, e2, velocityX, velocityY);
}
}


ここでは、次の 3 つの U/I 操作を定義します。

 ・シングルタップ
座標を引数として screenTapped() が呼び出されます。この関数は、タップされた位置により別々の機能を持ちます。まず、現在、ターゲットが設定されているとき、画面右下隅のミサイル発射ボタンを押すと、すべてのターゲットに向かって同時にミサイルが発射されます。ターゲットが設定されていないときは、前回と同様に、タップした位置をターゲットとして 1 発のミサイルが発射されます。
 ・ホールド
座標を引数として addTarget() が呼び出されます。これにより、座標位置に新しいターゲットが追加して設定されます。
 ・フリック
clearAllTargets() が呼び出されます。これにより、現在設定されているターゲットがすべてクリアされます。


したがって、ミサイルを単発で撃つ場合には従来どおり画面をタップすればよく、複数のターゲットを設定する場合には、画面の長押しによりターゲットを 1 つずつ設定して、最後に発射ボタンを押せばよいということになります。また、ターゲットを 1 つずつ削除することはできず、画面のフリックによりまとめて削除する機能だけが用意されています。


次にターゲットの処理を見ていきましょう。


AimnShoot.java (2)

package com.artiscc.aimnshoot;
...

public class AimnShoot extends Activity
{
...
@Override
public void onCreate(Bundle savedInstanceState)
{
...
mTarList = new HashMap<Integer, Target>();
}

/******************************************************************************
* Operation on Targets
******************************************************************************/
public HashMap<Integer, Target> mTarList = null;
private int mTarId = 0;
public boolean isWaitingForAttack = false;
private void screenTapped(int x, int y)
{
int scrWidth = mCamView.getWidth();
int scrHeight = mCamView.getHeight();
if(y < scrHeight - 88){
if(! isWaitingForAttack){
attackTarget(x, y);
}
}else if((x > scrWidth - 145) && (y > scrHeight - 88)){
if(isWaitingForAttack){
attackAllTargets();
isWaitingForAttack = false;
}
}
}
private void attackTarget(int x, int y)
{
Target tar = addTarget(x, y);
isWaitingForAttack = false;
tar.startTimer();
}
private void attackAllTargets()
{
for(Target tar: mTarList.values()){
tar.startTimer();
}
mARView.postInvalidate();
}
private Target addTarget(int x, int y)
{
if(y < mCamView.getHeight() - 88){
Target tar = new Target(this, mTarId, x, y);
mTarList.put(mTarId, tar);
mTarId++;
mARView.postInvalidate();
isWaitingForAttack = true;
return tar;
}else{
return null;
}
}
public void clearTarget(int tarId)
{
mTarList.get(tarId).cancelTimer();
mTarList.remove(tarId);
mARView.postInvalidate();
}
public void clearAllTargets()
{
if(isWaitingForAttack){
mTarList.clear();
}
isWaitingForAttack = false;
mARView.postInvalidate();
}
private void showNextFrame(int tarId)
{
Target tar = mTarList.get(tarId);
if(tar != null){
tar.tick++;
}
mARView.postInvalidate();
}


まず、前回の target1 に代わるものとして、HashMap<Integer, Target> mTarList を宣言します。これは複数のターゲットを ID をキーとして保持するもので、onCreate() の中で初期化されます。なお、各ターゲットはキーとなる ID を自分の属性値として持っていますから、どちらからでも参照できることになります。


mTarId は、次に追加するターゲットの ID となる整数値を保持する変数で、初期値は 0 としておきます。


isWaitingForAttack は、現在ターゲットを設定中か否かを示すフラグです。初期値は false で、1 個目のターゲットが設定されると true になり、発射ボタンが押されると false に戻ります。


ターゲットに対する関数には、次の 7 つがあります。まず screenTapped() は、上述のように画面がタップされたときに呼ばれる関数ですが、画面下辺の 「ボタン領域 (画面下辺から 88 ピクセル以内)」 を除いた領域がタップされ、かつ、そのときターゲット設定中でなければ (isWaitingForAttack が false)、タップされた場所をターゲットとして attackTarget() を呼び出します。一方、タップされた場所がボタン領域の右端であり、かつ、そのときターゲット設定中であれば、発射ボタンが押されたものとみなし、attackAllTargets() を呼び出してすべてのターゲットにミサイルを発射します。


attackTarget() は単発でミサイルを発射するもので、addTarget() により指定された地点にターゲットを設定し、すぐにタイマーをスタートさせてミサイルのアニメーションを開始します。


attackAllTargets() は現在設定されているすべてのターゲットにミサイルを同時に発射するもので、mTarList 中の全ターゲットに対してタイマーをスタートさせます。


addTarget() では、新規にターゲットを生成してそれを mTarList に登録します。ターゲット生成時に mTarId で ID を指定していることに注意してください。


clearTarget() は ID をキーとして呼び出されるので、その ID を持つターゲットのタイマーを削除し、次に mTarList からそのターゲットを削除します。


clearAllTargets() では、現在ターゲット設定中であれば、mTarList からすべてのターゲットを削除します。


最後の showNextFrame() は、ターゲットのタイマーが発火したときに呼び出される関数ですが、前回の形から引数に ID が追加されていることに注意してください。中では、指定された ID を持つターゲットの内部時計を 1 刻み進めます。


AimnShoot の最後にタイマータスクとハンドラの変更点を調べます。


AimnShoot.java (3)

    /******************************************************************************
* Message Handler
******************************************************************************/
protected static final int MSG_TIMER_TICKED = 1;
Handler mHandler = new Handler()
{
@Override
public void handleMessage(Message msg)
{
if(isFinishing()){
return;
}
switch(msg.what){
case MSG_TIMER_TICKED:
showNextFrame(msg.arg1);
break;
}
}
};

/******************************************************************************
* Timer Task
******************************************************************************/
public void tickTimer(int tarId)
{
mHandler.sendMessage(mHandler.obtainMessage(MSG_TIMER_TICKED, tarId, 0));
}


Target.java で見たように、tickTimer() にはターゲットの ID が引数として追加されました。このとき、引数付きのメッセージを作成し、それをハンドラに送るために、上記のようなコードを使います。


ハンドラ側では、msg.what に MSG_TIMER_TICKED が入ってきたときに、msg.arg1 によって 1 つ目の引数を取り出し、それを showNextFrame() に送っています。


さて、全体の最後に描画クラスの変更点を見てみます。変わったのは onDraw() の部分だけです。


ARView.java

    ...
private boolean isDrawing = false;
...
@Override
public void onDraw(Canvas canvas)
{
if((main.mTarList == null) || isDrawing){
return;
}
isDrawing = true;
if(main.isWaitingForAttack && (bmpBtnFire != null)){
canvas.drawBitmap(bmpBtnFire, getWidth()-135, getHeight()-78, null);
}
ArrayList<Integer> remList = new ArrayList<Integer>();
for(Target tar: main.mTarList.values()){
int tracking = 20 - (int)(10f * ((float)tar.y/(float)getHeight()));
if(tar.tick < tracking){
if(bmpReticleOn != null){
canvas.drawBitmap(bmpReticleOn, tar.x - bmpReticleOn.getWidth()/2, tar.y - bmpReticleOn.getHeight()/2, null);
}
if(tar.tick > -1){
int launcherX = getWidth() / 2;
int launcherY = getHeight();
int bombX = launcherX + tar.tick * (tar.x - launcherX) / tracking;
int bombY = launcherY + tar.tick * (tar.y - launcherY) / tracking;
if(bmpBomb != null){
canvas.drawBitmap(bmpBomb, bombX - bmpBomb.getWidth()/2, bombY - bmpBomb.getHeight()/2, null);
}
}
}else if(tar.tick < tracking + NUM_EXP_FRAMES*2){
int tick = (tar.tick - tracking) / 2;
if(bmpExp[tick] != null){
canvas.drawBitmap(bmpExp[tick], tar.x - bmpExp[tick].getWidth()/2, tar.y - bmpExp[tick].getHeight()/2, null);
}
}else if(tar.tick < tracking + NUM_EXP_FRAMES*2 + NUM_FADING_FRAMES*2){
int tick = (tar.tick - tracking - NUM_EXP_FRAMES*2) / 2;
int lastFrame = NUM_EXP_FRAMES - 1;
canvas.drawBitmap(bmpExp[lastFrame], tar.x - bmpExp[lastFrame].getWidth()/2, tar.y - bmpExp[lastFrame].getHeight()/2, trans[tick]);
}else{
remList.add(tar.id);
}
}
for(int tarId: remList){
main.clearTarget(tarId);
}
isDrawing = false;
}
...


まず、isDrawing という変数を用意して描画中の状態を保持し、描画中は次の描画要求を受け付けないようにしています。


次に、ターゲット設定中の場合には、画面右下隅にミサイル発射ボタンを表示します。これは bmpBtnFire というビットマップとして、例によって prepareImages() の中でリソースから読み込みます。


次に、

        for(Target tar: main.mTarList.values()){
...
}


というループで、メインの mTarList に登録されているすべてのターゲットに対する描画を実行します。一つ一つのターゲットに対する描画処理は前回の内容とほとんど同じですが、変更した部分について以下に説明します。


まず、前回は NUM_TRACKING_FRAMES という定数でミサイル飛行中のアニメーションのフレーム数を固定していましたが、ターゲットが複数になると、同時に発射されたミサイルが同時に爆発するのでは現実味に欠け、また近いターゲットにはゆっくりと、遠いターゲットには速く進むのも不自然なので、飛行中のフレーム数をターゲットの位置によって変えるようにしました。それが tracking という変数で、10~20 の適当な値を取るようになっています。


次に、ターゲットのライフサイクルの判定を少々変更して、tar.tick < tracking + NUM_EXP_FRAMES*2 のような条件にしました。また、その中では内部時計の刻みを tick = (tar.tick - tracking) / 2 によって計算しています。これは、爆発のアニメーションにおいては、ターゲットの内部時計の 2 刻みごとにフレームを 1 だけ進めることを表しています。フェードアウトの場合も同様です。これにより、ミサイルの飛行の場合と比較すると、爆発やフェードアウトのアニメーションにはスローモーションがかかるようになります。


ターゲットの消滅に関しては、ループの中で直接ターゲットを削除することはせず、ライフサイクルの終了を迎えたターゲットの ID を ArrayList<Integer> remList というリストに保存し、ループを出たところでまとめて削除していることに注意してください。


最後に、ミサイル発射ボタンの画像例を出しておきます。これは prepareImages() の中で他の画像とともに読み込みます。


$エドさんのAndroid AR講座-btnfire.png
btnfire.png



第1部 ARアプリ作成の基本

第1回: カメラ画面の表示
    画面上にカメラのプレビュー画像を表示し続けるだけのアプリで、ARの基本中の基本です。
第2回: AR画面の表示
    カメラ画面の上にAR画面を載せ、カメラ画面上にいろいろな画像を重ね合わせます。
第3回: ジェスチャの認識
    タップ、ダブルタップ、長押し、フリックなどの画面操作を認識し、対応する処理を行います。
第4回: アニメーションの表示
    タイマーを用いてミサイルが目標に向かって飛んでいく単純なアニメーションを表示します。
第5回: 複雑なアニメーションの表示
    ミサイルが当たった後の爆発や残像の表示など、複雑なアニメーションを表示します。
第6回: 複数の目標の扱い
    同時に複数個の目標を設定し、それぞれをミサイルで撃破する枠組みを導入します。
第7回: ズーム機能の導入
    ズームボタンを設置し、カメラ画面をズームして表示する機構を手作りで実装します。
第8回: ネイティブコードの利用
    ズーム機構を高速化するため、C、C++ を使ったネイティブコードを導入します。

第2部 ARアプリ作成の応用

近日アップ予定! 乞う御期待!