モバイルのもと

Threadを使ったメインループの設計

Threadの基本

ゲームを作成する場合、大抵Threadを利用すると思います。一般的な構成は以下の様になるかと思います。

ここでGameCanvasにRunnableを実装(Implements)してThreadをスタートさせる場合について少し考察します。
Runnalbeを実装した一番小さな実装は以下の様になります。

import javax.microedition.lcdui.*;
import javax.microedition.lcdui.game.*;

class mycanvas extends GameCanvas implements Runnable {
  /* ----------------------------------------------------------------------*/
  mycanvas() {
    super(false);	// キーイベントを全て取得する
  }
  
  /* paint()関数は不要になるので消しておきます。
    public void paint( Graphics g ) {
  }
  */
  
  public void run() {
  }
}

mycanvasのコンストラクタにあるsuper(false)はキーイベントを全てGameCanvasへ送出するために呼び出します。

次にこのスレッドを開始するには、MIDletクラス側でThreadインスタンスを作成し、start()を実行します。

public final class applet extends MIDlet implements CommandListener {

  private static Command cmdExit = new Command("Exit", Command.EXIT,1);
  private static Display display;
  private static Thread thread;

  /* ----------------------------------------------------------------------*/
  public applet() {
    display = Display.getDisplay(this);
    mycanvas canvas = new mycanvas();

    canvas.addCommand(cmdExit);
    canvas.setCommandListener(this);
    display.setCurrent( canvas );
    
    try {
      thread = new Thread(canvas);
      thread.start();                  // スレッドの開始。run()関数が実行される。
    } catch (Exception e) {
      System.out.println( e.toString() );
    }

  }
}

ゲームアプリでは、Threadを利用してrun()関数でメインループをwhile(true)でぐるぐる回す実装を行います。

  public void run()
  {
    // メインループ
    while(true) {
      // 画目の消去
      g.setColor( 255, 255, 255 );
      g.fillRect( 0, 0, getWidth(), getHeight() );

      // 位置を変更
      posx++;

      // 四角を描画 
      g.setColor( 128,128,128 );
      g.drawRect( posx, 0, 10,10 );
      
      if( posx > getWidth() ) {
        // 画面の端まで行ったらリセット
        posx = 0;
      }
      flushGraphics();
    }
  }

これで画面の一番上を永遠に移動する四角が描画されるものが出来ました。

run()内部の例外キャッチ

普通run()関数の中はtry〜catchでくくっておき、例外がスローされた場合それをユーザーに告知する方法を用意すべきです。(まぁデバッグ用です)

  public void run()
  {
    // メインループ
    while(true) {
      try {
        // ...
 
      } catch( Exception e ) {
        System.out.println( "exception at run(): "+e.toString() );
        g.setColor( 255, 255, 255 );
        g.drawString( "exception at run(): "+e.toString(), 0, 0, Graphics.LEFT|Graphics.TOP);
        flushGraphics();
        break;  // loopの終了
      }
    }
  }

一定速度での移動

このままではプラットフォームの性能ギリギリで高速動作してしまいます。でも実際には以下の図の様に1秒間で10ドット等の移動をさせたい場合があります。

水平なら10ドット、斜め45°ならX,Y共に7ドット、といった具合です。
一番シンプルにやってしまいますと、先ほどのコードに一行付け加えるだけで出来ます。

  public void run() {
    // メインループ
    while(true) {
      try {
        g.setColor( 255, 255, 255 );
        g.fillRect( 0, 0, getWidth(), getHeight() );
        posx++;
        g.setColor( 128,128,128 );
        g.drawRect( posx, 0, 10,10 );
        if( posx > getWidth() ) {
          posx = 0;
        }
        flushGraphics();
        Thread.sleep(100);         // 0.1秒スリープ 
      } catch( Exception e ) {
        // ...
      }
    }
  }

これで0.1秒毎に1ドットづつ右へ動作しますので、1秒間に10ドットの移動が実現しました。

この時、考え方がいくつかあります。ここでは簡易的に「ベストエフォート方式」「定間隔方式」と呼んでみます。

定間隔方式

次に10ドット/秒のオブジェクトと20ドット/秒のオブジェクトを描画してみます。

  public void run() {
    int posx1 = 0;
    int posx2 = 0;
    // メインループ
    while(true) {
      try {
        // 画目の消去
        g.setColor( 255, 255, 255 );
        g.fillRect( 0, 0, getWidth(), getHeight() );

        // 10ドット/秒のオブジェクト
        posx1 += 1;
        g.setColor( 128,128,128 );
        g.drawRect( posx1, 0, 10,10 );

        if( posx1 > getWidth() ) {
          posx1 = 0;
        }
        
        // 20ドット/秒のオブジェクト
        posx2 += 2;
        g.setColor( 128,128,128 );
        g.drawRect( posx2, 0, 10,10 );
        
        if( posx2 > getWidth() ) {
          posx2 = 0;
        }
        flushGraphics();
        Thread.sleep(100);
      } catch( Exception e ) {
      }
    }
  }

二つ目のオブジェクトが2ドットづつ動作するので少々カクカク感がありかっこ悪いのですが、基本はこの通りです。

上の例ではメインループ処理がとても軽いので問題にはなりませんが、本格的にコードを書き始めるともっとメインループの処理が重くなってきます。例えば20msecとか30msecとか。例えば正確に1秒ループをまわすにはそれを考慮したコードを書く必要があります。

図に示すと上記の様になります。コードで表すと以下の様になるかと思います。

  static final int MAIN_INTERVAL = 100;  // 100msec間隔でまわす

  public void run()
  {
    long lBeforeTime, lSleepTime = 0;
    int posx1 = 0;
    // メインループ
    while(true) {
      try {
        // 現在時間(Loopの開始時間)を保存
        lBeforeTime = System.currentTimeMillis();
        
        // 画目の消去
        g.setColor( 255, 255, 255 );
        g.fillRect( 0, 0, getWidth(), getHeight() );

        posx1 += 1;
        g.setColor( 128,128,128 );
        g.drawRect( posx1, 0, 10,10 );

        if( posx1 > getWidth() ) {
          posx1 = 0;
        }
        
        // ちょっと分かりやすくスリープ時間を描画してみる
        g.drawString( "Sleep:"+lSleepTime, 0, 20, Graphics.LEFT|Graphics.TOP);
        flushGraphics();

        // スリープ時間の算出
        lSleepTime = (MAIN_INTERVAL - (System.currentTimeMillis() - lBeforeTime));

        // もしループ間隔を超えていたらスリープしない
        if( lSleepTime > 0 ) {
          Thread.sleep(lSleepTime);
        }
      } catch( Exception e ) {
        // ...
      }
    }
  }

実際に実行したイメージは以下の様になります。処理が軽すぎて100msecスリープしてますが。

ベストエフォート方式

定間隔処理ではある一定の間隔での処理を行うため、色々と便利なことがありますが、問題もあります。メリットデメリットは後述しますが、まずはベストエフォート方式について説明します。

Sleepはせずに、前回の処理に掛かった時間分だけ次の処理を進めます。

例えば処理に20msec掛かったとすれば、次は20msec後の位置を計算してオブジェクトを移動させれば良いことになります。但しこのままだと、あまりにも移動量が微細すぎて、一回のループでの移動量が1ドット以下になってしまいます。例えば先ほどの例で、20ドット/秒の移動を行い、20msecでループしてしまった場合、以下の様になってしまいます。

そこで、オブジェクトの座標を100倍の分解能にして対処します。(floatを使うと遅いのでlongを使う)実際に描画する際に100分の1にしてやります。実際のコードは以下の様な感じになります。

  public void run()
  {
    
    long lBeforeTime, lDiffTime = 0;
    long posx1 = 0;
    // メインループ
    while(true) {
      try {
        // 現在時間(Loopの開始時間)を保存
        lBeforeTime = System.currentTimeMillis();
        
        // 画目の消去
        g.setColor( 255, 255, 255 );
        g.fillRect( 0, 0, getWidth(), getHeight() );

        // 移動量 * 分解能 * ループ処理時間(msec) / 1秒(msec)
        // 先に掛け算しないと0になってしまうかも知れないので順番に注意
          posx1 += 10 * 100 * lDiffTime / 1000;

        g.setColor( 128,128,128 );
        g.drawRect( (int)(posx1 / 100), 0, 10,10 );

        // 現在位置(*100)を表示して確認する
        g.drawString( "posx1:"+posx1, 0, 20, Graphics.LEFT|Graphics.TOP);

        if( posx1 > getWidth() * 100 ) {
          posx1 = 0;
        }
        
        flushGraphics();

        // 今回ループ処理に掛かった時間を算出する。
        lDiffTime = System.currentTimeMillis() - lBeforeTime;
        if( lDiffTime > MAX_THREAD_INTERVAL ) {
          lDiffTime = MAX_THREAD_INTERVAL;
        }
        // もしループ時間が0だった場合何も起こらなくなってしまうので1とする
          else if( 0 == lDiffTime ) {
          lDiffTime = 1;
        }
        
      } catch( Exception e ) {
        // ...
      }
    }
  }

実際に実行したイメージはこんな感じになります。

 

両者の違い

今、二つのメインループ処理について説明しましたが、両者のメリットデメリットについて考察してみます。

まず、定間隔処理の方が実装自体はシンプルです。
常に一定時間で各オブジェクトが動作するため、実際にやってみて見た感じでゲームループを調整できます。キー入力処理もgetKeyState()を使ってメインループ内でやってしまえば、一定間隔でのキー処理が実装できます。ベストエフォート式ですと、早い端末だとメインループがどんどん処理されて、例えば1秒間にキー入力が100回処理されますが、遅い端末だと70回しか処理されないかもしれません。遅い端末だと車の方向を変えるのが遅かったりしてしまいます。(もちろん回避する方法がありますが)
そして何より、Sleep()によってCPUに余裕が出来るので、キー入力の反応が良いです。

ベストエフォート式は実装は複雑になりますが、早い端末だとオブジェクトの動作がスムーズになり、遅い端末だとそれなりに動作する、といったメリットがあります。つまり、端末速度を気にしなくても良いことになります。定間隔処理では、早い端末でも遅い端末でも一定間隔で動作しますので、速い端末を使うメリットがありません。
但し全てのオブジェクトに対し、「ループ毎にxドット」と定めるシンプルな定間隔処理よりも、「xドット/秒」といった設定を行い、今回ループ処理では何ドット移動させるかを計算しなくてはなりません。

個人的なお勧めはベストエフォート式になります。実装の複雑さは移動オブジェクトのクラス内部で一度処理してしまえば問題ありません。
良く見かけるのが、遅い端末用に定間隔処理を行っており、最近の端末でも動作がカクカクしているものを見かけます。一度ベストエフォート式で作ってしまえば、端末速度をあまり考慮せず、その時そのときのまさにベストエフォートで動作してくれます。

最期に注意しておきたいのは、ベストエフォート式は「消費電力が大きい」という問題です。ベストエフォートは端末が許す限り全力でループしますので、CPUパワーを常に100%使います。

★注意
ベストエフォート式でもやはりSleep()処理は10msec程度入れておいた方が良いです。これによってキーの反応が改善します。

スレッドの終了

ちょっと閑話休題して、スレッドの終了について考えて見ます。SDKで開発しているとなぜかアプリが終了してもスレッドが回り続けてしまいます。これが少しうっとおしいので、MIDletクラスが終了する際にスレッドも終了してやることにします。

import javax.microedition.midlet.*;
import javax.microedition.lcdui.*;
import javax.microedition.lcdui.game.*;

public final class applet extends MIDlet implements CommandListener {

  private static Command cmdExit = new Command("Exit", Command.EXIT,1);
  private static Display display;
  private static Thread thread;
  public static boolean bThreadLoop;

  /* ----------------------------------------------------------------------*/
  public applet() {
    display = Display.getDisplay(this);
    mycanvas canvas = new mycanvas();

    canvas.addCommand(cmdExit);
    canvas.setCommandListener(this);
    display.setCurrent( canvas );
    
    try {
      // スレッドの開始
      thread = new Thread(canvas);
      bThreadLoop = true;
      thread.start();
    } catch (Exception e) {
      System.out.println( e.toString() );
    }

  }
  
  /* ----------------------------------------------------------------------*/
  public void startApp() {}
  public void pauseApp() {}

  /* ----------------------------------------------------------------------*/
  public void destroyApp( boolean flag ) {
    try {
      bThreadLoop = false;
      // スレッドの終了を待つ
      thread.join();
      // 参照を0にする
      thread = null;
    } catch( Exception e ) {}
  }

  /* ----------------------------------------------------------------------*/
  public void commandAction(Command c, Displayable d) {
    try {
      destroyApp(true);
      notifyDestroyed();
    } catch ( Exception e) {}
  }
}


class mycanvas extends GameCanvas implements Runnable {
  static final int MAX_THREAD_INTERVAL = 100;
  Graphics g;
  /* ----------------------------------------------------------------------*/
  mycanvas() {
    super(false);
    g = getGraphics();
  }
  
  /* ----------------------------------------------------------------------*/
  public void run()
  {
    // メインループ
    while(applet.bThreadLoop) {
      try {
        // ... 各種処理 ...
        }
      } catch( Exception e ) {
        // ...
      }
    }
  }
}

最期にベストエフォート式のサンプルコードを置いておきます。(thread.zip)
Sleep()処理は入れてません。


モバイルの素へ戻る MIDP Scrollトップへ戻る


Google
MIDP2.0 CLDC1.1 JSR WWW

Copyright 2007 Mobile no THU