【Cocos Creator 3.8】GrapplingHook(フックショット)の実装:アタッチするだけで「クリック位置にロープを伸ばし、刺さった地点まで自分を牽引」できる汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「マウスクリック(またはタップ)した方向にフックを射出し、命中地点にロープを張り、その地点まで自分自身(親ノード)を引き寄せる」挙動を実現する GrapplingHook コンポーネントを実装します。

プレイヤーキャラやカメラ、任意のオブジェクトにアタッチするだけで使えるように設計し、外部の GameManager やシングルトンには一切依存しません。ロープの見た目やスピード、到達判定距離などはすべてインスペクタから調整可能です。


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

機能要件の整理

  • 画面クリック(またはタップ)位置に向かってフックショットを発射する。
  • フックは一定速度で伸びていき、指定距離またはクリック地点に達したら「命中」とみなす。
  • 命中したら、その地点に向かってこのコンポーネントが付いているノード(=プレイヤーなど)を牽引する。
  • 牽引中はロープが常に「発射元(自分の位置)→フック地点」を結ぶように描画される。
  • 牽引完了(目標地点に十分近づいた)か、最大ロープ長を超えた場合はロープを解除する。
  • クリック連打や途中キャンセルにも破綻しないよう、防御的に状態管理する。
  • 外部スクリプトに依存せず、このコンポーネント単体で完結させる。

内部的な挙動のイメージ

  1. 待機状態(Idle)でクリックを待つ。
  2. クリックされたら、ワールド座標に変換したターゲット地点を記録し、Hooking 状態へ。
  3. Hooking 中は、フック先端の位置を「発射元 → ターゲット方向」に向かって hookSpeed で移動させる。
  4. フック先端がターゲット地点に十分近づいたら「命中」とみなし、その地点を anchorPoint(アンカー) として固定し、Pulling 状態へ。
  5. Pulling 中は、自分(this.node)の位置を pullSpeed で anchorPoint に向かって移動させる。
  6. 自分が anchorPoint に十分近づいたら牽引終了し、ロープを消して Idle に戻る。
  7. いずれの状態でも、ロープ用の Node(Line)を伸縮・回転させてロープ表示を更新する。

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

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

  • 入力関連
    • enableMouseInput: boolean
      マウスクリック(デスクトップ)でフックを発射するか。
    • enableTouchInput: boolean
      タッチ入力(モバイル)でフックを発射するか。
    • fireOnPress: boolean
      true: 押した瞬間で発射、false: 離した瞬間で発射。
  • フック挙動
    • hookSpeed: number
      フック先端がターゲットに向かって進む速度(単位:ユニット/秒)。
    • pullSpeed: number
      自分がアンカー地点に向かって移動する速度。
    • maxHookDistance: number
      フックが届く最大距離。これを超えると自動キャンセル。
    • hitThreshold: number
      フック先端がターゲット地点にどれだけ近づいたら「命中」とみなすか。
    • arriveThreshold: number
      自分がアンカー地点にどれだけ近づいたら牽引完了とみなすか。
    • allowWhileMoving: boolean
      牽引中でも新しいフックを撃てるか(true なら上書き)。
  • ロープ見た目
    • ropeNode: Node | null
      ロープを表示するための子ノード。未指定の場合は動的に生成(Sprite 必須)。
    • ropeSpriteFrame: SpriteFrame | null
      ロープに使うスプライト。未指定の場合は ropeNode に既に設定されたものを使用。
    • ropeWidth: number
      ロープの太さ(ロープスプライトの高さとして使用)。
    • ropeMinLength: number
      ロープが極端に短くなったときでも見えるようにする最小長。
    • ropeColor: Color
      ロープスプライトの色。
  • デバッグ
    • debugLog: boolean
      状態遷移などのログを出力するか。

ロープ描画には Sprite コンポーネントを使い、高さ=ropeWidth幅=ロープ長 としてスケーリング+回転で実現します。
ropeNode を未設定の場合は、スクリプトが自動で子ノードを生成し、Sprite をアタッチしてくれます(ただし SpriteFrame は自分で設定するのが望ましい)。


TypeScriptコードの実装

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


import {
    _decorator,
    Component,
    Node,
    Vec2,
    Vec3,
    v2,
    v3,
    input,
    Input,
    EventMouse,
    EventTouch,
    UITransform,
    Sprite,
    SpriteFrame,
    Color,
    math,
    Canvas,
    director,
} from 'cc';
const { ccclass, property } = _decorator;

enum GrappleState {
    Idle = 0,
    Hooking = 1,
    Pulling = 2,
}

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

    // --------------------
    // 入力設定
    // --------------------

    @property({
        tooltip: 'マウス入力でフックを発射できるようにするか(PC向け)。',
    })
    public enableMouseInput: boolean = true;

    @property({
        tooltip: 'タッチ入力でフックを発射できるようにするか(モバイル向け)。',
    })
    public enableTouchInput: boolean = true;

    @property({
        tooltip: 'true: 押した瞬間に発射 / false: 離した瞬間に発射。',
    })
    public fireOnPress: boolean = true;

    // --------------------
    // フック挙動
    // --------------------

    @property({
        tooltip: 'フック先端がターゲット地点に向かって進む速度(単位: ユニット/秒)。',
        min: 1,
    })
    public hookSpeed: number = 800;

    @property({
        tooltip: 'プレイヤー(このノード)がアンカー地点へ移動する速度(単位: ユニット/秒)。',
        min: 1,
    })
    public pullSpeed: number = 600;

    @property({
        tooltip: 'フックが届く最大距離。これを超えるとフックはキャンセルされる(0以下で無制限)。',
        min: 0,
    })
    public maxHookDistance: number = 1500;

    @property({
        tooltip: 'フック先端がターゲット地点にこの距離以内に入ったら「命中」とみなす。',
        min: 1,
    })
    public hitThreshold: number = 20;

    @property({
        tooltip: 'プレイヤーがアンカー地点にこの距離以内に入ったら牽引完了とみなす。',
        min: 1,
    })
    public arriveThreshold: number = 30;

    @property({
        tooltip: '牽引中でも新しいフックを撃てるようにするか(true なら上書き)。',
    })
    public allowWhileMoving: boolean = true;

    // --------------------
    // ロープ見た目
    // --------------------

    @property({
        tooltip: 'ロープを表示するための子ノード。未指定の場合は自動生成される。',
        type: Node,
    })
    public ropeNode: Node | null = null;

    @property({
        tooltip: 'ロープに使用するスプライト。未指定の場合は ropeNode に既に設定されている SpriteFrame を利用。',
        type: SpriteFrame,
    })
    public ropeSpriteFrame: SpriteFrame | null = null;

    @property({
        tooltip: 'ロープの太さ(スプライトの高さとして使用)。',
        min: 1,
    })
    public ropeWidth: number = 8;

    @property({
        tooltip: 'ロープが極端に短いときでも見えるようにする最小長さ。',
        min: 1,
    })
    public ropeMinLength: number = 10;

    @property({
        tooltip: 'ロープスプライトの色。',
    })
    public ropeColor: Color = new Color(255, 255, 255, 255);

    // --------------------
    // デバッグ
    // --------------------

    @property({
        tooltip: '状態遷移などのデバッグログをコンソールに出力するか。',
    })
    public debugLog: boolean = false;

    // --------------------
    // 内部状態
    // --------------------

    private _state: GrappleState = GrappleState.Idle;

    private _anchorPoint: Vec3 = new Vec3();       // 命中(アンカー)地点(ワールド座標)
    private _hookTip: Vec3 = new Vec3();           // 現在のフック先端位置(ワールド座標)
    private _targetPoint: Vec3 = new Vec3();       // クリックされたターゲット地点(ワールド座標)

    private _ropeSprite: Sprite | null = null;     // ropeNode の Sprite 参照
    private _canvasNode: Node | null = null;       // 画面座標→ワールド座標変換用 Canvas

    // 再利用用ベクトル
    private _tmpV2: Vec2 = v2();
    private _tmpV3: Vec3 = v3();
    private _tmpDir: Vec3 = v3();

    // =========================================================
    // ライフサイクル
    // =========================================================

    onLoad() {
        // Canvas の取得(UI空間で座標変換するため)
        this._canvasNode = this._findCanvasNode();

        if (!this._canvasNode) {
            console.warn(
                '[GrapplingHook] Canvas ノードがシーン内に見つかりません。' +
                'UI座標からワールド座標への変換に Canvas が必要です。' +
                'Canvas をシーンに追加してください。'
            );
        }

        this._initRopeNode();

        // 入力イベント登録
        this._registerInputEvents();
    }

    start() {
        // 開始時はロープを非表示
        this._setRopeVisible(false);
    }

    onDestroy() {
        this._unregisterInputEvents();
    }

    update(deltaTime: number) {
        switch (this._state) {
            case GrappleState.Hooking:
                this._updateHooking(deltaTime);
                break;
            case GrappleState.Pulling:
                this._updatePulling(deltaTime);
                break;
            case GrappleState.Idle:
            default:
                // 何もしない
                break;
        }
    }

    // =========================================================
    // 初期化系
    // =========================================================

    /**
     * シーン上の Canvas ノードを探す。
     */
    private _findCanvasNode(): Node | null {
        const scene = director.getScene();
        if (!scene) {
            return null;
        }
        const children = scene.children;
        for (let i = 0; i < children.length; i++) {
            const node = children[i];
            if (node.getComponent(Canvas)) {
                return node;
            }
        }
        return null;
    }

    /**
     * ropeNode / ropeSprite の初期化。
     */
    private _initRopeNode() {
        if (!this.ropeNode) {
            // ropeNode が未設定なら自動生成
            this.ropeNode = new Node('Rope');
            this.node.addChild(this.ropeNode);
            if (this.debugLog) {
                console.log('[GrapplingHook] ropeNode が指定されていなかったため、自動生成しました。');
            }
        }

        // Sprite コンポーネントを取得または追加
        this._ropeSprite = this.ropeNode.getComponent(Sprite);
        if (!this._ropeSprite) {
            this._ropeSprite = this.ropeNode.addComponent(Sprite);
            console.warn(
                '[GrapplingHook] ropeNode に Sprite が存在しなかったため、自動で追加しました。' +
                'ロープの見た目を変更したい場合は ropeSpriteFrame を設定してください。'
            );
        }

        // SpriteFrame の設定(Inspector で指定されていればそれを優先)
        if (this.ropeSpriteFrame) {
            this._ropeSprite.spriteFrame = this.ropeSpriteFrame;
        } else if (!this._ropeSprite.spriteFrame) {
            console.warn(
                '[GrapplingHook] ropeSpriteFrame が設定されておらず、ropeNode の Sprite にも SpriteFrame がありません。' +
                'ロープは表示されません。スプライトを割り当ててください。'
            );
        }

        // カラー設定
        this._ropeSprite.color = this.ropeColor;

        // ropeNode のアンカーと位置を初期化(発射元を基準に伸びるように)
        const uiTrans = this.ropeNode.getComponent(UITransform);
        if (uiTrans) {
            uiTrans.anchorX = 0; // 左端を基準に伸ばす
            uiTrans.anchorY = 0.5;
            uiTrans.setContentSize(this.ropeMinLength, this.ropeWidth);
        }
        this.ropeNode.setPosition(Vec3.ZERO);
        this.ropeNode.setRotationFromEuler(0, 0, 0);
    }

    private _registerInputEvents() {
        if (this.enableMouseInput) {
            if (this.fireOnPress) {
                input.on(Input.EventType.MOUSE_DOWN, this._onMouseEvent, this);
            } else {
                input.on(Input.EventType.MOUSE_UP, this._onMouseEvent, this);
            }
        }
        if (this.enableTouchInput) {
            if (this.fireOnPress) {
                input.on(Input.EventType.TOUCH_START, this._onTouchEvent, this);
            } else {
                input.on(Input.EventType.TOUCH_END, this._onTouchEvent, this);
            }
        }
    }

    private _unregisterInputEvents() {
        input.off(Input.EventType.MOUSE_DOWN, this._onMouseEvent, this);
        input.off(Input.EventType.MOUSE_UP, this._onMouseEvent, this);
        input.off(Input.EventType.TOUCH_START, this._onTouchEvent, this);
        input.off(Input.EventType.TOUCH_END, this._onTouchEvent, this);
    }

    // =========================================================
    // 入力処理
    // =========================================================

    private _onMouseEvent(event: EventMouse) {
        const location = event.getLocation(this._tmpV2); // 画面座標
        this._fireGrappleAtScreenPos(location);
    }

    private _onTouchEvent(event: EventTouch) {
        const location = event.getLocation(this._tmpV2); // 画面座標
        this._fireGrappleAtScreenPos(location);
    }

    /**
     * 画面座標(スクリーン座標)からフックを発射する。
     */
    private _fireGrappleAtScreenPos(screenPos: Vec2) {
        if (!this.allowWhileMoving && this._state !== GrappleState.Idle) {
            if (this.debugLog) {
                console.log('[GrapplingHook] 現在フック動作中のため、新しいフックは無視されました。');
            }
            return;
        }

        if (!this._canvasNode) {
            console.warn(
                '[GrapplingHook] Canvas が見つからないため、画面座標からワールド座標への変換ができません。' +
                'Canvas をシーンに追加してください。'
            );
            return;
        }

        // 画面座標 → Canvas の UITransform 経由でワールド座標へ
        const canvasTransform = this._canvasNode.getComponent(UITransform);
        if (!canvasTransform) {
            console.warn(
                '[GrapplingHook] Canvas ノードに UITransform が見つかりません。' +
                'Canvas コンポーネントが正しく設定されているか確認してください。'
            );
            return;
        }

        // UITransform の convertToNodeSpaceAR を使って Canvas ローカル座標へ
        const localPos = canvasTransform.convertToNodeSpaceAR(v3(screenPos.x, screenPos.y, 0), this._tmpV3);

        // Canvas ローカル座標 → ワールド座標
        const worldPos = this._canvasNode!.getWorldPosition(this._tmpV3);
        worldPos.set(localPos.x, localPos.y, 0);

        // このコンポーネントが付いているノードの現在位置(ワールド)
        const origin = this.node.worldPosition;

        // ターゲット地点を記録
        this._targetPoint.set(worldPos);

        // 最大距離チェック(0 以下なら無制限)
        if (this.maxHookDistance > 0) {
            const dist = Vec3.distance(origin, this._targetPoint);
            if (dist > this.maxHookDistance) {
                if (this.debugLog) {
                    console.log('[GrapplingHook] ターゲットが最大フック距離を超えているため、発射をキャンセルしました。距離:', dist);
                }
                return;
            }
        }

        // フック先端を発射元にリセット
        this._hookTip.set(origin);

        // 状態を Hooking に切り替え
        this._setState(GrappleState.Hooking);

        // ロープを表示開始
        this._setRopeVisible(true);

        if (this.debugLog) {
            console.log('[GrapplingHook] フック発射: origin=', origin, ' target=', this._targetPoint);
        }
    }

    // =========================================================
    // 状態更新
    // =========================================================

    private _updateHooking(dt: number) {
        const origin = this.node.worldPosition;

        // 方向ベクトル = ターゲット - 現在のフック先端
        Vec3.subtract(this._tmpDir, this._targetPoint, this._hookTip);
        const distanceToTarget = this._tmpDir.length();

        if (distanceToTarget <= this.hitThreshold) {
            // 命中とみなす
            this._anchorPoint.set(this._targetPoint);
            this._setState(GrappleState.Pulling);

            if (this.debugLog) {
                console.log('[GrapplingHook] フック命中。アンカー地点:', this._anchorPoint);
            }
            return;
        }

        if (this.maxHookDistance > 0) {
            const distFromOrigin = Vec3.distance(origin, this._hookTip);
            if (distFromOrigin >= this.maxHookDistance) {
                // 最大距離で打ち切り
                if (this.debugLog) {
                    console.log('[GrapplingHook] フックが最大距離に達したためキャンセルされました。');
                }
                this._resetGrapple();
                return;
            }
        }

        // 正規化して速度分の距離だけ進める
        this._tmpDir.normalize();
        const moveDist = this.hookSpeed * dt;
        this._hookTip.x += this._tmpDir.x * moveDist;
        this._hookTip.y += this._tmpDir.y * moveDist;

        // ロープ描画更新
        this._updateRopeVisual(origin, this._hookTip);
    }

    private _updatePulling(dt: number) {
        const origin = this.node.worldPosition;

        // 自分からアンカー地点への方向
        Vec3.subtract(this._tmpDir, this._anchorPoint, origin);
        const distToAnchor = this._tmpDir.length();

        if (distToAnchor <= this.arriveThreshold) {
            // 牽引完了
            if (this.debugLog) {
                console.log('[GrapplingHook] 牽引完了。');
            }
            this._resetGrapple();
            return;
        }

        // 正規化して pullSpeed 分だけ移動
        this._tmpDir.normalize();
        const moveDist = this.pullSpeed * dt;

        // 万一 moveDist が残り距離を超える場合はアンカー地点にスナップ
        if (moveDist >= distToAnchor) {
            this.node.setWorldPosition(this._anchorPoint);
        } else {
            this._tmpV3.set(
                origin.x + this._tmpDir.x * moveDist,
                origin.y + this._tmpDir.y * moveDist,
                origin.z
            );
            this.node.setWorldPosition(this._tmpV3);
        }

        // プレイヤー位置更新後、ロープ描画更新(発射元が動くため)
        const newOrigin = this.node.worldPosition;
        this._updateRopeVisual(newOrigin, this._anchorPoint);
    }

    // =========================================================
    // ロープ描画
    // =========================================================

    /**
     * ロープの見た目(長さ・角度・位置)を更新する。
     * @param origin ロープの始点(通常は this.node.worldPosition)
     * @param tip ロープの終点(フック先端 or アンカー)
     */
    private _updateRopeVisual(origin: Vec3, tip: Vec3) {
        if (!this.ropeNode || !this._ropeSprite) {
            return;
        }

        const uiTrans = this.ropeNode.getComponent(UITransform);
        if (!uiTrans) {
            console.warn(
                '[GrapplingHook] ropeNode に UITransform が存在しません。' +
                'ロープの長さ変更ができないため、UITransform コンポーネントを追加してください。'
            );
            return;
        }

        // 始点→終点ベクトル
        Vec3.subtract(this._tmpDir, tip, origin);
        const length = this._tmpDir.length();

        // ロープノードはこのノードのローカル座標系にあるため、
        // origin(ワールド)→ this.node のローカルに変換
        const localOrigin = this.node.getComponent(UITransform)
            ? this.node.getComponent(UITransform)!.convertToNodeSpaceAR(origin, this._tmpV3)
            : this.node.inverseTransformPoint(this._tmpV3, origin);

        // ropeNode の位置を発射元に
        this.ropeNode.setPosition(localOrigin);

        // 角度を計算(atan2 でラジアン→度)
        const angleRad = Math.atan2(this._tmpDir.y, this._tmpDir.x);
        const angleDeg = math.toDegree(angleRad);
        this.ropeNode.setRotationFromEuler(0, 0, angleDeg);

        // 長さと太さを反映
        const ropeLength = Math.max(length, this.ropeMinLength);
        uiTrans.setContentSize(ropeLength, this.ropeWidth);

        // カラーも毎フレーム同期しておく(Inspector から変更された場合に対応)
        this._ropeSprite.color = this.ropeColor;
    }

    private _setRopeVisible(visible: boolean) {
        if (this.ropeNode) {
            this.ropeNode.active = visible;
        }
    }

    // =========================================================
    // 状態管理
    // =========================================================

    private _setState(newState: GrappleState) {
        if (this._state === newState) {
            return;
        }
        if (this.debugLog) {
            console.log(`[GrapplingHook] 状態遷移: ${GrappleState[this._state]} -> ${GrappleState[newState]}`);
        }
        this._state = newState;
    }

    private _resetGrapple() {
        this._setState(GrappleState.Idle);
        this._setRopeVisible(false);
    }

    // =========================================================
    // 外部から呼び出せる補助メソッド(任意)
    // =========================================================

    /**
     * 現在のフック動作を強制キャンセルして Idle に戻す。
     */
    public cancelGrapple() {
        if (this.debugLog) {
            console.log('[GrapplingHook] 外部からの要求によりフックをキャンセルしました。');
        }
        this._resetGrapple();
    }

}

コードの要点解説

  • onLoad
    • シーン内の Canvas を探し、画面座標→ワールド座標変換に利用。
    • _initRopeNode() でロープ用ノードと Sprite を準備(なければ自動生成)。
    • マウス・タッチ入力イベントを登録。
  • start
    • 開始時点ではロープを非表示にする。
  • update
    • 状態に応じて _updateHooking() または _updatePulling() を呼び出す。
  • _fireGrappleAtScreenPos
    • クリック/タップ位置(スクリーン座標)を Canvas 基準のワールド座標に変換。
    • 最大距離チェック後、フック先端位置とターゲットを設定し、Hooking 状態へ。
    • ロープの表示を開始。
  • _updateHooking
    • フック先端を hookSpeed でターゲットに向かって移動。
    • hitThreshold 以内に近づいたら命中とみなし、アンカーを固定して Pulling 状態へ。
    • 毎フレーム _updateRopeVisual でロープを伸ばす。
  • _updatePulling
    • 自分自身のワールド座標を pullSpeed でアンカー地点へ移動。
    • arriveThreshold 以内に入ったら牽引完了としてリセット。
    • 移動に合わせてロープ描画も更新。
  • _updateRopeVisual
    • 始点(自分)と終点(フック先端 or アンカー)の距離からロープ長を算出。
    • ロープノードを始点位置に配置し、atan2 で角度を求めて回転。
    • UITransform.setContentSize で長さと太さを反映。

使用手順と動作確認

1. TypeScript スクリプトの作成

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

2. シーンに Canvas を用意する

このコンポーネントはクリック位置をワールド座標に変換するために Canvas を参照します。既に UI 用 Canvas がある場合はそのままで構いません。

  1. Hierarchy パネルで Canvas が存在するか確認します。
  2. もしなければ:
    1. Hierarchy で右クリック → Create → UI → Canvas を選択します。
    2. 生成された Canvas ノードに Canvas コンポーネントと UITransform コンポーネントが付いていることを確認します(デフォルトで付いています)。

3. テスト用のプレイヤーノードを作成

フックで牽引される対象として、単純な Sprite ノードを用意します。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択します。
  2. 生成された Sprite ノードの名前を Player などに変更します。
  3. Inspector で Sprite コンポーネントに任意の画像を設定しておくと分かりやすいです。

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

  1. Hierarchy で先ほど作成した Player ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom → GrapplingHook を選択してアタッチします。

5. ロープ用ノード・見た目の設定

ロープを表示するための Sprite を設定します。

  1. Hierarchy で Player ノードの子としてロープ用ノードを作成してもよいですが、GrapplingHookropeNode 未設定時に自動生成 するため、最初は空のままでも動作します。
  2. ロープの画像(細長い線など)を用意していれば:
    1. Assets パネルに PNG 画像をインポートします。
    2. Inspector で Player ノードにアタッチした GrapplingHook を確認し、Rope Sprite Frame にその画像をドラッグ&ドロップします。
  3. ロープの太さを調整したい場合は、Rope Width を 4〜16 くらいの値に設定してみてください。
  4. ロープの色を変えたい場合は、Rope Color で任意の色に変更できます。

6. フック挙動のパラメータ調整

Player ノードを選択し、Inspector の GrapplingHook セクションで以下のように設定してみましょう。

  • Enable Mouse Input: チェック(PC でテストする場合)
  • Enable Touch Input: 任意(モバイル向けならチェック)
  • Fire On Press: チェック(押した瞬間にフック発射)
  • Hook Speed: 800〜1200(フックの伸びる速さ)
  • Pull Speed: 500〜800(プレイヤーの引っ張られる速さ)
  • Max Hook Distance: 1500(ステージサイズに応じて調整)
  • Hit Threshold: 20(ターゲットへの命中判定距離)
  • Arrive Threshold: 30(プレイヤーがアンカーに到達したとみなす距離)
  • Allow While Moving: 最初はオフでもよい(挙動を理解してからオンに)
  • Debug Log: 最初はオンにしてログを確認すると挙動が分かりやすいです。

7. 再生して動作確認

  1. エディタ上部の Play ボタンを押してゲームを再生します。
  2. Game ビュー上で任意の位置をクリックします。
    • クリック位置に向かってロープが伸びていく様子が見えるはずです。
    • ロープ先端がターゲット地点に到達すると、プレイヤーノードがその地点に向かって引っ張られます。
    • プレイヤーがアンカー地点に近づくとロープが消え、再度クリックすると新しいフックを撃てます。
  3. Console に [GrapplingHook] ログが出ていれば、状態遷移も確認できます(Debug Log がオンの場合)。

8. よくあるつまずきポイントと対処

  • ロープが表示されない
    • ropeSpriteFrame が未設定で、かつ ropeNode の Sprite にも SpriteFrame が無いと、ロープは描画されません。
    • Assets から細長い画像をインポートし、Rope Sprite Frame に割り当ててください。
  • クリックしても何も起きない
    • シーンに Canvas ノードが存在するか確認してください。
    • GrapplingHook の Enable Mouse Input がオンになっているか確認してください。
    • Max Hook Distance が極端に小さいと、多くのターゲットが射程外になりキャンセルされます。
  • プレイヤーが引っ張られない / ほとんど動かない
    • Pull Speed が小さすぎないか確認してください(例: 50 など)。
    • Arrive Threshold が大きすぎると、すぐに到達扱いになってしまう場合があります。

まとめ

この GrapplingHook コンポーネントは、

  • 任意のノードにアタッチするだけで「クリック位置にロープを伸ばし、その地点へ自分を牽引する」フックショット機能を提供します。
  • 外部の GameManager やシングルトンに一切依存せず、CanvasSprite / UITransform といった標準コンポーネントだけで完結しています。
  • 入力種別(マウス / タッチ)、フック速度、牽引速度、最大距離、命中判定距離、ロープの太さや色など、ゲームごとに変わるパラメータはすべてインスペクタから調整可能です。

このままでも 2D アクションゲームのフックショットとして十分に使えますが、応用として:

  • アンカー地点に到達したときにジャンプ力を加算して「スイング」させる。
  • 特定レイヤー(地形だけ)にしかフックできないように、レイキャストと組み合わせる。
  • ロープの見た目をアニメーションさせたり、パーティクルを追加する。

といった拡張も、すべてこのコンポーネント単体をベースに行えます。
ゲームのプレイヤーキャラやカメラ、あるいは敵キャラにまでこの GrapplingHook を再利用して、フックショットを活用した多彩な演出やギミックを実装してみてください。