Godot 4 で 2D/3D ゲームを作っていると、ミニマップって作りたくなりますよね。でも素直に作ろうとすると:

  • プレイヤーシーンにミニマップ用のカメラや Sprite をゴリゴリ継承で追加していく
  • UI シーン側で SubViewport, TextureRect, Camera… と毎回同じようなノードを並べる
  • シーンごとにミニマップのカメラ設定をコピペして地味にズレていく

みたいな「なんか毎回同じことやってない?」という状態になりがちです。
しかも、プレイヤーの実装とミニマップの実装がベッタリくっついてしまうと、あとから UI だけ別シーンに分けたいときに面倒です。

そこで今回は、SubViewport + Camera2D/Camera3D をまるごとカプセル化した「Minimap」コンポーネントを作って、どのシーンにもポン付けでミニマップを生やせるようにしてみましょう。
「継承でミニマップ付きプレイヤーを作る」のではなく、プレイヤーや UI に「Minimap コンポーネント」をアタッチするだけの構成にしていきます。

【Godot 4】SubViewport でサクッと上空視点!「Minimap」コンポーネント

今回のコンポーネントのゴールは以下です:

  • ゲーム内の任意のカメラ(またはターゲット)を「上空から」映すミニマップカメラを自動生成
  • SubViewport の中でレンダリングし、そのテクスチャを UI 右上の TextureRect に表示
  • ズーム倍率・カメラの高さ・回転固定などを @export パラメータで調整可能
  • 2D/3D どちらでも使えるように、2D/3D 切り替えモードを用意

このコンポーネントは UI 側(CanvasLayer や Control シーン)にポンと置くだけで、指定したターゲットを追いかけるミニマップを自前で組み立ててくれます。


フルコード:Minimap.gd


extends Control
class_name Minimap
## Minimap コンポーネント
## - SubViewport + Camera(2D/3D) を内部で自動構築
## - 自身の Control の右上にミニマップを表示
## - 任意のターゲット(プレイヤーなど)を追従して上空から描画

@export_category("共通設定")

## 2D 用ミニマップか 3D 用ミニマップかを切り替え
@export_enum("2D", "3D") var mode: String = "2D"

## ミニマップで追いかけるターゲット
## - 2D の場合: Node2D / CharacterBody2D など
## - 3D の場合: Node3D / CharacterBody3D など
@export var target: NodePath

## ミニマップの表示サイズ(ピクセル)
@export var minimap_size: Vector2i = Vector2i(200, 200)

## ミニマップのマージン(右上基準)
@export var margin: int = 16

@export_category("2D 用設定")

## 2D モードでのズーム倍率(大きいほど広範囲)
@export_range(0.1, 10.0, 0.1) var zoom_2d: float = 2.5

## 2D モードでカメラを回転させるか(ターゲットの回転に追従)
@export var rotate_with_target_2d: bool = false

@export_category("3D 用設定")

## 3D モードでのカメラの上空高さ(Y 軸)
@export var height_3d: float = 30.0

## 3D モードでの真下を見る角度(度数)
@export_range(30.0, 90.0, 1.0) var pitch_deg_3d: float = 90.0

## 3D モードでのカメラの視野角
@export_range(10.0, 120.0, 1.0) var fov_3d: float = 45.0

## 3D モードでターゲットの Y 座標を無視して水平位置だけ追従するか
@export var lock_height_to_constant_3d: bool = true

@export_category("描画設定")

## ミニマップの背景色(クリアカラー)
@export var clear_color: Color = Color(0, 0, 0, 1)

## ミニマップの角を丸くするかどうか(TextureRect の corner_radius を利用)
@export var use_rounded_corners: bool = true

## 角丸の半径
@export var corner_radius: int = 12

## ミニマップの枠線色
@export var border_color: Color = Color(1, 1, 1, 0.8)

## ミニマップの枠線太さ
@export var border_thickness: int = 2

## 内部で使う SubViewport と TextureRect
var _viewport: SubViewport
var _texture_rect: TextureRect

## 2D / 3D 用のカメラ
var _camera_2d: Camera2D
var _camera_3d: Camera3D

func _ready() -> void:
    _setup_viewport()
    _setup_texture_rect()
    _setup_camera()
    _layout_to_top_right()

func _process(delta: float) -> void:
    _update_camera_transform()

func _setup_viewport() -> void:
    # SubViewport を生成して、この Minimap の子として追加
    _viewport = SubViewport.new()
    _viewport.name = "MinimapViewport"
    _viewport.size = minimap_size
    _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
    _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
    _viewport.transparent_bg = false
    _viewport.canvas_cull_mask = 0xFFFFFFFF
    _viewport.usage = SubViewport.USAGE_2D if mode == "2D" else SubViewport.USAGE_3D
    _viewport.gui_disable_input = true
    _viewport.disable_3d = (mode == "2D")
    _viewport.disable_2d = (mode == "3D")
    _viewport.handle_input_locally = false
    _viewport.clear_color = clear_color
    add_child(_viewport)

func _setup_texture_rect() -> void:
    # SubViewport のテクスチャを表示するための TextureRect を生成
    _texture_rect = TextureRect.new()
    _texture_rect.name = "MinimapTextureRect"
    _texture_rect.texture = _viewport.get_texture()
    _texture_rect.custom_minimum_size = minimap_size
    _texture_rect.stretch_mode = TextureRect.STRETCH_SCALE
    _texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
    _texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE

    # 見た目調整(角丸&枠線)
    var style := StyleBoxFlat.new()
    style.bg_color = Color(0, 0, 0, 0.3)
    style.border_color = border_color
    style.border_width_all = border_thickness
    if use_rounded_corners:
        style.corner_radius_top_left = corner_radius
        style.corner_radius_top_right = corner_radius
        style.corner_radius_bottom_left = corner_radius
        style.corner_radius_bottom_right = corner_radius
    _texture_rect.add_theme_stylebox_override("panel", style)

    add_child(_texture_rect)

func _setup_camera() -> void:
    if mode == "2D":
        _camera_2d = Camera2D.new()
        _camera_2d.name = "MinimapCamera2D"
        # ズームは Vector2。値が大きいほど広く映る
        _camera_2d.zoom = Vector2(zoom_2d, zoom_2d)
        _camera_2d.enabled = true
        _viewport.add_child(_camera_2d)
    else:
        _camera_3d = Camera3D.new()
        _camera_3d.name = "MinimapCamera3D"
        _camera_3d.current = true
        _camera_3d.fov = fov_3d
        _viewport.add_child(_camera_3d)

func _layout_to_top_right() -> void:
    # この Minimap 自身(Control)の中で右上に配置する
    anchor_left = 1.0
    anchor_top = 0.0
    anchor_right = 1.0
    anchor_bottom = 0.0

    # 右上からのマージン分だけ内側に
    position = Vector2(-margin - minimap_size.x, margin)
    size = minimap_size
    _texture_rect.position = Vector2.ZERO
    _texture_rect.size = minimap_size

func _get_target_node() -> Node:
    if target.is_empty():
        return null
    return get_node_or_null(target)

func _update_camera_transform() -> void:
    var t := _get_target_node()
    if t == null:
        return

    if mode == "2D":
        _update_camera_2d(t)
    else:
        _update_camera_3d(t)

func _update_camera_2d(t: Node) -> void:
    if _camera_2d == null:
        return

    # Node2D 系であれば position / rotation を使う
    if t is Node2D:
        var n2d := t as Node2D
        _camera_2d.global_position = n2d.global_position

        if rotate_with_target_2d:
            _camera_2d.rotation = n2d.global_rotation
        else:
            _camera_2d.rotation = 0.0
    else:
        # それ以外の場合は Transform2D の原点だけ追従(簡易対応)
        if t.has_method("get_global_transform"):
            var xf = t.call("get_global_transform")
            if xf is Transform2D:
                _camera_2d.global_position = xf.origin

func _update_camera_3d(t: Node) -> void:
    if _camera_3d == null:
        return

    if t is Node3D:
        var n3d := t as Node3D
        var pos := n3d.global_position

        # 高さを固定する場合は Y を上書き
        if lock_height_to_constant_3d:
            pos.y = 0.0

        # カメラの位置はターゲットの真上
        var cam_pos := pos + Vector3(0, height_3d, 0)
        _camera_3d.global_position = cam_pos

        # 真下を見るように回転
        var basis := Basis()
        basis = basis.rotated(Vector3.RIGHT, deg_to_rad(pitch_deg_3d))
        _camera_3d.global_transform = Transform3D(basis, cam_pos)
    else:
        # Node3D 以外は簡易対応(Transform3D の origin を使う)
        if t.has_method("get_global_transform"):
            var xf3d = t.call("get_global_transform")
            if xf3d is Transform3D:
                var pos3 := (xf3d as Transform3D).origin
                if lock_height_to_constant_3d:
                    pos3.y = 0.0
                var cam_pos3 := pos3 + Vector3(0, height_3d, 0)
                var basis3 := Basis()
                basis3 = basis3.rotated(Vector3.RIGHT, deg_to_rad(pitch_deg_3d))
                _camera_3d.global_transform = Transform3D(basis3, cam_pos3)

## --------------- 便利メソッド(任意で利用) ---------------

## ランタイムでターゲットを差し替えるためのヘルパー
func set_target(node: Node) -> void:
    if node == null:
        target = NodePath("")
    else:
        target = node.get_path()

## ミニマップのオン/オフ切り替え
func set_minimap_visible(visible: bool) -> void:
    self.visible = visible

使い方の手順

ここでは 2D アクションゲームのプレイヤーにミニマップを付ける例で説明します。3D でも手順はほぼ同じです。

手順①:スクリプトを用意してリソース化

  1. Minimap.gd をプロジェクトの任意の場所(例: res://addons/components/minimap/Minimap.gd)に保存します。
  2. 上記コードには class_name Minimap があるので、Godot 再起動後は「ノードを追加」ダイアログから Minimap として直接追加できるようになります。

手順②:UI シーンに Minimap を追加

プレイヤーとは別に UI 用のシーンを作るとスッキリします。例:

UIRoot (CanvasLayer)
 ├── Control
 │    └── Minimap (Control)  ← このノードに Minimap.gd がアタッチされる
 └── その他の UI(HP バーなど)
  • UIRootCanvasLayer で、ゲーム画面とは別に UI を固定表示します。
  • Control はレイアウトの親。ここに Minimap ノードを追加します。
  • Minimap ノードには自動的に SubViewportTextureRect が子として生成されます。

手順③:ターゲット(プレイヤー)を指定する

2D プレイヤーシーンの例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D (メインカメラ)

ゲームのルートシーンの例:

Main (Node2D)
 ├── Player (CharacterBody2D)
 ├── Level (TileMap / StaticBody2D など)
 └── UIRoot (CanvasLayer)
      └── Control
           └── Minimap (Control)

この構成で、Minimap ノードを選択し、インスペクタから以下を設定します:

  • mode: "2D"(2D ゲームの場合)
  • target: ../.. / Player など、プレイヤーノードへのパス
  • zoom_2d: 2.0〜4.0 あたりでお好みの広さに
  • rotate_with_target_2d: プレイヤーの向きに合わせてミニマップを回したい場合は ON

これだけで、プレイヤーの位置を中心にした上空視点のミニマップが UI 右上に表示されます。

手順④:3D ゲームでの使用例

3D の場合も基本は同じです。シーン例:

Player3D (CharacterBody3D)
 ├── MeshInstance3D
 ├── CollisionShape3D
 └── Camera3D (メインカメラ)
Main3D (Node3D)
 ├── Player3D (CharacterBody3D)
 ├── Level3D (StaticBody3D / MeshInstance3D など)
 └── UIRoot (CanvasLayer)
      └── Control
           └── Minimap (Control)

Minimap の設定:

  • mode: "3D"
  • target: ../.. / Player3D
  • height_3d: 20〜50 くらいで上空の高さを調整
  • pitch_deg_3d: 90 に近いほど真下を見る。斜め俯瞰にしたいなら 60〜75 くらい
  • lock_height_to_constant_3d: 高低差のあるステージでもミニマップ上では高さを無視したい場合は ON

あとはゲームを再生するだけで、プレイヤーの真上からのカメラ映像が UI 右上に出ます。SubViewport や Camera3D を自分で毎回組み立てる必要はありません。


メリットと応用

継承ではなくコンポーネントとしての Minimap を採用することで、次のようなメリットがあります。

  • プレイヤーの実装とミニマップの実装が分離される
    プレイヤーシーンにミニマップ用のノードを増やさなくて済むので、プレイヤーはあくまで「動きと見た目」に集中できます。
  • UI シーン側だけでミニマップのデザインを完結できる
    枠線や角丸、サイズなどは Minimap コンポーネントのパラメータだけで完結します。
  • 2D/3D を同じ API で扱える
    mode を切り替えるだけで 2D/3D 両方に対応できるので、「3D版も作りたい」となったときの差し替えが楽です。
  • シーン構造が浅く、見通しがよくなる
    「プレイヤーにミニマップを持たせる」のではなく、「UI に Minimap コンポーネントを持たせる」構成なので、ノードツリーが素直になります。

レベルデザイン的にも、「このシーンはミニマップなし」「このシーンはミニマップあり」といった切り替えが、Minimap ノードの有無だけで管理できるのが嬉しいところですね。

改造案:ミニマップの表示範囲を段階的に切り替える

例えば、プレイヤーが「望遠鏡アイテム」を取ったらミニマップの範囲を広くしたい、というケースを考えてみましょう。
2D モードの場合、zoom_2d をランタイムで変更するだけで実現できます。


## Minimap.gd 内に追記しても良いし、外部から呼んでも OK
func set_zoom_level_2d(level: int) -> void:
    # レベルごとにプリセットを用意
    match level:
        1:
            zoom_2d = 2.0
        2:
            zoom_2d = 3.5
        3:
            zoom_2d = 5.0
        _:
            zoom_2d = 2.0

    # すでにカメラが存在していれば即反映
    if _camera_2d:
        _camera_2d.zoom = Vector2(zoom_2d, zoom_2d)

これを使えば、ゲーム中のイベントに応じて:

  • ボス戦だけミニマップを広くする
  • ダンジョンに入ったらミニマップを狭くして「迷い感」を出す

といったギミックも簡単に作れます。
Minimap コンポーネントをベースに、レーダー表示や敵アイコンのオーバーレイなども、別コンポーネントとして積み上げていくと「継承地獄」を避けながらリッチな UI を作っていけますね。