【Cocos Creator 3.8】Quicksand(流砂)の実装:アタッチするだけで「入ると移動速度が低下し、徐々に沈んでいく」エリアを作れる汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「流砂エリア」を作れる Quicksand コンポーネントを実装します。
プレイヤーなどのオブジェクトがこのエリアに入ると、移動速度を減速させ、Y軸方向に引きずり込む(沈める)挙動を実現します。
特徴:
- 他のカスタムスクリプトに一切依存しない完全独立コンポーネント
- インスペクタから「減速率」「沈み速度」「適用対象」などを細かく調整可能
- 2D/3Dどちらでも利用可能(Transform の Y 座標を直接操作)
コンポーネントの設計方針
1. 機能要件の整理
- このコンポーネントをアタッチしたノードが「流砂エリア」となる。
- 対象オブジェクトが流砂エリアに「入った時」:
- そのオブジェクトの移動速度を低下させる。
- Y軸方向に徐々に沈める(座標を下げる)。
- 対象オブジェクトが流砂エリアから「出た時」:
- 速度低下の影響を解除する(元の係数に戻す)。
- 沈み処理も停止する。
- 外部の GameManager や Player スクリプトに依存しない。
- 「速度低下」は「対象が持つ任意の速度ベクトル」を直接いじらず、
- 対象側に用意された「速度スケール(倍率)」プロパティを乗算で弱めるという設計にする。
- このプロパティ名をインスペクタで文字列指定できるようにし、どんなプレイヤー実装にも対応できるようにする。
なぜこの設計か:
- 他スクリプトの実装を一切知らない前提では、「速度そのもの」を安全に操作することは難しい。
- そこで、「対象側が
speedScaleやmoveMultiplierのようなプロパティを持っている前提」で、その値を 0~1 に絞るアプローチにする。 - プロパティ名をインスペクタから文字列で指定できるようにし、対象の実装に合わせて柔軟に対応する。
また、流砂エリアへの「侵入/離脱」を検出する方法としては:
- コライダーによるトリガー検出を採用します。
- 3D物理(
Collider+RigidBody)と 2D物理(Collider2D+RigidBody2D)の両方に対応するため、両方のイベントを購読する実装にします。
2. インスペクタで設定可能なプロパティ設計
以下のプロパティを用意します。
- 対象の絞り込み
targetNodeName(string)- 流砂の影響を受けるノード名。
- 空文字なら「名前での絞り込みなし」。
targetTag(string)- 流砂の影響を受けるノブに付ける任意のタグ文字列。
- 対象ノードに
QuicksandTargetTagコンポーネント(後述)を付け、そのtagプロパティと一致した場合のみ影響を与える。 - 空文字なら「タグでの絞り込みなし」。
- 速度低下関連
useSpeedScaleProperty(boolean)true: 対象ノードの任意スクリプトにある「速度スケール用プロパティ」を操作する。false: 速度スケールは操作せず、「沈み」だけを適用する。
speedScalePropertyName(string)- 対象ノード側のスクリプトが持つ「速度スケール」的なプロパティ名。
- 例:
speedScale,moveMultiplierなど。 useSpeedScalePropertyがtrueのときのみ使用。
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 座標を元に戻すときの補間時間(秒)。
restorePositionOnExitがtrueのときのみ使用。
- デバッグ・補助
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で警告。 - それぞれのトリガー/コンタクトイベントにハンドラを登録。
- 3D用
- トリガー/コンタクトイベント
_onTriggerEnter3D,_onBeginContact2Dで_handleEnterを呼び出し。_onTriggerExit3D,_onEndContact2Dで_handleExitを呼び出し。
_handleEnter_isTargetNodeで対象判定(名前/タグ)を行う。- 初回侵入なら
- 現在の Y 座標を
startYとして記録。 - 対象ノードから速度スケールプロパティを探して初期値を保存。
- 現在の Y 座標を
- 速度スケールを
inQuicksandSpeedScaleへ補間する設定を行う。
_handleExit- 対象の
isInQuicksandをfalseにする。 - 速度スケールを元の値へ戻す補間を設定。
restorePositionOnExitがtrueの場合、Y座標をstartYへ戻す補間を設定。
- 対象の
update- 各対象ごとに:
- 速度スケールの補間(
math.lerp)。 - 流砂内にいる間の沈み処理(Y座標を減少)。
- 流砂から出た後の位置戻し処理。
- 速度スケールの補間(
- 各対象ごとに:
- 速度スケールの操作
_getSpeedScaleFromTarget: 対象ノードに付いている全コンポーネントを走査し、指定名の数値プロパティを探す。_applySpeedScaleToTarget: 同様にプロパティを探して値を書き換える。- これにより、対象側の実装に依存せず「名前だけ合わせれば連携できる」仕組みになる。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、任意のフォルダ(例:
assets/scripts)を選択します。 - 右クリック → Create → TypeScript を選択します。
- ファイル名を
Quicksand.tsに変更します。 - 生成された
Quicksand.tsをダブルクリックして開き、上記のコード全文を貼り付けて保存します。
2. テスト用シーンの準備(2D例)
ここでは 2D プロジェクトを例に説明しますが、3D でも基本は同様です。
- プレイヤーノードを作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選び、
Playerノードを作成します。 Playerノードに RigidBody2D と BoxCollider2D を追加します。- Inspector → Add Component → Physics 2D → RigidBody2D
- Inspector → Add Component → Physics 2D → BoxCollider2D
BoxCollider2Dのサイズをスプライトに合わせて調整します。- プレイヤーの移動用に、簡単なスクリプトを用意します(例)。
例:
SimplePlayerMove.tsimport { _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); } }Playerノードを選択し、Inspector で Add Component → Custom → SimplePlayerMove を追加します。SimplePlayerMoveのmoveSpeedを5~10程度に設定します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選び、
- 流砂エリアノードを作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選び、
QuicksandAreaノードを作成します。 - Inspector でスプライト画像を砂っぽいテクスチャに設定し、サイズをプレイヤーが通れる程度に広げます。
QuicksandAreaに BoxCollider2D を追加します。- Inspector → Add Component → Physics 2D → BoxCollider2D
sensorチェックボックスを ON にします(トリガーとして動作させるため)。
QuicksandAreaに Quicksand コンポーネントを追加します。- Inspector → Add Component → Custom → Quicksand
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選び、
- 対象タグの設定(推奨)
Playerノードを選択し、Inspector で Add Component → Custom → QuicksandTargetTag を追加します。QuicksandTargetTagのtagをPlayerなど任意の文字列に設定します。QuicksandAreaノードを選択し、Quicksandコンポーネントのプロパティを以下のように設定します(例)。
例:
targetNodeName: 空のまま(任意)targetTag:PlayeruseSpeedScaleProperty:truespeedScalePropertyName:speedScale(SimplePlayerMoveに合わせる)inQuicksandSpeedScale:0.3lerpIntoSpeedScaleDuration:0.2lerpOutSpeedScaleDuration:0.2sinkSpeed:1.0maxSinkDistance:2.0restorePositionOnExit:truerestorePositionDuration:0.3debugLog: 開発中はtrueにすると挙動が分かりやすいです。
3. 動作確認
- メインツールバーの Play ボタン(▶)を押してゲームを実行します。
- キーボードの
WASDキーでPlayerを移動させ、QuicksandAreaに入ってみます。 - 以下の挙動を確認します。
- 流砂に入った瞬間から、移動速度が徐々に落ちていき、最終的に約 30% になる。
- 流砂の上にいる間、プレイヤーがゆっくりと下方向(-Y)に沈んでいく。
- ある程度沈んだら(
maxSinkDistance)、それ以上は沈まない。 - 流砂から出ると、速度が元に戻り、Y座標もゆっくり元の高さまで戻る。
debugLogを ON にしている場合、コンソールに Enter/Exit や speedScale の変更ログが出る。
4. 3Dでの利用ポイント
- 2D の代わりに 3D の Node + MeshRenderer + BoxCollider などを使います。
BoxColliderのisTriggerを ON にします。- プレイヤーには RigidBody + Collider を付け、移動スクリプトに
speedScaleプロパティを用意します。 - その他の設定は 2D とほぼ同様です。
まとめ
この Quicksand コンポーネントを使うことで、
- 任意のノードを「流砂エリア」に変えることができる。
- 対象の移動実装に強く依存せず、「速度スケール用プロパティ名」さえ合わせれば減速効果を付与できる。
- 沈み量や速度、戻し方をすべてインスペクタから調整でき、ゲームデザインの試行錯誤がしやすい。
- タグベースで対象を絞れるため、「プレイヤーだけ沈む」「特定の敵だけ沈む」といった表現も簡単。
他のギミック(泥沼、氷床、加速床など)も、同じ設計パターン(エリア+対象タグ+スケールプロパティ操作)で汎用コンポーネントとして切り出すと、
シーンに「アタッチしてパラメータを調整するだけ」で様々な仕掛けを追加できるようになり、ゲーム開発の効率が大きく向上します。
この Quicksand.ts 1ファイルだけで完結しているので、必要なシーンにコピーして貼り付けるだけで再利用できます。
プロジェクトに合わせて初期値やプロパティ名を調整し、自分のゲームに合った「流砂表現」を作り込んでみてください。




