【Cocos Creator】アタッチするだけ!Quicksand (流砂)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】Quicksand(流砂)の実装:アタッチするだけで「入ると移動速度が低下し、徐々に沈んでいく」エリアを作れる汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「流砂エリア」を作れる Quicksand コンポーネントを実装します。
プレイヤーなどのオブジェクトがこのエリアに入ると、移動速度を減速させ、Y軸方向に引きずり込む(沈める)挙動を実現します。

特徴:

  • 他のカスタムスクリプトに一切依存しない完全独立コンポーネント
  • インスペクタから「減速率」「沈み速度」「適用対象」などを細かく調整可能
  • 2D/3Dどちらでも利用可能(Transform の Y 座標を直接操作)

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

1. 機能要件の整理

  • このコンポーネントをアタッチしたノードが「流砂エリア」となる。
  • 対象オブジェクトが流砂エリアに「入った時」:
    • そのオブジェクトの移動速度を低下させる。
    • Y軸方向に徐々に沈める(座標を下げる)。
  • 対象オブジェクトが流砂エリアから「出た時」:
    • 速度低下の影響を解除する(元の係数に戻す)。
    • 沈み処理も停止する。
  • 外部の GameManager や Player スクリプトに依存しない。
  • 「速度低下」は「対象が持つ任意の速度ベクトル」を直接いじらず、
    • 対象側に用意された「速度スケール(倍率)」プロパティを乗算で弱めるという設計にする。
    • このプロパティ名をインスペクタで文字列指定できるようにし、どんなプレイヤー実装にも対応できるようにする。

なぜこの設計か:

  • 他スクリプトの実装を一切知らない前提では、「速度そのもの」を安全に操作することは難しい。
  • そこで、「対象側が speedScalemoveMultiplier のようなプロパティを持っている前提」で、その値を 0~1 に絞るアプローチにする。
  • プロパティ名をインスペクタから文字列で指定できるようにし、対象の実装に合わせて柔軟に対応する。

また、流砂エリアへの「侵入/離脱」を検出する方法としては:

  • コライダーによるトリガー検出を採用します。
  • 3D物理(Collider + RigidBody)と 2D物理(Collider2D + RigidBody2D)の両方に対応するため、両方のイベントを購読する実装にします。

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

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

  • 対象の絞り込み
    • targetNodeName (string)
      • 流砂の影響を受けるノード名。
      • 空文字なら「名前での絞り込みなし」。
    • targetTag (string)
      • 流砂の影響を受けるノブに付ける任意のタグ文字列。
      • 対象ノードに QuicksandTargetTag コンポーネント(後述)を付け、その tag プロパティと一致した場合のみ影響を与える。
      • 空文字なら「タグでの絞り込みなし」。
  • 速度低下関連
    • useSpeedScaleProperty (boolean)
      • true: 対象ノードの任意スクリプトにある「速度スケール用プロパティ」を操作する。
      • false: 速度スケールは操作せず、「沈み」だけを適用する。
    • speedScalePropertyName (string)
      • 対象ノード側のスクリプトが持つ「速度スケール」的なプロパティ名。
      • 例: speedScale, moveMultiplier など。
      • useSpeedScalePropertytrue のときのみ使用。
    • inQuicksandSpeedScale (number)
      • 流砂の中にいる間に適用する速度スケール(倍率)。
      • 0 ~ 1 の値を推奨(例: 0.3 なら移動速度 30%)。
    • lerpIntoSpeedScaleDuration (number)
      • 流砂に入ったときに、元のスケールから inQuicksandSpeedScale まで補間する時間(秒)。
      • 0 にすると瞬時に切り替え。
    • lerpOutSpeedScaleDuration (number)
      • 流砂から出たときに、元のスケールへ戻す時間(秒)。
  • 沈み挙動関連
    • sinkSpeed (number)
      • 1秒あたりに下方向(-Y)へ移動させる距離。
      • 例: 1.0 なら 1秒で 1ユニット沈む。
    • maxSinkDistance (number)
      • 沈み始めた位置からどれだけ最大で沈むか(ユニット)。
      • 0 以下なら「制限なし」(沈み続ける)。
    • restorePositionOnExit (boolean)
      • true: 流砂から出たときに、沈み始めた Y 座標まで戻す。
      • false: 沈んだ位置のままにする。
    • restorePositionDuration (number)
      • Y 座標を元に戻すときの補間時間(秒)。
      • restorePositionOnExittrue のときのみ使用。
  • デバッグ・補助
    • debugLog (boolean)
      • 侵入/離脱、速度スケール変更などのログを出すかどうか。

3. 必要な標準コンポーネントと防御的な実装

流砂エリア側(このコンポーネントを付けるノード)には、物理トリガー検出のために以下のいずれかが必要です。

  • 3D物理:
    • Collider(例: BoxCollider, SphereCollider など)
    • isTrigger = true に設定
  • 2D物理:
    • Collider2D(例: BoxCollider2D, CircleCollider2D など)
    • sensor = true に設定

コード内では、getComponent(Collider)getComponent(Collider2D) を試み、どちらも存在しない場合は console.error で警告を出します。
また、トリガー/センサ設定がされていない場合も警告して、エディタでの設定を促します。


TypeScriptコードの実装

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


import { _decorator, Component, Node, Collider, ITriggerEvent, Collider2D, IPhysics2DContact, Vec3, math } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 任意のノードに付けて「流砂エリア」を実現する汎用コンポーネント。
 *
 * ・このノードに 3D Collider(Trigger) または 2D Collider2D(Sensor) を付けてください。
 * ・対象ノードは、名前 or タグ で絞り込みます。
 * ・速度低下は「対象ノードの任意スクリプトが持つ速度スケール用プロパティ」を操作することで実現します。
 */
@ccclass('Quicksand')
export class Quicksand extends Component {

    // =========================
    // 対象の絞り込み
    // =========================

    @property({
        tooltip: '流砂の影響を受けるノード名。\n空文字の場合は名前での絞り込みを行いません。'
    })
    public targetNodeName: string = '';

    @property({
        tooltip: '流砂の影響を受けるノードに付ける任意のタグ文字列。\n' +
            '対象ノードに QuicksandTargetTag コンポーネントを付け、その tag プロパティと一致した場合のみ影響を与えます。\n' +
            '空文字の場合はタグでの絞り込みを行いません。'
    })
    public targetTag: string = '';

    // =========================
    // 速度低下関連
    // =========================

    @property({
        tooltip: 'true の場合、対象ノードの任意スクリプトが持つ「速度スケール用プロパティ」を操作して移動速度を低下させます。\n' +
            'false の場合、速度スケールは変更せず、沈み挙動のみ適用します。'
    })
    public useSpeedScaleProperty: boolean = true;

    @property({
        tooltip: '対象ノード側のスクリプトが持つ「速度スケール」プロパティ名。\n' +
            '例: "speedScale", "moveMultiplier" など。\n' +
            'useSpeedScaleProperty が true の場合にのみ使用します。'
    })
    public speedScalePropertyName: string = 'speedScale';

    @property({
        tooltip: '流砂の中にいる間に適用する速度スケール(倍率)。\n0~1 の値を推奨(0.3 なら移動速度 30%)。'
    })
    public inQuicksandSpeedScale: number = 0.3;

    @property({
        tooltip: '流砂に入ったときに、元のスケールから inQuicksandSpeedScale まで補間する時間(秒)。\n0 にすると瞬時に切り替えます。'
    })
    public lerpIntoSpeedScaleDuration: number = 0.3;

    @property({
        tooltip: '流砂から出たときに、元のスケールへ戻す時間(秒)。\n0 にすると瞬時に戻します。'
    })
    public lerpOutSpeedScaleDuration: number = 0.3;

    // =========================
    // 沈み挙動関連
    // =========================

    @property({
        tooltip: '1秒あたりに下方向(-Y)へ移動させる距離。1.0 なら 1秒で 1ユニット沈みます。'
    })
    public sinkSpeed: number = 1.0;

    @property({
        tooltip: '沈み始めた位置からどれだけ最大で沈むか(ユニット)。\n0 以下の場合は制限なし(沈み続けます)。'
    })
    public maxSinkDistance: number = 2.0;

    @property({
        tooltip: 'true の場合、流砂から出たときに沈み始めた Y 座標まで戻します。\nfalse の場合は沈んだ位置のままにします。'
    })
    public restorePositionOnExit: boolean = true;

    @property({
        tooltip: 'Y 座標を元に戻すときの補間時間(秒)。restorePositionOnExit が true の場合のみ使用します。'
    })
    public restorePositionDuration: number = 0.3;

    // =========================
    // デバッグ
    // =========================

    @property({
        tooltip: 'true の場合、侵入/離脱や速度スケール変更などのデバッグログを出力します。'
    })
    public debugLog: boolean = false;

    // =========================
    // 内部管理用
    // =========================

    private _collider3D: Collider | null = null;
    private _collider2D: Collider2D | null = null;

    /**
     * 流砂の影響を受けている対象ごとの状態。
     */
    private _targets: Map<Node, {
        originalSpeedScale: number | null;   // null の場合は速度スケール未使用
        currentSpeedScale: number | null;
        speedLerpTime: number;
        speedLerpDuration: number;
        speedLerpFrom: number;
        speedLerpTo: number;

        startY: number;                      // 沈み始めたときの Y 座標
        currentSinkDistance: number;         // どれだけ沈んだか
        restoreYFrom: number;                // 戻し開始時の Y
        restoreYTo: number;                  // 戻し目標の Y(= startY)
        restoreYTime: number;                // 戻し経過時間
        restoreYDuration: number;            // 戻しにかける時間
        isInQuicksand: boolean;              // 現在流砂内かどうか
    >> = new Map();

    onLoad() {
        // 3D Collider の取得
        this._collider3D = this.getComponent(Collider);
        // 2D Collider の取得
        this._collider2D = this.getComponent(Collider2D);

        if (!this._collider3D && !this._collider2D) {
            console.error('[Quicksand] このノードには Collider も Collider2D もアタッチされていません。' +
                '流砂エリアとして動作させるには、3D Collider(Trigger) または 2D Collider2D(Sensor) を追加してください。', this.node);
        }

        // 3D Collider のイベント登録
        if (this._collider3D) {
            if (!this._collider3D.isTrigger) {
                console.warn('[Quicksand] 3D Collider の isTrigger が false です。トリガーとして動作させるには true に設定してください。', this.node);
            }
            this._collider3D.on('onTriggerEnter', this._onTriggerEnter3D, this);
            this._collider3D.on('onTriggerExit', this._onTriggerExit3D, this);
        }

        // 2D Collider のイベント登録
        if (this._collider2D) {
            if (!this._collider2D.sensor) {
                console.warn('[Quicksand] 2D Collider2D の sensor が false です。センサとして動作させるには true に設定してください。', this.node);
            }
            this._collider2D.on('onBeginContact', this._onBeginContact2D, this);
            this._collider2D.on('onEndContact', this._onEndContact2D, this);
        }

        // プロパティの簡易バリデーション
        if (this.lerpIntoSpeedScaleDuration < 0) this.lerpIntoSpeedScaleDuration = 0;
        if (this.lerpOutSpeedScaleDuration < 0) this.lerpOutSpeedScaleDuration = 0;
        if (this.restorePositionDuration < 0) this.restorePositionDuration = 0;
    }

    onDestroy() {
        // イベント登録解除
        if (this._collider3D) {
            this._collider3D.off('onTriggerEnter', this._onTriggerEnter3D, this);
            this._collider3D.off('onTriggerExit', this._onTriggerExit3D, this);
        }
        if (this._collider2D) {
            this._collider2D.off('onBeginContact', this._onBeginContact2D, this);
            this._collider2D.off('onEndContact', this._onEndContact2D, this);
        }
        this._targets.clear();
    }

    update(deltaTime: number) {
        // すべての対象に対して沈み・速度補間・位置補間を更新
        this._targets.forEach((state, targetNode) => {

            // すでに破棄されたノードは削除
            if (!targetNode.isValid) {
                this._targets.delete(targetNode);
                return;
            }

            // =========================
            // 速度スケールの補間
            // =========================
            if (this.useSpeedScaleProperty && state.originalSpeedScale != null && state.currentSpeedScale != null) {
                if (state.speedLerpDuration > 0 && state.speedLerpTime < state.speedLerpDuration) {
                    state.speedLerpTime += deltaTime;
                    const t = math.clamp01(state.speedLerpTime / state.speedLerpDuration);
                    const newScale = math.lerp(state.speedLerpFrom, state.speedLerpTo, t);
                    state.currentSpeedScale = newScale;
                    this._applySpeedScaleToTarget(targetNode, newScale);
                }
            }

            // =========================
            // 沈み処理
            // =========================
            if (state.isInQuicksand) {
                if (this.sinkSpeed !== 0) {
                    const sinkDelta = this.sinkSpeed * deltaTime;
                    let newSinkDistance = state.currentSinkDistance + sinkDelta;

                    if (this.maxSinkDistance > 0) {
                        newSinkDistance = Math.min(newSinkDistance, this.maxSinkDistance);
                    }

                    const actualDelta = newSinkDistance - state.currentSinkDistance;
                    state.currentSinkDistance = newSinkDistance;

                    if (actualDelta !== 0) {
                        const pos = targetNode.worldPosition.clone();
                        pos.y -= actualDelta;
                        targetNode.worldPosition = pos;
                    }
                }

                // 流砂内にいる間は位置戻し補間はリセット
                state.restoreYTime = 0;
            } else {
                // =========================
                // 位置戻し処理
                // =========================
                if (this.restorePositionOnExit && this.restorePositionDuration > 0 && state.restoreYTime < state.restoreYDuration) {
                    state.restoreYTime += deltaTime;
                    const t = math.clamp01(state.restoreYTime / state.restoreYDuration);
                    const pos = targetNode.worldPosition.clone();
                    pos.y = math.lerp(state.restoreYFrom, state.restoreYTo, t);
                    targetNode.worldPosition = pos;
                }
            }
        });
    }

    // =========================
    // 3D Trigger イベント
    // =========================

    private _onTriggerEnter3D(event: ITriggerEvent) {
        const otherNode = event.otherCollider.node;
        this._handleEnter(otherNode);
    }

    private _onTriggerExit3D(event: ITriggerEvent) {
        const otherNode = event.otherCollider.node;
        this._handleExit(otherNode);
    }

    // =========================
    // 2D Contact イベント
    // =========================

    private _onBeginContact2D(self: Collider2D, other: Collider2D, _contact: IPhysics2DContact | null) {
        const otherNode = other.node;
        this._handleEnter(otherNode);
    }

    private _onEndContact2D(self: Collider2D, other: Collider2D, _contact: IPhysics2DContact | null) {
        const otherNode = other.node;
        this._handleExit(otherNode);
    }

    // =========================
    // 侵入・離脱の共通処理
    // =========================

    private _handleEnter(targetNode: Node) {
        if (!this._isTargetNode(targetNode)) {
            return;
        }

        if (this.debugLog) {
            console.log('[Quicksand] Enter:', targetNode.name);
        }

        let state = this._targets.get(targetNode);
        if (!state) {
            // 新規侵入
            const pos = targetNode.worldPosition.clone();
            const currentY = pos.y;

            // 速度スケール取得
            let originalScale: number | null = null;
            if (this.useSpeedScaleProperty) {
                originalScale = this._getSpeedScaleFromTarget(targetNode);
                if (originalScale == null) {
                    if (this.debugLog) {
                        console.warn('[Quicksand] 対象ノードに指定された速度スケールプロパティが見つからないため、速度低下は無効になります。', targetNode);
                    }
                }
            }

            state = {
                originalSpeedScale: originalScale,
                currentSpeedScale: originalScale,
                speedLerpTime: 0,
                speedLerpDuration: 0,
                speedLerpFrom: originalScale ?? 1,
                speedLerpTo: originalScale ?? 1,

                startY: currentY,
                currentSinkDistance: 0,
                restoreYFrom: currentY,
                restoreYTo: currentY,
                restoreYTime: 0,
                restoreYDuration: this.restorePositionDuration,
                isInQuicksand: true,
            };

            this._targets.set(targetNode, state);
        } else {
            // すでに状態がある場合は「再侵入」とみなす
            state.isInQuicksand = true;
            // 再侵入時に位置基準を更新したい場合はここで更新してもよいが、
            // 今回は元の startY を維持する実装とする。
        }

        // 速度スケールを inQuicksandSpeedScale へ補間
        if (this.useSpeedScaleProperty && state.originalSpeedScale != null) {
            state.speedLerpTime = 0;
            state.speedLerpDuration = this.lerpIntoSpeedScaleDuration;
            state.speedLerpFrom = state.currentSpeedScale ?? state.originalSpeedScale;
            state.speedLerpTo = state.originalSpeedScale * this.inQuicksandSpeedScale;

            if (state.speedLerpDuration === 0) {
                state.currentSpeedScale = state.speedLerpTo;
                this._applySpeedScaleToTarget(targetNode, state.speedLerpTo);
            }
        }
    }

    private _handleExit(targetNode: Node) {
        const state = this._targets.get(targetNode);
        if (!state) {
            return;
        }

        if (this.debugLog) {
            console.log('[Quicksand] Exit:', targetNode.name);
        }

        state.isInQuicksand = false;

        // 速度スケールを元に戻す補間
        if (this.useSpeedScaleProperty && state.originalSpeedScale != null) {
            state.speedLerpTime = 0;
            state.speedLerpDuration = this.lerpOutSpeedScaleDuration;
            state.speedLerpFrom = state.currentSpeedScale ?? state.originalSpeedScale;
            state.speedLerpTo = state.originalSpeedScale;

            if (state.speedLerpDuration === 0) {
                state.currentSpeedScale = state.originalSpeedScale;
                this._applySpeedScaleToTarget(targetNode, state.originalSpeedScale);
            }
        }

        // 位置を戻す設定
        if (this.restorePositionOnExit) {
            const pos = targetNode.worldPosition.clone();
            state.restoreYFrom = pos.y;
            state.restoreYTo = state.startY;
            state.restoreYTime = 0;
            state.restoreYDuration = this.restorePositionDuration;

            if (state.restoreYDuration === 0) {
                pos.y = state.restoreYTo;
                targetNode.worldPosition = pos;
            }
        }
    }

    // =========================
    // 対象判定・速度スケール操作
    // =========================

    /**
     * このノードが流砂の影響対象かどうか判定する。
     */
    private _isTargetNode(node: Node): boolean {
        // 自分自身は無視
        if (node === this.node) {
            return false;
        }

        // 名前での絞り込み
        if (this.targetNodeName && node.name !== this.targetNodeName) {
            return false;
        }

        // タグでの絞り込み(任意の QuicksandTargetTag コンポーネントを利用)
        if (this.targetTag) {
            const tagComp = node.getComponent(QuicksandTargetTag);
            if (!tagComp || tagComp.tag !== this.targetTag) {
                return false;
            }
        }

        return true;
    }

    /**
     * 対象ノードから速度スケール用プロパティを取得する。
     * どのコンポーネントにあるか分からないため、ノードに付いているすべてのコンポーネントを走査し、
     * 最初に該当プロパティ名を持つものを採用する。
     */
    private _getSpeedScaleFromTarget(node: Node): number | null {
        if (!this.speedScalePropertyName) {
            return null;
        }

        const comps = node.components;
        for (const comp of comps) {
            const anyComp = comp as any;
            if (this.speedScalePropertyName in anyComp) {
                const value = anyComp[this.speedScalePropertyName];
                if (typeof value === 'number') {
                    return value;
                }
            }
        }
        return null;
    }

    /**
     * 対象ノードの速度スケール用プロパティに値を設定する。
     */
    private _applySpeedScaleToTarget(node: Node, scale: number) {
        if (!this.speedScalePropertyName) {
            return;
        }

        const comps = node.components;
        for (const comp of comps) {
            const anyComp = comp as any;
            if (this.speedScalePropertyName in anyComp) {
                if (typeof anyComp[this.speedScalePropertyName] === 'number') {
                    anyComp[this.speedScalePropertyName] = scale;
                    if (this.debugLog) {
                        console.log(`[Quicksand] speedScaleProperty "${this.speedScalePropertyName}" を ${scale} に設定`, node);
                    }
                    return;
                }
            }
        }
    }
}

/**
 * 任意のノードに付けて「流砂対象タグ」を指定するための補助コンポーネント。
 * 他のスクリプトには依存していないため、単体でも安全に利用できます。
 */
@ccclass('QuicksandTargetTag')
export class QuicksandTargetTag extends Component {

    @property({
        tooltip: 'このノードに付与する任意のタグ文字列。\nQuicksand コンポーネントの targetTag と一致した場合にのみ流砂の影響を受けます。'
    })
    public tag: string = 'Player';
}

主要な処理の解説

  • onLoad
    • 3D用 Collider と 2D用 Collider2D を取得。
    • どちらも無い場合は console.error で警告。
    • それぞれのトリガー/コンタクトイベントにハンドラを登録。
  • トリガー/コンタクトイベント
    • _onTriggerEnter3D, _onBeginContact2D_handleEnter を呼び出し。
    • _onTriggerExit3D, _onEndContact2D_handleExit を呼び出し。
  • _handleEnter
    • _isTargetNode で対象判定(名前/タグ)を行う。
    • 初回侵入なら
      • 現在の Y 座標を startY として記録。
      • 対象ノードから速度スケールプロパティを探して初期値を保存。
    • 速度スケールを inQuicksandSpeedScale へ補間する設定を行う。
  • _handleExit
    • 対象の isInQuicksandfalse にする。
    • 速度スケールを元の値へ戻す補間を設定。
    • restorePositionOnExittrue の場合、Y座標を startY へ戻す補間を設定。
  • update
    • 各対象ごとに:
      • 速度スケールの補間(math.lerp)。
      • 流砂内にいる間の沈み処理(Y座標を減少)。
      • 流砂から出た後の位置戻し処理。
  • 速度スケールの操作
    • _getSpeedScaleFromTarget: 対象ノードに付いている全コンポーネントを走査し、指定名の数値プロパティを探す。
    • _applySpeedScaleToTarget: 同様にプロパティを探して値を書き換える。
    • これにより、対象側の実装に依存せず「名前だけ合わせれば連携できる」仕組みになる。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、任意のフォルダ(例: assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. ファイル名を Quicksand.ts に変更します。
  4. 生成された Quicksand.ts をダブルクリックして開き、上記のコード全文を貼り付けて保存します。

2. テスト用シーンの準備(2D例)

ここでは 2D プロジェクトを例に説明しますが、3D でも基本は同様です。

  1. プレイヤーノードを作成
    1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選び、Player ノードを作成します。
    2. Player ノードに RigidBody2DBoxCollider2D を追加します。
      • Inspector → Add ComponentPhysics 2DRigidBody2D
      • Inspector → Add ComponentPhysics 2DBoxCollider2D
    3. BoxCollider2D のサイズをスプライトに合わせて調整します。
    4. プレイヤーの移動用に、簡単なスクリプトを用意します(例)。

    例: SimplePlayerMove.ts

    
    import { _decorator, Component, Vec3, input, Input, EventKeyboard, KeyCode } from 'cc';
    const { ccclass, property } = _decorator;
    
    @ccclass('SimplePlayerMove')
    export class SimplePlayerMove extends Component {
    
        @property
        public moveSpeed: number = 5;
    
        // Quicksand から操作される速度スケール
        @property
        public speedScale: number = 1.0;
    
        private _dir: Vec3 = new Vec3();
    
        onLoad() {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
        }
    
        onDestroy() {
            input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
        }
    
        private _onKeyDown(event: EventKeyboard) {
            if (event.keyCode === KeyCode.KEY_A) this._dir.x = -1;
            if (event.keyCode === KeyCode.KEY_D) this._dir.x = 1;
            if (event.keyCode === KeyCode.KEY_W) this._dir.y = 1;
            if (event.keyCode === KeyCode.KEY_S) this._dir.y = -1;
        }
    
        private _onKeyUp(event: EventKeyboard) {
            if (event.keyCode === KeyCode.KEY_A || event.keyCode === KeyCode.KEY_D) this._dir.x = 0;
            if (event.keyCode === KeyCode.KEY_W || event.keyCode === KeyCode.KEY_S) this._dir.y = 0;
        }
    
        update(deltaTime: number) {
            const pos = this.node.position.clone();
            pos.x += this._dir.x * this.moveSpeed * this.speedScale * deltaTime;
            pos.y += this._dir.y * this.moveSpeed * this.speedScale * deltaTime;
            this.node.setPosition(pos);
        }
    }
    
    1. Player ノードを選択し、Inspector で Add ComponentCustomSimplePlayerMove を追加します。
    2. SimplePlayerMovemoveSpeed510 程度に設定します。
  2. 流砂エリアノードを作成
    1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選び、QuicksandArea ノードを作成します。
    2. Inspector でスプライト画像を砂っぽいテクスチャに設定し、サイズをプレイヤーが通れる程度に広げます。
    3. QuicksandAreaBoxCollider2D を追加します。
      • Inspector → Add ComponentPhysics 2DBoxCollider2D
      • sensor チェックボックスを ON にします(トリガーとして動作させるため)。
    4. QuicksandAreaQuicksand コンポーネントを追加します。
      • Inspector → Add ComponentCustomQuicksand
  3. 対象タグの設定(推奨)
    1. Player ノードを選択し、Inspector で Add ComponentCustomQuicksandTargetTag を追加します。
    2. QuicksandTargetTagtagPlayer など任意の文字列に設定します。
    3. QuicksandArea ノードを選択し、Quicksand コンポーネントのプロパティを以下のように設定します(例)。

    例:

    • targetNodeName: 空のまま(任意)
    • targetTag: Player
    • useSpeedScaleProperty: true
    • speedScalePropertyName: speedScaleSimplePlayerMove に合わせる)
    • inQuicksandSpeedScale: 0.3
    • lerpIntoSpeedScaleDuration: 0.2
    • lerpOutSpeedScaleDuration: 0.2
    • sinkSpeed: 1.0
    • maxSinkDistance: 2.0
    • restorePositionOnExit: true
    • restorePositionDuration: 0.3
    • debugLog: 開発中は true にすると挙動が分かりやすいです。

3. 動作確認

  1. メインツールバーの Play ボタン(▶)を押してゲームを実行します。
  2. キーボードの WASD キーで Player を移動させ、QuicksandArea に入ってみます。
  3. 以下の挙動を確認します。
    • 流砂に入った瞬間から、移動速度が徐々に落ちていき、最終的に約 30% になる。
    • 流砂の上にいる間、プレイヤーがゆっくりと下方向(-Y)に沈んでいく。
    • ある程度沈んだら(maxSinkDistance)、それ以上は沈まない。
    • 流砂から出ると、速度が元に戻り、Y座標もゆっくり元の高さまで戻る。
    • debugLog を ON にしている場合、コンソールに Enter/Exit や speedScale の変更ログが出る。

4. 3Dでの利用ポイント

  • 2D の代わりに 3D の NodeMeshRendererBoxCollider などを使います。
  • BoxColliderisTrigger を ON にします。
  • プレイヤーには RigidBodyCollider を付け、移動スクリプトに speedScale プロパティを用意します。
  • その他の設定は 2D とほぼ同様です。

まとめ

この Quicksand コンポーネントを使うことで、

  • 任意のノードを「流砂エリア」に変えることができる。
  • 対象の移動実装に強く依存せず、「速度スケール用プロパティ名」さえ合わせれば減速効果を付与できる。
  • 沈み量や速度、戻し方をすべてインスペクタから調整でき、ゲームデザインの試行錯誤がしやすい。
  • タグベースで対象を絞れるため、「プレイヤーだけ沈む」「特定の敵だけ沈む」といった表現も簡単。

他のギミック(泥沼、氷床、加速床など)も、同じ設計パターン(エリア+対象タグ+スケールプロパティ操作)で汎用コンポーネントとして切り出すと、
シーンに「アタッチしてパラメータを調整するだけ」で様々な仕掛けを追加できるようになり、ゲーム開発の効率が大きく向上します。

この Quicksand.ts 1ファイルだけで完結しているので、必要なシーンにコピーして貼り付けるだけで再利用できます。
プロジェクトに合わせて初期値やプロパティ名を調整し、自分のゲームに合った「流砂表現」を作り込んでみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!