【Godot】Godotのクエスト入門:薬草を集めて報告し、報酬を一度だけ受け取る

作成: 2026-09-23最終更新: 2026-10-10

依頼書(.tres)と進みの記録(Autoload)を分けて、薬草を集めて報告し、報酬を一度だけ受け取るクエストを1本作ります。

村人から依頼を受けて薬草を集め、報告して報酬をもらうクエストは、変わらない依頼書と、遊ぶうちに変わる進みの記録に分けて作ります。依頼書はカスタムリソースの .tres に、進みの記録は Autoload の辞書に書きます。

進みの記録には4つの状態を持たせ、前にしか進ませません。報酬は、先に状態を「受け取り済み」へ進めてから渡します。こうしておけば、薬草を拾っても台詞が変わらない、話しかけるたびに報酬がもらえる、といった食い違いが起きません。

この記事では、依頼書と進みの記録を分けて、薬草を集めて報告するクエストを1本作る方法を解説します。

薬草3つと硬貨が描かれた依頼書と、薬草を拾いながら歩く人形、その先に進みを書き込むメモ帳

この記事でわかること

  • 依頼書(.tres)と進みの記録(Autoload の辞書)を分ける理由
  • 未受注・進行中・達成・受け取り済みの4つの状態と、前にしか進まない作り
  • 拾った数を数えて「報告しよう」に切り替える流れ
  • 報酬を一度だけ渡すための順番
  • 実践:薬草を3つ集めて報告し、報酬を一度だけ受け取る

動作確認環境: Godot 4.7

Sponsored

依頼書と進みの記録

クエストには、変わらないものと変わるものがあります。「薬草を3つ集める」「報酬は100G」は、誰が受けても同じです。「いま2つ集めた」「もう報酬を受け取った」は、遊んでいるうちに変わります。

変わらないほうを依頼書と呼び、カスタムリソースの .tres に書きます。変わるほうは進みの記録と呼び、Autoload(シーンを切り替えても残り続けるノード)の辞書に書きます。辞書は、名前と値の組を入れておく Dictionary です(配列と辞書)。依頼書はプレイ中に書き換えません。

依頼書の .tres(集める物・必要な数・報酬)は読むだけ。Autoload の記録には、依頼の id ごとに状態と集め��た数が書かれる

分ける理由は、.tres の性質にあります。ふつうに読み込むと、同じ .tres を読み込んだ場所は、すべて同じ1つのデータを見ています。依頼書に「集めた数」を書き込むと、同じ依頼書を使うほかの村人の数まで一緒に動きます。依頼書は読むだけにして、数は記録のほうに書きます。.tres の作り方と共有の仕組みは カスタムリソース入門 にあります。

同じ依頼書の .tres を使う村人2人。片方で集めた数を依頼書に書き込むと、もう片方の数も同じだけ動く

他のエンジンでいうと: 定義のデータをアセットとして持つ使い方では、Godot のカスタムリソース(.tres)は、Unity の ScriptableObject や UE の Data Asset に近い役割です。

クエストの4つの状態

進みの記録には、状態を1つ持たせます。クエストはいつも、次の4つのどれかにいます。

状態意味
NOT_STARTEDまだ依頼を聞いていない
ACTIVE受けて、集めている途中(薬草 1 / 3)
COMPLETEDそろった。まだ報告していない
REWARDED報告して、報酬も受け取った

分けておきたいのは、COMPLETED と REWARDED の間です。「達成したか」の1つだけで持つと、報告してほしい状態と、報酬を渡し終えた状態が同じに見えます。村人の台詞も「報告を聞く」と「もう渡した」を分けられません。

未受注から受注で進行中へ、3つそろって達成へ、報告して受け取り済みへ。矢印は前向きの一方通行

状態を変える関数は1つにまとめ、前へ進む変更だけを受け付けます。同じ状態や、後ろへ戻る変更は何もしません。これで、2回目の受注や報告の二重取りを、入口で断てます。

拾った数の数え方

薬草を拾ったら、Autoload の QuestManager に「薬草を1つ拾った」と知らせます。QuestManager は、薬草を集めている途中の依頼があれば、数を1つ増やします。必要な数に届いたら、状態を COMPLETED へ進めます。

薬草を拾うと QuestManager に届き、数が 1/3、2/3 と増え、3つ目で達成になって画面に「報告しよう」と出る

QuestManager は、記録が変わるたびに quest_changed シグナル(「変わったよ」の知らせ)を出します。画面の表示は、それを受けて書き換えます。表示の側は、いまの状態と数を読むだけです。シグナルのつなぎ方は シグナル入門 にあります。

薬草の側は、依頼の中身を知りません。「いま薬草を集めているか」を聞き、集めていれば知らせて消えるだけです。集めていないときは拾わずに残します。受ける前に拾って消えると、その分はどこにも数えられないからです。

報酬を渡す順番

報告されたら、状態が COMPLETED のときだけ報酬を渡します。このとき、先に状態を REWARDED へ進め、そのあとで報酬の額を返します。2回目に話しかけられても、状態はもう REWARDED なので、報酬は0です。

達成の状態で報告すると、先に受け取り済みにしてから100Gを渡す。2回目の報告は受け取り済みなので何も渡さない

先に受け取り済みにしておけば、そのあと何回報告されても、report() は0を返します。お金を先に足す順番だと、足してから状態を進めるまでのあいだは、まだ達成のままです。お金が増えたときに出すシグナルの先から報告がもう一度呼ばれると、そのあいだに二重に渡してしまいます。

Sponsored

実践:薬草を3つ集めて、報酬を一度だけ受け取る

RPG のおつかい、農場ゲームの納品、探索ゲームの素材集め —— 「集めて、報告して、報酬をもらう」形は同じです。村人に話しかけて依頼を受け、薬草を3つ拾って報告し、100G を受け取ります。もう一度話しても、報酬は出ません。

村人のそばで話しかけて依頼を受け、フィールドの薬草3つを拾い、村人へ戻って100Gを受け取る場面

① 置くもの

新しい2Dのプロジェクトで始めます。

main.tscnにPlayer・Villager・薬草3つ・Hud、薬草のシーン、依頼書の.tres、AutoloadのQuestManager
  • FileSystem ドックで右クリック → Create New → Script... で quest_data.gd を作って下のコードを書く。そのあと res:// フォルダを右クリック → Create New → Resource... で QuestData を選び、herb_quest.tres として保存する。一覧に QuestData が出るのは、コードの class_name QuestData で型に名前を付けたから。値は id が herb_quest、title が 薬草集め、target_item が herb、required が 3、reward_gold が 100
  • herb.tscn: ルートは Area2D(名前 Herb)。子に Sprite2D(Texture に icon.svg、Scale 0.25)と CollisionShape2D(CircleShape2D、Radius 16)。herb.gd を付ける
  • main.tscn: ルートは Node2D(名前 Main)。子に次のものを置く
    • Player(CharacterBody2D、位置 (200, 300)、見下ろし型なので Motion Mode を Floating)。子に Sprite2D(Texture に icon.svg、Scale 0.5)と CollisionShape2D(CircleShape2D、Radius 32)。Groups ドックの + で player グループを作って入れる
    • Villager(Area2D、位置 (600, 300))。子に Sprite2D(Texture に icon.svg、Scale 0.5)、CollisionShape2D(CircleShape2D、Radius 80。話しかけられる範囲)、Speech(Label、Position (-110, -70)、Text は空)
    • FileSystem ドックから herb.tscn を3回ドラッグして、(350, 120)・(850, 480)・(1000, 150) に置く
    • Hud(Label、Position (16, 16))
  • Autoload: quest_manager.gd を QuestManager の名前で登録する(Project Settings の Globals タブ → Autoload)

Scale を変えるのは Sprite2D だけです。Player・Villager・Herb と CollisionShape2D は (1, 1) のままにします。話しかけるのは Space(最初からある ui_accept)、移動は矢印キーです。

② 組み立て

依頼書の型です。状態の名前もここに置きます。

# quest_data.gd(依頼書の型)
class_name QuestData
extends Resource

enum State { NOT_STARTED, ACTIVE, COMPLETED, REWARDED }   # 上から 0, 1, 2, 3。この順にしか進まない

@export var id := ""              # 記録を引くための名前。依頼書ごとに1つ
@export var title := ""
@export var target_item := ""     # 集める物
@export var required := 3         # 必要な数
@export var reward_gold := 100    # 報酬

進みを記録する役です。

# quest_manager.gd(Autoload に QuestManager の名前で登録)
extends Node

signal quest_changed(quest: QuestData)

var progress := {}                    # 依頼書の id → {"state": 状態, "count": 集めた数}
var _active: Array[QuestData] = []    # 受けている依頼書

func get_state(quest: QuestData) -> QuestData.State:
	# 記録が無ければ空の辞書から読み、未受注として返す
	return progress.get(quest.id, {}).get("state", QuestData.State.NOT_STARTED)

func get_count(quest: QuestData) -> int:
	return progress.get(quest.id, {}).get("count", 0)

# 状態を変えるのはここだけ。前にしか進めない
func _advance(quest: QuestData, to: QuestData.State) -> bool:
	if to <= get_state(quest):
		return false                  # 同じか、後ろへ戻る変更は受け付けない
	if not progress.has(quest.id):
		progress[quest.id] = {"count": 0}
	progress[quest.id]["state"] = to
	quest_changed.emit(quest)
	return true

func accept(quest: QuestData) -> void:
	if _advance(quest, QuestData.State.ACTIVE):
		_active.append(quest)

func is_collecting(item: String) -> bool:
	for quest in _active:
		if quest.target_item == item and get_state(quest) == QuestData.State.ACTIVE:
			return true
	return false

func add_item(item: String) -> void:
	for quest in _active:
		if quest.target_item != item or get_state(quest) != QuestData.State.ACTIVE:
			continue
		progress[quest.id]["count"] += 1
		if get_count(quest) >= quest.required:
			_advance(quest, QuestData.State.COMPLETED)   # そろった。あとは報告だけ
		else:
			quest_changed.emit(quest)

# 報酬の額を返す。達成しているときだけ、一度だけ
func report(quest: QuestData) -> int:
	if get_state(quest) != QuestData.State.COMPLETED:
		return 0
	_advance(quest, QuestData.State.REWARDED)          # 先に「受け取った」にする
	_active.erase(quest)
	return quest.reward_gold

薬草は、拾ったことを知らせて消えるだけです。

# herb.gd(Herb に付ける)
extends Area2D

@export var item_id := "herb"

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
	if body.is_in_group("player") and QuestManager.is_collecting(item_id):
		QuestManager.add_item(item_id)    # 拾ったことを知らせる
		queue_free()

Main は、移動・話しかけ・表示を受け持ちます。

# main.gd(Main に付ける)
extends Node2D

const HERB_QUEST := preload("res://herb_quest.tres")   # 依頼書。読むだけ

@onready var player: CharacterBody2D = $Player
@onready var villager: Area2D = $Villager
@onready var speech: Label = $Villager/Speech
@onready var hud: Label = $Hud

var gold := 0

func _ready() -> void:
	QuestManager.quest_changed.connect(_update_hud)
	_update_hud(HERB_QUEST)                   # 最初の表示は、自分から読みに行く

func _physics_process(_delta: float) -> void:
	player.velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * 200.0
	player.move_and_slide()

func _unhandled_input(event: InputEvent) -> void:    # ほかで使われなかった入力が届く
	if event.is_action_pressed("ui_accept") and villager.overlaps_body(player):   # 村人の範囲にプレイヤーが重なっているか
		_talk(HERB_QUEST)

# 台詞は、いまの状態を読んで決める
func _talk(quest: QuestData) -> void:
	match QuestManager.get_state(quest):            # 状態によって分ける
		QuestData.State.NOT_STARTED:
			QuestManager.accept(quest)
			speech.text = "薬草を%dつ集めてきて" % quest.required
		QuestData.State.ACTIVE:
			speech.text = "まだかな(%d / %d)" % [QuestManager.get_count(quest), quest.required]
		QuestData.State.COMPLETED:
			gold += QuestManager.report(quest)          # 報酬は一度だけ
			speech.text = "ありがとう。%dG だ" % quest.reward_gold
		QuestData.State.REWARDED:
			speech.text = "この前は助かったよ"
	_update_hud(quest)

func _update_hud(quest: QuestData) -> void:
	var line := ""
	match QuestManager.get_state(quest):
		QuestData.State.NOT_STARTED:
			line = "村人に話しかけよう"
		QuestData.State.ACTIVE:
			line = "%s %d / %d" % [quest.title, QuestManager.get_count(quest), quest.required]
		QuestData.State.COMPLETED:
			line = "%s 報告しよう" % quest.title
		QuestData.State.REWARDED:
			line = "%s 完了" % quest.title
	hud.text = "%s\n所持金 %dG" % [line, gold]

③ 動かして確かめる

左上の表示が「村人に話しかけよう」から「薬草集め 0/3」、「薬草集め 報告しよう」、「薬草集め 完了・所持金100G」へ順に変わる

main.tscn を開いて F6 で実行します。見るのは3つです。

まず、左上に「村人に話しかけよう」と「所持金 0G」が出ます。この時点で薬草に触れても、拾えずにそのまま残ります。プレイヤーの中心が (500, 300) くらいになるまで村人に近づき、止まって Space を押します。村人が「薬草を3つ集めてきて」と話し、左上が「薬草集め 0 / 3」に変わります。

次に、薬草を拾っていきます。受ける前に触れた薬草も、触れ直せば拾えます。1つ拾うごとに「1 / 3」「2 / 3」と増え、3つ目で「薬草集め 報告しよう」に変わります。

仕上げに、村人のところへ戻って Space を押します。「ありがとう。100G だ」と話し、左上が「薬草集め 完了」「所持金 100G」になります。もう一度 Space を押すと「この前は助かったよ」に変わり、所持金は100G のままです。

続けて、herb_quest.tres の required を 3 から 2 に変えます。もう一度 F6 で最初から遊びます。2つ拾った時点で「薬草集め 報告しよう」に変わり、3つ目の薬草は触れても拾えずに残ります。依頼書の数字を1つ変えただけで、コードは開いていません。確かめたら 3 に戻します。

ポイントは2つです。

  • 台詞も表示も、状態を読んで決める: 村人も左上の表示も、自分で数や状態を持ちません。QuestManager に聞いて、4つの状態で分けるだけです。会話の分岐の作り方は 会話システム入門 にあります
  • 状態を変える入口は1つ: _advance() が後ろへ戻る変更を断るので、2回目の受注も、報酬の二重取りも起きません

うまくいかないときは

症状原因直し方
話しかけても台詞が出ない村人の範囲(半径80)とプレイヤーの体(半径32)の円が重なっていない。または CollisionShape2D に形が入っていない両方に CircleShape2D を入れ、中心どうしが112より近くまで寄ってから Space を押す
薬草に触れても拾えないPlayer が player グループに入っていない。または target_item と item_id の文字が違うGroups ドックで player に入れる。herb_quest.tres と薬草の両方を herb にそろえる
実行すると「Identifier "QuestManager" not declared in the current scope.」と出るquest_manager.gd を Autoload に登録していないか、名前が違うProject Settings の Globals → Autoload に、QuestManager の名前で登録する

おまけ:先に知っておくと良いこと

  • セーブするのは記録と所持金: 依頼書の .tres は保存せず、QuestManager.progress の辞書と所持金を JSON にします。JSON から読んだ状態は 3.0 のような小数になり、match で比べると一致しないので、int() で整数に戻します。読み戻したら、進行中の依頼書を _active に入れ直します。書き方は セーブ/ロード入門 にあります
  • 進みは Autoload で見る: 実行中にシーンドックを Remote に切り替え、QuestManager を選びます。インスペクタの progress に、いまの状態(0〜3の数)と数が見えます。村人のノードには何も入っていません
  • 次の依頼も、依頼書を1枚足す: 集める物と数が違う依頼でも、QuestManager はそのまま使えます。村人を増やすときは、話しかけた相手の依頼書を渡すように Main を広げます
Sponsored

まとめ

  • 依頼書と記録を分ける: 変わらない依頼書は .tres で読むだけ。変わる進みは Autoload の辞書に書く
  • 4つの状態: 未受注・進行中・達成・受け取り済み。達成と受け取り済みを分ける
  • 前にしか進まない: 状態を変える関数を1つにして、後ろへ戻す変更を断る
  • 拾ったら知らせる: 薬草は QuestManager に知らせるだけ。数がそろったら「報告しよう」
  • 受け取り済みにしてから渡す: 先に状態を進め、そのあとで報酬を渡す

あなたのゲームの最初のクエストは、何を集めて、誰に報告する依頼にしますか。

次に読む次のカテゴリ「シーン構成」へ
シーン構成

グループによる柔軟なオブジェクト判定

この記事を読む
Godot このセクションのノート104