Godot 4 でアクションゲームを作っていると、「地形ごとにプレイヤーの挙動を変えたい」という場面がよくありますよね。
たとえば、

  • 泥や雪の上では移動速度を落としたい
  • 流砂に入ったら、だんだん沈んでいってジャンプも難しくしたい
  • でも毎回プレイヤーのスクリプトを書き換えるのはイヤ…

Godot の「素直な」実装だと、ついこうなりがちです。

  • プレイヤーのスクリプトに if on_quicksand: のような分岐を増やしまくる
  • PlayerQuicksand みたいなサブクラスを作って、シーンを分ける
  • タイルマップや床ノードごとに専用の処理を書いて、依存がぐちゃぐちゃになる

これ、どんどん「継承」と「巨大スクリプト」に寄っていってしまうんですよね。
そこで今回は、プレイヤー側は一切改造せず、「流砂の性質」をコンポーネントとしてシーンにポン付けするだけで使える 「Quicksand」コンポーネントを作っていきましょう。

【Godot 4】足を取られて沈む床をコンポーネント化!「Quicksand」コンポーネント

今回の Quicksand コンポーネントは、ざっくりいうとこんなことをします。

  • エリアに入った CharacterBody2D / RigidBody2D の「移動速度」を一定割合で減速させる
  • 同時に、Y 軸方向へじわじわと引き込む「沈み込み力」を加える
  • エリアから出たら、元の移動速度に戻す(あるいは「自前で戻す」方式も選べる)
  • 1 つの流砂エリアに複数のオブジェクトが入っても、きちんと個別に管理

継承は一切使わず、「床側にだけ」このコンポーネントをアタッチしておけば、プレイヤーや敵の実装はそのままで効果を与えられる、という設計にしてあります。


フルコード:Quicksand.gd


extends Area2D
class_name Quicksand
"""
Quicksand (流砂) コンポーネント

- この Area2D に入った CharacterBody2D / RigidBody2D の移動速度を低下させ、
  Y 軸方向へ徐々に引き込む「流砂」挙動を付与します。
- プレイヤー側は「速度ベクトル」を export で公開しておくだけで OK。
  (例: PlayerMovement コンポーネントが velocity: Vector2 を持っている、など)

想定ユースケース:
- 2D アクションの流砂床
- 泥沼・沼地・粘性の高い液体エリア
- 一定時間で沈んでゲームオーバーになるトラップ
"""

## --- 設定パラメータ ---

@export_category("基本設定")

@export_range(0.0, 1.0, 0.05)
var speed_multiplier: float = 0.4:
	"""
	流砂内での「移動速度倍率」。
	1.0 = 通常速度 (減速なし)
	0.5 = 半分の速度
	0.0 = その場からほぼ動けない
	"""

@export var sink_force: float = 200.0:
	"""
	Y 軸方向 (下向き) に加える「沈み込み力」。
	数値が大きいほど、早く沈んでいきます。
	CharacterBody2D の場合は velocity.y に加算される想定です。
	"""

@export var affects_character_bodies: bool = true:
	"""
	CharacterBody2D を対象にするかどうか。
	"""

@export var affects_rigid_bodies: bool = false:
	"""
	RigidBody2D を対象にするかどうか。
	(物理挙動をしている箱や岩などを沈めたい場合に有効)
	"""

@export_category("対象オブジェクトの設定")

@export var velocity_property_name: StringName = "velocity":
	"""
	対象オブジェクトが持っている「速度ベクトル」のプロパティ名。
	Player や Enemy が `var velocity: Vector2` を持っている前提です。

	例:
	- PlayerMovement コンポーネントに velocity プロパティがある
	- 敵 AI が velocity プロパティを持っている
	"""

@export var auto_restore_speed_on_exit: bool = true:
	"""
	流砂から出たときに「元の速度」を自動で戻すかどうか。

	true:
	  - 流砂に入った瞬間の速度を記録し、
	    出たときにその速度に戻します。
	false:
	  - 戻し処理は行いません。
	    (プレイヤー側ロジックで勝手に変化させたい場合など)
	"""

@export_category("ビジュアル & デバッグ")

@export var debug_draw_gizmo: bool = true:
	"""
	エディタ / 実行時に流砂エリアの範囲を簡易表示するかどうか。
	"""

@export var debug_color: Color = Color(0.9, 0.7, 0.1, 0.25):
	"""
	流砂エリアのデバッグ表示色。
	"""

## --- 内部状態 ---

# 侵入中のオブジェクトを管理する辞書
# key: Node (対象オブジェクト)
# value: Dictionary {
#   "original_speed": Vector2 (流砂侵入時の元速度)
# }
var _affected_bodies: = {}

func _ready() -> void:
	# Area2D のコリジョンイベントを利用します。
	body_entered.connect(_on_body_entered)
	body_exited.connect(_on_body_exited)


func _physics_process(delta: float) -> void:
	# 侵入中オブジェクトへ継続的に「沈み込み力」を適用
	for body: Node in _affected_bodies.keys():
		if not is_instance_valid(body):
			_affected_bodies.erase(body)
			continue

		# CharacterBody2D / RigidBody2D 以外は無視
		if body is CharacterBody2D and affects_character_bodies:
			_apply_quicksand_to_character(body as CharacterBody2D, delta)
		elif body is RigidBody2D and affects_rigid_bodies:
			_apply_quicksand_to_rigidbody(body as RigidBody2D, delta)


func _on_body_entered(body: Node) -> void:
	if body is CharacterBody2D and affects_character_bodies:
		_register_body(body)
	elif body is RigidBody2D and affects_rigid_bodies:
		_register_body(body)


func _on_body_exited(body: Node) -> void:
	if not _affected_bodies.has(body):
		return

	if auto_restore_speed_on_exit:
		_restore_original_speed(body)

	_affected_bodies.erase(body)


func _register_body(body: Node) -> void:
	# velocity プロパティを持っていなければ何もしない
	if not body.has_variable(velocity_property_name) and not body.has_method("get"):
		# プロパティがなくても、RigidBody2D は直接 force を加えるので OK
		if body is RigidBody2D:
			_affected_bodies[body] = {}
		return

	# 侵入時の速度を記録しておく
	var original_speed: Vector2 = Vector2.ZERO
	if body.has_variable(velocity_property_name):
		original_speed = body.get(velocity_property_name)

	# 速度を減速させる
	if body.has_variable(velocity_property_name):
		var slowed: Vector2 = original_speed * speed_multiplier
		body.set(velocity_property_name, slowed)

	_affected_bodies[body] = {
		"original_speed": original_speed,
	}


func _restore_original_speed(body: Node) -> void:
	var data = _affected_bodies.get(body, null)
	if data == null:
		return

	if body.has_variable(velocity_property_name):
		if data.has("original_speed"):
			body.set(velocity_property_name, data["original_speed"])


func _apply_quicksand_to_character(body: CharacterBody2D, delta: float) -> void:
	# CharacterBody2D 側が velocity プロパティを持っていない場合は何もしない
	if not body.has_variable(velocity_property_name):
		return

	var v: Vector2 = body.get(velocity_property_name)
	# 下方向に沈み込み力を加える (重力に上乗せするイメージ)
	v.y += sink_force * delta
	body.set(velocity_property_name, v)


func _apply_quicksand_to_rigidbody(body: RigidBody2D, delta: float) -> void:
	# RigidBody2D には直接力を加える
	var force: Vector2 = Vector2(0, sink_force)
	# 中心に力を加えて、下向きに引き込む
	body.apply_central_force(force)


func _draw() -> void:
	if not debug_draw_gizmo:
		return

	# CollisionShape2D の形状をざっくり矩形で表示する簡易実装。
	# 厳密にやるなら、CollisionShape2D から shape を参照して描画してください。
	var rect := Rect2(Vector2(-64, -16), Vector2(128, 32))
	draw_rect(rect, debug_color, true)
	draw_rect(rect, debug_color.darkened(0.4), false)


func _notification(what: int) -> void:
	if what == NOTIFICATION_EDITOR_DRAW:
		_draw()
	elif what == NOTIFICATION_DRAW:
		_draw()

使い方の手順

ここからは、実際に Quicksand コンポーネントをシーンに組み込んでいきましょう。

前提:プレイヤー側の最低限の準備

このコンポーネントは、「対象オブジェクトが velocity プロパティを持っている」ことを前提にしています。
典型的には、こんな感じのプレイヤー移動コンポーネントを想定しています:


# PlayerMovement.gd (例)
extends Node
class_name PlayerMovement

@export var speed: float = 200.0
var velocity: Vector2 = Vector2.ZERO

func physics_process_movement(body: CharacterBody2D, delta: float) -> void:
	var input_dir := Input.get_axis("ui_left", "ui_right")
	velocity.x = input_dir * speed
	# 重力やジャンプ処理など…
	body.velocity = velocity
	body.move_and_slide()
	# body.velocity をそのまま使っている場合は、
	# Quicksand の velocity_property_name を "velocity" ではなく
	# "body.velocity" にするなど、設計に合わせて調整してください。

このように、velocity: Vector2 を持っていれば OK です。

手順①:Quicksand コンポーネントのシーンを作る

  1. 新規シーンを作成し、ルートに Area2D を追加します。
  2. 名前を Quicksand に変更します。
  3. 子ノードとして CollisionShape2D を追加し、矩形や多角形などで「流砂の範囲」を設定します。
  4. ルートの Area2D に、上記の Quicksand.gd スクリプトをアタッチします。
  5. 必要に応じて、Sprite2D や AnimatedSprite2D で見た目(砂のアニメなど)を追加します。

シーン構成図の例:

Quicksand (Area2D)
 ├── CollisionShape2D
 └── Sprite2D (任意:砂の見た目)

手順②:パラメータを調整する

Inspector から、以下のように設定してみましょう。

  • speed_multiplier = 0.4(流砂内で 40% の速度に減速)
  • sink_force = 250.0(そこそこ早く沈む)
  • affects_character_bodies = true
  • affects_rigid_bodies = false(今回はプレイヤーだけ対象)
  • velocity_property_name = "velocity"(PlayerMovement のプロパティ名に合わせる)
  • auto_restore_speed_on_exit = true(流砂から出たら速度を元に戻す)

手順③:プレイヤーシーンに配置する

プレイヤーのシーン構成は、例えばこんな感じを想定します:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PlayerMovement (Node)  # 移動ロジックのコンポーネント

プレイヤー側には一切 Quicksand を参照するコードを書かず、「流砂側からプレイヤーの velocity をいじる」形にするのがポイントです。
これで、プレイヤーのスクリプトは「普通の地形でも、流砂でも、同じコードで動く」状態になります。

手順④:ステージに流砂を配置する

あとはレベルシーン(Stage や Level1 など)に、先ほど作った Quicksand.tscn を好きなだけ配置するだけです。

Level1 (Node2D)
 ├── TileMap (地面)
 ├── Player (CharacterBody2D)
 ├── Quicksand (Area2D)
 └── Quicksand (Area2D)  # 複数配置もOK

この構成なら、

  • プレイヤーは「ただの移動コンポーネント」を持つだけ
  • 流砂は「床の一種」として、Quicksand コンポーネントを持つだけ
  • どちらもお互いを知らずに動く(疎結合)

という、コンポーネント指向らしい設計になりますね。


具体的な使用例

例1:プレイヤーが沈む流砂トラップ

もっとも典型的な例です。プレイヤーが流砂に入ると、

  • 左右の移動が重くなる(speed_multiplier の効果)
  • ジャンプしても、沈み込み力で上方向の速度が打ち消される
  • 一定時間沈み続けると、画面外まで落ちてゲームオーバー

ゲームオーバー判定は、プレイヤー側で「Y 座標が一定以下になったら死亡」などをチェックするだけで実現できます。

例2:敵だけが沈む「罠の砂穴」

敵 AI も velocity: Vector2 を持っているなら、同じ Quicksand コンポーネントで敵だけを沈めることもできます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── EnemyMovement (Node)  # velocity を持つ

この場合、プレイヤーには別の移動ロジックを使っていても、「共通の流砂コンポーネント」で処理を一元化できます。

例3:動く床 + 流砂

動く床の上に流砂エリアを重ねると、「動きながら沈む足場」を簡単に作れます。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── PlatformMover (Node)   # 床を左右に動かすコンポーネント
 └── Quicksand (Area2D)     # 上に乗ると沈む
      └── CollisionShape2D

床の移動ロジックと、流砂の「移動速度低下 + 沈み込み」は完全に分離されているので、後からどちらかだけ差し替えるのも簡単です。


メリットと応用

Quicksand コンポーネントを使うと、次のようなメリットがあります。

  • プレイヤーや敵のスクリプトが肥大化しない
    「流砂のときだけ特別扱い」みたいな条件分岐を、キャラクター側に書かなくて済みます。
  • レベルデザインが直感的になる
    ステージ上に「流砂エリア」をポンポン配置するだけで挙動が決まるので、レベルデザイナーがプログラムを触らずに調整できます。
  • 再利用性が高い
    プレイヤー、敵、動く床、物理オブジェクト…どれに対しても「同じコンポーネント」で流砂効果を与えられます。
  • パラメータ調整が楽
    speed_multipliersink_force を変えるだけで、
    「ちょっと足を取られる砂」から「即死級の底なし沼」まで、バリエーションを簡単に作れます。

継承ベースで PlayerOnQuicksandEnemyOnQuicksand を増やしていくと、組み合わせ爆発が起きやすいですが、
こうして「床側のコンポーネント」として切り出しておけば、「どんなキャラでもこのエリアに入れば同じルールで沈む」という、シンプルで強力な設計になります。

改造案:一定時間経過で「完全に飲み込む」

最後に、簡単な改造案として、「流砂に一定時間いると、自動的に Kill する」処理を追加してみましょう。
Quicksand に次のようなメソッドを足すだけで実現できます。


@export_category("追加ギミック")
@export var kill_after_seconds: float = 0.0
# 0.0 のときは無効 (殺さない)

# 侵入時間の管理を追加
# _affected_bodies[body]["time_in_quicksand"] を使う
func _physics_process(delta: float) -> void:
	for body: Node in _affected_bodies.keys():
		if not is_instance_valid(body):
			_affected_bodies.erase(body)
			continue

		# 経過時間をカウント
		var data = _affected_bodies[body]
		data["time_in_quicksand"] = (data.get("time_in_quicksand", 0.0) as float) + delta
		_affected_bodies[body] = data

		# 一定時間経過で Kill
		if kill_after_seconds > 0.0 and data["time_in_quicksand"] >= kill_after_seconds:
			if body.has_method("die"):
				body.die()  # プレイヤーや敵側で die() を実装しておく
			elif body is Node2D:
				body.queue_free()
			continue

		if body is CharacterBody2D and affects_character_bodies:
			_apply_quicksand_to_character(body as CharacterBody2D, delta)
		elif body is RigidBody2D and affects_rigid_bodies:
			_apply_quicksand_to_rigidbody(body as RigidBody2D, delta)

このように、「流砂のルール」だけをコンポーネント側で育てていくと、キャラクターやレベルの実装はシンプルなまま、ゲームの表現力だけがどんどん上がっていきます。
ぜひ、自分のプロジェクト用にカスタマイズしながら使い回してみてください。