吹き出しとか会話画面とかメッセージ系のものを作ってみたい!【Godot 4.7】

カテゴリ: Godot
タグ: Godot UI

会話システム(キャラクターがしゃべったセリフを表示する)を作ってみたい!!!

とはいっても、会話的なシステムと言っても色々ある気はしますが・・・ とりあえず下の二つを作ってみたいなぁと思いました

  • 近づくとポップアップ的にテキストが出てくるようなもの
    • アクションゲームとかでキャラクターに近づいたりするとそのキャラクターのセリフとか呟きとか、考えてくることが表示されるみたいな・・・そんなイメージのモノ!
    • 自動的にセリフが流れていくイメージ
  • 話しかけて画面全体を占有するようなもの
    • 実際にキャラクターに話しかけたりした後に、画面下部にテキストを表示するエリアが表示されてクリックとかボタンを押すことによって会話が進んでいくようなイメージのもの
    • その間キャラクターは操作は行えない感じのモノ

近づくとポップアップ的にテキストが出てくるもの

それではやってみているのですが・・・早速、そもそもテキストノードを入れた瞬間問題が発生しました。

(問題)なんか文字がぼやける

RichTextLabelノードを利用して文字を表示してみようと思ったのですが・・・

文字サイズを小さくしよーと思って、ちょうど「font size」という項目があったのでその項目を小さくしたのですが・・・なんか文字がぼやけて表示されてしまう

2Dのウィンドウだとそれっぽく見えるけど・・・実施にゲーム画面を表示するとものすごいボケて見える

解決策をチョーーと調べてみたりしたのですが・・・(これで合っているのか?)発想の逆転でしたね・・・フォントのサイズを小さくするのではなくTransformのScaleを小さくしたうえで調整をしたらいい感じに表示されるようになりました

フォントサイズはそのままで、Scaleを半分にする

するとボケない!!!とても素晴らしいね・・・!!

プレイヤーが近づいたことを検知する

とりあえずArea2Dノードを使えば近づいたことはわかるはず!っていうことでそれをもとにしたノード構成を作っています

Playerのノードにグループ(これもまだあんまり理解できていませんが・・・)を指定して上げて、それをもとに接触したものがプレイヤーかどうか~を判断してメッセージを変えるようにしてみました。

NPCのノードにつけたスクリプト
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
extends Node2D

@onready var interaction_area: Area2D = $InteractionArea
@onready var rich_text_label: RichTextLabel = $RichTextLabel

func _ready() -> void:
	interaction_area.body_entered.connect(change_message)
	pass # Replace with function body.

func change_message(body: Node2D) -> void:
	if body.get_groups().has("Player"):
		rich_text_label.set_text("仲間・・・?")

テキストの変更方法はまだよくわかっていないけどとりあえずこれで・・・っていう感じでやっている。

最初はグループによるプレイヤーの判定をしていなかったんだけど、そうするとどうやら「タイルマップ」との接触を最初に検知してしまうらしく・・・それで勝手にテキストが変わっちゃう・・・ので追加しました。

alt text

これで近づいたときにテキストが変わるはとりあえずなんとなくできました。

・・・が、さすがにもう少しだけ見た目とか色々リッチにするはどうしたらよいかを見たい!!

ま、定番は・・・漫画の吹き出しっぽい形状の背景があってその中にテキストがあるっていう感じだよね。それはどうやったらできるだろうか

まだUI系のチュートリアルを読んでいないから色々わかっていない・・・RichTextLabelノード単体で作るのであれば、Themeっていうのがそれっぽいかも・・・

https://docs.godotengine.org/en/4.7/tutorials/ui/gui_skinning.html

と思って色々見たんだけど・・・わからん!! Themeっていうのを使って設定したら行けるかなって思って触ってみたけどRichTextLabelに設定して背景色っぽい設定をいじってみたけど変わらん!!!

コンテナ系のノードを追加してそれを背景として使うのが正しいのかぁ・・・ってことでその方向性で見てみようかな・・・

(関係ない、なんかいい方ないかなぁの話。)
当然ではあるのだと思うけど、上のコードみたいなのを作った後に、ノードの構成を変えたい(例えばRichTextLaabelに親を追加してそれに紐づける形でメッセージ関係何かを管理したい)ってなったときにコードの参照している部分も変える必要がある感じだよねぇ・・・そんなの絶対自分忘れるんだよな・・・自動でその辺を差し替えるようにしてくれたり、跡参照をややこしくしないように自分の子までは参照するようにする(孫とかどんどん参照してくとまたややこしくなりそうな気がするからね・・・)とかあったほうが良いのだろうか・・・
コンテナ?とかを使って吹き出しを作ってみようとする

コンテナを使ってみて?(このコンテナが特殊なのか?)早速ひかかったポイントを紹介します。

コンテナの子の要素が表示されない

なんか知らないけど・・・コンテナのこの要素のminimux sizeとかmaximum sizeが負の値になっている影響で?全く表示されない形になっていました・・・(なんでこんなことになって入るのだ・・・?)

その辺の値をちょこちょこ弄ると見えるようになりました・・・

いや!!!違う!!!コンテナとかいらないかも!!!!

Stylesの設定をしたらいけそうだった・・・

(AIさんにお願いして調べてもらっていて・・・)

ここ!!!ここのStylesを定義して上げたら背景色とかついていそうだった!!!!これが使いやすいかも!!

吹き出しのしっぽを作る

Stylesを使うことで囲いっぽいものは作れるってことわかったから、より吹き出しっぽくするために「尻尾」の部分を作っていきたいですね!

だた、いい感じに三角形を作る方法が全く思いつかなったのでAIさんと相談しながら・・・どうやら、「CanvasItemが持っているdraw_colored_polygonとか」を使うことでポイントを指定して色付きポリゴン(ポリゴンがまだよくわかっていないけど・・・ポリゴンとメッシュって何が違うのーーー!!)を使って三角形を表示することが出きるっぽいのでそれで試していました。

こんな感じのノードの構成にして、FukidashiShippoっていMarker2Dを使って尻尾の先端の位置を決める。他に点はテキストラベルの中心あたりに適当に用意しておけばよいかなぁ~という魂胆ですね。

で、引っかかったポイントをメモっておきます

表示されねぇ~んだけど!!
text
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func _draw() -> void:
	# 吹き出し尻尾、色を設定していく
	var fukidashi_point :PackedVector2Array
	var fukidashi_left: Vector2 = rich_text_label.position + rich_text_label.size * 0.5 + Vector2.RIGHT * 20
	var fukidashi_right: Vector2 = rich_text_label.position + rich_text_label.size * 0.5 + Vector2.LEFT * 20
	fukidashi_point.append(fukidashi_left)
	fukidashi_point.append(fukidashi_right)
	# TODO:なんか尻尾の位置が思ってたところよりもずれるんだよなぁ・・・何なのだろうか?座標とかなんだろうけど、ローカルグローバル???
	fukidashi_point.append(fukidashi_shippo.position)
	
	draw_colored_polygon(fukidashi_point, fukidashi_color)

このdraw_colored_polygon とかのメソッドは_draw()の中で描く必要があるみたいですね・・・最初は_ready()内に書いていて「なんかでないな~と」悩んでいたらめちゃくちゃ _draw()使えよ!! というエラーが出ていました;;

なんか若干ずれるんだけど!!!

↑のTODOコメントの通りなんだけど、なんか若干ずれる・・・

AIさんとドキュメントにそれっぽそうなところを投げて調べてもらったところ・・・?(これは正しいのか?)

Godot の _draw() では、すべての描画命令(draw_colored_polygon など)は**「この Control ノード自体を (0,0) -origin に持つローカル座標系」**で実行されます。ドキュメントの local space という記述がまさにこれです。

とのこと。今はスクリプトをNPCっていうノードにつけていて、その子が若干動いているんだよね。(吹き出しの親)

だからその分ずれている?のかな?なのでこのノードの位置をリセットすると多分吹き出しのしっぽの位置は合うと思うので試してみる。

その通りのようですね・・・!すっきりではあるが・・・

AIさんは「座標系の変換をかましてあげましょう!それを考慮したうえで!」

っと言っていますが・・・ぶっちゃうけNPCが持つスクリプトでやるべきことではないっていうのが正しいのでしょうね・・・なんでTweetPopUP自体にスクリプトを持たせましょうか・・・

もう少し動きをつけたいなぁと思っていたのですが、Tween系のチュートリアルドキュメントはなさそうなのですね・・・

なんとなくの使い方は多分わかっているつもりですが・・・

https://docs.godotengine.org/en/stable/classes/class_tween.html#tween

に一応例とか色々いたのでこれを参考にしながらちょこっと動きをつけてみましょう!

npc.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
extends Node2D

@onready var interaction_area: Area2D = $InteractionArea
@onready var tweet_pop_up: Node2D = $TweetPopUP
@export var fukidashi_color: Color = Color(0.6, 0.6, 0.6, 1)

func _ready() -> void:
	# プレイヤが近づいたことを検知するイベントに紐づける
	interaction_area.body_entered.connect(tweet_pop_up.show_message)
	interaction_area.body_exited.connect(tweet_pop_up.close_message)
tweet_pop_up.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
extends Node2D

@onready var rich_text_label: RichTextLabel = $RichTextLabel
@onready var fukidashi_shippo: Marker2D = $FukidashiShippo

@export var fukidashi_color: Color = Color(0.6, 0.6, 0.6, 1)

var tween: Tween

func _ready() -> void:
	# 色の設定
	var new_stylebox_normal :StyleBoxFlat = rich_text_label.get_theme_stylebox("normal").duplicate()
	new_stylebox_normal.set_bg_color(fukidashi_color)
	rich_text_label.add_theme_stylebox_override("normal", new_stylebox_normal)

func _draw() -> void:
	# 吹き出し尻尾、色を設定していく
	var fukidashi_point :PackedVector2Array
	var fukidashi_left: Vector2 = rich_text_label.position + rich_text_label.size * 0.5 + Vector2.RIGHT * 20
	var fukidashi_right: Vector2 = rich_text_label.position + rich_text_label.size * 0.5 + Vector2.LEFT * 20
	fukidashi_point.append(fukidashi_left)
	fukidashi_point.append(fukidashi_right)
	fukidashi_point.append(fukidashi_shippo.position)
	
	draw_colored_polygon(fukidashi_point, fukidashi_color)
	

func show_message(body: Node2D) -> void:
	if body.get_groups().has("Player"):
		rich_text_label.set_text("仲間・・・?")
	# アニメーションをつける
	if tween:
		tween.kill()
	tween = create_tween()
	tween.set_parallel()
	tween.tween_property(self, "scale", Vector2.ONE, 0.3)
	tween.tween_property(self, "modulate", Color(1,1,1,1), 0.3)
	
func close_message(body: Node2D) -> void:
	if body.get_groups().has("Player"):
		rich_text_label.set_text("やはり孤独・・・")
	# アニメーションをつける
	if tween:
		tween.kill()
	tween = create_tween()
	tween.set_parallel()
	tween.tween_property(self, "scale", Vector2.ZERO, 0.3)
	tween.tween_property(self, "modulate", Color(1,1,1,0), 0.3)

ということでスクリプトをまた別でつける方式にしてみました!!

alt text

まぁまぁいい感じに出たり消えたりしているかなぁ~という感じかな・・?(出たり消えたりするときに、吹き出しのしっぽとテキストの背景色が分離しているのがわかるように見えるのが気になるけど・・・なんでだろうか・・・)

話しかけて画面全体を占有するようなもの

とりあえずこれは近づいて「話す」ボタンを押す⇒画面全体にメッセージ表示が出る⇒ボタンを押すと会話が進む

という流れのやつだよね。うーん・・・どうやって作っていこうかしら・・・

まあ、NPC側でイベントキャッチ?みたいな感じにしてあとはシグナルを上げて誰かが拾ってUI側の表示をする・・・みたいな感じになるかなぁ・・・

色々頑張ってきたけど、これでいいのだろうか・・・?

ということで色々頑張ってみたので説明(メモ)を書こうと思っているのですが・・・う~~ん~~~これでいいのか??ああ??っという感じです・・・ゲーム作りってすぐにスパゲッティになるのですね・・・(「ゲーム作り」というより私の問題でしょうケド)

できたのがこんな感じ!

alt text

NPCノードについているスクリプトからですね。省きながらですけど。

npc.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
extends Node2D

@onready var interaction_area: Area2D = $InteractionArea
@onready var tweet_pop_up: Node2D = $TweetPopUP
@export var fukidashi_color: Color = Color(0.6, 0.6, 0.6, 1)

signal talked_by_player #プレイヤーに話されたときに発火する
# TODO: こんな風にnpcとかの方にメッセージを持ってあげてイベントと一緒に渡してあげたほうが良いか?どういう構成良いんだろうぇ・・・
var dialog_messages: Array[String] = ["こんにちは","この世界一人孤独かと思っていたけど・・・","自分以外人間やはりいたんだね・・・","私に話しかけたら最後","動けないでしょう?","もう逃がさないよ"]

func _ready() -> void:
	# プレイヤーが近づいたことを検知するイベントに紐づける
	interaction_area.body_entered.connect(tweet_pop_up.show_message)
	interaction_area.body_exited.connect(tweet_pop_up.close_message)

func _process(_delta: float) -> void:
	# 攻撃ボタンが押されたときにプレイヤーと接触していたかを調べる
	if Input.is_action_just_pressed("attack"):
		for body in interaction_area.get_overlapping_bodies():
			if body.get_groups().has("Player"):
				# 会話されたイベントを発火する
				talked_by_player.emit(dialog_messages)

攻撃ボタンをインタラクションボタン的な感じってことにして、それを押されたときにイベントを発火するようにしました。これを受け取って入って処理を進めていきます。

Playerノードですね。

player.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
extends CharacterBody2D

@onready var anim = $AnimatedSprite2D
@onready var attack_sprite = $AttackArea/Sprite2D
@onready var attack_area = $AttackArea
@onready var npc: Node2D = $"../NPC"

var can_attack = true
var can_action = true #メッセージ表示イベントなど、動けない状態にするときに切り替える

const SPEED = 200.0
const JUMP_VELOCITY = -400.0
const PUSH_FORCE := 120.0
const HIT_TRANSFER = 1.0
const HIT_DECAY = 1200.0

var knockback := Vector2.ZERO # 受け取った勢い

@onready var dust_effect: Node2D = $DustEffect # 土煙エフェクト

func _ready() -> void:
	# npcに話しかけたときに動けないようにする
	npc.talked_by_player.connect(
		func(_strings: Array[String]):
			can_action = false
	)
	
func _physics_process(delta: float) -> void:
	
	# TODO: 本当は操作系の処理を読んでいるところをまとめてあげて、そこでこの判定で止めたりするっていうのが正しいんだろうなぁ・・・これだ関係ないエフェクトと何とかが後続でいたとしても止めてしまうことになるから・・・
	if can_action == false:
		return
	
	if Input.is_action_just_pressed("attack") and can_attack:
		attack()
	# Add the gravity.
	if not is_on_floor():
		velocity += get_gravity() * delta

func set_can_action() -> void:
	can_action = true

NPCからもらったイベントを受け取って、プレイヤーを操作不能状態にするフラグを持たせてあげました。 ・・・ちょっと描き方まだまだ微妙な気はするが・・・(実際、強制的に動けなくなるから、空中で話しかけると空中に浮いたまま話すことになるし・・・)

後、メッセージの表示が終わった後にプレイヤーを操作可能にするためのset_can_action()っていうメソッドもいますね。

次はWorldノードですね

world.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
extends Node

# TODO:まあ参照を持たないとイベント取ることもできないか・・・うーん・・・とはいあんまり持ちたくないのだよな・・・実際にはみんなどうしているのだろうか。グループで取ってきて設定しているとか??
@onready var npc: Node2D = $NPC
@onready var canvas_layer: CanvasLayer = $"../CanvasLayer"
@onready var player: CharacterBody2D = $Player

func _ready() -> void:
	# メッセージシグナルを受け取ったときにUIの方のメソッドを呼びだすようにする
	npc.talked_by_player.connect(canvas_layer.call_set_text)

# メッセージの表示が終了した際に呼び出される
func message_finished() -> void:
	# プレイヤーを移動可能な状態にする
	player.set_can_action()
	

UI側のノードを呼び出してメッセージ表示して~ってお願いしたり、メッセージ表示が終わったことを受け取ってプレイヤーを操作可能にしたりとかそんな感じですね~~

ノードの参照ってあんまり持ちたくないなぁ・・・の気持ちがものすごい出てきているけど・・・けど持たないと難しいよなぁ・・・うーんという感じを凄い感じた。ただ一つだけわかった気がするのは絶対にコードの上の方で参照を書いて変数に入れて使っていったほうが良い気がするね 最低限でもそうじゃないと何がなんだか、訳が分からなくなるという直感が働きましたね・・・

で、UI側、CanvasLayerですね

canvas_layer.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
extends CanvasLayer

@onready var message_area: Control = $MessageArea
@onready var world: Node = $"../World"

func _ready() -> void:
	# メッセージが呼び出し終わったらプレイヤーの方のノードのメソッドを呼ぶ
	message_area.message_play_finished.connect(world.message_finished)

# ダイアログメッセージ表示のコントールのメソッド呼び出す
func call_set_text(strings: Array[String]) -> void:
	message_area.set_text(strings)

ま、こっちもゲーム内部の方の親と似た感じで、メッセージ表示してねのメソッド呼び出しを受けて、子の方のメソッドを改めて呼び出すようにしたり、メッセージ表示が終わったよーを向こう側に伝えるための存在ですね。

で、MessageAreaノードの方

message_area.gd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
extends Control

@onready var text_area: RichTextLabel = $TextArea
var is_now_messge_playing: bool = false

signal pressed_buttom_while_message_playing
signal message_play_finished

func _process(_delta: float) -> void:
	# テキスト再生中にボタンを押されたらイベントを発火する(次のメッセージを表示する)
	if is_now_messge_playing and Input.is_action_just_pressed("attack"):
		pressed_buttom_while_message_playing.emit()

# メッセージをUIに表示する
func set_text(strings: Array[String]) -> void:
	# もし既にメッセージの再生中であれば、メソッド呼ばれても後続の処理に進まない
	if is_now_messge_playing:
		return
	
	# メッセージの再生を開始する
	is_now_messge_playing = true
	
	# UIが非表示状態であれば表示に切り替える
	if self.visible == false:
		self.set_visible(true)
	
	# メッセージの表示切替を行う
	for s in strings:
		text_area.text = s
		# ボタン押されたら次のメッセージを表示する
		await pressed_buttom_while_message_playing
	
	# メッセージの非表示、再生を終了する
	self.set_visible(false)
	is_now_messge_playing = false
	message_play_finished.emit()

メッセージ表示してねーの連絡を受けてメッセージを表示して、ボタンが呼ばれるたびにイベントを発火して、それを見てメッセージ送りをしている感じですね。

最初は、「既にメッセージの再生中かどうか」の判定がなかったので、無限に次のメッセージが呼ばれ続けるということになっていましたね・・・

本当はもう少しTweenを使ってキャラクターの立ち絵が横から入ってくるとか、色々やってみたかったけど、いったん力尽きたのでここまで・・・本当に難しいね・・・

おわり

正直「このくらいなら簡単でしょ?すぐできちゃうかも」と思っていましたが・・・;;

まあ、とはいえ、ドキュメントを見たりしながら少しづつ理解しながら進めていっている感じがして結構よかった気がする。Godotのことが少しわかってきたというか///

参考リンク