Godot 4 で「水に浮く表現」をしようとすると、けっこう面倒なんですよね。典型的なのは:

  • 水用のシーンを作って、そこに Area2D/Area3D を置く
  • プレイヤーや敵のスクリプト側で「水に入ったら重力を弱める」「上方向に力をかける」などをベタ書き
  • オブジェクトごとに条件分岐が増えて、if is_in_water だらけになる

しかも、プレイヤー・敵・浮く箱・浮遊ギミック…と種類が増えるたびに、各スクリプトに「水用ロジック」をコピペしがちです。これは完全に 継承と Godot のノード階層に引きずられた設計 ですね。

そこでこの記事では、「水に入ったら浮力をかける」という機能を 1つのコンポーネントに切り出すアプローチを紹介します。
どんなノードにもペタッと貼るだけで、水エリア内では自動的に上方向の力がかかるようにして、本体スクリプトから水ロジックを追い出すのが狙いです。

【Godot 4】水に入ったら自動でプカプカ!「WaterBuoyancy」コンポーネント

今回作る WaterBuoyancy コンポーネントは:

  • 親ノードが「水エリア」に入っている間だけ、上方向の力(浮力)を加える
  • 2D/3D のどちらでも使えるように、ベクトル方向と適用方法をパラメータ化
  • 水エリア側は Area2D/Area3D で、「水タグ」を付けるだけ

という、かなり汎用的なコンポーネントです。


フルコード:WaterBuoyancy.gd


extends Node
class_name WaterBuoyancy
## 親ノードに「水中にいる間だけ浮力を加える」コンポーネント。
## 2D/3D 両対応。親側は Rigidbody / CharacterBody / 任意の自前スクリプトでもOK。
##
## 使い方の概要:
## - 親: プレイヤーや箱などの「浮かせたいオブジェクト」
## - 子: この WaterBuoyancy ノードをアタッチ
## - 水: Area2D / Area3D に "water" などのグループを付ける
##
## WaterBuoyancy は「水エリアに入っているかどうか」を検出し、
## 入っている間だけ毎フレーム上方向の力を加えます。

@export_group("基本設定")
## どのグループ名の Area を「水」とみなすか。
## 例: "water", "water_area" など。複数付けたい場合は Area 側を複数グループに入れる。
@export var water_group_name: String = "water"

## 浮力の強さ。数値が大きいほど強く上に押し上げます。
## 実際の挙動は「質量」「重力」「移動ロジック」に依存するので、
## プレイヤー用、箱用などで調整しましょう。
@export var buoyancy_force: float = 800.0

## 浮力の方向ベクトル。
## 2D なら通常 (0, -1)、3D なら (0, 1, 0) など、プロジェクトの軸に合わせて設定。
@export var force_direction: Vector3 = Vector3(0, 1, 0)

## 浮力を適用するモード。
## - "velocity" : 親に velocity プロパティがあると仮定し、そこに加算
## - "force"    : RigidBody2D/3D の apply_central_force / apply_central_impulse を使用
## - "custom"   : signal を発火して、外部スクリプトで好きに処理
@export_enum("velocity", "force", "custom")
var apply_mode: String = "velocity"

@export_group("詳細設定")
## 2D 用か 3D 用か。force_direction の解釈と、内部キャストに使います。
@export_enum("2D", "3D")
var space_dimension: String = "2D"

## 毎フレームではなく、一定間隔ごとに浮力を適用したい場合のタイムステップ。
## 0 のときは _physics_process の delta ごとに適用。
@export var apply_interval_sec: float = 0.0

## 水から出た瞬間に、速度を少しだけ減衰させて「水切り感」を出すかどうか。
@export var dampen_velocity_on_exit: bool = true
## 減衰率。0.0〜1.0。1.0 に近いほど急ブレーキ。
@export_range(0.0, 1.0, 0.05)
var exit_damping_factor: float = 0.3

signal buoyancy_applied(force: Vector3, delta: float)
## apply_mode == "custom" のときに発火。
## 外部スクリプトでこの signal を受け取り、自由に力を適用してください。

# 内部状態
var _in_water: bool = false
var _accum_time: float = 0.0

# Area との接触を検出するためのキャッシュ
var _water_areas: Array[Area2D] = []
var _water_areas_3d: Array[Area3D] = []

func _ready() -> void:
	# 親ノードの存在確認
	if get_parent() == null:
		push_warning("WaterBuoyancy は親ノードと一緒に使ってください。")
	
	# 親が Area と衝突できるように、親側の CollisionObject に body_entered / area_entered を接続してもよいですが、
	# ここでは「水エリア側が親を検出してくれる」前提にします。
	# つまり、水エリア側で body_entered / area_entered を使い、
	# オブジェクトが入ってきたら WaterBuoyancy にフラグを立てる方式です。
	#
	# ただし、「自分で自動判定したい」ケースのために、
	# 空間探索で水エリアを探す fallback も用意しておきます(_physics_process 内)。

	pass


func _physics_process(delta: float) -> void:
	# まず、空間内で「水に触れているか」をざっくり判定
	_update_water_state_by_overlap()
	
	if not _in_water:
		return
	
	# 浮力適用のタイミング管理
	if apply_interval_sec > 0.0:
		_accum_time += delta
		if _accum_time < apply_interval_sec:
			return
		# 規定時間が経ったので適用し、カウンタをリセット
		_accum_time = 0.0
	
	_apply_buoyancy(delta)


func _update_water_state_by_overlap() -> void:
	# 「水に触れているか」を、簡易的に Overlap チェックで判定します。
	# 実運用では、水エリア側の signal から _set_in_water(true/false) を呼ぶ方が正確です。
	var parent := get_parent()
	if parent == null:
		_in_water = false
		return
	
	if space_dimension == "2D":
		var world := get_world_2d()
		if world == null:
			_in_water = false
			return
		
		var space_state := world.direct_space_state
		# 親の中心位置を基準に、小さめの円でオーバーラップチェック
		var query := PhysicsPointQueryParameters2D.new()
		query.position = parent.global_position
		query.collide_with_areas = true
		var result := space_state.intersect_point(query, 8)
		
		var is_in_water := false
		for item in result:
			var collider := item.get("collider")
			if collider is Area2D and collider.is_in_group(water_group_name):
				is_in_water = true
				break
		_in_water = is_in_water
	else:
		var world3d := get_world_3d()
		if world3d == null:
			_in_water = false
			return
		
		var space_state3d := world3d.direct_space_state
		var query3d := PhysicsPointQueryParameters3D.new()
		query3d.position = get_parent().global_position
		query3d.collide_with_areas = true
		var result3d := space_state3d.intersect_point(query3d, 8)
		
		var is_in_water3d := false
		for item in result3d:
			var collider3d := item.get("collider")
			if collider3d is Area3D and collider3d.is_in_group(water_group_name):
				is_in_water3d = true
				break
		_in_water = is_in_water3d


func _apply_buoyancy(delta: float) -> void:
	var parent := get_parent()
	if parent == null:
		return
	
	# direction は正規化してから使う
	var dir := force_direction
	if dir.length() == 0.0:
		return
	dir = dir.normalized()
	
	var force_vec := dir * buoyancy_force
	
	match apply_mode:
		"velocity":
			_apply_to_velocity(parent, force_vec, delta)
		"force":
			_apply_to_rigidbody(parent, force_vec, delta)
		"custom":
			emit_signal("buoyancy_applied", force_vec, delta)
		_:
			push_warning("未知の apply_mode: %s" % apply_mode)


func _apply_to_velocity(parent: Node, force_vec: Vector3, delta: float) -> void:
	# CharacterBody2D/3D や自前の Kinematic 系スクリプト向け。
	# velocity プロパティが存在する場合のみ適用します。
	if not parent.has_variable("velocity"):
		return
	
	var v = parent.get("velocity")
	
	if space_dimension == "2D":
		# Vector2 に変換
		var force2d := Vector2(force_vec.x, force_vec.y)
		v += force2d * delta
	else:
		# 3D はそのまま Vector3 として加算
		v += force_vec * delta
	
	parent.set("velocity", v)


func _apply_to_rigidbody(parent: Node, force_vec: Vector3, delta: float) -> void:
	# RigidBody2D / RigidBody3D に対して中央に力を加えます。
	if space_dimension == "2D":
		if parent is RigidBody2D:
			var rb2d := parent as RigidBody2D
			var force2d := Vector2(force_vec.x, force_vec.y)
			# delta を掛けて「力」っぽくするか、掛けずに「インパルス」として扱うかは好みですが、
			# ここでは「力」として適用します。
			rb2d.apply_central_force(force2d * delta)
	else:
		if parent is RigidBody3D:
			var rb3d := parent as RigidBody3D
			rb3d.apply_central_force(force_vec * delta)


## 外部から「水に入った/出た」を明示的に制御したい場合に使うヘルパー。
## 水エリアの body_entered / body_exited から呼ぶのがおすすめです。
func set_in_water(value: bool) -> void:
	if _in_water == value:
		return
	
	_in_water = value
	
	# 水から出た瞬間に速度を減衰させる処理
	if not _in_water and dampen_velocity_on_exit:
		_dampen_parent_velocity_on_exit()


func _dampen_parent_velocity_on_exit() -> void:
	var parent := get_parent()
	if parent == null:
		return
	
	if not parent.has_variable("velocity"):
		return
	
	var v = parent.get("velocity")
	if space_dimension == "2D":
		var v2d := v as Vector2
		v2d = v2d * (1.0 - exit_damping_factor)
		parent.set("velocity", v2d)
	else:
		var v3d := v as Vector3
		v3d = v3d * (1.0 - exit_damping_factor)
		parent.set("velocity", v3d)

使い方の手順

ここからは、具体的なシーン構成と一緒に使い方を見ていきましょう。まずは 2D のプレイヤーを例にします。

例1:2D プレイヤーが水に浮く

WaterBuoyancy.gd を用意して、オートロードではなく「コンポーネント」として使う
上のコードを res://components/WaterBuoyancy.gd などに保存します。
class_name WaterBuoyancy を指定しているので、インスペクタから「スクリプトを追加」→「WaterBuoyancy」で選べるようになります。

プレイヤーシーンに WaterBuoyancy を子ノードとして追加
典型的な構成はこんな感じです:

Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── WaterBuoyancy (Node)

PlayerCharacterBody2D で、velocity: Vector2 を持つ前提。

WaterBuoyancyNode として追加し、スクリプトに WaterBuoyancy.gd をアタッチ。

インスペクタで space_dimension = "2D"apply_mode = "velocity" を選びます。

水エリアのシーンを作る
例えばこんな構成:

WaterArea (Area2D)
├── CollisionShape2D
└── Sprite2D (水の見た目)

WaterAreawater グループに追加します(ノード → グループ → water)。

WaterBuoyancy.water_group_name"water" のままにしておけば OK です。

シンプルに使う場合は、特にスクリプトを書かなくても_update_water_state_by_overlap() による重なり判定だけで動きます。

プレイヤーの移動スクリプト側では「水のことを一切考えない」
たとえばプレイヤー側は、こんな感じのシンプルなコードでOKです:

extends CharacterBody2D

const SPEED := 200.0
const GRAVITY := 1200.0
const JUMP_SPEED := -400.0

func _physics_process(delta: float) -> void:
# 横移動
var input_dir := Input.get_axis("ui_left", "ui_right")
velocity.x = input_dir * SPEED

# 重力
if not is_on_floor():
velocity.y += GRAVITY * delta

# ジャンプ
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_SPEED

# ここでは「水中かどうか」は一切見ない!
# WaterBuoyancy コンポーネントが velocity に対して上向きの力を加えてくれる。

move_and_slide()


水に入ると、WaterBuoyancy が勝手に velocity.y を上方向に持ち上げてくれるので、
プレイヤースクリプトは「水中ロジック」を知らなくていい、というのがポイントです。


例2:2D で「動く箱」が水にプカプカする

次に、RigidBody2D の箱を水に浮かせてみましょう。

FloatingBox (RigidBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── WaterBuoyancy (Node)
  • FloatingBoxRigidBody2D。重力などは Project Settings の物理設定に従う。
  • WaterBuoyancy の設定:
    • space_dimension = "2D"
    • apply_mode = "force"apply_central_force を使う)
    • buoyancy_force = 2000.0 くらいから調整

この構成なら、箱のスクリプトはゼロ行でも、水に入るとふわっと浮き上がる箱ができます。
「浮力の強さ」は buoyancy_force だけで調整できるので、レベルデザイン時にも扱いやすいです。

例3:3D プレイヤーにもそのまま使う

3D でも考え方は同じです。例えば:

Player3D (CharacterBody3D)
 ├── MeshInstance3D
 ├── CollisionShape3D
 └── WaterBuoyancy (Node)
  • WaterBuoyancy の設定:
    • space_dimension = "3D"
    • force_direction = Vector3(0, 1, 0)(Y+ が上のプロジェクト想定)
    • apply_mode = "velocity"velocity: Vector3 に加算
  • 水エリアは Area3DCollisionShape3D で作成し、water グループに入れる。

プレイヤー側のコードは 2D と同じく、水中かどうかを一切意識しなくてOK です。


メリットと応用

この WaterBuoyancy コンポーネントを使うメリットは、ざっくり言うと以下の通りです。

  • 水ロジックが「1か所」にまとまる
    プレイヤー・敵・箱・ギミックなど、複数のシーンに同じ水中処理をコピペする必要がありません。
    「浮力の計算を変えたい」時も WaterBuoyancy.gd だけを直せば全体に反映されます。
  • シーン構造がフラットで見通しが良い
    「水用プレイヤー」「水用敵」みたいな派生シーンを増やさず、
    どのオブジェクトも ベースは1つのシーン + コンポーネント という構成にできます。
  • レベルデザイン時に「浮力の強さ」をオブジェクト単位で調整できる
    例えば同じ水エリアでも、重い箱は沈みがち、木の箱はよく浮く、などを buoyancy_force の数値で簡単に表現できます。
  • 2D / 3D 両方で再利用できる
    プロジェクト内で 2D と 3D を混ぜて使う場合でも、コンポーネントを使い回せるのはかなり嬉しいポイントです。

さらに、コンポーネント指向 らしく、WaterBuoyancy 自体も簡単に改造できます。
例えば、「水面より上に出たら浮力を弱める(半分だけかかる)」みたいな演出を入れたい場合、こんな関数を追加できます。


## 親ノードの高さに応じて浮力係数を変える例。
## 例えば、水面の Y 座標を引数で受け取り、
## それより上に出るほど浮力が弱くなるようにする。
func get_buoyancy_factor(surface_height: float) -> float:
	var parent := get_parent()
	if parent == null:
		return 1.0
	
	var y: float
	if space_dimension == "2D":
		y = parent.global_position.y
	else:
		y = parent.global_position.y
	
	# y が surface_height より上なら、0.2〜1.0 の範囲でスケール
	if y < surface_height:
		var t := clamp((surface_height - y) / 64.0, 0.0, 1.0)
		return lerp(0.2, 1.0, t)
	
	return 1.0

この get_buoyancy_factor()_apply_buoyancy() 内で使って buoyancy_force に掛ければ、
水面付近で「ふわふわ」する感じを簡単に作れます。

こんなふうに、挙動ごとにコンポーネントを切り出しておくと、後からの調整・拡張が圧倒的に楽になります。
継承ツリーをいじる前に、「これコンポーネントにできないかな?」と一度考えてみると、Godot 4 の開発体験がかなり快適になりますよ。