第4回 迷路ゲームを作ろう!

今回の講座は?

サンプルを作り、試すことは良い学習方法だと思います。 でもそればかりでは、ちょっと退屈したりします。 今回の講座では、簡単な 迷路ゲーム を作ってみましょう。

新しいプロジェクトを開始する

まずは KToolbar で "新規作成(N)" のボタンを押し、新規プロジェクトを作りましょう。 プロジェクト名 "MazeGame"、クラス名 "SampleMaze" としておきます。

Create a new project, MazeGame

迷路を表現するクラス MazeData

表現したい対象を表現するのに、 どんなデータや処理が必要かを考えてみるのが オブジェクト指向 の第一歩だと思います。 迷路のゲームですから、まずは迷路の表現を考えてみましょう。

迷路を表現するのに、必要なものはなんでしょう? 僕はまず、その 大きさ の情報だと思います。 ここでは迷路の横の大きさ(width)、縦の大きさ(height)が必要だと定義します。 データの型としては、一般的な int (integer: 4バイト長の整数値) を選択します。

MazeData.java という名前のテキストファイルを新規作成し、以下のように入力してください。

MazeData.java (その1)
public class MazeData {
  public int width;  // 迷路の横の大きさ
  public int height;  // 迷路の縦の大きさ
}

ちなみに、このプログラムはあまり Java的 ではありません。 Java の教科書を読むと、次のような書き方が推奨されています。

MazeData.java (その1 補足)
public class MazeData {
  private int width;  // 迷路の横の大きさ
  private int height;  // 迷路の縦の大きさ

  public void setWidth(int value) {
    if (value > 1)
      width = value;
  }
  public int getWidth() { return width; }
  public void setHeight(int value) { height = value; }
  public int getHeight() { return height; }
}

private が 「上品」 な雰囲気を醸し出しているのが感じ取れますでしょうか? public 宣言した変数は外部からもアクセス可能になるため、 予想もつかない値を入力されて問題が起きる可能性があります。 例えば迷路の幅にマイナスの値を入れられても、処理できなくて困ってしまいますよね?

そこで Java では、変数の値を private で外部からアクセス不能にし、 そのかわり値を変更するためのメソッドを提供する手法が推奨されています。 変数の名前に set/get を付け、最初の文字を大文字にするのが一般的です。 これだと例えば 赤い部分 のように、 「幅に1以下の値を設定できない」というルールを追加するのも簡単です。

ただし、です。 携帯機器向けのJ2MEでは、 冗長な記述を省き小さな実行コードを生成するのも、また大切なことです。 なので今回は、あえて「上品」ではない記述方法を選択します。 Case by case ということで。

さて、大きさだけじゃ迷路になりません。 ここが壁で、ここが通路、のように実際の迷路の中身を表現しなくては。

MazeData.java (その2)
public class MazeData {
  public int width;  // 迷路の横の大きさ
  public int height;  // 迷路の縦の大きさ
  public byte[] data;  // 実際の迷路データ
}

また「上品」ではない記述が出てきました。 迷路の中身を表現するのに、byte型の一次元配列 を選択しています。

迷路は平面(二次元)なので、素直に考えれば二次元配列を選択しがちです。 ただ Java においては、多次元配列の利用はメモリ効率と実行速度の面でかなり不利になります。 なので一次元配列を選択しました。

また真/偽 の二値を表現する boolean型 ではなく、byte型 (1バイト長の整数値) を利用したのは、 通路/壁 以外の要素も表現したかったからです。 具体的には迷路の入り口、出口など。

おまけコラム: 多次元配列が苦手な java

java における多次元配列は、実際には 一次元配列の多重使用 です。 例えば int[][] 型は、 「int[] という一次元配列型を要素とした、一次元配列」 として実現されています。

  int[][] foo = {{0, 1, 2}, {3, 4, 5}};

というコードは

  int[][] foo = new int[2][];
   foo[0] = new int[3];
   foo[1] = new int[3];
    foo[0][0] = 0;
    foo[0][1] = 1;
    foo[0][2] = 2;
    foo[1][0] = 3;
    foo[2][1] = 4;
    foo[3][2] = 5;

と展開することができます。 最初の行が「int[]型を格納する、長さ2の配列」 を定義していることに注目してください。

二次元配列を実現するために、 合計で3個の配列が生成(new)されているのが理解できるでしょうか? 10x10 の要素数では11個の配列が生成されます。 三次元配列 10x10x10 になると、必要な配列は111個になります。

java は多次元配列の処理が苦手だと言ってよいでしょう。 なので決まった大きさの配列は、 次元数を落として実装するのがお勧めです。

ちなみに宣言 int[][] foo; は、C言語風に int foo[][]; とも書くことができます。 ですが java っぽくないので、個人的にはあまりお勧めしません。



さて引き続き、データの中身を規定しましょう。 C言語では #define 文を使いますが、Java では static 構文を使って 定数 を定義します。 とりあえず通路、壁、入口、出口の値を定義しておきます。 例えば壁を意味する値は 1 ですが、WALL という定数を定数を使用することにより、 プログラムの内容を把握し易くなります。

ちなみに final をつけて継承したクラスにおける変更を禁止しておけば、 定数を使用しても実行の際のデメリットはありません。 (static final ならば、コンパイル時にコンパイラが実際の数値に置き換えることができるため) 安心して使用してください。

MazeData.java (その3)
public class MazeData {
  public int width;  // 迷路の横の大きさ
  public int height;  // 迷路の縦の大きさ
  public byte[] data;  // 実際の迷路データ

  public static final byte PATH = 0;  // 通路
  public static final byte WALL = 1;  // 壁
  public static final byte ENTRANCE = 2;  // 入口
  public static final byte EXIT = 3;  // 出口
}

とりあえず迷路を用意してみる

迷路を操作するクラスをテストするためには、迷路のデータが必要になります。 MazeData を継承し、初期化するメソッド init() を追加してみましょう。

データの自動生成は難しいので、とりあえずダミーのデータを用意します。 適度にダミーでごまかしつつ、まず動くものを作るのは個人的にお勧めする手法です。 そのほうが楽しいですし、問題点も早期に発見できる可能性があります。

MazeData2.java という名前のテキストファイルを新規作成し、以下のように入力してください。

MazeData2.java (その1)
public class MazeData2 extends MazeData {
  public void init() {
    width = 9;  // 迷路の横幅は 9
    height = 6; // 迷路の縦幅は 6
    data = new byte[] {
      2, 1, 0, 1, 0, 1, 0, 0, 0,  // 0 は 通路
      0, 1, 0, 0, 0, 0, 0, 1, 0,  // 1 は 壁
      0, 1, 0, 1, 0, 1, 0, 1, 0,  // 2 は 入口
      0, 0, 0, 1, 0, 1, 0, 1, 0,  // 3 は 出口
      0, 1, 1, 1, 0, 1, 0, 1, 0,
      0, 0, 0, 0, 1, 0, 0, 1, 3
    };
  }
}

MazeData に値を設定しているだけの単純なメソッドになっています。

誰が迷路を描画するの?

迷路を表現するクラス、つまり迷路のデータを保持するクラス MazeData を定義しました。 そしてデータを初期化するメソッドを追加し MazeData2 としました。 次は画面に描画する処理を追加しましょう。

この際に考えなければいけないのは、描画機能を提供するのがどのクラスか? ということです。 この場合だと MazeData2 に実装するか、MazeData2 を描画するための補助的なクラスを追加するか、 もしくは MazeData2 を利用するクラスが実装するかです。

MazeData2 が描画機能を提供する場合、MazeData2 が J2ME の描画機能に依存した存在になります。 逆に MazeData2 と違うクラスが描画機能を提供する場合、 その描画するクラスは MazeData2 に依存したクラスになる、 もしくはそれを避けるために MazeData2 を抽象化するための追加デザインが必要になります。

ここではシンプルな前者のアプローチを選択します。 ただし MazeData2 を継承した MadeData3 を定義し、これに描画機能を実装します。 つまり MazeData2 まではどの Java 環境でも動作しますが、 MazeData3 は J2ME の描画機能に依存した実装になります。

MazeData2.java という名前のテキストファイルを新規作成し、以下のように入力してください。

MazeData3.java (その1)
import javax.microedition.lcdui.*;

public class MazeData3 extends MazeData2 {
  public Graphics maze_g;
  public int maze_x, maze_y;
  public int maze_width, maze_height;
  public int block_width, block_height;

  // 最初に迷路を描画する際に呼び出される
  public void drawMaze(Graphics g, int x, int y, int w, int h) {
    // 迷路の描画情報を保存
    maze_g = g;
    maze_x = x;
    maze_y = y;
    maze_width = w;
    maze_height = h;

    // ブロックのサイズを求める
    block_width = w / width;
    block_height = h / height;

    // 実際に迷路を描画する
    updateMaze();
  }

  public void updateMaze() {
    // まず描画する範囲を白色で塗りつぶす
    // 描画する範囲は、ブロックの大きさの整数倍に切捨て
    // (切捨てをしないと、右端と下端に隙間ができてしまう)
    maze_g.setColor(0x00ffffff);
    maze_g.fillRect(maze_x, maze_y, block_width * width, block_height * height);

    // 迷路データをチェックし、1ブロックずつ描画
    maze_g.setColor(0x00000000);
    for (int loop = 0; loop < data.length; loop++)
      drawBlock(maze_g, data[loop], maze_x + loop % width * block_width,
            maze_y + loop / width * block_height, block_width, block_height);
  }

  // 座標を指定し、ブロックを描画する
  public static void drawBlock(Graphics g, byte type, int x, int y, int w, int h) {
    if (type == WALL)
      g.fillRect(x, y, w, h); // 描画色は黒であることが前提
    else if (type == ENTRANCE) {
      g.setColor(0x00ff0000);
      g.drawArc(x, y, w, h, 0, 360);
      g.setColor(0x00000000);
    } else if (type == EXIT) {
      g.setColor(0x000000ff);
      g.fillArc(x, y, w, h, 0, 360);
      g.setColor(0x00000000);
    }
  }
}

迷路を描画するには drawMaze() というメソッドを使用します。 任意の描画対象(g)に対し、指定された位置(x,y)指定された大きさ(w,h)で、 迷路を描画するメソッドです。

drawMaze() で行う処理は、必要な情報を用意 (インスタンス変数に格納) するだけです。 実際の描画処理は updateMaze() というメソッドで行っています。 最初に1回だけ必要な計算を分離することで、 複数回呼び出される描画処理を短く抑えています。

更にブロックを描画する部分を drawBlock() メソッドに分離しました。 指定された位置(x,y)に指定された大きさ(w,h)で、 指定された迷路の要素(type)を描画します。 type の値によって丸や四角を描くだけのシンプルな処理内容ですね。

このへんの内部で使用しているメソッドは、 private 宣言でも問題無いのですが、 将来の再利用がありそうな気がして public 宣言にしています。 drawBlock() は static 宣言し、更に利用し易くしています。 インスタンスを生成しなくても、 MazeData3.drawBlock() とダイレクトに呼び出すことができます。

各レベルの描画処理を切り離しておくと便利なことが多いです。 例えばチュートリアルやヘルプの機能を追加したいと思った時、 drawBlock() を利用すれば、それぞれの要素を簡単に表示することができます。

MazeData3 でのちょっとした工夫は、 描画色は黒に決めうちしている点でしょうか。 壁を描画する回数が圧倒的に多いため、毎回黒に設定しては実行速度が落ちてしまいます。 最初から黒を指定しておき、入口/出口を描画した後には黒に戻しています。 また最初に白で塗りつぶしているため、通路を描画する必要はありません。

迷路を表示してみよう!

さて、実際に迷路を表示させてみましょう。 実行されるのは MIDlet だけなので、 MIDlet クラスを継承した表示用のアプリケーションを作成します。

MazeData オブジェクトを生成し、必要な情報を設定します。 その後、MazeData オブジェクトの drawMaze 機能を使用して、迷路を画面に描画します。 HelloWorld2 の描画部分を、MazeData3 におまかせした感じのアプリケーションですね。

SampleMaze.java という名前のテキストファイルを新規作成し、以下のように入力してください。

SampleMaze.java
import javax.microedition.midlet.MIDlet;
import javax.microedition.lcdui.*;

public class SampleMaze extends MIDlet {
  // 迷路データは内部クラスで参照するため、
  // インスタンス変数として宣言
  private MazeData3 md;

  // 最初に実行される部分
  public void startApp() {

    // 迷路データを用意する
    md = new MazeData3();
    md.init();

    // 表示するための Canvas クラスを定義し、生成
    Canvas canvas = new Canvas() {
      public void paint(Graphics g) {
        // まず全体を黒色で塗りつぶす
        g.setColor(0x00000000);
        g.fillRect(0, 0, getWidth(), getHeight());
        // 周りに5ドット残し、迷路を描画
        md.drawMaze(g, 5, 5, getWidth() - 10, getHeight() - 10);
      }
    };

    // 生成した Canvas インスタンスを表示
    Display.getDisplay(this).setCurrent(canvas);
  }

  public void pauseApp() {}
  public void destroyApp(boolean unconditional) {}
}

データには迷路の外壁は含まれていないので、5ドットの枠を表示しています。

さて、ビルドして実行してみましょう。 以下のような迷路が表示されましたか?

SampleMaze MIDlet

プロジェクトの中身は?

プロジェクトの内容はどうなっているでしょうか? c:\j2mewtk\apps\MazeGame の中を調べてみましょう。

 MazeGame
│  ├ bin
│  │  ├ MANIFEST.MF
│  │  └ MazeGame.jad
│  ├ classes
│  │  ├ MazeData.class
│  │  ├ MazeData2.class
│  │  ├ MazeData3.class
│  │  ├ SampleMaze.class
│  │  └ SampleMaze$1.class
│  ├ lib
│  ├ res
│  ├ src
│  │  ├ MazeData.java
│  │  ├ MazeData2.java
│  │  ├ MazeData3.java
│  │  └ SampleMaze.java
│  ├ tmpclasses
│  │  ├ MazeData.class
│  │  ├ MazeData2.class
│  │  ├ MazeData3.class
│  │  ├ SampleMaze.class
│  │  └ SampleMaze$1.class
│  └ tmplib

迷路を自動生成しよう!

SampleMaze は、起動するたびに同じ迷路を表示します。 これではゲームとは言えませんよね。 迷路の自動生成機能を追加してみましょう。

具体的には MazeData2 に定義された初期化メソッド init() を変更します。 MazeData2 を以下のように書き換えてください。

MazeData2.java (その2)
import java.util.*;

public class MazeData2 extends MazeData {
  public long mazeSeed;
  private Random rand;

  // 迷路を自動作成する
  public void init(long seed) {
    mazeSeed = seed;
    rand = new Random(seed);
    if (data == null || data.length != width * height)
      data = new byte[width * height];

    // はじめに全て壁にする
    for (int loop = 0; loop < data.length; loop++)
      data[loop] = WALL;

    // 左上から通路の作成を開始
    expand(0);

    // 通路(岐路)が作成できなくなるまで、繰り返し作成
    int count = countExpandable();
    while (count > 0) {
      expand(getExpandPos(rand.nextInt() % count));
      count = countExpandable();
    }

    // 入口と出口を追加
    data[0] = ENTRANCE;
    int pos = data.length - 1;
    while (data[pos] != 0)
      pos--;
    data[pos] = EXIT;
  }

  // 指定した場所から岐路が作成できるか判断する
  public boolean isExpandable(int pos) {
    if (data[pos] != PATH)
      return false;
    if (isDigable(pos, UP) || isDigable(pos, DOWN)
          || isDigable(pos, LEFT) || isDigable(pos, RIGHT))
      return true;
    return false;
  }

  // 指定した場所から岐路を作成する
  public void expand(int pos) {
    data[pos] = PATH;
    while (isExpandable(pos)) {
      int direction = rand.nextInt() % 4;
      while (! isDigable(pos, direction))
        direction = (direction + 1) % 4;
      pos = getNewPos(pos, direction);
      data[pos] = PATH;
    }
  }

  // 岐路が作成できるポイントの数を数える
  public int countExpandable() {
    int count = 0;
    for (int loop = 0; loop < data.length; loop++)
      if (isExpandable(loop))
        count++;
    return count;
  }

  // 何番目かの、岐路が作成できるポイントを返す
  public int getExpandPos(int order) {
    int count = 0;
    for (int loop = 0; loop < data.length; loop++)
      if (isExpandable(loop)) {
        if (count >= order)
          return loop;
        else
          count++;
      }
    return -1;
  }

  // 指定した方向に掘ることができるか確認する
  public boolean isDigable(int pos, int direction) {
    // 掘る対象の座標を得る
    pos = getNewPos(pos, direction);
    if (pos < 0)
      return false;  // 範囲外ならば掘れない
    if (data[pos] != WALL)
      return false;  // 壁以外ならば掘れない

    // 元の通路以外が、全て壁であることを確認
	if (! isWall(pos, direction))
      return false;  // 進行方向の壁が無い
	if (! isWall(pos, (direction + 1) % 4))
      return false;  // 向かって左の壁が無い
	if (! isWall(pos, (direction + 3) % 4))
      return false;  // 向かって右の壁が無い

    return true;
  }

  // 指定した方向が壁かどうか確認
  public boolean isWall(int pos, int direction) {
    pos = getNewPos(pos, direction);
    if (pos < 0)
      return true;  // 範囲外は壁とみなす
    if (data[pos] == WALL)
      return true;
    else
      return false;
  }

  // 指定された方向の座標を得る
  // 結果が width, height の範囲外の場合は -1 を返す
  public int getNewPos(int pos, int direction) {
    if (direction == UP) {
      if (pos < width)
        return -1;
      else
        return pos - width;
    }
    if (direction == DOWN) {
      if (pos >= width * (height - 1))
        return -1;
      else
        return pos + width;
    }
    if (direction == LEFT) {
      if (pos % width <= 0)
        return -1;
      else
        return pos - 1;
    }
    if (direction == RIGHT) {
      if (pos % width >= width - 1)
        return -1;
      else
        return pos + 1;
    }
    return -1;
  }

  public static final int UP = 0;
  public static final int LEFT = 1;
  public static final int DOWN = 2;
  public static final int RIGHT = 3;

  // 互換性を維持してみる
  public void init() {
    if (width < 1 || height < 1) {
      width = 9;
      height = 6;
    }
    init(32);
  }

}

迷路を自動生成する init(long seed) と、内部で呼び出す幾つかのメソッドだけで、 かなり長いプログラムになってしまいました。 エイヤッ! と作ったので、けっこう無駄もあるかとは思います。 まぁ、そこらへんは大目にみてください。

まず左上を起点として、ガンガン通路を掘っていきます。 ランダムに方向を選択し、その方向に壁が2つ連続していれば、そのまま掘り進みます。 壁が1つしか無い状態で掘ると、別な通路に繋がっちゃいますからね。 ちなみに2つめの壁をチェックする際、範囲外は外壁なので、壁扱いです。

すべての方向に壁が掘れなくなると、つまりは行き止まりです。 通路全体を調べ (ここが少しダサい)、まだ掘り進める場所を探し、 発見できれば、ランダムに1つ選んでまた掘り始めます。 発見できなければ作成は終了です。 ちなみに、モグラの気持ちになって考えてみました。。

init メソッドに乱数の種(seed)を与えて起動すると、 迷路データ(data)の中身を自動的に作成してくれます。 init メソッドを起動する前には、width と height に、 希望する迷路の大きさを指定しておく必要があります。

いらなくなったメソッドは消してもいいの?

さて init(long seed) の導入によって、 init() を使用する必要は無くなりました。 ダミーデータを含んでいて無駄も多いので、消してしまいましょう。 ・・・あれ、いいのかな?

いえいえ、消してはいけません。 うかつに消すと、SampleMaze が動かなくなってしまいます。

そこで新しい init(long seed) を利用するように書き直してみました。 ソースの最後の部分です。 まったく同じ迷路を再現するのは無理ですが、 init() を使っているプログラムでもほぼ以前と同じ動作をします。

メソッドを public 宣言すると、それを他から参照される可能性があることを忘れないでください。 変更には注意が必要です。 まぁ今回は、init() を使用しているのは SampleMaze だけというのは明白なので、 SampleMaze を変更してもよかったのですが。。 勉強も兼ねて互換性を維持してみました。

さてこの時点でビルド・実行を行い、SampleMaze に 悪影響が出ていないことを確認してください。 大きな変更を加えるたび、可能な限りテストするのは大切なことです。

SampleMaze MIDlet (updated)

自動生成した迷路を表示しよう!

さて、自動生成をきちんと利用してみましょう。 ダミーでは(面倒なので)実現できなかった、15x25 の大きな迷路を表示します。

KToolbar の "属性設定(E)" メニューからアプリケーションを追加します。 メニューに表示される名前は "MazeGame2"、 実行されるクラス名には "SampleMaze2" を指定します。

Add a new item, MazeGame2

SampleMaze2.java という名前のテキストファイルを新規作成し、以下のように入力してください。

SampleMaze2.java
import java.util.*;
import javax.microedition.midlet.MIDlet;
import javax.microedition.lcdui.*;

public class SampleMaze2 extends MIDlet {
  private MazeData3 md;

  public void startApp() {

    // 迷路データを定義する
    md = new MazeData3();
    md.width = 15;  // 迷路の横方向の大きさ
    md.height = 25; // 迷路の縦方向の大きさ
    md.init(new Date().hashCode());  // 迷路の自動生成

    Canvas canvas = new Canvas() {
      public void paint(Graphics g) {
        g.setColor(0x00000000);
        g.fillRect(0, 0, getWidth(), getHeight());
        md.drawMaze(g, 5, 5, getWidth() - 10, getHeight() - 10);
      }
    };

    Display.getDisplay(this).setCurrent(canvas);
  }

  public void pauseApp() {}
  public void destroyApp(boolean unconditional) {}
}

迷路の横の大きさは15、縦の大きさは25を指定しています。 乱数の種には、起動した時間のハッシュ値を利用しています。 なので再起動するたびに迷路が変わります。

実行結果は以下のようになります。 15x25 はちょっと大きすぎるのか、初期化に時間がかかるので気長に待ってくださいね。 ちなみに Visor Platinum で 1〜2 分ぐらいかかりました。。

MazeGame selection   SampleMaze2 MIDlet

"DefaultColorPhone" を選んで、携帯電話エミュレータで実行してみます。 小さい画面でも、なんとか表示されているようです。

SampleMaze2 MIDlet on DefaultColorPhone

余談ですが・・・

この迷路ゲーム、Palm だけじゃなくて携帯電話でも動きそうですよね。 そう考えるとワクワクしてきませんか? でも Java の応用はまだまだありますよー

Java が最初に注目されたのは、Applet と呼ばれる Web ブラウザ上で動作する規格です。 MIDlet も Applet を元にしていますから、これらの仕組みは似ています。 ね、これもワクワクしませんか?

ワクワクした人は、ちょっと寄り道して 補足4 Applet に改造し、Web で実行してみよう! も読んでみてください。 今回の迷路ゲームが、ちょっとの改造で Web ゲームに生まれ変わりますよ!!

ファイルのダウンロード

今回の講座で作成した prc ファイルです。 まだ完成版では無いので MazeGame_1.prc というファイル名にしてあります。 また zip 圧縮してあります。

リンクを右クリックして "対象をファイルに保存" や "Save Link As..." 等でダウンロードし、解凍して実行してみてください。

ファイル 種類 説明
MazeGame_1.zip Palm用prc 2つのサンプルを含んだPalm用インストールファイル

次回の講座は?

サンプルの作成が基本だとすると、アプリケーションの作成は応用です。 次回は再び基本に戻り、HelloWorld! の改造を続けたいと思います。 新しいテクニックを学び、この迷路ゲームを更に発展させていきましょう。

楽しみましょう! (^-^)/



Copyright (c) 2002 Toshio Yamashita
first version 2002/08/20