Javaでゲーム: スクロールの表現
前回の記事の続きです。もう1年以上経ってた。
Javaを使った2Dゲームの、スクロールに挑戦しました。
はじめに
スクロールを表現するにあたって、題材は「迷路ゲーム」にしました。
※フレームと色を少なくしているので粗いです。
なぜ迷路なのかというと、まず、ごく小規模で完結したゲームを作ろうと思ったからです。そして、昔の2Dゲームの方式で作ろうとしていて、スクロールを取り入れたものにしたかったんですね。
3Dゲームは、作る根気が無いのでいまのところは作る予定はありません。
迷路の外見は、RPGのダンジョンを意識しています。
対象読者?
私はゲーム制作の素人ですので、もし本気でゲーム制作をしたい方は、まずは他のゲーム制作専門サイトさまを当たっていただいた方が無難です。
それと、このサンプルはゲームの原理を理解するためのものなので、本格的なゲームを作るとなると、DirectXやゲーム専用ライブラリーなどを使うのは避けて通れないでしょう。
ただ、DirectXや専用ライブラリーは少しハードルが高い1ので、入門にはJavaの標準機能を使ってみるのも良いんじゃないかと思います。
ということで、ゲーム作ってみたいけどあれもこれも難しくて捗らない…Javaならなんとか行けそう、という人向けです。
環境と準備
- Java8 (8u121)
- JInput 2.0.6
Java8の新機能を使っているので、Java8必須です。GUIはAWT+Swingで、JavaFXは使っていません。
制作作業はWindows上で行っています。動作確認は、Windows10,Windows7,Mac(Sierra, v10.12.4)でも行いました。Linux(Ubuntu14)でも確認してみたのですが、エラーになってしまいました。下記のissueが関係しているようですが、詳細は不明です。
JInputについての説明と、キーボードとゲームパッドからの入力方法については、以前の記事「Javaでゲームパッドの入力をJInputを使って実現する」を参照してください。特に、JInputのネイティブライブラリーをパスに通すところを忘れると動かないので注意して下さい。JInput以外の部分も一部流用していますので、そちらを先に読んでいただいたほうが理解しやすいと思います。
また、ControllerInput
,NullController
,GamepadControllerInput
,KeyboardControllerInput
クラスorインターフェイスのコードは、上記の記事のものをそのまま使うので説明は省略します。ソースコードは、Gistに含まれています。Gistへのリンクは最後に載せてあります。
Tile-based とは
昔のゲームは、少ないリソースで作らないといけないこともあって、同じサイズの小さな画像をいくつか用意して、それを組み合わせて画面を作る方式が一般的でした。 このようなゲームのことを、英語では Tile-based video game というそうです。
タイルを組み合わせて絵を作っているから、Tile-basedなんですね。
比較的、作る手間がかからないからか、同人作品などではいまでもこの方式を採用することが多いみたいです。「ツクール」なんかはこれですね。
今回は、この方式を採用しています。
設計を簡単に説明
この迷路ゲームの設計と、実装方針について簡単に触れます。
ゲームのルール
迷路には、壁などの障害物があり、それを進んで行って、上り階段にたどり着けばゴールです。要はただの迷路ですね。ダンジョン風の。
今回は、1フロアだけを実現しています。応用すれば、ダンジョンを上っていくようなものも作れるでしょう。
メインループ
前回は、入力に対してイベントドリブン方式で処理をさせていましたが、今回は、メインループ内で処理させるようにしました。こちらの方がよく知られた方式だと思います。
メインループのフロー 1. 入力をチェックし、入力があれば2へ進む。なければ一定時間待機して再度1を行う。 2. 入力された方向の地形が通過可能であれば、3へ進む。そうでなければ、1へ戻る。 3. ウェイトをかけながら少しずつスクロールし、1マス分移動する。 現在位置を移動先に変更する。 4. 移動先がゴールなら、ゴール処理をして終了。そうでなければ、1へ戻る。
マップチップ
マップを作るのに用意する画像は、ひとつひとつ別ファイルにすると作業効率が悪いので、1つの画像として用意します。
これを、マップチップ(方式?)と呼びます。英語では、tileset
というそうです。
なお、マッピングと地図のマップが紛らわしいので、ここではマップチップを「タイルセット」、マップを「地図」と呼びます。
著作権などのややこしいことは避けたいので、今回は雑ではありますが、自作しました。
- マップチップ
- マップチップ 拡大版(分かりやすくするために番号をつけています)
なお、今回はキャラクターもここに含めていて、いちばん右下(E
)をプレイヤーキャラクター(棒人間)としています。
この画像と、地図データを組み合わせて、迷路の画像を作ります。
地図データのフォーマット
地図データは、テキストファイルで作成します。
今回は、単純にするために、ASCII文字1字で1マスを表現することにします。
また、地形を表すコードなので、地形コードと呼ぶことにします。
画像を16種類用意しているので、地形コードはピリオドと0
~9
、A
~E
(.0123456789ABCDE
)を使用します。
ピリオドを入れているのは、テキストファイルでの視認性のためです。地図データはスプレッドシートで作成すると楽2なので、その場合はスペースだと視認しにくいので避けました。
なお、下記のデータは、冒頭に貼ってある画像のものとは異なっています。
- 地図情報(テキストファイル
map.txt
)
AAAAAAAAAAAAAAAAAAAAAAAAAA A8C......................A A.C.CCCCCCCBBBBBC.CCCCCC.A A.C.C...C.......C.C....C.A A.C.CCCCC.CCCCCCC.C....C.A A.C.5656C.C.....C.C....C.A A.C.6565C.C.....C.C....C.A A.C.5656C.C.....C.C....C.A A9C.6565C.C.CCCCC.C....C.A ACC.....C.C.D.....C....C.A A.C.....C3C.D.CCC.CCCCCC.A A.C.....C3C.D...C.7....C.A A.C.....C3C.D..CC.C....C.A A.C.....C.C.C..C..C...9C.A A.C.....C.C....C.CCBBBCC.A A.C.....C.CBBBCCCCC....C.A A.C.CBBBC.......8..9...C.A A.C.......CAAAAAAAAAAAAC.A A.C.CBBBC.CA0123456789AC.A A.C.CBBBC.CA..ABCDE...AC.A A.C.....C.CAAAAAAAAAAAAC.A A.CCCCCCC.C.CCCC.CBBCCCC.A A..............C.........A AAAAAAAAAAAAAAAAAAAAAAAAAA
このファイルを読み込んで、char
型の2次元配列に格納します。
地図情報ファイルを読み込む際は、スプレッドシートから直接コピペしても良いように、タブ文字は除外します。
地形コードは、通れるかどうかを判定するのにも使用します。 今回はパターンが少ないので、第6ビットが1のA~Eを通れない地形、それ以外の第6ビットが0のものを通れる地形にします。
第6ビットが0かどうかを判定するには、ビット演算の論理積を使って、(code & 0b01000000) == 0
のようにします。
ビット操作については以下の記事などを参考にして下さい。
実装
ここに書かれているもの以外は、Gistの完成形をご参照ください。
地図データ読み込み
地図情報(map.txt
)を読み取ってchar[][]
に変換します。
// インポートは省略 char[][] readMapData() { List<String> a = new ArrayList<>(); try (Scanner scanner = new Scanner(getClass().getResourceAsStream("map.txt"))) { while (scanner.hasNextLine()) { String line = scanner.nextLine(); a.add(line.replace("\t", "")); } } int lineCount = a.size(); char[][] chars = new char[lineCount][]; for (int i = 0; i < lineCount; i++) { chars[i] = a.get(i).toCharArray(); } // すべての行が同じ桁数かどうかのチェックは省略 return chars; }
タイルセットを使って地図画像を生成
プログラム上では、マップチップから指定した番号の画像を取り出せるようにすると便利です。
今回は、地形コード(char
値)を渡すと、対応する地形をBufferedImage
で返すクラスTileset
を定義します。
TSIZE
はタイルサイズを示すグローバル定数(static import
している)です。タイルサイズは32x32なので、TSIZE
は32です。
Tileset
クラス
// インポートは省略 final class Tileset { static String codemap = ".0123456789ABCDE"; private BufferedImage image; Tileset() { try { this.image = ImageIO.read(getClass().getResourceAsStream("tileset.png")); } catch (IOException e) { throw new UncheckedIOException(e); } } BufferedImage getTile(char code) { int index = codemap.indexOf(code); if (index < 0) { throw new IllegalArgumentException("unexpected code: " + code); } int x = (index % 4) * TSIZE; int y = (index / 4) * TSIZE; return image.getSubimage(x, y, TSIZE, TSIZE); } }
このTileset
クラスと地図情報を使って、地図画像を生成します。
地図画像のサイズは、余白を空けるために大きめにしています。
- 地図情報を元に地図画像を生成
// インポートは省略 private char[][] data = readMapData(); BufferedImage createImage() { BufferedImage bi = new BufferedImage(TSIZE * 124, TSIZE * 120, BufferedImage.TYPE_INT_RGB); Graphics g = bi.getGraphics(); Tileset tileset = new Tileset(); int verticalLength = data.length; int horizontalLength = data[0].length; int gY = TSIZE * 8; for (int y = 0; y < verticalLength; y++) { int gX = TSIZE * 8; for (int x = 0; x < horizontalLength; x++) { g.drawImage(tileset.getTile(getCode(x, y)), gX, gY, null); gX += TSIZE; } gY += TSIZE; } return bi; }
これで1フロア分の地図画像が完成しました。
あとは、BufferedImage#getSubimage
メソッドを使って、区画を切り取って画面に表示します。
補足
前回からの改良点として、以下があります。
- 画面更新スレッドは、ループの代わりに
ScheduledExecutorService
を使うようにした - イベント処理をメインループ方式にしたことに伴い、入力デバイスをまとめる処理を追加
完成形
GistにJavaソースコード(App.java
,ControllerInput.java
)をアップしてあります。
Gradleプロジェクト、もしくはMavenプロジェクトに、下記のようにファイルを配置します。
- ファイルの配置
│ build.gradle └─src └─main ├─java │ └─game │ App.java │ ControllerInput.java └─resources └─game map.txt tileset.png
後は、ビルドして、game.App.main()
を実行するだけです。
ビルドが面倒という方のために、Dropboxにビルド済みファイル一式を用意しました。
※ご利用の際は自己責任でお願いいたします。
今回はほとんど頭の中にある情報だけで構築できました。
次はもうちょっとゲームらしいものを作ってみたいと思っています。
(おわり)