
今まで作成してきたのは以下の通りです。このダンジョンでは、壁や床などは仮置きした無機質な MeshInstance3D の BoxMesh になっていました。今回はこの無機質なものをダンジョンらしいものに置き換えていきます。この記事の内容を実施することで、仮置きしたものが一気にダンジョンRPGのものになります。
- ダンジョンの壁と床の設置
- プレイヤーの現在座標とその表記
- イベントメッセージの表示
- 敵とのランダムエンカウント
- ダンジョンの扉と階段の設置
- ダンジョンのB1から街に戻る処理
- ダンジョンのB1からB2に降りる処理
グラフィックの置き換え (アセット適用)
現在のダンジョンのグラフィック置き換え作業は以下の流れで行います。
- アセットの用意
- 無料素材
- Blender で素材自作
- MeshLibrary の新規作成
- 既存の GridMap に新規 MeshLibrary を割り当てる
- 3DモデルをアイテムIDに紐付ける
アセット (3Dモデル) の用意
アセットは、主に以下の2パターンで用意することが可能です。
- 無料素材のダウンロード
- Blender で自作する
無料素材(プロトタイプ用アセット) のダウンロード

Godot の Filesystem を右クリックして、assets フォルダを作成します。

ダウンロードしてきたファイルで必要なものを assets フォルダにドラッグ&ドロップしてください。ファイル形式は .gltf または .glb になっているものです。
Blender で自作する
Blender (無料3D素材制作ソフト) で自作することもできます。この手順については以下の別記事をご参照ください。
MeshLibrary (3Dタイルセット) の作成

メニューバーの Scene から New Scene を選びます。

ルートノードを 3D Scene として作成し、名前を MeshLibrarySource として保存します。

先ほどインポートした素材ファイル (.glb など) をドラッグ & ドロップして子ノードとして追加します。
既存の GridMap に新規 MeshLibrary を割り当てる

MeshLibrarySource にドラッグしてできた子ノードの名前を変更します。その名前は GridMap と同じアイテムIDになるようにします。たとえば GridMap で現在仮置きしている床のアイテムIDは 0 なので、床の素材ファイルのノードの名前も 0 にします。他素材も同様に変更してください。
| 床の素材ファイルのノード | 0 |
| 壁の素材ファイルのノード | 1 |
| 扉の素材ファイルのノード | 2 |
| 下り階段の素材ファイルのノード | 3 |
| 上り階段の素材ファイルのノード | 4 |

Scene メニューから Export As… > MeshLibrary… を選択します。

名前を dungeon_assets.meshlib として保存します。
3DモデルをアイテムIDに紐付ける

エディタの Main タブをクリックし、Main シーンの GridMap をクリックします。GridMap の Inspector 欄にある v マークをクリックして、load を選択してください。

読み込みのウィンドウが出たら、先ほどエクスポートして保存した dungeon_assets.meshlib を開きます。

開くと、エディタが素材ファイルに置き換わっていることが確認できるかと思います。

あとは微調整で完成です。階段やドアの向きを変えるなど調整内容は人によって異なるので、省略します。(上記画像では Scale を 2.0m にしています)
この記事に書かれていることを応用すれば、水路・ワナ・石碑・橋・宝箱など何でもダンジョンに足すことが可能です。壁や扉の種類も増やせます。
グラフィックの置き換え関連のソースコード修正

res://
├── data/
│ └── map_data.json <-- 階段やイベントの定義ファイル
└── scripts/
└── dungeon.gd <-- JSONを読み込むロジックに修正ここまでグラフィックの置き換え作業をしてきました。置き換え作業をするとダンジョンの見え方が変わるので、それに伴う問題が発生します。以下の問題点を解決するために、dungeon.gd の修正を行います。
| 目線の高さをキープ | 階段の上り下りで目線の高さが変わる現象が発生。階段の上り下りでも目線の高さをキープするように。 |
| 階段めり込み自動回避ロジックの追加 | 階段を置き換えたことにより、階段マスにそのままいると目線の高さで登り階段にめり込む現象が発生。階段マスの周りにある移動可能なマスに自動移動することで対処 |
| 街からダンジョンB1に戻ったとき、座標がズレる (階段前にプレイヤーがいない) | JSONデータ内から「target_floorが0」になっている階段の座標を動的に探すようにしました。 |
| 敵との遭遇時に座標が表示されている。 | 表示されないようにしました。 |
| 階段座標がコード内に直に記載されている | 階段座標はJSONで管理します。上記 data フォルダを作成して、マップデータを管理します。 (ダンジョンマップを変えても、テキストを変えるだけでコードを修正しなくて済むため) |
| 扉を一度開けたら、元に戻らない | 階段の上り下りで、扉が復元するように修正 |
Map データ (JSON)
{
"1": {
"0,0,1": {
"target_floor": 0,
"target_pos": [0, 0, 1]
},
"0,0,-9": {
"target_floor": 2,
"target_pos": [0, 0, -14]
}
},
"2": {
"0,0,-14": {
"target_floor": 1,
"target_pos": [0, 0, -9]
}
}
}階段座標がコード内に直に記載されているため、マップを変更するたびにソースコードも変更しなければならない状態でした。この修正をするために Map データを上記のように JSON で管理するようにしました。
map_data.json ファイルを新規作成し、テキストファイルとして開きます。その中に上記のようなコードを記述して保存します。その JSON ファイルを Godot Engine の Filesystem の data フォルダの下にドラッグ & ドロップしてください。
ソースコード
# ==============================================================================
# dungeon.gd : 商用・量産対応版(JSONデータ駆動型設計)
# ==============================================================================
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 = 8
const BASE_ENCOUNTER_CHANCE: float = 0.02
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())
# 🛠️【既存関数を修正】階段移動処理(去る階層の扉を復活させる)
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:
# 💡 街に戻る際にも、今いるB1の扉を復活させておく
_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:
# 💡 【完全修正】戦闘中など探索が無効化されたら、実在する status_label の文字を空にして消去する
if status_label:
status_label.text = ""
func reset_to_dungeon_start() -> void:
current_floor = 1
steps_since_last_fight = 0
# JSONデータ内から「target_floorが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():
var warp_info: Dictionary = b1_data[coord_str]
if int(warp_info["target_floor"]) == 0:
# 文字列キー "0,0,1" を Vector3i にパースする
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] = []
# 💡 GridMapから扉の現在の「向き(0〜23の整数)」を取得
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)
# 🛠️【修正】扉の復活処理(保存しておいた orientation を適用して再配置する)
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"]
# 💡 第3引数に保存していた 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 のコードは上記のように書きなおしました。
コメント