Godot4 GUI、サウンド、その他の機能
ゲームを仕上げる
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);
Discussion