
前回は、JavaでRPGの画面を作り、勇者をフィールド上で動かせるようにしました。
草原のマス目を表示し、勇者の画像を配置して、矢印キーで移動できるようにしました。
また、前回の記事では、ゲームの状態を管理する Rpg クラスと、画面を表示する RpgFrame クラスを分けました。
Rpg は Model です。
勇者の位置など、ゲームの状態を管理します。
RpgFrame は View ですが、キー入力を受け取る Controller の役割も持っています。
Rpg の状態を見て、フィールド画面を表示します。
今回は、この形を崩さずに、敵との遭遇処理を追加していきます。
RPGらしくするなら、フィールドを歩いているときに敵と遭遇したいところです。
そこで今回は、勇者が移動したときに一定の確率でスライムと遭遇するようにします。
そして、スライムと遭遇したら戦闘画面を表示してみます。
今回作るもの
今回作る内容は、次の通りです。
- 勇者が移動したときに敵との遭遇判定を行う
- 一定の確率でスライムと遭遇する
- スライムと遭遇したら戦闘画面を表示する
- 戦闘画面に勇者とスライムを表示する
- HeroLabel、FieldLabel、SlimeLabel の共通処理を ImageLabel にまとめる
ただし、ここで注意したいことがあります。
敵との遭遇判定を RpgFrame に書いてしまうと、RpgFrame が画面表示だけでなく、ゲームのルールまで持つことになります。
Rpg を Model として、その View として RpgFrame を作りました。
敵と遭遇するかどうかは、ゲームの状態やルールに関わる処理です。
そのため、遭遇判定は Rpg に書きます。
RpgFrame は、キー入力を受け取り、Rpg にキー入力による移動を伝え、必要に応じて画面を表示します。
今回のクラスの役割
今回の主なクラスの役割は、次のようになります。
| クラス | 役割 |
|---|---|
| Rpg | ゲームの状態を管理する |
| RpgFrame | フィールド画面を表示する |
| BattleFrame | 戦闘画面を表示する |
| Hero | 勇者の情報を持つ |
| Slime | スライムの情報を持つ |
| ImageLabel | 画像表示の共通処理を持つ |
| HeroLabel | 勇者の画像を表示する |
| FieldLabel | フィールド画像を表示する |
| SlimeLabel | スライムの画像を表示する |
RPGでは、フィールドを歩いているときに敵と遭遇することがあります。
そのため、敵との遭遇判定は、勇者が移動したあとに行うのが自然です。
たとえば、右キーを押して勇者が右に移動したあと、敵と遭遇するかどうかを判定します。
ただし、キーを押しただけで、実際には移動していない場合もあります。
たとえば、勇者がフィールドの左端にいるときに左キーを押しても、勇者は移動しません。
この場合は、敵との遭遇判定をしない方が自然です。
そこで、今回は「実際に移動した場合だけ」敵との遭遇判定を行うようにします。
移動できたかどうかを返す
前回の Rpg クラスでは、勇者を上下左右に動かすために、次の4つのメソッドを作りました。
- moveLeft()
- moveRight()
- moveUp()
- moveDown()
今回は、このメソッドを少し変更します。
勇者が実際に移動できた場合は true を返します。
移動できなかった場合は false を返します。
まず、左に動く処理を見てみます。
public boolean moveLeft() {
if (heroX > 0) {
heroX--;
return true;
}
return false;
}
heroX が0より大きければ、左に移動できます。
その場合は、heroX-- してから true を返します。
すでに左端にいる場合は移動できません。
その場合は false を返します。
右、上、下も同じようにします。
public boolean moveRight() {
if (heroX < FIELD_WIDTH - 1) {
heroX++;
return true;
}
return false;
}
public boolean moveUp() {
if (heroY > 0) {
heroY--;
return true;
}
return false;
}
public boolean moveDown() {
if (heroY < FIELD_HEIGHT - 1) {
heroY++;
return true;
}
return false;
}
これで、RpgFrame 側では、勇者が実際に移動したかどうかを判断できるようになります。
Rpgにスライムと遭遇判定を追加する
次に、Rpg に敵との遭遇処理を追加します。
敵と遭遇するかどうかは、ゲームのルールです。
そのため、RpgFrame ではなく Rpg に書きます。
import java.util.Random;
public class Rpg {
public static final int FIELD_WIDTH = 10;
public static final int FIELD_HEIGHT = 8;
private Hero hero;
private Slime slime;
private int heroX;
private int heroY;
private boolean inBattle;
private Random random = new Random();
public Rpg() {
hero = new Hero();
heroX = 0;
heroY = 0;
inBattle = false;
}
public Hero getHero() {
return hero;
}
public Slime getSlime() {
return slime;
}
public int getHeroX() {
return heroX;
}
public int getHeroY() {
return heroY;
}
public boolean isInBattle() {
return inBattle;
}
public boolean moveLeft() {
if (heroX > 0) {
heroX--;
return true;
}
return false;
}
public boolean moveRight() {
if (heroX < FIELD_WIDTH - 1) {
heroX++;
return true;
}
return false;
}
public boolean moveUp() {
if (heroY > 0) {
heroY--;
return true;
}
return false;
}
public boolean moveDown() {
if (heroY < FIELD_HEIGHT - 1) {
heroY++;
return true;
}
return false;
}
public boolean encounter() {
if (inBattle) {
return false;
}
int value = random.nextInt(100);
if (value < 20) {
slime = new Slime();
inBattle = true;
return true;
}
return false;
}
public void endBattle() {
inBattle = false;
slime = null;
}
}
今回追加した主なフィールドは、次の3つです。
private Slime slime; private boolean inBattle; private Random random = new Random();
slime は、遭遇したスライムです。
敵と遭遇したときに、ここに Slime オブジェクトを入れます。
inBattle は、現在戦闘中かどうかを表します。
戦闘中にさらに敵と遭遇して、戦闘画面が何枚も開いてしまうと困ります。
そのため、戦闘中かどうかを Rpg に持たせます。
random は、乱数を作るためのオブジェクトです。
encounter() メソッドを作る
敵との遭遇判定は、encounter() メソッドにまとめます。
public boolean encounter() {
if (inBattle) {
return false;
}
int value = random.nextInt(100);
if (value < 20) {
slime = new Slime();
inBattle = true;
return true;
}
return false;
}
まず、すでに戦闘中なら false を返します。
if (inBattle) {
return false;
}
次に、乱数を作ります。
int value = random.nextInt(100);
random.nextInt(100) は、0から99までの整数をランダムに返します。
今回は、value が20未満なら敵と遭遇したことにします。
if (value < 20) {
0から99までの100通りのうち、0から19までの20通りで敵と遭遇します。
つまり、20%の確率でスライムと遭遇することになります。
敵と遭遇したら、Slime オブジェクトを作ります。
slime = new Slime();
そして、戦闘中にします。
inBattle = true;
最後に、敵と遭遇したことを表すために true を返します。
return true;
敵と遭遇しなかった場合は、false を返します。
return false;
このようにしておくと、RpgFrame は次のように書けます。
if (rpg.encounter()) {
BattleFrame battleFrame = new BattleFrame(rpg);
battleFrame.setVisible(true);
}
RpgFrame は、敵と遭遇したかどうかを Rpg に聞いています。
敵との遭遇確率や、スライムを作る処理は Rpg にあります。
戦闘を終了する endBattle()
今回はまだ本格的な戦闘はしません。
ただ、戦闘画面を閉じたら、戦闘中ではない状態に戻したいです。
そのために、endBattle() メソッドを作っています。
public void endBattle() {
inBattle = false;
slime = null;
}
inBattle を false に戻します。
inBattle = false;
そして、遭遇中のスライムを null にします。
slime = null;
これで、戦闘画面を閉じたあと、またフィールドを歩いて敵と遭遇できるようになります。
RpgFrameでキー入力を処理する
次に、RpgFrame を修正します。
RpgFrame は、キー入力を受け取ります。
ただし、勇者の位置を自分で変更するのではなく、Rpg に命令します。
@Override
public void keyPressed(KeyEvent e) {
if (rpg.isInBattle()) {
return;
}
boolean moved = false;
if (e.getKeyCode() == KeyEvent.VK_LEFT) {
moved = rpg.moveLeft();
}
if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
moved = rpg.moveRight();
}
if (e.getKeyCode() == KeyEvent.VK_UP) {
moved = rpg.moveUp();
}
if (e.getKeyCode() == KeyEvent.VK_DOWN) {
moved = rpg.moveDown();
}
if (moved) {
drawField();
checkEncounter();
}
}
まず、戦闘中なら何もしないようにしています。
if (rpg.isInBattle()) {
return;
}
戦闘画面が開いているときにフィールド上の勇者が動くと、少し変な感じになります。
そこで、戦闘中はフィールド画面のキー入力を無視しています。
次に、勇者が移動したかどうかを覚えるための変数を用意します。
boolean moved = false;
左キーが押されたら、Rpg に左へ動くように伝えます。
moved = rpg.moveLeft();
右、上、下も同じです。
moved = rpg.moveRight(); moved = rpg.moveUp(); moved = rpg.moveDown();
ここで、RpgFrame は heroX や heroY を直接変更していません。
実際に勇者の位置を変更するのは Rpg です。
そして、実際に移動できた場合だけ、画面を描き直し、敵との遭遇判定を行います。
if (moved) {
drawField();
checkEncounter();
}
RpgFrameのcheckEncounter()
次に、RpgFrame 側の checkEncounter() を作ります。
ただし、ここでは敵との遭遇確率を計算しません。
遭遇判定そのものは Rpg に任せます。
RpgFrame は、敵と遭遇したら戦闘画面を表示するだけです。
private void checkEncounter() {
if (rpg.encounter()) {
BattleFrame battleFrame = new BattleFrame(rpg);
battleFrame.setVisible(true);
}
}
rpg.encounter() が true を返したら、敵と遭遇したということです。
その場合は、BattleFrame を作って表示します。
BattleFrame battleFrame = new BattleFrame(rpg);
battleFrame.setVisible(true);
ここでも、BattleFrame に Rpg を渡しています。
戦闘画面も、同じゲームの状態を見て表示するためです。
RpgFrameの全体コード
ここまでをまとめると、RpgFrame は次のようになります。
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import javax.swing.JFrame;
public class RpgFrame extends JFrame implements KeyListener {
private Rpg rpg;
public RpgFrame(Rpg rpg) {
super("RPG");
this.rpg = rpg;
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setLayout(new GridBagLayout());
drawField();
addKeyListener(this);
setFocusable(true);
pack();
setLocationRelativeTo(null);
}
private void drawField() {
getContentPane().removeAll();
for (int y = 0; y < Rpg.FIELD_HEIGHT; y++) {
for (int x = 0; x < Rpg.FIELD_WIDTH; x++) {
GridBagConstraints gbc = new GridBagConstraints();
gbc.gridx = x;
gbc.gridy = y;
if (x == rpg.getHeroX() && y == rpg.getHeroY()) {
add(new HeroLabel(), gbc);
} else {
add(new FieldLabel(), gbc);
}
}
}
revalidate();
repaint();
}
private void checkEncounter() {
if (rpg.encounter()) {
BattleFrame battleFrame = new BattleFrame(rpg);
battleFrame.setVisible(true);
}
}
@Override
public void keyPressed(KeyEvent e) {
if (rpg.isInBattle()) {
return;
}
boolean moved = false;
if (e.getKeyCode() == KeyEvent.VK_LEFT) {
moved = rpg.moveLeft();
}
if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
moved = rpg.moveRight();
}
if (e.getKeyCode() == KeyEvent.VK_UP) {
moved = rpg.moveUp();
}
if (e.getKeyCode() == KeyEvent.VK_DOWN) {
moved = rpg.moveDown();
}
if (moved) {
drawField();
checkEncounter();
}
}
@Override
public void keyReleased(KeyEvent e) {
}
@Override
public void keyTyped(KeyEvent e) {
}
}
RpgFrame の役割は、主に次の2つです。
- Rpg の状態を画面に表示する
- キー入力を受け取り、Rpg に命令する
画面表示に関する処理は RpgFrame にあります。
ゲームの状態やルールは Rpg にあります。
戦闘画面を表すBattleFrameクラスを作る
次に、敵と遭遇したときに表示する戦闘画面を作ります。
戦闘画面用のクラスとして、BattleFrame を作ります。
import java.awt.GridLayout;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
public class BattleFrame extends JFrame {
private Rpg rpg;
public BattleFrame(Rpg rpg) {
super("戦闘");
this.rpg = rpg;
setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
rpg.endBattle();
}
});
createScreen();
pack();
setLocationRelativeTo(null);
}
private void createScreen() {
JPanel panel = new JPanel(new GridLayout(2, 2));
panel.add(new HeroLabel());
panel.add(new SlimeLabel());
panel.add(new JLabel(rpg.getHero().name, JLabel.CENTER));
panel.add(new JLabel(rpg.getSlime().name, JLabel.CENTER));
add(panel);
}
}
BattleFrame も Rpg を受け取ります。
private Rpg rpg;
public BattleFrame(Rpg rpg) {
this.rpg = rpg;
}
戦闘画面でも、勝手に new Hero() や new Slime() をしません。
勇者やスライムは、Rpg が持っています。
BattleFrame は、その Rpg の状態を見て、戦闘画面を表示します。
BattleFrameはView
BattleFrame は View です。
名前から想像すると、Battle の View ですが、今は Battle というクラスはありません。
ただ、勇者とスライムが遭遇して戦闘が始まるので、そのために BattleFrame を作ったのです。
戦闘画面には、勇者やスライムを表示します。
panel.add(new HeroLabel()); panel.add(new SlimeLabel());
勇者とスライムの名前も、Rpg から取得しています。
panel.add(new JLabel(rpg.getHero().name, JLabel.CENTER)); panel.add(new JLabel(rpg.getSlime().name, JLabel.CENTER));
今はまだ、名前を表示しているだけです。
次回以降、HPや攻撃ボタンを追加するときも、Rpg が持っている勇者やスライムの情報を表示する形にしていきます。
戦闘画面を閉じたら戦闘終了にする
BattleFrame では、ウィンドウを閉じたときに rpg.endBattle() を呼び出しています。
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
rpg.endBattle();
}
});
戦闘画面を閉じたら、Rpg 側の inBattle を false に戻します。
これにより、再びフィールドを歩いて敵と遭遇できるようになります。
本格的なRPGなら、戦闘に勝ったとき、逃げたとき、負けたときなどで処理を分けます。
今回は、戦闘画面を閉じたら戦闘終了にする、という簡単な形にしておきます。
スライム画像を表示するSlimeLabelクラスを作る
戦闘画面にスライム画像を表示するために、SlimeLabel クラスを作ります。
ただし、その前に、画像表示の共通処理を整理しておきます。
前回の記事では、勇者の画像を表示するために HeroLabel を作りました。
また、草原の画像を表示するために FieldLabel も作りました。
今回、スライム画像を表示する SlimeLabel を追加すると、画像表示用のクラスが3つになります。
- HeroLabel
- FieldLabel
- SlimeLabel
これらは、どれも画像を表示するためのクラスです。
違うのは、読み込む画像ファイル名だけです。
このように、複数のクラスに同じような処理が出てきたら、共通部分を親クラスにまとめることを考えます。
前の記事では、Hero と Slime の共通部分を Chara クラスにまとめました。
今回は、HeroLabel、FieldLabel、SlimeLabel の共通部分を、ImageLabel にまとめてみます。
画像表示の共通処理をImageLabelクラスにまとめる
HeroLabel、FieldLabel、SlimeLabel は、どれも画像を表示するためのラベルです。
そこで、画像を読み込んでサイズを変更し、ラベルに設定する処理を ImageLabel クラスにまとめます。
import java.awt.Dimension;
import java.awt.Image;
import javax.swing.ImageIcon;
import javax.swing.JLabel;
public class ImageLabel extends JLabel {
public static final int ICON_WIDTH = 48;
public static final int ICON_HEIGHT = 48;
public ImageLabel(String filename) {
ImageIcon icon = new ImageIcon(filename);
Image image = icon.getImage().getScaledInstance(
ICON_WIDTH,
ICON_HEIGHT,
Image.SCALE_SMOOTH);
setIcon(new ImageIcon(image));
setPreferredSize(new Dimension(ICON_WIDTH, ICON_HEIGHT));
}
}
ImageLabel は JLabel を継承しています。
public class ImageLabel extends JLabel {
コンストラクタで、画像ファイル名を受け取っています。
public ImageLabel(String filename)
この filename に "hero.png" や "slime.png" を渡せば、それぞれの画像を表示できます。
画像サイズは、ImageLabel の中で定数として持たせています。
public static final int ICON_WIDTH = 48; public static final int ICON_HEIGHT = 48;
画像表示に関するサイズなので、RpgFrame ではなく ImageLabel に置いています。
これも、役割を分けるためです。
HeroLabelはImageLabelを継承する
次に、HeroLabel を書き換えます。
public class HeroLabel extends ImageLabel {
public HeroLabel() {
super("hero.png");
}
}
かなり短くなりました。
ImageLabel のコンストラクタには画像ファイル名が必要なので、super("hero.png") を呼び出しています。
super("hero.png");
super() は、親クラスのコンストラクタを呼び出すための書き方です。
ここでは、親クラスである ImageLabel に "hero.png" を渡しています。
FieldLabelもImageLabelを継承する
同じように、FieldLabel も ImageLabel を継承するようにします。
public class FieldLabel extends ImageLabel {
public FieldLabel() {
super("grass.png");
}
}
FieldLabel は、草原の画像を表示するためのクラスです。
そのため、super("grass.png") を呼び出します。
SlimeLabelもImageLabelを継承する
次に、スライム画像を表示する SlimeLabel を作ります。
public class SlimeLabel extends ImageLabel {
public SlimeLabel() {
super("slime.png");
}
}
SlimeLabel は、スライムの画像を表示するためのクラスです。
そのため、super("slime.png") を呼び出します。
HeroLabel、FieldLabel、SlimeLabel は、どれもとても短いクラスになりました。
画像を読み込む処理や、サイズを変更する処理は、すべて ImageLabel にまとめたからです。
ImageLabelを作ると何がうれしいのか
ImageLabel を作ることで、画像表示に関する共通処理を1か所にまとめることができました。
これは、継承の分かりやすい使用例です。
共通する処理を親クラスにまとめ、子クラスでは違いだけを書く。
今回の場合、違いは画像ファイル名だけです。
そのため、HeroLabel、FieldLabel、SlimeLabel では、親クラスに渡すファイル名だけを指定しています。
もし画像サイズを変えたくなった場合も、ImageLabel の定数を変更すれば済みます。
public static final int ICON_WIDTH = 48; public static final int ICON_HEIGHT = 48;
画像表示の処理がそれぞれのクラスに重複していると、同じ修正を何か所にも書く必要があります。
共通処理をまとめておくと、そのような重複を避けられます。
GuiAppはそのまま使える
前回作った GuiApp は、そのまま使えます。
import javax.swing.SwingUtilities;
public class GuiApp {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
Rpg rpg = new Rpg();
RpgFrame frame = new RpgFrame(rpg);
frame.setVisible(true);
});
}
}
GuiApp で Rpg を作ります。
Rpg rpg = new Rpg();
その Rpg を RpgFrame に渡します。
RpgFrame frame = new RpgFrame(rpg);
敵と遭遇したときは、RpgFrame が同じ Rpg を BattleFrame に渡します。
BattleFrame battleFrame = new BattleFrame(rpg);
これで、フィールド画面と戦闘画面が、同じゲームの状態を見ることができます。
クラス図で整理する
今回のクラスを簡単なクラス図で整理すると、次のようになります。
+----------------+
| GuiApp |
+----------------+
| main(args) |
+----------------+
|
| 作る
v
+----------------+
| Rpg | ← Model
+----------------+
| hero |
| slime |
| heroX |
| heroY |
| inBattle |
| random |
+----------------+
| moveLeft() |
| moveRight() |
| moveUp() |
| moveDown() |
| encounter() |
| endBattle() |
+----------------+
△
| 状態を見る / 操作する
+-------------------------+
| |
+----------------+ +----------------+
| RpgFrame | | BattleFrame |
+----------------+ +----------------+
| rpg | | rpg |
+----------------+ +----------------+
| drawField() | | createScreen() |
| keyPressed(e) | +----------------+
| checkEncounter()|
+----------------+
+----------------+
| JLabel |
+----------------+
△
|
+----------------+
| ImageLabel |
+----------------+
| ICON_WIDTH |
| ICON_HEIGHT |
+----------------+
△
|
+--------------------+--------------------+
| | |
+----------------+ +----------------+ +----------------+
| HeroLabel | | FieldLabel | | SlimeLabel |
+----------------+ +----------------+ +----------------+
ModelとViewを分ける意味
今回のような小さなプログラムなら、RpgFrame に全部書いても動きます。
RpgFrame に Random を持たせて、checkEncounter() を書き、スライムも作ることはできます。
でも、それを続けると、だんだん分かりにくくなります。
敵との遭遇。
戦闘開始。
HPの増減。
勝敗判定。
アイテム。
経験値。
ゲームオーバー。
こういった処理をすべて画面クラスに書いていくと、画面表示のクラスなのか、ゲーム本体なのか分からなくなります。
そこで、ゲームの状態やルールは Rpg に置きます。
画面表示は RpgFrame や BattleFrame に任せます。
この分け方をしておくと、あとから機能を追加しても、どこに何を書くべきか分かりやすくなります。
まとめ:移動すると敵と遭遇するようになった
今回は、勇者がフィールド上を移動したときに、敵と遭遇する処理を追加しました。
まず、Rpg の moveLeft()、moveRight()、moveUp()、moveDown() が、実際に移動できたかどうかを boolean で返すようにしました。
次に、Rpg に encounter() メソッドを追加しました。
encounter() では、Random を使って乱数を作り、20%の確率でスライムと遭遇するようにしました。
スライムと遭遇したら、Rpg の中で Slime オブジェクトを作り、戦闘中の状態にしました。
RpgFrame は、キー入力を受け取り、Rpg に勇者の移動を命令します。
そして、勇者が実際に移動した場合だけ、敵との遭遇判定を行います。
敵と遭遇したら、BattleFrame を表示します。
また、HeroLabel、FieldLabel、SlimeLabel の共通処理を ImageLabel にまとめました。
これで、フィールド画面から戦闘画面へつながる流れができました。
次回は、戦闘画面に攻撃ボタンを追加し、勇者とスライムを実際に戦わせてみます。
勇者がスライムを攻撃し、スライムが反撃し、HPが減っていく処理を作っていきます。