MENU

Godot 4 でWiz系ダンジョンRPG開発 (敵のデータ管理と複数表示) [009]

前回はダンジョンRPGの迷宮っぽい画像に差し替えました。それに関連してコードの修正なども行いました。その際、階段の座標を JSON 形式でデータ管理するよう変更したのを覚えているでしょうか。以後、データは JSON で管理するようにしていきます。

今回は敵のデータ管理と敵関連の対応を行います。この記事に書かれている実行することで、スライムもゴブリンもランダムで複数体出現するようになります。(上の画像をクリックすると、イメージが沸くと思います)

今まで作成してきたのは以下の通りです。詳しくは過去記事をご確認ください。

  • ダンジョンの壁と床の設置
  • プレイヤーの現在座標とその表記
  • イベントメッセージの表示
  • 敵とのランダムエンカウント
  • ダンジョンの扉と階段の設置
  • ダンジョンのB1から街に戻る処理
  • ダンジョンのB1からB2に降りる処理
  • ダンジョンの画像置き換え
  • 座標データ管理 (階段)

このあたりから記事にあるコードをコピペするだけではきびしいかもしれません。なぜなら、作り手独自の設定にすることが多くなってくるからです。問題が発生しても、プログラマー的素養がある人であれば自力で解決できます。何にせよここから先は、試す前にバックアップしておくとよいかもしれません。

目次

敵のデータ作成

まず、敵データを作成します。そのあとで、ダンジョンでデータにある敵が出現するように実装していきます。

  • JSON ファイル作成 (敵データ用)
  • 敵データインポート

JSON ファイル作成 (敵データ用)

{
  "goblin": {
    "name_key": "ENEMY_GOBLIN",
    "max_hp": 15,
    "attack": 4,
    "defense": 2,
    "agility": 5
  }
}

最初に敵データを JSON 形式で作成してください。ここでは仮に上記のようにします。そのまま使う場合は、テキストファイルに上記内容をコピペし、enemy_data.json として保存してください。

この記事では説明のために上記のようになっています。実際には、敵のMPなど他にも管理しなければならない項目があるはずです。あとで追加の作業をしないようにするためにも、敵データの項目 (敵のレベル、MPなど) は、この時点で完成させておくとよいでしょう。データは SpreadSheet で作成し JSON で書き出してください。スライムやゾンビなど敵の種類は後回しでも大丈夫です。

敵データインポート

Godot Engine の Filesystem にある data フォルダに enemy_data.json をインポートします。作成した敵データJSONファイルを res://data/ にドラッグ & ドロップしてください。

敵データファイル(JSON)を読み込んでダンジョンに出現させる処理

ここからは実装です。この記事では敵データがかなり簡素化された内容になっていることに注意してください。

  • 敵個体ごとの動的データ処理
  • 戦闘処理用コード修正
  • メイン処理用コード修正

敵個体ごとの動的データ処理

Filesystem に active_enemy.gd というスクリプトファイルを作成します。

# ==============================================================================
# active_enemy.gd : 多言語対応・戦闘用個体データクラス
# ==============================================================================
class_name ActiveEnemy
extends RefCounted

var id: String
var name: String # tr()によって動的に決定されるローカライズ名(ゴブリンAなど)
var hp: int
var max_hp: int
var attack: int
var defense: int
var agility: int
var is_dead: bool = false

func _init(enemy_id: String, master_data: Dictionary, suffix: String = "") -> void:
	id = enemy_id
	# 💡 Godot標準の tr() を通すことで、現在の言語設定に自動追従します
	name = tr(master_data["name_key"]) + suffix
	
	max_hp = int(master_data["max_hp"])
	hp = max_hp
	attack = int(master_data["attack"])
	defense = int(master_data["defense"])
	agility = int(master_data["agility"])

作成した active_enemy.gd を上記内容にしてください。これは敵個体ごとの動的データ (現在のHPなど) を管理するクラスです。RefCounted を継承させ、tr() 関数による多言語変換を内包させるものです。

作り込んだ敵データを使用したい場合は、上記の内容はデータに沿って変数を変える必要があります。(MPとか)

戦闘処理用コード修正

# ==============================================================================
# battle.gd : 戦闘の進行、コマンド操作だけを担当するクラス(完全データ駆動版)
# ==============================================================================
class_name BattleSystem
extends RefCounted

signal battle_finished

var message_window: PanelContainer
var battle_text: Label
var attack_button: Button
var auto_button: Button
var flee_button: Button
var enemy_graphic: TextureRect

# 💾 JSONから読み込んだ敵のマスターデータ
var enemy_master_data: Dictionary = {}

# ⚔️ 現在の戦闘に参加している敵インスタンスのリスト(汎用)
var current_enemies: Array[ActiveEnemy] = []

func _init(main_node: Node) -> void:
	randomize()

	message_window = main_node.get_node_or_null("CanvasLayer/PanelContainer")
	battle_text = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/BattleText")
	attack_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AttackButton")
	auto_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AutoButton")
	flee_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/FleeButton")
	enemy_graphic = main_node.get_node_or_null("CanvasLayer/EnemyGraphic")
	
	if attack_button: attack_button.pressed.connect(_on_attack_button_pressed)
	if auto_button: auto_button.pressed.connect(_on_auto_button_pressed)
	if flee_button: flee_button.pressed.connect(_on_flee_button_pressed)
	
	_load_enemy_data_from_json()

func _load_enemy_data_from_json() -> void:
	var file_path: String = "res://data/enemy_data.json"
	if not FileAccess.file_exists(file_path):
		return
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	var json = JSON.new()
	if json.parse(json_string) == OK and typeof(json.data) == TYPE_DICTIONARY:
		enemy_master_data = json.data

# 🛠️ 【仕様変更】引数として出現させたい敵のIDを受け取るように汎用化
func start_battle(enemy_id: String) -> void:
	# 指定された敵のデータを元に、ランダムな匹数を動的に生成
	_generate_enemies(enemy_id)
	
	# UIボタン群を表示
	if attack_button: attack_button.show()
	if auto_button: auto_button.show()
	if flee_button: flee_button.show()
	if message_window: message_window.show()
	if enemy_graphic: enemy_graphic.show()
	if attack_button: attack_button.grab_focus()

# ⚙️ 【完全データ駆動化】引数で受け取ったIDを使ってマスターデータを動的ルックアップ
func _generate_enemies(enemy_id: String) -> void:
	current_enemies.clear()
	
	# 💡 特定の敵に依存せず、渡された enemy_id が存在するかどうかを安全にチェック
	if not enemy_master_data.has(enemy_id):
		if battle_text: 
			battle_text.text = "Error: Enemy data [%s] not found." % enemy_id
		return
		
	var enemy_master = enemy_master_data[enemy_id]
	
	# 1〜4匹の数をランダムに決定
	var enemy_count: int = randi_range(1, 4)
	
	for i in range(enemy_count):
		var suffix: String = ""
		if enemy_count > 1:
			# 💡 【仕様変更】名前と suffix の間に明確に半角スペースを挿入
			suffix = " " + ["A", "B", "C", "D"][i]
			
		var enemy_instance = ActiveEnemy.new(enemy_id, enemy_master, suffix)
		current_enemies.append(enemy_instance)
	
	# 📜 テキストウィンドウの初期文言を構築
	var msg: String = ""
	var base_enemy_name: String = tr(enemy_master["name_key"])
	
	if enemy_count == 1:
		msg = "%sが現れた!\nコマンドを選択してください。" % base_enemy_name
	else:
		var names = current_enemies.map(func(e): return e.name)
		msg = "%sが 複数をなして 現れた!\n(%s が出現)\nコマンドを選択してください。" % [base_enemy_name, ", ".join(names)]
		
	if battle_text:
		battle_text.text = msg

func end_battle_mode() -> void:
	if message_window: message_window.hide()
	if enemy_graphic: enemy_graphic.hide()
	_hide_battle_buttons()

# 🛠️ 【たたかう】未実装のため、デバッグ出力のみを行う
func _on_attack_button_pressed() -> void:
	print("「たたかう」コマンドが選択されました")

# 🛠️ 【オート】未実装のため、デバッグ出力のみを行う
func _on_auto_button_pressed() -> void:
	print("「オート」コマンドが選択されました")

func _on_flee_button_pressed() -> void:
	print("【Battle】無事に逃げ切った!")
	battle_finished.emit()

func _hide_battle_buttons() -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()

戦闘関連を処理するスクリプト (battle.gd) を上記内容に変更します。これで enemy_id で敵を判別するようになりました。

メイン処理用コード修正

# ==============================================================================
# main.gd : ゲーム全体(街 / 探索 / 戦闘)の状態を統括するコントローラー(修正版)
# ==============================================================================
extends Node

enum GameState { EXPLORE, BATTLE, TOWN }
var current_state: GameState = GameState.EXPLORE

var town_system: TownSystem
var battle_system: BattleSystem

# 💡 型定義を標準の Camera3D から、自作のクラス(dungeon.gdのclass_name)に変更します
@onready var dungeon_explore: DungeonExplore = $Camera3D

func _ready() -> void:
	print("【Main】コントローラー起動。")
	
	# 💡 街の処理も戦闘の処理も、ここで確実に両方ともインスタンス化します
	town_system = TownSystem.new(self)
	battle_system = BattleSystem.new(self) # このnewのタイミングで内部で敵JSONがロードされます
	
	# シグナル結合
	dungeon_explore.battle_triggered.connect(_on_battle_triggered)
	dungeon_explore.town_entered.connect(_on_town_entered)
	
	town_system.dungeon_entered.connect(_on_dungeon_entered)
	battle_system.battle_finished.connect(_on_battle_finished)
	
	# 初期状態を探索モードに設定
	_change_state(GameState.EXPLORE)

func _change_state(next_state: GameState) -> void:
	current_state = next_state
	
	if town_system: town_system.end_town_mode()
	if battle_system: battle_system.end_battle_mode()
	if dungeon_explore: dungeon_explore.set_explore_active(false)
	
	match current_state:
		GameState.EXPLORE:
			dungeon_explore.set_explore_active(true)
		GameState.TOWN:
			town_system.start_town_mode()
		GameState.BATTLE:
			# 💡 ここで出現させたい敵のID("goblin")を渡します。
			# 今後、ここを動的な変数に書き換えます。
			battle_system.start_battle("goblin")

func _on_battle_triggered() -> void:
	_change_state(GameState.BATTLE)

func _on_battle_finished() -> void:
	_change_state(GameState.EXPLORE)

func _on_town_entered() -> void:
	_change_state(GameState.TOWN)

func _on_dungeon_entered() -> void:
	dungeon_explore.reset_to_dungeon_start()
	_change_state(GameState.EXPLORE)

このダンジョンRPGをメインでコントロールしている main.gd を上記内容に書き換えます。これでテキストで作成された敵用のJSONファイルを参照して、敵が出るようになりました。

敵の複数表示

今度は敵が複数体出現したとき、敵画像を画面に同じ数だけ出るようにしていきます。上記のコードだと、「Goblin A, Goblin B …」と複数体敵が現れても画像は Goblin 1体でした。これを修正していきます。

  • 敵表示用 (HBoxContainer) の配置
  • 敵データ用JSONファイルの修正
  • 戦闘処理用コード修正

敵表示用 (HBoxContainer) の配置

今ある CanvasLayer を右クリックし、Add Child Node を選択します。

HBoxContainer を選択し、子ノードを作成してください。作成した HBoxContainer の名前を EnemyContainer にします。

もともと使っていた EnemyGraphic は使用しなくなるので、非表示か削除しておくとよいでしょう。

作成した EnemyContainer (HBoxContainer) は PanelContainer より上に配置してください。これにより敵の手前に「たたかう」などのコマンドが表示されるようになります。あとは EnemyContainer の Inspector から敵が画面のどこに表示されるかを設定してください。

敵の表示に関しては好みが分かれるところです。Inspector の Layout にある Anchors Preset や Transform などを駆使して、ご自身の手で設定を自由に決めるとよいでしょう。

敵データ用JSONファイルの修正

{
  "goblin": {
    "name_key": "ENEMY_GOBLIN",
    "image_path": "res://image/enemy/goblin.png",
    "max_hp": 15,
    "attack": 4,
    "defense": 2,
    "speed": 5
  }
}

敵データをJSON(テキスト)で保存していると思います。上記のように image_path の項目を追加し、敵画像の path を記述してください。この記事では res://image/enemy/goblin.png とします。

敵の画像ファイルを配置し直します。image フォルダなどは必要に応じて作成してください。

戦闘処理用コード修正

# ==============================================================================
# battle.gd : 戦闘の進行、コマンド操作だけを担当するクラス(完全データ駆動・画像JSON管理版)
# ==============================================================================
class_name BattleSystem
extends RefCounted

signal battle_finished

var message_window: PanelContainer
var battle_text: Label
var attack_button: Button
var auto_button: Button
var flee_button: Button

# 💡 追加:サイズ制御用の親ノードもスクリプト側で管理します
var enemy_layout: Control
var enemy_container: HBoxContainer

# 💾 JSONから読み込んだ敵のマスターデータ
var enemy_master_data: Dictionary = {}

# ⚔️ 現在の戦闘に参加している敵インスタンスのリスト
var current_enemies: Array[ActiveEnemy] = []

func _init(main_node: Node) -> void:
	randomize()

	message_window = main_node.get_node_or_null("CanvasLayer/PanelContainer")
	battle_text = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/BattleText")
	attack_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AttackButton")
	auto_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AutoButton")
	flee_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/FleeButton")
	
	# 💡 【修正】新設した親ノードとコンテナの両方を取得
	enemy_layout = main_node.get_node_or_null("CanvasLayer/EnemyLayout")
	enemy_container = main_node.get_node_or_null("CanvasLayer/EnemyLayout/EnemyContainer")
	
	if attack_button: attack_button.pressed.connect(_on_attack_button_pressed)
	if auto_button: auto_button.pressed.connect(_on_auto_button_pressed)
	if flee_button: flee_button.pressed.connect(_on_flee_button_pressed)
	
	_load_enemy_data_from_json()

func _load_enemy_data_from_json() -> void:
	var file_path: String = "res://data/enemy_data.json"
	if not FileAccess.file_exists(file_path):
		return
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	var json = JSON.new()
	if json.parse(json_string) == OK and typeof(json.data) == TYPE_DICTIONARY:
		enemy_master_data = json.data

# 🛠️ 戦闘開始
func start_battle(enemy_id: String) -> void:
	_generate_enemies(enemy_id)
	
	if attack_button: attack_button.show()
	if auto_button: auto_button.show()
	if flee_button: flee_button.show()
	if message_window: message_window.show()
	
	# 💡 【バグ修正】親のレイアウト自体を明示的に可視化します
	if enemy_layout: enemy_layout.show()
	if enemy_container: enemy_container.show()
	if attack_button: attack_button.grab_focus()

func _generate_enemies(enemy_id: String) -> void:
	current_enemies.clear()
	
	if enemy_container:
		for child in enemy_container.get_children():
			child.queue_free()
	
	if not enemy_master_data.has(enemy_id):
		if battle_text: 
			battle_text.text = "Error: Enemy data [%s] not found." % enemy_id
		return
		
	var enemy_master = enemy_master_data[enemy_id]
	var enemy_count: int = randi_range(1, 4)
	
	var img_path: String = enemy_master.get("image_path", "res://icon.svg")
	var texture: Texture2D = null
	
	if ResourceLoader.exists(img_path):
		texture = load(img_path)
	else:
		texture = load("res://icon.svg")
	
	for i in range(enemy_count):
		var suffix: String = ""
		if enemy_count > 1:
			suffix = " " + ["A", "B", "C", "D"][i]
			
		var enemy_instance = ActiveEnemy.new(enemy_id, enemy_master, suffix)
		current_enemies.append(enemy_instance)
		
		if enemy_container and texture:
			var rect = TextureRect.new()
			rect.texture = texture
			rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
			rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
			rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			enemy_container.add_child(rect)
	
	var base_enemy_name: String = tr(enemy_master["name_key"])
	var msg: String = "%sが現れた!\n" % base_enemy_name
	
	if enemy_count > 1:
		var names = current_enemies.map(func(e): return e.name)
		msg += "(%s が出現)\n" % ", ".join(names)
		
	msg += "コマンドを選択してください。"
		
	if battle_text:
		battle_text.text = msg

func end_battle_mode() -> void:
	if message_window: message_window.hide()
	
	# 💡 【バグ修正】親レイアウトごと非表示にしてお掃除
	if enemy_layout: enemy_layout.hide()
	if enemy_container:
		enemy_container.hide()
		for child in enemy_container.get_children():
			child.queue_free()
			
	_hide_battle_buttons()

func _on_attack_button_pressed() -> void:
	print("「たたかう」コマンドが選択されました")

func _on_auto_button_pressed() -> void:
	print("「オート」コマンドが選択されました")

func _on_flee_button_pressed() -> void:
	print("【Battle】無事に逃げ切った!")
	battle_finished.emit()

func _hide_battle_buttons() -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()

戦闘処理用のコード (battle.gd) を上記のように書き換えます。修正内容は以下の通りです。

  • enemy_graphic の代わりに、enemy_container (HBoxContainer) を参照するようにします。これにより単体表示から複数体表示に対応します。
  • 敵との遭遇時、出現した敵の数だけ TextureRect を動的に生成しコンテナに追加します。
  • 戦闘終了後、コンテナの中身を削除します。

敵を生成するループの中で、JSONから動的に “image_path” を読み込み、load() 関数でテクスチャとしてインポートして TextureRect に適用しています。

他の敵のデータを増やす & 敵出現フロアの制限

今までのコードだとゴブリンしかでません。それを他の敵を出現させるようにしていきます。

  • 敵データの追加・修正
  • 戦闘処理用コード修正
  • メイン用コード修正

敵データの追加・修正

{
  "goblin": {
    "name_key": "ENEMY_GOBLIN",
    "image_path": "res://image/enemy/goblin.png",
    "appear_floors": [1],
    "max_hp": 15,
    "attack": 4,
    "defense": 2,
    "speed": 5
  },
  "slime": {
    "name_key": "ENEMY_SLIME",
    "image_path": "res://image/enemy/slime.png",
    "appear_floors": [1, 2],
    "max_hp": 10,
    "attack": 3,
    "defense": 5,
    "speed": 2
  }
}

各敵のデータに、その敵が出現できる階層を配列で指定する “appear_floors” というキーを追加します。 今後の拡張 (スライムなどの追加) も見据えて、以下のようにデータを書き換えてください。

これにより、ゴブリンは地下1階のみ、スライムは地下1階と2階に出現するというルールを完全にデータ側で表現できるようになりました。

戦闘処理用コード修正

# ==============================================================================
# battle.gd : 戦闘の進行、コマンド操作だけを担当するクラス(完全データ駆動・画像JSON管理版)
# ==============================================================================
class_name BattleSystem
extends RefCounted

signal battle_finished

var message_window: PanelContainer
var battle_text: Label
var attack_button: Button
var auto_button: Button
var flee_button: Button

# 💡 追加:サイズ制御用の親ノードもスクリプト側で管理します
var enemy_layout: Control
var enemy_container: HBoxContainer

# 💾 JSONから読み込んだ敵のマスターデータ
var enemy_master_data: Dictionary = {}

# ⚔️ 現在の戦闘に参加している敵インスタンスのリスト
var current_enemies: Array[ActiveEnemy] = []

func _init(main_node: Node) -> void:
	randomize()

	message_window = main_node.get_node_or_null("CanvasLayer/PanelContainer")
	battle_text = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/BattleText")
	attack_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AttackButton")
	auto_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AutoButton")
	flee_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/FleeButton")
	
	# 💡 【修正】新設した親ノードとコンテナの両方を取得
	enemy_layout = main_node.get_node_or_null("CanvasLayer/EnemyLayout")
	enemy_container = main_node.get_node_or_null("CanvasLayer/EnemyLayout/EnemyContainer")
	
	if attack_button: attack_button.pressed.connect(_on_attack_button_pressed)
	if auto_button: auto_button.pressed.connect(_on_auto_button_pressed)
	if flee_button: flee_button.pressed.connect(_on_flee_button_pressed)
	
	_load_enemy_data_from_json()

func _load_enemy_data_from_json() -> void:
	var file_path: String = "res://data/enemy_data.json"
	if not FileAccess.file_exists(file_path):
		return
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	var json = JSON.new()
	if json.parse(json_string) == OK and typeof(json.data) == TYPE_DICTIONARY:
		enemy_master_data = json.data

# 🛠️ 戦闘開始
func start_battle(enemy_id: String) -> void:
	_generate_enemies(enemy_id)
	
	if attack_button: attack_button.show()
	if auto_button: auto_button.show()
	if flee_button: flee_button.show()
	if message_window: message_window.show()
	
	# 💡 【バグ修正】親のレイアウト自体を明示的に可視化します
	if enemy_layout: enemy_layout.show()
	if enemy_container: enemy_container.show()
	if attack_button: attack_button.grab_focus()

func _generate_enemies(enemy_id: String) -> void:
	current_enemies.clear()
	
	if enemy_container:
		for child in enemy_container.get_children():
			child.queue_free()
	
	if not enemy_master_data.has(enemy_id):
		if battle_text: 
			battle_text.text = "Error: Enemy data [%s] not found." % enemy_id
		return
		
	var enemy_master = enemy_master_data[enemy_id]
	var enemy_count: int = randi_range(1, 4)
	
	var img_path: String = enemy_master.get("image_path", "res://icon.svg")
	var texture: Texture2D = null
	
	if ResourceLoader.exists(img_path):
		texture = load(img_path)
	else:
		texture = load("res://icon.svg")
	
	for i in range(enemy_count):
		var suffix: String = ""
		if enemy_count > 1:
			suffix = " " + ["A", "B", "C", "D"][i]
			
		var enemy_instance = ActiveEnemy.new(enemy_id, enemy_master, suffix)
		current_enemies.append(enemy_instance)
		
		if enemy_container and texture:
			var rect = TextureRect.new()
			rect.texture = texture
			rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
			rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
			rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			enemy_container.add_child(rect)
	
	var base_enemy_name: String = tr(enemy_master["name_key"])
	var msg: String = "%sが現れた!\n" % base_enemy_name
	
	if enemy_count > 1:
		var names = current_enemies.map(func(e): return e.name)
		msg += "(%s が出現)\n" % ", ".join(names)
		
	msg += "コマンドを選択してください。"
		
	if battle_text:
		battle_text.text = msg

func end_battle_mode() -> void:
	if message_window: message_window.hide()
	
	# 💡 【バグ修正】親レイアウトごと非表示にしてお掃除
	if enemy_layout: enemy_layout.hide()
	if enemy_container:
		enemy_container.hide()
		for child in enemy_container.get_children():
			child.queue_free()
			
	_hide_battle_buttons()

func _on_attack_button_pressed() -> void:
	print("「たたかう」コマンドが選択されました")

func _on_auto_button_pressed() -> void:
	print("「オート」コマンドが選択されました")

func _on_flee_button_pressed() -> void:
	print("【Battle】無事に逃げ切った!")
	battle_finished.emit()

func _hide_battle_buttons() -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()

フロアに応じたランダム選出アルゴリズムの追加のために、battle.gd を上記のように変更します。これまでは main.gd から “goblin” というIDを直接受け取っていましたが、これからは現在の階層を受け取るように変更します。

メイン処理用コード修正

# ==============================================================================
# main.gd : ゲーム全体(街 / 探索 / 戦闘)の状態を統括するコントローラー(修正版)
# ==============================================================================
extends Node

enum GameState { EXPLORE, BATTLE, TOWN }
var current_state: GameState = GameState.EXPLORE

var town_system: TownSystem
var battle_system: BattleSystem

# 💡 型定義を標準の Camera3D から、自作のクラス(dungeon.gdのclass_name)に変更します
@onready var dungeon_explore: DungeonExplore = $Camera3D

func _ready() -> void:
	print("【Main】コントローラー起動。")
	
	# 💡 街の処理も戦闘の処理も、ここで確実に両方ともインスタンス化します
	town_system = TownSystem.new(self)
	battle_system = BattleSystem.new(self) # このnewのタイミングで内部で敵JSONがロードされます
	
	# シグナル結合
	dungeon_explore.battle_triggered.connect(_on_battle_triggered)
	dungeon_explore.town_entered.connect(_on_town_entered)
	
	town_system.dungeon_entered.connect(_on_dungeon_entered)
	battle_system.battle_finished.connect(_on_battle_finished)
	
	# 初期状態を探索モードに設定
	_change_state(GameState.EXPLORE)

func _change_state(next_state: GameState) -> void:
	current_state = next_state
	
	if town_system: town_system.end_town_mode()
	if battle_system: battle_system.end_battle_mode()
	if dungeon_explore: dungeon_explore.set_explore_active(false)
	
	match current_state:
		GameState.EXPLORE:
			dungeon_explore.set_explore_active(true)
		GameState.TOWN:
			town_system.start_town_mode()
		GameState.BATTLE:
			# 💡 現在の階層(整数)を取得
			var current_floor_num: int = dungeon_explore.current_floor
			
			# 💡 登場可能な敵ID(文字列)を格納する配列
			var available_enemy_ids: Array[String] = []
			
			# 💡 battle_system がロード済みの enemy_master_data から、この階層に出る敵を抽出
			for enemy_id in battle_system.enemy_master_data.keys():
				var enemy_data = battle_system.enemy_master_data[enemy_id]
				
				# JSONの "appear_floors" に現在の階層が含まれているか厳密に判定
				if typeof(enemy_data) == TYPE_DICTIONARY and enemy_data.has("appear_floors"):
					var floors_list: Array = enemy_data["appear_floors"]
					for f in floors_list:
						if int(f) == current_floor_num:
							available_enemy_ids.append(enemy_id)
							break
			
			# 💡 抽選処理
			var selected_enemy_id: String = "goblin" # デフォルトのフォールバック
			if not available_enemy_ids.is_empty():
				var random_index: int = randi() % available_enemy_ids.size()
				selected_enemy_id = available_enemy_ids[random_index]
			
			# 💡 決定した敵ID("goblin" や "slime")を、元の設計通りそのまま渡して開始
			battle_system.start_battle(selected_enemy_id)

func _on_battle_triggered() -> void:
	_change_state(GameState.BATTLE)

func _on_battle_finished() -> void:
	_change_state(GameState.EXPLORE)

func _on_town_entered() -> void:
	_change_state(GameState.TOWN)

func _on_dungeon_entered() -> void:
	dungeon_explore.reset_to_dungeon_start()
	_change_state(GameState.EXPLORE)

上記のようにメイン処理 (main.gd) を修正します。先ほどまでのコードだと “goblin” と決め打ちしていましたが、どんな敵が来てもその敵が表示されるように修正しました。

複数種類の敵が複数匹同時に出現する対応

上記のコードだと、1戦闘に1種類の敵しか出ないようになっています。ゴブリンだけしかいない戦闘、スライムしかいない戦闘になります。これをゴブリンA, ゴブリンB, スライムのように複数種類の敵が複数匹同時に出るようにしていきます。

  • 敵データの追加
  • メイン処理用コード修正
  • 戦闘処理用コード修正

敵データの追加

{
  "goblin": {
    "name_key": "ENEMY_GOBLIN",
    "image_path": "res://image/enemy/goblin.png",
    "appear_floors": [1],
    "max_hp": 10,
    "attack": 4,
    "defense": 2,
    "agility": 5
  },
  "slime": {
    "name_key": "ENEMY_SLIME",
    "image_path": "res://image/enemy/slime.png",
    "appear_floors": [1, 2],
    "max_hp": 6,
    "attack": 6,
    "defense": 20,
    "agility": 1
  },
   "monster_hare": {
    "name_key": "ENEMY_MONSTER_HARE",
    "image_path": "res://image/enemy/monster_hare.png",
    "appear_floors": [1],
    "max_hp": 12,
    "attack": 12,
    "defense": 8,
    "agility": 12
  },
  "zombie": {
    "name_key": "ENEMY_ZOMBIE",
    "image_path": "res://image/enemy/zombie.png",
    "appear_floors": [1, 2],
    "max_hp": 22,
    "attack": 18,
    "defense": 2,
    "agility": 4
  }
}

JSONファイルを編集し、敵を4種類にしました。

メイン処理用コード修正

# ==============================================================================
# main.gd : ゲーム全体(街 / 探索 / 戦闘)の状態を統括するコントローラー(修正版)
# ==============================================================================
extends Node

enum GameState { EXPLORE, BATTLE, TOWN }
var current_state: GameState = GameState.EXPLORE

var town_system: TownSystem
var battle_system: BattleSystem

# 💡 型定義を標準の Camera3D から、自作のクラス(dungeon.gdのclass_name)に変更します
@onready var dungeon_explore: DungeonExplore = $Camera3D

func _ready() -> void:
	print("【Main】コントローラー起動。")
	
	# 💡 街の処理も戦闘の処理も、ここで確実に両方ともインスタンス化します
	town_system = TownSystem.new(self)
	battle_system = BattleSystem.new(self) # このnewのタイミングで内部で敵JSONがロードされます
	
	# シグナル結合
	dungeon_explore.battle_triggered.connect(_on_battle_triggered)
	dungeon_explore.town_entered.connect(_on_town_entered)
	
	town_system.dungeon_entered.connect(_on_dungeon_entered)
	battle_system.battle_finished.connect(_on_battle_finished)
	
	# 初期状態を探索モードに設定
	_change_state(GameState.EXPLORE)

func _change_state(next_state: GameState) -> void:
	current_state = next_state
	
	if town_system: town_system.end_town_mode()
	if battle_system: battle_system.end_battle_mode()
	if dungeon_explore: dungeon_explore.set_explore_active(false)
	
	match current_state:
		GameState.EXPLORE:
			dungeon_explore.set_explore_active(true)
		GameState.TOWN:
			town_system.start_town_mode()
		GameState.BATTLE:
			# 現在の階層(整数)を取得
			var current_floor_num: int = dungeon_explore.current_floor
			
			# 登場可能な敵ID(文字列)を格納する配列
			var available_enemy_ids: Array[String] = []
			
			# ロード済みの enemy_master_data から、この階層に出る敵をすべて抽出
			for enemy_id in battle_system.enemy_master_data.keys():
				var enemy_data = battle_system.enemy_master_data[enemy_id]
				
				if typeof(enemy_data) == TYPE_DICTIONARY and enemy_data.has("appear_floors"):
					var floors_list: Array = enemy_data["appear_floors"]
					for f in floors_list:
						if int(f) == current_floor_num:
							available_enemy_ids.append(enemy_id)
							break
			
			# 安全装置:もし空ならデフォルトでゴブリンを入れておく
			if available_enemy_ids.is_empty():
				available_enemy_ids.append("goblin")
			
			# 💡 選別された「配列」をそのまま渡して戦闘を開始します
			battle_system.start_battle(available_enemy_ids)

func _on_battle_triggered() -> void:
	_change_state(GameState.BATTLE)

func _on_battle_finished() -> void:
	_change_state(GameState.EXPLORE)

func _on_town_entered() -> void:
	_change_state(GameState.TOWN)

func _on_dungeon_entered() -> void:
	dungeon_explore.reset_to_dungeon_start()
	_change_state(GameState.EXPLORE)

上記のようにメイン処理 (main.gd) を修正します。今度は戦闘開始時に敵IDが含まれている「配列」を返しています。

戦闘処理用コード修正

# ==============================================================================
# battle.gd : 戦闘の進行、コマンド操作だけを担当するクラス(完全データ駆動・画像JSON管理版)
# ==============================================================================
class_name BattleSystem
extends RefCounted

signal battle_finished

var message_window: PanelContainer
var battle_text: Label
var attack_button: Button
var auto_button: Button
var flee_button: Button

# 💡 追加:サイズ制御用の親ノードもスクリプト側で管理します
var enemy_layout: Control
var enemy_container: HBoxContainer

# 💾 JSONから読み込んだ敵のマスターデータ
var enemy_master_data: Dictionary = {}

# ⚔️ 現在の戦闘に参加している敵インスタンスのリスト
var current_enemies: Array[ActiveEnemy] = []

func _init(main_node: Node) -> void:
	randomize()

	message_window = main_node.get_node_or_null("CanvasLayer/PanelContainer")
	battle_text = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/BattleText")
	attack_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AttackButton")
	auto_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AutoButton")
	flee_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/FleeButton")
	
	# 💡 【修正】新設した親ノードとコンテナの両方を取得
	enemy_layout = main_node.get_node_or_null("CanvasLayer/EnemyLayout")
	enemy_container = main_node.get_node_or_null("CanvasLayer/EnemyLayout/EnemyContainer")
	
	if attack_button: attack_button.pressed.connect(_on_attack_button_pressed)
	if auto_button: auto_button.pressed.connect(_on_auto_button_pressed)
	if flee_button: flee_button.pressed.connect(_on_flee_button_pressed)
	
	_load_enemy_data_from_json()

func _load_enemy_data_from_json() -> void:
	var file_path: String = "res://data/enemy_data.json"
	if not FileAccess.file_exists(file_path):
		return
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	var json = JSON.new()
	if json.parse(json_string) == OK and typeof(json.data) == TYPE_DICTIONARY:
		enemy_master_data = json.data

# 🛠️ 戦闘開始(引数を敵IDの配列に変更)
func start_battle(available_enemy_ids: Array[String]) -> void:
	_generate_enemies(available_enemy_ids)
	
	if attack_button: attack_button.show()
	if auto_button: auto_button.show()
	if flee_button: flee_button.show()
	if message_window: message_window.show()
	
	if enemy_layout: enemy_layout.show()
	if enemy_container: enemy_container.show()
	if attack_button: attack_button.grab_focus()

# 🛠️ 混成の敵部隊を動的生成(最大8体)
func _generate_enemies(available_enemy_ids: Array[String]) -> void:
	current_enemies.clear()
	
	if enemy_container:
		for child in enemy_container.get_children():
			child.queue_free()
			
	# 💡 総出現数を 1〜8体 の間でランダム決定
	var total_enemy_count: int = randi_range(1, 8)
	
	# 同名の敵が何体出たかをカウントするための辞書(アルファベット命名用)
	# 例:{"goblin": 2, "slime": 1}
	var enemy_type_counts: Dictionary = {}
	
	# 1. まずは今回の戦闘に出現する「個体」をランダムに選出して仮リストを作成
	var chosen_enemies_data: Array[Dictionary] = []
	
	for i in range(total_enemy_count):
		# 出現可能リストの中から、1体ごとにランダムで敵IDを決定
		var random_index: int = randi() % available_enemy_ids.size()
		var enemy_id: String = available_enemy_ids[random_index]
		
		if enemy_master_data.has(enemy_id):
			var enemy_master = enemy_master_data[enemy_id]
			
			# この種類の出現数をカウントアップ
			if not enemy_type_counts.has(enemy_id):
				enemy_type_counts[enemy_id] = 0
			enemy_type_counts[enemy_id] += 1
			
			# データを一時保存
			chosen_enemies_data.append({
				"id": enemy_id,
				"master": enemy_master,
				"current_count": enemy_type_counts[enemy_id]
			})

	# 2. 決定した敵データに基づいて、インスタンスと画像を生成
	for data in chosen_enemies_data:
		var enemy_id: String = data["id"]
		var enemy_master: Dictionary = data["master"]
		var current_count: int = data["current_count"]
		var total_type_count: int = enemy_type_counts[enemy_id]
		
		# 💡 その種類の敵が全体で2体以上いる場合のみ「A, B...」の接尾辞をつける
		var suffix: String = ""
		if total_type_count > 1:
			suffix = " " + ["A", "B", "C", "D", "E", "F", "G", "H"][current_count - 1]
			
		var enemy_instance = ActiveEnemy.new(enemy_id, enemy_master, suffix)
		current_enemies.append(enemy_instance)
		
		# 💡 この敵個体専用の画像をロードして配置
		var img_path: String = enemy_master.get("image_path", "res://icon.svg")
		var texture: Texture2D = null
		if ResourceLoader.exists(img_path):
			texture = load(img_path) as Texture2D
		else:
			texture = load("res://icon.svg") as Texture2D
			
		if enemy_container and texture:
			var rect = TextureRect.new()
			rect.texture = texture
			rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
			rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
			rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			enemy_container.add_child(rect)
	
	# 📜 混成対応の出現メッセージ構築
	# 出現した敵の名前を綺麗に並べる(例:「ゴブリンA、スライム、ゴブリンB が出現」)
	var names = current_enemies.map(func(e): return e.name)
	var msg: String = "モンスターが現れた!\n"
	msg += "(%s が出現)\n" % ", ".join(names)
	msg += "コマンドを選択してください。"
		
	if battle_text:
		battle_text.text = msg

func end_battle_mode() -> void:
	if message_window: message_window.hide()
	
	# 💡 【バグ修正】親レイアウトごと非表示にしてお掃除
	if enemy_layout: enemy_layout.hide()
	if enemy_container:
		enemy_container.hide()
		for child in enemy_container.get_children():
			child.queue_free()
			
	_hide_battle_buttons()

func _on_attack_button_pressed() -> void:
	print("「たたかう」コマンドが選択されました")

func _on_auto_button_pressed() -> void:
	print("「オート」コマンドが選択されました")

func _on_flee_button_pressed() -> void:
	print("【Battle】無事に逃げ切った!")
	battle_finished.emit()

func _hide_battle_buttons() -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()

上記のように戦闘処理 (battle.gd) を修正します。敵配列を受け取り、それを表示するようにしています。また、敵の出現数を1~8体出現するようにしています。

フロアごとに最大敵数を設定する

今までは敵の出現数はどのフロアでも1~8体の間のランダムな値で決定していました。これをフロアごとに設定します。

マップデータの追加・修正

{
  "1": {
    "min_enemies": 1,
    "max_enemies": 4,
    "0,0,1": {
      "target_floor": 0,
      "target_pos": [0, 0, 1]
    },
    "0,0,-9": {
      "target_floor": 2,
      "target_pos": [0, 0, -14]
    }
  },
  "2": {
    "min_enemies": 2,
    "max_enemies": 8,
    "0,0,-14": {
      "target_floor": 1,
      "target_pos": [0, 0, -9]
    }
  }
}

マップデータ (res://data/map_data.json) を上記のように修正します。これはB1は最低1匹から4匹、B2は最低2匹から8匹敵が出るという意味を付け加えています。

ダンジョン探索用コードの修正

# ==============================================================================
# dungeon.gd : 商用・量産対応版(JSONデータ駆動型設計・プランA対応版)
# ==============================================================================
class_name DungeonExplore
extends Camera3D

signal battle_triggered
signal town_entered

enum Direction { NORTH, EAST, SOUTH, WEST }

const MOVE_STEP: float = 2.0
const ROTATE_STEP: float = 90.0

const WALL_ITEM_ID: int = 1
const DOOR_ITEM_ID: int = 2
const STAIRS_DOWN_ITEM_ID: int = 3
const STAIRS_UP_ITEM_ID: int = 4

const MIN_SAFE_STEPS: int = 4
const BASE_ENCOUNTER_CHANCE: float = 0.1

const SFX_DOOR_OPEN = preload("res://door_open.mp3")
const SFX_STAIRS    = preload("res://stairs.mp3")

# 💾 JSONから読み込んだマスターデータを格納するメモリバッファ
var dungeon_map_data: Dictionary = {}

var current_grid_pos: Vector2i = Vector2i.ZERO
var current_dir: Direction = Direction.NORTH
var is_showing_message: bool = false
var is_explore_active: bool = false
var triggered_events: Dictionary = {}
var steps_since_last_fight: int = 0
var opened_doors: Dictionary = {}
var current_floor: int = 1

var dungeon_start_position: Vector3
var dungeon_start_rotation: Vector3

@onready var grid_map: GridMap = $"../GridMap"
@onready var status_label: Label = $"../CanvasLayer/Label"
@onready var message_window: PanelContainer = $"../CanvasLayer/PanelContainer"
@onready var battle_text: Label = $"../CanvasLayer/PanelContainer/BattleMenu/BattleText"
@onready var sfx_player: AudioStreamPlayer = $SfxPlayer

@onready var attack_button: Button = $"../CanvasLayer/PanelContainer/BattleMenu/AttackButton"
@onready var auto_button: Button = $"../CanvasLayer/PanelContainer/BattleMenu/AutoButton"
@onready var flee_button: Button = $"../CanvasLayer/PanelContainer/BattleMenu/FleeButton"

func _ready() -> void:
	dungeon_start_position = global_position
	dungeon_start_rotation = global_rotation
	steps_since_last_fight = 0
	
	# 💡 起動時にJSONマスターデータをロード
	_load_map_data_from_json()
	update_player_status()

# 📥 JSONファイルを読み込んで辞書にパースする堅牢なデータロード処理
func _load_map_data_from_json() -> void:
	var file_path: String = "res://data/map_data.json"
	
	if not FileAccess.file_exists(file_path):
		print("【Critical Error】マップデータファイルが見つかりません: ", file_path)
		return
		
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	
	var json = JSON.new()
	var error = json.parse(json_string)
	
	if error == OK:
		if typeof(json.data) == TYPE_DICTIONARY:
			dungeon_map_data = json.data
			print("【System】JSONマップデータのロードに成功しました。")
		else:
			print("【Error】JSONのルートがオブジェクト(辞書)形式ではありません。")
	else:
		print("【Error】JSONパース失敗。 行: ", json.get_error_line(), " エラー: ", json.get_error_message())

# 💡 【プランA用拡張】指定フロアの敵出現数設定を安全に取得するヘルパー
func get_floor_enemy_limits(floor_num: int) -> Dictionary:
	var floor_key: String = str(floor_num)
	var default_limits: Dictionary = {"min": 1, "max": 4} # フォールバック用初期値
	
	if dungeon_map_data.has(floor_key):
		var floor_data: Dictionary = dungeon_map_data[floor_key]
		if floor_data.has("settings") and typeof(floor_data["settings"]) == TYPE_DICTIONARY:
			var settings: Dictionary = floor_data["settings"]
			return {
				"min": int(settings.get("min_enemies", 1)),
				"max": int(settings.get("max_enemies", 4))
			}
	return default_limits

# 階段移動処理(去る階層の扉を復活させる)
func teleport_by_stairs(current_stair_coord: Vector3i) -> void:
	var floor_key: String = str(current_floor)
	if not dungeon_map_data.has(floor_key): return
	var floor_data: Dictionary = dungeon_map_data[floor_key]
	
	var coord_key: String = "%d,%d,%d" % [current_stair_coord.x, current_stair_coord.y, current_stair_coord.z]
	if not floor_data.has(coord_key): return

	var warp_info: Dictionary = floor_data[coord_key]
	var next_floor: int = int(warp_info["target_floor"])
	var target_pos_array: Array = warp_info["target_pos"]
	var next_pos: Vector3i = Vector3i(target_pos_array[0], target_pos_array[1], target_pos_array[2])

	steps_since_last_fight = 0
	if sfx_player and SFX_STAIRS:
		sfx_player.stream = SFX_STAIRS
		sfx_player.play()

	if next_floor == 0:
		_respawn_doors_on_floor(current_floor)
		town_entered.emit()
	else:
		_respawn_doors_on_floor(current_floor)
		
		var old_floor: int = current_floor
		current_floor = next_floor
		
		var keep_eye_height: float = global_position.y
		
		var safe_global_pos: Vector3 = _get_adjacent_walkable_pos(next_pos)
		global_position = safe_global_pos
		global_position.y = keep_eye_height
		
		force_update_transform()
		
		var final_map_coord = grid_map.local_to_map(grid_map.to_local(global_position))
		current_grid_pos = Vector2i(final_map_coord.x, final_map_coord.z)
		
		if current_floor > old_floor:
			show_explore_message("階段を下りた…… 🕳️\nここは B%d だ。\n\n(Wボタンでメッセージを閉じる)" % current_floor)
		else:
			show_explore_message("階段を上った…… 🧗\nここは B%d だ。\n\n(Wボタンでメッセージを閉じる)" % current_floor)
		update_player_status()

func set_explore_active(active: bool) -> void:
	is_explore_active = active
	if is_explore_active:
		update_player_status()
	else:
		if status_label:
			status_label.text = ""

func reset_to_dungeon_start() -> void:
	current_floor = 1
	steps_since_last_fight = 0
	
	var town_stair_map_coord: Vector3i = Vector3i.ZERO
	var found: bool = false
	
	if dungeon_map_data.has("1"):
		var b1_data: Dictionary = dungeon_map_data["1"]
		for coord_str in b1_data.keys():
			# 💡 安全ガード: "settings" キーは階段座標データではないのでスキップ
			if coord_str == "settings": continue
			
			var warp_info: Dictionary = b1_data[coord_str]
			if int(warp_info["target_floor"]) == 0:
				var parts = coord_str.split(",")
				if parts.size() == 3:
					town_stair_map_coord = Vector3i(int(parts[0]), int(parts[1]), int(parts[2]))
					found = true
					print("【System】JSONから街への階段座標を検出しました: ", town_stair_map_coord)
					break
					
	if not found:
		print("【Critical Error】JSONのB1データ内に、target_floorが0の階段(街へのゲート)が見つかりません。")
		var clean_start_pos: Vector3 = dungeon_start_position
		clean_start_pos.y = 0.0
		town_stair_map_coord = grid_map.local_to_map(grid_map.to_local(clean_start_pos))

	var keep_eye_height: float = dungeon_start_position.y
	var safe_start_pos: Vector3 = _get_adjacent_walkable_pos(town_stair_map_coord)
	
	global_position = safe_start_pos
	global_position.y = keep_eye_height
	global_rotation = dungeon_start_rotation
	
	update_player_status()

func _unhandled_input(event: InputEvent) -> void:
	if not event.is_pressed() or event.is_echo(): return
	if not is_explore_active: return
	if is_showing_message:
		if event.is_action_pressed("move_forward"): close_message_window()
		return
	if event.is_action_pressed("move_forward"): try_move_direction(Vector3(0, 0, -1))
	elif event.is_action_pressed("move_backward"): try_move_direction(Vector3(0, 0, 1))
	elif event.is_action_pressed("move_left"): try_move_direction(Vector3(-1, 0, 0))
	elif event.is_action_pressed("move_right"): try_move_direction(Vector3(1, 0, 0))
	elif event.is_action_pressed("turn_left"): rotate_horizontal(ROTATE_STEP)
	elif event.is_action_pressed("turn_right"): rotate_horizontal(-ROTATE_STEP)

func try_move_direction(local_direction: Vector3) -> void:
	var direction_vector: Vector3 = global_transform.basis * local_direction
	direction_vector.y = 0
	direction_vector = direction_vector.normalized()
	
	var target_global_pos: Vector3 = global_position + (direction_vector * MOVE_STEP)
	var map_coord: Vector3i = grid_map.local_to_map(grid_map.to_local(target_global_pos))
	var cell_item_id: int = grid_map.get_cell_item(map_coord)
	
	if cell_item_id == WALL_ITEM_ID: return
	
	var is_door_opened: bool = false
	if opened_doors.has(current_floor):
		for door_data in opened_doors[current_floor]:
			if door_data["coord"] == map_coord:
				is_door_opened = true
				break
	
	if cell_item_id == DOOR_ITEM_ID and not is_door_opened:
		interact_with_door(map_coord)
	
	var old_map_coord: Vector3i = grid_map.local_to_map(grid_map.to_local(global_position))
	global_position = target_global_pos
	update_player_status()
	
	var current_map_coord: Vector3i = grid_map.local_to_map(grid_map.to_local(global_position))
	var current_cell_id: int = grid_map.get_cell_item(current_map_coord)
	
	if current_map_coord != old_map_coord:
		if current_cell_id == STAIRS_DOWN_ITEM_ID or current_cell_id == STAIRS_UP_ITEM_ID:
			teleport_by_stairs(current_map_coord)
			return

	is_door_opened = false
	if opened_doors.has(current_floor):
		for door_data in opened_doors[current_floor]:
			if door_data["coord"] == current_map_coord:
				is_door_opened = true
				break
				
	if current_cell_id == DOOR_ITEM_ID and not is_door_opened:
		return

	var event_triggered: bool = check_coordinate_event()
	if not event_triggered:
		check_random_encounter()

func interact_with_door(door_coord: Vector3i) -> void:
	if not opened_doors.has(current_floor):
		opened_doors[current_floor] = []
		
	var current_orientation: int = grid_map.get_cell_item_orientation(door_coord)
	var door_data: Dictionary = {
		"coord": door_coord,
		"orientation": current_orientation
	}
	opened_doors[current_floor].append(door_data)
	
	if sfx_player and SFX_DOOR_OPEN:
		sfx_player.stream = SFX_DOOR_OPEN
		sfx_player.play()
		
	grid_map.set_cell_item(door_coord, GridMap.INVALID_CELL_ITEM)

func _respawn_doors_on_floor(floor_num: int) -> void:
	if not opened_doors.has(floor_num): return
	var closed_count: int = 0
	for door_data in opened_doors[floor_num]:
		var coord: Vector3i = door_data["coord"]
		var orientation: int = door_data["orientation"]
		grid_map.set_cell_item(coord, DOOR_ITEM_ID, orientation)
		closed_count += 1
		
	opened_doors[floor_num].clear()
	print("【System】階層 B%d の扉を正しい向きでリセットしました。復活数: %d" % [floor_num, closed_count])

func _get_adjacent_walkable_pos(stair_map_pos: Vector3i) -> Vector3:
	var directions = [Vector3i(0, 0, -1), Vector3i(1, 0, 0), Vector3i(0, 0, 1), Vector3i(-1, 0, 0)]
	for offset in directions:
		var check_pos = stair_map_pos + offset
		var cell_id = grid_map.get_cell_item(check_pos)
		if cell_id != WALL_ITEM_ID and cell_id != STAIRS_DOWN_ITEM_ID and cell_id != STAIRS_UP_ITEM_ID:
			return grid_map.to_global(grid_map.map_to_local(check_pos))
	return grid_map.to_global(grid_map.map_to_local(stair_map_pos))

func rotate_horizontal(deg: float) -> void:
	rotate_y(deg_to_rad(deg))
	update_player_status()

func update_player_status() -> void:
	if not is_inside_tree() or not is_explore_active: return
	var map_coord: Vector3i = grid_map.local_to_map(grid_map.to_local(global_position))
	current_grid_pos = Vector2i(map_coord.x, map_coord.z)
	var forward_vector: Vector3 = -global_transform.basis.z
	if abs(forward_vector.z) > abs(forward_vector.x):
		current_dir = Direction.NORTH if forward_vector.z < -0.5 else Direction.SOUTH
	else:
		current_dir = Direction.EAST if forward_vector.x > 0.5 else Direction.WEST
	update_ui_display()

func update_ui_display() -> void:
	if status_label == null: return
	var dir_string: String = ""
	match current_dir:
		Direction.NORTH: dir_string = "北"
		Direction.EAST:  dir_string = "東"
		Direction.SOUTH: dir_string = "南"
		Direction.WEST:  dir_string = "西"
	status_label.text = "B%d | 現在地: (X: %d, Y: %d) | 向き: %s" % [current_floor, current_grid_pos.x, current_grid_pos.y, dir_string]

func check_coordinate_event() -> bool:
	var events: Dictionary = {
		Vector2i(0, -2): { "message": "「前方に不気味な気配を感じる…」\n\n(Wボタンで閉じる)", "is_repeatable": false },
		Vector2i(2, -4): { "message": "壁に血文字が刻まれている。\n『引き返せ、この先は深淵なり』\n\n(Wボタンで閉じる)", "is_repeatable": false },
		Vector2i(1, -1): { "message": "足元でカチリと音がした!\nピピッ!マスの罠が作動した!\n\n(Wボタンで閉じる)", "is_repeatable": true }
	}
	if not events.has(current_grid_pos): return false
	var event_data: Dictionary = events[current_grid_pos]
	if not event_data["is_repeatable"] and triggered_events.get(current_grid_pos) == true: return false
	show_explore_message(event_data["message"])
	if not event_data["is_repeatable"]: triggered_events[current_grid_pos] = true
	return true

func check_random_encounter() -> void:
	steps_since_last_fight += 1
	if steps_since_last_fight <= MIN_SAFE_STEPS: return
	var current_chance: float = BASE_ENCOUNTER_CHANCE * (steps_since_last_fight - MIN_SAFE_STEPS)
	var roll: float = randf()
	if roll < current_chance:
		steps_since_last_fight = 0
		battle_triggered.emit()

func show_explore_message(text: String) -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()
	battle_text.text = text
	message_window.show()
	is_showing_message = true

func close_message_window() -> void:
	message_window.hide()
	is_showing_message = false

ダンジョン探索用のコード (dungeon.gd ) を上記のように修正してください。dungeon.gd に外部 (main.gd) から「指定されたフロアの出現数設定」を安全に引っ張るためのヘルパー関数 get_floor_enemy_limits を1つ追加しています。また、teleport_by_stairs などの既存処理で “settings” キーを座標と誤認してクラッシュしないような流れも追加しました。

メイン処理用コード修正

# ==============================================================================
# main.gd : ゲーム全体(街 / 探索 / 戦闘)の状態を統括するコントローラー(プランA統合版)
# ==============================================================================
extends Node

enum GameState { EXPLORE, BATTLE, TOWN }
var current_state: GameState = GameState.EXPLORE

var town_system: TownSystem
var battle_system: BattleSystem

# 型定義を自作のクラス(dungeon.gdのclass_name)に変更
@onready var dungeon_explore: DungeonExplore = $Camera3D

func _ready() -> void:
	print("【Main】コントローラー起動。")
	
	town_system = TownSystem.new(self)
	battle_system = BattleSystem.new(self)
	
	# シグナル結合
	dungeon_explore.battle_triggered.connect(_on_battle_triggered)
	dungeon_explore.town_entered.connect(_on_town_entered)
	
	town_system.dungeon_entered.connect(_on_dungeon_entered)
	battle_system.battle_finished.connect(_on_battle_finished)
	
	_change_state(GameState.EXPLORE)

func _change_state(next_state: GameState) -> void:
	current_state = next_state
	
	if town_system: town_system.end_town_mode()
	if battle_system: battle_system.end_battle_mode()
	if dungeon_explore: dungeon_explore.set_explore_active(false)
	
	match current_state:
		GameState.EXPLORE:
			dungeon_explore.set_explore_active(true)
		GameState.TOWN:
			town_system.start_town_mode()
		GameState.BATTLE:
			# 現在の階層(整数)を取得
			var current_floor_num: int = dungeon_explore.current_floor
			
			# 💡JSON(map_data.json)内の出現数設定を取得
			var limits: Dictionary = dungeon_explore.get_floor_enemy_limits(current_floor_num)
			var min_count: int = limits["min"]
			var max_count: int = limits["max"]
			
			# 登場可能な敵ID(文字列)を格納する配列の作成
			var available_enemy_ids: Array[String] = []
			
			# ロード済みの enemy_master_data から、この階層に出る敵をすべて抽出
			for enemy_id in battle_system.enemy_master_data.keys():
				var enemy_data = battle_system.enemy_master_data[enemy_id]
				
				if typeof(enemy_data) == TYPE_DICTIONARY and enemy_data.has("appear_floors"):
					var floors_list: Array = enemy_data["appear_floors"]
					for f in floors_list:
						if int(f) == current_floor_num:
							available_enemy_ids.append(enemy_id)
							break
			
			# 安全装置
			if available_enemy_ids.is_empty():
				available_enemy_ids.append("goblin")
			
			# 選別された配列と、JSONから動的に引っ張ってきた出現数範囲を渡してバトル開始
			battle_system.start_battle(available_enemy_ids, min_count, max_count)

func _on_battle_triggered() -> void:
	_change_state(GameState.BATTLE)

func _on_battle_finished() -> void:
	_change_state(GameState.EXPLORE)

func _on_town_entered() -> void:
	_change_state(GameState.TOWN)

func _on_dungeon_entered() -> void:
	dungeon_explore.reset_to_dungeon_start()
	_change_state(GameState.EXPLORE)

メイン処理用コード (main.gd) を修正します。ダンジョン探索用コードから引っ張ってきたマップデータ内の出現数設定を、出現数範囲を渡して戦闘を始める流れです。

戦闘処理用コード修正

# ==============================================================================
# battle.gd : 戦闘の進行、コマンド操作だけを担当するクラス(完全データ駆動・出現数可変版)
# ==============================================================================
class_name BattleSystem
extends RefCounted

signal battle_finished

var message_window: PanelContainer
var battle_text: Label
var attack_button: Button
var auto_button: Button
var flee_button: Button

var enemy_layout: Control
var enemy_container: HBoxContainer

# 💾 JSONから読み込んだ敵のマスターデータ
var enemy_master_data: Dictionary = {}

# ⚔️ 現在の戦闘に参加している敵インスタンスのリスト
var current_enemies: Array[ActiveEnemy] = []

func _init(main_node: Node) -> void:
	randomize()

	message_window = main_node.get_node_or_null("CanvasLayer/PanelContainer")
	battle_text = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/BattleText")
	attack_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AttackButton")
	auto_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/AutoButton")
	flee_button = main_node.get_node_or_null("CanvasLayer/PanelContainer/BattleMenu/FleeButton")
	
	enemy_layout = main_node.get_node_or_null("CanvasLayer/EnemyLayout")
	enemy_container = main_node.get_node_or_null("CanvasLayer/EnemyLayout/EnemyContainer")
	
	if attack_button: attack_button.pressed.connect(_on_attack_button_pressed)
	if auto_button: auto_button.pressed.connect(_on_auto_button_pressed)
	if flee_button: flee_button.pressed.connect(_on_flee_button_pressed)
	
	_load_enemy_data_from_json()

func _load_enemy_data_from_json() -> void:
	var file_path: String = "res://data/enemy_data.json"
	if not FileAccess.file_exists(file_path):
		return
	var file = FileAccess.open(file_path, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()
	var json = JSON.new()
	if json.parse(json_string) == OK and typeof(json.data) == TYPE_DICTIONARY:
		enemy_master_data = json.data

# 🛠️ 戦闘開始(引数に出現数の最小・最大を受け取る)
func start_battle(available_enemy_ids: Array[String], min_count: int, max_count: int) -> void:
	_generate_enemies(available_enemy_ids, min_count, max_count)
	
	if attack_button: attack_button.show()
	if auto_button: auto_button.show()
	if flee_button: flee_button.show()
	if message_window: message_window.show()
	
	if enemy_layout: enemy_layout.show()
	if enemy_container: enemy_container.show()
	if attack_button: attack_button.grab_focus()

# 🛠️ 混成の敵部隊を動的生成
func _generate_enemies(available_enemy_ids: Array[String], min_count: int, max_count: int) -> void:
	current_enemies.clear()
	
	if enemy_container:
		for child in enemy_container.get_children():
			child.queue_free()
			
	# 引数(min_count 〜 max_count)による範囲で動的決定
	var total_enemy_count: int = randi_range(min_count, max_count)
	
	# 同名の敵が何体出たかをカウントするための辞書(アルファベット命名用)
	var enemy_type_counts: Dictionary = {}
	
	# 1. 出現する個体のランダム選出
	var chosen_enemies_data: Array[Dictionary] = []
	
	for i in range(total_enemy_count):
		var random_index: int = randi() % available_enemy_ids.size()
		var enemy_id: String = available_enemy_ids[random_index]
		
		if enemy_master_data.has(enemy_id):
			var enemy_master = enemy_master_data[enemy_id]
			
			if not enemy_type_counts.has(enemy_id):
				enemy_type_counts[enemy_id] = 0
			enemy_type_counts[enemy_id] += 1
			
			chosen_enemies_data.append({
				"id": enemy_id,
				"master": enemy_master,
				"current_count": enemy_type_counts[enemy_id]
			})

	# 2. 決定した敵データに基づいて、インスタンスと画像を生成
	for data in chosen_enemies_data:
		var enemy_id: String = data["id"]
		var enemy_master: Dictionary = data["master"]
		var current_count: int = data["current_count"]
		var total_type_count: int = enemy_type_counts[enemy_id]
		
		var suffix: String = ""
		if total_type_count > 1:
			suffix = " " + ["A", "B", "C", "D", "E", "F", "G", "H"][current_count - 1]
			
		var enemy_instance = ActiveEnemy.new(enemy_id, enemy_master, suffix)
		current_enemies.append(enemy_instance)
		
		var img_path: String = enemy_master.get("image_path", "res://icon.svg")
		var texture: Texture2D = null
		if ResourceLoader.exists(img_path):
			texture = load(img_path) as Texture2D
		else:
			texture = load("res://icon.svg") as Texture2D
			
		if enemy_container and texture:
			var rect = TextureRect.new()
			rect.texture = texture
			rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
			rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
			rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			enemy_container.add_child(rect)
	
	var names = current_enemies.map(func(e): return e.name)
	var msg: String = "モンスターが現れた!\n"
	msg += "(%s が出現)\n" % ", ".join(names)
	msg += "コマンドを選択してください。"
		
	if battle_text:
		battle_text.text = msg

func end_battle_mode() -> void:
	if message_window: message_window.hide()
	
	if enemy_layout: enemy_layout.hide()
	if enemy_container:
		enemy_container.hide()
		for child in enemy_container.get_children():
			child.queue_free()
			
	_hide_battle_buttons()

func _on_attack_button_pressed() -> void:
	print("「たたかう」コマンドが選択されました")

func _on_auto_button_pressed() -> void:
	print("「オート」コマンドが選択されました")

func _on_flee_button_pressed() -> void:
	print("【Battle】無事に逃げ切った!")
	battle_finished.emit()

func _hide_battle_buttons() -> void:
	if attack_button: attack_button.hide()
	if auto_button: auto_button.hide()
	if flee_button: flee_button.hide()

戦闘処理用コード (battle.gd) を上記のように修正してください。これでフロアごとに敵の最小出現数と最大出現数を自由に変更することができます。

ここまで進めると、ダンジョンRPGらしい敵の出現に近づいてきているかと思います。敵出現時のエフェクト・効果音や戦闘音楽の処理は、後の工程で追加します。「たたかう」「オート」などのコマンド実装も同様です。

敵の名前の英語 / 日本語表記切り替え処理 (ローカライズ)

現時点でテストをすると、敵の名前は JSON に直書きされた”name_key” の値が表示されています。(ENEMY_GOBLINなど) これを言語設定で “Goblin” や “ゴブリン” など切り替えて表示するようにします。

英語en
日本語ja

商業リリースまでを考えるのであれば、ローカライズは必須です。今後、ダンジョンRPG制作を進めていくと最終的には英語版などを考慮する人もいるでしょう。もし、ローカライズを考えていないのであれば、name_key を “ゴブリン” など敵の名前に変更すれば完了です。いずれにせよダンジョンRPG制作を進めるうえで、最終目的は考えておく必要があります。

翻訳データCSVファイルの作成

id,en,ja
ENEMY_GOBLIN,Goblin,ゴブリン
ENEMY_SLIME,Slime,スライム
ENEMY_MONSTER_HARE,Monster Hare,かいぶつうさぎ
ENEMY_ZOMBIE,Zombie,ゾンビ

翻訳データCSVファイルの作成を作成してください。ここでは localization.csv というファイル名にしています。上記敵の名前は一例です。

  • 1行目は必ず id,en,ja (キー、英語の言語コード、日本語の言語コード) にしてください。
  • カンマの前後に余計なスペースを入れないようにファイルを作成してください。

中国語やフランス語、スペイン語などもここで記述します。その際は1行目は cn, fr, sp などつけてください。

翻訳データCSVファイル (localization.csv) を Filesystem にある data フォルダ (res://data/) にインポートしてください。インポートするとlocalization.en.translation と localization.ja.translation という翻訳用バイナリファイルが自動生成されます。

翻訳ファイルの登録

メニューバーの Project から Project Settings を選択します。

Localization タブを開き、Add ボタンをクリックします。

先ほどインポートしたときに自動生成されたファイルを選択し、Open します。

自動生成されたファイルをすべて追加し、ウィンドウを閉じてください。

この登録作業は Godot に「この翻訳ファイルを使ってください」と教えるものです。

メイン処理用コード修正 (起動時のデフォルト言語を英語に固定)

# ==============================================================================
# main.gd : ゲーム全体(街 / 探索 / 戦闘)の状態を統括するコントローラー(プランA統合版)
# ==============================================================================
extends Node

enum GameState { EXPLORE, BATTLE, TOWN }
var current_state: GameState = GameState.EXPLORE

var town_system: TownSystem
var battle_system: BattleSystem

# 型定義を自作のクラス(dungeon.gdのclass_name)に変更
@onready var dungeon_explore: DungeonExplore = $Camera3D

func _ready() -> void:
	print("【Main】コントローラー起動。")
	
	# ゲーム起動時のデフォルト言語を「英語」に設定
	TranslationServer.set_locale("en")
	
	town_system = TownSystem.new(self)
	battle_system = BattleSystem.new(self)
	
	# シグナル結合
	dungeon_explore.battle_triggered.connect(_on_battle_triggered)
	dungeon_explore.town_entered.connect(_on_town_entered)
	
	town_system.dungeon_entered.connect(_on_dungeon_entered)
	battle_system.battle_finished.connect(_on_battle_finished)
	
	_change_state(GameState.EXPLORE)

func _change_state(next_state: GameState) -> void:
	current_state = next_state
	
	if town_system: town_system.end_town_mode()
	if battle_system: battle_system.end_battle_mode()
	if dungeon_explore: dungeon_explore.set_explore_active(false)
	
	match current_state:
		GameState.EXPLORE:
			dungeon_explore.set_explore_active(true)
		GameState.TOWN:
			town_system.start_town_mode()
		GameState.BATTLE:
			# 現在の階層(整数)を取得
			var current_floor_num: int = dungeon_explore.current_floor
			
			# dungeon_explore経由で、JSON(map_data.json)内の出現数設定を取得
			var limits: Dictionary = dungeon_explore.get_floor_enemy_limits(current_floor_num)
			var min_count: int = limits["min"]
			var max_count: int = limits["max"]
			
			# 登場可能な敵ID(文字列)を格納する配列の作成
			var available_enemy_ids: Array[String] = []
			
			# ロード済みの enemy_master_data から、この階層に出る敵をすべて抽出
			for enemy_id in battle_system.enemy_master_data.keys():
				var enemy_data = battle_system.enemy_master_data[enemy_id]
				
				if typeof(enemy_data) == TYPE_DICTIONARY and enemy_data.has("appear_floors"):
					var floors_list: Array = enemy_data["appear_floors"]
					for f in floors_list:
						if int(f) == current_floor_num:
							available_enemy_ids.append(enemy_id)
							break
			
			# 安全装置
			if available_enemy_ids.is_empty():
				available_enemy_ids.append("goblin")
			
			# 選別された配列と、JSONから動的に引っ張ってきた出現数範囲を渡してバトル開始
			battle_system.start_battle(available_enemy_ids, min_count, max_count)

func _on_battle_triggered() -> void:
	_change_state(GameState.BATTLE)

func _on_battle_finished() -> void:
	_change_state(GameState.EXPLORE)

func _on_town_entered() -> void:
	_change_state(GameState.TOWN)

func _on_dungeon_entered() -> void:
	dungeon_explore.reset_to_dungeon_start()
	_change_state(GameState.EXPLORE)

メイン処理用コード (main.gd) を上記のように修正します。これは、ゲーム起動時にデフォルト言語を英語 (en) に設定する処理を _ready() の先頭に追加したものです。

本来のゲームでは設定画面で言語をセットしますが、その設定ファイル処理は後の工程で実施する予定です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA


目次