「Godot」0からゲーム作ろう! (UI(基礎編), Audioシステム & Resource (ScriptableObject) ー2Dゲーム基礎ー)

記事6へようこそ! Coding課のAugustです.


本シリーズは, 完全に0から, あらゆる2Dゲームにも使われるエンジン機能や, ゲーム制作に欠かせない

  • キャラ操作
  • 信号(Signal, ~=UntiyのEvent System)
  • Tweenの作り方
  • User Interface & Sound

などを


Godotエンジンを使って解説するものです.

Unity達人でも, Godotエンジンでの制作流れをみて, 感じられるものがあるかもしれません.

Information

音響はゲームへの導入方法のみ

本記事は,

  • UI基礎(Control, Label, TextureRect, Container)
  • Startメニュー
  • 一時停止メニュー
  • Game Overメニュー
  • Singleton主導Audioプレイヤーバス & Poolingシステム
  • Resource

という順で進みます.


成果はこれ:


プロジェクト全体コードと素材は
https://github.com/August13742/Godot-2D-Starter-Project-for-Article-RiG-Ritsumeikan

(未来からの人はより発展したコードしか見えないかもしれないが)



なお, もし 「このゲーム・この機能, どうやって作れる?」に対して興味を持つ方は, 複雑過ぎない機能なら, Discordでメッセージくれたら次回の記事で作りたいと思います.

August

素材提供希望ヌ
これからの記事(Inventory, Crafting, 作物システム, 簡易Game AIなど, あるいは提案・リクエストされたもの)のために, もし素材課の方々が興味あったら僕に連絡

因みに, 次の記事は円形UIパネル




では早速始めましょう.

User Interface 基礎

ゲームメニュー, インベントリー, 工作台, スコア表示, HPバーなど, UIはゲームがゲームとして成立するに必要不可欠な要素である.

GodotにおけるUIの仕組みは, ControlNodeとその子クラスで構成される.


この中, 文字を表示するLabel, 画像などを表示するTextureRectやNinePatchRect, ボタンの機能を発揮する…Button. これらは独立で適当な位置に放置すれば表示はするけど,

手動で要素一個一個移動するのはしんどい単純重複労働になることを避けたい場合, Containerが使われる.

Containerは沢山種類がある. 例えば, 場所とマージンを管理したい場合はMargin Container (位置固定機能は全部のControlNodeが持つ, Containerでやる必要はない)


他にH・VBoxContainer (Horizontal & Vertical, 水平と垂直)やGridなど, 色々使い分けられる


色々説明しようとしても, 情報量オーバーロードだけになるので, 実践でやるのが効率的だと思う, 早速実践始めよう.

Start Menu

まずCanvasLayer というノードを作成する. これは, Unityと同じで, 画面に画布を置くと意味する. 3Dゲームでも, これを使って2D投影する.

基本的に, Start Menuの要素は:

  • Title (無くてもいいけど) -> Label
  • Start ボタン -> Button
  • Quitボタン -> Button
  • 背景画像 (Option) -> TextureRect(静止画) / VideoStreamPlayer(動画の場合) / 専用ゲーム場面・アニメーション(ゲームシーンの上にCanvas Layerを載る)
  • 各種設定(基礎編なので今回は作らないが, 基本的に Button ->設定用UI Layerに遷移, という感覚で作れる


前回の記事にも言ったらように, 僕はUI作りあまり得意ではないため, 今回は制作過程をビデオで取るのは少なめになる.

全域操作 & フォント変更(Custom Theme)


例えば, ゲームを作っている最中, フォントを新しく変えたい, あるいは, ある種のUI要素のに対して一気に全体操作したい場合, 自分でTheme(スタイルと考えていい) を定義すれば, 後はゲームに適用すれば良い.


ボタンを機能に繋ぐ

Start & Quitボタンに, コードを使って実際に機能させない限り, ただの画像だけなので, 繋ぐ

ここで, コードを入れる場所は自由である. Startボタン自身に貼り付けても良いし, 今の一番親ノードであるCanvasLayerに貼り付けても良い. 用途で分けることが一般的. 例えば, 僕が今開発している3DゲームのCraftingシステムの機能確認用で雑に作ったUIは, 現時点ではこうなっていて

自動的にレシピー ファイルから対象素材, 必要部品と数などを補充する仕組みになっている. このメニューのすべての機能は, 「工作台」など, 素材を作れる物体ならこれを部品として取り入れるので, パラメーター調整できるよう, ルートにスクリプトを貼り付けている.


とはいえ, どこにコート貼り付けても, 機能はする, ちゃんと正しく書いていれば.



さて, 本題に戻って,
一番最初の実践で, 「開始」ボタンを押すとデフォルトとして出るシーンを, “main”と設定したが,
それは今からStart Menuに設定変更して, Start押すと, “main” シーンがロードされる構造にする.

具体的に, Project Setting -> Run -> Main Sceneを変える
その後, ボタンの信号「pressed」に繋いで, get_tree().change_scene_to_file() を使って現在実行シーンを切り替えて, deferred callでこのフレームの最後にTitleメニューを消す (deferredいらないと思うけど, 一応)


Quitボタンは同じ流れで,
get_tree().quit()を使えばゲームが閉じられる. queue_free()はいらない, ゲーム全体が閉じられるので

一時停止メニューを作る

さて, 一時停止メニューは, 本質的にStart Menuと変わらない. ただし, ゲームを一時停止する必要がある.

どうやって? とあるキーを押したら, _ready()でget_tree().paused = trueを呼んだらおk, これだけ, 簡単だろ?


だと思ったら, バグる.


考えてみて, get_tree().pausedは, すべてのノードを停止する


つまり, 一時停止メニューも停止する. 停止すると, ボタンが押されても対応できない.

ノードは自分のProcessモードが設定できる. 常に動けるか, 一時停止時だけ動くか, 全く動かない(ゲーム内のイベントなどに使う, 例えば時間停止系の効果とか)かなど. ここで, 停止時のみ, あるいは 常に に設定すれば動く.

Game Overメニューを作る

はい, これも流れは同じで, ただし, 敵撃破(勝利)か, プレイヤー死亡(失敗)かに分岐する. これは専用メニュー作っても良いし, 切り替え要素や素材を準備して, 一つのメニューでコードを使って対応させるのもあり.

まず, 以前の記事で, EventSystemというAutoload Singletonを作ったことを思い出して, ここで, スライムちゃん撃破とプレイヤー敗北という2つの信号で発動する関数を分岐させる.

一応HealthComponent, つまりHP部品の中に, diedという信号は既に存在するので, 各キャラの主要スクリプトでそれに繋ぎ, EventSystemの関数を発動させれば良い.

簡潔にまとめると, UI場面を分岐したいなら, 勝つ時・負けるの文字や画像の場所を保存して, ブール代数を使ってフラグを管理して,

Call Deferredで実行する関数をフレームの最後に後回しして

if 勝利 << 勝利の素材と文字を表示する, else負ける時の素材を表示する

キャラの主要スクリプトで
health_component.died.connect(EventSystem.player_died)
で繋ぐ.

ここはちょっと基礎の幅を超えて難しかったかもしれない. 分からなかったら複数メニュー作れば良い. 結果は変わらない.

Audioを繋ぐ

Let there be sound. 今までは全部無音のゲームだったが, 音はゲームの主要要素で欠かせない.

Audioの要るオブジェクトにAudioPlayer Nodeを導入してもいいけど, それだと複数欠点がある:

  • 音量バケ (例えば, 弾丸のノードに音を導入して, 弾丸が短時間に数十, 数百個作られたら, 同時にその数の音がプレイされる)
  • 一個一個触らないと管理不可能 (例えば環境音全体の音量を下げたい場合)
  • メモリー浪費 (作業していないAudio Player Nodeが沢山作られる)
  • などなど


じゃあどうすれば良いかというと, 一般的に, Singletonで管理することは多い.

August

今更だけど,

こういう方面から, これは本当に「基礎シリーズ」と言えるかどうかは分からないんだよね

僕は現時点では半年しかGodott使ってないから, 「僕のやることは全部基礎」, と言える説ある?

まず AudioManger(名前任意)というSingletonを作るって, Autoloadにして, 画面のしたに Audioというタブから, 新しくBusを追加して, SFX と Musicに任命する (音声など, 更に追加してもいい, 名前は任意だけど, スクリプトで呼ぶ名前と同一する必要ある)

AudioManagerは, 以下の機能を持つ (僕のスクリプトの場合):

  • SFX Busにプレイヤー(チャンネルと考えていいかも) をリサイクルするための Object Poolingシステム (最大同時再生数を制限できる)
  • MusicをFade-in, Fade-outするための Tween (はい, Tweenはアニメーションだけではない, 以前言ったように, 本質はただの時間単位の自動補完)
  • SFX, Music をプレイするメソード
  • 足音など, Loopする音に対するサポート
  • 音量調整


その後, 好きな場所でAudioManager.play_sfx() play_music()を使えばいい

func enter():
	super()
	animation_player.play("walk")
	AudioManager.play_sfx("player_walk",true,.3)

Decouping問題 & Resource (Scriptable Object)

ここで, 一つ機能に影響しない問題がある.
AudioManager.play_sfx("player_walk",true,.3)この行は, 歩くsfx音をループありでかつ全体SFX音設定の30%のボリュームでプレイして, を意味する.

この行は別に問題はないが, 問題は, この行の位置と, そのパラメーターにある.

このコードは, StateMachineのWalk Stateにある.
つまり, もしこの30%のデフォルト音量が, 聞きづらかったら, 調整するのはStateMachineのコードを呼んで, この行を見つけ出して設定を変えてからテストしなければならない.
要は, 音量を弄る人はもしStateMachineを築いた人とは別人である場合, かなりしんどい



そこで, 何らかのDataBaseを作って, すべてのAudioに対するパラメーター設定をまとめて設定すれば, 音の人はコード見ずに音だけ弄ってれる.

実際にこのデータベースは色々作り方がある. 例えば, 直接AudioManagerに書き込むことや, Resource (= Unity の ScriptableObject)やJsonファイルなどを使う方が一般的.

今回はResourceで作る.

Resourceクラスから継承するスクリプトを作って, id, file, パラメーターなど, 必要要素を全部exportで作ったら, Editorだけで編集できる.
更に, このResourceスクリプトを行列に打ち込めば, まとめて管理できる.


将来専用記事を書く予定だが, 基本的にItemや装備, スキルの効果なども全く同じ流れで作られる.

で, ここからは単純作業…要はすべてのイベントに対してResourceを作るってこと.

注意すべきは, 上のビデオで僕がやった作業, Audioファイルを直接にResourceに打ち込んで, 一つの行列にまとめることは(現段階)意味ない. 普通にファイル一個一個作ると良い.

その後,

@export var hover_sound_resource:SFXResource = preload("uid://d4bu6gix5u45")
@export var pressed_sound_resource:SFXResource = preload("uid://b5efmh1qdopx3")

で, AudioManager.play_sfx(hover_sound_resource)を使えばパラメーターに関係なく発動するし, 調整は直接Resourceで行う.

Warning

Editorで直接指定しても, preload使っても, コンパイル(ゲーム起動)時にデータ全部読むことを意味する.

Freeされるまで常時メモリーにある.

これが避けたい場合, @export var はString, 若しくはStringNameをファイルへのPathを保存して, 使う時はload(path)で使う.

結果はこう:

Audio Manager Singletonコード

extends Node

# Pool of AudioStreamPlayer nodes for one-shot sounds
var _sfx_player_pool: Array[AudioStreamPlayer] = []
const SFX_PLAYER_POOL_SIZE: int = 10 # Adjust as needed
var _looping_sfx_players: Dictionary[StringName,AudioStreamPlayer] = {}
func _ready():
	self.process_mode = Node.PROCESS_MODE_ALWAYS
	_initialise_audio_pool()
	# load audio settings here from save file (save system not implemented yet)
	# and apply them to buses (e.g. Master, Music, SFX volumes).

func _initialise_audio_pool():
	for i in range(SFX_PLAYER_POOL_SIZE):
		var player = AudioStreamPlayer.new()
		add_child(player)
		_sfx_player_pool.append(player)

## Call to play a one-shot sound effect in SFX bus
func _play_one_shot_sfx(audio_stream: AudioStream, volume_linear: float = 1.0, pitch_scale: float = 1.0):
	# Find an available player in the pool
	for player in _sfx_player_pool:
		if not player.playing:
			player.stream = audio_stream
			player.volume_linear = volume_linear
			player.pitch_scale = pitch_scale
			player.bus = "SFX" # Ensure it plays on the SFX bus
			player.play()
			return

	# If no player is available then make new
	printerr("[AudioManager]: SFX pool exhausted for one-shots. Consider increasing SFX_PLAYER_POOL_SIZE.")
	var new_player = AudioStreamPlayer.new()
	add_child(new_player)
	new_player.stream = audio_stream
	new_player.volume_linear = volume_linear
	new_player.pitch_scale = pitch_scale
	new_player.bus = "SFX"
	new_player.play()
	new_player.finished.connect(func(): new_player.queue_free()) # Self-destruct after playing
	## or Add to pool for future use (can lead to large pool), remove line above
	#_sfx_player_pool.append(new_player) 
	## or add logic to shrink pool size during idle


## Call to play a sound effect. Can be one-shot or looped.
func play_sfx(resource: SFXResource)->void:
	var audio_stream: AudioStream = resource.stream
	var event_name: StringName = resource.event_name
	if resource.loop:
		# --- Looping SFX Logic ---
		# Stop and remove any existing looping sound with the same name to prevent duplicates
		# and allow restarting with new parameters if needed.
		if _looping_sfx_players.has(event_name):
			var existing_loop_player = _looping_sfx_players[event_name]
			if is_instance_valid(existing_loop_player):
				existing_loop_player.stop()
				existing_loop_player.queue_free()
			_looping_sfx_players.erase(event_name) # Remove from tracking

		## IMPORTANT: For looping to work, the AudioStream resource itself (e.g., .wav, .ogg)
		## MUST be imported into Godot with its loop property enabled
		var loop_player = AudioStreamPlayer.new()
		add_child(loop_player)
		loop_player.stream = audio_stream
		loop_player.volume_linear = resource.volume_linear
		loop_player.pitch_scale = resource.pitch_scale
		loop_player.bus = "SFX"
		
		loop_player.play()
		_looping_sfx_players[event_name] = loop_player # Track this looping player
	else:
		_play_one_shot_sfx(audio_stream, resource.volume_linear, resource.pitch_scale)

## Call to stop a specific looping sound effect
func stop_looped_sfx(resource:SFXResource, fade_out_duration: float = 0.0)->void:
	var sfx_name:StringName = resource.event_name
	if _looping_sfx_players.has(sfx_name):
		var player_to_stop = _looping_sfx_players[sfx_name]
		_looping_sfx_players.erase(sfx_name) # Remove from tracking immediately

		if not is_instance_valid(player_to_stop):
			# Player might have been freed already if stop_looped_sfx was called rapidly
			return

		if fade_out_duration > 0.001: # Check against a small epsilon for float comparison
			var tween = create_tween()
			tween.tween_property(player_to_stop, "volume_linear", 0.0, fade_out_duration)
			tween.tween_callback(func():
				if is_instance_valid(player_to_stop):
					player_to_stop.stop()
					player_to_stop.queue_free()
			)
		else:
			player_to_stop.stop()
			player_to_stop.queue_free()
	else:
		printerr("[AudioManager]: Attempted to stop non-existent or already stopped looping SFX: ", sfx_name)

var _paused_sfx_players:Array[AudioStreamPlayer] = []
func pause_sfx():
	_paused_sfx_players.clear()
	for player in _sfx_player_pool:
		if player.playing:
			player.stop()
			_paused_sfx_players.append(player)
	for player in _looping_sfx_players.values():
		if player.playing:
			player.stop()
			_paused_sfx_players.append(player)
func resume_sfx():
	for player in _paused_sfx_players:
		player.play()
	_paused_sfx_players.clear()
	
## Call to play music using the Music Bus
var _music_player: AudioStreamPlayer
func play_music(resource: MusicResource, fade_duration: float = 2.0):
	if not _music_player:
		_music_player = AudioStreamPlayer.new()
		add_child(_music_player)
		_music_player.bus = "Music"

	var audio_stream: AudioStream = resource.stream
	if audio_stream and _music_player.stream != audio_stream:
		## crossfading using Tween
		if _music_player.playing:
			# Fade out current
			var tween = create_tween()
			tween.tween_property(_music_player, "volume_linear", 0.0, fade_duration / 2.0)
			tween.tween_callback(func():
				_music_player.stop()
				_music_player.stream = audio_stream
				_music_player.play()
				# Fade in new
				var fade_in_tween = create_tween()
				fade_in_tween.tween_property(_music_player, "volume_linear", 1.0, fade_duration / 2.0)
			)
		else:
			_music_player.stream = audio_stream
			_music_player.volume_linear = 1.0 # Set to full volume immediately if no fading
			_music_player.play()

func stop_music(fade_duration: float = 1.5)->void:
	if _music_player and _music_player.playing:
		var tween = create_tween()
		tween.tween_property(_music_player, "volume_linear", 0.0, fade_duration)
		tween.tween_callback(_music_player.stop)
func resume_music(fade_duration: float = 1.5)->void:
	if _music_player and !_music_player.playing:
		var tween = create_tween()
		tween.tween_property(_music_player, "volume_linear", 1.0, fade_duration)
		tween.tween_callback(_music_player.play)
		
var _music_fade_tween: Tween
func fade_music(lower_threshold_linear: float = 0.2, fade_duration: float = 0.5):
	if not _music_player:
		return
	if _music_fade_tween and _music_fade_tween.is_running():
		_music_fade_tween.kill() # Prevent overlapping tweens
	
	_music_fade_tween = create_tween()

	_music_fade_tween.tween_property(_music_player, "volume_linear", lower_threshold_linear, fade_duration)
func unfade_music(fade_duration: float = 0.5):
	if not _music_player:
		return
	if _music_fade_tween and _music_fade_tween.is_running():
		_music_fade_tween.kill()
	
	_music_fade_tween = create_tween()
	_music_fade_tween.tween_property(_music_player, "volume_linear", 1.0, fade_duration)

	
# --- Bus Volume Control ---
func set_master_volume(volume_linear: float):
	AudioServer.set_bus_volume_linear(AudioServer.get_bus_index("Master"), volume_linear)

func set_music_volume(volume_linear: float):
	AudioServer.set_bus_volume_linear(AudioServer.get_bus_index("Music"), volume_linear)

func set_sfx_volume(volume_linear: float):
	AudioServer.set_bus_volume_linear(AudioServer.get_bus_index("SFX"), volume_linear)

コメントを残す

CAPTCHA