【Cocos Creator 3.8】OutlineHighlight の実装:アタッチするだけで「マウスオーバー時に白い縁取りを表示」できる汎用スクリプト

この記事では、ノードにアタッチするだけで「マウスカーソルが乗ったときに白い2pxアウトラインを表示」できる汎用コンポーネント OutlineHighlight を実装します。

マウスで選択できるオブジェクトや、ホバー時に強調したいボタン・アイテム・キャラクターなどにそのまま使えます。外部の GameManager やシングルトンには一切依存せず、このコンポーネント単体で完結するように設計します。


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

機能要件の整理

  • このコンポーネントをアタッチしたノードに対して、マウスカーソルが乗っている間だけアウトライン(縁取り)を表示する。
  • アウトラインは シェーダー(Effect / Material)ベース で描画する。
  • 2D UI / Sprite を主ターゲットとし、Sprite または UIRenderer を持つノードに対応する。
  • コンポーネントは完全に独立しており、他のカスタムスクリプトには依存しない。
  • マウス座標とノードの AABB(軸平行境界ボックス)を使って簡易的な矩形ヒット判定を行う。
  • アウトラインの太さ・色・フェード時間などは @property 経由でインスペクタから調整可能にする。
  • 標準コンポーネント(Sprite / UIRenderer / Camera など)がなければ、コンソールに警告を出し、安全に動作を諦める。

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

以下のようなプロパティを用意します。

  • enableHighlight (boolean)
    – デフォルト: true
    – 全体の ON / OFF スイッチ。テスト時に一時的に無効化したいときに便利。
  • outlineColor (Color)
    – デフォルト: Color.WHITE
    – アウトラインの色。白以外にも、黄色・赤など自由に設定可能。
  • outlineWidth (number)
    – デフォルト: 2(ピクセル)
    – アウトラインの太さ。ピクセル単位として扱い、シェーダー側では UV スケールに変換して使用。
  • fadeDuration (number)
    – デフォルト: 0.1
    – マウスが乗った / 離れたときに、アウトラインの強さを補間する時間。
    – 0 にすると即座に ON / OFF が切り替わる。
  • materialTemplate (Material | null)
    – デフォルト: null
    アウトライン用のカスタムマテリアル(Effect付き) を指定するスロット。
    – ここに設定されたマテリアルを clone() して、対象ノード専用のインスタンスを作成し、パラメータ(色や太さ)を制御する。
  • targetRenderer (UIRenderer | null)
    – デフォルト: null
    – アウトラインを適用する対象のレンダラー。
    – 未指定の場合は this.node.getComponent(UIRenderer) で自動取得を試みる。
  • useWorldCamera (boolean)
    – デフォルト: true
    Camera.main を使ってワールド空間でのマウス位置を判定するかどうか。
    – UI カメラなど別のカメラを使いたい場合は OFF にして、customCamera を指定する。
  • customCamera (Camera | null)
    – デフォルト: null
    – 特定のカメラでマウス座標をレイキャストしたい場合に指定。
    useWorldCamera = false のときのみ使用される。

これらのプロパティにより、外部の管理スクリプトに頼らずに、インスペクタだけで完結して調整できるようにします。


TypeScriptコードの実装

ここでは、OutlineHighlight.ts として動作する完全なコードを掲載します。


import {
    _decorator,
    Component,
    Node,
    UIRenderer,
    Material,
    Color,
    Camera,
    input,
    Input,
    EventMouse,
    Vec2,
    Vec3,
    geometry,
    PhysicsSystem,
    view,
    UITransform,
    math,
    log,
    warn,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * OutlineHighlight
 * マウスオーバー時にアウトラインを表示する汎用コンポーネント
 *
 * 前提:
 *  - 対象ノードには UIRenderer (Sprite, Label, RichText, etc.) が付いていること
 *  - outline 用のカスタム Material を用意しておき、materialTemplate に設定すること
 *    - Effect 側で以下の uniform を用意しておくとこのスクリプトと連携しやすい:
 *      - uniform vec4 outlineColor;
 *      - uniform float outlineWidth;   // ピクセルベース
 *      - uniform float outlineStrength; // 0.0 ~ 1.0
 */
@ccclass('OutlineHighlight')
export class OutlineHighlight extends Component {

    @property({
        tooltip: 'コンポーネント全体の有効/無効スイッチ。false にすると処理を行いません。',
    })
    public enableHighlight: boolean = true;

    @property({
        tooltip: 'アウトラインの色。デフォルトは白。',
    })
    public outlineColor: Color = Color.WHITE.clone();

    @property({
        tooltip: 'アウトラインの太さ(ピクセル単位のイメージ)。2 で 2px 程度の縁取りになります。',
        min: 0,
        step: 0.5,
    })
    public outlineWidth: number = 2.0;

    @property({
        tooltip: 'マウスオーバー/離脱時のフェード時間(秒)。0 にすると即時切り替え。',
        min: 0,
        step: 0.01,
    })
    public fadeDuration: number = 0.1;

    @property({
        type: Material,
        tooltip: 'アウトライン用のカスタムマテリアル。必ず Effect に outlineColor / outlineWidth / outlineStrength の uniform を用意してください。',
    })
    public materialTemplate: Material | null = null;

    @property({
        type: UIRenderer,
        tooltip: 'アウトラインを適用する対象 UIRenderer。未設定の場合はこのノードから自動取得を試みます。',
    })
    public targetRenderer: UIRenderer | null = null;

    @property({
        tooltip: 'true: Camera.main を使用してマウス座標をワールド座標に変換します。',
    })
    public useWorldCamera: boolean = true;

    @property({
        type: Camera,
        tooltip: 'useWorldCamera が false の場合に使用するカスタムカメラ。',
    })
    public customCamera: Camera | null = null;

    // 内部状態
    private _instanceMaterial: Material | null = null;
    private _hovered: boolean = false;
    private _currentStrength: number = 0.0;   // 0.0 ~ 1.0
    private _targetStrength: number = 0.0;    // 0.0 or 1.0
    private _mousePos: Vec2 = new Vec2();

    onLoad() {
        // UIRenderer の取得
        if (!this.targetRenderer) {
            this.targetRenderer = this.node.getComponent(UIRenderer);
        }

        if (!this.targetRenderer) {
            warn('[OutlineHighlight] UIRenderer が見つかりません。このコンポーネントを使用するには Sprite や Label などの UIRenderer をアタッチしてください。 Node:', this.node.name);
            return;
        }

        // マテリアルの準備
        if (!this.materialTemplate) {
            warn('[OutlineHighlight] materialTemplate が設定されていません。アウトライン用の Material をインスペクタから指定してください。 Node:', this.node.name);
            return;
        }

        // Material インスタンスを複製して、このノード専用にする
        this._instanceMaterial = this.materialTemplate.clone();
        this.targetRenderer.setMaterial(this._instanceMaterial, 0);

        // 初期 uniform 設定
        this._applyMaterialProperties(0.0);

        // マウス入力リスナー登録
        input.on(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
    }

    start() {
        // ここでは特に何もしないが、将来的な拡張用に分けておく
    }

    onDestroy() {
        // 入力リスナー解除
        input.off(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
    }

    /**
     * 毎フレーム、マウス位置とノードの AABB を使ってヒット判定し、
     * アウトラインの強さを補間します。
     */
    update(dt: number) {
        if (!this.enableHighlight) {
            // 無効化時はアウトラインを完全にオフにして終了
            if (this._currentStrength !== 0.0) {
                this._currentStrength = 0.0;
                this._targetStrength = 0.0;
                this._applyMaterialProperties(this._currentStrength);
            }
            return;
        }

        if (!this.targetRenderer || !this._instanceMaterial) {
            // 必要コンポーネントがなければ何もしない
            return;
        }

        // マウスがこのノードの矩形内に入っているかチェック
        this._hovered = this._checkMouseInside();

        // 目標強度を設定(ホバー中:1, 非ホバー:0)
        this._targetStrength = this._hovered ? 1.0 : 0.0;

        // フェード処理
        if (this.fadeDuration > 0) {
            const diff = this._targetStrength - this._currentStrength;
            if (Math.abs(diff) > 0.0001) {
                const step = dt / this.fadeDuration;
                this._currentStrength += math.clamp(diff, -step, step);
                this._currentStrength = math.clamp01(this._currentStrength);
                this._applyMaterialProperties(this._currentStrength);
            }
        } else {
            // 即時切り替え
            if (this._currentStrength !== this._targetStrength) {
                this._currentStrength = this._targetStrength;
                this._applyMaterialProperties(this._currentStrength);
            }
        }
    }

    /**
     * マウス移動イベント。常に最新のマウス座標を保持しておく。
     */
    private _onMouseMove(event: EventMouse) {
        event.getLocation(this._mousePos);
    }

    /**
     * 現在のマウス座標が、このノードの矩形(UITransform)内にあるかどうかを判定する。
     */
    private _checkMouseInside(): boolean {
        const uiTrans = this.node.getComponent(UITransform);
        if (!uiTrans) {
            // UITransform がない場合は AABB を簡易的に使用するが、
            // UI ベースを想定しているのでここでは警告して false を返す。
            warn('[OutlineHighlight] UITransform が見つかりません。矩形ヒット判定が行えません。 Node:', this.node.name);
            return false;
        }

        // 画面座標 (マウス) → ワールド座標に変換
        const worldPos = this._screenToWorld(this._mousePos);
        if (!worldPos) {
            return false;
        }

        // UITransform の矩形にワールド座標点が含まれるかどうか
        return uiTrans.hitTest(worldPos);
    }

    /**
     * 画面座標 (スクリーン座標) をワールド座標に変換する。
     */
    private _screenToWorld(screenPos: Vec2): Vec3 | null {
        let cam: Camera | null = null;

        if (this.useWorldCamera) {
            cam = Camera.main;
        } else {
            cam = this.customCamera;
        }

        if (!cam) {
            warn('[OutlineHighlight] 有効な Camera が見つかりません。Camera.main も customCamera も利用できません。');
            return null;
        }

        const out = new Vec3();
        // スクリーン座標からワールド座標へ変換
        cam.screenToWorld(screenPos, out);
        return out;
    }

    /**
     * Material に対してアウトライン関連の uniform をまとめて反映する。
     */
    private _applyMaterialProperties(strength: number) {
        if (!this._instanceMaterial) {
            return;
        }

        // 色
        const c = this.outlineColor;
        this._instanceMaterial.setProperty('outlineColor', new Color(c.r, c.g, c.b, c.a));

        // 太さ(ピクセルベース)。Effect 側で適切にスケールして使用する想定。
        this._instanceMaterial.setProperty('outlineWidth', this.outlineWidth);

        // 強度(0.0 ~ 1.0)
        this._instanceMaterial.setProperty('outlineStrength', strength);
    }
}

主要メソッドのポイント解説

  • onLoad()
    targetRenderer が未設定なら this.node.getComponent(UIRenderer) で自動取得。なければ警告を出して終了。
    materialTemplate が未設定なら警告を出して終了。
    materialTemplate.clone() でインスタンスを作成し、setMaterial() で適用。
    – マウス移動イベントを購読して、常に最新のマウス座標を保持する。
  • update(dt)
    enableHighlight が false のときはアウトラインを完全にオフにする。
    _checkMouseInside() でマウスがノードの矩形内にいるかをチェックし、_hovered を更新。
    fadeDuration に応じて _currentStrength_targetStrength に向かって補間し、_applyMaterialProperties() でマテリアルに反映。
  • _checkMouseInside()
    UITransform を取得して、screenToWorld() で変換したマウス座標に対して hitTest() を行う。
    – UI 系ノードであればこれだけで矩形ヒット判定ができる。
  • _screenToWorld()
    useWorldCamera が true の場合は Camera.main、false の場合は customCamera を使ってスクリーン座標→ワールド座標変換。
  • _applyMaterialProperties()
    – マテリアルの uniform outlineColor, outlineWidth, outlineStrength をまとめて更新する。
    – Effect 側で同名の uniform を定義しておくことで、シェーダーから利用できる。

使用手順と動作確認

ここでは、実際に Cocos Creator 3.8.7 のエディタで OutlineHighlight コンポーネントを使う手順を、マテリアル(シェーダー)の準備も含めて解説します。

1. アウトライン用 Effect / Material の準備

まずは、アウトラインを描画するためのカスタムシェーダー(Effect)と Material を用意します。

  1. Assets パネルで右クリック → Create → Effect を選択し、
    ファイル名を outline-sprite.effect などにします。
  2. 作成した Effect をダブルクリックして開き、Sprite 用のアウトラインシェーダー を記述します。
    ここでは詳細なシェーダー実装は割愛しますが、最低限以下のような uniform を定義してください。
    • uniform vec4 outlineColor;
    • uniform float outlineWidth; (ピクセルベースとして扱う)
    • uniform float outlineStrength;(0.0 ~ 1.0)

    そして、outlineStrength が 0 のときは通常描画、1 のときは最大アウトラインになるようにフラグメントシェーダー内で処理します。

  3. Effect を保存したら、Assets パネルで右クリック → Create → Material を選択し、
    ファイル名を OutlineSprite.mat などにします。
  4. OutlineSprite.mat を選択し、Inspector の Effect スロットに先ほど作成した outline-sprite.effect を設定します。
  5. 必要に応じて、デフォルトの outlineColoroutlineWidth をここで設定しておいても構いません(スクリプトから上書きされます)。

これで、OutlineHighlight コンポーネントが参照するための Material が完成しました。

2. OutlineHighlight.ts を作成してコードを貼り付け

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を OutlineHighlight.ts に変更します。
  3. ダブルクリックで開き、先ほど掲載した OutlineHighlight クラスのコードを全て貼り付けて保存します。

3. テスト用ノード(Sprite)を作成

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を作成(既にある場合は不要)。
  2. Canvas を選択し、その子として右クリック → Create → UI → Sprite を作成します。
  3. 作成した Sprite ノードを選択し、Inspector で以下を確認します。
    • Sprite コンポーネントが付いている(= UIRenderer を持っている)。
    • 画像(SpriteFrame)が設定されている。
    • UITransform のサイズが適切(例: 200×200 など)。

4. OutlineHighlight コンポーネントをアタッチ

  1. テスト用 Sprite ノードを選択します。
  2. Inspector の一番下にある Add Component ボタンをクリックします。
  3. CustomOutlineHighlight を選択して追加します。

5. プロパティを設定

OutlineHighlight コンポーネントを選択した状態で、Inspector 上で以下のように設定します。

  • Enable Highlight: チェック ON(デフォルトで ON)。
  • Outline Color: 白(RGBA(255, 255, 255, 255))。
    ※ 他の色にしたい場合はここを変更。
  • Outline Width: 2(2px 相当)。
  • Fade Duration: 0.1 秒(滑らかにフェード)。
  • Material Template: 先ほど作成した OutlineSprite.mat をドラッグ&ドロップで設定。
  • Target Renderer: 空欄のままで OK(自動的に Sprite の UIRenderer が使用されます)。
  • Use World Camera: チェック ON(通常の 2D ゲームならこれで問題ありません)。
  • Custom Camera: 空欄のまま(Use World Camera が ON のため未使用)。

6. 再生して動作確認

  1. エディタ右上の Play ボタンを押してゲームを再生します。
  2. ゲームウィンドウ内で、先ほどの Sprite の上にマウスカーソルを移動します。
  3. カーソルが Sprite の矩形内に入った瞬間、白い 2px のアウトラインがふわっと表示されるのを確認します。
  4. カーソルを外に出すと、アウトラインがフェードアウトして消えるはずです。

もしアウトラインが表示されない場合は、以下を確認してください。

  • Console に [OutlineHighlight] の警告が出ていないか(UIRenderer / Material が不足していないか)。
  • OutlineSprite.mat の Effect が正しく設定されているか。
  • Effect 内で outlineColor, outlineWidth, outlineStrength の uniform 名が一致しているか。
  • Sprite が Canvas 内にあり、画面内に表示されているか。

まとめ

今回実装した OutlineHighlight コンポーネントは、

  • ノードにアタッチして、アウトライン用 Material を 1 つ指定するだけで、マウスオーバー時の視覚的な強調を簡単に実現できる。
  • 外部の GameManager やシングルトンに一切依存せず、完全に独立した再利用可能コンポーネントとして設計されている。
  • 色・太さ・フェード時間・対象レンダラー・使用カメラなどをインスペクタから柔軟に調整できるため、様々なシーンで使い回しやすい。

応用例としては、

  • インベントリ UI のアイテムスロットにアタッチして、選択可能なアイテムを視覚的にアピールする。
  • マップ上の交互可能オブジェクト(宝箱・ドアなど)にアタッチして、プレイヤーに「ここをクリックできる」と伝える
  • ボタンやメニュー項目にアタッチして、ホバー時のフィードバックを統一した見た目で提供する。

このように、1 つの汎用コンポーネント + 1 つのカスタムマテリアルを用意しておくだけで、ゲーム全体の UI/オブジェクトのホバー表現を統一でき、開発効率と見た目の一貫性を大きく向上させることができます。

必要に応じて、OutlineHighlight をベースに「クリック時に色を変える」「キーボード選択時にも反応させる」などの拡張も簡単に行えるので、自分のプロジェクトに合わせてカスタマイズしてみてください。