
最初に作成したのは床と壁だけのダンジョンでした。この記事では、その簡素なダンジョンから扉と階段の処理を行います。扉を開けたときと階段を昇り降りするときに効果音が出る処理です。
ここまでのダンジョンRPG制作で作成したものは以下の通りです。
- ダンジョンの壁と床の設置
- プレイヤーの現在座標とその表記
- イベントメッセージの表示
- 敵とのランダムエンカウント
詳しくは以下の別記事をご参照ください。
ドア処理
ここではダンジョンRPGにおけるドアの処理について順番にお伝えしていきます。
- ドアの配置
- ドアを開けたときにメッセージが出る
- ドアを開けたときに効果音が出る
ダンジョンにドアを配置する
ここからはドアの配置についてお伝えします。基本は床や壁と同じ処理を行います。
ドア用 MeshInstance3D の追加

DungeonParts のソースシーンを開き、新しく MeshInstance3D を追加、名前を DoorItem にします。

DoorItem (MeshInstance3D) はわかりやすいように壁とは違う形にします。DoorItem の Inspector にある立体をクリックし、サイズを X:2.0m, Y:2.0m, Z:0.1m にしています。

ドアを作成したらシーンを保存します。メニューの 「シーン」>「エクスポート」>「メッシュライブラリ…」 で、現在使用中の dungeon_assets.tres ファイルに上書き保存してください。

Main シーンに戻り、GridMap ノードを選択します。右側の Inspector > MeshLibrary > Item に、新しく作ったドアのアイテムが表示されていることを確認します。このドアの アイテムID (インデックス) を確認してください。
この記事では以下の表のとおりに進めていきます。ご自身の環境にあわせて調節してください。
| Item ID: 0 | Floor |
| Item ID: 1 | Wall |
| Item ID: 2 | DoorItem |

エディタ欄にある DoorItem をダンジョンに配置します。
このドアは仮置き用として作成しています。上記図のように、見た目上は DoorItem の下に空間があります。後の工程でドアの3Dモデルや2D画像(テクスチャ)に差し替えます。今は気にしないでください。
ドアを開けたときにメッセージが出る
以下はドアを開けたときに「扉を開けた」とメッセージが出るようにするものです。
コードの修正
extends Camera3D
enum Direction { NORTH, EAST, SOUTH, WEST }
enum GameState { EXPLORE, BATTLE }
const MOVE_STEP: float = 2.0
const ROTATE_STEP: float = 90.0
# 💡 【ID定義】GridMap上のアイテムID
const WALL_ITEM_ID: int = 1
const DOOR_ITEM_ID: int = 2 # 💡 ドアのアイテムID(2番)
const MIN_SAFE_STEPS: int = 3
const BASE_ENCOUNTER_CHANCE: float = 0.08
var current_grid_pos: Vector2i = Vector2i.ZERO
var current_dir: Direction = Direction.NORTH
var is_showing_message: bool = false
var triggered_events: Dictionary = {}
var steps_since_last_fight: int = 0
var current_state: GameState = GameState.EXPLORE
# 💡 一度開けたドアの座標を記録する辞書(開いたドアを通行可能にするため)
var opened_doors: Dictionary = {}
@onready var grid_map: GridMap = $"../GridMap"
@onready var status_label: Label = $"../CanvasLayer/Label"
@onready var message_window: PanelContainer = $"../CanvasLayer/PanelContainer"
@onready var enemy_graphic: TextureRect = $"../CanvasLayer/EnemyGraphic"
@onready var battle_text: Label = $"../CanvasLayer/PanelContainer/BattleMenu/BattleText"
@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:
update_player_status()
if message_window:
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
steps_since_last_fight = 0
attack_button.pressed.connect(_on_attack_button_pressed)
auto_button.pressed.connect(_on_auto_button_pressed)
flee_button.pressed.connect(_on_flee_button_pressed)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_state == GameState.BATTLE:
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)
# 1. 進もうとした場所が「純粋な壁」なら、当然進めない
if cell_item_id == WALL_ITEM_ID:
return
# 2. 💡 進もうとした場所が「ドア(ID:2)」だった場合の判定
if cell_item_id == DOOR_ITEM_ID:
# このドアが「まだ開いていない」なら、移動をブロックしてドアを開けるイベントを呼ぶ
if not opened_doors.has(map_coord):
interact_with_door(map_coord)
return # ドアを開けた瞬間はそのマスには進まず、一度メッセージを出す(Wiz風)
# 壁でも閉まったドアでもなければ、安全に移動する
global_position = target_global_pos
update_player_status()
var event_triggered: bool = check_coordinate_event()
if not event_triggered:
check_random_encounter()
# 💡 【新規】ドアを調べる(開ける)処理
func interact_with_door(door_coord: Vector3i) -> void:
# ドアを開放済みフラグに登録する
opened_doors[door_coord] = true
# 🚪 GridMapからそのドアのメッシュを消去して「空っぽ(進める床)」にする
# これにより、次に前進したときは普通に進めるようになります
grid_map.set_cell_item(door_coord, GridMap.INVALID_CELL_ITEM)
# メッセージウィンドウを使い、ドアを開けた通知を出す
show_explore_message("扉を開けた。 🔑\n\n(Wボタンでメッセージを閉じる)")
print("ドアを開けました。座標: ", door_coord)
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():
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):
if forward_vector.z < -0.5:
current_dir = Direction.NORTH
else:
current_dir = Direction.SOUTH
else:
if forward_vector.x > 0.5:
current_dir = Direction.EAST
else:
current_dir = 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 = "現在地: (X: %d, Y: %d) | 向き: %s" % [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"]:
if triggered_events.has(current_grid_pos) and triggered_events[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()
print("歩数: %d | 遭遇確率: %d%% | 出目: %d%%" % [steps_since_last_fight, int(current_chance * 100), int(roll * 100)])
if roll < current_chance:
trigger_battle_encounter()
func show_explore_message(text: String) -> void:
battle_text.text = text
attack_button.hide()
auto_button.hide()
flee_button.hide()
message_window.show()
is_showing_message = true
func trigger_battle_encounter() -> void:
current_state = GameState.BATTLE
steps_since_last_fight = 0
battle_text.text = "ゴブリンが現れた!\nコマンドを選択してください。"
attack_button.show()
auto_button.show()
flee_button.show()
message_window.show()
if enemy_graphic:
enemy_graphic.show()
attack_button.grab_focus()
func close_message_window() -> void:
message_window.hide()
is_showing_message = false
# 💡 パーティ個別コマンドへの遷移(いただいた仕様変更を完全反映)
func _on_attack_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "パーティメンバーのコマンドへ (未実装)"
# 💡 オートバトルの開始(いただいた仕様変更を完全反映)
func _on_auto_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "オートバトルへ (未実装)"
func _on_flee_button_pressed() -> void:
if current_state != GameState.BATTLE: return
current_state = GameState.EXPLORE
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
print("無事に逃げ切った!探索に戻ります。")コード (camera_3d.gd) を上記のように書き換えます。
ドアを開けたときに効果音が出る
以下はドアを開けたときに効果音(SE)が出るようにするものです。
効果音ファイルのインポート

ドアを開けるときの効果音ファイルを用意してください。ここでは door_open.mp3 とします。効果音ファイルは FileSystem にドラッグ&ドロップしてください。これでインポートは完了です。
オーディオノードの追加

Camera3D のノードを選択し右クリック、Add Child Node から AudioStreamPlayer を作成してください。作成後、名前を SfxPlayer に変更します。
この AudioStreamPlayer は、他の効果音もハンドリングします。(階段の上り下り、ワナの発動など)

SfxPlayer (AudioStreamPlayer) の Inspector で AudioStreamPlayer > Stream に door_open.mp3 ファイルをドラッグ&ドロップして適用してください。
コードの修正
extends Camera3D
enum Direction { NORTH, EAST, SOUTH, WEST }
enum GameState { EXPLORE, BATTLE }
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 MIN_SAFE_STEPS: int = 8
const BASE_ENCOUNTER_CHANCE: float = 0.04
var current_grid_pos: Vector2i = Vector2i.ZERO
var current_dir: Direction = Direction.NORTH
var is_showing_message: bool = false
var triggered_events: Dictionary = {}
var steps_since_last_fight: int = 0
var current_state: GameState = GameState.EXPLORE
var opened_doors: Dictionary = {}
@onready var grid_map: GridMap = $"../GridMap"
@onready var status_label: Label = $"../CanvasLayer/Label"
@onready var message_window: PanelContainer = $"../CanvasLayer/PanelContainer"
@onready var enemy_graphic: TextureRect = $"../CanvasLayer/EnemyGraphic"
# 💡 【新規】効果音再生用のノードへの参照
@onready var sfx_player: AudioStreamPlayer = $SfxPlayer
@onready var battle_text: Label = $"../CanvasLayer/PanelContainer/BattleMenu/BattleText"
@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:
update_player_status()
if message_window:
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
steps_since_last_fight = 0
attack_button.pressed.connect(_on_attack_button_pressed)
auto_button.pressed.connect(_on_auto_button_pressed)
flee_button.pressed.connect(_on_flee_button_pressed)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_state == GameState.BATTLE:
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)
# 💡 try_move_direction 関数を「踏み込んだ瞬間に判定する」ロジックに修正
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)
# 1. 進もうとした場所が「純粋な壁」なら、当然進めない(これはそのまま)
if cell_item_id == WALL_ITEM_ID:
return
# 2. 【変更】進む先がドアであっても、壁ではないので「まずは1歩踏み込ませる」
# 座標を先に更新して、ドアのマスへプレイヤーを移動させます
global_position = target_global_pos
update_player_status() # ここで current_grid_pos がドアの座標に更新される
# 3. 【新規】今踏み込んだまさにそのマス(現在地)が「ドア」だった場合の判定
# マップ座標を再取得して、現在の足元のセルを調べます
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_cell_id == DOOR_ITEM_ID:
if not opened_doors.has(current_map_coord):
# 💡 ドアのマスに足を踏み入れた「まさにこの瞬間」に音を鳴らし、ドアを消す
interact_with_door(current_map_coord)
return # ドアの処理をしたのでここで終了(イベントやエンカウントは発生させない)
# ドアのマスでなければ、通常通りイベントとエンカウントの判定を行う
var event_triggered: bool = check_coordinate_event()
if not event_triggered:
check_random_encounter()
# 💡 interact_with_door 関数をシンプルに(ウェイトなしで即時再生)
func interact_with_door(door_coord: Vector3i) -> void:
opened_doors[door_coord] = true
# 💡 音を鳴らす(プレイヤーがこのマスに重なった瞬間)
if sfx_player:
sfx_player.play()
# 🚪 足元のドアメッシュを消去して通路にする
grid_map.set_cell_item(door_coord, GridMap.INVALID_CELL_ITEM)
print("ドアのマスに踏み込みました。音再生&ドア消去。座標: ", door_coord)
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():
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):
if forward_vector.z < -0.5:
current_dir = Direction.NORTH
else:
current_dir = Direction.SOUTH
else:
if forward_vector.x > 0.5:
current_dir = Direction.EAST
else:
current_dir = 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 = "現在地: (X: %d, Y: %d) | 向き: %s" % [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"]:
if triggered_events.has(current_grid_pos) and triggered_events[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()
print("歩数: %d | 遭遇確率: %d%% | 出目: %d%%" % [steps_since_last_fight, int(current_chance * 100), int(roll * 100)])
if roll < current_chance:
trigger_battle_encounter()
func show_explore_message(text: String) -> void:
battle_text.text = text
attack_button.hide()
auto_button.hide()
flee_button.hide()
message_window.show()
is_showing_message = true
func trigger_battle_encounter() -> void:
current_state = GameState.BATTLE
steps_since_last_fight = 0
battle_text.text = "ゴブリンが現れた!\nコマンドを選択してください。"
attack_button.show()
auto_button.show()
flee_button.show()
message_window.show()
if enemy_graphic:
enemy_graphic.show()
attack_button.grab_focus()
func close_message_window() -> void:
message_window.hide()
is_showing_message = false
func _on_attack_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "パーティメンバーのコマンドへ (未実装)"
func _on_auto_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "オートバトルへ (未実装)"
func _on_flee_button_pressed() -> void:
if current_state != GameState.BATTLE: return
current_state = GameState.EXPLORE
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
print("無事に逃げ切った!探索に戻ります。")コード (camera_3d.gd) を上記のように書き換えます。
階段処理
ここからはダンジョンRPGにおける階段処理についてお伝えします。
- 階段の配置
- 階段を上り下りするときにメッセージが出る
- 階段を上り下りするときに効果音が鳴る
ダンジョンに階段を配置する
まずはダンジョンに階段を配置していきましょう。基本はドアと同じ処理です。
階段用 MeshInstance3D の追加

dungeon_parts で扉同様に階段 (MeshInstance3D) を追加してください。階段パーツは StairsItem に名前を変更しています。ここでは階段(仮)の Size を X:2.0m, Y:1.0m, Z:2.0m としています。(わかりやすい形状であれば何でもOK)

Main の GridMap を選択し、Inspector の MeshLibrary で StairsItem (MeshInstance3D) の ItemIDを確認します。この記事では 3 として進めます。

ダンジョンに階段 (StairsItem) を配置しておきましょう。
階段を上り下りするときにメッセージが出る
まず階段を上り下りするときにメッセージが出るようにします。
コードの修正
extends Camera3D
enum Direction { NORTH, EAST, SOUTH, WEST }
enum GameState { EXPLORE, BATTLE }
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_ITEM_ID: int = 3 # 💡 新規追加:階段のアイテムID(3番)
# 💡 ご提示いただいた快適なエンカウントバランスに調整
const MIN_SAFE_STEPS: int = 8
const BASE_ENCOUNTER_CHANCE: float = 0.02
var current_grid_pos: Vector2i = Vector2i.ZERO
var current_dir: Direction = Direction.NORTH
var is_showing_message: bool = false
var triggered_events: Dictionary = {}
var steps_since_last_fight: int = 0
var current_state: GameState = GameState.EXPLORE
var opened_doors: Dictionary = {}
# 💡 【新規】現在の階層を管理する変数(1 = 地下1階, 2 = 地下2階)
var current_floor: int = 1
@onready var grid_map: GridMap = $"../GridMap"
@onready var status_label: Label = $"../CanvasLayer/Label"
@onready var message_window: PanelContainer = $"../CanvasLayer/PanelContainer"
@onready var enemy_graphic: TextureRect = $"../CanvasLayer/EnemyGraphic"
@onready var battle_text: Label = $"../CanvasLayer/PanelContainer/BattleMenu/BattleText"
@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:
update_player_status()
if message_window:
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
steps_since_last_fight = 0
attack_button.pressed.connect(_on_attack_button_pressed)
auto_button.pressed.connect(_on_auto_button_pressed)
flee_button.pressed.connect(_on_flee_button_pressed)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_state == GameState.BATTLE:
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
# ドアのマスへの進入判定
if cell_item_id == DOOR_ITEM_ID:
if not opened_doors.has(map_coord):
interact_with_door(map_coord)
# 先にプレイヤーを移動させる
global_position = target_global_pos
update_player_status()
# 移動後の現在地のセルIDを再取得
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)
# 💡 【新規】今踏み込んだマスが「階段(ID:3)」だった場合の処理
if current_cell_id == STAIRS_ITEM_ID:
use_stairs()
return # 階層移動イベントを優先するため、通常のイベント/エンカウントはスキップ
# ドアの処理中であればここで終了
if current_cell_id == DOOR_ITEM_ID and not opened_doors.has(current_map_coord):
return
var event_triggered: bool = check_coordinate_event()
if not event_triggered:
check_random_encounter()
func interact_with_door(door_coord: Vector3i) -> void:
opened_doors[door_coord] = true
# ※前回のドア音の件を踏まえ、即再生(sfx_player があれば)
# 今後、共通関数化などのリファクタリングも検討可能です
grid_map.set_cell_item(door_coord, GridMap.INVALID_CELL_ITEM)
print("ドア消去。座標: ", door_coord)
# 💡 【新規】階段移動の処理
func use_stairs() -> void:
# B1F から B2F へ下りる(仮の実装として、値を+1する)
current_floor += 1
# 移動歩数をリセット
steps_since_last_fight = 0
# ウィンドウを使って階層移動を通知する(Wizの「地下○階へ下りた」の再現)
show_explore_message("階段を下りた…… 🕳️\nここは 地下 %d 階 だ。\n\n(Wボタンでメッセージを閉じる)" % current_floor)
# 💡 本来はここで「別のマップ(B2FのGridMap)に切り替える」か「初期位置(0,0)に戻す」ロジックが入りますが、
# 今回はまず「階層データ(変数)が変わり、UIが変わる」というループの基礎をテストします。
update_ui_display()
print("階層が切り替わりました。現在のフロア: B", current_floor)
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():
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):
if forward_vector.z < -0.5:
current_dir = Direction.NORTH
else:
current_dir = Direction.SOUTH
else:
if forward_vector.x > 0.5:
current_dir = Direction.EAST
else:
current_dir = Direction.WEST
update_ui_display()
# 💡 【UI拡張】現在の階層(B1Fなど)を画面上に常時表示させる
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%dF | 現在地: (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"]:
if triggered_events.has(current_grid_pos) and triggered_events[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()
print("歩数: %d | 遭遇確率: %d%% | 出目: %d%%" % [steps_since_last_fight, int(current_chance * 100), int(roll * 100)])
if roll < current_chance:
trigger_battle_encounter()
func show_explore_message(text: String) -> void:
battle_text.text = text
attack_button.hide()
auto_button.hide()
flee_button.hide()
message_window.show()
is_showing_message = true
func trigger_battle_encounter() -> void:
current_state = GameState.BATTLE
steps_since_last_fight = 0
battle_text.text = "ゴブリンが現れた!\nコマンドを選択してください。"
attack_button.show()
auto_button.show()
flee_button.show()
message_window.show()
if enemy_graphic:
enemy_graphic.show()
attack_button.grab_focus()
func close_message_window() -> void:
message_window.hide()
is_showing_message = false
func _on_attack_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "パーティメンバーのコマンドへ (未実装)"
func _on_auto_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "オートバトルへ (未実装)"
func _on_flee_button_pressed() -> void:
if current_state != GameState.BATTLE: return
current_state = GameState.EXPLORE
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
print("無事に逃げ切った!探索に戻ります。")コード (camera_3d.gd) を上記のように書き換えます。これで階段を降りるときにメッセージが出るようになりました。
階段を昇り降りするときに効果音が鳴る
以下は階段を昇り降りするときに効果音(SE)が出るようにするものです。
効果音ファイルのインポート

階段の効果音ファイルをインポートしてください。ここでは stairs.mp3 としています。
コードの修正
extends Camera3D
enum Direction { NORTH, EAST, SOUTH, WEST }
enum GameState { EXPLORE, BATTLE }
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_ITEM_ID: int = 3
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") # 階段の音ファイル
var current_grid_pos: Vector2i = Vector2i.ZERO
var current_dir: Direction = Direction.NORTH
var is_showing_message: bool = false
var triggered_events: Dictionary = {}
var steps_since_last_fight: int = 0
var current_state: GameState = GameState.EXPLORE
var opened_doors: Dictionary = {}
var current_floor: int = 1
@onready var grid_map: GridMap = $"../GridMap"
@onready var status_label: Label = $"../CanvasLayer/Label"
@onready var message_window: PanelContainer = $"../CanvasLayer/PanelContainer"
@onready var enemy_graphic: TextureRect = $"../CanvasLayer/EnemyGraphic"
# 1つのオーディオプレイヤーを使い回します
@onready var sfx_player: AudioStreamPlayer = $SfxPlayer
@onready var battle_text: Label = $"../CanvasLayer/PanelContainer/BattleMenu/BattleText"
@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:
update_player_status()
if message_window:
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
steps_since_last_fight = 0
attack_button.pressed.connect(_on_attack_button_pressed)
auto_button.pressed.connect(_on_auto_button_pressed)
flee_button.pressed.connect(_on_flee_button_pressed)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_state == GameState.BATTLE:
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
if cell_item_id == DOOR_ITEM_ID:
if not opened_doors.has(map_coord):
interact_with_door(map_coord)
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_cell_id == STAIRS_ITEM_ID:
use_stairs()
return
if current_cell_id == DOOR_ITEM_ID and not opened_doors.has(current_map_coord):
return
var event_triggered: bool = check_coordinate_event()
if not event_triggered:
check_random_encounter()
# 💡 【ロジック変更】使い回しプレイヤーに「ドアの音」をセットして鳴らす
func interact_with_door(door_coord: Vector3i) -> void:
opened_doors[door_coord] = true
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)
print("ドア消去。座標: ", door_coord)
# 💡 【ロジック変更】使い回しプレイヤーに「階段の音」をセットして鳴らす
func use_stairs() -> void:
current_floor += 1
steps_since_last_fight = 0
# 💡 階段の音に差し替えて再生!
if sfx_player and SFX_STAIRS:
sfx_player.stream = SFX_STAIRS # 音声データを階段用に差し替え
sfx_player.play()
# 💡 メッセージ内の表記も「B2」のグローバル形式に統一
show_explore_message("階段を下りた…… 🕳️\nここは B%d だ。\n\n(Wボタンでメッセージを閉じる)" % current_floor)
update_ui_display()
print("階層が切り替わりました。現在のフロア: B", current_floor)
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():
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):
if forward_vector.z < -0.5:
current_dir = Direction.NORTH
else:
current_dir = Direction.SOUTH
else:
if forward_vector.x > 0.5:
current_dir = Direction.EAST
else:
current_dir = Direction.WEST
update_ui_display()
# 💡 【表記変更】常時表示UIの「B1F」から、世界標準の「B1」表記へリファクタリング
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"]:
if triggered_events.has(current_grid_pos) and triggered_events[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()
print("歩数: %d | 遭遇確率: %d%% | 出目: %d%%" % [steps_since_last_fight, int(current_chance * 100), int(roll * 100)])
if roll < current_chance:
trigger_battle_encounter()
func show_explore_message(text: String) -> void:
battle_text.text = text
attack_button.hide()
auto_button.hide()
flee_button.hide()
message_window.show()
is_showing_message = true
func trigger_battle_encounter() -> void:
current_state = GameState.BATTLE
steps_since_last_fight = 0
battle_text.text = "ゴブリンが現れた!\nコマンドを選択してください。"
attack_button.show()
auto_button.show()
flee_button.show()
message_window.show()
if enemy_graphic:
enemy_graphic.show()
attack_button.grab_focus()
func close_message_window() -> void:
message_window.hide()
is_showing_message = false
func _on_attack_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "パーティメンバーのコマンドへ (未実装)"
func _on_auto_button_pressed() -> void:
if current_state != GameState.BATTLE: return
battle_text.text = "オートバトルへ (未実装)"
func _on_flee_button_pressed() -> void:
if current_state != GameState.BATTLE: return
current_state = GameState.EXPLORE
message_window.hide()
if enemy_graphic:
enemy_graphic.hide()
print("無事に逃げ切った!探索に戻ります。")コード (camera_3d.gd) を上記のように書き換えます。
この記事の段階では、階段マスに移動したときにメッセージと音が出るだけです。次の記事で違うフロアに遷移する処理を行います。
コメント