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)
Player は CharacterBody2D で、velocity: Vector2 を持つ前提。
WaterBuoyancy は Node として追加し、スクリプトに WaterBuoyancy.gd をアタッチ。
インスペクタで space_dimension = "2D"、apply_mode = "velocity" を選びます。
水エリアのシーンを作る
例えばこんな構成:
WaterArea (Area2D)
├── CollisionShape2D
└── Sprite2D (水の見た目)
WaterArea を water グループに追加します(ノード → グループ → 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)
FloatingBoxはRigidBody2D。重力などは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に加算
- 水エリアは
Area3DとCollisionShape3Dで作成し、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 の開発体験がかなり快適になりますよ。
