Godot 4でトップダウンや横スクロールのゲームを作っていると、
- プレイヤーが壁の裏に隠れると見えなくなる
- でも、当たり判定はそのままだから操作はできる
- 結果として「今どこにいるのか分からない」ストレスが発生する
よくある解決策としては、
- プレイヤー専用のシルエット用シーンを別で作る
- プレイヤーシーンを継承して「シルエット版プレイヤー」を作る
- 壁の裏かどうかをプレイヤー自身のスクリプトに直接書く
…といった「継承+巨大スクリプト」パターンになりがちです。
でもこれをやり始めると、
- プレイヤーと敵とNPCそれぞれに同じようなシルエット処理をコピペする
- 壁の判定ロジックを変えたくなったら全部のスクリプトを修正
- 結局、ノード階層もスクリプトも肥大化していく
そこで今回は、「継承より合成」の方針で、どんなキャラクターにもポン付けできる
「Silhouette」コンポーネント
を作ってみましょう。
プレイヤーでも敵でも、CharacterBody2D でも Node2D でも、横に 1 ノード足すだけで「壁の裏に隠れたら手前に単色シルエット表示」ができるようにします。
【Godot 4】壁の裏でも見失わない!「Silhouette」コンポーネント
このコンポーネントはざっくりいうと、
- 対象キャラのスプライトを複製して単色マテリアルを適用
- 「壁レイヤー」との重なりをチェックして「隠れているか」を判定
- 隠れている間だけ、壁の手前の CanvasLayer にシルエットを表示
という仕組みで動きます。
壁や障害物の判定は Physics2DDirectSpaceState のレイキャストを使い、プレイヤー本体のスクリプトには一切手を入れません。
フルコード(GDScript / Godot 4)
extends Node2D
class_name Silhouette
## 壁の裏に隠れたとき、対象ノードのシルエットを前面に単色表示するコンポーネント。
##
## 想定:
## - このノードは「対象キャラ」の子として追加する(例: Player の子)。
## - 対象キャラの見た目用ノード(Sprite2D, AnimatedSprite2D など)を参照して
## そのコピーをシルエットとして描画する。
##
## ポイント:
## - 「壁レイヤー」をレイキャストで判定し、プレイヤーとカメラの間に壁があるかをチェック。
## - 隠れているときだけ CanvasLayer 上にシルエットを表示(壁より前面に来る)。
@export_group("基本設定")
## シルエットを生成する対象ノード(通常はこのコンポーネントの親)
@export var target_node: Node2D
## 対象の見た目ノード(Sprite2D / AnimatedSprite2D / AnimatedSprite2D の親など)
## 未指定の場合は target_node の子から Sprite2D / AnimatedSprite2D を自動検出する
@export var visual_node: Node2D
## シルエットの色
@export var silhouette_color: Color = Color(1, 1, 1, 0.8)
## 壁として扱うコリジョンレイヤー(物理レイヤーのビットフラグ)
## 例: 壁が 2 番レイヤーにあるなら 1 << 1 = 2
@export_flags_2d_physics var wall_collision_layer: int = 1 << 1
## シルエットを描画する CanvasLayer のレイヤー番号
## 0 より大きい値にすると、通常のゲームプレイレイヤーより前面に描画されやすくなります
@export var canvas_layer_index: int = 10
@export_group("判定設定")
## レイキャストの開始位置をどこから飛ばすか
## - "camera": カメラの位置(2D カメラがある場合)
## - "screen_center": ビューポートの中央
## - "custom": custom_ray_origin を使用
@export_enum("camera", "screen_center", "custom") var ray_origin_mode: String = "camera"
## ray_origin_mode が "custom" のときに使う開始位置(ローカル座標ではなくワールド座標)
@export var custom_ray_origin: Vector2 = Vector2.ZERO
## レイキャストのマージン(少しだけ手前を狙うことで、めり込みによる誤判定を減らす)
@export var ray_margin: float = 2.0
## 壁の裏に「何フレーム以上」隠れていたらシルエットを表示するか
## (一瞬だけ壁をかすめたときのチラつき防止)
@export_range(0, 60, 1) var frames_to_show: int = 3
## 壁の裏から「何フレーム連続で見えていたら」シルエットを消すか
@export_range(0, 60, 1) var frames_to_hide: int = 5
@export_group("デバッグ")
## デバッグ用にレイキャストラインを描画するか
@export var debug_draw: bool = false
var _canvas_layer: CanvasLayer
var _silhouette_root: Node2D
var _is_hidden_by_wall: bool = false
var _hidden_frames: int = 0
var _visible_frames: int = 0
func _ready() -> void:
if target_node == null:
# デフォルトで親ノードを対象にする
var parent = get_parent()
if parent is Node2D:
target_node = parent
else:
push_warning("Silhouette: target_node が指定されておらず、親も Node2D ではありません。明示的に設定してください。")
if visual_node == null and target_node != null:
visual_node = _find_visual_node(target_node)
if visual_node == null:
push_warning("Silhouette: visual_node が見つかりませんでした。Sprite2D / AnimatedSprite2D を指定してください。")
_setup_canvas_layer()
_create_silhouette_instance()
set_process(true)
func _process(_delta: float) -> void:
if target_node == null or visual_node == null:
return
var hidden_now := _check_hidden_by_wall()
_update_state(hidden_now)
_sync_silhouette_transform()
if debug_draw:
queue_redraw()
func _draw() -> void:
if not debug_draw or target_node == null:
return
var from_pos := _get_ray_origin()
var to_pos := target_node.global_position
draw_line(to_pos, from_pos, Color(1, 0, 0, 0.6), 1.0)
# ============================================================
# セットアップ系
# ============================================================
func _setup_canvas_layer() -> void:
# シーンツリーのルートに CanvasLayer を生成し、そこにシルエットを描画する
# (壁の手前に出したいので、通常のゲームレイヤーよりも前面の layer を使う)
_canvas_layer = CanvasLayer.new()
_canvas_layer.layer = canvas_layer_index
_canvas_layer.name = "SilhouetteCanvasLayer"
# ルートに直接ぶら下げる
get_tree().root.add_child(_canvas_layer)
_silhouette_root = Node2D.new()
_silhouette_root.name = "SilhouetteRoot"
_canvas_layer.add_child(_silhouette_root)
func _create_silhouette_instance() -> void:
if visual_node == null:
return
# 既存の子をクリア(再生成時用)
for c in _silhouette_root.get_children():
c.queue_free()
var silhouette_node: Node2D = null
if visual_node is Sprite2D:
silhouette_node = _create_sprite_silhouette(visual_node)
elif visual_node is AnimatedSprite2D:
silhouette_node = _create_animated_sprite_silhouette(visual_node)
else:
push_warning("Silhouette: 未対応の visual_node タイプです: %s" % [visual_node])
if silhouette_node:
_silhouette_root.add_child(silhouette_node)
_silhouette_root.visible = false
func _create_sprite_silhouette(sprite: Sprite2D) -> Sprite2D:
var s := Sprite2D.new()
s.texture = sprite.texture
s.region_enabled = sprite.region_enabled
s.region_rect = sprite.region_rect
s.centered = sprite.centered
s.flip_h = sprite.flip_h
s.flip_v = sprite.flip_v
s.offset = sprite.offset
s.scale = sprite.scale
s.rotation = sprite.rotation
# 単色で塗りつぶすマテリアルを作成
var mat := ShaderMaterial.new()
mat.shader = _get_silhouette_shader()
mat.set_shader_parameter("silhouette_color", silhouette_color)
s.material = mat
return s
func _create_animated_sprite_silhouette(anim: AnimatedSprite2D) -> AnimatedSprite2D:
var a := AnimatedSprite2D.new()
a.sprite_frames = anim.sprite_frames
a.animation = anim.animation
a.playing = true
a.flip_h = anim.flip_h
a.flip_v = anim.flip_v
a.centered = anim.centered
a.offset = anim.offset
a.scale = anim.scale
a.rotation = anim.rotation
var mat := ShaderMaterial.new()
mat.shader = _get_silhouette_shader()
mat.set_shader_parameter("silhouette_color", silhouette_color)
a.material = mat
# アニメ同期用に、元の AnimatedSprite2D の animation_changed シグナルを拾ってもよい
anim.animation_changed.connect(func():
a.animation = anim.animation
)
anim.frame_changed.connect(func():
a.frame = anim.frame
)
return a
func _get_silhouette_shader() -> Shader:
# 単色シルエット用のシンプルな 2D シェーダー
var code := """
shader_type canvas_item;
uniform vec4 silhouette_color : source_color;
void fragment() {
vec4 tex = texture(TEXTURE, UV);
// 元テクスチャのアルファを使って形だけを抜き出す
float alpha = tex.a;
COLOR = vec4(silhouette_color.rgb, silhouette_color.a * alpha);
}
"""
var shader := Shader.new()
shader.code = code
return shader
func _find_visual_node(root: Node) -> Node2D:
# 子孫から Sprite2D / AnimatedSprite2D を探す
for child in root.get_children():
if child is Sprite2D or child is AnimatedSprite2D:
return child
var found := _find_visual_node(child)
if found:
return found
return null
# ============================================================
# 判定・状態更新
# ============================================================
func _get_ray_origin() -> Vector2:
match ray_origin_mode:
"camera":
var viewport := get_viewport()
var cam := viewport.get_camera_2d()
if cam:
return cam.global_position
# カメラがなければフォールバック
return viewport.get_visible_rect().get_center()
"screen_center":
var rect := get_viewport().get_visible_rect()
return rect.get_center()
"custom":
return custom_ray_origin
_:
return get_viewport().get_visible_rect().get_center()
func _check_hidden_by_wall() -> bool:
if target_node == null:
return false
var space_state := get_world_2d().direct_space_state
var from_pos := _get_ray_origin()
var to_pos := target_node.global_position
# ターゲットに向かってレイを飛ばすが、少し手前で止める
var dir := (to_pos - from_pos)
var dist := dir.length()
if dist <= 0.001:
return false
dir = dir.normalized()
var ray_to := from_pos + dir * max(dist - ray_margin, 0.0)
var query := PhysicsRayQueryParameters2D.create(from_pos, ray_to)
query.collision_mask = wall_collision_layer
var result := space_state.intersect_ray(query)
# 壁に当たっていれば「隠れている」とみなす
return result.size() > 0
func _update_state(hidden_now: bool) -> void:
if hidden_now:
_hidden_frames += 1
_visible_frames = 0
else:
_visible_frames += 1
_hidden_frames = 0
if not _is_hidden_by_wall and _hidden_frames >= frames_to_show:
_is_hidden_by_wall = true
_silhouette_root.visible = true
elif _is_hidden_by_wall and _visible_frames >= frames_to_hide:
_is_hidden_by_wall = false
_silhouette_root.visible = false
func _sync_silhouette_transform() -> void:
if target_node == null:
return
# シルエットの位置・回転・スケールをターゲットに追従させる
_silhouette_root.global_transform = target_node.global_transform
使い方の手順
ここからは、実際にプレイヤーに「Silhouette」コンポーネントを付ける手順を見ていきましょう。
手順①: スクリプトを用意する
- 上の
Silhouette.gdをそのままコピーして、res://components/Silhouette.gdなどに保存します。 - Godot エディタで開き、ツールバーの「スクリプト」→「クラスとして登録」されていることを確認します(
class_name Silhouetteがあるので自動登録されます)。
手順②: プレイヤーシーンにアタッチする
例として、シンプルな 2D プレイヤーのシーン構成はこんな感じだとします:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── Silhouette (Node2D) ← ここにコンポーネントを追加
- シーンツリーで
Playerを開く。 - 右クリック → 「子ノードを追加」 →
Node2Dを追加。 - 追加した
Node2Dを選択し、「ノード名」をSilhouetteに変更。 - インスペクタの「スクリプト」欄で
Silhouette.gdをアタッチ。
これで プレイヤー本体のスクリプトを一切いじらずに、シルエット機能を合成できました。
手順③: パラメータを設定する
Silhouette ノードを選択して、インスペクタで以下を設定しましょう。
- target_node
未設定なら自動で親ノード(Player)を対象にします。特別な理由がなければ空のままでOKです。 - visual_node
– Player の子にあるSprite2Dを指定
– 空のままでも、自動で Sprite2D / AnimatedSprite2D を探索してくれます。 - silhouette_color
– 例: 白っぽい半透明にしたいならColor(1, 1, 1, 0.8)(デフォルト)
– 赤い警告っぽくしたいならColor(1, 0, 0, 0.7)など。 - wall_collision_layer
– 壁のコリジョンレイヤーを設定します。
– 例: 壁用の Physics Layer を「2」にしているなら、値は2(= 1 << 1)。
– インスペクタではチェックボックス形式で選べるので、「Wall」レイヤーにチェックを入れればOKです。 - canvas_layer_index
– デフォルト10のままで大抵は問題ありません。
– UI より前に出したくない場合などは、他の CanvasLayer との兼ね合いで調整してください。 - ray_origin_mode
– 通常は"camera"でOK。カメラからプレイヤーに向けてレイを飛ばし、「間に壁があるか」を見るイメージです。
– カメラがないゲーム(固定画面など)なら"screen_center"でも良いです。
手順④: 壁のコリジョンレイヤーを合わせる
シルエット表示の肝は、「プレイヤーとカメラの間に壁レイヤーのコリジョンがあるかどうか」です。
壁として扱いたい TileMap / StaticBody2D / CollisionShape2D などのノードで、
- Collision > Layer を、Silhouette の
wall_collision_layerと同じレイヤーに設定する
これで、プレイヤーが壁の裏に隠れたときだけ、手前の CanvasLayer にシルエットが出るようになります。
他の例(敵キャラ・動く床など)
敵キャラに使う場合
Enemy (CharacterBody2D) ├── AnimatedSprite2D ├── CollisionShape2D └── Silhouette (Node2D)
- プレイヤーと同じ要領で
Silhouetteを子として追加。 visual_nodeにAnimatedSprite2Dを指定(または自動検出)。- これで、敵が壁の裏に隠れても、どこにいるか把握しやすくなります。
「動く床」のシルエット(ちょっと変わり種)
例えば、上に乗れるけど壁の裏を通る「動く足場」があるとします:
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── Silhouette (Node2D)
- 同じく
Silhouetteを追加し、visual_nodeにSprite2Dを指定。 - プレイヤー視点で「今どこに足場があるのか」を見せたいときに便利です。
このように、「何かが壁の裏に隠れたときにだけ前面にシルエットを出す」という振る舞いを、どんなノードにも再利用できます。
メリットと応用
この Silhouette コンポーネントを使うことで、次のようなメリットがあります。
- プレイヤーや敵のスクリプトが太らない
– シルエットのロジック(レイキャスト、CanvasLayer、シェーダーなど)を全部コンポーネント側に閉じ込めているので、
本体スクリプトは「移動」「攻撃」「AI」などのロジックに集中できます。 - ノード階層がシンプルなまま
– 「シルエット用の別シーン」を作らず、Silhouetteノードを 1 個足すだけ。
– プレイヤーも敵も NPC も、構成パターンが揃って見通しが良くなります。 - 再利用性が高い
– 「壁に隠れたら前面に単色表示」という振る舞いを、どのキャラにも同じように適用できます。
– シルエットの色やレイヤー、判定レイヤーをインスペクタから変えるだけでカスタマイズ可能です。 - テストがしやすい
– シルエットの挙動だけを単体でテストできるので、「プレイヤーのバグ」と「シルエットのバグ」が混ざりにくくなります。
コンポーネント指向でこういう「視覚的な補助機能」を切り出しておくと、
ゲームの後半で「やっぱり敵はシルエット出さないでいいや」となっても、ノードを 1 個消すだけで済みます。
継承ベースでガッツリ組んでしまうと、こういう仕様変更が地味に辛くなってきますね。
改造案:シルエットの点滅で「危険状態」を表現する
応用として、「HP が一定以下のときはシルエットを点滅させる」みたいな演出も簡単に追加できます。
例えば Silhouette コンポーネントに次のような関数を足してみましょう。
var _blink_timer: float = 0.0
var _blink_speed: float = 6.0
var _base_silhouette_alpha: float = 0.8
var _danger_blink: bool = false
## 危険状態の ON/OFF を外部から切り替える API
func set_danger_blink(enabled: bool) -> void:
_danger_blink = enabled
if not _danger_blink and _silhouette_root and _silhouette_root.get_child_count() > 0:
var node := _silhouette_root.get_child(0)
if node.material is ShaderMaterial:
node.material.set_shader_parameter("silhouette_color",
Color(silhouette_color.r, silhouette_color.g, silhouette_color.b, _base_silhouette_alpha))
func _process(delta: float) -> void:
._process(delta) # 既存処理を呼ぶ
if _danger_blink and _silhouette_root.visible and _silhouette_root.get_child_count() > 0:
_blink_timer += delta * _blink_speed
var alpha := 0.3 + 0.7 * abs(sin(_blink_timer))
var node := _silhouette_root.get_child(0)
if node.material is ShaderMaterial:
var col: Color = silhouette_color
col.a = alpha
node.material.set_shader_parameter("silhouette_color", col)
これで、外部(プレイヤーの HP 管理コンポーネントなど)から
$Silhouette.set_danger_blink(true)
と呼ぶだけで、「壁の裏に隠れている間だけ、点滅するシルエット」を表現できます。
こういう「表現の追加」も、コンポーネントが独立していると安全に拡張できて気持ちいいですね。
ぜひ、自分のプロジェクト用に色や判定ロジックをカスタマイズして、
「見失わないけど、うるさくない」ちょうど良いシルエット表示を作ってみてください。




