【Godot 4】Silhouette (シルエット表示) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

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」コンポーネントを付ける手順を見ていきましょう。

手順①: スクリプトを用意する

  1. 上の Silhouette.gd をそのままコピーして、res://components/Silhouette.gd などに保存します。
  2. Godot エディタで開き、ツールバーの「スクリプト」→「クラスとして登録」されていることを確認します(class_name Silhouette があるので自動登録されます)。

手順②: プレイヤーシーンにアタッチする

例として、シンプルな 2D プレイヤーのシーン構成はこんな感じだとします:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Silhouette (Node2D)  ← ここにコンポーネントを追加
  1. シーンツリーで Player を開く。
  2. 右クリック → 「子ノードを追加」 → Node2D を追加。
  3. 追加した Node2D を選択し、「ノード名」を Silhouette に変更。
  4. インスペクタの「スクリプト」欄で 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_nodeAnimatedSprite2D を指定(または自動検出)。
  • これで、敵が壁の裏に隠れても、どこにいるか把握しやすくなります。

「動く床」のシルエット(ちょっと変わり種)

例えば、上に乗れるけど壁の裏を通る「動く足場」があるとします:

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Silhouette (Node2D)
  • 同じく Silhouette を追加し、visual_nodeSprite2D を指定。
  • プレイヤー視点で「今どこに足場があるのか」を見せたいときに便利です。

このように、「何かが壁の裏に隠れたときにだけ前面にシルエットを出す」という振る舞いを、どんなノードにも再利用できます。


メリットと応用

この 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)

と呼ぶだけで、「壁の裏に隠れている間だけ、点滅するシルエット」を表現できます。
こういう「表現の追加」も、コンポーネントが独立していると安全に拡張できて気持ちいいですね。

ぜひ、自分のプロジェクト用に色や判定ロジックをカスタマイズして、
「見失わないけど、うるさくない」ちょうど良いシルエット表示を作ってみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!