Godot 4 でアクションゲームを作っていると、ジャンプ台・トランポリン・バネ床みたいな「プレイヤーを強制的に跳ね飛ばすギミック」を作りたくなることが多いですよね。
素直にやると、Player シーンのスクリプトに「ジャンプ台用の特別処理」を書き足していきがちですが…
- プレイヤー側のスクリプトがどんどん巨大化する
- 敵や動く床など、別キャラにも流用したくなったときにコピペ地獄
- 「このシーンだけ、ちょっとだけ強く跳ねたい」といった調整がしづらい
といった問題が出てきます。
Godot 標準の「ノード継承でジャンプ台付きプレイヤーを作る」方式も、シーンが増えるたびに継承ツリーが複雑になっていきます。
そこで今回は、「乗った瞬間に、対象の velocity.y を強制的に上書きして跳ね飛ばす」処理を
完全に独立したコンポーネントとして切り出した SpringBoard を用意してみましょう。
【Godot 4】踏んだら即ジャンプ!「SpringBoard」コンポーネント
このコンポーネントは、基本的には Area2D にアタッチして使う想定です。
Area2D のコリジョンにプレイヤー(や敵)が触れた瞬間、そのオブジェクトの velocity.y を上書きして上方向に吹き飛ばします。
- 「誰を」「どれくらいの強さで」跳ね飛ばすか
- 連続ヒット防止(1フレームで何度も当たらないように)
- 音・エフェクトの再生
といった要素を、すべてコンポーネント側に閉じ込めておきます。
フルコード:SpringBoard.gd
extends Area2D
class_name SpringBoard
## SpringBoard (ジャンプ台) コンポーネント
## - Area2D にアタッチして使う
## - body_entered で対象の velocity.y を強制的に上書きして跳ね飛ばす
@export_category("Spring Settings")
@export var bounce_strength: float = -900.0:
## 跳ね飛ばす強さ(マイナスで上方向)
## プレイヤーの重力やジャンプ力に合わせて調整しましょう
set(value):
bounce_strength = value
@export var only_affect_groups: Array[StringName] = [&"player"]:
## 影響を与える対象のグループ名リスト
## 例: ["player", "enemy"] としておけばプレイヤーと敵両方に効く
set(value):
only_affect_groups = value
@export var require_downward_motion: bool = true:
## 対象が「落下中」のときだけ反応させるかどうか
## true にすると、下からぶつかったときには発動しない
set(value):
require_downward_motion = value
@export var cooldown_time: float = 0.05:
## 同じオブジェクトに対して、何秒間は再度バウンドさせないか
## 1フレーム複数回ヒット防止 & 多段バウンドの制御用
set(value):
cooldown_time = max(value, 0.0)
@export_category("Feedback")
@export var play_animation: bool = true
@export var animation_name: StringName = &"bounce"
## アニメーションを再生する場合、同じノード階層か子に AnimationPlayer を置いてください
@export var play_sound: bool = true
@export var sound_stream_player_path: NodePath = ^"AudioStreamPlayer2D"
## 効果音用の AudioStreamPlayer2D のパス
@export var one_shot: bool = false:
## 一度踏まれたら無効化するジャンプ台にしたい場合 true
set(value):
one_shot = value
@export var disable_collision_on_one_shot: bool = true:
## one_shot 時、Collider を無効化するかどうか
set(value):
disable_collision_on_one_shot = value
## 内部状態
var _last_bounced_bodies: Dictionary = {} # body -> last_bounced_time
var _animation_player: AnimationPlayer
var _audio_player: AudioStreamPlayer2D
var _is_active: bool = true
func _ready() -> void:
## AnimationPlayer と AudioStreamPlayer2D を取得
_animation_player = _find_animation_player()
_audio_player = _find_audio_player()
## シグナル接続(エディタで接続していなくても自動でつなぐ)
if not is_connected("body_entered", Callable(self, "_on_body_entered")):
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
## クールダウン時間を過ぎたエントリを掃除
if _last_bounced_bodies.is_empty():
return
var now := Time.get_ticks_msec() / 1000.0
var to_erase: Array = []
for body: Object in _last_bounced_bodies.keys():
var t: float = _last_bounced_bodies[body]
if now - t > cooldown_time:
to_erase.append(body)
for body in to_erase:
_last_bounced_bodies.erase(body)
func _on_body_entered(body: Node) -> void:
if not _is_active:
return
# グループフィルタリング
if not _is_in_target_group(body):
return
# velocity プロパティを持っているか確認
if not body.has_method("get") or not body.has_method("set"):
return
if not body.has_variable("velocity"):
# CharacterBody2D/3D は velocity プロパティを持つが、
# 独自クラスの場合は自前で velocity を定義しておく必要がある
return
var velocity := body.velocity
# 落下中かどうかの判定(require_downward_motion が true の場合)
if require_downward_motion and velocity.y <= 0.0:
return
# クールダウンチェック
if _is_in_cooldown(body):
return
# 実際にバウンドさせる
_apply_bounce(body)
# フィードバック(アニメーション・サウンド)
_play_feedback()
# one_shot 処理
if one_shot:
_deactivate_one_shot()
func _apply_bounce(body: Node) -> void:
## velocity.y を強制的に上書き
var v := body.velocity
v.y = bounce_strength
body.velocity = v
# クールダウン登録
var now := Time.get_ticks_msec() / 1000.0
_last_bounced_bodies[body] = now
func _is_in_target_group(body: Node) -> bool:
if only_affect_groups.is_empty():
return true
for group_name in only_affect_groups:
if body.is_in_group(group_name):
return true
return false
func _is_in_cooldown(body: Node) -> bool:
if cooldown_time <= 0.0:
return false
if not _last_bounced_bodies.has(body):
return false
var last_time: float = _last_bounced_bodies[body]
var now := Time.get_ticks_msec() / 1000.0
return (now - last_time) < cooldown_time
func _play_feedback() -> void:
if play_animation and _animation_player and animation_name != StringName():
if _animation_player.has_animation(animation_name):
_animation_player.play(animation_name)
if play_sound and _audio_player:
_audio_player.play()
func _deactivate_one_shot() -> void:
_is_active = false
if disable_collision_on_one_shot:
# 自身の CollisionShape2D を全部無効化
for child in get_children():
if child is CollisionShape2D:
child.disabled = true
# one_shot 用のアニメーションがあればそちらを再生するなど、
# ここで見た目の変化を追加してもよい
func _find_animation_player() -> AnimationPlayer:
## 自身か子孫から AnimationPlayer を探すヘルパー
if self is AnimationPlayer:
return self
return find_child("AnimationPlayer", true, false) as AnimationPlayer
func _find_audio_player() -> AudioStreamPlayer2D:
## 自身か子孫から AudioStreamPlayer2D を探すヘルパー
if self is AudioStreamPlayer2D:
return self
if sound_stream_player_path != NodePath():
var node := get_node_or_null(sound_stream_player_path)
if node and node is AudioStreamPlayer2D:
return node
return find_child("AudioStreamPlayer2D", true, false) as AudioStreamPlayer2D
使い方の手順
- SpringBoard シーンを作る
1. 新規シーンを作成し、ルートに Area2D を追加します。
2. その Area2D に上記の SpringBoard.gd をアタッチします。
3. 子ノードとして CollisionShape2D と Sprite2D を追加し、見た目と当たり判定を設定します。
SpringBoard (Area2D) ├── CollisionShape2D ├── Sprite2D ├── AnimationPlayer ※任意(バウンド時のアニメ用) └── AudioStreamPlayer2D ※任意(効果音用)
SpringBoard のインスペクタで、以下をお好みで設定します。
bounce_strength… 上方向の速度。例:-900(マイナスで上へ)only_affect_groups…["player"]など、対象グループrequire_downward_motion… 落下中だけ反応させたいならtrueone_shot… 一度だけ発動するジャンプ台にしたいならtrue
- プレイヤー側の最低条件を満たす
このコンポーネントは「velocity プロパティを持つノード」を対象にしています。
典型的な CharacterBody2D プレイヤーなら、こんな感じになっているはずです。
extends CharacterBody2D
const GRAVITY: float = 2000.0
const JUMP_SPEED: float = -600.0
const MOVE_SPEED: float = 200.0
func _physics_process(delta: float) -> void:
# 重力
if not is_on_floor():
velocity.y += GRAVITY * delta
# 左右入力
var dir := Input.get_axis("ui_left", "ui_right")
velocity.x = dir * MOVE_SPEED
# ジャンプ
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = JUMP_SPEED
# ジャンプ台からの上書きは、move_and_slide() の前に行われるので
# 特別な処理は不要
move_and_slide()
重要なのは、プレイヤーに velocity プロパティが存在することです。
CharacterBody2D を使っていればデフォルトで持っているので、そのままで OK です。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── (他のコンポーネントいろいろ)
プレイヤーのスクリプトに「ジャンプ台用の if 文」を書く必要はありません。
SpringBoard が勝手に velocity.y を上書きしてくれます。
- シーンに配置して、グループを設定する
メインステージ(例: Level1.tscn)を開き、先ほど作った SpringBoard シーンをインスタンスとして配置します。
Level1 (Node2D) ├── Player (CharacterBody2D) ├── SpringBoard (インスタンス) x N └── TileMap
プレイヤー側には、グループ設定 をしておきましょう。
- Player ノードを選択
- インスペクタ横の「ノード」タブ → 「グループ」タブ
playerというグループ名を追加
SpringBoard の only_affect_groups が ["player"] になっていれば、
プレイヤーだけがジャンプ台の効果を受けるようになります。
- 敵や動く床にも使ってみる
コンポーネント指向のいいところは、プレイヤー専用ではないところです。
たとえば、こんな敵シーンがあるとします。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── HealthComponent (Node)
この Enemy も CharacterBody2D なので velocity を持っています。
あとは、Enemy に enemy グループを付けて、SpringBoard 側の only_affect_groups に "enemy" を追加すれば、
プレイヤーと同じジャンプ台をそのまま敵にも適用できます。
動く床(MovingPlatform など)にも velocity を持たせておけば、同じようにバネ床として機能させることができますね。
メリットと応用
この SpringBoard コンポーネントを使うことで、次のようなメリットがあります。
- プレイヤーのスクリプトが一切汚れない
「ジャンプ台に乗ったときだけ特別な処理をする」if 文をプレイヤー側に書かなくて済みます。 - レベルデザインが直感的になる
「ここにジャンプ台が欲しい」と思ったら、SpringBoard シーンをポンと置くだけ。
プレイヤーや敵の実装を一切触らずに、ギミックを追加できます。 - グループとエクスポート変数で柔軟に制御
only_affect_groupsとbounce_strengthを変えるだけで、
「プレイヤー専用の強力バネ」「敵専用のトラップバネ」などを簡単に作り分けられます。 - アニメーション・サウンドもコンポーネント側で完結
バネがへこむアニメや、バネ音の再生までひとまとめ。
見た目の調整も SpringBoard シーン内だけで完結します。
「継承より合成」の思想でいくと、SpringBoard はあくまで
「velocity をいじるだけの小さな部品」としてとどめておくのがおすすめです。
ノード階層を深くせず、「何が何をしているか」が一目でわかる構成になります。
改造案:バウンド方向を法線ベースにする
今の実装では、常に「上方向」に跳ね飛ばしていますが、
ステージによっては「斜め方向に飛ばしたい」こともありますよね。
その場合、コリジョンの法線ベクトルを使って、バウンド方向を決めるように改造できます。
例えば、以下のような関数を追加し、_apply_bounce() から呼ぶようにすれば、
「指定した方向に一定速度で飛ばす」ジャンプ台になります。
@export var use_custom_direction: bool = false
@export var custom_direction: Vector2 = Vector2.UP
## use_custom_direction が true のとき、custom_direction の方向に飛ばす
## custom_direction は正規化されていなくても OK(内部で正規化)
func _apply_bounce(body: Node) -> void:
var v := body.velocity
if use_custom_direction:
var dir := custom_direction
if dir == Vector2.ZERO:
dir = Vector2.UP
dir = dir.normalized()
v = dir * abs(bounce_strength)
else:
# 従来通り、Y だけを上書き
v.y = bounce_strength
body.velocity = v
var now := Time.get_ticks_msec() / 1000.0
_last_bounced_bodies[body] = now
これで、「右上に飛ばすバネ」「左下に飛ばすトラップ床」なども簡単に作れるようになります。
同じ SpringBoard コンポーネントをベースに、レベルごとのバリエーションを増やしていきましょう。
