【Godot】ラン&ジャンプゲームのチュートリアル

Godot Engine Advent Celendar 2022 21日目

この記事はGodot Engine Advent Celendar 2022 21日目の記事となります。


この記事では、Godot Engine を使用した Jump ’n’ run game (ラン&ジャンプゲーム) の作り方を解説します。

目次

ラン&ジャンプゲームの作り方

ラン&ジャンプゲームとは

ラン&ジャンプゲームとは、 ジャンプで足場を飛び移ることを目的とした. Platformer (プラットフォーマー)プレイヤーの移動を自動にしたものです。

おそらくですが、このジャンルは、携帯電話のアプリで登場した「チャリ走」が始まりなのではないかと思っています。

チャリ走全国一周 (出典:スパイシーソフト)

キャラクターは、自動で右方向に移動してしまうため、プレイヤーができることは「タイミング良くジャンプして足場を飛び移る」という操作のみで、そのゲームプレイに注力したステージ構成となっています。

その後、BIT.TRIP RUNNER などスタイリッシュな見た目になったり、そのお手軽なゲームプレイから、スマートフォン向けのアプリとしてスーパーマリオ ランなどが登場したりと、何かとカジュアルユーザーに人気のジャンルです。

BIT.TRIP RUNNER (出典:Steam)

最近の傾向としては、スマートフォンを縦持ちでプレイできることから、3Dで奥に向かって進むタイプのゲームが人気のようです。

Temple Run 2
(出典:Imangi Studios)

ただ今回の記事では 「2D機能」で実装するため、2Dの Side scroller (サイドビュー) で ラン&ジャンプ ゲームの作り方を紹介します。

使用する素材

今回使用する素材は以下のものとなります。

使用する素材
run-and-jump
 +-- bg_back.png: 背景画像 (奥)
 +-- bg_sky.png: 背景画像 (空)
 +-- floor.png: 足場画像
 +-- mplus_theme.tres: M+フォントテーマ
 +-- plus-1c-reqular.ttf: M+フォント(TTF)
 +-- player.png: プレイヤー画像
 +-- spike.png: トゲ画像

プロジェクト作成

それではプロジェクトを作成します。名前は何でもOKですが今回は「TestRunAndJump」としておきます。

プロジェクトを作成したら、素材データをすべて「ファイルシステム」にドラッグ&ドロップして追加しておきます。

プレイヤーの作成

まずはプレイヤーを作成します。プラットフォーマーを作るときのキャラクターは「KinemeticBody2D」だと都合が良いので、「KinematicBody2D」ノードを作成します。

ノード名は「Player」に変更しておきます。

Playerスプライトの作成

次に「player.png」をキャンバスにドラッグ&ドロップします。

この「player.png」は、8パターンのスプライトシートなので、PlayerスプライトのAnimation > Hframes」の値を「8にします。

コリジョンの作成

続けてコリジョンノードの「CollisionShape2D」を作成します。

ここまでのノード構成は以下のとおりです。

コリジョンの形状が設定されていないので、「CollisionShape2D」のインスペクタから「Shape > [空]」をクリックして「新規 RectangleShape2D (矩形コリジョン)」を選びます。

するとコリジョンが作られるのですが、スプライトの位置やコリジョンのサイズが合っていないので、調整をします。

おおよそこのようになれば良いです。

ただ、スプライトの足元と中央は、できれば揃うようになっていた方が良いです。

念のためパラメータをのせておきますが、このあたりの設定が面倒な場合は、プレイヤー作成ができたらいったんプロジェクトを添付しますので、そのデータをつかってしまっても良いです。

  • Playerスプライト
    • Transform > Position > x: 0
    • Transform > Position > y: -28
  • CollisionShape2Dノード
    • CollisionShape2D > Shape: RectangleShape2D
    • CollisionShape2D > Extents > x: 10
    • CollisionShape2D > Extents > y: 24
    • Transform > Position > x: 0
    • Transform > Position > y: -24

スクリプトのアタッチ

最後にスクリプトをアタッチします。

そしてスクリプトを以下のように記述します。

extends KinematicBody2D

const ANIM_SPEED = 7 # アニメーション速度
const GRAVITY = 45 # 重力
const JUMP_POWER = 1000 # ジャンプ力
const MOVE_SPEED = 300 # 移動速度.
const MAX_JUMP_CNT = 2 # 2段ジャンプまで可能.

onready var _spr = $Player # スプライト

var _tAnim:float = 0 # アニメーションタイマー.
var _velocity := Vector2.ZERO # 移動ベクトル.
var _is_jumping = false # ジャンプ中かどうか.
var _cnt_jump = 0 # ジャンプ回数.
var _speed := MOVE_SPEED # 移動速度.

# 更新.
func _process(delta: float) -> void:
	# アニメーションの更新.
	_update_anim(delta)

func _update_anim(delta:float) -> void:
	_tAnim += delta
	
	var t = int(_tAnim * ANIM_SPEED) % 2
	var tbl = [2, 3] # 走りアニメーションしか使わない.
	_spr.frame = tbl[t]

後々必要な定数や変数も追加しているので、少しややこしいですが、ここでやっているのはアニメーションの更新だけで、スプライトのフレームの「2」と「3」を交互に切り替えているだけです。

これを動かすためにいったんメインとなるシーンを作成します。

Mainシーンの作成

「+」をクリックするなどして新規シーンを作成します。

2Dシーンをクリックします。

新規で2Dシーンが作成されるので、名前を「Main」に変更します。

Mainシーンに「Player.tscn (プレイヤーシーン)」をドラッグ&ドロップで配置します。

Mainシーンを実行して配置したプレイヤーが走りアニメーションを行うのを確認します。

ここまでのプロジェクトファイル

ここまでのプロジェクトファイルを添付しておきます。

足場(Floor)の作成

新しくシーンを作成するので「+」をクリックします。

「その他のノード」をクリックして、「StaticBody2D」を作成します。

ノード名は「Floor」に変更しておきます。

スプライトの作成

次に「floor.png」をキャンバスにドラッグ&ドロップして Spriteを作成します。

CollisonShape2Dの作成

続けて「CollisionShape2D」を作成します。

ノード階層は以下のようになります。

CollsionShapeの種類が設定されていないので、インスペクタから「Shape > [空]」をクリックして「新規 RectangleShape2D」を選びます。

そうしたらコリジョンのサイズをスプライトの大きさに合わせます。

Extents の (x, y) = (30, 8) になるかと思います。

Mainシーンに配置

Floorシーンを保存したら、Mainシーンに配置します。

では実行して確認…と思ったのですが、プレイヤーの重力を設定していなかったので、そのあたりを設定します。

Playerの重力とジャンプを実装する

Player.gd を開いて以下のように修正します。

const JUMP_POWER = 1000 # ジャンプ力
const MOVE_SPEED = 300 # 移動速度.
const MAX_JUMP_CNT = 2 # 2段ジャンプまで可能.

onready var _spr = $Player # スプライト

var _tAnim:float = 0 # アニメーションタイマー.
var _velocity := Vector2.ZERO # 移動ベクトル.
var _is_jumping = false # ジャンプ中かどうか.
var _cnt_jump = 0 # ジャンプ回数.
var _speed := MOVE_SPEED # 移動速度.

# 更新.
func _process(delta: float) -> void:
	# アニメーションの更新.
	_update_anim(delta)

func _update_anim(delta:float) -> void:
	_tAnim += delta
	
	var t = int(_tAnim * ANIM_SPEED) % 2
	var tbl = [2, 3] # 走りアニメーションしか使わない.
	_spr.frame = tbl[t]

# -----------------------
# ここから重力とジャンプ処理
# -----------------------
func _physics_process(delta: float) -> void:
	
	# 重力を反映.
	_velocity.y += GRAVITY
	
	_velocity = move_and_slide(_velocity)
	
	if Input.is_action_just_pressed("ui_accept"):
		# ジャンプ.
		_velocity.y = -JUMP_POWER

追加したのは「ここから重力とジャンプ処理」以下の部分となります。

では実行して動作を確認します(SPACEキーでジャンプします)。

ここまでのプロジェクトファイル

ここまで実装した内容のプロジェクトファイルを添付しておきます。

プレイヤーのジャンプ周りの調整

ひとまずジャンプできるようになったのですが、さらにプレイヤーのジャンプアニメーションや2段ジャンプを実装しておきます。

ジャンプ中にアニメーションを変更する

現状だと「走りアニメーション」と「ジャンプ中のアニメーション」に変化がないので、ここを修正します。

Player.gd」を開いて “_physics_process()” を以下のように修正します。

func _physics_process(delta: float) -> void:
	
	# 重力を反映.
	_velocity.y += GRAVITY
	
	# ※ここの第2引数を追加
	_velocity = move_and_slide(_velocity, Vector2.UP)
	
	# ※ジャンプ中チェック.
	if is_on_floor():
		# 着地した
		_is_jumping = false
		_velocity.y = 0 # 重力クリア.
	else:
		# ジャンプ中.
		_is_jumping = true
	
	if Input.is_action_just_pressed("ui_accept"):
		# ジャンプ.
		_velocity.y = -JUMP_POWER

修正は2ヵ所です。

  1. move_and_slide() の第2引数に Vector2.UP を追加
  2. is_on_floor() で着しているかどうかのチェックを追加

is_on_floor() は 地面に着地しているかどうかを判定する便利な関数なのですが、それを有効にするためには move_and_slide() の第2引数にどの方向 (ベクトル) から接触したら床と扱うのか、という情報を与える必要があります。

これでジャンプ中かどうか (_is_jumping) を判定できるようになったので、_update_anim() を以下のように修正します。

func _update_anim(delta:float) -> void:
	_tAnim += delta
	
	if _is_jumping:
		# ジャンプ中
		_spr.frame = 2
		return
	
	var t = int(_tAnim * ANIM_SPEED) % 2
	var tbl = [2, 3] # 走りアニメーションしか使わない.
	_spr.frame = tbl[t]

実行して、ジャンプ中は「2」フレーム目のスプライト画像で固定されていることを確認します。

2段ジャンプを実装する

Player.gd_physics_process() を以下のように修正して Double Jump (2段ジャンプ) を実装します。

func _physics_process(delta: float) -> void:
	
	# 重力を反映.
	_velocity.y += GRAVITY
	
	# ※ここの第2引数を追加
	_velocity = move_and_slide(_velocity, Vector2.UP)
	
	# ジャンプ中チェック.
	if is_on_floor():
		# 着地した
		_is_jumping = false
		_velocity.y = 0 # 重力クリア.
		_cnt_jump = 0 # ジャンプ回数をリセットする.
	else:
		# ジャンプ中.
		_is_jumping = true
	
	if _cnt_jump < MAX_JUMP_CNT:
		if Input.is_action_just_pressed("ui_accept"):
			# ジャンプ.
			_velocity.y = -JUMP_POWER
			_cnt_jump += 1

ジャンプするたびに _cnt_jump をカウントアップして、2回までジャンプできるようにします。

では、実行して動作を確認します。

これでも良いのですが、2段ジャンプできるかどうかが一見わかりにくいという問題があります。

そこで2段ジャンプが可能な場合、front flip (前方宙返り) をするようにしてみます。_update_anim() を以下のように修正します。

func _update_anim(delta:float) -> void:
	_tAnim += delta
	
	if _is_jumping:
		# ジャンプ中
		if _cnt_jump == 1:
			# 2段ジャンプできるので前方宙返りする.
			_spr.rotation_degrees += 2000 * delta
		else:
			_spr.rotation_degrees = 0
		_spr.frame = 2
		return
	
	# 回転をリセットする.
	_spr.rotation_degrees = 0
	var t = int(_tAnim * ANIM_SPEED) % 2
	var tbl = [2, 3] # 走りアニメーションしか使わない.
	_spr.frame = tbl[t]

では実行して動作を確認します。

単に回転させているだけで、専用のアニメーションでないため、少し動きが粗いように見えますが、前よりは明確に2段ジャンプ可能なタイミングが視認できるようになります。

2段ジャンプを採用するメリット

基本的に Platformer (プラットフォーマー) では 2段ジャンプが存在すると遊びやすくなるので、採用したほうがメリットが多いです。理由としては以下の2点となります。

  • 1. 軌道修正しやすい:小さくジャンプしたりや空中で制御したりできる。それにより多彩なジャンプの軌道をコントロールできるようになる
  • 2. ジャンプの飛距離が伸びる:通常、2段ジャンプは重力の働きを打ち消すため飛距離が伸びるケースが多い

もちろんパズルゲームなど、2段ジャンプを採用することでゲームバランスが大きく崩れてしまうことがあるので、ケースバイケースではあります。

2段ジャンプを採用する際に考慮すべき要素

2段ジャンプを採用するときに考慮すべき要素を以下に列挙します。

  • 落下ダメージの回避:高いところから落下した際、着地直前で2段ジャンプすることで落下ダメージを回避できてしまう
  • 2段ジャンプできるかどうかが分かりづらい:明確な表示がない限り2段ジャンプできるかどうかがわからないことが多い(※2段ジャンプするとキャラクターが「前方宙返り」を行うなど、見た目を変化させるとわかりやすくなる)
  • タイミング要素を入れるべきではないドラゴンバスターではタイミングよく押さないと2段ジャンプできないようになっていたが、現代的なゲームではそういったタイミング要素は不要
  • 3段以上のジャンプは不要:通常3段以上のジャンプを採用する必要はない。理由はプラットフォーマー要素を無意味にしたり、わかりにくくなってしまうため。ただし、一時的なパワーアップとして無限空中ジャンプできるなどの採用方法であれば問題ない

ここまでのプロジェクトファイル

ここまで実装したプロジェクトファイルを添付しておきます。

スクロールの実装

続けてスクロールを実装します。

Camera2Dを配置する

Mainシーンに Camera2D を作成して配置します。

初期状態だとカメラの位置が (0, 0) に配置されてしまっているので、画面中央に移動しておきます。

Camera2D のインスペクタから、Node2D > Transform > Position」の (x, y) を (512, 300) にすることでも調整できます。

さらに インスペクタから「Current」にチェックを入れて有効化しておきます。

次に、Mainシーンにスクリプトをアタッチします。

スクリプトには以下のように記述します。

extends Node2D

# カメラの座標関連
const CAMERA_POS_Y = 320 # カメラの位置(Y).
const CAMERA_OFS_X = 400 # プレイヤーからのオフセット

onready var _player = $Player # プレイヤー.
onready var _camera = $Camera2D # カメラ.

func _ready() -> void:
	pass

func _process(delta: float) -> void:
	# カメラをプレイヤーに追従させる.
	_camera.position.x = _player.position.x + CAMERA_OFS_X

Playerノードからプレイヤー位置を取得し、それをカメラの X座標に反映させます。さらにカメラ位置はプレイヤーよりも手前にすることで、プレイヤーの表示位置を画面の左側に固定します。

実行すると、以下のような配置となっていることが確認できます。

カメラのY座標は画面中央で、X座標はプレイヤーよりも「前方」にしたいため「+400 (CAMERA_OFS_X)」となっています。

最後に Player.gd を開いて、_physics_process() に移動処理を実装しておきます。

func _physics_process(delta: float) -> void:
	# 移動速度を反映.
	_velocity.x = _speed
	
	# 重力を反映.
	_velocity.y += GRAVITY
	
	……

修正箇所は最初の「移動速度を反映」を追加するだけです。

では実行して動作を確認します。

スクロールは実装できた…ように見えるのですが、プレイヤーがすぐに落下して消えてしまいます。

Player.gd の _physics_process() を編集して落下して消えないようにしておきます。

func _physics_process(delta: float) -> void:
	# 移動速度を反映.
	_velocity.x = _speed
	
	# 重力を反映.
	_velocity.y += GRAVITY
		
	# デバッグ用 (画面外に出ないようにする)
	if position.y > 680:
		_velocity.y = -1500	

	# ※ここの第2引数を追加
	_velocity = move_and_slide(_velocity, Vector2.UP)

	……

追加したコードは「デバッグ用」の部分となります。

では実行して、画面外に出たときに上方向に移動して復帰することを確認します。

スクロールする背景を表示する

スクロールする背景を表示して、プレイヤーが右へ進んでいる印象を強くします。

なお、背景をスクロールさせる方法の詳細については、以下の記事で詳しく書いています。

【Godot】多重スクロールの実装方法

まずは Main シーンに ParallaxBackground を追加します。

背景は「最背面」に表示したいので、Mainノードの直下に移動させます。

そして「ParallaxLayer」ノードを 2つ追加します。

このようなノード構成になればOKです。

そして、「ParallaxLayerノード」に「bg_back.pngを配置、「ParallaxLayer2ノード」に「bg_sky.pngを配置します。

スプライトの位置は、それぞれ Offset > Centered” のチェックを外しTransform > Position を (0, 0) にすることで画面にピッタリ表示されるようになります。

そして、”ParallaxLayerノード” (BgBackスプライトが含まれるもの) を選び、インスペクタから以下のように設定します。

  • Motion > Scale > x: 0.7
  • Motion > Mirroring > x: 1024

これは、BgBack (町並みの背景) を 1024px (画面サイズ) でミラーリングし、カメラ速度の0.7の速さ (少しゆっくり) でスクロールする設定となります。

次に “ParallaxLayer2ノード” (BgSkyスプライトが含まれるもの) を選び、インスペクタから以下のように設定します。

  • Motion > Scale > x: 0.2
  • Motion > Mirroring > x: 1024

これは、BgSky (空の背景) を 1024px (画面サイズ) でミラーリングし、カメラ速度の0.2倍の速さ (ゆっくり) でスクロールする設定です。なぜ空の背景をここまでゆっくりにするのかというと、空は距離的に遠いため、ゆっくりにすることで奥行きを表現できるようになるためです。

では実行して背景がスクロールすることを確認します。

ここまでのプロジェクトファイル

ここまで実装したプロジェクトファイルを添付しておきます。

足場を自動生成する

スクロールが実装できたので、次に足場の自動生成を実装します。

足場管理用のノードを追加する

まずは足場管理用のノードとして Node2DMainシーンに追加します。

ノードの名前は「FieldMgr」にリネームしておきます。

そして、FieldMgr ノードの下の階層に「Floor」オブジェクトをすべて移動させておきます。

今後、足場オブジェクトはこの「FieldMgr」の下の階層にすべて配置します。

ゲーム開始時の足場を配置しておく

ゲーム開始状態の足場をいくつか追加したいので、現在配置済みの足場を複製したりして、以下のように並べておきます。

Godot のエディタは、「複製」や「スナップして移動」などの便利ツールが用意されているので、この機能で複製すると楽です。

少しややこしいので、手順を動画にしておきました。

足場を生成する

Main.gd を開いて、足場を生成する処理を実装します。

extends Node2D

# 生成するシーン
var obj_floor = preload("res://Floor.tscn") # 床オブジェクト

# カメラの座標関連
const CAMERA_POS_Y = 320 # カメラの位置(Y).
const CAMERA_OFS_X = 400 # プレイヤーからのオフセット
const FLOOR_INTERVAL = 64 # 足場は 64px 単位で登場する.
const SCREEN_W = 1024 # 画面の幅.

onready var _player = $Player # プレイヤー.
onready var _camera = $Camera2D # カメラ.
onready var _field_mgr = $FieldMgr # フィールド管理.

var _prev_x:float = 0 # 前回の座標(X)
var _prev_y:float = 320 # 前回の座標(Y)
var _cnt_create:int = 0 # 足場を生成した回数.
var _timer:float = 0 # 経過時間.

func _ready() -> void:
	# プレイヤーの初期位置を設定しておく.
	_prev_x = _player.position.x

func _process(delta: float) -> void:
	# カメラをプレイヤーに追従させる.
	_camera.position.x = _player.position.x + CAMERA_OFS_X
	
	# 足場の生成チェック.
	_check_floor()

# 足場の出現チェック.
func _check_floor() -> void:
	# FLOOR_INTERVALの単位になるようにする.
	var prev = floor(_prev_x / FLOOR_INTERVAL) * FLOOR_INTERVAL # 前回.
	var now = floor(_player.position.x / FLOOR_INTERVAL) * FLOOR_INTERVAL # 現在値.

	if prev != now:
		# 出現タイミングになった.
		_cnt_create += 1 # 生成カウンタをアップする.
		
		if _cnt_create%9 == 8:
			# 出現座標(Y)を更新する.
			var y = _prev_y + (randi()%8 - 4) * 32
			if y < 200:
				y = 200 # 200よりも上にならないようにする
			if y > 540:
				y = 540 # 540よりも下にならないようにする
			_prev_y = y
		if _cnt_create%9 < 6:
			# (0〜5)なら床出現.
			_create_floor(now, _prev_y)
	
	# 今回のプレイヤー位置を保存しておく.
	_prev_x = _player.position.x
	
# 足場を生成する.
func _create_floor(px:float, py:float) -> void:
	var obj = obj_floor.instance()
	obj.position.x = px + SCREEN_W
	obj.position.y = py
	_field_mgr.add_child(obj)

かなり大幅に書き換えていますが、注目すべきなのは _check_floor() です。

  1. FLOOR_INTERVALの単位 (64px) に値を丸める
  2. 前回の値と今回の値に変化があれば足場を生成する
  3. 足場は 6つ連続で作って、3つ空白を作る
  4. 9回判定したら、新しいY座標を再計算する

足場のサイズは1つあたり64pxなので、64pxのカメラのスクロール値が 64px の境界を超えたら足場を出現させます。

出現ルールとしては 6つ出現したら、3つぶん空白を開けるようにしています。そして、9つぶん処理したら足場の高さを変えるようにしています。

では実行して動作を確認します。

足場を「Oneway 床」にする

足場との衝突を色々と確認するとわかるのですが、横からや下から衝突したときに引っかかりが発生しています。

横から衝突すると引っかかる
下から衝突しても引っかかる

Godotの衝突は標準で全方向からの衝突応答(押し返し)が実装されています。(というよりも一般的にはこの実装です)

ただ今回の足場は見た目的にも上からのみ乗れるようにした方が、自然な感じがします。

このような一方向からのみ衝突がある床を Oneway床 (一方通行床) と呼びます。それでは、Godotで Oneway床を実装する方法について説明します。

といっても実装方法は簡単で、”Floor.tscn” を開いて、インスペクタからOne Way Collision」にチェックを入れるだけです。

ちなみに一方通行床を有効にすると、キャンバス上の Floorオブジェクトにコリジョンの有効な方向が表示されるようになります。

では実行して、一方通行床が有効になっていることを確認します。

画面外に出た足場を消す

動作上、あまり問題に気がしますが、画面外に消えた足場のインスタンスが消えていないのが気になったので、これを消す処理を実装します。

Main.gd を開いて、_precess() を以下のように修正します。

func _process(delta: float) -> void:
	# カメラをプレイヤーに追従させる.
	_camera.position.x = _player.position.x + CAMERA_OFS_X
	
	# 足場の生成チェック.
	_check_floor()
	
	# 画面外に出た足場を消す.
	for child in _field_mgr.get_children():
		if child.position.x < _player.position.x - 100:
			child.queue_free()

実行してプレイヤーの後ろ 100px に移動した床が消えることを確認します。

なお、今回はわかりやすく消えているところを見えるようにしましたが、通常はもう少し後ろにして消えるのを見えないようにした方が良いと思います。

ここまでのプロジェクトファイル

ここまでの実装内容を反映したプロジェクトファイルを添付しておきます。

補足:プラットフォーマーでの地形の自動生成

プラットフォーマーでの地形の自動生成

今回の地形の自動生成は、すべてスクリプトで実装しましたが、完全にランダムに実装するよりもある程度規則性をもたせたほうがゲームバランスが良くなります

というのも、プラットフォーマーのレベルを完全にランダムで作ってしまうと、ジャンプ力の制限により移動不可な地点が作られて詰み状態になりがちだからです。

 

その問題に対して、例えば Spelunky のランダムマップは、入口から出口までを移動可能である(接続している)マップをランダムに組み合わせで作り、それ以外を行き止まりのマップにする、という規則性を持ったランダムにより実現しています。

引用:Spelunkyのステージ作成法| Game Maker’s Toolkit

他にも Risk of Rain などもこの方法で実装しており、検証はしていないですがおそらくチャリ走のエンドレスステージもこの方法で実現している(=接続可能な固定マップをランダムで組み合わせる)と思われます。

 

さらに、Spelunky では、地形を壊せる「爆弾」アイテムや、高いところに登れる「ロープ」アイテムもあり、移動の自由度の高さによって、詰みを回避しやすいシステムにするのもありです。

今回のサンプルゲームでは「2段ジャンプ」を採用していますが、ジャンプに自由度をもたせることで、ランダムな床によって詰み状態になるのをできるだけ避けたい、という狙いもあります。

ゲームオーバー処理を実装する

最後にゲームオーバー処理を実装します。

ゲームオーバーラベルの追加

MainシーンLabel ノードを2つ追加します。

このように追加しておきます。

ノード名は「LabelSpeed」「LabelGameover」にリネームしておきました。

そしてそれぞれのインスペクタの「Control > Theme > [空]」をクリックして「読み込み」を選びます。

「ファイルを開く」ダイアログが表示されるので、「mplus_theme.tres」を選んで「開く」を押します。

これでフォントが適用されたので、「LabelSpeed」のテキストに「Speed: 300」として左上に配置、「LabelGameover」のテキストに「GAME OVER」と入力して、画面中央に配置します。

ただこのままだと、ラベルのテキストがカメラのスクロールに流されてしまいます。

ラベルの位置を固定化する

ラベルテキストの位置を固定する場合は「CanvasLayer」ノードを追加します。

そしてこのように CanvasLayerノードの下に各ラベルノードをぶら下げます。

これでラベルの位置が固定されます。

ゲームオーバーへの状態遷移

Main.gd を開いて以下のように編集します。

extends Node2D

# 生成するシーン
var obj_floor = preload("res://Floor.tscn") # 床オブジェクト

# -------------------------------------
# ※ここを追加.
# 状態
enum eState {
	MAIN, # メイン.
	GAMEOVER, # ゲームオーバー.
}
# -------------------------------------

# カメラの座標関連
const CAMERA_POS_Y = 320 # カメラの位置(Y).
const CAMERA_OFS_X = 400 # プレイヤーからのオフセット
const FLOOR_INTERVAL = 64 # 足場は 64px 単位で登場する.
const SCREEN_W = 1024 # 画面の幅.

onready var _player = $Player # プレイヤー.
onready var _camera = $Camera2D # カメラ.
onready var _field_mgr = $FieldMgr # フィールド管理.
# -------------------------------------
# ※ここを追加.
onready var _labelSpeed = $CanvasLayer/LabelSpeed
onready var _labelGameover = $CanvasLayer/LabelGameover
# -------------------------------------

var _prev_x:float = 0 # 前回の座標(X)
var _prev_y:float = 320 # 前回の座標(Y)
var _cnt_create:int = 0 # 足場を生成した回数.
var _timer:float = 0 # 経過時間.
# -------------------------------------
# ※ここを追加.
var _state = eState.MAIN # 状態.
# -------------------------------------

func _ready() -> void:
	……

「※ここを追加」と書かれている部分が追加箇所です。

状態定数(eState)や、ラベルノード(_labelSpeed, _labelGameover)の取得、状態変数(_state)の追加をしています。

続けて、Main.gd 内の _process() の修正と、新たに _update_main() / _update_gameover() を追加します。

func _process(delta: float) -> void:
	match _state:
		eState.MAIN:
			_update_main(delta)
		eState.GAMEOVER:
			_update_gameover(delta)

# 更新 > メイン.
func _update_main(delta:float) -> void:
	if is_instance_valid(_player) == false:
		# プレイヤーが消滅したらゲームオーバー.
		_state = eState.GAMEOVER
		return
	
	if _player.position.y > 640:
		# 落下死.
		_player.queue_free()
		return
		
	# カメラをプレイヤーに追従させる.
	_camera.position.x = _player.position.x + CAMERA_OFS_X
	
	# 足場の生成チェック.
	_check_floor()
	
	# 現在のスピードを表示.
	_labelSpeed.text = "SPEED: %d"%_player._speed
	
	# 画面外に出た足場を消す.
	for child in _field_mgr.get_children():
		if child.position.x < _player.position.x - 100:
			child.queue_free()
			
# 更新 > ゲームオーバー.
func _update_gameover(delta:float) -> void:
	# "GAME OVER" を表示.
	_labelGameover.visible = true
	
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでリスタート.
		get_tree().change_scene("res://Main.tscn")

追加した処理としては以下のとおりです。

  • プレイヤーのY座標が 640 を超えたら queue_free() で消滅させる
  • 現在のプレイヤーのスピードを _labelSpeed で表示する
  • プレイヤーのインスタンスが消滅したら eState.GAMEOVER に遷移する
  • ゲームオーバー時には “_labelGameover” を表示する
  • ゲームオーバー時には Spaceキーでリスタートできる

それと LabelGameover はゲームオーバー時のみ表示したいので、ノードツリー状で非表示にしておきます。

では実行して、落下するとゲームオーバーになる、”GAME OVER” の文字が表示される、Spaceキーでリスタートできることを確認します。

ゲームオーバー時に画面揺れを入れる

ゲームオーバー時のインパクトが弱いので、画面揺れを入れてみます。

Main.gd を開いて 揺れ時間の定数 (TIMER_SHAKE) を追加します。

extends Node2D

# 生成するシーン
var obj_floor = preload("res://Floor.tscn") # 床オブジェクト

const TIMER_SHAKE = 0.5 # 0.5秒の間揺らす

# 状態
enum eState {
	MAIN, # メイン.
	GAMEOVER, # ゲームオーバー.
}

それと揺れ時間を計測する変数 (_tShake) を追加します。

var _prev_x: float = 0
var _prev_y: float = 320
var _cnt_create: int = 0
var _timer: float = 0
var _state = eState.MAIN
var _tShake: float = 0 # 画面揺らすタイマー.

そして、_update_main() で eState.GAMEOVER に遷移するタイミングで 揺れ時間 (_tShake) を設定するように修正します。

func _update_main(delta: float) -> void:
	if is_instance_valid(_player) == false:
		# プレイヤーが消滅したらゲームオーバー.
		_tShake = TIMER_SHAKE # 揺れ開始.
		_state = eState.GAMEOVER
		return
		
	# 落下チェック.
	if _player.position.y > 640:

さらに _update_gameover() で画面揺れ処理を入れておきます。

func _update_gameover(delta: float) -> void:
	# "GAME OVER" を表示
	_label2.visible = true
	
	# 画面揺らす
	_camera.offset = Vector2.ZERO
	_tShake -= delta
	if _tShake > 0:
		var rate = _tShake / TIMER_SHAKE
		var vx = 64 * rate
		var vy = 16 * rate
		_camera.offset.x = rand_range(-vx, vx)
		_camera.offset.y = rand_range(-vy, vy)
		# 画面揺れ中はリスタートできなくする.
		return
	
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでリスタート
		get_tree().change_scene("res://Main.tscn")

では実行して動作を確認します。

なお、今回のコードでは、揺れ時間を処理する変数を定義しましたが、Godot 3.x では yield() 、Godot 4 以降では await() で実装する方法もありますので、興味があれば試してみても良いかもしれません。

完成プロジェクトファイル

一通りのゲームが完成したのでプロジェクトファイルを添付しておきます。

おまけ

いったんはここまででラン&ジャンプゲームは完成ですが、ここからはゲームらしさを追加する要素として、以下の機能を実装していきます。

  • 1. 時間が経過するほど移動速度が上昇させる
  • 2. 障害物を出現させる
  • 3. 2段ジャンプをしたときにスロー処理にする

1. 時間が経過するほど移動速度を上昇させる

Player.gd を開いて、速度設定と取得の関数を追加します。(_speed変数の定義方法に誤りがあったのでそれも修正しています

var _speed:float = MOVE_SPEED # 移動速度.

# 速度の設定
func set_speed(speed:float) -> void:
	_speed = speed
# 速度の取得
func get_speed() -> float:
	return _speed

GDScriptの変数は基本的にどこからでもアクセスできるので、わざわざ関数を用意する必要はないのですが、処理をわかりやすくするために追加しました。

次に Main.gd を開いて _update_main() を編集します。

# 更新 > メイン.
func _update_main(delta:float) -> void:
	if is_instance_valid(_player) == false:
		# プレイヤーが消滅したらゲームオーバー.
		_tShake = TIMER_SHAKE # 揺れ開始.
		_state = eState.GAMEOVER
		return
	
	if _player.position.y > 640:
		# 落下死.
		_player.queue_free()
		return
		
	# カメラをプレイヤーに追従させる.
	_camera.position.x = _player.position.x + CAMERA_OFS_X
	
	# 足場の生成チェック.
	_check_floor()
	
	# 時間経過に合わせてプレイヤーの速度を変更する
	_timer += delta
	_player.set_speed(200 + _timer * 30)	
	
	# 現在のスピードを表示.
	_labelSpeed.text = "SPEED: %d"%_player.get_speed()
	
	# 画面外に出た足場を消す.
	for child in _field_mgr.get_children():
		if child.position.x < _player.position.x - 100:
			child.queue_free()

では実行して動作を確認します。

ゲームの経過時間に合わせて移動速度が変化するようになりました。

ただ、個人的な印象では速度が 600 を超えたあたりから思うように制御ができなくなる上、速度が可変だと着地場所の予測が難しくなるため、ちゃんとゲームらしくするには速度ではなく、地形や障害物の配置をしっかり考えた方が良さそうです。

ここまでのプロジェクトファイル

ということで、ここまでのプロジェクトファイルを添付しておきます。

2. 障害物オブジェクトを生成する

Spikeノードの作成

次に障害物オブジェクトを実装してみます。「+」をクリックして新規シーンを追加します。

その他のノード」を選びます。

障害物であるスパイク(トゲ)は、プレイヤーとの接触判定だけ行えれば十分であるため「Area2D」ノードを選びます。

そしてノード名を「Spike」にリネームしてきます。

アニメーションの追加

続けて「AnimatedSprite」ノードを追加します。

AnimatedSpriteのインスペクタから「AnimatedSprite > Frames > [空]」をクリックして、「新規 SpriteFrames」を選びます。

そして作られたSpriteFrames」をクリックします。

するとエディタの下部に「アニメーションフレーム」が表示されるので、そこからスプライトシートからフレームを追加する」アイコンをクリックします。

ファイル選択ダイアログを表示されるので「spike.png」を選んで「開く」をクリックします。

フレームを選択ダイアログが表示されるので、以下のように設定します。

  • 水平:4
  • 垂直:1

そうしたら「すべてのフレームを選択 (消去)」をクリックするとフレームが分割されるので、「4フレームを追加」をクリックします。

アニメーションフレームが以下のように分割されればOKです。

そうしたら、AnimatedSpriteのインスペクタからPlaying」にチェックを入れてアニメーションを確認します。

コリジョンの設定

スパイクにコリジョンを設定します。「CollisionShape2D」ノードを追加します。

これにより、ノード階層は以下の通りとなります。

CollsionShape2Dのインスペクタから、「Shape > [空]」をクリックして、「新規 CircleShape2D」を選びます。

スパイクを覆うように円のコリジョンを設定します。

コリジョンレイヤーを設定する

次にコリジョンレイヤーを設定します。

メニューから「プロジェクト > プロジェクト設定」を選びます。

プロジェクト設定の「一般 > プロパティ > Layer Names > 2D物理」を選び、以下のように設定します。

  • Layer 1: Player
  • Layer 2: Floor
  • Layer 3: Spike

各オブジェクトのインスペクタの Collision 設定から LayerMask を以下のように設定します。

  • Player オブジェクト
    • Layer: 1 (Player)
    • Mask: 1 + 2 + 3 (すべてと衝突する)
  • Floor オブジェクト
    • Layer: 2 (Floor)
    • Mask: 1 (Playerとのみ衝突する)
  • Spike オブジェクト
    • Layer: 3 (Spike)
    • Mask: 1 (Playerとのみ衝突する)
Playerのコリジョンレイヤー設定
Floorのコリジョンレイヤー設定
Spikeのコリジョンレイヤー設定
コリジョンレイヤーの設定について

ちなみにこの設定ですが、今回は Spikeと衝突するのが Playerだけなので、設定しなくても動作します。

今後オブジェクトが追加されたときに、衝突を行うオブジェクトを制御するための拡張設定となります。

Spikeとの衝突時にPlayerを消滅させる

まずは Spikeにスクリプトを追加します。

ただ、何か特別な処理はしないので、以下のように、ほぼ空っぽで問題ないです。

extends Area2D

そして衝突のシグナルを実装するために、インスペクタから「ノード」を選んで「body_entered()」をダブルクリックします。

そのまま「接続」をクリックします。

シグナルメソッド「_on_Spike_body_entered()」を以下のように記述して、衝突したオブジェクト (Player) を削除するようにします。

extends Area2D

func _on_Spike_body_entered(body: Node) -> void:
	# 接触したオブジェクトを消す
	body.queue_free()

スパイクを生成する

Main.gd を開いて、スパイクの生成処理を実装します。

extends Node2D

# 生成するシーン
var obj_floor = preload("res://Floor.tscn") # 床オブジェクト
var obj_spike = preload("res://Spike.tscn") # スパイクオブジェクト

const TIMER_SHAKE = 0.5 # 0.5秒の間揺らす
……

preload() でスパイクオブジェクトを読み込んでおきます。

そして _check_floor() を以下のように修正します。

# 足場の出現チェック.
func _check_floor() -> void:
	# FLOOR_INTERVALの単位になるようにする.
	var prev = floor(_prev_x / FLOOR_INTERVAL) * FLOOR_INTERVAL # 前回.
	var now = floor(_player.position.x / FLOOR_INTERVAL) * FLOOR_INTERVAL # 現在値.

	if prev != now:
		# 出現タイミングになった.
		_cnt_create += 1 # 生成カウンタをアップする.
		
		if _cnt_create%9 == 8:
			# 出現座標(Y)を更新する.
			var y = _prev_y + (randi()%8 - 4) * 32
			if y < 200:
				y = 200 # 200よりも上にならないようにする
			if y > 540:
				y = 540 # 540よりも下にならないようにする
			_prev_y = y
		if _cnt_create%9 < 6:
			# (0〜5)なら床出現.
			_create_floor(now, _prev_y)
		# -----------------------------------
		# ※ここを追加
		if _cnt_create%19 == 0:
			# スパイク出現.
			_create_spike(now, _prev_y)
		# -----------------------------------		
	
	# 今回のプレイヤー位置を保存しておく.
	_prev_x = _player.position.x

「※ここを追加」が今回追加した部分です。床の生成数が 19の倍数の場合に生成するようにしています。

次に「_create_spike()」を追加します。

# スパイク生成.
func _create_spike(px: float, py: float) -> void:
	var obj = obj_spike.instance()
	obj.position.x = px + SCREEN_W
	obj.position.y = py - 64 - 32 * (randi()%4) # ランダムで床の上に生成する
	_field_mgr.add_child(obj)

床の生成関数 _create_floor() と似た処理になっていますが、Y座標を少しずらして床の上のランダムな位置に出現するところが異なります。

では実行して動作を確認します。

Spikeオブジェクトが自動生成され、接触するとゲームオーバーとなります。

ここまでのプロジェクトファイル

スパイクを実装したプロジェクトファイルを添付しておきます。

3. 2段ジャンプ時にスローにする

正直なところ、この処理は微妙なような(あってもなくてもゲームプレイの印象が変わらない)気がしましたが、ひょっとしたら需要があるかもしれない…ので記載しておきます。

仕組みとしては、Godotはデフォルトで 60FPS で動作しているのですが、一時的に 30FPS に落とすことでスローを実現しています。

Player.gd_physics_process() を以下のように修正します。

func _physics_process(delta: float) -> void:
	# 移動速度を反映.
	_velocity.x = _speed
	
	# 重力を反映.
	_velocity.y += GRAVITY
		
	# デバッグ用 (画面外に出ないようにする)
	if position.y > 680:
		_velocity.y = -1500	
		
	
	# -------------------------
	# ※ここを追加.
	Engine.set_time_scale(1)
	if _cnt_jump == MAX_JUMP_CNT and _velocity.y < 0:
		# 2段ジャンプの上昇中はスローになる.
		Engine.set_time_scale(0.5)	
	# -------------------------

	# ※ここの第2引数を追加
	_velocity = move_and_slide(_velocity, Vector2.UP)
	
	# ジャンプ中チェック.
	if is_on_floor():
		# 着地した
		_is_jumping = false
		_velocity.y = 0 # 重力クリア.
		_cnt_jump = 0 # ジャンプ回数をリセットする.
	else:
		# ジャンプ中.
		_is_jumping = true
	
	if _cnt_jump < MAX_JUMP_CNT:
		if Input.is_action_just_pressed("ui_accept"):
			# ジャンプ.
			_velocity.y = -JUMP_POWER
			_cnt_jump += 1

「※ここから追加」の部分が追加したところです。

Engine.set_time_scale() の初期値は「1」なので、「0.5」にすることでスローとなります。

プロジェクトファイル

スロー処理を実装したプロジェクトファイルを添付しておきます。