【Cocos Creator 3.8】SpeedLines(集中線)の実装:アタッチするだけで「速度に応じて画面端から中心へ伸びる集中線エフェクト」を自動生成する汎用スクリプト

このコンポーネントは、ゲームの「スピード感」を簡単に演出したいときに使える汎用エフェクトです。カメラに相当するノードや、UIキャンバスのルートノードなどに SpeedLines コンポーネントをアタッチし、速度値を渡すだけで、画面の四辺から中心へ向かって伸びる集中線(スピードライン)を自動生成・制御します。

他のカスタムスクリプトへの依存は一切なく、このスクリプト単体で完結します。エフェクトの本数や長さ、太さ、色、フェード挙動などはすべてインスペクタから調整可能です。


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

1. 機能要件の整理

  • ノードにアタッチするだけで「集中線エフェクト用の子ノード群」を自動生成する。
  • ゲーム側から「現在の速度(0〜任意の最大値)」を渡すと、その値に応じて集中線の
    • 表示/非表示(閾値以下は非表示)
    • 不透明度(徐々に濃くなる)
    • 長さ(速度が高いほど長く)

    を自動制御する。

  • 集中線は画面の四辺(上・下・左・右)から中心へ向かって伸びるように配置する。
  • UIとして扱いやすいように、UITransformSprite を用いたシンプルな矩形スプライトで実装する。
  • 外部の GameManager などに依存せず、速度入力は setSpeed(value: number) メソッドで受け取るだけにする。

2. 外部依存をなくすためのアプローチ

  • 集中線はすべて、このコンポーネントがアタッチされたノードの子として自動生成する。
  • 必要な標準コンポーネントはコード内で getComponent / addComponent して補完する。
    • 親ノード:UITransform(なければ自動追加)
    • 各集中線ノード:UITransformSprite(自動追加)
  • スプライト画像(テクスチャ)は Inspector から指定できるようにし、未設定の場合はエラーログを出した上で、単色矩形としてでも動くようにする(Cocos のデフォルトマテリアルで描画)。
  • ゲーム側からは
    • setSpeed(currentSpeed: number) … 毎フレームなどで呼び出して速度を更新

    だけでよい設計にする(呼び出さなければ常に速度0扱い)。

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

インスペクタから調整できるプロパティを以下のように設計します。

  • maxSpeed: number
    • 想定される最大速度。
    • 0〜maxSpeed の範囲で setSpeed に渡された値を正規化し、集中線の強さ(長さ・不透明度)に反映する。
    • 例: 1000 など。
  • speedThreshold: number
    • この値未満の速度では集中線を非表示にする。
    • 「ある程度加速してからエフェクトが出る」ようにしたいときに使用。
  • lineCountPerSide: number
    • 各辺(上・下・左・右)ごとに生成する集中線の本数。
    • 合計本数は lineCountPerSide * 4
  • baseLineLength: number
    • 速度0(もしくは threshold 付近)での基本的な線の長さ(ピクセル単位)。
    • 速度に応じてこの値に倍率を掛けて最終的な長さを決める。
  • maxLineLengthMultiplier: number
    • 最大速度時の長さ倍率。
    • 例: 3.0 なら、最大速度時の長さは baseLineLength * 3.0
  • lineThickness: number
    • 集中線の太さ(ピクセル単位)。
    • UITransform の height / width に適用する。
  • lineColor: Color
    • 集中線の色。
    • Sprite の color に設定。
  • maxOpacity: number
    • 最大速度時の不透明度(0〜255)。
    • 実際の不透明度は速度に応じて 0〜maxOpacity の範囲で変化。
  • fadeInLerp: number
    • 現在の速度に追従する際の補間係数(0〜1)。
    • 値が小さいほど、エフェクトの変化がなめらか(遅く)になる。
  • randomizeOffset: number
    • 集中線の配置位置を少しランダムにずらす量(0〜1)。
    • 1 に近いほど、線の間隔がランダムになり、自然な見た目になる。
  • spriteFrame: SpriteFrame | null
    • 集中線に使用するスプライト画像。
    • 未設定の場合はログで警告し、デフォルトマテリアルで矩形を描画。
  • useWorldSpaceSize: boolean
    • 親ノードの UITransform サイズを「画面サイズ的な基準」として扱うかどうか。
    • true の場合、親ノードの幅・高さに応じて線の最大長を自動調整し、「画面中心に向かう」感を出す。

また、ゲーム側から速度を入力するためのメソッドとして、以下を用意します。

  • public setSpeed(speed: number): void
    • 毎フレーム(あるいは速度変化時)に呼び出し、現在速度をコンポーネントに通知する。
    • 内部では _targetSpeed に保存し、update 内で補間して _currentSpeed に反映。

TypeScriptコードの実装

以下が、Cocos Creator 3.8.7 用の完全な SpeedLines コンポーネント実装です。


import { _decorator, Component, Node, UITransform, Sprite, Color, SpriteFrame, Vec3, math, UIOpacity } from 'cc';
const { ccclass, property } = _decorator;

enum LineSide {
    TOP,
    BOTTOM,
    LEFT,
    RIGHT,
}

interface LineInfo {
    node: Node;
    side: LineSide;
    basePos: Vec3;
}

@ccclass('SpeedLines')
export class SpeedLines extends Component {

    @property({
        tooltip: '想定される最大速度。この値を1.0として正規化してエフェクト強度を決定します。'
    })
    maxSpeed: number = 1000;

    @property({
        tooltip: 'この速度未満では集中線を非表示にします。'
    })
    speedThreshold: number = 200;

    @property({
        tooltip: '各辺(上・下・左・右)ごとの集中線の本数です。合計は lineCountPerSide * 4 になります。',
        min: 1
    })
    lineCountPerSide: number = 6;

    @property({
        tooltip: '速度0〜閾値付近での基本線長(ピクセル単位)です。'
    })
    baseLineLength: number = 150;

    @property({
        tooltip: '最大速度時の線長倍率です。最終的な長さは baseLineLength * maxLineLengthMultiplier になります。',
        min: 1
    })
    maxLineLengthMultiplier: number = 3.0;

    @property({
        tooltip: '集中線の太さ(ピクセル単位)です。横線なら高さ、縦線なら幅として使われます。',
        min: 1
    })
    lineThickness: number = 8;

    @property({
        tooltip: '集中線の基本色です。'
    })
    lineColor: Color = new Color(255, 255, 255, 255);

    @property({
        tooltip: '最大速度時の不透明度(0〜255)です。実際の不透明度は速度に応じて0〜この値の範囲で変化します。',
        min: 0,
        max: 255
    })
    maxOpacity: number = 200;

    @property({
        tooltip: 'エフェクトの変化速度を決める補間係数(0〜1)。小さいほど変化がなめらかになります。',
        min: 0,
        max: 1
    })
    fadeInLerp: number = 0.15;

    @property({
        tooltip: '集中線の配置位置に加えるランダムオフセットの強さ(0〜1)。1に近いほどランダムになります。',
        min: 0,
        max: 1
    })
    randomizeOffset: number = 0.4;

    @property({
        tooltip: '集中線に使用するスプライト画像。未設定でも動作しますが、警告ログが出ます。'
    })
    spriteFrame: SpriteFrame | null = null;

    @property({
        tooltip: '親ノードのUITransformサイズを画面サイズ的な基準として扱うかどうか。trueの場合、線の最大長を親ノードサイズから自動調整します。'
    })
    useWorldSpaceSize: boolean = true;

    // 内部状態
    private _uiTransform: UITransform | null = null;
    private _lines: LineInfo[] = [];

    private _currentSpeed: number = 0;
    private _targetSpeed: number = 0;

    onLoad() {
        // 親ノードに UITransform がない場合は自動追加
        this._uiTransform = this.node.getComponent(UITransform);
        if (!this._uiTransform) {
            console.warn('[SpeedLines] 親ノードに UITransform がありません。自動で追加します。');
            this._uiTransform = this.node.addComponent(UITransform);
        }

        if (!this.spriteFrame) {
            console.warn('[SpeedLines] spriteFrame が設定されていません。デフォルトマテリアルの矩形で描画します。');
        }

        this._createLines();
    }

    start() {
        // 初期状態では速度0として扱う
        this._applyToLines(0);
    }

    update(deltaTime: number) {
        // 速度をなめらかに補間
        const lerpFactor = math.clamp01(this.fadeInLerp);
        this._currentSpeed = math.lerp(this._currentSpeed, this._targetSpeed, lerpFactor);

        this._applyToLines(this._currentSpeed);
    }

    /**
     * ゲーム側から現在速度を通知するためのメソッド。
     * 0〜maxSpeed の範囲を想定していますが、超えた場合も内部でクランプします。
     */
    public setSpeed(speed: number): void {
        if (isNaN(speed)) {
            console.warn('[SpeedLines] setSpeed に NaN が渡されました。無視します。');
            return;
        }
        this._targetSpeed = Math.max(0, speed);
    }

    /**
     * 集中線ノードを生成して配置する。
     */
    private _createLines(): void {
        // 既存の子ノードをクリア(SpeedLinesが再アタッチされた場合など)
        for (const info of this._lines) {
            info.node.destroy();
        }
        this._lines.length = 0;

        const parentSize = this._uiTransform!;
        const width = parentSize.width;
        const height = parentSize.height;

        const sides: LineSide[] = [
            LineSide.TOP,
            LineSide.BOTTOM,
            LineSide.LEFT,
            LineSide.RIGHT,
        ];

        for (const side of sides) {
            for (let i = 0; i < this.lineCountPerSide; i++) {
                const lineNode = new Node(`SpeedLine_${LineSide[side]}_${i}`);
                lineNode.parent = this.node;

                const ui = lineNode.addComponent(UITransform);
                const sprite = lineNode.addComponent(Sprite);
                const opacity = lineNode.addComponent(UIOpacity);

                if (this.spriteFrame) {
                    sprite.spriteFrame = this.spriteFrame;
                }
                sprite.color = this.lineColor.clone();
                opacity.opacity = 0;

                // 線の基本サイズ(長さは後で速度に応じて変更)
                if (side === LineSide.TOP || side === LineSide.BOTTOM) {
                    // 横線:幅 = baseLineLength, 高さ = lineThickness
                    ui.setContentSize(this.baseLineLength, this.lineThickness);
                } else {
                    // 縦線:幅 = lineThickness, 高さ = baseLineLength
                    ui.setContentSize(this.lineThickness, this.baseLineLength);
                }

                // 配置座標を決める(画面端から中心へ向かうように)
                const pos = this._calculateBasePositionForLine(side, i, width, height);
                lineNode.setPosition(pos);

                // 回転を設定(中心へ向かう角度に)
                const angle = this._calculateRotationForLine(side, pos, width, height);
                lineNode.setRotationFromEuler(0, 0, angle);

                this._lines.push({
                    node: lineNode,
                    side,
                    basePos: pos.clone(),
                });
            }
        }
    }

    /**
     * 各線の基準位置を計算します。
     * side に応じて、画面の四辺に沿って等間隔に配置し、randomizeOffset で少しランダムにします。
     */
    private _calculateBasePositionForLine(side: LineSide, index: number, width: number, height: number): Vec3 {
        // 親ノードの中心を (0,0) として扱う
        const halfW = width / 2;
        const halfH = height / 2;

        const t = (index + 0.5) / this.lineCountPerSide; // 0〜1の範囲(中心寄せ)
        const rand = (Math.random() - 0.5) * 2 * this.randomizeOffset; // -randomizeOffset〜+randomizeOffset

        let x = 0;
        let y = 0;

        switch (side) {
            case LineSide.TOP:
                x = math.lerp(-halfW, halfW, t + rand);
                y = halfH;
                break;
            case LineSide.BOTTOM:
                x = math.lerp(-halfW, halfW, t + rand);
                y = -halfH;
                break;
            case LineSide.LEFT:
                x = -halfW;
                y = math.lerp(-halfH, halfH, t + rand);
                break;
            case LineSide.RIGHT:
                x = halfW;
                y = math.lerp(-halfH, halfH, t + rand);
                break;
        }

        return new Vec3(x, y, 0);
    }

    /**
     * 線が画面中心へ向かうように回転角度を計算します。
     */
    private _calculateRotationForLine(side: LineSide, pos: Vec3, width: number, height: number): number {
        // 中心座標
        const center = new Vec3(0, 0, 0);
        const dir = center.subtract(pos);
        const angleRad = Math.atan2(dir.y, dir.x);
        const angleDeg = math.toDegree(angleRad);
        return angleDeg;
    }

    /**
     * 現在速度に基づいて、集中線の長さ・不透明度などを更新します。
     */
    private _applyToLines(speed: number): void {
        if (this._lines.length === 0) {
            return;
        }

        const clampedSpeed = math.clamp(speed, 0, this.maxSpeed);
        const norm = this.maxSpeed > 0 ? clampedSpeed / this.maxSpeed : 0;

        // 閾値以下なら全て非表示
        if (clampedSpeed < this.speedThreshold) {
            for (const info of this._lines) {
                const opacity = info.node.getComponent(UIOpacity);
                if (opacity) {
                    opacity.opacity = 0;
                }
            }
            return;
        }

        // 速度に応じた線長倍率と不透明度
        const lengthMultiplier = math.lerp(1.0, this.maxLineLengthMultiplier, norm);
        const targetOpacity = this.maxOpacity * norm;

        // 親サイズに応じて最大長を制限(useWorldSpaceSize が true のとき)
        let maxPossibleLength = this.baseLineLength * this.maxLineLengthMultiplier;
        if (this.useWorldSpaceSize && this._uiTransform) {
            // 対角線長を基準に、中心まで届く程度の長さに制限
            const w = this._uiTransform.width;
            const h = this._uiTransform.height;
            const diag = Math.sqrt(w * w + h * h);
            maxPossibleLength = diag * 0.6; // 画面中心付近まで
        }

        const finalLength = math.clamp(this.baseLineLength * lengthMultiplier, 0, maxPossibleLength);

        for (const info of this._lines) {
            const ui = info.node.getComponent(UITransform);
            const opacity = info.node.getComponent(UIOpacity);
            const sprite = info.node.getComponent(Sprite);
            if (!ui || !opacity || !sprite) {
                continue;
            }

            // 長さ更新(横線か縦線かでサイズの軸が異なる)
            if (info.side === LineSide.TOP || info.side === LineSide.BOTTOM) {
                ui.setContentSize(finalLength, this.lineThickness);
            } else {
                ui.setContentSize(this.lineThickness, finalLength);
            }

            // 不透明度更新
            opacity.opacity = targetOpacity;

            // 色はそのまま(必要なら速度に応じて色を変える拡張も可能)
            sprite.color = this.lineColor;
        }
    }
}

コードのポイント解説

  • onLoad
    • 親ノードに UITransform がなければ自動追加します。
    • spriteFrame 未設定時に警告ログを出します(動作は継続)。
    • _createLines() を呼んで、集中線用の子ノード群を生成します。
  • _createLines()
    • 上・下・左・右の4辺それぞれに対して lineCountPerSide 本の線を作成。
    • 各線に UITransform, Sprite, UIOpacity を自動追加。
    • _calculateBasePositionForLine で画面端の位置を計算し、_calculateRotationForLine で中心を向く回転角度を設定。
  • setSpeed(speed: number)
    • ゲーム側からの速度入力窓口。
    • 内部の _targetSpeed に格納し、update で補間して使います。
  • update(deltaTime)
    • _currentSpeed_targetSpeed に向かって fadeInLerp で補間。
    • 補間後の速度を _applyToLines() に渡して、集中線の長さ・不透明度を更新します。
  • _applyToLines(speed)
    • 速度を 0〜maxSpeed にクランプし、正規化した値をもとに線長倍率と不透明度を算出。
    • speedThreshold 未満ならすべて不透明度0(非表示)にします。
    • useWorldSpaceSize が true の場合、親ノードの対角線長から「中心付近まで届く最大線長」を計算し、その範囲に収まるようにします。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を SpeedLines.ts に変更します。
  3. 作成された SpeedLines.ts をダブルクリックしてエディタで開き、先ほどの TypeScript コード全体を貼り付けて保存します。

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

UIとして画面全体に集中線を表示する例で説明します。

  1. Hierarchy パネルで右クリック → Create → Canvas を選択し、UI用のキャンバスを作成します。
  2. Canvas ノードを選択し、子ノードとして UI レイヤーを追加します。
    • Canvas を右クリック → Create → UI → Widget など、任意の UI ノードを作成し、名前を SpeedLinesRoot に変更します。
    • SpeedLinesRoot ノードの UITransform の Width / Height を画面サイズ(例: 1920 x 1080)に設定しておくと分かりやすいです。

3. SpeedLines コンポーネントをアタッチ

  1. Hierarchy で SpeedLinesRoot ノードを選択します。
  2. Inspector の Add Component ボタンをクリック → CustomSpeedLines を選択してアタッチします。

4. プロパティの設定

Inspector で SpeedLines コンポーネントの各プロパティを以下のように設定してみましょう(あくまで一例です)。

  • maxSpeed: 1000
  • speedThreshold: 200
  • lineCountPerSide: 8
  • baseLineLength: 200
  • maxLineLengthMultiplier: 3.0
  • lineThickness: 6
  • lineColor: 白(デフォルトのまま)
  • maxOpacity: 220
  • fadeInLerp: 0.15
  • randomizeOffset: 0.4
  • spriteFrame:
    • Assets パネルに任意の細長い白いテクスチャ(1×16 など)を用意し、SpriteFrame として設定します。
    • UI 用のスプライトアトラスがあれば、その中の白いバー画像などを指定しても構いません。
  • useWorldSpaceSize: true

5. 速度を変化させて動作確認する

コンポーネントは setSpeed メソッドで速度を受け取る設計なので、簡単なテスト用スクリプトを作って連携させます。(このテストスクリプトはあくまで確認用であり、SpeedLines 自体はこれに依存しません

  1. Assets パネルで右クリック → Create → TypeScriptSpeedLinesTester.ts を作成します。
  2. 以下のような簡易テストコードを貼り付けます。

import { _decorator, Component } from 'cc';
import { SpeedLines } from './SpeedLines';
const { ccclass, property } = _decorator;

@ccclass('SpeedLinesTester')
export class SpeedLinesTester extends Component {

    @property(SpeedLines)
    speedLines: SpeedLines | null = null;

    @property
    testMaxSpeed: number = 1000;

    private _time = 0;

    update(deltaTime: number) {
        if (!this.speedLines) {
            return;
        }

        this._time += deltaTime;
        // 速度を 0〜testMaxSpeed の間で周期的に変化させる
        const t = (Math.sin(this._time) + 1) / 2; // 0〜1
        const speed = this.testMaxSpeed * t;
        this.speedLines.setSpeed(speed);
    }
}

このテストスクリプトの使い方:

  1. SpeedLinesRoot ノード、あるいは別の管理用ノードに SpeedLinesTester をアタッチします。
  2. Inspector の speedLines プロパティに、Hierarchy から SpeedLinesRoot をドラッグ&ドロップします。
  3. 再生ボタン(▶)でゲームを実行すると、速度が周期的に変化し、それに応じて集中線の本数・長さ・不透明度が変化していく様子が確認できます。

実際のゲームでは、プレイヤーの移動速度やカメラの移動速度などを SpeedLinessetSpeed に渡すだけで同様の演出が可能です。


まとめ

この記事では、Cocos Creator 3.8.7 と TypeScript を用いて、

  • 他のカスタムスクリプトに一切依存しない
  • ノードにアタッチするだけで使える
  • 速度に応じて画面端から中心へ伸びる集中線(スピードライン)を自動生成する

SpeedLines コンポーネントを実装しました。

主なポイントは以下の通りです。

  • 集中線用ノードをすべてコンポーネント内部で自動生成し、完全な独立性を保っていること。
  • エフェクトの見た目(本数・長さ・太さ・色・不透明度・ランダム性)をすべて Inspector の @property で調整できること。
  • ゲーム側からは setSpeed を呼ぶだけでよく、既存のゲームロジックに簡単に組み込めること。

このコンポーネントを応用すれば、

  • ブースト時のみ強く出る集中線
  • UI 上の演出(メニュー遷移時のスピード感演出など)
  • 2D シューティングやレースゲームのカメラに紐づいた速度エフェクト

などを、毎回自前でエフェクトを組むことなく再利用できるようになります。プロジェクトの 共通エフェクトコンポーネント としてライブラリ化しておくと、今後の開発効率が大きく向上するはずです。

必要に応じて、

  • 速度に応じて色を変える
  • 線の出現位置を時間で揺らす(ゆらゆら動かす)
  • 3D カメラの前面に貼る UI として利用する

といった拡張も容易に行える構造になっているので、ぜひプロジェクトに合わせてカスタマイズしてみてください。