2Dアクションの主人公は、待機・移動・ジャンプ・攻撃を切り替えながら動きます。攻撃中は足を止め、振り終わったら押している向きへ歩き出すのが基本の手触りです。Godot では、いまの状態を1つに決めるステートマシンで作ります。
攻撃を足すときに is_attacking のような変数と if で止めると、攻撃しながら横に滑ったり、ジャンプの最中に斬れたりします。条件を足すたびに、別の穴が見つかります。
この記事では、入力を状態の中で読み分けて、待機・移動・ジャンプ・攻撃を切り替え、攻撃中は動けない主人公を作る方法を解説します。
この記事でわかること
- 同じ入力でも、状態によって「切り替える」「動かす」「捨てる」に分かれること
- 入力を状態ごとの関数の中で読む理由と、押した瞬間と押しているあいだの読み分け
- 攻撃が自分で終わる仕組みと、残り時間を状態の中で数える理由
- 実践:攻撃中は動けず、終わると歩きに戻る主人公
動作確認環境: Godot 4.7
状態ごとの入力の意味
状態を enum(選べる状態の名前の一覧)で1つに決め、match(いまの状態に合った処理を選ぶ分岐)で状態ごとの関数へ振り分ける形は、敵AIと同じです。考え方の基本は ステートマシン入門 にあります。
他のエンジンでいうと: Unity で C# の
enumとswitchを使って書く状態管理と、同じ書き方です。
違うのは、状態を切り替える理由です。敵は距離や視界で切り替わりますが、プレイヤーはボタンで切り替わります。しかも同じボタンでも、いまの状態によって意味が変わります。

入力の受け取り方は、次の3つに分かれます。
- 切り替える: 別の状態へ移る。待機中の←→、地上のジャンプと攻撃
- 動かす: 状態はそのままで、速さだけ変える。移動中と空中の←→
- 捨てる: 何もしない。攻撃中の入力すべてと、空中のジャンプと攻撃
| 状態 | ←→ | ジャンプ | 攻撃 |
|---|---|---|---|
IDLE(待機) | 切り替える | 切り替える | 切り替える |
MOVE(移動) | 動かす | 切り替える | 切り替える |
AIR(空中) | 動かす | 捨てる | 捨てる |
ATTACK(攻撃) | 捨てる | 捨てる | 捨てる |
敵AIでは、距離や視界の条件は「移るかどうか」を決めるだけでした。プレイヤーでは、同じキーが状態によって「切り替える」にも「動かす」にも「捨てる」にもなります。この表が、そのままコードの形になります。
入力を読む場所
入力の if を、状態を見ずに _physics_process()(物理の処理のたびに毎回呼ばれる関数)の先頭へ並 べると、空中でも攻撃ボタンが効いてしまいます。ジャンプの放物線の途中で、斬撃が始まります。

入力は、状態ごとの関数の中で読みます。空中の関数にジャンプと攻撃の判定を書かなければ、その2つは「捨てる」になります。読まなかった押下が、あとから効くこともありません。着地してから押し直すと攻撃が出ます。
読み方も、入力の種類で分けます。←→は押しているあいだずっと効くので、Input.get_axis() で読みます。←なら -1、→なら 1、押していないか両方なら 0 を返す関数です。ジャンプと攻撃は押した瞬間だけ効くので、Input.is_action_just_pressed() で読みます。どちらも _physics_process() の中で読めます。
ジャンプを is_action_pressed()(押しているあいだ)で読むと、Space を押したまま着地するたびに、また跳びます。1回押したら1回跳ぶ、にしたいので、押した瞬間で読みます。
攻撃の終わり方(残り時間)
ステートマシン入門 の敵の ATTACK は、プレイヤーが間合いにいるあいだ続く状態でした。プレイヤーの攻撃は、ボタンを押し続けていてもいなくても、決めた時間が来たら自分で終わります。

残り時間は、攻撃の状態に入った瞬間にセットし、攻撃の関数の中で毎フレーム、経過した秒数(delta)ずつ減らします。0 になったら、そのとき押している向きを読み直して、←→が押されていれば MOVE、離れていれば IDLE へ戻します。「攻撃の前は歩いていた」を覚えておく必要はありません。
攻撃中に止めるのは、横の速さ(velocity.x、1秒あたりに進むピクセル数)だけです。velocity.x は0を入れ直すまで前の値が残るので、歩きから攻撃に入ったら自分で0にします。velocity に入れた速さで体を動かすのが move_and_slide() です。重力とこの関数は、どの状態でも同じ場所で動かしておきます。
攻撃中は攻撃ボタンも読まないので、連打しても攻撃の時間は延びません。状態を切り替えるのは、自分で書く change_state() という関数です(全文は実践のコード)。その先頭では、同じ状態への切り替えを無視します。何かの拍子に ATTACK を指定し直しても、残り時間は戻りません。
攻撃の長さを await で待つ形
攻撃の長さを await(その行で関数を止め、時間がたってから続きを動かす書き方)で待つ書き方は、短くて分かりやすく見えます。
# やりがちな形
func _start_attack() -> void:
change_state(State.ATTACK)
await get_tree().create_timer(0.4).timeout
change_state(State.IDLE) # 途中で別の状態に移っていても、ここで IDLE に戻してしまう
困るのは、待っているあいだに状態が変わったときです。たとえば攻撃中にダメージを受けて、のけぞりの状態へ移ったとします。await の後ろの処理は、状態が変わっても取り消されません。0.4秒たつと続きが走り、のけぞりの途中で IDLE に戻してしまいます。

残り時間を攻撃の関数の中で数えれば、攻撃を抜けた時点で、数えるのも終わりの判定も止まります。await そのものの使い方は await入門 にあります。
Timer ノードで数えるなら、攻撃を抜けるときに stop() します。AnimationPlayer の animation_finished シグナルで終わらせる方法もあります。このシグナルは着地のアニメなど、どのアニメが終わっても来るので、受け取ったらアニメの名前といまの状態を確かめてから戻します。ループするアニメでは一周しても来ないので、攻撃のアニメはループをオフにしておきます。
見た目の切り替え(change_state)
攻撃のアニメを始めるのは、ATTACK に入った瞬間の1回だけにします。change_state() の中にまとめて書けば、攻撃1回につき再生の開始も1回です。終わったアニメが、勝手にもう一度始まることもありません。攻撃のアニメの長さは、実践のコードの定数 ATTACK_TIME(0.4秒)とそろえておきます。ずれていると、振り終わったのに動けない時間や、歩き出したのに剣が残る時間ができます。

状態は必ず change_state() を通して変えます。state = State.ATTACK と直接書くと、残り時間のセットもアニメの開始も飛ばされます。残り時間は 0 のままなので、次のフレームで攻撃が終わってしまい、振ったようには見えません。AnimationTree のステートマシンで見た目を切り替えるときも、change_state() の中で1回だけ切り替えます。作り方は AnimationTree入門 にあります。
実践:攻撃中は動けず、終わると歩きに戻る主人公を作る
横スクロールの剣士、メトロイドヴァニアの主人公、ベルトスクロールの格闘家——攻撃のあいだ足を止める主人公は、多くのアクションゲームにいます。ここでは床の上を歩いて跳べる主人公に攻撃を足し、攻撃中は動けず、終わったら押している向きへ歩き出すところまで作ります。
① 置くもの
新しいシーンから始めます。見た目の素材は使わず、状態の名前をラベルに出して確かめます。床は Debug メニューの Visible Collision Shapes をオンにすると見えます。
Project Settings の Input Map に、次の4つのアクションを足します(InputMap入門)。
| アクション | キー |
|---|---|
move_left | A と ← |
move_right | D と → |
jump | Space |
attack | J |
- ルートに Node2D を置いて Main にし、
main.tscnとして保存して Project Settings の Application → Run → Main Scene に指定する - 動かない床や壁のための StaticBody2D を置いて Floor にし、Position を (576, 628) にする。子の CollisionShape2D の Shape に RectangleShape2D を作り、Size を (1152, 40) にする
- CharacterBody2D を置いて Player にし、Position を (576, 592) にする。子の CollisionShape2D には RectangleShape2D (32, 32) を入れる
- Player の子に Sprite2D(Texture に
icon.svg、Scale (0.25, 0.25))と、Label(名前を StateLabel、Position (-24, -48))を置く

新規プロジェクトの既定の画面(1152×648)の下端に床を置いています。床の上面は y=608 で、32×32 の Player は中心が y=592 のとき、ちょうど床に乗ります。
② 組み立て
状態の行き来は次の図のとおりです。攻撃の終わりと着地だけは、そのとき押している向きで MOVE と IDLE に分かれます。

Player にスクリプトを付けます。状態ごとの関数の中身が、上の表の1行ずつにあたります。
# player.gd(Player の CharacterBody2D)
extends CharacterBody2D
enum State { IDLE, MOVE, AIR, ATTACK }
const SPEED := 200.0
const JUMP_VELOCITY := -400.0
const GRAVITY := 980.0
const ATTACK_TIME := 0.4 # 攻撃の長さ(秒)
var state: State = State.IDLE
var attack_left := 0.0 # 攻撃の残り時間
@onready var state_label: Label = $StateLabel
func _ready() -> void:
state_label.text = State.keys()[state]
func _physics_process(delta: float) -> void:
if not is_on_floor(): # 床に立っていなければ
velocity.y += GRAVITY * delta # 重力はどの状態でもかかる
match state:
State.IDLE:
_state_idle()
State.MOVE:
_state_move()
State.AIR:
_state_air()
State.ATTACK:
_state_attack(delta)
move_and_slide() # どの状態でも1回だけ
func _state_idle() -> void:
velocity.x = 0.0
if not is_on_floor():
change_state(State.AIR) # 足場から落ちた
elif Input.is_action_just_pressed("jump"):
_jump()
elif Input.is_action_just_pressed("attack"):
change_state(State.ATTACK)
elif _axis() != 0.0:
change_state(State.MOVE) # ←→ は「切り替える」
func _state_move() -> void:
velocity.x = _axis() * SPEED # ←→ は「動かす」
if not is_on_floor():
change_state(State.AIR)
elif Input.is_action_just_pressed("jump"):
_jump()
elif Input.is_action_just_pressed("attack"):
change_state(State.ATTACK)
elif _axis() == 0.0:
change_state(State.IDLE)
func _state_air() -> void:
velocity.x = _axis() * SPEED # 空中でも ←→ は「動かす」。ジャンプと攻撃は読まない
if is_on_floor():
change_state(_ground_state()) # 着地したら、そのとき押している向きで決める
func _state_attack(delta: float) -> void:
velocity.x = 0.0 # 止めるのは横だけ。入力は読まない
attack_left -= delta # delta はこの物理フレームの秒数
if attack_left <= 0.0:
change_state(_ground_state()) # 自分で終わり、そのとき押している向きで戻る
func _jump() -> void:
velocity.y = JUMP_VELOCITY
change_state(State.AIR)
func _axis() -> float:
return Input.get_axis("move_left", "move_right")
func _ground_state() -> State:
return State.MOVE if _axis() != 0.0 else State.IDLE
func change_state(next: State) -> void:
if next == state:
return # 同じ状態への切り替えは無視する
state = next
if state == State.ATTACK:
attack_left = ATTACK_TIME # 入った瞬間に時計を合わせる
velocity.x = 0.0 # 入ったフレームから横は止める
state_label.text = State.keys()[state]
State.keys() は、enum の名前を並べた配列を返します。enum に数値を書いていないので、状態の値がそのまま配列の番号になります。State.keys()[state] で、いまの状態の名前("ATTACK" など)をラベルに出しています。
③ 動かして確かめる

F5 で実行し、ラベルの状態名と主人公の位置を見ます。is_on_floor() は move_and_slide() で床に触れたあとに true になります。そのため起動直後の1フレームだけ AIR を通り、すぐ IDLE に落ち着きます。見るのは3つです。
←を押すと MOVE になり、1秒で約200ピクセル左へ進みます。離すと IDLE です。Space で跳ぶと AIR になり、約85ピクセル上がって約0.83秒で着地します。空中で J を押しても AIR のまま何も起きず、着地したときに←を押していれば MOVE、離していれば IDLE になります。空中で斬れてしまうなら、_state_air() の中で攻撃ボタンを読んでいます。
Space を押したまま着地しても、続けては跳びません。跳び直すなら、いったん離して押し直します。着地のたびに跳ぶなら、ジャンプを is_action_pressed() で読んでいます。
仕上 げに、←を押したまま J を押します。ラベルが ATTACK になり、約0.4秒のあいだ主人公の位置は1ピクセルも動きません。そのあいだ J を連打しても、攻撃は延びません。振り終わると、IDLE を挟まずに MOVE へ戻り、そのまま左へ歩き出します。攻撃中にじわじわ進むなら、ATTACK のどこかで velocity.x が残っています。
ポイントは2つです。
- 入力の意味は、状態の関数が決める: 同じ←キーでも、待機では切り替え、移動では動かし、攻撃中は捨てます。捨てる入力は、その状態の関数に書かないだけで済みます
- 攻撃は自分で終わり、戻り先はそのときの入力で決める: 残り時間は攻撃の関数の中で数えます。終わったら押している向きを読み直すので、歩きへ自然につながります
足を止めない攻撃を足すなら、表の攻撃の行の←→を「捨てる」から「動かす」に変えます。攻撃中に押したジャンプを覚えておく先行入力なら、「捨てる」を「覚えておいて、攻撃が終わったら使う」に変えます。ジャンプの手触りを整えるなら ジャンプ入門 の先行入力が同じ場所に入ります。
うまくいかないときは
| 症状 | 原因 | 直し方 |
|---|---|---|
| 着地のたびに、一瞬だけ止まってから歩き出す | 着地したら、いつも IDLE へ戻している | 戻り先を _ground_state() のように、そのとき押している向きで決める |
| 絵を付けたら 、攻撃のたびに右を向いてしまう | 向きを velocity.x の符号で毎フレーム決めていて、攻撃中の 0 を右として扱っている | 向きは、←→が押されているとき(_axis() が 0 でないとき)だけ変える |
| ジャンプと攻撃を同時に押すと、攻撃が出ない | 地上の関数では elif の順にジャンプが先に書いてあり、同じフレームの攻撃の押下は読まれない | 優先したいほうを上に書く |
足場の端から歩き出たのに、MOVE のまま落ちる | 移動中の関数で is_on_floor() を見ていない | 地上の状態の関数で、床から離れたら AIR へ移す |
おまけ:先に知っておくと良いこと
- 空中攻撃を足すなら、重力は止めない:
AIRで攻撃ボタンを「切り替える」に変え、ATTACKの中ではvelocity.xだけを止めます。縦まで止めると、跳んだ頂点で浮いたまま止まります。終わったときは床を見て、空中ならAIRへ戻します - 被弾を足すなら、状態を1つ増やす: のけぞりの状態を足し、ダメージを受けたら
change_state()で移します。攻撃の残り時間は攻撃の関数の中にしかないので、のけぞりの途中で勝手に戻ることはありません - 状態が増えてきたら: 状態ごとの関数が長くなったら、状態ごとにノードとスクリプトを分ける形に進みます。移り時は ステートマシン入門 の「状態 クラスへ分けるタイミング」にあります
まとめ
- 入力は3種類: 同じボタンでも、状態によって「切り替える」「動かす」「捨てる」に分かれる。押した瞬間は
is_action_just_pressed()、押しているあいだはget_axis() - 状態の関数の中で読む: 捨てる入力は、その状態の関数に書かない。読まなかった押下があとから効くことはない
- 攻撃は自分で終わる: 残り時間を攻撃の関数の中で数え、終わったら押している向きで戻り先を決める
- 止めるのは横だけ: 重力と
move_and_slide()はどの状態でも同じ場所で動かす - 状態は
change_state()を通して変える: 直接書くと、時計のセットも見た目の切り替えも飛ぶ
まずは今の主人公の状態と入力を、上の表の形に書き出してみてください。どのマスにも「切り替える」「動かす」「捨てる」のどれかが入れば、そのままコードにできます。あなたの主人公には、状態がいくつありますか。
