「Godot」0からゲーム作ろう! (実践3: Tween, Camera2D, Singleton, Coroutine, Shader ー2Dゲーム基礎ー)
記事4へようこそ! Coding課のAugustです.

本シリーズは, 完全に0から, あらゆる2Dゲームにも使われるエンジン機能や, ゲーム制作に欠かせない
キャラ操作信号(Signal, ~=UntiyのEvent System)- Tweenの作り方
- User Interface
などを
Godotエンジンを使って解説するものです.
Unity達人でも, Godotエンジンでの制作流れをみて, 感じられるものがいるかもしれません.
本記事は,
- Tween
- 2Dカメラ
- Singleton Autoload(軽く説明)
- Coroutine(軽く説明)
- Shader(使用のみ)
という順で進みます.
成果はこれ

なお, もし 「このゲーム・この機能, どうやって作れる?」に対して興味を持つ方は, (複雑過ぎない機能かつ現段階2D限定)なら, Discordでメッセージくれたら次回の記事で作りたいと思います.
では早速始めましょう.
目次
Tweenとは?
Tween(ツイーン), または 「in-between」は, 名前通り, 「間」ということを示す. 時間経過に基づいて数値を補間する手法であり, 主にアニメーションや動的な変化を表現する際に使用される. 始点と終点の間の中間状態を滑らかに補完する目的で設計されている
ゲームエンジンやUIフレームワークなどにおいては, Tweenはフレーム単位の手動更新を回避し, 時間依存の状態遷移を自動化する手段として利用される. 補間対象は座標, スケール, 回転, 色, 透明度など多岐に渡る. 数学的には、線形補間 (Linear Interpolation; Lerp) が基礎となるが、多くの実装では非線形の補間関数 (ease-in, ease-out, bounce, elastic等) を採用している.
まあ, 簡単に言うと, Start, End, Time, 3つのパラメーターで, アニメーションを作る手法. 補完関数というものは, 要は2点を繋ぐとき, 直線を使うか, 曲線を使うか, である.

上図はhttps://easings.net/en, 非常に助かるサイト.
では早速制作始めよう
まず今回は, 新しい スライムちゃん の素材を使う.

以前の記事から継続して, EnemyのSprite2D素材を入れ替えよう. そして, Offsetをスライムの足元に調整しよう(y=-85)

それから, 当たり判定エリアも調整して, PlayerのSpriteも同じくOffsetを足元にする.
更に, EnemyにTimerというNodeを追加して,

こう設定する
Godot (GDScript)でTweenを作る
まず実際にtweenのメソードを呼ぶvar tween1 = create_tween()
変数名は任意. このステップ自体は何も機能しない.
次に:tween1.tween_property(self,"scale",Vector2(1.5,1.5),1)
tween_propertyとは, 属性をtweenするということ. 属性は, 位置, 色, サイズ など, 色々試せるし便利だが, 制限も多い(内蔵属性以外は使えない).tween_property(対象, 対象属性名, 最終数値, 時間)
という順になっている.tween1.tween_property(self,"scale",Vector2(1.5,1.5),1)
この行は, 自分のサイズを1秒渡って1.5にすると意味する. 注意すべきは, (現在値が1でない場合) 1.5倍ではなく, ただ1.5になるということ.
内蔵属性以外をtweenしたい場合は
tween_method(関数名, 初期値, 最終値, 時間)
を使えばいい.
例えば, tween_method(my_tween_method, 0.0, 1.0, 2) (小数点必須, ない場合整数のみ実行される)
func my_tween_method(progression:float)
そこから, 目標値-初期値で違いを求める,
値 += 違い*progression
で補完するか,
Lambda関数を使うこともできる
tween1.tween_method(
func(value:Vector2): sprite_material.set_shader_parameter("deform", value),
Vector2(0.0,1.0),
Vector2(0.0,8.0),
1.0).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tweenのあとに.set_easeや, .set_transで, 補完する関数種類を選べる.
一般的に, Tweenは並行作業しない.
tween.parallel()を呼んで前のtweenと次のtweenを並行に実行させるか
tween = create_tween().set_parallel(true)
で, 並行作業をデフォルト化し,
tween.chain().tween_method()...
で手動的に並行を破ることで自由に操ることができる.
試行錯誤
このブロックは, 僕がこの記事用のプロジェクトを作る時で遭遇したトラブルの試行錯誤である. 実際に作業する必要はない.


このTweenは, デカくして->飛んで -> 目標に移動して -> 落下する, 1+1+1+0.5秒で, 3.5秒のアニメーションである.
という順に発動する.
ここの問題は, 目標が少し移動したら, 飛び降りる位置が大変ズレるということ
この原因は, Tweenの本質にある. Tweenは, 補完であるが, 目標値の更新は 恐らくしない. つまり, 3.5秒(落下前なので正確には3秒) の位置に落下している.
これを治すのも容易である. 僕が考えるに方法は2つある.

その1: 上昇と落下を2つのTweenに別れて,
tween_callback()という, tweenが終わる時に関数を発動させる. この場合tween1のcallbackはtween2を始めることである.
(その2は下のPlus Ultra章にある)

図のように, かなり時間による位置ズレをなくしている.
作業に戻る
さて, ここからまず, 細かい所を調整して始めよう
1. 敵はPlayerを後ろにする(あるいはPlayerの優先レベルを上げる)

カメラ & Camera Shake
スライムちゃんの落下をより衝撃的に見せるには, Camera Shakeが効果的だと思う.
Shakeさせるにはまずカメラを追加しなければならないので, 作った. (実際に記事2のプレイヤー操作と大差ない, ただプレイヤーの位置に移動するだけ)


そこからShakeを追加する

Hmmm, Shakeは作ったとは言え, どうやって(強制連結より美しく)カメラに「揺られろ!」って伝わるんだ…?
=====
(醜いやり方も一応教える)
┖╴Main
┠╴Player
┃ ┠╴CollisionShape2D
┃ ┖╴Sprite2D
┠╴Enemy
┃ ┠╴Sprite2D
┃ ┠╴Area2D
┃ ┃ ┖╴CollisionShape2D
┃ ┖╴Timer
┖╴Camera2D
これが, 今のゲームの構造木. Cameraは, Player や Enemyと同様に, Mainの下にあるので, Enemyがget_parent()を呼ぶとMainになり, get_node() もしくは find_child()で, カメラに繋げる.
これがなぜ醜いかというと, 壊れやすい. 木の構造の変更や, スクリプトの位置に大きく左右するので, 作業しながら急に破られる.
Singleton 信号中継局 (Autoload)
ここがSingletonの出場所だと考える.
Singletonとは, ゲーム全体を通じてグローバルにアクセス可能なスクリプトまたはノードのことである.
主な用途は以下に分類される:
- グローバル状態の保持(例:スコア, 設定, プレイヤーの所持アイテム, 信号中継)
- システムユーティリティの提供(例:入力管理, イベントディスパッチャ, オーディオ再生)
- デバッグ・開発支援ツール(例:ロガー, パフォーマンストラッカー)
もと分かり安い言葉で説明すると:
「僕(一般ノード)はCameronさん(別のノード)と面識がない. だが, Simonさん(オートロード)は政府機関にあり, 誰もが知っていて, どこにでも存在する.
だから僕はSimonさんに「Cameronさんに伝えてほしい」と依頼すれば, Simonさんを介して間接的にCameronさんに要望を伝えることができる.」
まあ, ざっとこういうもん(少なくとも僕の理解では)
では実装しよう

EventSystem(名前任意)というスクリプトを作って, 必ずNode(もしくはそれより高度なノード)をextendして, signal 作ってProject Setting -> Globals に入れる. class_nameはつけない.
中身は一般的にsignalだけでいいが, 一応より複合的な使い方も入れてる.

Coroutine
ここからは質上げと改良を行う.
まず, 直前の図から, Tweenが終わった後に地面を揺らすことが多少遅く感じる.
ここで, Coroutineを軽く説明する
要は, 簡易Multi-threading(本質は違う). 一般的に, コードは実行が終わるまで次の行に行くことはないが, coroutineは, タイマーなどで 「callを受けるが実行はタイマーが終わるまで」.

ここで, ループは一度(~一瞬)で5回関数を呼んだので, 1秒に固定しているループは, 1秒の後一瞬で5回print実行した. 逆に, もう一つのループは, タイマーが0,1,2,3,4秒に設定されているので, 1秒間隔で出力されている.
故に, Timerを短時間で一度だけコールされる関数内に設定すると, 関数全体を制御することができる.

まあ, 非常に分かりにくいと思うけど.
func print_await(delay:float):
await get_tree().create_timer(delay).timeout
print("hi%f"%delay)
func print_await_no_delay(count:int):
await get_tree().create_timer(1).timeout
print("hi no delay %d"%count)
func _ready():
for i in range(5):
print_await(i)
print_await_no_delay(i)
for j in range(10):
await get_tree().create_timer(1).timeout print("coroutine in loop")
(自己責任で実行してみて?)
要は, よほど自信ない限りは, あまりループで使わないことを勧める.
Plus Ultra (& Shader)
まずスライムちゃんのTweenに, 衝突判定を閉じる・再開すると設定する.

set_deferred, call_deferred, .call_deferred()は, 機会が来たらまた紹介する. 結構大事なものであるが. (簡単にいうと実行をQueueの最後に後回す)
次に, Shaderを使って, 2D絵に影を追加する
Shaderとは, 要はCPUではなく, GPU用に書いたコード. 実はShadingだけではなく, 非常に難しいが, 並行制御でものを計算することもできる.
初歩の理解は, つまり すべての画素に対して同時に同じ処理作業する (1920*1080たと毎フレーム207万3千6百回, 4K解像度は四倍で829万4千4百回) fragment shader と, すべてのVertex(頂点)に対して同時に同じ処理作業するVertex Shader二種類あるが, Compute Shaderも存在する. (処理は一箇所1回とは限らない, multi-samplingもできる)
/**
* Shadow 2D.
* License: CC0
* https://creativecommons.org/publicdomain/zero/1.0/
*/
shader_type canvas_item;
render_mode blend_mix;
uniform vec2 deform = vec2(1.0, 1.0);
uniform vec2 offset = vec2(0.0, 0.0);
uniform vec4 modulate : source_color;
void fragment() {
vec2 ps = TEXTURE_PIXEL_SIZE;
vec2 uv = UV;
float sizex = float(textureSize(TEXTURE,int(ps.x)).x);
float sizey = float(textureSize(TEXTURE,int(ps.y)).y);
uv.y+=offset.y*ps.y;
uv.x+=offset.x*ps.x;
float decalx=((uv.y-ps.x*sizex)*deform.x);
float decaly=((uv.y-ps.y*sizey)*deform.y);
uv.x += decalx;
uv.y += decaly;
vec4 shadow = vec4(modulate.rgb, texture(TEXTURE, uv).a * modulate.a);
vec4 col = texture(TEXTURE, UV);
COLOR = mix(shadow, col, col.a);
}
これは, 僕が書いたコードではない.

ではこのShaderを適用しよう:
EnemyのSpriteのCanvasItemから, Materialを選択してShaderMaterialを作って, Shaderを貼り付ける. そして, 影のパラメーターを調整しよう.
次のステップからかなり説明にくいし, ただの作業で面白くないので, 直接結果を貼り付ける
Enemyの最終スクリプト:
extends Node2D
@onready var timer: Timer = $Timer
@onready var area2D:Area2D = $Area2D
@onready var sprite:Sprite2D = $Sprite2D
var jump_height := 150
@onready var collision_shape_2d: CollisionShape2D = $Area2D/CollisionShape2D
@onready var target = get_tree().get_first_node_in_group("player")
var can_tween:bool = true
func _ready():
area2D.body_entered.connect(_on_body_entered)
timer.timeout.connect(_on_timer_timeout)
@onready var sprite_material:ShaderMaterial = sprite.material
func jump_tween():
var tween1 = create_tween()
tween1.tween_property(self,"scale",Vector2(1.5,1.5),1).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween1.tween_property(sprite,"position:y",sprite.position.y - jump_height,1)\
.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween1.parallel()
tween1.tween_method(
func(value:Vector2): sprite_material.set_shader_parameter("deform", value),
Vector2(0.0,1.0),
Vector2(0.0,8.0),
1.0).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween1.parallel()
tween1.tween_method(
func(value:Vector2): sprite_material.set_shader_parameter("offset", value),
Vector2(-5.0,85.0),
Vector2(-20.0,-10.0),
1.0).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween1.tween_callback(tween_2)
func tween_2():
var tween2 = create_tween()
tween2.tween_property(self,"global_position",target.global_position,1)\
.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween2.tween_property(sprite,"position:y",0,0.5)\
.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween2.parallel()
tween2.tween_method(
func(value:Vector2): sprite_material.set_shader_parameter("deform", value),
Vector2(0.0,8.0),
Vector2(0.0,1.0),
0.5).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween2.parallel()
tween2.tween_method(
func(value:Vector2): sprite_material.set_shader_parameter("offset", value),
Vector2(-20.0,-10.0),
Vector2(-5.0,85.0),
0.5).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_EXPO)
tween2.tween_property(self, "scale",Vector2(1,1),0.5)
tween2.tween_callback(_tween_callback)
await get_tree().create_timer(1.2).timeout
collision_shape_2d.disabled = false
await get_tree().create_timer(0.2).timeout
EventSystem.emit_trigger_camera_shake()
func _tween_callback():
timer.start()
can_tween = true
func _process(_delta: float) -> void:
sprite.flip_h = true if (target.global_position.x - self.global_position.x) < 0 else false
func _on_timer_timeout():
if can_tween:
jump_tween()
collision_shape_2d.set_deferred("disabled",true)
can_tween = false
func _on_body_entered(_other:Node2D):
if can_tween:
jump_tween()
collision_shape_2d.set_deferred("disabled",true)
can_tween = false
カメラの最終スクリプト
extends Camera2D
## Higher Means higher Initial Camera Acceleration Growth
@export var damping_factor:float = 20
## Defaults to Player if not Manually Set
@export var target:Node
var target_position = Vector2.ZERO
func _ready() -> void:
make_current()
if target == null:
target = get_tree().get_first_node_in_group("player")
print_debug("[Debug/Referencing]: Target not Manually Set, Defaulting Camera Target to Player")
if target == null:
print_debug("[Debug/Referencing]: Cannot Find Player")
EventSystem.trigger_camera_shake.connect(_on_trigger_camera_shake)
var shake_time := 0.0
var shake_duration := 0.0
var shake_strength := 0.0
func _process(delta: float) -> void:
if target == null: return
target_position = target.global_position
global_position = global_position.lerp(
target_position, (1.0-exp(-delta*damping_factor)))
if shake_time > 0.0:
shake_time -= delta
var decay = shake_time / shake_duration
var max_offset = shake_strength * decay
offset = offset + Vector2(
randf_range(-1.0, 1.0),
randf_range(-1.0, 1.0)
) * max_offset
else:
offset = offset.lerp(
Vector2.ZERO, (1.0-exp(-delta*damping_factor)))
func shake(duration: float = 0.3, strength: float = 35.0):
shake_time = duration
shake_duration = duration
shake_strength = strength
func _on_trigger_camera_shake():
shake()