【Cocos Creator 3.8】Silhouette(シルエット表示)の実装:アタッチするだけで「壁の裏に隠れたキャラを単色シルエットで前面表示」する汎用スクリプト

キャラクターが壁や建物の裏に隠れて見えなくなると、プレイ感が悪くなりがちです。本記事では、キャラクターにアタッチするだけで「壁に隠れたときだけ、壁の手前に単色のシルエットを表示する」汎用コンポーネント Silhouette を実装します。

このコンポーネントは、他のカスタムスクリプトに一切依存せず、インスペクタで色や Z 座標などを調整するだけで使えるように設計します。


コンポーネントの設計方針

機能要件の整理

  • キャラクター(プレイヤー)ノードにアタッチして使う。
  • キャラ本体が「壁の裏に隠れた」とみなされたときだけ、壁の手前に単色シルエットを表示する。
  • シルエットはキャラと同じスプライト画像を使い、色・不透明度を自由に設定できる。
  • シルエットはキャラ本体とは別ノードとして自動生成し、SortingLayerID / SortingOrder ではなく Z 座標で前後関係を制御する(2D/3D混在でも扱いやすいように)。
  • 「壁の裏にいるかどうか」の判定は、カメラからキャラまでの間に壁レイヤーのコライダーがあるかを RayCast でチェックする。
  • 壁のレイヤーやカメラなど、「何を壁とみなすか」「どのカメラから見て判定するか」はすべて @property で指定できるようにする。
  • 他のカスタムスクリプト(GameManager など)には一切依存しない。

前提と必要コンポーネント

このコンポーネントは以下の前提で動作します。

  • キャラクターノードに Sprite または SpriteRenderer(2Dオブジェクト)を持つ。
  • シーンに Camera が存在し、インスペクタから参照を設定する。
  • 「壁」には Collider2D が付いており、特定の Layer を設定しておく(例:WALL)。

防御的な実装として、必要なコンポーネントが見つからない場合はログを出し、挙動を停止します。

インスペクタで設定可能なプロパティ

Silhouette コンポーネントは、以下のようなプロパティを持ちます。

  • enabledOnStart: boolean
    • 初期状態でシルエット機能を有効にするかどうか。
    • true の場合、ゲーム開始と同時に判定を開始。
  • camera: Camera
    • 「どのカメラから見て壁の裏か」を判定するためのカメラ。
    • 通常はメインカメラをドラッグ&ドロップで設定。
  • wallLayerMask: number
    • RayCast で「壁」とみなすレイヤーのビットマスク。
    • インスペクタでは Mask として表示されるので、WALL レイヤーなどをチェックして指定。
  • checkInterval: number
    • 壁の裏判定を行う間隔(秒)。
    • 例:0.05 なら 0.05 秒ごとに RayCast する。頻度を下げると軽くなるが反応がやや遅くなる。
  • silhouetteColor: Color
    • シルエットの単色カラー。
    • 通常は白やシアンなど、背景とコントラストのある色を指定。
  • silhouetteOpacity: number
    • シルエットの不透明度(0〜255)。
    • 半透明にしたい場合は 100〜180 程度。
  • silhouetteZOffset: number
    • シルエットをキャラよりどれだけ手前に出すか(Z オフセット)。
    • 例:0.1 ならキャラの Z + 0.1 に配置される。カメラが -10 などの位置にある 2D シーンで有効。
  • copyScale: boolean
    • キャラ本体の scale をシルエットにもコピーするかどうか。
    • アニメーションなどでスケールが変わる場合に true にしておくと見た目が揃う。
  • copyRotation: boolean
    • キャラ本体の rotation をシルエットにもコピーするかどうか。
    • 横向き・斜め向きなどのキャラで、向きを合わせたい場合に true。
  • debugLog: boolean
    • RayCast の結果やエラーをログに出すかどうか。
    • 開発中は true、本番では false 推奨。

TypeScriptコードの実装

以下が完成した Silhouette.ts の全コードです。


import { _decorator, Component, Node, Camera, Vec3, PhysicsSystem2D, EPhysics2DDrawFlags, Contact2DType, ERaycast2DType, Color, Sprite, UITransform, SpriteFrame, math, Layers, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * Silhouette
 * 壁の裏に隠れたときに、キャラと同じスプライトを使った単色シルエットを
 * カメラ手前に表示するコンポーネント。
 *
 * 前提:
 * - このノードに Sprite コンポーネントが付いていること。
 * - シーンに Camera が存在し、本コンポーネントの camera プロパティに設定されていること。
 * - 壁には Collider2D が付いており、wallLayerMask で指定した Layer に属していること。
 */
@ccclass('Silhouette')
export class Silhouette extends Component {

    @property({
        tooltip: 'ゲーム開始時にシルエット機能を有効にするかどうか。'
    })
    public enabledOnStart: boolean = true;

    @property({
        tooltip: '壁の裏判定に使用するカメラ。通常はメインカメラを指定します。'
    })
    public camera: Camera | null = null;

    @property({
        tooltip: 'RayCast で「壁」とみなす Layer のビットマスク。\nWALL レイヤーなど、壁に使うレイヤーをチェックしてください。'
    })
    public wallLayerMask: number = 0;

    @property({
        tooltip: '壁の裏判定を行う間隔(秒)。値を小さくすると反応が早くなりますが、処理負荷が増えます。'
    })
    public checkInterval: number = 0.05;

    @property({
        tooltip: 'シルエットのカラー。キャラのスプライトにこの色が乗算されます。'
    })
    public silhouetteColor: Color = new Color(0, 255, 255, 255); // シアン系

    @property({
        tooltip: 'シルエットの不透明度(0〜255)。'
    })
    public silhouetteOpacity: number = 180;

    @property({
        tooltip: 'シルエットをキャラよりどれだけ手前に出すか(Z オフセット)。\n正の値でカメラに近づきます。'
    })
    public silhouetteZOffset: number = 0.1;

    @property({
        tooltip: 'キャラ本体のスケールをシルエットにもコピーするかどうか。'
    })
    public copyScale: boolean = true;

    @property({
        tooltip: 'キャラ本体の回転をシルエットにもコピーするかどうか。'
    })
    public copyRotation: boolean = true;

    @property({
        tooltip: 'デバッグログを出力するかどうか。開発中のみ有効にすることを推奨します。'
    })
    public debugLog: boolean = false;

    // 内部用
    private _sprite: Sprite | null = null;
    private _silhouetteNode: Node | null = null;
    private _silhouetteSprite: Sprite | null = null;
    private _timer: number = 0;
    private _isHiddenByWall: boolean = false;
    private _initialized: boolean = false;

    onLoad() {
        // 必須コンポーネントの取得
        this._sprite = this.getComponent(Sprite);
        if (!this._sprite) {
            console.error('[Silhouette] このノードには Sprite コンポーネントが必要です。ノードに Sprite を追加してください。', this.node);
            return;
        }

        if (!this.camera) {
            console.error('[Silhouette] camera プロパティが未設定です。メインカメラをインスペクタから設定してください。', this.node);
            return;
        }

        if (this.wallLayerMask === 0) {
            console.warn('[Silhouette] wallLayerMask が 0 です。どのレイヤーも壁とみなされないため、シルエットは表示されません。', this.node);
        }

        this._createSilhouetteNode();
        this._initialized = true;
    }

    start() {
        if (!this._initialized) {
            return;
        }

        // 初期状態での有効/無効設定
        this.setEnabled(this.enabledOnStart);
    }

    update(deltaTime: number) {
        if (!this._initialized || !this.enabled) {
            return;
        }

        this._timer += deltaTime;
        if (this._timer < this.checkInterval) {
            // 一定間隔でのみ判定
            return;
        }
        this._timer = 0;

        const hidden = this._checkHiddenByWall();

        if (hidden !== this._isHiddenByWall) {
            this._isHiddenByWall = hidden;
            this._updateSilhouetteVisibility();
        }

        // シルエットの位置・スケール・回転を追従
        this._syncSilhouetteTransform();
    }

    /**
     * 外部から機能の有効/無効を切り替えるためのメソッド。
     */
    public setEnabled(value: boolean) {
        this.enabled = value;
        if (this._silhouetteNode) {
            this._silhouetteNode.active = false;
        }
        this._isHiddenByWall = false;
    }

    /**
     * シルエット用ノードを生成して初期設定する。
     */
    private _createSilhouetteNode() {
        if (!this._sprite) {
            return;
        }

        const parent = this.node.parent;
        if (!parent) {
            console.warn('[Silhouette] 親ノードが存在しません。シルエットノードはルートに作成されます。', this.node);
        }

        this._silhouetteNode = new Node(this.node.name + '_Silhouette');
        const targetParent = parent ?? director.getScene();
        targetParent?.addChild(this._silhouetteNode);

        // レイヤーをキャラと同じにしておく(UI/World など)
        this._silhouetteNode.layer = this.node.layer;

        // Sprite を追加し、元スプライトの SpriteFrame をコピー
        this._silhouetteSprite = this._silhouetteNode.addComponent(Sprite);
        this._silhouetteSprite.spriteFrame = this._sprite.spriteFrame as SpriteFrame;
        this._silhouetteSprite.color = new Color(
            this.silhouetteColor.r,
            this.silhouetteColor.g,
            this.silhouetteColor.b,
            this.silhouetteOpacity
        );

        // RenderPriority を少し上げておくと、同一Zでも前に出やすい
        this._silhouetteSprite.priority = this._sprite.priority + 1;

        // Transform を初期同期
        this._syncSilhouetteTransform();

        // 初期状態では非表示
        this._silhouetteNode.active = false;
    }

    /**
     * シルエットの Transform をキャラに追従させる。
     */
    private _syncSilhouetteTransform() {
        if (!this._silhouetteNode) {
            return;
        }

        // 位置:キャラと同じ X,Y で Z のみオフセット
        const worldPos = this.node.worldPosition.clone();
        worldPos.z += this.silhouetteZOffset;
        this._silhouetteNode.worldPosition = worldPos;

        // 回転・スケールのコピー
        if (this.copyRotation) {
            this._silhouetteNode.worldRotation = this.node.worldRotation;
        }
        if (this.copyScale) {
            this._silhouetteNode.worldScale = this.node.worldScale;
        }
    }

    /**
     * キャラが「壁の裏に隠れている」かを RayCast で判定する。
     */
    private _checkHiddenByWall(): boolean {
        if (!this.camera) {
            return false;
        }

        const camNode = this.camera.node;

        // カメラ位置 → キャラ位置 へのレイを飛ばす
        const from = new Vec3();
        const to = new Vec3();
        camNode.worldPosition.clone().subtract(this.node.worldPosition).normalize();
        from.set(camNode.worldPosition);
        to.set(this.node.worldPosition);

        // 2D 物理 RayCast(PhysicsSystem2D)を使用
        const physics2D = PhysicsSystem2D.instance;
        if (!physics2D) {
            if (this.debugLog) {
                console.warn('[Silhouette] PhysicsSystem2D が有効ではありません。2D物理を有効にしてください。');
            }
            return false;
        }

        // from と to を 2D 上の x,y のみ使用
        const hits = physics2D.raycast(
            new Vec3(from.x, from.y, 0),
            new Vec3(to.x, to.y, 0),
            ERaycast2DType.Closest,
            this.wallLayerMask
        );

        if (this.debugLog) {
            console.log('[Silhouette] RayCast hits count:', hits.length);
        }

        // 何かしら壁レイヤーに当たっていれば「壁の裏」とみなす
        return hits.length > 0;
    }

    /**
     * シルエットの表示・非表示を切り替える。
     */
    private _updateSilhouetteVisibility() {
        if (!this._silhouetteNode) {
            return;
        }
        this._silhouetteNode.active = this._isHiddenByWall;

        if (this.debugLog) {
            console.log('[Silhouette] Silhouette visibility changed:', this._isHiddenByWall, this.node);
        }
    }

    onDestroy() {
        // シルエットノードをクリーンアップ
        if (this._silhouetteNode && this._silhouetteNode.isValid) {
            this._silhouetteNode.destroy();
        }
        this._silhouetteNode = null;
        this._silhouetteSprite = null;
    }
}

コードの要点解説

  • onLoad()
    • 必須の SpriteCamera を取得・チェック。
    • 不足していれば console.error で通知し、_initialized を false のままにして処理を止めます。
    • 問題なければ _createSilhouetteNode() でシルエット用ノードを生成。
  • start()
    • enabledOnStart に応じて setEnabled() を呼び、初期状態を決定。
  • update(deltaTime)
    • checkInterval ごとに _checkHiddenByWall() で RayCast 判定。
    • 結果が変化したときのみ _updateSilhouetteVisibility() で表示/非表示を切り替え。
    • 毎フレーム _syncSilhouetteTransform() で位置(と必要に応じてスケール・回転)を追従。
  • _checkHiddenByWall()
    • PhysicsSystem2D.raycast() を使い、カメラ位置からキャラ位置までの直線上に wallLayerMask に該当するコライダーがあるか調べます。
    • 1つでもヒットすれば「壁の裏に隠れている」と判定します。
  • _createSilhouetteNode()
    • キャラと同じ親ノードの下に _Silhouette 付きのノードを自動生成。
    • Sprite を追加し、元の SpriteFrame をコピーして単色+不透明度を設定。
    • priority を元スプライトより 1 高くして、描画順の衝突を避けています。
    • 初期状態では active = false にしておき、壁の裏に入ったときだけ表示します。
  • onDestroy()
    • キャラノードが破棄されたときに、生成したシルエットノードも破棄してメモリリークを防ぎます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を Silhouette.ts にします。
  3. 作成された Silhouette.ts をダブルクリックして開き、中身をすべて削除してから 上記のコードを貼り付けて保存します。

2. テスト用シーンの準備

キャラクター(プレイヤー)ノードの作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を Player に変更します。
  2. Inspector の Sprite コンポーネントで、任意のキャラ画像(SpriteFrame)を設定します。

壁ノードの作成

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、名前を Wall に変更します。
  2. Inspector の Sprite コンポーネントで、壁用の画像を設定します(単色でも可)。
  3. 壁に Collider2D を追加します:
    1. Wall ノードを選択。
    2. Inspector 下部の Add Component ボタンをクリック。
    3. Physics 2D → BoxCollider2D(または適切な形状)を選択します。
  4. Layer 設定:
    1. Editor 上部の Project → Project Settings → Layers を開きます。
    2. 任意の User Layer(例:USER_1)を WALL などの名前に変更します。
    3. Wall ノードを選択し、Inspector 上部の LayerWALL に変更します。

カメラの確認

  1. Hierarchy に Main Camera があることを確認します(新規プロジェクトなら最初からあります)。
  2. 2D シーンの場合、Main Camera の位置は通常 (0, 0, 10) 付近です。

3. Silhouette コンポーネントのアタッチ

  1. Hierarchy で Player ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリック → CustomSilhouette を選択します。
  3. Silhouette コンポーネントのプロパティを設定します:
    • Enabled On Start:チェック ON(デフォルトのままでOK)。
    • CameraMain Camera をドラッグ&ドロップ。
    • Wall Layer Mask
      1. プロパティ右側のマスク選択(チェックボックス)を開きます。
      2. 先ほど作成した WALL レイヤーにチェックを入れます。
    • Check Interval0.050.1 程度を推奨。
    • Silhouette Color:見やすい色(例:シアンや白)を選択。
    • Silhouette Opacity160200 くらいにすると少し透けた感じになります。
    • Silhouette Z Offset0.1 を設定(キャラより少し手前に出します)。
    • Copy Scale / Copy Rotation:とりあえず両方 ON のままで問題ありません。
    • Debug Log:挙動を確認したい場合は ON、本番では OFF 推奨。

4. 動作確認

  1. シーン上で PlayerWall の位置を調整し、カメラから見て Player の手前に Wall が来るように配置します。
    • 例:2Dシーンで
      • Player の Position: (0, 0, 0)
      • Wall の Position: (0, 0, 0)(同じ位置でも OK)
      • Main Camera の Position: (0, 0, 10)
  2. 再生ボタン(▶)でゲームを実行します。
  3. Scene ビューまたは Game ビューで、Player が Wall の裏側に隠れている状態を確認します。
  4. Player が完全に壁の裏にある場合、キャラと同じ形の単色シルエットが壁の手前に表示されるはずです。
  5. Player を動かせるようにしている場合(例:簡単な移動スクリプトを自作)、壁の手前・奥を行き来させると、壁の裏に入った瞬間だけシルエットが ON/OFF されるのが確認できます。

もしシルエットが表示されない場合は、次の点を確認してください。

  • Player ノードに Sprite コンポーネントが付いているか。
  • SilhouetteCamera プロパティに Main Camera が設定されているか。
  • WallCollider2D が付いているか。
  • Wall の LayerWALL になっているか。
  • Silhouette の Wall Layer MaskWALL がチェックされているか。
  • Physics 2D が有効か(Project Settings → Module で Physics2D が ON になっているか)。

まとめ

本記事では、Cocos Creator 3.8 向けに、

  • キャラクターにアタッチするだけで、
  • 壁の裏に隠れたときだけ、
  • 壁の手前に単色シルエットを自動表示する

汎用コンポーネント Silhouette を実装しました。

特徴は次の通りです。

  • 完全独立:他のカスタムスクリプトに依存せず、必要なのは Sprite / Camera / Collider2D だけ。
  • 柔軟な調整:色・不透明度・Z オフセット・判定間隔・レイヤーマスクなどをインスペクタから直感的に変更可能。
  • 防御的実装:必須コンポーネントが不足している場合はログで知らせ、予期しないエラーを防止。

このコンポーネントをベースに、

  • シルエットをぼかし風にする(シェーダーやマテリアルを差し替える)
  • 壁の裏にいるときはキャラ本体を半透明にする
  • 一定時間だけシルエットを残像のように残す

といった拡張も容易に行えます。ゲーム内で「キャラが見えなくなるストレス」を減らしつつ、視認性の高い演出を手軽に追加したいときに、今回の Silhouette コンポーネントを活用してみてください。