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

使い方の手順

  1. SpringBoard シーンを作る

1. 新規シーンを作成し、ルートに Area2D を追加します。
2. その Area2D に上記の SpringBoard.gd をアタッチします。
3. 子ノードとして CollisionShape2DSprite2D を追加し、見た目と当たり判定を設定します。

SpringBoard (Area2D)
 ├── CollisionShape2D
 ├── Sprite2D
 ├── AnimationPlayer        ※任意(バウンド時のアニメ用)
 └── AudioStreamPlayer2D    ※任意(効果音用)

SpringBoard のインスペクタで、以下をお好みで設定します。

  • bounce_strength … 上方向の速度。例: -900(マイナスで上へ)
  • only_affect_groups["player"] など、対象グループ
  • require_downward_motion … 落下中だけ反応させたいなら true
  • one_shot … 一度だけ発動するジャンプ台にしたいなら true

  1. プレイヤー側の最低条件を満たす

このコンポーネントは「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 を上書きしてくれます。


  1. シーンに配置して、グループを設定する

メインステージ(例: Level1.tscn)を開き、先ほど作った SpringBoard シーンをインスタンスとして配置します。

Level1 (Node2D)
 ├── Player (CharacterBody2D)
 ├── SpringBoard (インスタンス) x N
 └── TileMap

プレイヤー側には、グループ設定 をしておきましょう。

  1. Player ノードを選択
  2. インスペクタ横の「ノード」タブ → 「グループ」タブ
  3. player というグループ名を追加

SpringBoard の only_affect_groups["player"] になっていれば、
プレイヤーだけがジャンプ台の効果を受けるようになります。


  1. 敵や動く床にも使ってみる

コンポーネント指向のいいところは、プレイヤー専用ではないところです。
たとえば、こんな敵シーンがあるとします。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── HealthComponent (Node)

この Enemy も CharacterBody2D なので velocity を持っています。
あとは、Enemy に enemy グループを付けて、SpringBoard 側の only_affect_groups"enemy" を追加すれば、
プレイヤーと同じジャンプ台をそのまま敵にも適用できます。

動く床(MovingPlatform など)にも velocity を持たせておけば、同じようにバネ床として機能させることができますね。


メリットと応用

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

  • プレイヤーのスクリプトが一切汚れない
    「ジャンプ台に乗ったときだけ特別な処理をする」if 文をプレイヤー側に書かなくて済みます。
  • レベルデザインが直感的になる
    「ここにジャンプ台が欲しい」と思ったら、SpringBoard シーンをポンと置くだけ。
    プレイヤーや敵の実装を一切触らずに、ギミックを追加できます。
  • グループとエクスポート変数で柔軟に制御
    only_affect_groupsbounce_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 コンポーネントをベースに、レベルごとのバリエーションを増やしていきましょう。