Godotで3Dゲームを作っていると、デバッグ中に「ステージ全体を自由に見回したい!」という場面がよくありますよね。
でも素直にやろうとすると…

  • プレイヤーに追従する Camera3D と、フリーカメラ用の Camera3D を別々に用意して切り替える
  • あるシーンでは Player にカメラが直付け、別シーンでは World にカメラがある…など構造がバラバラ
  • 「このシーンだけフリーカメラ欲しい」たびに、新しいカメラノードとスクリプトをコピペ

と、わりとカオスになりがちです。
Godot標準のやり方だと、「プレイヤーにカメラを継承でくっつける」「シーンごとにカメラ構造が違う」みたいな設計になりやすく、後からメンテするときに地味にツラいんですよね。

そこで今回は、「どのシーンにもポン付けできる自由カメラコンポーネント」を用意して、プレイヤーからカメラを切り離し、WASDでステージ全体を見回せるようにしてしまいましょう。
カメラ自体は 1つのコンポーネント として設計しておき、必要なシーンにアタッチするだけ、という構成にしておくとかなりスッキリします。

【Godot 4】デバッグが一気に快適!「FreeCamera」コンポーネント

今回作る FreeCamera コンポーネントは、ざっくり言うとこんな機能を持った「カメラ制御専用ノード」です。

  • WASD + Space + Ctrl で自由移動(フライカメラ)
  • マウスで視点回転(FPS 風のカメラ操作)
  • プレイヤー追従カメラと簡単に切り替えられる(アクティブ切り替え)
  • 移動速度・回転速度・感度などをインスペクタから調整可能
  • 「ゲーム中は封印、デバッグ時だけ有効」みたいな運用もできる

継承ベースで「FreeCamera付きプレイヤー」を増やすのではなく、FreeCamera という独立コンポーネントをシーンに1つ置くだけにしておくことで、どのプロジェクトにも持ち回しできるようにします。


GDScriptフルコード:FreeCamera.gd


extends Node3D
class_name FreeCamera
## FreeCamera コンポーネント
## - 自身の子にある Camera3D を操作して、WASD + マウスで自由に動かせるようにする
## - デバッグ用フリーカメラとして、どのシーンにもポン付けできる

@export_group("基本設定")
## このコンポーネントが有効かどうか
@export var enabled: bool = true

## このカメラを有効化する入力アクション(例: "toggle_free_camera")
## 空文字の場合は常に有効(切り替えなし)
@export var toggle_action: StringName = &"toggle_free_camera"

## 有効化されたときに Input.set_mouse_mode をキャプチャにするか
@export var capture_mouse_on_enable: bool = true

## 無効化されたときにマウスモードを可視に戻すか
@export var release_mouse_on_disable: bool = true

@export_group("移動設定")
## 通常時の移動速度(ユニット / 秒)
@export var move_speed: float = 10.0

## Shift 押下時の倍率
@export var sprint_multiplier: float = 3.0

## マウスホイールで速度を増減するか
@export var use_wheel_speed_adjust: bool = true

## ホイール1ノッチあたりの速度変化量
@export var wheel_speed_step: float = 2.0

## 最小速度
@export var min_speed: float = 1.0

## 最大速度
@export var max_speed: float = 100.0

@export_group("回転設定")
## マウス感度(値が大きいほど速く回転)
@export var mouse_sensitivity: float = 0.003

## 上下方向の回転制限(ラジアン)。例: 1.5 ≒ 約86度
@export var max_pitch: float = 1.5

## マウス右ボタンを押している間だけ回転するモード
@export var rotate_only_when_right_mouse: bool = true

@export_group("入力アクション名")
## 前後左右・上下移動用のアクション名
## InputMap で設定しておくこと
@export var action_move_forward: StringName = &"freecam_forward"
@export var action_move_back: StringName    = &"freecam_back"
@export var action_move_left: StringName    = &"freecam_left"
@export var action_move_right: StringName   = &"freecam_right"
@export var action_move_up: StringName      = &"freecam_up"
@export var action_move_down: StringName    = &"freecam_down"
@export var action_sprint: StringName       = &"freecam_sprint"

## 内部状態
var _yaw: float = 0.0   ## 左右回転
var _pitch: float = 0.0 ## 上下回転
var _camera: Camera3D
var _is_active: bool = false

func _ready() -> void:
    # 子ノードから Camera3D を自動取得
    _camera = _find_camera()
    if _camera == null:
        push_warning("FreeCamera: 子に Camera3D が見つかりません。このノードの子に Camera3D を追加してください。")
        return

    # 初期の向きから yaw / pitch を計算
    var basis := global_transform.basis
    var euler := basis.get_euler()
    _yaw = euler.y
    _pitch = euler.x

    # enabled が true なら、最初からアクティブにする
    if enabled:
        _set_active(true)
    else:
        _set_active(false)


func _unhandled_input(event: InputEvent) -> void:
    # トグル用アクションが設定されている場合は、押下で有効/無効を切り替え
    if toggle_action != &"" and event.is_action_pressed(toggle_action):
        enabled = !enabled
        _set_active(enabled)

    if not _is_active:
        return

    # マウスの移動で回転
    if event is InputEventMouseMotion:
        _handle_mouse_motion(event)

    # マウスホイールで速度調整
    if use_wheel_speed_adjust and event is InputEventMouseButton:
        _handle_mouse_wheel(event)


func _process(delta: float) -> void:
    if not _is_active or _camera == null:
        return

    _handle_movement(delta)


func _set_active(active: bool) -> void:
    _is_active = active
    if _camera:
        _camera.current = active

    if active:
        if capture_mouse_on_enable:
            Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
    else:
        if release_mouse_on_disable:
            Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)


func _handle_mouse_motion(event: InputEventMouseMotion) -> void:
    # 右クリックしている時だけ回転、というモード
    if rotate_only_when_right_mouse:
        if not Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
            return

    # マウスの相対移動から角度を更新
    _yaw   -= event.relative.x * mouse_sensitivity
    _pitch -= event.relative.y * mouse_sensitivity

    # ピッチを制限(上下向きすぎ防止)
    _pitch = clampf(_pitch, -max_pitch, max_pitch)

    # 回転を反映
    rotation = Vector3(_pitch, _yaw, 0.0)


func _handle_movement(delta: float) -> void:
    var input_dir := Vector3.ZERO

    if action_move_forward != &"" and Input.is_action_pressed(action_move_forward):
        input_dir -= Vector3.FORWARD
    if action_move_back != &"" and Input.is_action_pressed(action_move_back):
        input_dir += Vector3.FORWARD
    if action_move_left != &"" and Input.is_action_pressed(action_move_left):
        input_dir -= Vector3.RIGHT
    if action_move_right != &"" and Input.is_action_pressed(action_move_right):
        input_dir += Vector3.RIGHT
    if action_move_up != &"" and Input.is_action_pressed(action_move_up):
        input_dir += Vector3.UP
    if action_move_down != &"" and Input.is_action_pressed(action_move_down):
        input_dir -= Vector3.UP

    if input_dir == Vector3.ZERO:
        return

    input_dir = input_dir.normalized()

    # カメラの向きに対して相対的に動く
    # basis は Node3D の向き。Z が前、X が右、Y が上
    var basis := global_transform.basis
    var move_vec := (
        basis.z * input_dir.z +
        basis.x * input_dir.x +
        basis.y * input_dir.y
    )

    var speed := move_speed
    if action_sprint != &"" and Input.is_action_pressed(action_sprint):
        speed *= sprint_multiplier

    global_position += move_vec * speed * delta


func _handle_mouse_wheel(event: InputEventMouseButton) -> void:
    if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
        move_speed = clampf(move_speed + wheel_speed_step, min_speed, max_speed)
    elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
        move_speed = clampf(move_speed - wheel_speed_step, min_speed, max_speed)


func _find_camera() -> Camera3D:
    # 直下の子から Camera3D を探す。なければツリー全体から検索
    for child in get_children():
        if child is Camera3D:
            return child

    # 念のため、少しだけ柔軟に検索
    var cameras := get_tree().get_nodes_in_group(&"FreeCamera_Internal_Search")
    # ↑ 実際には group を使わず、単純に再帰探索してもOK
    # ここでは単純化のために再帰探索を自前で書く
    return _find_camera_recursive(self)


func _find_camera_recursive(node: Node) -> Camera3D:
    for child in node.get_children():
        if child is Camera3D:
            return child
        var found := _find_camera_recursive(child)
        if found:
            return found
    return null

※補足: _find_camera() は「このノードの子から Camera3D を1つ見つける」ための関数です。
シンプルにしたい場合は、for child in get_children() の部分だけにして、再帰探索を削ってしまってもOKです。


使い方の手順

ここからは、実際のシーンに FreeCamera コンポーネントを組み込んでみましょう。

手順①:InputMap を設定する

まずは プロジェクト設定 > Input Map で、フリーカメラ用の入力アクションを追加します。

  • freecam_forward : W
  • freecam_back : S
  • freecam_left : A
  • freecam_right : D
  • freecam_up : Space
  • freecam_down : Ctrl / C / Q など好み
  • freecam_sprint : Shift
  • toggle_free_camera : F1 など適当なキー

これで、スクリプト内の @export var action_*** と名前が一致するようになります。

手順②:FreeCamera シーンを作る

  1. 新規シーン を作成し、ルートに Node3D を追加します。
  2. ルートノードの名前を FreeCamera に変更します。
  3. 子ノードとして Camera3D を追加します。
  4. ルートの FreeCamera に、上記の FreeCamera.gd をアタッチします。
  5. シーンを FreeCamera.tscn として保存します。

シーン構成はこんな感じになります。

FreeCamera (Node3D)
 └── Camera3D

手順③:ゲームシーンに組み込む(例:プレイヤー + ステージ)

たとえば、プレイヤーが歩き回れる 3D ステージのシーンがあるとします。

World (Node3D)
 ├── Player (CharacterBody3D)
 │    ├── MeshInstance3D
 │    └── PlayerCamera (Camera3D)  # 通常の追従カメラ
 ├── Level (Node3D)
 │    ├── StaticBody3D
 │    └── MeshInstance3D
 └── FreeCamera (Node3D)           # <-- ここに今回のコンポーネントを配置
      └── Camera3D

使い方はシンプルで、World シーンのどこかに FreeCamera シーンをインスタンス するだけです。
特に Player に紐づける必要もなく、World のルート直下に置いておけば OK です。

  • ゲーム開始時は PlayerCameracurrent = true にしておく
  • FreeCamera の enabled を true にしておく
  • toggle_free_camera アクション(例: F1)を押すと、FreeCamera が有効化されて current が切り替わる

この構成だと、

  • 普段はプレイヤー視点でプレイ
  • F1 を押すとフリーカメラに切り替え、WASD + マウスでステージ全体を見回す
  • もう一度 F1 を押すと元のプレイヤーカメラに戻る

というデバッグフローが簡単に実現できます。

手順④:デバッグ用だけにしたい場合

「本番ビルドでは FreeCamera を完全に無効化したい」という場合は、いくつか方法があります。

  • 方法1: プロジェクト設定の Feature Tags を使って、OS.has_feature("editor") のときだけ enabled = true にする
  • 方法2: デバッグ用の WorldDebug.tscn だけに FreeCamera を入れておき、製品版では別のシーンを使う

たとえば、World のスクリプトでこんな感じにしておくと、「エディタで実行したときだけフリーカメラ有効」にできます。


func _ready() -> void:
    var freecam := $FreeCamera
    if freecam:
        freecam.enabled = OS.has_feature("editor")

メリットと応用

FreeCamera をコンポーネントとして切り出しておくと、継承ベースでカメラ機能を増やしていく場合に比べて、いろいろと嬉しいポイントがあります。

1. シーン構造がシンプルになる

プレイヤー側は「自分のことだけ考えればOK」で、カメラのことは FreeCamera に丸投げできます。

  • Player シーンは「移動・アニメーション・当たり判定」だけに集中
  • カメラ周りのロジックは World シーンにある FreeCamera が担当

「プレイヤーに Camera3D を継承でくっつけて、さらにデバッグカメラを別スクリプトで…」という深い階層や複雑な継承関係を避けられるのがポイントですね。

2. どのプロジェクトにもコピペで持ち込める

FreeCamera は Node3D + Camera3D という非常に薄い構成なので、

  • 別プロジェクトに FreeCamera.tscnFreeCamera.gd をフォルダごとコピー
  • InputMap に数個アクションを追加
  • World シーンにインスタンス

だけで即使い回せます。
「このゲームでもあのゲームでも同じフリーカメラが欲しい」というときに、継承ベースだとけっこう面倒ですが、コンポーネントとして独立していれば本当に楽です。

3. レベルデザインが圧倒的にやりやすくなる

3D ステージを作っていると、「この崖の裏側ちゃんと見えてる?」「ライトの当たり方どうなってる?」みたいな確認を何度もします。
FreeCamera があると、

  • プレイヤーが行けない高さや裏側も即チェック
  • ライトやエフェクトの見え方をカメラ位置を変えながら調整
  • ナビメッシュやコリジョンの配置を真上から確認

といった作業が一気に快適になります。
「レベルデザイナー向けのツール」としてもかなり有用ですね。

4. 応用例:シネマティックカメラとの併用

FreeCamera はあくまで「操作系を提供するコンポーネント」なので、他のカメラと組み合わせるのも簡単です。

  • 通常時:プレイヤー追従カメラ
  • イベント時:シネマティックカメラ(パス上を動く Camera3D)
  • デバッグ時:FreeCamera

といった感じで、カメラの役割ごとにシーンを分割 しておくと、管理もしやすくなります。


改造案:任意キーでプレイヤー位置にテレポートする

最後に、ちょっとした改造案として「任意のキーを押したら、FreeCamera をプレイヤーの位置にワープさせる」機能を追加してみましょう。
レベルの特定ポイントからすぐに見回したいときに便利です。

FreeCamera.gd に、例えばこんな関数を追加します(teleport_to_player_action というアクション名も InputMap に追加しておきましょう)。


@export_group("テレポート設定")
@export var teleport_to_player_action: StringName = &"freecam_to_player"
@export var player_path: NodePath = ^"../Player" # プレイヤーのパス

func _unhandled_input(event: InputEvent) -> void:
    # 既存の処理はそのまま…
    if toggle_action != &"" and event.is_action_pressed(toggle_action):
        enabled = !enabled
        _set_active(enabled)

    if teleport_to_player_action != &"" and event.is_action_pressed(teleport_to_player_action):
        _teleport_to_player()

    if not _is_active:
        return

    if event is InputEventMouseMotion:
        _handle_mouse_motion(event)

    if use_wheel_speed_adjust and event is InputEventMouseButton:
        _handle_mouse_wheel(event)


func _teleport_to_player() -> void:
    var player := get_node_or_null(player_path)
    if player and player is Node3D:
        global_transform.origin = player.global_transform.origin
    else:
        push_warning("FreeCamera: player_path に Node3D が見つかりません。")

こうしておくと、

  • ゲーム中にプレイヤーを動かす
  • FreeCamera を有効化
  • freecam_to_player アクション(例: F2)を押す

だけで、「今のプレイヤー位置から自由カメラで見回す」ことができます。


FreeCamera のような「小さくて独立したコンポーネント」を積み重ねていくと、
継承や巨大なベースシーンに頼らなくても、柔軟で再利用しやすい Godot プロジェクトを組んでいけます。
ぜひ自分のワークフローに合わせて、移動ロジックやショートカットキーをどんどんカスタマイズしてみてください。