アクションゲームで「溜め撃ち」を実装しようとすると、ついプレイヤーのスクリプトが肥大化しがちですよね。
入力処理、チャージ時間の計測、エフェクト、弾の生成…すべてを Player.gd に書き始めると、あっという間に「巨大クラス」のできあがりです。

Godot 4 でも、継承ベースで PlayerWithChargeShot.gd みたいな派生クラスを作るのは簡単ですが、「溜め撃ちできる敵」「溜め撃ちできるタレット」 を作りたくなった瞬間に、また別バージョンを作るか、コピペ地獄に陥ります。

そこでこの記事では、「溜め撃ち」だけを担当するコンポーネントとして、ChargeShot を用意して、プレイヤーでも敵でも「ポン付け」できる形にしてみましょう。
ノード階層を深くせず、合成(Composition)で機能を足していくスタイルですね。

【Godot 4】押しっぱなしでド派手に!「ChargeShot」コンポーネント

今回の ChargeShot コンポーネントは、ざっくり言うとこんな役割です。

  • 指定したボタン(例: attack)を押している間、チャージ時間を計測
  • ボタンを離した瞬間に、チャージ量に応じた弾を生成
  • 威力・サイズ・ノックバックなどをチャージ量から自動計算
  • プレイヤーにも敵にもアタッチ可能な、汎用コンポーネント

入力は InputMap のアクション名で指定するので、ゲームパッドでもキーボードでも対応できます。


フルコード: ChargeShot.gd


extends Node
class_name ChargeShot
"""
ChargeShot コンポーネント
- 指定アクションを押している間「チャージ」
- 離した瞬間に、チャージ量に応じた弾を生成する

想定:
- 親ノードが「向き」や「位置」を持っている (例: CharacterBody2D, Node2D)
- 弾シーンは PackedScene として渡す
"""

@export_group("Input")
## チャージに使う InputMap のアクション名
@export var fire_action: StringName = "attack"

@export_group("Charge Settings")
## チャージの最小時間(この時間未満だと「通常ショット」として扱う)
@export var min_charge_time: float = 0.1
## フルチャージに必要な時間(これ以上はチャージ率 1.0 で固定)
@export var max_charge_time: float = 1.5
## チャージ中に連射を許可するか(false なら離すまで一発のみ)
@export var allow_hold_repeat: bool = false

@export_group("Projectile")
## 発射する弾のシーン (必須)
@export var projectile_scene: PackedScene
## 弾を生成する位置。未設定の場合は親ノードの位置を使う
@export var spawn_marker: NodePath
## 弾のベース速度
@export var base_speed: float = 500.0
## チャージ最大時に速度を何倍まで上げるか
@export var max_speed_multiplier: float = 1.5
## 弾のベーススケール
@export var base_scale: Vector2 = Vector2.ONE
## チャージ最大時にスケールを何倍まで上げるか
@export var max_scale_multiplier: float = 2.5
## 弾のベースダメージ (弾側が受け取って使う想定)
@export var base_damage: float = 10.0
## チャージ最大時にダメージを何倍まで上げるか
@export var max_damage_multiplier: float = 3.0

@export_group("Direction")
## 親ノードにこのプロパティがあれば、発射方向として利用する (例: direction: Vector2)
@export var parent_direction_property: StringName = "facing_direction"
## 上記が無い場合に使うデフォルト方向
@export var default_direction: Vector2 = Vector2.RIGHT
## 親が Node2D の場合、回転を向きとして使うかどうか
@export var use_parent_rotation_as_direction: bool = true

@export_group("Debug / FX")
## チャージ中の割合 (0.0 ~ 1.0) を外部から読めるようにする
var charge_ratio: float = 0.0:
	get:
		return charge_ratio
## デバッグ用: チャージ量をログに出すか
@export var debug_log: bool = false

# 内部状態
var _is_charging: bool = false
var _charge_time: float = 0.0
var _fire_pressed_last_frame: bool = false

func _process(delta: float) -> void:
	if fire_action == StringName():
		return
	
	var is_pressed := Input.is_action_pressed(fire_action)
	var just_pressed := Input.is_action_just_pressed(fire_action)
	var just_released := Input.is_action_just_released(fire_action)
	
	# 押し始め
	if just_pressed:
		_start_charge()
	
	# 押している間はチャージ時間を加算
	if _is_charging and is_pressed:
		_charge_time += delta
		charge_ratio = clamp(_charge_time / max_charge_time, 0.0, 1.0)
	
	# 離した瞬間にショット
	if just_released and _is_charging:
		_shoot_and_reset()
	
	_fire_pressed_last_frame = is_pressed


func _start_charge() -> void:
	# 連射許可が false で、すでにチャージ中なら無視
	if not allow_hold_repeat and _is_charging:
		return
	
	_is_charging = true
	_charge_time = 0.0
	charge_ratio = 0.0
	# ここでチャージエフェクト開始などの処理を入れてもよい
	# 例: 親にシグナルを送るなど
	if debug_log:
		print("[ChargeShot] start charge")


func _shoot_and_reset() -> void:
	_is_charging = false
	
	# チャージ量を 0.0 ~ 1.0 に正規化
	var t := clamp(_charge_time / max_charge_time, 0.0, 1.0)
	charge_ratio = t
	
	# 最小チャージ時間未満なら「通常ショット」として t を 0 扱いにしてもよい
	if _charge_time < min_charge_time:
		t = 0.0
	
	_spawn_projectile(t)
	
	# リセット
	_charge_time = 0.0
	charge_ratio = 0.0
	
	if debug_log:
		print("[ChargeShot] shoot with charge: ", t)


func _spawn_projectile(charge: float) -> void:
	if projectile_scene == null:
		push_warning("[ChargeShot] projectile_scene is not assigned.")
		return
	
	var projectile := projectile_scene.instantiate()
	
	# 位置決定
	var spawn_position: Vector2 = Vector2.ZERO
	var parent_node2d := owner as Node2D
	
	if spawn_marker != NodePath():
		var marker_node := get_node_or_null(spawn_marker)
		if marker_node is Node2D:
			spawn_position = (marker_node as Node2D).global_position
		elif parent_node2d:
			# Marker が Node2D でない場合は親位置にフォールバック
			spawn_position = parent_node2d.global_position
	else:
		if parent_node2d:
			spawn_position = parent_node2d.global_position
	
	# 発射方向決定
	var dir := _get_fire_direction()
	
	# 弾が Node2D なら位置と向きを設定
	if projectile is Node2D:
		var p2d := projectile as Node2D
		p2d.global_position = spawn_position
		# 向きに応じて回転を設定 (任意)
		if dir.length() > 0.0:
			p2d.rotation = dir.angle()
	
	# スピード・スケール・ダメージをチャージ量から計算
	var speed := lerp(base_speed, base_speed * max_speed_multiplier, charge)
	var scale_mul := lerp(1.0, max_scale_multiplier, charge)
	var damage := lerp(base_damage, base_damage * max_damage_multiplier, charge)
	
	# Node2D ならスケールを変更
	if projectile is Node2D:
		(projectile as Node2D).scale = base_scale * scale_mul
	
	# 弾側に「velocity」「damage」などのプロパティがあれば設定
	# これは「緩いインターフェース」として利用
	if projectile.has_variable("velocity"):
		projectile.velocity = dir.normalized() * speed
	if projectile.has_variable("damage"):
		projectile.damage = damage
	if projectile.has_variable("charge_ratio"):
		projectile.charge_ratio = charge
	
	# シーンツリーに追加
	# 通常は親と同じレイヤーに出したいので、親のルートにぶら下げる
	var root := get_tree().current_scene
	if root:
		root.add_child(projectile)
	else:
		# 念のため owner の親に追加
		if owner and owner.get_parent():
			owner.get_parent().add_child(projectile)
		else:
			add_child(projectile) # 最後のフォールバック
	
	if debug_log:
		print("[ChargeShot] projectile spawned. charge=", charge, 
			" speed=", speed, " damage=", damage)


func _get_fire_direction() -> Vector2:
	var dir := default_direction
	
	# 親に「facing_direction」などのプロパティがあれば利用
	if owner and owner.has_method("get"):
		if owner.has_meta(parent_direction_property):
			# meta に持っているパターンも許可
			dir = owner.get_meta(parent_direction_property)
		elif owner.has_variable(parent_direction_property):
			dir = owner.get(parent_direction_property)
	
	# 親が Node2D で、回転を使う設定ならそちらを優先
	var parent_node2d := owner as Node2D
	if use_parent_rotation_as_direction and parent_node2d:
		dir = Vector2.RIGHT.rotated(parent_node2d.global_rotation)
	
	if dir == Vector2.ZERO:
		dir = default_direction
	
	return dir.normalized()

使い方の手順

ここでは 2D アクションを想定して、プレイヤーに溜め撃ちを付ける例で説明します。

① InputMap にアクションを追加

  1. Godot エディタ上部メニューから 「Project > Project Settings…」 を開く
  2. 「Input Map」タブで、attack というアクションを追加
  3. attack にキーボードの J キーやゲームパッドボタンなどを割り当て

(別名を使いたい場合は、fire_action にそのアクション名を設定すればOKです)

② プレイヤーシーンに ChargeShot をアタッチ

プレイヤーのシーン構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Muzzle (Marker2D)        ← 弾の発射位置
 └── ChargeShot (Node)        ← 今回のコンポーネント
  1. ChargeShot.gd をプロジェクト内に保存(例: res://components/ChargeShot.gd
  2. Player シーンを開き、+ ボタンで Node を追加Node を選択
  3. 追加した Node を選択して、インスペクタの「Script」欄から ChargeShot.gd をアタッチ
  4. ノード名を ChargeShot にリネームすると分かりやすいです

さらに、弾の発射位置用に Muzzle (Marker2D) を追加しておきましょう。

③ 弾(Projectile)シーンを用意する

シンプルな弾シーンの例:

Bullet (CharacterBody2D)
 ├── Sprite2D
 └── CollisionShape2D

弾側のスクリプト例(Bullet.gd):


extends CharacterBody2D
class_name Bullet

## ChargeShot から渡される想定のパラメータ
var velocity: Vector2 = Vector2.ZERO
var damage: float = 10.0
var charge_ratio: float = 0.0

func _physics_process(delta: float) -> void:
	velocity = velocity # 明示的に使うならここで補正など
	velocity = move_and_slide(velocity)
	
	# 画面外で削除などの処理を入れてもよい

この Bullet.tscn を作成し、ChargeShotprojectile_scene にドラッグ&ドロップで割り当てます。

④ ChargeShot のパラメータを調整

Player シーンで ChargeShot ノードを選択し、インスペクタから:

  • fire_action: attack
  • min_charge_time: 0.1(0.1秒未満は通常ショット扱い)
  • max_charge_time: 1.5(1.5秒でフルチャージ)
  • spawn_marker: ../Muzzle を指定
  • base_speed: 500
  • max_speed_multiplier: 1.5
  • base_scale: (1, 1)
  • max_scale_multiplier: 2.5
  • base_damage: 10
  • max_damage_multiplier: 3
  • use_parent_rotation_as_direction: プレイヤーを回転させるなら true、左右反転だけなら false

もしプレイヤーが「向き」を facing_direction: Vector2 で持っているなら、プレイヤー側スクリプトに:


var facing_direction: Vector2 = Vector2.RIGHT

func _physics_process(delta: float) -> void:
	# 入力に応じて向きを更新
	var input_x := Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
	if input_x != 0.0:
		facing_direction = Vector2(sign(input_x), 0)

のように書いておけば、ChargeShot は自動でその向きを使ってくれます。


敵やタレットにもそのまま流用できる

このコンポーネントの良いところは、プレイヤー専用ロジックが一切入っていないことです。
例えば「溜め撃ちしてから強力な弾を撃つ敵」を作りたい場合も、同じようにアタッチするだけです。

ChargeEnemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Muzzle (Marker2D)
 └── ChargeShot (Node)

敵側では、ChargeShot.fire_action を使わず、スクリプトから直接チャージ開始/終了を呼びたい場合もあると思います。
その場合は、Input に依存しないバージョンに改造するか、下の「改造案」を参考にしてください。


メリットと応用

  • プレイヤー、敵、タレットで「溜め撃ちロジック」を完全共有できる
  • 弾の種類を変えたいときは projectile_scene を差し替えるだけ
  • チャージ時間や倍率をいじるだけで「溜めパンチ」「溜めレーザー」なども簡単に作れる
  • プレイヤー本体のスクリプトは「移動」「ステート管理」に集中でき、責務が分離される

特に「敵もプレイヤーも同じような攻撃をする」ゲームでは、コンポーネント化の効果が絶大です。
シーン構造も浅く保てるので、後から見返しても構造が理解しやすくなります。

改造案: 外部からチャージ開始/終了を制御する

敵AIなどから ChargeShot を制御したい場合、Input に依存せずにチャージを開始/終了できるメソッドを追加すると便利です。


# ChargeShot.gd に追記

## 外部スクリプトからチャージを開始したいときに呼ぶ
func start_charge_external() -> void:
	_start_charge()

## 外部スクリプトからチャージを終了(発射)したいときに呼ぶ
func release_charge_external() -> void:
	if _is_charging:
		_shoot_and_reset()

こうしておけば、敵のスクリプトから:


@onready var charge_shot: ChargeShot = $ChargeShot

func _ready() -> void:
	# 1秒後にチャージ開始、さらに1秒後に発射する例
	await get_tree().create_timer(1.0).timeout
	charge_shot.start_charge_external()
	await get_tree().create_timer(1.0).timeout
	charge_shot.release_charge_external()

といった感じで、AI からも自在に「溜め撃ち」をコントロールできます。

継承で「溜め撃ちプレイヤー」「溜め撃ち敵」を増やしていくのではなく、ChargeShot コンポーネントをポン付けして合成するスタイル、ぜひ試してみてくださいね。