【JavaFX】カメラクラスを利用したゲームスクロールの実装

2019年3月27日

本稿はJavaFXプログラミングでゲームスクロールを実装する方法を考えてみたいと思います。

ゲームスクロールはシューティングゲーム、アクションゲーム、RPGなどで必要になります。

私は、今までswing、awtでゲームを制作したときに使用したスクロールは下記の二点があります。

1.全オブジェクトの座標はウィンドウ内座標(クライアント座標)を保持し、動かない物体等は逆側に移動させ、スクロールしているように見せる。
2.全てのオブジェクトの座標はワールド全体の座標で保持し、描画時に自作カメラクラスの座標を元に計算して表示する。

当初1の方法でRPGのスクロールを実現した時にややこしすぎて2の方法を考えた記憶があります。。。

今回は2のスクロールに似た方法になりますが、カメラは自作せず、JavaFXのカメラクラスを利用して少し楽にスクロールを実現してやりたいなと思います。

1.カメラクラスの種類

カメラクラスにはParallelCamera、PerspectiveCameraが用意されています。

ParallelCameraは平行投影(正射投影)をする。よくわかりませんが、常に中心に位置するらしいです。とりあえず試したところ、Z座標が反応しなくて、本来のデータの大きさが表示されました。まあこれは使いどころがあまりなさそう。多分3Dでも使わない。

PerspectiveCameraは透視投影カメラなんですって。こっちもよくわからんけど。こちらはx、y、z座標を考慮した風景が描画されます。恐らく一般的な3Dゲームもこちらのカメラの方式を使用しているのでしょう。

2DオンリーであればParallelCameraでも問題ないのですが、PerspectiveCameraを利用すればZ座標を操作することで、ズームインズームアウトのような処理も実現できます。今回は背景の奥行きスクロールをZ座標を使って実装したいと思っているので、PerspectiveCameraクラスを使用してスクロールを実現してみましょう。

2.PerspectiveCameraクラスの使用方法

インスタンス化して、Sceneオブジェクトに設定しておけば準備完了です。

あとは、translateXやtranslateYなどを設定してカメラ座標を変更することで描画位置が変更されます。カメラの座標はウィンドウ内描画領域のちょうど左上と一致します。

カメラ座標設定イメージ

試しに、X座標上にここが今どこかを文字列で表示させるオブジェクトを横並びに配置しておき、カメラスクロールで確認しに行くサンプルを作成してみます。

import javafx.animation.TranslateTransition;
import javafx.application.Application;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import javafx.util.Duration;

public class Test extends Application{


	@Override
	public void start(Stage primaryStage) throws Exception {
		Pane p = new Pane();
		Scene scene = new Scene(p, 400, 300);
		primaryStage.setScene(scene);
		primaryStage.show();


		//今どこにいるか表示(横に文字キャンバスを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			//Text t = new Text("ここのX座標は"+i * 200+"です");
			Canvas c = new Canvas(400,300);
			c.setTranslateX(i*400);
			c.setTranslateY(0);
			GraphicsContext g = c.getGraphicsContext2D();
			g.setFont(new Font(20));
			if ( i%2==0 ) {
				g.setFill(Color.BLACK);
				g.fillRect(0,0,400,300);
				g.setFill(Color.WHITE);
				g.fillText("ここのX座標は"+ (i * 400 + 200) +"です", 100, 100);
			} else {
				g.setFill(Color.WHITE);
				g.fillRect(0,0,400,300);
				g.setFill(Color.BLACK);
				g.fillText("ここのX座標は"+ (i * 400 + 200) +"です", 100, 100);
			}
			g.setStroke(Color.RED);
			g.setLineWidth(5);
			g.strokeLine(200, 0, 200, 300);
			p.getChildren().add(c);
		}

		//カメラの設置
		PerspectiveCamera camera = new PerspectiveCamera();
		scene.setCamera(camera);

		//カメラのX座標を2000まで移動させるアニメーション
		TranslateTransition moveAnimation = new TranslateTransition(Duration.seconds(10), camera);
		moveAnimation.setFromX(0);
		moveAnimation.setToX(2000);
		moveAnimation.play();
	}
}
実行結果

本当はTextオブジェクトを配置したかったんだけど、ウィンドウのサイズ分の座標までしか文字が表示されなかった。もしかしたらJavaFXのバグかもしれないです(Java9の時点)。TextクラスとPerspectiveCameraクラス一緒に使えないやないか!!誰かうまく表示する方法知ってませんか?

3.ステージをユーザーのカメラ操作でスクロールする

アクションゲームのステージのような背景を構築し、ユーザーがカメラを操作してスクロールする機能を作成してみます。

まずは、ステージを構築するための画像をいろいろ用意しました。数分で作れるような手抜き画像です。

これらを利用してステージを構築します。奥行きも考慮したいので2Dではあまり使用されないZ座標も設定します。

まずは奥行きの指定ですが、Nodeオブジェクトに対してZ座標を指定します。Z座標が大きくなれば、オブジェクトは遠くにあるように描画されます。遠くにあるものほど、画像が小さく描画され、スクロール量を自動で小さくしてくれます。

なので、空はZ座標を大きく、山はその次に大きく、木はその次に大きく・・・といった感じにZ座標を指定します。

Z座標の指定はsetTranslateZメソッドで指定できます。

キャラクタを用意したのは、ステージ上を歩かせるためです。

キャラクタの移動とカメラの移動はこちらの記事でも紹介したAnimationTimerクラスで実装します。

カメラの操作はこちらの記事で紹介した方法でキー操作処理を実装します。

だらだらと書いてもしょうがないので、実装サンプルとサンプルソースのコメントを参考にしてください。


import java.io.File;
import java.util.Random;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.Camera;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class Test extends Application{


	//自キャラオブジェクト
	ImageView chara = new ImageView(new File("nono.gif").toURI().toString());

	//木の画像
	Image ki = new Image(new File("ki.png").toURI().toString());

	//草の画像
	Image kusa = new Image(new File("kusa.png").toURI().toString());

	//草2の画像
	Image kusa2 = new Image(new File("kusa2.png").toURI().toString());

	//山の画像
	Image yama = new Image(new File("yama.png").toURI().toString());

	//空の画像
	Image sora = new Image(new File("sora.png").toURI().toString());

	//地面の画像
	Image jimen = new Image(new File("jimen.png").toURI().toString());

	//カメラオブジェクト
	Camera camera = new PerspectiveCamera();

	//キーフラグ
	boolean isLeft;
	boolean isRight;

	@Override
	public void start(Stage primaryStage) throws Exception {
		Group p = new Group();
		Scene scene = new Scene(p, 400, 300);
		primaryStage.setScene(scene);
		primaryStage.show();
		primaryStage.setResizable(false);

		//カメラの設置
		scene.setCamera(camera);


		//空のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(sora);
			img.setTranslateX(i*1600-1000);
			img.setTranslateZ(1600);
			img.setTranslateY(-450);
			p.getChildren().add(img);
		}

		//山のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(yama);
			img.setTranslateX(i*600);
			img.setTranslateZ(600);
			img.setTranslateY(0);
			p.getChildren().add(img);
		}

		//木のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(ki);
			img.setTranslateX(i*600);
			img.setTranslateZ(200);
			img.setTranslateY(80);
			p.getChildren().add(img);
		}

		//地面のセッティング(横にオブジェクトを100個並べる)
		for ( int i = 0;i < 100;i++ ) {
			ImageView img = new ImageView(jimen);
			img.setTranslateX(i*50);
			img.setTranslateY(250);
			p.getChildren().add(img);
		}

		//草のセッティングランダムに配置(横にオブジェクトを100個並べる)
		Random rand = new Random();
		for ( int i = 0;i < 100;i++ ) {
			ImageView img = null;
			int r = rand.nextInt(5);
			if ( r == 0 ) {
				img = new ImageView(kusa);
			} else if ( r == 1 ) {
				img = new ImageView(kusa2);
			} else {
				//何も配置しない
				continue;
			}
			img.setTranslateX(i*50);
			img.setTranslateY(200);
			p.getChildren().add(img);
		}

		//キャラのセッティング
		chara.setX(100);
		chara.setY(195);
		chara.setScaleX(-1);//画像反転
		p.getChildren().add(chara);

		//操作説明のオブジェクトを配置しておく
		Rectangle rect = new Rectangle(100,0,300,50);
		Text text = new Text(100, 30, "左右キーでカメラを移動させます。");
		text.setFont(new Font(20));
		text.setStroke(null);
		text.setFill(Color.WHITE);
		p.getChildren().add(rect);
		p.getChildren().add(text);


		//キーイベントの登録
		scene.setOnKeyPressed(this::keyPressed);
		scene.setOnKeyReleased(this::keyReleased);

		//ゲームループの起動
		new AnimationTimer() {

			@Override
			public void handle(long arg0) {
				gameLoop();
			}
		}.start();
	}

	//ゲームループ(通常60fpsで呼び出される)
	private void gameLoop() {
		//キャラ移動
		charaMove();

		//カメラの移動
		cameraMove();
	}

	//キャラ移動処理
	private void charaMove() {
		//右側に移動させる
		chara.setX(chara.getX()+0.8);
	}

	//カメラ移動処理
	private void cameraMove() {
		if ( isLeft ) {
			//カメラを左に移動
			camera.setTranslateX(camera.getTranslateX()-2);
		}
		if ( isRight ) {
			//カメラを右に移動
			camera.setTranslateX(camera.getTranslateX()+2);
		}
	}

	//キー押し下げイベント
	private void keyPressed(KeyEvent e) {
		//上下左右キーを押した時フラグをONにする。
		switch(e.getCode()) {
		case LEFT:
			isLeft = true;
			break;
		case RIGHT:
			isRight = true;
			break;
		default:
			break;
		}
	}

	//キーを離した時のイベント
	private void keyReleased(KeyEvent e) {
		//上下左右キーを離した時フラグをOFFにする。
		switch(e.getCode()) {
		case LEFT:
			isLeft = false;
			break;
		case RIGHT:
			isRight = false;
			break;
		default:
			break;
		}
	}
}
実行結果

サンプルプログラムではオブジェクトを配置しっぱなしにしていますが、実際のゲームでは座標によってオブジェクトを描画するかしないかの制御をする必要があります。

なぜかというと、画面外にあるオブジェクトも描画してしまうと無駄な処理が増え、処理速度が遅くなってしまうからです。

オブジェクトの描画を拒否するにはsetVisible(false)を指定してあげましょう。実際に大量にオブジェクトを配置すると処理が重くなりますが、これを設定するだけで、処理速度は遅くならなくなりました。

4.キー操作で動く自キャラにカメラを追従させたスクロール

キャラクターに追従したスクロールを実現するにはカメラ座標をキャラクタ座標に合わせて変更することで実現できます。

カメラが表している座標はウィンドウ内描画領域の左上の座標です。

ということは、単純にキャラをウィンドウ内の中心となるようにスクロールしたい場合、(キャラの中心座標 - ウィンドウの幅の半分)の計算結果をカメラ座標に毎フレーム設定しておけば実現できます。

※このイメージは横スクロールのみを想定しており、Y座標は0で設定するという前提です。

それではサンプルをご覧ください。


import java.io.File;
import java.util.Random;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Bounds;
import javafx.scene.Camera;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class Test extends Application{


	//自キャラオブジェクト
	Image charaStop = new Image(new File("nono_stop.gif").toURI().toString());
	Image charaAnime = new Image(new File("nono.gif").toURI().toString());
	ImageView chara = new ImageView(charaStop);

	//木の画像
	Image ki = new Image(new File("ki.png").toURI().toString());

	//草の画像
	Image kusa = new Image(new File("kusa.png").toURI().toString());

	//草2の画像
	Image kusa2 = new Image(new File("kusa2.png").toURI().toString());

	//山の画像
	Image yama = new Image(new File("yama.png").toURI().toString());

	//空の画像
	Image sora = new Image(new File("sora.png").toURI().toString());

	//地面の画像
	Image jimen = new Image(new File("jimen.png").toURI().toString());

	//カメラオブジェクト
	Camera camera = new PerspectiveCamera();

	//キーフラグ
	boolean isLeft;
	boolean isRight;

	//ウィンドウ表示領域の幅の半分
	double wx;

	@Override
	public void start(Stage primaryStage) throws Exception {
		Group p = new Group();
		Scene scene = new Scene(p, 400, 300);
		primaryStage.setScene(scene);
		primaryStage.show();
		primaryStage.setResizable(false);

		//シーンの横幅半分を算出
		wx = scene.getWidth() / 2;

		//カメラの設置
		scene.setCamera(camera);


		//空のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(sora);
			img.setTranslateX(i*1600-1000);
			img.setTranslateZ(1600);
			img.setTranslateY(-450);
			p.getChildren().add(img);
		}

		//山のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(yama);
			img.setTranslateX(i*600);
			img.setTranslateZ(600);
			img.setTranslateY(0);
			p.getChildren().add(img);
		}

		//木のセッティング(横にオブジェクトを5つ並べる)
		for ( int i = 0;i < 5;i++ ) {
			ImageView img = new ImageView(ki);
			img.setTranslateX(i*600);
			img.setTranslateZ(200);
			img.setTranslateY(80);
			p.getChildren().add(img);
		}

		//地面のセッティング(横にオブジェクトを100個並べる)
		for ( int i = 0;i < 100;i++ ) {
			ImageView img = new ImageView(jimen);
			img.setTranslateX(i*50);
			img.setTranslateY(250);
			p.getChildren().add(img);
		}

		//草のセッティングランダムに配置(横にオブジェクトを100個並べる)
		Random rand = new Random();
		for ( int i = 0;i < 100;i++ ) {
			ImageView img = null;
			int r = rand.nextInt(5);
			if ( r == 0 ) {
				img = new ImageView(kusa);
			} else if ( r == 1 ) {
				img = new ImageView(kusa2);
			} else {
				//何も配置しない
				continue;
			}
			img.setTranslateX(i*50);
			img.setTranslateY(200);
			p.getChildren().add(img);
		}

		//キャラのセッティング
		chara.setX(100);
		chara.setY(195);
		chara.setScaleX(-1);//画像反転
		p.getChildren().add(chara);

		//操作説明のオブジェクトを配置しておく
		Rectangle rect = new Rectangle(100,0,300,50);
		Text text = new Text(100, 30, "左右キーでキャラを移動させます。");
		text.setFont(new Font(20));
		text.setStroke(null);
		text.setFill(Color.WHITE);
		p.getChildren().add(rect);
		p.getChildren().add(text);


		//キーイベントの登録
		scene.setOnKeyPressed(this::keyPressed);
		scene.setOnKeyReleased(this::keyReleased);

		//ゲームループの起動
		new AnimationTimer() {

			@Override
			public void handle(long arg0) {
				gameLoop();
			}
		}.start();
	}

	//ゲームループ(通常60fpsで呼び出される)
	private void gameLoop() {
		//キャラ移動
		charaMove();

		//カメラの移動
		cameraMove();
	}

	//キャラ移動処理
	private void charaMove() {
		//キー押し下げ時の方向に移動します。
		if ( isLeft ) {
			chara.setScaleX(1);//画像反転
			chara.setImage(charaAnime);//アニメーション画像に差し替え
			chara.setX(chara.getX()-1);
		} else if ( isRight ) {
			chara.setScaleX(-1);//画像を普通に戻す
			chara.setImage(charaAnime);//アニメーション画像に差し替え
			chara.setX(chara.getX()+1);
		} else {
			//待機画像に差し替え
			chara.setImage(charaStop);
		}
	}

	//カメラ移動処理
	private void cameraMove() {
		Bounds b = chara.getBoundsInLocal();

		//キャラの中心X座標(キャラのX座標+キャラの幅の半分) - ウィンドウの幅の半分
		camera.setTranslateX(b.getMinX() + b.getWidth() / 2 - wx);
	}

	//キー押し下げイベント
	private void keyPressed(KeyEvent e) {
		//上下左右キーを押した時フラグをONにする。
		switch(e.getCode()) {
		case LEFT:
			isLeft = true;
			break;
		case RIGHT:
			isRight = true;
			break;
		default:
			break;
		}
	}

	//キーを離した時のイベント
	private void keyReleased(KeyEvent e) {
		//上下左右キーを離した時フラグをOFFにする。
		switch(e.getCode()) {
		case LEFT:
			isLeft = false;
			break;
		case RIGHT:
			isRight = false;
			break;
		default:
			break;
		}
	}
}
実行結果

テスト用の画像は目次3で利用した画像を使用しています。

これでキャラに追従するスクロールの実装が完了しました。

今回は横スクロールのみでしたが縦スクロールも同じような考え方で実現できます。

そろそろなんかゲーム作らないといけないな・・・

JavaJavaFX

Posted by nompor