アクションゲームで「溜め撃ち」を実装しようとすると、ついプレイヤーのスクリプトが肥大化しがちですよね。
入力処理、チャージ時間の計測、エフェクト、弾の生成…すべてを 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 にアクションを追加
- Godot エディタ上部メニューから 「Project > Project Settings…」 を開く
- 「Input Map」タブで、
attackというアクションを追加 attackにキーボードのJキーやゲームパッドボタンなどを割り当て
(別名を使いたい場合は、fire_action にそのアクション名を設定すればOKです)
② プレイヤーシーンに ChargeShot をアタッチ
プレイヤーのシーン構成例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── Muzzle (Marker2D) ← 弾の発射位置 └── ChargeShot (Node) ← 今回のコンポーネント
ChargeShot.gdをプロジェクト内に保存(例:res://components/ChargeShot.gd)- Player シーンを開き、+ ボタンで Node を追加 →
Nodeを選択 - 追加した Node を選択して、インスペクタの「Script」欄から
ChargeShot.gdをアタッチ - ノード名を
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 を作成し、ChargeShot の projectile_scene にドラッグ&ドロップで割り当てます。
④ ChargeShot のパラメータを調整
Player シーンで ChargeShot ノードを選択し、インスペクタから:
fire_action:attackmin_charge_time: 0.1(0.1秒未満は通常ショット扱い)max_charge_time: 1.5(1.5秒でフルチャージ)spawn_marker:../Muzzleを指定base_speed: 500max_speed_multiplier: 1.5base_scale: (1, 1)max_scale_multiplier: 2.5base_damage: 10max_damage_multiplier: 3use_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 コンポーネントをポン付けして合成するスタイル、ぜひ試してみてくださいね。
