Godot 4でキャラクターの「ツルツル滑る床」を作ろうとすると、多くの人がまず考えるのは:
- PhysicsMaterial の friction を変えた専用の TileSet を作る
- プレイヤーシーンを継承して「氷エリア用プレイヤー」を作る
- マップ側でプレイヤーのスクリプトに直接アクセスしてパラメータを書き換える
…みたいなやり方だと思います。どれも動くのですが、
- タイルセットが増える&管理が面倒
- プレイヤーの派生シーンが増えすぎてカオス
- マップからプレイヤーを直接いじると依存関係がベタベタになる
という「あとから効いてくるツラさ」がありますよね。
そこで今回は「そのエリアに入っている間だけプレイヤーの摩擦を極端に下げて、慣性を残す」ためのコンポーネント、IceFloor を用意しました。
プレイヤーには「滑りやすさ」を調整できるコンポーネントをアタッチしておき、床の側から「お前、いま氷モードね」と指示を出すだけにします。
継承ではなく 合成(Composition) で、氷エリアのロジックをきれいに分離していきましょう。
【Godot 4】ツルツル氷エリアをコンポーネントで!「IceFloor」コンポーネント
今回の構成は以下の2コンポーネントで考えます:
- IceFloor:エリアに入ったキャラクターに「氷モード」をオン/オフする
- FrictionController:キャラクター側にアタッチし、「今は通常摩擦 or 氷摩擦」を切り替える
どちらも 独立したコンポーネント なので、プレイヤーにも敵にも、動く床にも、好きなノードにアタッチして再利用できます。
ソースコード(Full Code)
1. キャラクター側:FrictionController.gd
プレイヤーや敵など「動くもの」にアタッチするコンポーネントです。
外部(今回だと IceFloor)から「氷モードにして」と言われると、摩擦関連パラメータを切り替えます。
extends Node
class_name FrictionController
## キャラクターの「摩擦・慣性」を一元管理するコンポーネント。
## IceFloor などのエリアから状態を切り替えてもらう想定です。
@export var normal_friction: float = 1.0:
## 通常時の「減速の強さ」を表すパラメータ。
## 値が大きいほど急ブレーキ、値が小さいほどヌルヌル滑るイメージです。
set(value):
normal_friction = max(value, 0.0)
@export var ice_friction: float = 0.1:
## 氷エリア内での「減速の強さ」。
## 0 に近づけるほど慣性が強くなり、なかなか止まりません。
set(value):
ice_friction = max(value, 0.0)
@export var normal_max_speed: float = 300.0:
## 通常時の最大速度(あれば)。
## プレイヤー側で使っていないなら 0 のままでもOKです。
set(value):
normal_max_speed = max(value, 0.0)
@export var ice_max_speed: float = 450.0:
## 氷エリア内での最大速度。
## 氷の上ではちょっとスピードアップ、のような演出もここで。
set(value):
ice_max_speed = max(value, 0.0)
@export var debug_print_state: bool = false
## true にすると、モード切り替え時にログを出します。
## 現在の摩擦モード
var _is_on_ice: bool = false
## 実際に移動処理を行っているノード(CharacterBody2D など)への参照。
## デフォルトでは親ノードを想定しています。
var _body: Node = null
## 外部から参照しやすいように、現在のパラメータを公開
var current_friction: float:
get:
return _is_on_ice ? ice_friction : normal_friction
var current_max_speed: float:
get:
return _is_on_ice ? ice_max_speed : normal_max_speed
func _ready() -> void:
# デフォルトでは親ノードを「動く本体」として扱います。
_body = get_parent()
if debug_print_state:
print("[FrictionController] Ready. body =", _body)
func set_on_ice(enabled: bool) -> void:
## IceFloor などから呼ばれるエントリポイント。
## enabled = true で氷モード、false で通常モードに戻します。
if _is_on_ice == enabled:
return # 変化なしなら何もしない
_is_on_ice = enabled
if debug_print_state:
print("[FrictionController] on_ice =", _is_on_ice,
" friction =", current_friction,
" max_speed =", current_max_speed)
# ここでは「今すぐ速度を書き換える」まではしません。
# 実際の速度制御は、プレイヤーの移動スクリプト側で
# current_friction / current_max_speed を参照して行う想定です。
func is_on_ice() -> bool:
## 現在氷モードかどうかを返します。
return _is_on_ice
プレイヤー側の移動スクリプト例(抜粋)
上記 FrictionController を前提にした、CharacterBody2D 用のシンプルな移動ロジック例です。
extends CharacterBody2D
@export var acceleration: float = 2000.0
var friction_controller: FrictionController
func _ready() -> void:
# 同じノード階層の子に FrictionController が付いている前提
friction_controller = $FrictionController
func _physics_process(delta: float) -> void:
var input_dir := Input.get_axis("ui_left", "ui_right")
# 加速処理
if input_dir != 0:
velocity.x += input_dir * acceleration * delta
else:
# 入力が無いときの減速処理。
# FrictionController の current_friction を使って減速量を決定。
var friction := friction_controller.current_friction
if abs(velocity.x) > 0.1:
var decel := friction * acceleration * delta
if abs(velocity.x) <= decel:
velocity.x = 0.0
else:
velocity.x -= sign(velocity.x) * decel
# 最大速度制限(0 の場合は制限なし)
var max_speed := friction_controller.current_max_speed
if max_speed > 0.0:
velocity.x = clamp(velocity.x, -max_speed, max_speed)
move_and_slide()
2. 氷の床側:IceFloor.gd
こちらが今回の主役コンポーネントです。
Area2D にアタッチして、エリアに入った/出たキャラクターの FrictionController を探し出し、氷モードをオン/オフします。
extends Area2D
class_name IceFloor
## このエリア内にいる間だけ、対象の FrictionController を「氷モード」にするコンポーネント。
## Area2D をベースにしているので、コリジョン形状で氷エリアの形を自由に作れます。
@export var affect_players: bool = true:
## プレイヤーを氷モード対象にするかどうか。
## グループ "player" を前提にしています。
set(value):
affect_players = value
@export var affect_enemies: bool = false:
## 敵(グループ "enemy")も氷モード対象にするか。
set(value):
affect_enemies = value
@export var auto_disable_when_empty: bool = false:
## 誰も乗っていないときに自動で無効化したい場合用。
## (例えば動く氷床などで、パフォーマンスを気にするケース)
set(value):
auto_disable_when_empty = value
@export var debug_print: bool = false
## true でログを出す(デバッグ用)。
## 現在この氷床の上に乗っている FrictionController のリスト
var _controllers: Array[FrictionController] = []
func _ready() -> void:
# コリジョンレイヤー・マスクはエディタ側で設定しておいてOKですが、
# 念のため Monitoring は有効化しておきます。
monitoring = true
monitorable = true
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
if debug_print:
print("[IceFloor] Ready. affect_players =", affect_players,
" affect_enemies =", affect_enemies)
func _on_body_entered(body: Node) -> void:
if not _should_affect_body(body):
return
var controller := _find_friction_controller(body)
if controller == null:
if debug_print:
print("[IceFloor] body entered but no FrictionController:", body)
return
if controller in _controllers:
return # すでに登録済み
_controllers.append(controller)
controller.set_on_ice(true)
if debug_print:
print("[IceFloor] ENTER: set_on_ice(true) for", body, "controllers:", _controllers.size())
func _on_body_exited(body: Node) -> void:
if not _should_affect_body(body):
return
var controller := _find_friction_controller(body)
if controller == null:
return
if controller in _controllers:
_controllers.erase(controller)
controller.set_on_ice(false)
if debug_print:
print("[IceFloor] EXIT: set_on_ice(false) for", body, "controllers:", _controllers.size())
if auto_disable_when_empty and _controllers.is_empty():
# 誰も乗っていないなら自動で無効化(任意)
monitoring = false
if debug_print:
print("[IceFloor] auto disabled (no bodies on ice).")
func _should_affect_body(body: Node) -> bool:
## この氷床が、与えられた body を対象にするかどうかを判定する。
if affect_players and body.is_in_group("player"):
return true
if affect_enemies and body.is_in_group("enemy"):
return true
return false
func _find_friction_controller(body: Node) -> FrictionController:
## body 自身、もしくは子孫ノードから FrictionController を探す。
## ここでは「親が CharacterBody2D / RigidBody2D などで、
## その子に FrictionController が付いている」構成を想定しています。
# 1. body 自身が FrictionController を持っているか?
if body is FrictionController:
return body
# 2. 直接の子孫から探す(最初に見つかったものを返す)
for child in body.get_children():
if child is FrictionController:
return child
# 再帰的に探したい場合は以下を使う(コストは上がる)
var found := _find_friction_controller_recursive(child)
if found != null:
return found
return null
func _find_friction_controller_recursive(node: Node) -> FrictionController:
for child in node.get_children():
if child is FrictionController:
return child
var found := _find_friction_controller_recursive(child)
if found != null:
return found
return null
使い方の手順
手順①:プレイヤーに FrictionController をアタッチする
まずはプレイヤーシーンに FrictionController コンポーネントを追加します。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── FrictionController (Node) ← このコンポーネントをアタッチ
Playerのスクリプトは、先ほどの「移動スクリプト例」をベースにして、friction_controller.current_frictionを参照するようにします。- プレイヤーのルートノードを グループ “player” に追加しておきましょう。
手順②:氷の床シーンを作る
次に、氷エリア用のシーンを作ります。
IceFloor (Area2D) ├── CollisionShape2D ← 氷エリアの形 └── Sprite2D ← 氷の見た目(任意)
IceFloorノードにIceFloor.gdをアタッチします。CollisionShape2Dでエリアの形を決めます(矩形、ポリゴン、なんでもOK)。- コリジョンレイヤー/マスクは「プレイヤーのコリジョン」とぶつかるように設定してください(Area2D と Body の検出用)。
シーンツリー例(マップ側から見た構成):
Level1 (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── FrictionController (Node)
├── IceFloor_A (Area2D)
│ ├── CollisionShape2D
│ └── Sprite2D
└── IceFloor_B (Area2D)
├── CollisionShape2D
└── Sprite2D
手順③:パラメータを調整する
- FrictionController(プレイヤー側)
normal_friction:通常地面での減速の強さice_friction:氷エリアでの減速の強さ(0.05~0.2 くらいがそれっぽいです)normal_max_speed:通常時の最高速度ice_max_speed:氷時の最高速度(少し高めにすると「滑ってる感」が出ます)
- IceFloor(床側)
affect_players:プレイヤーにだけ効かせるなら trueaffect_enemies:敵にも効かせたいなら true にして、敵を “enemy” グループにauto_disable_when_empty:誰も乗っていないときに監視停止したい場合のみ truedebug_print:挙動確認中だけ true にしてログを見ると便利です
手順④:応用例(敵や動く床にも適用)
同じコンポーネントを、敵や動く床にもそのまま使い回せます。
敵キャラ例:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── FrictionController (Node)
- 敵のルートノードをグループ
"enemy"に追加。 - 敵の移動スクリプトでも、プレイヤーと同様に
friction_controller.current_frictionを参照して減速処理を行う。 - IceFloor 側の
affect_enemiesを true にすれば、同じ床でプレイヤーも敵もツルツルにできます。
動く床(ベルトコンベア的なもの)が氷になる、みたいなギミックも:
MovingPlatform (Node2D) ├── CollisionShape2D ├── Sprite2D └── IceFloor (Area2D) ← ここに IceFloor を付ける
とすれば、「乗っている間だけ摩擦が下がる動く床」が簡単に作れます。
メリットと応用
- シーン構造がスッキリ:プレイヤーを継承して「氷用プレイヤー」を作る必要がありません。プレイヤーは常に1シーンでOK。
- レベルデザインが楽:氷エリアを増やしたいときは、
IceFloorシーンをマップにポンポン配置するだけ。 - 再利用性が高い:敵・プレイヤー・動く床など、どんな「動くもの」にも FrictionController を付ければ同じ仕組みが使えます。
- 依存関係がゆるい:IceFloor は「FrictionController を持っているかどうか」だけを見ており、具体的なプレイヤークラス名などには依存しません。
これぞまさに「継承より合成」ですね。
氷エリアのロジックは IceFloor コンポーネントに閉じ込め、キャラクターの物理パラメータは FrictionController に閉じ込める。
それぞれが役割に集中しているので、後から仕様変更が入っても壊れにくくなります。
改造案:時間経過で「だんだん滑りやすくなる」氷床
例えば、氷の上に乗ってから時間が経つほど摩擦が減っていき、最終的には超ツルツルになるギミックも簡単に作れます。
IceFloor に次のような関数を追加して、_physics_process から呼ぶ構成です。
func _physics_process(delta: float) -> void:
_update_ice_intensity(delta)
func _update_ice_intensity(delta: float) -> void:
## 氷の「強さ」を 0.0 ~ 1.0 で管理し、
## それに応じて FrictionController の ice_friction を書き換える例。
var intensity_speed := 0.3 # 1.0 になるまでの速さ
for controller in _controllers:
# FrictionController 側に「現在の強さ」を保持させてもいいですが、
# ここでは単純に ice_friction を少しずつ下げていきます。
var target := 0.02 # 最終的にこのくらいまで下げたい
var current := controller.ice_friction
var new_value := lerp(current, target, intensity_speed * delta)
controller.ice_friction = new_value
このように、床の側のロジックをいじるだけで、プレイヤーや敵のスクリプトを変更せずに挙動を変えられるのが、コンポーネント指向の気持ちよさですね。
ぜひ自分のプロジェクトでも、IceFloor をベースにいろいろな「特殊床コンポーネント」を量産してみてください。
