今回の記事では、2Dゲームでの画面揺れの実装方法を紹介する。ゲームに絶対に必要な要素ではないが、うまく使えばプレイヤーのゲーム体験をよりインタラクティブにでき、ユーザエクスペリエンスに直接影響を与えることができる。例えば、銃を撃った時や敵からダメージを受けた時、高いところから落ちた時など、使えそうな場面は山ほどある。ちなみに、このような必要ではないものの追加することでゲームをより面白くする要素を、英語圏ではゲーム・ジュース[Game Juice]といい、またそうすることをジューシング[Juicing]というようだ。

画面揺れの実装方法について解説したリソースは Web 上にたくさん存在し、今回紹介する以外の方法ももちろん存在する。今回はその中でも特に以下の動画と記事を参考にしているので、併せて確認いただくとより理解が深まるだろう。

Reference
YouTube: GDC - Math for Game Programmers: Juicing Your Cameras With Math
KidsCanCode: SCREEN SHAKE


このチュートリアルで最後にできあがるプロジェクトのファイルはGitHubリポジトリ に置いている。.zipファイルをダウンロードしていただき、「End」フォルダ内の「project.godot」ファイルを Godot Engine でインポートしていただければ、直接プロジェクトを確認していただくことも可能だ。

Environment
Godot のバージョン: 3.4.4
コンピュータのOS: macOS 11.6.5



準備

新規プロジェクトを作成する

それでは Godot Engine を立ち上げて、新規プロジェクトを作成しよう。プロジェクトの名前はあなたのお好みで決めていただいてOKだ。もし思いつかなければ「Screen Shake」としておこう。


プロジェクト設定を更新する

エディタが表示されたら、プロジェクト全体に関わる設定を更新しておく。

まずはゲームのディスプレイサイズを設定する。今回は 16 px を基準値として縦横 9:16 の比率とする。

  1. 「プロジェクト」メニュー>「プロジェクト設定」を開く。

  2. 「一般」タブで「window」で検索して、サイドバーの「Display」>「Window」を選択する。

  3. 「Size」セクションで以下の項目の値を変更する。

    • Width: 256
    • Height: 144
    • Test Width: 512
    • Test Height: 288
      project settings - Display - Window - Size
  4. 「Stretch」セクションで以下の項目の値を変更する。

    • Mode: 2d
    • Aspect: keep
      project settings - Display - Window - Stretch
  5. 「インプットマップ」タブに切り替え、アクションに「shake」を追加する。

  6. 「shake」の操作に「space」キーを割り当てる。
    Inputmap - action - shake


アセットをダウンロードしてインポートする

次に、KENNEYのサイトからアセットをダウンロードして利用させてもらおう。今回利用するのは「Tiny Dungeon 」というアセットパックだ。このアセットに含まれるタイルセットを使用する。この素晴らしすぎる無料の素材に感謝せずにはいられない。

ダウンロードしたら「/kenney_tinydungeon/Tilemap/tilemap_packed.png」をファイルシステムドックへドラッグしてプロジェクトにインポートする。

ファイルをインポートした直後は画像がぼやけた感じになっているので、これを以下の手順で修正しておく。

  1. ファイルシステムドックでインポートしたアセットファイルを選択した状態にする
  2. インポートドックで「プリセット」>「2D Pixel」を選択する
    select 2D Pixel
  3. 一番下にある「再インポート」ボタンをクリックする。
    click reinport

これでピクセルアート特有のエッジの効いた画像になったはずだ。インポートしたタイルセットは、後ほどタイルマップ作成時に利用する。



Camera シーンを作る

Camera シーンを新規作成する

まずは Camera シーンを作成しよう。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. 「Camera2D」クラスのノードをルートノードとして選択したら、名前を「Camera」に変更する。
  3. 一旦ここでシーンを保存する。フォルダを作成して、ファイルパスを「res://Camera/Camera.tscn」としてシーンを保存する。

シーンツリーは子ノードがないため、シーンドックは以下のように「Camera」ノードのみになっているはずだ。
Camera scene dock


Camera ノードのプロパティを編集する

続けてインスペクターで以下の編集をする。

  1. 「Current」プロパティを On にする。
    Camera - current property
  2. 「Limit」プロパティをディスプレイサイズに合わせる。
    Camera - limit property

Camera ノードにスクリプトをアタッチして編集する

Camera ノードに新規でスクリプトをアタッチする。ファイルパスを「res://Camera/Camera.tscn」としてスクリプトファイルを作成する。

今回、「荒い画面揺れ』と「滑らかな画面揺れ」の2種類の揺れを実装していく。まず先にコードの内容が比較的シンプルな「荒い画面揺れ」から。

スクリプトには以下のコードを記述する。

###Camera.gd###

extends Camera2D


# 揺れの強さ(0.0から1.0まで)
var trauma = 0.0
# 揺れの強さを累乗する際の指数
var trauma_power = 2
# 揺れの強さ trauma を指数 trauma_power で累乗した値を入れる
var amount = 0.0

# 1秒で減衰する揺れの強さ(0.0以下だと永遠に揺れるので注意)
var decay = 0.8
# 最大の揺れ幅(x軸方向、y軸方向それぞれの値をVector2型で一つのデータとして保持)
var max_offset = Vector2(36, 64) # display ratio is 16 : 9
# 最大の回転角度(ラジアン)
var max_roll = 0.1


# ノードが読み込まれたら最初に呼ばれる組み込み関数
func _ready():
    # ランダム値を返す関数のためにシード値をランダム化する
    # シード値が同じだと得られる数も同じになるため必須
	randomize()


# 毎フレーム呼ばれる組み込みのプロセス関数
func _process(delta):
    # もし trauma の数値が0より大きければ
	if trauma:
        # 揺れの強さを減衰させる
		trauma = max(trauma - decay * delta, 0)
        # 荒い画面揺れの揺れ幅と回転角度を設定するメソッドを呼ぶ
        # これを毎フレーム呼ぶことで画面揺れを表現する
        rough_shake() # このあと定義


# 荒い画面揺れの揺れ幅と回転角度を設定するメソッド
func rough_shake():
    # amount は揺れの強さを累乗した値
    # pow() 関数は第一引数を第二引数を指数として累乗する
    # 揺れの強さが 0 に近づくほど、累乗するとその値はより小さくなる
    # 例: 1.0 * 1.0 = 1.0, 0.5 * 0.5 = 0.25, 0.1 * 0.1 = 0.01
	amount = pow(trauma, trauma_power)
    # 回転角度 = 最大回転角度 * 揺れの強さを累乗した値 * -1 ~ 1 のランダム値
	rotation = max_roll * amount * rand_range(-1, 1)
    # x軸方向の揺れ幅 = x軸方向の最大揺れ幅 * 揺れの強さを累乗した値 * -1 ~ 1 のランダム値
	offset.x = max_offset.x * amount * rand_range(-1, 1)
    # y軸方向の揺れ幅 = y軸方向の最大揺れ幅 * 揺れの強さを累乗した値 * -1 ~ 1 のランダム値
	offset.y = max_offset.y * amount * rand_range(-1, 1)


# 揺れの強さをセットするメソッド
func set_shake(add_trauma = 0.5):
    # 引数 add_trauma の値を現在の trauma の値に加算する
    # 1.0 以上になる場合は trauma を 1.0 とする
	trauma = min(trauma + add_trauma, 1.0)


# 入力を処理する組み込みの関数
func _unhandled_input(event):
    # もしインプットマップのアクション「shake」のキーを押したら
	if event.is_action_pressed("shake"):
		# 揺れの強さをセットするメソッドを呼ぶ
        set_shake()

これで「荒い画面揺れ」のスクリプトは完成だ。



World シーンを作る

World シーンを新規作成して必要なノードを追加する

Camera シーンだけでは画像がないので揺れがわからない。揺れを確認するために、World シーンを作成し、そこに Camera シーンのインスタンスと、背景となるノードを用意していく。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. ルートノードとして「Node2D」を選択し、名前を「World」に変更する。
  3. ファイルパスを「res://World/World.tscn」としてシーンを保存する。

続いて、World シーンが以下のシーンツリーになるようにノードを追加する。

  • World (Node2D)
    • Camera (Camera2D、Cameraシーンのインスタンス)
    • TileMap

World scene tree


TileMap ノードを編集する

背景用に手早くタイルマップを作成しよう。

  1. 「TileMap」ノードの「Tile Set」プロパティに新規タイルセットリソースを適用する。
    TileMap - tile_set
  2. タイルセットパネルを開き、左側に KENNEY からダウンロードした「res://Assets/tilemap_packed.png」リソースファイルをドラッグし、シングルタイルかアトラスでタイルを適当に設定する。
    TileSet pannel
  3. シーンドックで「TileMap」を選択してタイルマップを作成する。範囲はディスプレイサイズを少しはみ出す程度に。
    TileSet pannel

「荒い画面揺れ」をテストする

ようやくプロジェクトを実行して「荒い画面揺れ」をテストだ。初めて実行する場合はプロジェクトのメインシーンに「World.tscn」を選択する。

スペースキーを押して画面を揺らしてみよう。少し待ってから押してみたり、間を空けずに連続的に押したりして、挙動を確認してみる。



違和感は特になく、それなりに良い感じだ。しかし、この後実装する滑らかな画面揺れと比較すると、やや荒い印象を受けるはずだ。



Camera ノードのスクリプトに「滑らかな画面揺れ」のコードを追加する

ここからは「滑らかな画面揺れ」を実装していく。Camera シーンに戻り、アタッチしている「Camera.gd」スクリプトにコードを追加する。

滑らかな画面揺れは、ノイズと呼ばれる以下のような画像を利用する。
OpenSimplexNoise OpenSimplexNoise

ノイズ画像には白、グレー、黒がランダムに分布している。黒を -1 白を 1 、中間のグレーを 0 として、ノイズの値は -1 ~ 1 まで変化する。ノイズ上の座標を指定して、そのピクセルのノイズの値を取得し、それを画面揺れに応用しようというわけだ。

Godot では OpenSimplexNoiseというクラス(リソース)が用意されている。これをスクリプト上で新規生成し、このリソースのクラスに組み込まれているget_noise_2dメソッドで、引数にx座標、y座標を渡してあげると、指定した座標のノイズ値が取得できる。今回は引数に渡す x座標をランダムで指定し、y座標を 1 pixel ずつずらしながらノイズ値を取得し、それを揺れ幅の計算に乗ずることで、滑らかな画面揺れを再現する。

ちなみに、ノイズを構成するいくつかのパラメータを変化させると、ノイズがどのように変わるのかは、Godot の OpenSimplexNoise を利用した以下のデモページで色々と試してみると直感的に理解できるかもしれない。

Reference
OpenSimplexNoise Viewer

ノイズについてはちょっとややこしく感じられたかもしれないが、ひとまずスクリプトを記述してみよう。ついでに「荒い画面揺れ」と「滑らかな画面揺れ」を切り替えられるようにしていく。

###Camera.gd###
extends Camera2D

# 画面揺れの種類をenumで定義
enum {
	ROUGH, # 荒い揺れの場合
	SMOOTH # 滑らかな揺れの場合
}

## 共通のプロパティ
var type = ROUGH # 画面揺れの種類(デフォルトは荒い揺れ)
var trauma = 0.0
var trauma_power = 2
var amount = 0.0

## 荒い画面揺れのプロパティ
var decay = 0.8
var max_offset = Vector2(36, 64)
var max_roll = 0.1

## 滑らかな画面揺れのプロパティ
var noise_y = 0 # ノイズの y 座標
onready var noise = OpenSimplexNoise.new() # ノイズのインスタンス


func _ready():
	randomize()
	
    ## 滑らかな画面揺れの場合に使用
	# シード:ノイズ特有のランダムな見た目を決める値(ランダムな整数を割り当てる)
    # シード値が変わればノイズの白から黒のドットの配置も変わる
	noise.seed = randi()
    # オクターブ:ノイズを作るレイヤー数(ここでは 2 とする)
    # 値が大きいほど白と黒の間のグレーの階層が増えて詳細なノイズになる
	noise.octaves = 2
    # ピリオド:ノイズの周期(ここでは 4 とする)
    # 値が小さいほど高周波ノイズになる
	noise.period = 4



func _process(delta):
	if trauma:
		trauma = max(trauma - decay * delta, 0)
        # もし画面揺れの種類が ROUGH の場合
		if type == ROUGH:
            # 荒い画面揺れの揺れ幅と回転角度を設定するメソッドを呼ぶ
			rough_shake()
        # もし画面揺れの種類が SMOOTH の場合
		elif type == SMOOTH:
            # 滑らかな画面揺れの揺れ幅と角度を設定するメソッドを呼ぶ
			smooth_shake()


func rough_shake():
	amount = pow(trauma, trauma_power)
	rotation = max_roll * amount * rand_range(-1, 1)
	offset.x = max_offset.x * amount * rand_range(-1, 1)
	offset.y = max_offset.y * amount * rand_range(-1, 1)


# 滑らかな画面揺れの揺れ幅と回転角度を設定するメソッド
func smooth_shake():
    # amount は揺れの強さを累乗した値
	amount = pow(trauma, trauma_power)
    # ノイズの y 座標を 1 ピクセル増やす
	noise_y += 1
    # 回転角度 = 最大回転角度 * 揺れの強さを累乗した値 * 指定した座標のノイズ値(-1 ~ 1)
	rotation = max_roll * amount * noise.get_noise_2d(noise.seed, noise_y)
    # x軸方向の揺れ幅 = x軸方向の最大揺れ幅 * 揺れの強さを累乗した値 * 指定した座標のノイズ値(-1 ~ 1)
    # noise.seed に乗じている 2 は回転角度とy軸方向の揺れ幅とは異なるノイズ値を取得するための適当な数値
	offset.x = max_offset.x * amount * noise.get_noise_2d(noise.seed * 2, noise_y)
    # y軸方向の揺れ幅 = y軸方向の最大揺れ幅 * 揺れの強さを累乗した値 * 指定した座標のノイズ値(-1 ~ 1)
    # noise.seed に乗じている 3 は回転角度とx軸方向の揺れ幅とは異なるノイズ値を取得するための適当な数値
	offset.y = max_offset.y * amount * noise.get_noise_2d(noise.seed * 3, noise_y)


func set_shake(add_trauma = 0.5):
	trauma = min(trauma + add_trauma, 1.0)


func _unhandled_input(event):
	if event.is_action_pressed("shake"):
		set_shake()
    # 右矢印キーまたは左矢印キーを押したら画面揺れの種類を切り替え
	if event.is_action_pressed("ui_right")\
	or event.is_action_pressed("ui_left"):
        # 現在荒い画面揺れの設定になっていたら
		if type == ROUGH:
            # 滑らかな画面揺れの設定にする
			type = SMOOTH
        # 滑らかな画面揺れの設定になっていたら
        else:
            # 荒い画面揺れの設定にする
			type = ROUGH

World シーンにノードを追加する

画面上どちらの種類の画面揺れになっているか分かりやすくするためシーンツリーに「CanvasLayer」ノードとその子として「Label」ノードを追加する。ノードの名前は「TypeLabel」としておく。
World scene tree

インスペクターで「Text」プロパティに「ROUGH」と初期値を入力しておく。
TypeLabel - Text property

「Theme Overrides」>「Color」>「Font Color」プロパティで、フォントの色を #000000(黒)とする。
TypeLabel = Color

2D ワークスペースのツールバーの「Layout」から「中央」を選択し、「TypeLabel」ノードの位置を中央に配置する。
2D Workspace - Layout - Center

「World」ルートノードにスクリプトをアタッチして、ファイルパスを「res://World/World.gd」として保存する。スクリプトには、画面揺れの種類を切り替える操作のために以下のコードを記述する。

###World.gd###
onready var type_label = $CanvasLayer/TypeLabel

func _unhandled_input(event):
	if event.is_action_pressed("ui_right")\
	or event.is_action_pressed("ui_left"):
		if type_label.text == "ROUGH":
			type_label.text = "SMOOTH"
		else:
			type_label.text = "ROUGH"

これで左右矢印キーで画面揺れの種類を ROUGH(荒い画面揺れ)と SMOOTH(滑らかな画面揺れ)とで切り替えられるようになった。


「滑らかな画面揺れ」をテストし「荒い画面揺れ」と比較する

それでは最後に再度プロジェクトを実行し、揺れの種類を切り替えつつ、「滑らかな画面揺れ」の挙動を確認し、「荒い画面揺れ」と比較してみよう。



以上で、画面揺れの実装は完了だ。違いを感じていただけただろうか。僅かな違いな気もするが、ゲームの演出にこだわるならその場面にふさわしい揺れを採用したいものだ。



サンプルプロジェクト

さらに視覚的に画面揺れの状態を分かりやすくしたプロジェクトを別で用意した。よければ触ってみてほしい。



サンプルゲームのプロジェクトファイルは、GitHubリポジトリ に置いているので、そこから .zip ファイルをダウンロードしていただき、「Sample」フォルダ内の「project.godot」ファイルを Godot Engine でインポートすれば確認していただけるはずだ。

サンプルプロジェクトの仕様はおおよそ以下の通りだ。

  • amountプロパティとtraumaプロパティをゲージで表示
  • キーボード操作で上下左右に移動できるキャラクターを追加
    • D キー: 右
    • A キー: 左
    • W キー: 上
    • S キー: 下
  • キャラクターが踏んだら画面揺れが発生する複数のスパイクを地面に追加
  • traumaに追加する値を上矢印キーで 0.1 増加、下矢印キーで 0.1 減少(最大 1.0、最小 0.0)
  • 「ROUGH」と「SMOOTH」の揺れの種類を左上に表示
  • 左右矢印キーで「ROUGH」と「SMOOTH」の揺れの種類を切り替え
  • スペースキーでも画面揺れを発生


おわりに

今回は2Dでの画面揺れの実装について紹介した。ゲームのジャンルや場面は選ぶが、画面揺れを適用できる機会はきっと多いだろう。また、画面揺れを構成するパラメータをどう変化させれば揺れがどう変わるのかを理解すれば、利用目的に最適な画面揺れを表現することができるはずだ。



参考



UPDATE:
2022/08/01 タイポ修正