はじめに
Godot 4.x+Claude Code+MCPでスマホ横向き向けFPS視点の3D脱出ゲームを作る連載の第3回です。
前回までの記事はこちら。
- 環境構築とプロジェクト作成
- Claude Code + godot-mcp セットアップ
- 【Godot 4.x】3D脱出ゲームを作ってみる①〜3Dプロジェクト作成とシーン構成〜
- 【Godot 4.x】3D脱出ゲームを作ってみる②〜FPSプレイヤーの実装〜(前回)
今回は、家具をタップしてズームインする「インタラクトカメラ」機能を実装します。タップ判定の実装から、コライダーのすり抜けバグ、ズーム状態管理まで、Claude Codeとのやり取りをプロンプト単位で振り返る形式でまとめます。
この記事でやること
脱出のために部屋の中を移動して、怪しいところをタップするとズーム、というところまでの実装です。タップ検出とインタラクトRayの実装、タップ時にカメラがズームインする演出、有効なコライダーだけを判定する仕組みとそのバグ修正、ズーム中(GIMMICK状態)と通常時(NORMAL状態)の入力制御、そしてズーム解除時に元の位置へ戻るTween演出まで扱います。
想定環境
| 項目 | 内容 |
|---|---|
| OS | macOS(Apple Silicon) |
| Godot | 4.7 stable |
| Claude Code | 最新版 |
| godot-mcp | セットアップ済み |
| 解像度 | 1870×840(横向き) |
1. 下準備
前回までに配置したベッドルームのサンプルは家具が多かったので、棚は「bed_table_2」だけ残して削除しました。Main/Camera3Dはセットアップでの確認のために配置したものだったので、こちらも削除しています(Player/Camera3Dがゲーム進行上のメインカメラになります)。
ジョイスティック位置が画面端だったので、少し画面内に移動させました。
プロンプト:
ジョイスティックの位置をやや右上に
virtual_joystick.tscn のBase・Stickを右へ40px、上へ40pxずらしました(サイズは160×160/80×80のまま維持)。
ジョイスティックUI外でも移動してしまう不具合があったので、UIでのみ動かせるよう修正しました。
プロンプト:
ジョイスティックのUI外でもジョイスティックが反応して動いているようです
virtual_joystick.gd の起動判定が「画面左半分ならどこでも反応」になっていたのが原因でした。Base UIの表示位置を基準に、半径110px以内のタッチのみ有効化するよう修正しました。
2. タップでインタラクトRayを飛ばす
タップしたらRay(光線)を飛ばして、コライダーとの衝突判定をさせます。
プロンプト:
プレイヤーが画面をタップしたらタップした場所にRayを飛ばしてください。距離は3m。Colliderに当たればオブジェクト情報や位置をログに出力。
player.gd にタップ判定(移動20px未満・300ms以内の押下→解放をタップとして認識し、スワイプ/ジョイスティック操作と区別)を追加しました。タップ位置を camera.project_ray_origin / project_ray_normal でワールド座標のRayに変換し、2m先までを PhysicsRayQueryParameters3D で判定、Colliderに当たった場合は名前と位置をログ出力する実装を行いました。
タップとスワイプ/ジョイスティック操作を区別するには、押下から解放までの「移動量」と「経過時間」の両方を閾値判定するのが確実です。
3. top_drawerコライダーへのスクリプトアタッチ
棚上段の引き出しに対してコライダーを設置して、この引き出しがタップされた際の挙動をスクリプトに定義します。棚上段の引き出しをタップするとズームし、ジョイスティックを少しでも動かすと元に戻る、という流れを目指します。
プロンプト:
bed_table_2のtop_drawerのコライダーにスクリプトをアタッチしてください
top_drawer(bed_table_2 内、small_drawer.tscn のインスタンス)にはコライダー自体が存在しなかったため、scripts/top_drawer.gd(雛形スクリプト)を新規作成しました。bed_table_2.tscn の top_drawer に StaticBody3D(スクリプトをアタッチ)+ CollisionShape3D(BoxShape3D、サイズ 0.4 x 0.28 x 0.35)を追加し、[editable path="top_drawer"] を追加してエディタ上での編集を可能にしました。
インスタンス化されたシーンの内部ノードを個別に編集するには、[editable path="..."] の設定が必要です。これを忘れるとエディタ上で子ノードを追加・編集できません。
4. タップ時のカメラズーム移動先を定義
プロンプト:
コライダーがタップされた時の、カメラの移動位置をこのスクリプトに定義しておきます。Position:-1.094, 0.293, -4.814、Rotation:-36.5, 0.0, 0.0。タップされてそのコライダーにカメラ移動定義があれば、カメラを指定定義のポジションにスムーズに移動させて、指定の回転をさせてください
top_drawer.gd に camera_target_position / camera_target_rotation_degrees のexport変数を追加し、指定値(Position -1.094, 0.293, -4.814、Rotation -36.5, 0.0, 0.0)をデフォルトに設定しました。player.gd 側では、タップでヒットしたコライダーがこれらのプロパティを持っていれば、Tweenでカメラをそのポジション・回転へスムーズに移動させる _zoom_camera_to を追加しています。
# top_drawer.gd(抜粋)
extends StaticBody3D
@export var camera_target_position: Vector3 = Vector3(-1.094, 0.293, -4.814)
@export var camera_target_rotation_degrees: Vector3 = Vector3(-36.5, 0.0, 0.0)
どの程度ズームして、どの角度で移すかは、Main/PlayerをコピーしたMain/PlayerTestで実際にプレビューを見ながら確認して行きました。
Playerをコピーして、PlayerTestを複製します(Currentからはチェックを外します)。
3Dモードでカメラを動かしプレビューを見ながら、ズームした時のカメラの位置・回転を調整します。
インスペクターでPositionやRotationを確認します。
5. is_enabled == trueのStaticBody3Dのみ判定するように
管理しやすいように、Rayが当たるコライダーを限定するためのフラグを立てます。
コライダーとなるStaticBody3Dのインスペクターの「メタデータを追加」から追加できます。
プロンプト:
rayはis_enabled == true のStaticBody3Dのみ判定
top_drawer.gd に @export var is_enabled: bool = true を追加しました。player.gd の _interact_raycast を、当たったコライダーが StaticBody3D かつ is_enabled プロパティを持ち false の場合はそれを除外リストに追加して再度Rayを飛ばし、奥のコライダーを判定する(最大8回までループ)処理に変更しています。
6. バグ発生
タップしてもコライダーにRayが届かない不具合が発生し、ここでClaudeと何度かやりとりが発生しました。コライダーのすり抜け判定が効かない問題、ループ終了時に無効な結果を拾ってしまうバグ、スキップ分岐でのフラグ立てについての確認と、地味に手間取った箇所です。
プロンプト:
まだ、is_enabledが設定されていないコライダーに当たってしまいます bed_table_2>StaticBody3D
タップ用のインタラクトRayが、スクリプト(is_enabled プロパティ)を持たない構造物のコライダー(bed_table_2 > StaticBody3D)で止まってしまう問題でした。is_enabled == true が明示されているStaticBody3Dだけをインタラクト対象とし、それ以外(プロパティ自体がない、またはfalse)は自動でスキップして奥のコライダーを再判定するようロジックを変更しました。
プロンプト:
is_enabledの定義、設定に関わらず、result = space_state.intersect_ray(query)で最初にresultに代入されているので、if not result.is_empty():の判定に入ってしまってます
パススルー用のループが「スキップのみ」で規定回数尽きた場合、result に最後のスキップ対象コライダーの結果が残ったまま有効なヒットとして扱われるバグでした。found_valid_hit フラグを導入し、実際にレイが止まった場合のみ true にするよう変更しました。
プロンプト:
if collider is StaticBody3D and not ("is_enabled" in collider and collider.is_enabled):の判定内で、found_valid_hit = trueしないといけないかもしれないです
スキップ分岐内で found_valid_hit = true にすると、次のイテレーションで結果が空になった際に古いヒットを有効扱いしてしまい、直前で直したバグが再発することを確認しました。現状のロジック(非スキップ分岐でのみ true にする)が正しいと判断し、変更なしとしています。
プロンプト:
手前にis_enabledが設定されていないコライダーがあると、その奥のis_enabled=trueのコライダーに到達していないです
原因は PhysicsRayQueryParameters3D.exclude がgetterのたびに新しいArrayを返す仕様で、query.exclude.append(...) が内部データに反映されていなかったことでした。
exclude プロパティは参照渡しではなく、アクセスするたびに新しい配列が生成される点に注意が必要です。.append() で直接追加しても内部状態には反映されません。
ローカル変数 exclude_rids で除外リストを保持し、query.exclude = exclude_rids で明示的に再代入(setter呼び出し)するよう修正し、奥のコライダーまで正しく到達するようになりました。
7. bottom_drawerへのコライダー追加
プロンプト:
bed_table_2のbottom_drawerにis_enabled=trueを設定して、コライダーを追加
bed_table_2.tscn の bottom_drawer に、top_drawer と同様の構成(top_drawer.gd をアタッチした StaticBody3D + CollisionShape3D、is_enabled = true)を追加しました。あわせて [editable path="bottom_drawer"] も追加し、インスタンス内への子ノード追加を可能にしています。
8. bottom_drawer用ズーム座標の設定
棚下段の引き出しに対しても、上段の引き出しと同様の設定・調整を行いました。
プロンプト:
カメラの移動位置は-1.094、-0.212、-4.814:回転は-49.6、0.0、0.0
bottom_drawer側の StaticBody3D に camera_target_position = Vector3(-1.094, -0.212, -4.814)、camera_target_rotation_degrees = Vector3(-49.6, 0.0, 0.0) を設定しました。
9. bottom_drawer専用スクリプトへの分離
プロンプト:
bottom_drawer.gdとしてスクリプトを移管
scripts/bottom_drawer.gd を新規作成し、bed_table_2.tscn のbottom_drawer側 StaticBody3D のスクリプト参照を top_drawer.gd からこちらに切り替えました。デフォルト値には設定済みのズーム座標を反映しています。
同じ振る舞いのスクリプトでも、対象ごとに分離しておくと後からデフォルト値を個別に調整しやすくなります。共通処理が増えてきたら継承やComponent化を検討するとよさそうです。
10. グローバルなview_mode変数の定義
通常時と、オブジェクトをタップしてズームした時とでモードを分けます。こうしておくと、追々ギミックの実装が楽になります。
プロンプト:
グローバルな変数、view_modeを定義して、通常時はNORMAL, ズーム時はGIMMICKとしてください
autoload/game_state.gd を新規作成し、ViewMode 列挙型(NORMAL / GIMMICK)と view_mode 変数を定義しました。GameState としてAutoload登録し、player.gd の _zoom_camera_to 実行時に GameState.view_mode を GIMMICK に設定するようにしています。
# autoload/game_state.gd
extends Node
enum ViewMode { NORMAL, GIMMICK }
var view_mode: ViewMode = ViewMode.NORMAL
Autoload(シングルトン)に状態を持たせることで、プレイヤーやUIなど複数のスクリプトから同じ状態を参照・変更できるようになります。
11. GIMMICK中の入力制御
ズームモードから通常モードへどう戻すか少し悩みましたが、ジョイスティックで移動を行った際に通常モードへ戻る、という仕様にしました。
プロンプト:
GIMMICKの時は画面右側のスワイプで視点回転を無効、ジョイスティックで少しでも動かせばNORMALに戻す
_handle_drag の冒頭で GameState.view_mode == GIMMICK なら即returnし、スワイプでの視点回転を無効化しました。_physics_process でジョイスティック入力(move_dir != Vector2.ZERO)を検知した際に GIMMICK から NORMAL へ戻す処理を追加しています。
12. プレイヤー初期位置の確認
プロンプト:
プレイヤーの初期ポジションを定義しておいてください
具体的な座標が未指定だったため、初期位置・回転の希望値を確認しました。
13. プレイヤー初期位置の設定
プロンプト:
ポジションは0,0,0:回転も0,0,0、Player/Camera3Dは0,0.8,0:回転は0,0,0
main.tscn の Player ノードに位置 (0, 0, 0) ・回転 (0, 0, 0)、Player/Camera3D に位置 (0, 0.8, 0) ・回転 (0, 0, 0) を明示的なTransformとして設定しました。
14. NORMAL状態の位置・カメラの保存と復元
プロンプト:
NORMALからGIMMICKに移行時、NORMALの時のプレイヤーの位置、カメラ位置を保持しておいてください。GIMMICKからNORMALに戻った時復元してください
player.gd に _saved_player_position / _saved_player_rotation / _saved_camera_position / _saved_camera_rotation_degrees を追加しました。_zoom_camera_to 実行時(GIMMICK移行時)にNORMAL時点の状態を保存し、ジョイスティック入力でNORMALに戻す際に呼び出す _restore_normal_view 関数で、保存しておいた値へ即座に復元するようにしています。
15. 復元時のTween演出
プロンプト:
Tween演出をお願いします
_restore_normal_view を、ズームイン時と同じ TRANS_SINE / EASE_IN_OUT のTweenで CAMERA_ZOOM_DURATION(0.6秒)かけてプレイヤー位置・回転、カメラ位置・回転を滑らかに元へ戻すよう変更しました。
func _restore_normal_view():
var tween = create_tween()
tween.set_trans(Tween.TRANS_SINE)
tween.set_ease(Tween.EASE_IN_OUT)
tween.set_parallel(true)
tween.tween_property(self, "position", _saved_player_position, CAMERA_ZOOM_DURATION)
tween.tween_property(self, "rotation", _saved_player_rotation, CAMERA_ZOOM_DURATION)
tween.tween_property(camera, "position", _saved_camera_position, CAMERA_ZOOM_DURATION)
tween.tween_property(camera, "rotation_degrees", _saved_camera_rotation_degrees, CAMERA_ZOOM_DURATION)
GameState.view_mode = GameState.ViewMode.NORMAL
set_parallel(true) を使うことで、複数のプロパティを同時に(直列ではなく並行に)アニメーションさせられます。位置と回転を同時に戻したい今回のケースに適しています。
16. レビュー
ゲーム画面をデバッグしていて、コライダーへのタップ処理からTweenしてズームするまでの間にコライダーをタップすると反応してしまう(ズーム中は反応させたくない)点と、GIMMICKモード中はコライダーへのタップが発生しないようにすべき点(これが原因でズーム解除後、カメラ位置が戻らない不具合が発生していた)に気づいたので、Claudeに相談して修正してもらいました。
プロンプト:
タップ後〜tween終了、ジョイスティックで戻る〜tween終了まではRAYを無効にしてください
以前の実装で失われていたRay無効化のガードを再度追加しました。_camera_tweenを保持し、_interact_raycast冒頭でTween実行中なら即returnするガードのみを最小構成で再実装しています(Tween競合防止とジョイスティックのデッドゾーン修正は今回は含めていません)。
プロンプト:
GIMMICKモードの時もRayを無効に
_interact_raycast冒頭にGameState.view_mode == GameState.ViewMode.GIMMICKのチェックを追加しました。ズームイン完了後もNORMALに戻るまでの間は、タップしてもRayが判定されないようにしています。
成果物
たくさんあった棚を一つだけにしたシンプルな構成にしました。
ジョイスティック操作で近づきます。
棚の上段引き出しをタップして、ズーム。
いったんジョイスティック操作でズーム前に戻って、棚の下段引き出しをタップしてズームします。
今回は、タップ検出・インタラクトRayの実装から、ズームカメラの状態管理(GameState.view_mode)と復元演出まで、一連の実装をプロンプト単位で振り返りました。タップとスワイプ/ジョイスティックの区別は移動量と経過時間の両方で判定すること、is_enabled プロパティで判定対象を絞り込み無効なコライダーはすり抜けさせること、exclude がgetterのたびに新しい配列を返す仕様なので明示的な再代入が必要なこと、そして GameState(Autoload)でNORMAL/GIMMICKの状態を一元管理し、ズームイン・復元ともにTweenで滑らかに演出すること、あたりが今回のポイントでした。
次回は、ダイヤル錠UIの自作(シリンダー+数字テクスチャ、スワイプで回すダイヤル謎解きロジック)を実装します。
連載リンク










