🎮

Godot4 GUI、サウンド、その他の機能

2023/10/12に公開

ゲームを仕上げる

GUIとサウンド、エフェクトを付けてゲームっぽくしていきます

ノードの配置は以下の通り
GameManagerとStarsを追加しました
SE/BGMはAudioStreamPlayerノード
文字はLabelノード
画像はファイルシステムから画像をドラッグします
AnimationPlayerはダメージのエフェクトを設定しています
CanvasLayer以下の設定は後ほど解説します

Starはとると得点になるアイテムです
専用のシーンを作成してそこで処理します
GameManagerではゲームの進行に関するGUIやサウンドなどの処理をします

サウンドの設定

BGMのボリュームとAutoplayをオンにしておきます
ゲーム開始時に自動で再生されます
SEのAutoplayはオフにし適宜再生します

Unique Name Nodeの設定

ノードを参照する場合にグループ機能とは別にユニークノードというものがあります
ゲーム内に1つしかないノードに名前を付けてアクセスできるようになります
GameManagerノードを右クリックから「Access as Unique Node」を選びます
これで「%GameManager」という名前でどこからでもアクセスできるようになります

アイテムの作成

得点となるアイテムのシーンを作成をします
Model以下は星形の拾ってきた3Dモデルです
GPUParicles3DとArea3D以下を追加してます
Area3Dは指定したエリアに物体が出たり入ったりするのを検知するノードになります

パーティクルの設定

パーティクルは設定を解説するだけでいくつも記事が書けるくらいボリュームがありますが
今回は簡単な設定だけにしてます
Amountはパーティクルの数です。常にこの数だけゲーム内に出続けます
OneShotをオンにするとパーティクルが有効になった瞬間だけパーティクルが出ます
Lifeタイムは1つのパーティクルがゲーム内にとどまる時間です

Shapeはパーティクルが出現する位置です
箱状とか球状とかその表面だけとか種類がいくつか選べます
Gravityは重力で上にゆっくり上がっていくように設定しています

パーティクルの描画設定です
DrawPassesのPass1にQuadMeshを指定しています。いわゆる板ポリです

そのMaterialの中身です
Transparencyは透過処理です
BlendModeは透過時の計算方法で、加算して明るくなるようにしています
BillbordのEnabledをオンにして常にカメラの方を向くように設定します

スクリプトは短いので簡単に説明します
このアイテムは出現場所は固定にして
表示のオンオフでアイテムを取ったり出現させたりしています

_processでモデルを上下移動と回転の処理をしています
body_enteredシグナルはArea3D内にプレイヤーが入ったときに呼ばれます
set_activeはGameManagerから呼ばれる処理で表示と当たり判定のオンオフを切り替えています
set_deferredはちょっと難しいですが、
パラメータによっては変更してはいけないタイミングがあるので、変更できるタイミングになったら設定しますよっていう関数です
どういうときに使うかは実行したときにエラーがでて怒られるので分かると思います

star.gd
extends Node3D

@onready var star = $Model
@onready var collision = $Model/Area3D/CollisionShape3D

var rotate_speed=2
var updown_speed=2
var updown_height=0.2
var time=0

func _process(delta):
	star.rotate_y(delta*rotate_speed)
	time += delta
	star.position.y = sin(time*updown_speed) * updown_height


func _on_area_3d_body_entered(body_):
	%GameManager.take_star(self);
	
func set_active(active):
	visible = active;
	collision.set_deferred("disabled",!active)

当たり判定のレイヤーの設定

ゲーム内のオブジェクトが増えてくると物によっては当たったらダメなものなどありますので
どれとどれは当たってよい、当たったらダメ等の設定が必要になります
それを管理するのがレイヤーになります
ここをきちんと設定しないと想定外のシグナルが呼ばれてうまく動きません

プロジェクトの設定でどのレイヤーにどの物体が属するのかラベルを付けます
これは分かりやすく名前を付けているだけなのでなくても良いです
今回は以下の4つに分けています

player/enemyのCharacterBody3D
starのArea3D
床や壁のStaticBody3D
でレイヤーの設定をしていきます
各ノードのCollisionの項目で設定します

Layerが自分が属するレイヤー
Maskがどのレイヤーに属するものと当たり判定を行うかの設定になります

プレイヤーは他のレイヤー全部と当たり判定を行います

敵は壁床と当たり判定をします

星はプレイヤーを当たり判定をします

床壁は特になし

少し過剰にチェックがついてそうな気がしますがとりあえず動いたのでこのままいきます

GUIの作成

GameManager>CanvasLayerの設定です
画面上の2Dのタブからレイアウトを編集できます
下は全部表示してますが進行に合わせて表示を切り替えます
ゲームオーバーのラベルとダメージエフェクトは非表示にしておきます

先にGameManager.gdの内容を張っておきます

GameManager.gd
extends Node3D

var is_playing =false
var player
var star_count = 0
var life_count = 3
@onready var star_label = $CanvasLayer/StarCount
@onready var game_over = $CanvasLayer/GameOver
@onready var se_star = $SE_Star
@onready var se_bite = $SE_Bite
@onready var bgm = $BGM
@onready var damage_effect = $CanvasLayer/Damage/AnimationPlayer

func _ready():
	var stars = get_tree().get_nodes_in_group("stars")
	for star in stars:
		star.set_active(false)

	for i in range(3):
		var star = pick_hide_star()
		star.set_active(true)

	player = get_tree().get_first_node_in_group("player")


func _process(_delta):
	if Input.is_action_pressed("ui_cancel"):
		get_tree().quit()

	if is_playing:
		return

	if Input.is_action_just_pressed("attack"):
		$CanvasLayer/ClickStart.visible = false
		await get_tree().create_timer(1).timeout
		is_playing = true
		

func pick_hide_star():
	var stars = get_tree().get_nodes_in_group("stars")
	while true:
		var index = randi_range(0,stars.size()-1)
		var star:Node3D = stars[index]
		if not star.visible:
			return star


func take_star(star):
	var next = pick_hide_star();
	next.set_active(true)
	star.set_active(false)
	star_count+=1
	star_label.text = "x {0}".format([star_count])
	se_star.play()
	

func damage(enemy):
	var lifes = get_tree().get_nodes_in_group("life")
	life_count -= 1
	lifes[life_count].visible=false
	se_bite.play()
	damage_effect.play("damage")
	if life_count<=0:
		player.die()
		game_over.visible = true
		is_playing = false

初期化処理

初期化処理です
starsはグループ機能で全部のstarシーンを登録しています
最初に全部を非アクティブにし、3つをランダムで選んでアクティブにしています

func _ready():
	var stars = get_tree().get_nodes_in_group("stars")
	for star in stars:
		star.set_active(false)

	for i in range(3):
		var star = pick_hide_star()
		star.set_active(true)

	player = get_tree().get_first_node_in_group("player")

ゲーム開始処理

is_playingはゲームプレイ中かのフラグです
最初はfalseでクリックしたらtrueにしてます
create_timerで1秒間隔をあけて設定してます
クリック時に攻撃しちゃうのでそれを回避するための苦肉の策です

func _process(_delta):
	if Input.is_action_pressed("ui_cancel"):
		get_tree().quit()

	if is_playing:
		return

	if Input.is_action_just_pressed("attack"):
		$CanvasLayer/ClickStart.visible = false
		await get_tree().create_timer(1).timeout
		is_playing = true

player.gdとenemy.gdの_physics_processに以下を追加してゲーム開始まで何もしないようにしています

# player.gd/enemy.gd
func _physics_process(delta):
	if not %GameManager.is_playing:
		return

アイテムの獲得/出現処理

非アクティブの星を1つ選ぶ処理です
while trueはあまり良くないかもしれません

func pick_hide_star():
	var stars = get_tree().get_nodes_in_group("stars")
	while true:
		var index = randi_range(0,stars.size()-1)
		var star:Node3D = stars[index]
		if not star.visible:
			return star

星を取ったときの処理です
star.gdから呼ばれます
取った星を非アクティブにし、新しい星をアクティブにします
星の数を+1してLabelノードのテキストを更新しています
SEの再生もしています

func take_star(star):
	var next = pick_hide_star();
	next.set_active(true)
	star.set_active(false)
	star_count+=1
	star_label.text = "x {0}".format([star_count])
	se_star.play()

ダメージ処理

プレイヤーが敵から攻撃を受けたときの処理です
グループ機能でライフの画像を登録してあります
ライフを1減らし画像を非表示にしています
SEを鳴らし、画面にダメージのエフェクトを表示します
ライフが0になったらゲームオーバーです
ゲームオーバーのラベルを非表示にしてゲーム中フラグをオフにします

func damage(enemy):
	var lifes = get_tree().get_nodes_in_group("life")
	life_count -= 1
	lifes[life_count].visible=false
	se_bite.play()
	damage_effect.play("damage")
	if life_count<=0:
		player.die()
		game_over.visible = true
		is_playing = false

ダメージのエフェクトはAnimationPlayerで行っています
エフェクト画像のmodulateで透過の割合を設定し、表示のオンオフも設定しています

damage関数はenemy.gdから呼び出されます
まずenemyの頭のあたりにArea3Dを追加し
嚙みつきのアニメーションで当たり判定のオンオフを設定します

body_enteredシグナルを追加しGameManagerのdamage関数を呼び出します
レイヤーを設定すればplayerかどうかの判定はいらないかも

func _on_area_3d_body_entered(body):
	if body == player:
		%GameManager.damage(self);

完成したものがこちら
敵の行動パターンは変えてあります
待機/徘徊/追跡/攻撃を適度に切り替えて動くようにしてあります
スクリプトだけ貼っておきます

enemy.gd
extends CharacterBody3D


@export var JUMP_VELOCITY = 6
@onready var navigation_agent_3d = $NavigationAgent3D
@onready var animation_tree = $AnimationTree
@export var is_attack = false


var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
var player:Node3D
var is_jump = false

enum EnemyState {
	NONE,
	WAIT,
	WANDER,
	CHASE,
	ATTACK,
}

var prev_state:EnemyState = EnemyState.NONE
var prev_time
var state:EnemyState = EnemyState.WAIT
var wander_speed = 2
var chase_speed = 5
var current_speed = 3


func _ready():
	player = get_tree().get_nodes_in_group("player")[0]
	animation_tree.active = true


func _physics_process(delta):

	if not %GameManager.is_playing:
		return

	var init =false
	if state != prev_state:
		prev_time = Time.get_unix_time_from_system()
		prev_state = state
		init = true

	match state:
		EnemyState.WAIT:
			wait_process(delta,init)
			pass
		EnemyState.WANDER:
			wander_process(delta,init)
			pass
		EnemyState.CHASE:
			chase_process(delta,init)
			pass
		EnemyState.ATTACK:
			attack_process(delta,init)
			pass

	move(delta)


func wait_process(_delta,_init):
	current_speed = 0
	var now = Time.get_unix_time_from_system()
	if now-prev_time>2:
		state = EnemyState.WANDER


func wander_process(_delta,init):
	current_speed = wander_speed
	if init:
		set_wander_point()
	else:
		var dist = (player.global_position - global_position).length()
		var now = Time.get_unix_time_from_system()
		if dist<8:
			state = EnemyState.CHASE
		elif now-prev_time>10:
			state = EnemyState.WAIT


func set_wander_point():
	var offset = ((player.global_position - global_position).normalized()*3).round()
	var min = Vector2(global_position.x-10+offset.x,global_position.z-10+offset.z)
	var max = Vector2(global_position.x+10+offset.x,global_position.z+10+offset.z)
	min = min.clamp(Vector2(-20,-20),Vector2(20,20))
	max = max.clamp(Vector2(-20,-20),Vector2(20,20))
	navigation_agent_3d.target_position = Vector3(randf_range(min.x,max.x),0,randf_range(min.y,max.y))


func chase_process(_delta,_init):
	current_speed = chase_speed
	navigation_agent_3d.target_position = player.global_position
	var dist = (player.global_position - global_position).length()
	if dist>12:
		state = EnemyState.WAIT
	if is_on_floor():
		navigation_agent_3d.target_position = player.global_position


func attack_process(_delta,_init):
	if not is_attack:
		state = EnemyState.WAIT


func move(delta):
	if not is_on_floor():
		var new_velocity = velocity - Vector3(0,gravity * delta,0)
		navigation_agent_3d.velocity = new_velocity
	elif not is_attack:
		var new_velocity = (navigation_agent_3d.get_next_path_position() - global_position).normalized()*current_speed
		navigation_agent_3d.velocity = new_velocity
	else:
		navigation_agent_3d.velocity = Vector3.ZERO

	var loco = "idle"
	if state==EnemyState.WANDER: loco = "walk"
	if state==EnemyState.CHASE: loco = "run"
	var jump = "landed" if is_on_floor() else "falling"
	var attack = "attack" if is_attack else "normal"

	animation_tree.set("parameters/loco/transition_request",loco)
	animation_tree.set("parameters/jump/transition_request",jump)
	animation_tree.set("parameters/attack/transition_request",attack)


func _on_navigation_agent_3d_velocity_computed(safe_velocity):
	velocity = velocity.move_toward(safe_velocity,0.3)

	var direction = navigation_agent_3d.get_next_path_position() - global_position
	direction.y=0
	direction = direction.normalized()
	
	if direction.length()>0:
		look_at(global_position+direction)

	move_and_slide()


func _on_navigation_agent_3d_link_reached(details):
	var entry = details["link_entry_position"]
	var exit = details["link_exit_position"]
	
	if entry.y < exit.y+0.1 and is_on_floor() and absf(entry.y-global_position.y)<0.1:
		velocity = exit - entry
		velocity.y=0
		velocity = velocity.normalized()*current_speed
		velocity.y = JUMP_VELOCITY
		print("jump")


func _on_navigation_agent_3d_target_reached():
	match state:
		EnemyState.WANDER:
			state = EnemyState.WAIT
			pass
		EnemyState.CHASE:
			var distance = (player.global_position - global_position).length()
			if is_on_floor() and !is_attack:
				print("attack")
				state = EnemyState.ATTACK
				is_attack=true


func _on_area_3d_body_entered(body):
	if body == player:
		%GameManager.damage(self);

https://youtu.be/Mo_R0gQsys8

Discussion