Godotでアクションゲームやコンボ制のあるバトルを作ろうとすると、「コンボ数の管理」って意外と面倒ですよね。
- プレイヤーのスクリプトに「コンボ用の変数」と「タイマー」と「UI更新処理」が全部入りになって肥大化する
- 敵やギミックごとに微妙に違うコンボ仕様を入れようとして、継承ツリーがどんどん深くなる
- 「このシーンにもコンボ欲しいな」と思ったら、また似たようなコードをコピペしてしまう
Godotはノード継承で何でもできてしまう分、「コンボ付きプレイヤー」「コンボ付き敵」みたいな専用シーンを量産しがちです。結果として、シーン階層は深く、スクリプトは太くなりがちなんですよね。
そこで今回は、どのノードにもポン付けできる「コンボ計測コンポーネント」として、ComboCounter を用意しました。攻撃がヒットしたタイミングで「コンボコンポーネントに通知する」だけで、
- 連続ヒット数のカウント
- 一定時間攻撃が途切れたら自動リセット
- UIやエフェクトへの通知(シグナル)
を全部まとめて担当してくれます。
【Godot 4】攻撃ヒットを数えるだけ!「ComboCounter」コンポーネント
以下が、そのままコピペで使える ComboCounter.gd のフルコードです。
extends Node
class_name ComboCounter
## 連続ヒット数をカウントし、一定時間攻撃しないとリセットするコンポーネント。
##
## 想定する使い方:
## - プレイヤーや敵など「攻撃する側」のノードにアタッチする
## - 攻撃がヒットしたタイミングで record_hit() を呼ぶ
## - シグナルを使って UI やエフェクトに連携する
## コンボ数が変化したときに発火するシグナル
signal combo_changed(current_combo: int)
## コンボがリセットされたときに発火するシグナル
signal combo_reset()
## 一定コンボ数に到達したときに発火するシグナル
signal combo_reached_threshold(current_combo: int)
## --- 設定パラメータ(インスペクタから編集可能) ---
@export_range(0.1, 10.0, 0.1)
var combo_timeout: float = 2.0:
## コンボ継続許容時間(秒)。
## 最後のヒットからこの秒数が経過するとコンボがリセットされます。
set(value):
combo_timeout = max(value, 0.1)
if is_inside_tree():
_timer.wait_time = combo_timeout
@export var start_from_one: bool = true:
## true の場合、最初のヒットでコンボ値が 1 から始まります。
## false の場合、内部的には 0 からカウントしたい場合などに使えます。
set(value):
start_from_one = value
_reset_internal()
@export var auto_start_timer: bool = true
## true の場合、record_hit() を呼んだときに自動でタイマーが再スタートします。
## false の場合は、外部でタイマー管理をしたい特殊なケース向けです。
@export var combo_threshold: int = 10:
## 「このコンボ数に到達したら特別な処理をしたい」という閾値。
## 0 以下の場合は無効扱いになります。
set(value):
combo_threshold = max(value, 0)
@export var debug_print: bool = false
## true にすると、コンボ開始・更新・リセットのログを出力します。
## デバッグ時に挙動を確認するのに便利です。
## --- 内部状態 ---
var _current_combo: int = 0:
get:
return _current_combo
var _last_hit_time: float = 0.0
var _timer: Timer
func _ready() -> void:
## タイマーを内部で自動生成して使います。
_timer = Timer.new()
_timer.one_shot = true
_timer.wait_time = combo_timeout
_timer.timeout.connect(_on_timeout)
add_child(_timer)
_reset_internal()
## 外部から参照したいとき用のゲッター
func get_current_combo() -> int:
return _current_combo
## 攻撃がヒットしたときに呼び出すメソッド
##
## 例:
## if hit_success:
## $ComboCounter.record_hit()
func record_hit() -> void:
var previous_combo := _current_combo
# 最初のヒットか、すでにリセットされた状態
if _current_combo == 0 and start_from_one:
_current_combo = 1
else:
_current_combo += 1
_last_hit_time = Time.get_ticks_msec() / 1000.0
if auto_start_timer:
_restart_timer()
if debug_print:
if previous_combo == 0:
print("[ComboCounter] コンボ開始: ", _current_combo)
else:
print("[ComboCounter] コンボ継続: ", _current_combo)
# コンボ数が変化したことを通知
combo_changed.emit(_current_combo)
# 閾値に到達したら通知(初回到達時のみ)
if combo_threshold > 0 \
and previous_combo < combo_threshold \
and _current_combo >= combo_threshold:
if debug_print:
print("[ComboCounter] 閾値に到達: ", _current_combo)
combo_reached_threshold.emit(_current_combo)
## 外部からコンボを明示的にリセットしたいときに呼ぶ
##
## 例:
## - ダメージを受けたらコンボリセット
## - シーン遷移時にコンボをクリア
func force_reset() -> void:
if _current_combo == 0:
return
if debug_print:
print("[ComboCounter] force_reset() によりコンボリセット")
_reset_internal()
combo_reset.emit()
## 「コンボが継続中かどうか」を知りたいとき用
func is_in_combo() -> bool:
return _current_combo > 0
## --- 内部処理 ---
func _restart_timer() -> void:
_timer.stop()
_timer.wait_time = combo_timeout
_timer.start()
func _on_timeout() -> void:
# タイマーが鳴った時点で、最後のヒットからの経過時間を確認
var now := Time.get_ticks_msec() / 1000.0
var elapsed := now - _last_hit_time
if elapsed >= combo_timeout and _current_combo > 0:
if debug_print:
print("[ComboCounter] タイムアウトによりコンボリセット (経過: ", elapsed, "秒)")
_reset_internal()
combo_reset.emit()
else:
# もしギリギリでヒットしていたら、再度タイマーをセット
if auto_start_timer and _current_combo > 0:
_restart_timer()
func _reset_internal() -> void:
_current_combo = 0
_last_hit_time = Time.get_ticks_msec() / 1000.0
_timer.stop()
使い方の手順
ここでは、プレイヤーが敵を攻撃してコンボを稼ぐケースを例にします。敵やトレーニングダミー、動く床に「当て続けるとコンボが上がる」みたいなギミックにもそのまま応用できます。
手順①: コンポーネントをプロジェクトに追加する
res://components/ComboCounter.gdなど、好きな場所に上記コードを保存します。- Godotエディタを再読み込みすると、スクリプトクラスとして
ComboCounterが認識されます。
手順②: プレイヤーシーンにアタッチする
プレイヤーシーンの一例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── ComboCounter (Node) ← このノードに ComboCounter.gd をアタッチ └── Label (Label) ← コンボ数を表示するUI(任意)
- Player シーンを開く
- Player の子として
Nodeを追加し、名前をComboCounterに変更 - そのノードに
ComboCounter.gdをアタッチ - インスペクタで
combo_timeoutやcombo_thresholdを好みに応じて設定
手順③: 攻撃ヒット時に record_hit() を呼ぶ
プレイヤーの攻撃判定(例: Area2D の body_entered シグナル)などで、敵に当たったタイミングで record_hit() を呼びます。
# Player.gd (例)
extends CharacterBody2D
@onready var combo_counter: ComboCounter = $ComboCounter
@onready var combo_label: Label = $Label
func _ready() -> void:
# コンボ数が変化したらラベルを更新
combo_counter.combo_changed.connect(_on_combo_changed)
# コンボがリセットされたらラベルをクリア
combo_counter.combo_reset.connect(_on_combo_reset)
func _on_attack_hit(target: Node) -> void:
# ここはあなたのゲームの攻撃ロジックに合わせて呼び出してください
combo_counter.record_hit()
func _on_combo_changed(current: int) -> void:
combo_label.text = str(current)
func _on_combo_reset() -> void:
combo_label.text = ""
攻撃ロジックの例として、Area2D を使ったシーン構成も載せておきます。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── AttackArea (Area2D) │ └── CollisionShape2D ├── ComboCounter (Node) └── Label (Label)
# Player.gd の一部例
@onready var attack_area: Area2D = $AttackArea
@onready var combo_counter: ComboCounter = $ComboCounter
func _ready() -> void:
attack_area.body_entered.connect(_on_attack_area_body_entered)
func _on_attack_area_body_entered(body: Node) -> void:
# 当たり判定が敵にヒットしたとき
if body.is_in_group("enemy"):
# ダメージ処理など
# ...
# コンボカウント
combo_counter.record_hit()
手順④: 敵やギミックにも再利用する
同じ ComboCounter を、敵側に付けて「プレイヤーに連続で当てた回数」を数えることもできます。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── HitBox (Area2D) └── ComboCounter (Node)
敵がプレイヤーに連続で攻撃を当てたら、combo_threshold に応じて「スタンさせる」「大技を出す」などの処理を追加できます。
# Enemy.gd (例)
extends CharacterBody2D
@onready var combo_counter: ComboCounter = $ComboCounter
func _ready() -> void:
combo_counter.combo_reached_threshold.connect(_on_combo_reached_threshold)
func _on_attack_hit_player(player: Node) -> void:
# プレイヤーに攻撃がヒットしたときに呼ばれる想定
combo_counter.record_hit()
func _on_combo_reached_threshold(current: int) -> void:
# 例えば、一定コンボに達したらプレイヤーをスタンさせる
print("Enemy special attack! combo: ", current)
メリットと応用
ComboCounter をコンポーネントとして分離することで、いくつか嬉しいポイントがあります。
- プレイヤーや敵のスクリプトがスリムになる
コンボに関するロジック(タイマー管理、リセット条件、閾値判定など)は全部ComboCounter側に閉じ込められます。攻撃ロジックは「ヒットしたらrecord_hit()」とシンプルに書けます。 - シーン構造がフラットで見通しがいい
「コンボ付きプレイヤー」「コンボ付き敵」みたいな継承ツリーを作らず、ComboCounterノードをポンと足すだけで済みます。
ノード階層が深くならないので、後から見返したときに「どこでコンボ管理してるんだっけ?」となりにくいです。 - どのシーンにも再利用できる
トレーニング用のダミー、コンボ練習用のターゲット、ボスの弱点など、「連続ヒット数が意味を持つもの」すべてに同じコンポーネントを使い回せます。 - UIやエフェクトとの連携が楽
シグナルcombo_changed,combo_reset,combo_reached_thresholdを拾うだけで、コンボ表示やSE・エフェクトの再生を好きな場所から制御できます。
「継承より合成(Composition)」で考えると、ComboCounter はただの「コンボ機能を提供する部品」です。プレイヤーでも敵でもギミックでも、必要なところにだけアタッチするという発想でシーンを組むと、プロジェクトの保守性がかなり上がりますね。
改造案:コンボに応じてダメージ倍率を返す関数
例えば、「コンボ数に応じてダメージ倍率を上げる」機能をコンポーネント側に持たせたい場合、以下のようなメソッドを追加できます。
## コンボ数に応じたダメージ倍率を返す例
## 例:
## 0コンボ: 1.0倍
## 1〜4コンボ: 1.0〜1.4倍
## 5コンボ以上: 2.0倍で固定 など
func get_damage_multiplier() -> float:
if _current_combo <= 0:
return 1.0
if _current_combo < 5:
# 1コンボごとに +0.1倍
return 1.0 + float(_current_combo) * 0.1
# 5コンボ以上は 2.0倍で頭打ち
return 2.0
攻撃側のコードからは、
var base_damage := 10
var damage := base_damage * combo_counter.get_damage_multiplier()
のように呼ぶだけで、「コンボ補正付きダメージ」が簡単に実装できます。コンボの仕様が変わっても、ComboCounter の中だけをいじれば済むので、ゲームバランス調整も楽になりますね。




