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 でも手順はほぼ同じです。
手順①:スクリプトを用意してリソース化
Minimap.gdをプロジェクトの任意の場所(例:res://addons/components/minimap/Minimap.gd)に保存します。- 上記コードには
class_name Minimapがあるので、Godot 再起動後は「ノードを追加」ダイアログから Minimap として直接追加できるようになります。
手順②:UI シーンに Minimap を追加
プレイヤーとは別に UI 用のシーンを作るとスッキリします。例:
UIRoot (CanvasLayer) ├── Control │ └── Minimap (Control) ← このノードに Minimap.gd がアタッチされる └── その他の UI(HP バーなど)
- UIRoot は
CanvasLayerで、ゲーム画面とは別に UI を固定表示します。 - Control はレイアウトの親。ここに
Minimapノードを追加します。 Minimapノードには自動的にSubViewportとTextureRectが子として生成されます。
手順③:ターゲット(プレイヤー)を指定する
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 を作っていけますね。
