2011年8月25日木曜日

とりあえずゲーム作り(8)

けっこうゲームっぽくなってきたので、実機(novo8)で動かしてみました。

開始画面は、デカい画面だと間抜けさが強調されますね…



ゲーム画面です。



一応、普通にプレイできますね。ただ、操作に敏感に反応しすぎて、ゲームが難しくなっています。一回タッチすると、複数回ぶん反映されているような感じ?

横画面でもプレイできました。予想通り、デブったAndroid君ですね。


ちょっと気になっていた縦横の切り替えですが、プレイ中に切り替わると、ゲームがリセットされるようです。プレイするには自動切換えをOFFにする必要ありそうです。

さて、それぞれ対応していきましょう。

まずは画面の向き(ScreenOrientation)ですが、Android Wiki に良い情報がありました。AndroidManifest.xml内でActivityに

android:screenOrientation="nosensor"

属性を追加すれば、センサーの影響を受けない模様。エミュレーターの Ctrl+F11 切り替えはセンサーとは関係ないので、これは実機でテストしないと確認できないですね。

続いて操作に敏感すぎる点ですが、これはイベント内容を確認していないのが理由。タッチだけじゃなくて、移動とか、指を離したときのアクションまで操作として受け取ってしまっています。

具体的にはonTouchEvent()中で、event.getAction() == MotionEvent.ACTION_DOWN の時だけ、操作として受け入れればOKかな?

ただせっかくのタッチパネルですので、スワイプも入力として受け付けたいですよね。これもあわせて実装してみましょう。down_x, down_yというメンバ変数を追加して、onTouchEvent()中を以下のように書き換えます。

if (event.getAction() == MotionEvent.ACTION_DOWN) {
  down_x = (int)event.getX();
  down_y = (int)event.getY();
  int x = ship.cx + ship.cw / 2;
  int y = ship.cy + ship.ch / 2;
  ship.accelerate((down_x - x) * s.w(0.0001F), (down_y - y) * s.h(0.0001F));
} if (event.getAction() == MotionEvent.ACTION_UP) {
  ship.accelerate((int)(event.getX() - down_x) * s.w(0.0001F), (int)(event.getY() - down_y) * s.h(0.0001F));
}
return true;

タッチしたときに自機からの距離で加速する部分は変わりません。タッチしたときの位置を記録するようにしておき、離したときにはそこからの距離も加速として加えるわけですね。

さーて、実機で試してみましょう。うん、ちゃんとセンサーに反応しないようになりました。ただ横向きで開始すると縦画面になるので、nocenser ではなく portrait の動作になってしまっている気も…

操作も普通にできるようになりました。スワイプも効くようになったので、緊急回避時の機動性がアップした気がします。

とりあえずapk置いておくので、興味ある方は自己責任で動かしてみてください。実機でAPKファイルを保存し、パッケージツールでインストールすれば動くとおもいます。

ここから ⇒ http://rinco.jp/blog/novo8/

メモ: .httaccess に AddType application/vnd.android.package-archive apk を追加

2011年8月11日木曜日

とりあえずゲーム作り(7)

さあ、またゲームが動くようになりました。

なのでまたまた、好きなように改造していきます。結果、コードが汚くなってきたら、また書き直しゃいーんです、的な気楽さでいきましょう。

さて、このあいだ岩を「赤い四角」から適当に「赤い石の写真」に置き換えただけで、雰囲気はえらく変わってしまいました。やっぱ見た目は大事ですねぇ、ということで。

PUBLIC-DOMAIN-PHOTOS.com さんのところで見つけた宇宙の画像を、背景に貼ってみました。


うん、更に宇宙旅行っぽくなりましたかね。コードはベタですが、まずGameViewに以下の変数を宣言しておき、

  Bitmap wall;
  Rect wall_f, wall_t;

初期化のところに以下のコードを追加して背景画像を準備し、

  wall = BitmapFactory.decodeResource(context.getResources(), R.drawable.galaxy);
  wall_f = new Rect(0, 0, wall.getWidth(), wall.getHeight());
  wall_t = new Rect(0, 0, s.cw, s.ch);

最後にdoDraw()で canvas.drawColor(Color.BLACK); の代わりに以下で背景をクリアするようにします。

  canvas.drawBitmap(wall, wall_f, wall_t, null);

もう少しマシな方法もある気はしますが、とりあえず簡単に実現できたのでコレで良しとしましょう。

次はゲーム性の向上を考えてみましょう。このゲームでは岩を避けるのがゲームの根っこですが、岩の出現のしかたが親切ではない気がします。いきなり出てきて、避けられないことがある。

そこで出現直後の一定時間は、自機との当たり判定が無いという仕様はどうでしょうか。更にそれがわかるように、出現時には半透明で、だんだんとはっきり見えてくる感じの表現が良いですね。半透明でなくなった時から当たり判定が発生する、であればプレイヤーに十分な情報を伝えることができます。

これを実現するために、GameBitmapObject を継承した Rock クラスを新しく作成してみましょう。

public class Rock extends GameBitmapObject {
  int count;
  public Rock(int _w, int _h, Bitmap b) {
    super(_w, _h, b);
    count = 0;
  }
}

動いた回数を記録する count変数を追加してみました。で、実際に動くmove()関数を上書きして、カウントアップしてあげないといけないですね。

  @Override public GameObject move() {
    super.move();
    count++;
    return this;
  }

で、最初の2秒ほどは衝突判定はないことにします。よって以下のように上書きしてみました。

  @Override public Boolean collision(GameObject o) {
    return count < 20 ? false : super.collision(o);
  }

あと、衝突判定がない間は、半透明表示でしたよね。なので以下のように上書きしています。

  @Override public GameObject draw(Canvas c) {
    if (count < 20) {
      Paint p = new Paint();
      p.setAlpha(count * 10);
      c.drawBitmap(bitmap, br, new Rect(cx, cy, cx + cw, cy + ch), p);
    } else {
      super.draw(c);
    }
    return this;
  }

それぞれ、countの値を条件として参照していて、条件によって動作を乗っ取る仕組みの上書き(Overrode)ですね。条件外のときには素直にsuper呼ぶのがポイントです。

透明度を count * 20 としているのは乱暴に見えますが、半透明の状態とふだんの表示(α値が255)は少しギャップがあったほうが、プレイヤーにわかりやすいと思って、この式にしています。

さて、この新しいクラス Rock を GameActivity に組み込みましょう。岩関係でクラス指定をしている、以下の二箇所を書き換えます。

List<GameBitmapObject> rocks = new ArrayList<GameBitmapObject>();

List<Rock> rocks = new ArrayList<Rock>();
 
GameBitmapObject o = new GameBitmapObject(s.w(0.01F * s.i(10, 5)), s.h(0.01F * s.i(10, 5)), b);
Rock o = new Rock(s.w(0.01F * s.i(10, 5)), s.h(0.01F * s.i(10, 5)), b);

あ、衝突判定の修正を生かすには、以下も忘れちゃ駄目ですね。

if (ship.collision(rock)) {
if (rock.collision(ship)) {

よし、これで大丈夫。プレイしてみましょう。


うん、これは良い。

新しい岩がじわじわ現れ、その間は衝突判定がないので、不条理な衝突が発生しなくなりました。運より実力が生かされるように調整されたと言えます。

修正点は少ないですが、満足してしまいましたので今日はこれで終了。暑くて汗だらだらなので、ガリガリ君でも食べてのんびりしますw

2011年8月10日水曜日

とりあえずゲーム作り(6)

さて、引き続きゲームのコード再構築です。今回はActivity関連。

まずGameOverActivityは、元のMyGameOverクラスそのままです。また最初に起動されるMainActivityも、以下のようにボタン表示するだけのめちゃ簡単版。

public class MainActivity extends Activity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTitle("Space Trip Game - Start");
    Button button = new Button(this);
    button.setText("Start Game");
    button.setOnClickListener(new View.OnClickListener() {
      public void onClick(View v) {
        Intent intent = new Intent(MainActivity.this, GameActivity.class);
        startActivity(intent);
      }
    });
    setContentView(button);
  }
}

画面いっぱいのボタンという、清清しいまでの手抜きな開始画面となっちょりますw


さて、今回のキモとなるGameActivityをみていきましょう。まずはクラス定義と、entire lifetime 用の関数定義です。

public class GameActivity extends Activity {
  GameView view;
  Thread mainThread;
  @Override protected void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTitle("Space Trip");
    view = new GameView(this);
    setContentView(view);
  }
  @Override protected void onDestroy() {
    super.onDestroy();
    if (mainThread != null && mainThread.isAlive()) {
      mainThread.interrupt();
    }
  }
}

まあ定番の処理ですよね。ゲームの主要部分はGameViewクラスにあることがわかります。

続いて foreground lifetime 用の関数も定義しておきます。GameViewクラスのisRunningメンバを設定して、ゲーム内時間を止めたりしているだけですね。

  protected void onResume() {
    super.onResume();
    view.isRunning = true;
  }
  protected void onPause() {
    super.onPause();
    view.isRunning = false;
  }

ここまではガチ定番処理な感じですねぇ。

さて、これからいよいよGameViewクラスの定義にはいります。GameActivityの内部クラスとして定義されています。まずはクラス定義とコンストラクタ。

class GameView extends SurfaceView implements Runnable {
  Context context;
  Paint paint = new Paint();
  Boolean isRunning;
  GameScreen s;
  List<GameObject> rocks = new ArrayList<GameObject>();
  GameBitmapObject ship;
  long score, loopCount;
  public GameView(Context c) {
    super(c);
    context = c;
    mainThread = new Thread(this);
    mainThread.start();
  }
}

ゲーム自体の初期化は別にあるので、コンストラクタはわりと定番っぽいコードですね。ゲームの本体はスレッドで実行されるrun()関数にあります。まずは擬似コードで表現してみましょう。

public void run() {
  画面表示ができるまで待つ
  ゲームの初期化
  while (true) {  // メインループ
    if (isRunning) {
      自機や岩を動かす
      doDraw(); // 自機や岩など画面表示
      衝突チェック  ⇒ 衝突でgameover()
      スコアを計算
      ループ回数に応じて岩を追加 ⇒ addRock()
    }
  }
}

さて実際のコードを見てみましょう。まずは「画面表示ができるまで待つ」の部分。Canvasをロックできるまでループして待つロジックになっています。

  Canvas canvas;
  do {
    sleep(100);
    canvas = getHolder().lockCanvas();
  } while (canvas == null);

次は「ゲームの初期化」部分。

  s = GameScreen.factory().init(canvas);
  getHolder().unlockCanvasAndPost(canvas);
  Bitmap b = BitmapFactory.decodeResource(context.getResources(),R.drawable.droid);
  ship = new GameBitmapObject(s.w(0.1F), s.h(0.1F), b);
  init();

自機のサイズは縦横共に画面の10%に設定しています。現時点ではアスペクト比などまったく考慮していないです。なので横長の画面だと、かなりデブった感じのAndroid君が表示されることになりますね。

オブジェクト生成以外の初期化処理は、ゲームオーバー後にも使用するのでinit()にまとめています。

  void init() {
    paint.setColor(Color.WHITE);  // スコアの表示色
    score = loopCount = 0;
    s.setup(ship);
    s.center(ship);  // 自機を中心に戻す
    ship.dx = ship.dy = 0;  // 自機の速度を0に戻す
    ship.rpw = s.w(0.01F);  // 自機の当たり判定を画像より狭める
    ship.rph = s.h(0.01F);
    rocks.clear();  // 岩を全て取り除く
    addRock();  // ゲーム開始時には3つ岩が追加されている
    addRock();
    addRock();
  }

自機は最初は速度がゼロで、画面中心に配置されます。当たり判定のPadding幅(rpw, rph)を1%にしていますから、それぞれ中心から8%ぐらいが当たり判定のあるエリアになります。

さて続いてはメインループの中を記述しましょう。「自機や岩を動かす」部分は以下のようになっています。

  ship.m(s).calc();
  for (GameObject rock : rocks) {
    rock.m(s).calc();
  }

GameObject の関数 m() の説明がまだですね。これは以下のように定義された便利関数です。

  public GameObject m(GameScreen s) {
    move();
    s.warp(this);
    s.transform(this);
    return this;
  }

「衝突チェック」は以下のようなコードです。説明の必要がないぐらいシンプルですねw

  for (GameObject rock : rocks) {
    if (ship.collision(rock)) {
      gameover();
      break;
    }
  }

「スコアを計算」「ループ回数に応じて岩を追加」も以下のような単純なコードです。速度が速いほど点数が増えやすくなっています。また10秒ごとに岩が増えていくのがわかります。

  score += Math.abs(s.scaleX(ship.dx)) + Math.abs(s.scaleX(ship.dy));
  if (rocks.size() -3  < ++loopCount / 100)
    addRock();

さて、これでメイン部分のコードは終わりです。後は呼び出される3つの関数(doDraw, addRock, gameover)が残っているだけになりました。

doDraw()はGameObjectのdraw()を利用しているので、以下のようにシンプルです。

  void doDraw() {
    Canvas canvas = getHolder().lockCanvas();
    if (canvas != null) {
      canvas.drawColor(Color.BLACK);
      String sc = "0000000000" + score;
      canvas.drawText(sc.substring(sc.length() - 10), 0, paint.getTextSize(), paint);
      ship.draw(canvas);
      for (GameObject rock : rocks) {
        rock.draw(canvas);
      }
      getHolder().unlockCanvasAndPost(canvas);
    }
  }

addRock()は少し長いですが、GameBitmapObjectを定義してランダムに配置しているだけです。

  void addRock() {
    Bitmap b;
if (rocks.size() > 0)
      b = ((GameBitmapObject)rocks.get(0)).bitmap;
    else
      b = BitmapFactory.decodeResource(context.getResources(), R.drawable.redrock0);
    GameBitmapObject o = new GameBitmapObject(s.w(0.01F * s.i(10, 5)), s.h(0.01F * s.i(10, 5)), b);
    o.rpw = s.w(0.01F);
    o.rph = s.h(0.01F);
    s.setup(o);
    do {
      s.random(o);
      o.calc();
    } while (ship.collision(o));
    o.dx = s.w(0.001F * s.i(80, -39));
    o.dy = s.h(0.001F * s.i(80, -39));
    rocks.add(o);
  }

岩の画像は毎回生成せずに、2個目からは最初の岩の画像を使いまわすようにしています。サイズは縦横それぞれ、画面の5~15%ってとこでしょうか。自機と同様に画像より小さめの当たり判定を設定しています。

初期位置ですが、random()で配置した後に、collision()で自機との当たり判定をチェックしているあたり、前回と同じですね。岩追加と同時にゲームオーバーになるのを防いでいます。

最後はgameover()ですが、これは前回のものとほぼ同じでしょうかね。

  public void gameover() {
    isRunning = false;
    Intent intent = new Intent(GameActivity.this, GameOverActivity.class);
    intent.putExtra("SCORE", score);
    startActivity(intent);
    init();
  }

さて、これでコードが揃いました。実行してみましょう。


ほら、前のと同じゲームがプレイできるようになりました。仮想画面を使っているせいか、前より動きがなめらかな気がします。また岩が画像になっただけで、雰囲気が良くなりましたねぇ。

ただ衝突判定をきちんとした結果、ゲーム自体は前より難しくなったみたいですw


岩は時間で増えるので、最初におもいきって速度出して点数を稼いでおくのがコツかなぁ。現時点でも、そこそこ面白いですw

とりあえず動作するので、パッケージを公開してみます。興味ある方はご参照ください ⇒ SpaceTrip_20110810.zip

2011年8月7日日曜日

とりあえずゲーム作り(5)

まずはプロジェクトから新規に作り直します。パッケージはJavaっぽく自分のサイトからとり、クラスもちゃんとファイル分けしていきます。


とりあえず2つのパッケージ、6つのクラスに整理しました。

まず基本となるのが、画面表示を担当する GameScreen クラス。個人的な好みで、仮想座標系にしました。実際とは異なる座標空間を用意して、画面に表示するときには変換する感じ。実際のスクリーンサイズを意識せずに開発できるかな、と。

仮想座標ですが、ある程度大きくて、かつ画面サイズの整数倍だと処理が楽です。そこでよくある画面サイズの最小公倍数を求め、

public static final int BASE_SIZE = 590284800;

と定義してみました。intなので9桁が妥当かな、と。コンストラクタは単純に以下のように用意します。乱数源のrand は初期化処理でよく使うので、ついでに用意。

public class GameScreen {
  int w, h;  // 仮想画面のサイズ
  Random rand;
  public GameScreen() {
    w = BASE_SIZE;
    h = BASE_SIZE;
    rand = new Random();
  }
}

シングルトン・パターン用のファクトリーも用意します。これは単なる趣味です。

static GameScreen singleton;
public static GameScreen factory() {
  return singleton == null ? (singleton = new GameScreen()) : singleton;
}

GameScreen はインスタンス生成しただけでは、あまり意味がありません。実際の画面サイズを教えてあげて、初期化しましょう。

int cw, ch;  // 実際の画面のサイズ
float xs, ys;  // 仮想と実際の画面サイズの比
public GameScreen init(Canvas c) {
  xs = cw = c.getWidth();
  ys = ch = c.getHeight();
  xs /= w;
  ys /= h;
  return this;
}

これで仮想画面と実際の画面の情報をもった GameScreen オブジェクトが出来上がりました。

なおこういった関数の戻り値は void で宣言することも多いとおもいますが、僕は基本的に関連オブジェクトを返すよう宣言するのが好みです。むろん、サイズや処理速度に制限がある場合には省きますが。

さて、この仮想画面上に配置される GameObject を定義してみましょう。

public class GameObject {
  public int w, h;  // 仮想画面上の大きさ
  public int x, y, z;  // 仮想画面上の位置
  public Rect r;  // 衝突判定用のRect
  public GameObject(int _w, int _h) {
    r = new Rect();
    init(_w, _h);
  }
}

これは見たまんま、あまり説明の必要がなさそうですね。で、別に初期化関数init()を用意します。コンストラクタでの初期化を最低限にして別に初期化関数を用意するのは、生成したインスタンスの再利用するための定番の構成だとおもいます。

  public int dx, dy;  // 移動スピード
  public int dxm, dym;  // 移動スピードの最大
  public int rpw, rph;  // 衝突判定エリアのPadding
  void init(int _w, int _h) {
    w = _w;
    h = _h;
    x = y = z = dx = dy = rpw = rph = 0;
    dxm = _w / 2;
    dym = _h / 2;
    calc();
  }
  public GameObject calc() {
    r.left = x + rpw;
    r.top = y + rph;
    r.right = x + w - rpw * 2;
    r.bottom = y + h - rph * 2;
    return this;
  }

まあ、移動スピードと衝突範囲を実装した、ベタなオブジェクト化って感じですね。衝突判定エリアはRectで実装してあります。これは移動するたび更新が必要ですから、calc()という関数に分離して他からも呼べるようにしています。

ちなみに今回のゲームでは、z軸方向の動きは使用しません。なのでzは0で固定として、処理は省略しています。将来的に必要となったら、拡張するとしましょうw

さて、GameScreenとGameObjectを関連付けていきましょう。まずはGameObjectに実画面に対応した座標情報を持たせてあげます。

  public int cw, ch;  // 実画面上の大きさ
  public int cx, cy;  // 実画面上の位置

この座標を扱うためにGameScreenのほうに幾つか関数を追加します。

  public int scaleX(int v) {  // x軸方向の座標変換
    return (int)(v * xs);
  }
  public int scaleY(int v) {  // y軸方向の座標変換
    return (int)(v * ys);
  }
  public GameObject transform(GameObject o) {
    o.cx = scaleX(o.x);
    o.cy = scaleY(o.y);
    return o;
  }
  public GameObject setup(GameObject o) {
    o.cw = scaleX(o.w);
    o.ch = scaleY(o.h);
    return transform(o);
  }

setup()は大きさも含めて設定してくれる関数で、今回はGameObjectの大きさは変化しないので最初に一回だけ呼べばOKです。移動した後にtrasnform()を呼べば、実画面の表示座標を更新してくれます。

さて実画面の座標が設定されたところで、GameObjectに画面表示用の関数を設定してあげましょう。

  public GameObject draw(Canvas c) {
    Paint paint = new Paint();
    paint.setColor(Color.RED);
    c.drawRect(new Rect(cx, cy, cx + cw, cy + ch), paint);
    return this;
  }

draw()は抽象関数としておき、サブクラスで実装させるのがオブジェクト指向っぽいのですが。僕の場合にはテスト目的で仮ロジックを記述してしまうことがおおいですね。コードサイズ的に問題がある場合には後で削除しちゃえば良いですし。

さて、実際に表示されるオブジェクトには画像を使用しますから、画像を扱えるようにGameObjectを拡張しておきましょう。

public class GameBitmapObject extends GameObject {
  public Bitmap bitmap;
  Rect br;
  public GameBitmapObject(int _w, int _h, Bitmap b) {
    super(_w, _h);
    br = new Rect(0, 0, b.getWidth(), b.getHeight());
    bitmap = b;
  }
  public GameObject draw(Canvas c) {
    c.drawBitmap(bitmap, br, new Rect(cx, cy, cx + cw, cy + ch), null);
    return this;
  }
}

コントラクタで画像をもらい、表示部分でそれを使っているだけ。このクラスは非常にシンプルに実装できましたね~

さて、ここまで作成したクラスを使用して、画面に何か表示させてみましょうか。まずは画面を初期化して

GameScreen gs = GameScreen.factory().init(canvas);

正方形のオブジェクトを1つ作りましょう。

GameObject go = new GameObject(10000, 10000);
gs.setup(go);

あとは表示させるだけですね。x, yの初期値は0なので、画面左上に赤い四角が表示されるはずです。

go.draw(canvas);

仮想座標上で右に10000ドット動かして表示してみましょう。

go.x += 10000;
gs.transform(go);  // 実座標を更新
go.draw(canvas);

ちなみにドロイド君が表示されるように変更したい場合、GameObject生成の1行を以下のように書き換えればOK。

Bitmap b = BitmapFactory.decodeResource(context.getResources(),R.drawable.droid);
GameBitmapObject go = new GameBitmapObject(10000, 10000, b);

とりあえず、これ使ってゲームを再構築していきます。そのために幾つかの便利関数を、それぞれのクラスに追加しておきましょう。

まずはGameObjectを動かす関数move()と、速度を変更する(加速させる)関数accelerate()を追加しておきます。最大速度を超えないようにしておきます。

  public GameObject move() {
    x += dx;
    y += dy;
    return this;
  }
  public GameObject accelerate(int x, int y) {
    dx += x;
    dx = dx < -dxm ? -dxm : dx > dxm ? dxm : dx;
    dy += y;
    dy = dy < -dym ? -dym : dy > dym ? dym : dy;
    return this;
  }

またGameObject同士の衝突を判定する関数collision()も追加しておきましょう。Rectクラスのcontains()は衝突判定として甘い感じ(相手が完全に含まれないとtrueにならない)なので、別途用意してみました。

  public Boolean collision(GameObject o) {
    return r.contains(o.r.left, o.r.top) || r.contains(o.r.left, o.r.bottom) || r.contains(o.r.right, o.r.top) || r.contains(o.r.right, o.r.bottom);
  }

GameScreenのほうにも追加しておきましょう。仮想座標を飛び出したGameObjectを逆側にワープさせる関数warp()は必要ですね。

  public GameObject warp(GameObject o) {
    o.x = warpCheck(o.x, o.w, w);
    o.y = warpCheck(o.y, o.h, h);
    return o;
  }
  static int warpCheck(int x, int w, int sw) {
    return x > sw ? -w : x < -w ? sw : x;
  }

また座標の初期化をする際に画面の中心に設定するcenter()と、画面上のランダムな位置に配置するrandom()はあると便利です。

  public GameObject center(GameObject o) {
    o.x = (w - o.w) / 2;
    o.y = (h - o.h) / 2;
    return o;
  }
  public GameObject random(GameObject o) {
    o.x = rand.nextInt(w - o.w);
    o.y = rand.nextInt(h - o.h);
    return o;
  }

また仮想座標系での大きさを得るため、以下の関数を用意してみました。例えばw(0.1F)は仮想画面の横幅の10%の長さを返します。

  public int w(float v) {
    return (int)(v * w);
  }
  public int h(float v) {
    return (int)(v * h);
  }

確保しておいた乱数を利用する手段も提供しておきましょうか。

  public int i(int r, int d) {
    return rand.nextInt(r) + d;
  }

さて、こんなものですかね。次回はこれらを使用し、実際にActivityを書き直していきましょう。

2011年8月1日月曜日

とりあえずゲーム作り(4)

やられた!小学生の息子にプレイさせてみたら、すぐに必勝パターンを作られてしまいましたw


右方向への速度が3,000を超えているのに注目。開始と同時に右に加速させ、速度が画面解像度以上になったら無敵モード発動。あとは操作しなくても、どんどんスコアが上昇していくだけなのでした。

とりあえずonTouchEvent()にコードを足し、最大速度を50に抑えることで対応。いやぁ、テストプレイは重要ですなぁ…

d_dx = d_dx < -50 ? -50 : d_dx > 50 ? 50 : d_dx;
d_dy = d_dy < -50 ? -50 : d_dy > 50 ? 50 : d_dy;

さて気をとりなおして、パッケージやクラス分けをすこしまともに修正します。

そしてそろそろ気にしなくてはいけないのが、Activityの状態遷移のあたり。アクションゲームなので比較的ルーズで良いとは思うのですが、今後のこともあるので理解は必要かな、と。

ざっと見ていて、やはり判りやすかったのは英語ですが ReferenceのActivityページ ですね。適当訳をメモしておきます。

まず大きな括りとして、onCreate(Bundle)からonDestroy()までの entire lifetime がある。Global stateはここで設定される。例えばバックグラウンドで動作するThreadはonCreate()で生成され、onDestroy()で停止されなければならない。

より小さな括りとして、onStart()からonStop()までの visible lifetime がある。この期間の間、ユーザーはActivityを画面で見ることができる。たとえ画面の前面に出てきておらず、ユーザーが直接操作できる状態でなくても、この状態に含まれる。

この期間の間、ユーザーにActivityを見せるのに必要なリソースは保持されていなければならない。例えばUIへの影響をモニターするためのBroadcastReceiverはonStart()で登録され、onStop()で解除されるべきである。onStart()とonStop()メソッドは、Activityが表示されたり隠されたりするたび、何度も繰り返し呼ばれることがある。

そして最も小さな括りとして、onResume()からonPause()までの foreground lifetime がある。この期間の間、Acticityは前面に出てきており操作可能である。例えばActivityはデバイスがスリープに入るなど、resumedとpausedのステータスは頻繁に入れ替わるため、これらに重い処理を記述するのはお勧めできない。

とまあ、こんな感じ?

個人的にはvisible lifetimeとforeground lifetimeの区別がいまいち判り難いんですが、これは対象デバイスによって大きく変わりそうなので、あえて曖昧な定義にしているのかも。

onStop()とonDestory()がKill可能とあるので、まぁ、onStop()は呼ばれることは保証されている?デバイスがフリーズしたなどの問題がなければonStop()でデータを永続化できれば良いのかな?

他の資料でもonStop()でisFinishing()見てデータ保存しなさい、とかあった気がします。

って訳で、とりあえずonCreate(),onDestroy(),onStop()の3つをおさえておけば、とりあえずは大丈夫なのかなぁ、って感じ?ただし今回はアクションゲームなので場合はonResume()/onPause()でゲーム(スレッド)を止めてあげる必要はあるかも。

さーて、お勉強は終わり。手を動かしちゃいましょう~

と言いつつ、夏休み期間ということもあり、最近は土日の体力消費が激しくて作業が進んでおりません。太陽の下でプールなど入ると、どうしてあんなに疲れるのでしょうね…

なので今回は、隕石用の仮画像でも作成して、お茶を濁してみます。息子の机に転がっていた謎の石をデジカメで撮影してみました。こんな感じ。


さて次回は、コードごっそり書き換えたものをご紹介します。

2011年7月25日月曜日

とりあえずゲーム作り(3)

さて作りかけのゲームですが、いろいろ直すべきところはあります。ただ今回は、もう少しゲーム内容について改善していきます。

現時点では、静止した宇宙の中をAndroid君が移動するだけのゲームになっています。これではちょっと寂しいので、障害物のほうにも動きをつけてあげましょう。

今まで障害物は Rect クラスの配列で扱ってきましたが、拡張するためには専用クラスを作ってあげるのが良いでしょう。Rockクラスとして新しく定義します。とりあえずはMyViewの内部クラスにしときます。

public class Rock {
  Rect r;
  int w, h, dx, dy;
  public Rock(int sw, int sh) {
    w = 6 + rand.nextInt(7);
    h = 6 + rand.nextInt(7);
    int x = rand.nextInt(sw - w);
    int y = rand.nextInt(sh - h);
    r = new Rect(x, y, x + w, y + h);
    dx = rand.nextInt(5) - 2;
    dy = rand.nextInt(5) - 2;
  }
}

Rockオブジェクトは、従来のRect の他に幅(w)、高さ(h)、x方向の速さ(dx)、y方向の速さ(dy)を属性として持ちます。dx, dy が目新しいですね。

Rockのコンストラクタは、addRock関数にあった初期化部分をもってきています。移動速度dx,dyは -2~2 の範囲でランダムに設定されるようにしてみました。また小さな障害物が動くのはつらいので、縦横の最小サイズをそれぞれ+2しています。

ついでに障害物に関する処理をRockクラスに集めてしまいましょう。以下のようなメソッドを追加します。

void move(Canvas canvas){
  r.left = MyGame.check(r.left + dx, r.width(), canvas.getWidth());
  r.top = MyGame.check(r.top + dy, r.height(), canvas.getHeight());
  r.right = r.left + w;
  r.bottom = r.top + h;
}
Boolean collision(Rect d) {
  return d.contains(r);
}
void draw(Canvas canvas) {
  Paint paint = new Paint();
  paint.setColor(Color.RED);
  canvas.drawRect(r, paint);
}

move()は移動、collisionはAndroid君などとの衝突判定、draw()は画面表示の部分をもってきました。内部クラスということで、無駄はありますが気にしないでください。

check()は以下のような静的メソッドで、座標を補正して上下左右を繋げる役割です。将来的にはUtilだとかToolだとかいうクラスにまとめちゃう便利関数ですね。

static int check(int x, int w, int sw) {
  return x > sw ? -w : x < -w ? sw : x;
}

さて、全体をRockクラスを利用するように変更してみましょう。まず、addRock()メソッドは以下のように簡単になります。

void addRock(int sw, int sh){
  Rect safe = new Rect(d_r);
  safe.offset(d_dx * 4, d_dy * 4);
  Rock rock;
  do {
    rock = new Rock(sw, sh);
  } while(rock.collision(d_r) || rock.collision(safe));
  rocks.add(rock);
}

他にも、こまごまと変更がありますね。

List<Rect> rocks = new ArrayList<Rect>();

List<Rock> rocks = new ArrayList<Rock>();

for (Rect rock : rocks)
  canvas.drawRect(rock, paint);
for (Rock rock : rocks)
  rock.move(canvas);
  rock.draw(canvas);
}

for (Rect rock : rocks)
  if (d_r.contains(rock))
    gameover();
for (Rock rock : rocks)
  if (rock.collision(d_r))
    gameover();

さてこれで従来と同様になったはずです。それに加えて、さりげなく障害物を動かすmove()も加えてあります。

さて、実行してみましょう。障害物も動いていることで、なんか一気にゲームっぽい感じになってきました。


ま、例によって画面キャプチャだと判り辛いですけどね…

もう一点、修正しておきましょう。ゲームオーバーの画面から「戻る」操作をすると、ゲームが初期化されていないので妙なことになるのが、気になります。

まずはMyViewのコンストラクタなどにある初期化部分を抜き出して、init()メソッドに集めます。

void init() {
  Display disp = ((WindowManager)getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
  d_x = (disp.getWidth() - droid.getWidth()) / 2;
  d_y = (disp.getHeight() - droid.getHeight()) / 2 - 20;
  d_dx = d_dy = 0;
  rocks.clear();
  score = 0;
  startTime = System.currentTimeMillis();
}

そしてこのinit()を、gameover()メソッドの最後でも呼び出します。startActivity(intent);でゲームオーバー画面を表示した後、戻ってきたタイミングで初期化するわけですね。

ちょっと遊んでみたところ、ちと簡単すぎる気が。最初から障害物が1個表示されるようにして、かつ5秒に1個づつ増えるように変更しておきます。

if (rocks.size() < (now - startTime) / 30000)

if (rocks.size() <= (now - startTime) / 5000)

さーて、これでひと段落。今日はここまでにして、次回はパッケージなどまともに整理しましょうかねー

現時点のソースは こんな感じ になっています。

2011年7月24日日曜日

とりあえずゲーム作り(2)

涼しくて過ごしやすい日曜日、いいですね。今日も頑張ろう!

さて、Android君が動くようになったMyGame、今回はゲームっぽくしていきましょう。得点の概念が必要でしょうし、障害を配置してゲームオーバーの危険性があるといいですね。

最初はシンプルに、動かない障害物を表示してみましょう。宇宙空間に浮かんでいる岩やデブリのイメージです。Android君が衝突したらそれでゲームオーバー。時間とともに増えていく仕組みも欲しいですね。

とりあえず今回の準備として、MyViewクラスに以下のメンバを追加しておきます。

Rect d_r = new Rect();
List<Rect> rocks = new ArrayList<Rect>();
long startTime = System.currentTimeMillis();
long score;
Random rand = new Random();

衝突判定に必要なのは、Android君の実体(当たり判定)の定義です。表示している画像の大きさをそのまま使ってもいいのですが、通常はそれより小さめに設定します。

以下のコードをonDraw()のdrawBitmap()直後に追加します。描画するときに、その当たり判定も設定してあげる、という感じです。単に画像サイズより4ドットづつ小さい領域にしているのは、いつもどおりの手抜きでありんす。

d_r.set(d_x+4, d_y+4, d_x+droid.getWidth()-8, d_y+droid.getHeight()-8);

これを使って、障害物との当たり判定のロジックも作成しましょう。処理は単純で、Android君の実体(d_r)が、障害物のどれかと接触したら、つまり領域が重なったらゲームオーバーです。

onDraw()のunlockCanvasAndPost()の後ぐらいに以下のコードを追加します。

for (Rect rock : rocks)
  if (rock.contains(d_r))
    gameover();

あ、ゲームオーバー処理がまだなかった。Activityクラスに仮のものを実装しておきましょう。現時点では、単に元メニューにもどるだけ。そのうちゲームオーバー用のActivity作成します。

public void gameover() {
  Intent intent = new Intent(MyGame.this, Main.class);
  startActivity(intent);
}

さあ実行してみましょう… あれ?前と一緒だ。てか、障害物を追加する部分がまだできていない、ですね。ははは。


さて、障害物を追加しますかー

まずは障害物を表示するロジックを加えます。これはonDraw()のdrawBitmap()の前がいいと思います。後だとAndroid君より手前に表示されてしまいますので。

またもや手抜きで、赤い四角にしておきます。以下のような感じ。

Paint paint = new Paint();
paint.setColor(Color.RED);
for (Rect rock : rocks)
  canvas.drawRect(rock, paint);

で、とりあえず、30秒ごとに障害物が追加されていくというルールにしてみましょう。onDraw()のunlockCanvasAndPost()の直前ぐらいに、以下のロジックを追加してみます。

long now = System.currentTimeMillis();
if (rocks.size() < (now - startTime) / 30000)
  addRock(canvas.getWidth(), canvas.getHeight());

MyViewのメソッドaddRock()が未定義ですね。コードは以下です。

void addRock(int sw, int sh){
  int w = 4 + rand.nextInt(7);
  int h = 4 + rand.nextInt(7);
  int x = rand.nextInt(sw - w);
  int y = rand.nextInt(sh - h);
  Rect rock = new Rect(x, y, x + w, y + h);
  rocks.add(rock);
}

このメソッドを使って、画面内のランダムな位置に幅4~10ドット、高さ4~10ドットの障害物(赤い四角)を追加します。

さて、これで障害物(赤い四角)を避けながら宇宙を漂えるようになりました。ちょっとゲームっぽくなりましたよねw


何回かゲームとしてプレイしてみると、不条理な状況があることに気がつきます。いきなり Android君の内部に障害物が発生して、そのままゲームオーバーになることがあるのです。

障害物を発生させるaddRock()を修正しましょう。

void addRock(int sw, int sh){
  Rect safe = new Rect(d_r);
  safe.offset(d_dx * 4, d_dy * 4);
  Rect rock;
  do {
    int w = 4 + rand.nextInt(7);
    int h = 4 + rand.nextInt(7);
    int x = rand.nextInt(sw - w);
    int y = rand.nextInt(sh - h);
    rock = new Rect(x, y, x + w, y + h);
  } while(d_r.contains(rock) || safe.contains(rock));
  rocks.add(rock);
}

追加したコードは、障害物を生成した位置がAndroid君の領域内であれば、生成しなおすというベタなロジックになっています。

Ract safeは4フレーム(0.4秒)後にAndroid君が占める領域です。これもあわせてチェックすることにより、移動するすぐ先に障害物が発生することを防いでいます。

ただこのロジックだと、上下左右の画面の繋がりまでは考慮していません。端のほうは危険だよ、というゲーム性なのです、という言い訳で誤魔化しておきましょう。

さてこれで、一応はプレイできるものになりました。まだ面白くはないですが… よりゲームっぽくするために、次は得点に関する機能を実装してみましょう。

基本的には、得点は時間経過で増えていくとします。長くプレイすることで、高得点が狙えるわけですね。ただし動いていないときは点が増えないことにしましょう。そうでないと、最初の位置で動かないことが攻略法になってしまいますからね。

加えて、動作速度が速いほうが点が上がりやすい、としたほうが面白いでしょう。より危険なプレイが評価される、ゲームデザインの王道でありますな。

ではonDraw()の後に以下のようなコードを追加してみましょう。

score += Math.abs(d_dx) + Math.abs(d_dy);
paint.setColor(Color.WHITE);
paint.setTextSize(24);
String s = "0000000000"+score;
canvas.drawText(s.substring(s.length() - 10), 0, paint.getTextSize(), paint);

得点は単純に移動距離にしてみました。高速移動のときのボーナスが大きすぎる気もしますが、後でゲームバランスを調整するときに見直しましょう。スコアも単純に、左上に表示するだけにしています。

ついでに現在の速度も表示しておきましょう。先ほどのコードの後に以下を追加します。

paint.setColor(Color.GRAY);
s = "x:" + d_dx + " y:" + d_dy;
canvas.drawText(s, 0, paint.getTextSize() * 2, paint);

さて実行してみましょう。よりゲームっぽくなった気がしますね。


さてプレイしてみると、ゲームオーバーした時に得点がわからないですね。そろそろ専用画面を追加してみましょう。

まずはgameover()メソッドを以下のように書き換えます。

public void gameover() {
  Intent intent = new Intent(MyGame.this, MyGameOver.class);
  intent.putExtra("SCORE", score);
  startActivity(intent);
}

で、MyGameOverクラスを新規作成しましょう。とりあえずは得点を表示するだけのシンプルなAvtivityです。

public class MyGameOver extends Activity {
  protected void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTitle("My Game - Game Over");
    TextView view = new TextView(this);
    Bundle extras = getIntent().getExtras();
    Long score = extras.getLong("SCORE");
    view.setText("SCORE: " + score);
    setContentView(view);
  }
}

表示はこんな感じ。


これで本当に最低限ですが、ゲームの体裁が整いました。ただしやっつけの部分が多いので、次からは少しずつまともにしていきましょう。

現時点のソースコードは こんな感じ