恋愛シミュレーションを作ろう!

Part 3: メインスクリプトの作成


Godot Engine バージョン4.4

このパートでは、メインとなるスクリプトの中身を丁寧に解説します。ゲーム中のセリフ表示、選択肢、立ち絵、BGM、背景などの演出がどのように制御されているかを学びましょう。これを理解することで、自作のシナリオを思い通りに演出できるようになります!






メインスクリプト

下記のスクリプトを作成してMainに付与します。

@tool
extends Control

# --- UI Export Configuration ---
@export_category("SCENARIO LIST")
@export_tool_button("シーンを読み込む")
var button = load_scenarios_from_directory

@export var scenarios: Array[Scenario]
@export_category("ENDING LIST")
@export var endings: Array[PackedScene]
@export_category("OTHERS")
@export var option_btn: PackedScene

# --- Node References ---
@onready var character_speak_panel = $CharacterSpeakPanel
@onready var character_name = $CharacterSpeakPanel/CharacterName
@onready var character_text = $CharacterSpeakPanel/CharacterText
@onready var background: TextureRect = $Background
@onready var bgm: AudioStreamPlayer2D = $Bgm
@onready var audio: AudioStreamPlayer2D = $Audio
@onready var pos_left: TextureRect = $PositionBox/PosLeft
@onready var pos_center: TextureRect = $PositionBox/PosCenter
@onready var pos_right: TextureRect = $PositionBox/PosRight
@onready var option_box_container: VBoxContainer = $OptionPanel/OptionBoxContainer
@onready var option_panel: Panel = $OptionPanel
@onready var next = $CharacterSpeakPanel/Next
@onready var text_delay = $TextDelay

# --- Runtime Variables ---
var current_scenario := 0
var current_scenario_group := "begin"
var scenario_list_loaded := []
var is_dialog_mode := false
var text_to_display := []
var current_index := 0
var current_text_index := 0
var has_option := false
var ready_to_next := false

# --- Constants ---
const SAVE_SCENARIO_DIRECTORY := "res://scenes/scenarios/"
const SAVE_ENDING_DIRECTORY := "res://scenes/endings/"

var loaded_list := {}

# 初期化処理
func _ready():
load_scenario_list()

# シーンをソートするためのカスタムソート関数
func natural_sort(a: String, b: String) -> bool:
var regex = RegEx.new()
regex.compile(r"^([A-Z]?)-?(\d+)$")
var match_a = regex.search(a)
var match_b = regex.search(b)
if match_a and match_b:
var prefix_a = match_a.get_string(1)
var num_a = int(match_a.get_string(2))

var prefix_b = match_b.get_string(1)
var num_b = int(match_b.get_string(2))

if prefix_a == prefix_b:
return num_a < num_b
else:
return prefix_a < prefix_b
elif match_a and not match_b:
return true
elif not match_a and match_b:
return false
return a < b

# --- シナリオとエンディングファイルをフォルダから読み込む ---
func load_scenarios_from_directory():
scenario_list_loaded.clear()
var dir := DirAccess.open(SAVE_SCENARIO_DIRECTORY)
if dir:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(".tres"):
scenario_list_loaded.append(file_name.replace(".tres", ""))
file_name = dir.get_next()
dir.list_dir_end()

if scenario_list_loaded.size() > 0:
scenario_list_loaded.sort_custom(natural_sort)
for file in scenario_list_loaded:
scenarios.append(load(SAVE_SCENARIO_DIRECTORY + file + ".tres") as Scenario)

# トリック:エディタ反映用に一度代入し直し
var temp = scenarios
scenarios = []
scenarios = temp

# エンディングシーンも同様に読み込み
var dir_ending = DirAccess.open(SAVE_ENDING_DIRECTORY)
if dir_ending:
dir_ending.list_dir_begin()
var file_name = dir_ending.get_next()
while file_name != "":
if file_name.ends_with(".tscn"):
endings.append(load(SAVE_ENDING_DIRECTORY + file_name))
file_name = dir_ending.get_next()
dir_ending.list_dir_end()
# インスペクターを更新
notify_property_list_changed()

# --- シナリオのプレフィックスごとに分類 ---
func load_scenario_list():
for file in scenarios:
var prefix := file.prefix_scenario if file.prefix_scenario != "" else "begin"
if loaded_list.has(prefix):
loaded_list[prefix].append(file)
else:
loaded_list[prefix] = [file]

if loaded_list.has("begin"):
current_scenario = 0
load_scenario(loaded_list[current_scenario_group][current_scenario])
else:
printerr("シナリオのプレフィックス 'begin' が見つかりません。")

# --- シナリオを読み込み、UIや音声、背景を設定 ---
func load_scenario(scenario: Scenario):
option_panel.hide()
audio.stop()

# プレフィックスが変わったら最初から再開
if scenario.prefix_scenario != "" and scenario.prefix_scenario != current_scenario_group:
current_scenario_group = scenario.prefix_scenario
current_scenario = 0

# 背景変更時にフェード
if background.texture != scenario.background:
character_speak_panel.hide()
Fade.start_fade(0.5, change_background.bind(scenario.background))
await get_tree().create_timer(1.0).timeout

# キャラ名とセリフをセット
character_name.text = scenario.text_name
text_to_display = [scenario.text_speak]

# BGM処理
if scenario.text_bgm != "" and (bgm.stream == null or scenario.text_bgm != bgm.stream.resource_path):
bgm.stream = load(scenario.text_bgm)
bgm.play()

# 音声処理
if scenario.text_audio != "":
audio.stream = load(scenario.text_audio)
audio.play()

# 立ち絵をセット
pos_left.texture = scenario.exp_pos_left
pos_center.texture = scenario.exp_pos_center
pos_right.texture = scenario.exp_pos_right

# 発言パネル表示
if scenario.text_name != "":
character_speak_panel.show()

# 選択肢がある場合はボタンを生成
has_option = scenario.option_list.size() > 0
if has_option:
for option in scenario.option_list:
var option_btn_instance = option_btn.instantiate()
option_btn_instance.get_child(0).text = option.option_text
option_btn_instance.pressed.connect(find_scenario_by_name.bind(option.option_value))
option_box_container.add_child(option_btn_instance)
else:
option_panel.hide()

start_dialog()
await get_tree().create_timer(0.5).timeout
ready_to_next = true

# --- テキストを1文字ずつ表示するタイマー処理 ---
func _on_text_delay_timeout() -> void:
if current_index < text_to_display.size():
if current_text_index < text_to_display[current_index].length():
character_text.text = text_to_display[current_index].substr(0, current_text_index + 1)
current_text_index += 1
else:
text_delay.stop()
next.visible = true
if has_option:
is_dialog_mode = false
option_panel.show()
else:
option_panel.hide()
else:
# テキスト終了後、次のシナリオへ
text_delay.stop()
is_dialog_mode = false
ready_to_next = false
if current_scenario < loaded_list[current_scenario_group].size() - 1:
current_scenario += 1
load_scenario(loaded_list[current_scenario_group][current_scenario])
else:
var ending_scene: String = loaded_list[current_scenario_group][current_scenario].ending_scenario
if ending_scene != "":
for ending in endings:
if ending.resource_path.get_file().get_basename() == ending_scene:
get_tree().change_scene_to_file(SAVE_ENDING_DIRECTORY + ending_scene + ".tscn")
return
else:
print("シナリオが終了しました。")

# --- 入力イベント処理(クリックで次へ) ---
func _input(event: InputEvent) -> void:
if event.is_action_pressed("next") and is_dialog_mode and ready_to_next:
if current_index < text_to_display.size():
if current_text_index == 0 or current_text_index == text_to_display[current_index].length():
text_delay.start()
current_text_index = 0
current_index += 1
next.visible = false
else:
# スキップして全文表示
character_text.text = text_to_display[current_index]
current_text_index = 0
text_delay.stop()
next.visible = true
if has_option:
is_dialog_mode = false
option_panel.show()
else:
option_panel.hide()

# --- ダイアログ開始処理(テキスト初期化+タイマー開始) ---
func start_dialog():
character_text.text = ""
current_text_index = 0
current_index = 0
text_delay.start()
is_dialog_mode = true

# --- 背景変更処理(フェード用) ---
func change_background(new_background: Texture):
background.texture = new_background

# --- 選択肢からシナリオを検索して読み込む ---
func find_scenario_by_name(scenario_name: String) -> void:
var prefix := scenario_name.split("-")[0] if scenario_name.contains("-") else "begin"

if loaded_list.has(prefix):
for scenario in loaded_list[prefix]:
if scenario.scenario_name == scenario_name:
Se.play()
load_scenario(scenario)
return



解説内容

1. スクリプトの全体構造

このスクリプトは @tool 付きで、Godot エディタ上でもシナリオをロード可能になっています。UIや音響の再生、選択肢の処理など、ギャルゲーの核となる処理が詰まった一枚岩です。
@tool
extends Control



2. エディタ上でシナリオをロードする

@export_tool_button("シーンを読み込む") を使うことで、ボタン一つでシナリオフォルダから .tres ファイルを読み込めます。これは開発の効率化にとても便利です。


@export_tool_button("シーンを読み込む")
var button = load_scenarios_from_directory



3. シナリオのロード処理

func load_scenarios_from_directory():
...
この関数では、res://scenes/scenarios/ フォルダ内の .tres を全てスキャンして scenarios に追加し、エディタに反映します。エンディングも res://scenes/endings/ から同様にロードされます。
  • natural_sort() で自然な順番にソート(例:A-1, A-2, A-10)
  • 読み込んだシナリオを prefix_scenario で分類し、ゲーム中のシナリオ切り替えに使用



4. シナリオの表示ロジック

func load_scenario(scenario: Scenario):
この関数は1つのシナリオを読み込んで表示の準備をします。
  • キャラ名やセリフの設定
  • 背景画像やBGMの変更
  • キャラ立ち絵の設定(左右中央)
  • 選択肢がある場合はボタンを生成し、押されたら該当シナリオに分岐
  • _on_text_delay_timeout() で1文字ずつセリフを表示



5. テキスト表示タイミングと入力制御

func _on_text_delay_timeout():
  • 指定時間ごとに1文字ずつテキスト表示
  • テキストが終わると「次へ」ボタン表示 or 選択肢出現
func _input(event: InputEvent):
  • 入力があれば「次へ」進む
  • 表示中でもクリックで全表示(スキップ)可能



6. 選択肢の実装

func find_scenario_by_name(scenario_name: String):
選択肢で選んだシナリオ名をキーにして、シナリオをロード。これによりマルチエンディングや分岐が実現できます。



このパートでできるようになること

  • シナリオのロードと表示の流れを理解
  • セリフや背景、音楽などの演出をコントロール
  • 分岐や選択肢の追加によるマルチルート化
  • .tres によるデータ駆動型シナリオ管理



次回予告

次のパートでは、実際にシナリオ(.tres)ファイルを作成し、ゲーム内で再生する方法を紹介します。分岐やエンディングの演出もここでカバー!



🔗 補足

Scenario はカスタム Resource。Part 2で作成したもの。
テキストアニメーションや背景フェードもこのスクリプト内で制御。
最終更新日: 2025/06/19 07:08

コメント

    恋愛シミュレーションを作ろう! Part 3: メインスクリプトの作成 | GODOT TUTORIAL