レバー的なスイッチって、つい専用の Lever シーンを作って、その中にアニメーションや当たり判定、インタラクト処理をごっそり書いてしまいがちですよね。さらに「プレイヤーが近づいたらEキーで操作」「敵AIもレバーを操作」「UIボタンからもON/OFFしたい」…と要件が増えてくると、レバーシーンのスクリプトがどんどん肥大化していきます。

Godot標準のやり方だと、

  • レバーごとに専用シーン&スクリプトを継承で増やしていく
  • 「インタラクト処理」と「ON/OFF状態管理」と「見た目のアニメーション」が1つのスクリプトにべったり結合する
  • 別のオブジェクトでも同じようなスイッチ機能を使いたいときに、コピペや継承で泥沼化

という状態になりがちです。

そこで今回は、レバーの「ON/OFFトグル機能」だけを切り出したコンポーネント LeverSwitch を作ってみましょう。どんなノードにもペタッと貼れるようにしておけば、

  • レバー型スイッチ
  • 壁のスイッチ
  • 床にあるペダルスイッチ

など、見た目が違うオブジェクトにも同じロジックを再利用できます。継承より合成、ですね。


【Godot 4】レバーで世界をON/OFF!「LeverSwitch」コンポーネント

このコンポーネントは、

  • インタラクト(プレイヤー操作・信号・スクリプト呼び出し)でON/OFFをトグル
  • 左右に倒れた見た目(回転 or スプライトフレーム)を自動制御
  • ON/OFF時にシグナル発火(ドアや仕掛けに接続しやすい)

という、レバー系ギミックの「コア機能」を提供します。


フルコード:LeverSwitch.gd


extends Node
class_name LeverSwitch
## レバー型スイッチのコンポーネント
## - インタラクトでON/OFFを切り替え
## - 見た目(回転 or スプライト)を自動更新
## - ON/OFF時にシグナルを発火

## スイッチがONになったとき
signal switched_on
## スイッチがOFFになったとき
signal switched_off
## ON/OFFが変化したとき(どちらでも)
signal switched(state: bool)

## 現在のON/OFF状態
@export var is_on: bool = false:
	set(value):
		if is_on == value:
			return
		is_on = value
		_update_visual()
		_emit_state_signals()

## 見た目の更新方法
enum VisualMode {
	ROTATE_NODE,     ## 親ノード(または指定ノード)を回転させる
	SPRITE_FRAME,    ## Sprite2D/AnimatedSprite2D のフレームを切り替える
	NONE             ## 見た目はこのコンポーネントでは制御しない
}

## 見た目の制御方法
@export var visual_mode: VisualMode = VisualMode.ROTATE_NODE

## 見た目を操作する対象ノードへのパス
## - 空なら親ノード(self.get_parent())を対象にする
## - 例: Sprite2D を制御したい場合は NodePath("Sprite2D")
@export var visual_target_path: NodePath

## ROTATE_NODE モード用: OFF時の回転角度(度)
@export var rotation_off_deg: float = -30.0
## ROTATE_NODE モード用: ON時の回転角度(度)
@export var rotation_on_deg: float = 30.0
## ROTATE_NODE モード用: 補間時間(秒)。0だと即座に切り替え
@export var rotate_transition_time: float = 0.1

## SPRITE_FRAME モード用: OFF時に表示するフレーム番号
@export var frame_off: int = 0
## SPRITE_FRAME モード用: ON時に表示するフレーム番号
@export var frame_on: int = 1

## インタラクト受付のクールダウン(秒)
## - 連打対策やアニメーション完了を待つため
@export var interact_cooldown: float = 0.1

## 最初に自動で見た目を状態に合わせるか
@export var sync_visual_on_ready: bool = true

## 内部用: クールダウンタイマー
var _cooldown_timer: float = 0.0

## 内部用: 補間用Tween(回転アニメ用)
var _tween: Tween

func _ready() -> void:
	# シーン起動時に見た目を状態に合わせる
	if sync_visual_on_ready:
		_update_visual(force_instant := true)


func _process(delta: float) -> void:
	# クールダウンを減らす
	if _cooldown_timer > 0.0:
		_cooldown_timer -= delta


## 外部から呼ぶインタラクト用API
## - プレイヤーの入力、エリアの衝突、UIボタンなどから呼び出してください
func interact() -> void:
	if _cooldown_timer > 0.0:
		return  # まだクールダウン中
	toggle()
	_cooldown_timer = interact_cooldown


## 状態をトグル(ON <-> OFF)
func toggle() -> void:
	is_on = !is_on


## 明示的にONにする
func turn_on() -> void:
	is_on = true


## 明示的にOFFにする
func turn_off() -> void:
	is_on = false


## 現在の状態を返す(読みやすさのためのヘルパー)
func is_on_state() -> bool:
	return is_on


## --- 内部処理 -----------------------------------------------------------

## 見た目を現在の state に合わせて更新
func _update_visual(force_instant: bool = false) -> void:
	match visual_mode:
		VisualMode.ROTATE_NODE:
			_update_visual_rotate(force_instant)
		VisualMode.SPRITE_FRAME:
			_update_visual_sprite()
		VisualMode.NONE:
			pass


## ROTATE_NODE モードの見た目更新
func _update_visual_rotate(force_instant: bool) -> void:
	var target_node := _get_visual_target()
	if target_node == null:
		return

	# 既存のTweenがあれば止める
	if _tween and _tween.is_valid():
		_tween.kill()

	var target_deg := rotation_on_deg if is_on else rotation_off_deg
	var target_rad := deg_to_rad(target_deg)

	if force_instant or rotate_transition_time <= 0.0:
		target_node.rotation = target_rad
	else:
		_tween = create_tween()
		_tween.tween_property(
			target_node,
			"rotation",
			target_rad,
			rotate_transition_time
		).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)


## SPRITE_FRAME モードの見た目更新
func _update_visual_sprite() -> void:
	var target_node := _get_visual_target()
	if target_node == null:
		return

	# Sprite2D の場合
	if target_node is Sprite2D:
		var sprite := target_node as Sprite2D
		# Godot 4 の Sprite2D は "frame" プロパティでアニメーションフレームを扱える
		sprite.frame = frame_on if is_on else frame_off
		return

	# AnimatedSprite2D の場合
	if target_node is AnimatedSprite2D:
		var anim_sprite := target_node as AnimatedSprite2D
		# AnimatedSprite2D では "frame" プロパティで現在のフレームを指定できる
		anim_sprite.frame = frame_on if is_on else frame_off
		return

	# 対応していないノードだった場合は何もしない
	push_warning("LeverSwitch: visual_mode = SPRITE_FRAME ですが、対象ノードが Sprite2D / AnimatedSprite2D ではありません。")


## 見た目ターゲットノードを取得
func _get_visual_target() -> Node2D:
	var target: Node = null

	if visual_target_path.is_empty():
		target = get_parent()
	else:
		target = get_node_or_null(visual_target_path)

	if target == null:
		push_warning("LeverSwitch: visual target not found. visual_target_path=%s" % visual_target_path)
		return null

	if not (target is Node2D):
		push_warning("LeverSwitch: visual target is not Node2D. path=%s" % visual_target_path)
		return null

	return target as Node2D


## 状態変化シグナルを発火
func _emit_state_signals() -> void:
	switched.emit(is_on)
	if is_on:
		switched_on.emit()
	else:
		switched_off.emit()

使い方の手順

ここでは典型的な「プレイヤーが近づいてEキーでレバーを倒し、ドアが開く」例で説明します。

① シーン構成を作る

まずはレバー本体のシーンを作りましょう。

Lever (Node2D)
 ├── Sprite2D
 ├── Area2D
 │    └── CollisionShape2D
 └── LeverSwitch (Node)
  • Lever (Node2D): レバーのルート
  • Sprite2D: レバーの見た目。回転させたりフレームを切り替えたりします
  • Area2D + CollisionShape2D: プレイヤーが近づいたかどうかを検知するためのエリア
  • LeverSwitch: 上記のコンポーネントスクリプトをアタッチした Node

LeverSwitch ノードには、先ほどの LeverSwitch.gd をアタッチしてください。

② LeverSwitch のパラメータを設定する

インスペクタで LeverSwitch ノードを選択し、以下のように設定します(例):

  • is_on: 初期状態(OFFにしたいなら false)
  • visual_mode: ROTATE_NODE(レバーを左右に倒す)
  • visual_target_path: "../Sprite2D"(親から見たパス)
  • rotation_off_deg: -30
  • rotation_on_deg: 30
  • rotate_transition_time: 0.1(ちょっとだけアニメーション)
  • interact_cooldown: 0.2 くらい(お好み)

もしスプライトシートで「左に倒れたフレーム」「右に倒れたフレーム」を持っているなら、

  • visual_mode: SPRITE_FRAME
  • visual_target_path: "../Sprite2D"
  • frame_off: OFFのフレーム番号
  • frame_on: ONのフレーム番号

とすることで、回転ではなくフレーム切り替えで表現できます。

③ プレイヤーからインタラクトする

次に、プレイヤーキャラからレバーを操作してみましょう。プレイヤーシーンは例えばこんな感じ:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── InteractArea (Area2D)
      └── CollisionShape2D

InteractArea でレバーに近づいているかを検知し、Eキー入力で LeverSwitch.interact() を呼び出します。


# Player.gd(サンプル)
extends CharacterBody2D

@export var interact_action_name := "interact" # InputMap で "E" などに割り当て

var _current_lever: LeverSwitch = null

func _ready() -> void:
	# InteractArea のシグナル接続(エディタからでもOK)
	var interact_area := $InteractArea
	interact_area.body_entered.connect(_on_interact_body_entered)
	interact_area.body_exited.connect(_on_interact_body_exited)


func _physics_process(delta: float) -> void:
	# 省略: 移動処理など

	if Input.is_action_just_pressed(interact_action_name) and _current_lever:
		_current_lever.interact()


func _on_interact_body_entered(body: Node) -> void:
	# 近づいたオブジェクトに LeverSwitch コンポーネントがあるか探す
	var lever := body.get_node_or_null("LeverSwitch")
	if lever and lever is LeverSwitch:
		_current_lever = lever


func _on_interact_body_exited(body: Node) -> void:
	if _current_lever and _current_lever.get_parent() == body:
		_current_lever = null

ここでは「プレイヤーの InteractAreaLever 本体のコリジョンに重なっているときだけ _current_lever をセットし、Eキーで interact() を呼ぶ」というシンプルな構成にしています。

④ レバーでドアなどのギミックを動かす

最後に、レバーON/OFFでドアを開閉してみましょう。ドア側もコンポーネント指向で作るとスッキリしますが、ここでは簡単な例としてドアのスクリプトに直接書いてみます。

Door (Node2D)
 ├── Sprite2D
 └── CollisionShape2D

# Door.gd(サンプル)
extends Node2D

@export var is_open: bool = false:
	set(value):
		is_open = value
		_update_visual()

func _ready() -> void:
	_update_visual()

func _update_visual() -> void:
	# 開いているときはコリジョンを無効化、スプライトを半透明にするなど
	$CollisionShape2D.disabled = is_open
	$Sprite2D.modulate.a = 0.5 if is_open else 1.0

## LeverSwitch の switched シグナルに接続して使う
func on_lever_switched(state: bool) -> void:
	is_open = state

エディタ上で、レバーシーンを開き、LeverSwitch ノードを選択して、

  • switched シグナル → ドアの on_lever_switched に接続

とするだけで、レバーONでドアが開き、OFFで閉じるようになります。


メリットと応用

LeverSwitch をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • シーン構造がシンプルに保てる
    レバーの「見た目」「当たり判定」「インタラクト」「ON/OFF状態管理」が、それぞれ別ノード・別スクリプトに分かれます。
    レバー専用の巨大スクリプトを作らずに済むので、後から仕様変更が来ても怖くありません。
  • どんなノードにも貼り付けて再利用できる
    見た目がレバーじゃなくてもOKです。壁スイッチにも、床スイッチにも、UI用の疑似スイッチにもそのまま使えます。
    visual_mode = NONE にして、見た目は別のコンポーネントに任せる、なんて構成もアリですね。
  • テストがしやすい
    turn_on(), turn_off(), toggle() といった明示的なAPIがあるので、ユニットテストやデバッグ用のスクリプトからも扱いやすいです。

応用としては、

  • 複数のレバーが同時にONになったら扉が開く「パズルドア」
  • 一定時間だけONになる「タイマー付きレバー」(クールダウンを応用)
  • 敵AIが巡回中にレバーを切り替える「罠の制御」

などに広げていけます。

改造案:一定時間で自動的にOFFに戻るレバー

例えば「押してから3秒後に自動でOFFに戻るレバー」にしたい場合、こんな関数を追加できます:


## 一定時間だけONにして、自動でOFFに戻す
@export var auto_off_time: float = 0.0  # 0以下なら自動OFFなし

func _emit_state_signals() -> void:
	switched.emit(is_on)
	if is_on:
		switched_on.emit()
		# ONになったタイミングで自動OFFを仕込む
		if auto_off_time > 0.0:
			# 既存のタイマーTweenを殺してから新規作成するなどの工夫をしてもOK
			var timer_tween := create_tween()
			timer_tween.tween_interval(auto_off_time)
			timer_tween.tween_callback(turn_off)
	else:
		switched_off.emit()

こうしておけば、インスペクタで auto_off_time に 3.0 を入れるだけで「3秒だけONになるレバー」が完成します。レバーの挙動を変えたいときも、レバー本体のシーンをいじらずにコンポーネントだけ差し替え/改造できるのが、合成志向の気持ちいいところですね。