【Cocos Creator 3.8】TeleportDash の実装:アタッチするだけで「進行方向に一定距離だけ瞬間移動(ダッシュ)」を実現する汎用スクリプト

このコンポーネントは、ノードの「現在向いている方向」に対して、指定距離だけ瞬間的に座標を移動させるための汎用スクリプトです。
プレイヤーの緊急回避、ブリンク系スキル、ワープギミックなど、「移動アニメーションなしで座標だけ一気に飛ばしたい」場面で、そのノードにアタッチするだけで使えます。


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

実現したい機能

  • ノードの「進行方向(向き)」に対して、一定距離だけ瞬間移動(テレポート)させる。
  • テレポートは「ボタン入力」や「一定間隔」など、いくつかのトリガー方式をサポートする。
  • テレポート前後で、簡易的なエフェクト(フェードアウト&イン)や無敵時間をオプションで付与できる。
  • 他のスクリプトや GameManager に一切依存せず、このコンポーネント単体で完結させる。

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

  • 入力取得は Cocos の標準 systemEvent / input に直接登録し、外部の InputManager などは使わない。
  • 進行方向は以下のいずれかから決定可能にする:
    • ノードのローカル X 軸(right
    • ノードのローカル Y 軸(up
    • 固定ベクトル(例:ワールド右方向)
  • フェード演出は、UIOpacity を利用し、存在しなければ警告ログを出すだけで機能自体は続行できるようにする。
  • 物理挙動との兼ね合いは、RigidBody2D があれば一時的に速度をゼロにする程度に留め、なくても正常動作する。

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

以下のプロパティを @property で公開し、アタッチしただけで調整しやすいようにします。

  • dashDistance: number
    • テレポートする距離(ワールド座標系)。
    • 単位:ノードの座標単位(通常ピクセル相当)。
    • 例:200 なら、向いている方向に 200 だけ瞬間移動。
  • cooldown: number
    • テレポートのクールダウン時間(秒)。
    • 0 ならクールダウンなしで連打可能。
  • triggerType: enum
    • テレポートを発動するトリガー方式。
    • KeyPress:指定されたキーを押したとき。
    • Interval:指定時間ごとに自動でテレポート。
    • Manual:外部から public dash() を呼ぶ場合(今回は「テスト用」程度)。
  • triggerKey: KeyCode
    • KeyPress 時に使用するキー。
    • 例:KeyCode.SPACE ならスペースキーで瞬間移動。
  • interval: number
    • Interval トリガー時の発動間隔(秒)。
  • directionMode: enum
    • 進行方向の決め方。
    • LocalRight:ノードのローカル X+ 方向。
    • LocalUp:ノードのローカル Y+ 方向。
    • WorldRight:ワールドの右方向 (1,0,0)。
    • WorldUp:ワールドの上方向 (0,1,0)。
    • 2D ゲームなら通常 LocalRightLocalUp を使う。
  • useFadeEffect: boolean
    • テレポート前後でフェード演出を行うかどうか。
    • true の場合、UIOpacity コンポーネントを使用してアルファ値を変化させる。
  • fadeDuration: number
    • フェードアウト、フェードインそれぞれにかける時間(秒)。
    • 合計で fadeDuration * 2 秒だけ視覚的に消える演出になる。
    • 0 の場合は即時テレポートで、フェード演出なし。
  • grantInvincible: boolean
    • テレポート中に「無敵フラグ」を立てるかどうか。
    • このフラグは public readonly isInvincible: boolean で外部から参照可能。
    • 他のコンポーネントがあればそれを見てダメージ判定をスキップできる。
  • invincibleExtraTime: number
    • フェード演出が終わった後も、追加で無敵を維持する時間(秒)。
    • 0 ならフェード完了と同時に無敵解除。
  • debugLog: boolean
    • テレポート発動時などに console.log を出すかどうか。
    • 挙動確認時に便利。

TypeScriptコードの実装


import { _decorator, Component, Node, input, Input, KeyCode, EventKeyboard, Vec3, v3, UIOpacity, RigidBody2D, math } from 'cc';
const { ccclass, property } = _decorator;

enum TeleportTriggerType {
    Manual = 0,
    KeyPress = 1,
    Interval = 2,
}

enum TeleportDirectionMode {
    LocalRight = 0,
    LocalUp = 1,
    WorldRight = 2,
    WorldUp = 3,
}

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

    @property({
        tooltip: 'テレポートする距離(ワールド座標系)。正の値で進行方向へ、負の値で逆方向へ移動します。',
    })
    public dashDistance: number = 200;

    @property({
        tooltip: 'テレポートのクールダウン時間(秒)。0 の場合はクールダウンなしで連続発動可能です。',
        min: 0,
    })
    public cooldown: number = 0.3;

    @property({
        tooltip: 'テレポートの発動方式を選択します。\nManual: 外部から dash() を呼び出す場合のみ\nKeyPress: 指定したキーを押したときに発動\nInterval: 一定間隔で自動発動',
        type: TeleportTriggerType,
    })
    public triggerType: TeleportTriggerType = TeleportTriggerType.KeyPress;

    @property({
        tooltip: 'triggerType が KeyPress のときに使用するキー。',
        type: KeyCode,
        visible() {
            return this.triggerType === TeleportTriggerType.KeyPress;
        }
    })
    public triggerKey: KeyCode = KeyCode.SPACE;

    @property({
        tooltip: 'triggerType が Interval のときの発動間隔(秒)。',
        min: 0.1,
        visible() {
            return this.triggerType === TeleportTriggerType.Interval;
        }
    })
    public interval: number = 1.0;

    @property({
        tooltip: 'テレポートの進行方向の決め方を選択します。\nLocalRight: ノードのローカルX+方向\nLocalUp: ノードのローカルY+方向\nWorldRight: ワールド右方向(1,0,0)\nWorldUp: ワールド上方向(0,1,0)',
        type: TeleportDirectionMode,
    })
    public directionMode: TeleportDirectionMode = TeleportDirectionMode.LocalRight;

    @property({
        tooltip: 'テレポート前後にフェードアウト/フェードイン演出を行うかどうか。\nUIOpacity コンポーネントが必要です(存在しない場合は警告のみ)。',
    })
    public useFadeEffect: boolean = true;

    @property({
        tooltip: 'フェードアウト/フェードインそれぞれにかける時間(秒)。\n合計で fadeDuration * 2 秒だけ視覚的に消える演出になります。\n0 の場合はフェード演出なしで即座にテレポートします。',
        min: 0,
        visible() {
            return this.useFadeEffect;
        }
    })
    public fadeDuration: number = 0.1;

    @property({
        tooltip: 'テレポート中に無敵フラグを立てるかどうか。\n外部スクリプトから isInvincible を参照してダメージ判定をスキップできます。',
    })
    public grantInvincible: boolean = true;

    @property({
        tooltip: 'フェード演出が完了した後も無敵を維持する追加時間(秒)。',
        min: 0,
        visible() {
            return this.grantInvincible;
        }
    })
    public invincibleExtraTime: number = 0.0;

    @property({
        tooltip: 'テレポート発動時などにデバッグログを出力するかどうか。',
    })
    public debugLog: boolean = false;

    // 外部から参照可能な無敵状態フラグ(書き換え不可)
    public get isInvincible(): boolean {
        return this._isInvincible;
    }

    private _isInvincible: boolean = false;
    private _lastDashTime: number = -9999;
    private _timeSinceLastDash: number = 0;
    private _intervalTimer: number = 0;
    private _uiOpacity: UIOpacity | null = null;
    private _rigidBody2D: RigidBody2D | null = null;
    private _isDashing: boolean = false;

    onLoad() {
        // 必要になりそうな標準コンポーネントを取得(存在しない場合は警告のみ)
        this._uiOpacity = this.getComponent(UIOpacity);
        if (!this._uiOpacity && this.useFadeEffect) {
            console.warn('[TeleportDash] UIOpacity コンポーネントが見つかりません。フェード演出は行われません。ノードに UIOpacity を追加してください。');
        }

        this._rigidBody2D = this.getComponent(RigidBody2D);
        if (!this._rigidBody2D) {
            // 物理ボディがなくても動作自体は問題ないので、情報ログ程度に留める
            if (this.debugLog) {
                console.log('[TeleportDash] RigidBody2D が見つかりません。物理挙動の制御は行われません。');
            }
        }

        // 入力イベント登録(KeyPress の場合のみ)
        if (this.triggerType === TeleportTriggerType.KeyPress) {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        }
    }

    start() {
        // 初期状態のアルファ値を保持(フェード用)
        if (this._uiOpacity) {
            // 特に何もしなくても UIOpacity.opacity に現在値が入っている
        }
    }

    onDestroy() {
        // イベントの解除
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
    }

    update(deltaTime: number) {
        this._timeSinceLastDash += deltaTime;

        // Interval トリガー時の自動発動
        if (this.triggerType === TeleportTriggerType.Interval) {
            this._intervalTimer += deltaTime;
            if (this._intervalTimer >= this.interval) {
                this._intervalTimer = 0;
                this.tryDash();
            }
        }
    }

    /**
     * キー入力イベントハンドラ
     */
    private _onKeyDown(event: EventKeyboard) {
        if (this.triggerType !== TeleportTriggerType.KeyPress) {
            return;
        }
        if (event.keyCode === this.triggerKey) {
            this.tryDash();
        }
    }

    /**
     * 外部からも呼べる「テレポート要求」メソッド。
     * クールダウンなどの判定を行い、実際に実行可能なら dashInternal() を呼び出します。
     */
    public tryDash() {
        if (this._isDashing) {
            // 既にテレポート処理中
            return;
        }
        if (this.cooldown > 0 && this._timeSinceLastDash < this.cooldown) {
            // クールダウン中
            return;
        }
        this._timeSinceLastDash = 0;
        this._lastDashTime = performance.now() / 1000;
        this._dashInternal().catch((e) => {
            console.error('[TeleportDash] dashInternal でエラーが発生しました:', e);
            this._isDashing = false;
            this._setInvincible(false);
        });
    }

    /**
     * 実際のテレポート処理(非同期:フェード演出を含む)
     */
    private async _dashInternal() {
        this._isDashing = true;
        if (this.debugLog) {
            console.log('[TeleportDash] Teleport start');
        }

        // 無敵開始
        if (this.grantInvincible) {
            this._setInvincible(true);
        }

        // 物理ボディがある場合は、一時的に速度をゼロにする(任意)
        if (this._rigidBody2D) {
            this._rigidBody2D.linearVelocity = v3(0, 0, 0) as any;
            this._rigidBody2D.angularVelocity = 0;
        }

        // フェードアウト
        if (this.useFadeEffect && this.fadeDuration > 0 && this._uiOpacity) {
            await this._fadeTo(0, this.fadeDuration);
        }

        // 実際の座標を瞬間移動
        this._applyTeleport();

        // フェードイン
        if (this.useFadeEffect && this.fadeDuration > 0 && this._uiOpacity) {
            await this._fadeTo(255, this.fadeDuration);
        }

        // 追加の無敵時間
        if (this.grantInvincible && this.invincibleExtraTime > 0) {
            await this._waitSeconds(this.invincibleExtraTime);
        }

        // 無敵解除
        if (this.grantInvincible) {
            this._setInvincible(false);
        }

        this._isDashing = false;

        if (this.debugLog) {
            console.log('[TeleportDash] Teleport end');
        }
    }

    /**
     * 実際にノードの座標を更新する処理
     */
    private _applyTeleport() {
        const node = this.node;

        // 進行方向ベクトルを取得
        const dir = this._getDirection();
        if (dir.length() === 0) {
            console.warn('[TeleportDash] 進行方向ベクトルがゼロです。テレポートをスキップします。');
            return;
        }

        // 正規化して距離を乗算
        const normalizedDir = dir.normalize();
        const offset = normalizedDir.multiplyScalar(this.dashDistance);

        const worldPos = new Vec3();
        node.getWorldPosition(worldPos);
        const newWorldPos = worldPos.add(offset);
        node.setWorldPosition(newWorldPos);

        if (this.debugLog) {
            console.log(`[TeleportDash] teleported from (${worldPos.x.toFixed(1)}, ${worldPos.y.toFixed(1)}) to (${newWorldPos.x.toFixed(1)}, ${newWorldPos.y.toFixed(1)})`);
        }
    }

    /**
     * 進行方向ベクトルを取得
     */
    private _getDirection(): Vec3 {
        const node = this.node;
        switch (this.directionMode) {
            case TeleportDirectionMode.LocalRight: {
                // ローカルX+方向をワールド空間に変換
                const rightLocal = v3(1, 0, 0);
                const worldRight = node.getWorldMatrix().transformDirection(rightLocal, new Vec3());
                return worldRight;
            }
            case TeleportDirectionMode.LocalUp: {
                const upLocal = v3(0, 1, 0);
                const worldUp = node.getWorldMatrix().transformDirection(upLocal, new Vec3());
                return worldUp;
            }
            case TeleportDirectionMode.WorldRight:
                return v3(1, 0, 0);
            case TeleportDirectionMode.WorldUp:
                return v3(0, 1, 0);
            default:
                return v3(1, 0, 0);
        }
    }

    /**
     * UIOpacity を指定値まで線形補間で変化させる簡易フェード
     */
    private _fadeTo(targetOpacity: number, duration: number): Promise<void> {
        return new Promise((resolve) => {
            if (!this._uiOpacity || duration <= 0) {
                // 即時反映
                if (this._uiOpacity) {
                    this._uiOpacity.opacity = targetOpacity;
                }
                resolve();
                return;
            }

            const startOpacity = this._uiOpacity.opacity;
            const diff = targetOpacity - startOpacity;
            let elapsed = 0;

            const updateFade = (dt: number) => {
                if (!this._uiOpacity) {
                    resolve();
                    return;
                }
                elapsed += dt;
                const t = math.clamp01(elapsed / duration);
                this._uiOpacity.opacity = startOpacity + diff * t;

                if (t >= 1) {
                    // 完了
                    this.unschedule(updateFade);
                    resolve();
                }
            };

            this.schedule(updateFade, 0);
        });
    }

    /**
     * 単純な時間待ち(秒)
     */
    private _waitSeconds(sec: number): Promise<void> {
        return new Promise((resolve) => {
            if (sec <= 0) {
                resolve();
                return;
            }
            this.scheduleOnce(() => {
                resolve();
            }, sec);
        });
    }

    /**
     * 無敵フラグの内部セット
     */
    private _setInvincible(value: boolean) {
        this._isInvincible = value;
        if (this.debugLog) {
            console.log('[TeleportDash] isInvincible =', value);
        }
    }
}

コードのポイント解説

  • onLoad
    • UIOpacityRigidBody2DgetComponent で取得。
    • 存在しない場合は警告やログのみで、コンポーネント自体は動作可能。
    • triggerType === KeyPress のときだけ input.on(KEY_DOWN) を登録。
  • update
    • クールダウン計測用の _timeSinceLastDash を加算。
    • triggerType === Interval のとき、interval 秒ごとに tryDash() を呼ぶ。
  • tryDash()
    • 外部からも呼べる「テレポート要求」メソッド。
    • クールダウン中かどうか、すでにテレポート処理中かどうかを判定し、OKなら内部処理 _dashInternal() を実行。
  • _dashInternal()
    • 非同期処理で、フェードアウト → 座標更新 → フェードイン → 追加無敵時間 → 無敵解除、という流れを実装。
    • RigidBody2D があれば、テレポート前に速度をゼロにして物理暴走を抑制。
  • _applyTeleport()
    • _getDirection() で進行方向ベクトルを取得し、正規化&距離乗算してオフセットを計算。
    • getWorldPosition / setWorldPosition でワールド座標ベースに瞬間移動。
  • _getDirection()
    • directionMode に応じて、ローカル軸をワールド方向に変換したり、固定ベクトルを返したりする。
    • 回転しているノードでも、常に「向いている方向」へテレポートできる。
  • _fadeTo()
    • UIOpacityopacity を線形補間で変化させる簡易フェード。
    • schedule を使って毎フレーム更新し、完了時に Promise を解決。

使用手順と動作確認

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

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

2. テスト用ノードの作成

2D ゲームを想定した簡単な例です。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノードを作成します。
  2. ノード名を分かりやすく Player などに変更します。
  3. Canvas の中央付近に配置されていることを確認します。

3. 必要に応じて UIOpacity を追加

フェード演出を使いたい場合のみ必要です。

  1. Hierarchy で Player ノードを選択します。
  2. Inspector の Add Component ボタンをクリックします。
  3. UI → UIOpacity を選択して追加します。

フェード演出を使わない場合は、この手順はスキップして構いません(その場合、useFadeEffectfalse にしておくとよいです)。

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

  1. Hierarchy で Player ノードを選択したまま、Inspector を確認します。
  2. Add ComponentCustomTeleportDash を選択します。

5. プロパティの設定例

まずは「スペースキーで右方向に 200px 瞬間移動」する基本設定を行います。

  1. dashDistance200
  2. cooldown0.3(0.3秒ごとに発動可能)
  3. triggerTypeKeyPress
  4. triggerKeySPACE
  5. directionModeLocalRight
  6. useFadeEffecttrue(UIOpacity を追加している場合)
  7. fadeDuration0.1
  8. grantInvincibletrue
  9. invincibleExtraTime0.0
  10. debugLogtrue(挙動確認用)

6. ゲームビューで動作確認

  1. エディタ右上の Play ボタンを押してプレビューを開始します。
  2. ゲームビューが表示されたら、スペースキー を押してみてください。
  3. 押すたびに、Player ノードが右方向に 200 ずつ瞬間移動し、フェードアウト→インの演出が入るはずです。
  4. Console に [TeleportDash] Teleport start / end や座標ログが出ていれば、デバッグログも正常に機能しています。

7. 他のバリエーション設定例

一定間隔で自動テレポートするギミック

  • triggerTypeInterval
  • interval1.5(1.5秒ごとにテレポート)
  • dashDistance300
  • directionModeWorldUp(常に画面上方向へ)
  • この設定で、一定間隔で上方向にワープするトラップやオブジェクトを簡単に作れます。

上下方向にブリンクするキャラクター

  • directionModeLocalUp
  • キャラクターの回転に応じて、常に「上方向」へテレポートする挙動になります。
  • キャラクターを回転させながらテレポートさせると、シューティングゲームなどで面白い動きが作れます。

フェードなしの瞬間移動(パッと消えてパッと出る)

  • useFadeEffectfalse
  • fadeDuration:任意(無視されます)
  • アニメーションなしで、位置だけ一瞬で変えたい場合に使います。

まとめ

この TeleportDash コンポーネントは、「進行方向に一定距離だけ瞬間移動する」 というよくあるゲーム要素を、1つのスクリプトをアタッチするだけ で実現できるように設計しました。

  • トリガー方式(キー入力 / 一定間隔 / 手動呼び出し)の切り替え。
  • 進行方向の指定(ローカル軸 / ワールド軸)。
  • フェード演出の有無や時間。
  • 無敵フラグの付与と継続時間。

これらをすべて @property 経由でインスペクタから調整できるため、ゲームデザイナーやレベルデザイナーがコードに触れずにチューニングできます。
また、外部の GameManager や他のカスタムスクリプトに一切依存していないため、どのプロジェクトにもそのままコピー&ペーストして再利用可能です。

このコンポーネントをベースに、例えば「テレポート先にエフェクトプレハブを自動生成する」「テレポート前にレイキャストして壁を貫通しないようにする」などを追加実装していけば、より高度なブリンク/ワープシステムにも発展させられます。
まずはこの記事のコードをそのまま TeleportDash.ts としてプロジェクトに入れ、実際にノードを瞬間移動させながら、自分のゲームに合った設定値を探ってみてください。