【Godot】Godotのプレイヤーステートマシン入門:攻撃中は動けず、終わると歩きに戻る主人公を作る

作成: 2026-09-23最終更新: 2026-10-10

待機・移動・ジャンプ・攻撃をステートマシンで切り替え、入力を状態の中で読み分けて、攻撃中は動けない主人公を作る方法を解説します。

2Dアクションの主人公は、待機・移動・ジャンプ・攻撃を切り替えながら動きます。攻撃中は足を止め、振り終わったら押している向きへ歩き出すのが基本の手触りです。Godot では、いまの状態を1つに決めるステートマシンで作ります。

攻撃を足すときに is_attacking のような変数と if で止めると、攻撃しながら横に滑ったり、ジャンプの最中に斬れたりします。条件を足すたびに、別の穴が見つかります。

この記事では、入力を状態の中で読み分けて、待機・移動・ジャンプ・攻撃を切り替え、攻撃中は動けない主人公を作る方法を解説します。

剣を振る主人公。攻撃のあいだは←キーを押していても受け付けず、その場で最後まで振り切る

この記事でわかること

  • 同じ入力でも、状態によって「切り替える」「動かす」「捨てる」に分かれること
  • 入力を状態ごとの関数の中で読む理由と、押した瞬間と押しているあいだの読み分け
  • 攻撃が自分で終わる仕組みと、残り時間を状態の中で数える理由
  • 実践:攻撃中は動けず、終わると歩きに戻る主人公

動作確認環境: Godot 4.7

Sponsored

状態ごとの入力の意味

状態を enum(選べる状態の名前の一覧)で1つに決め、match(いまの状態に合った処理を選ぶ分岐)で状態ごとの関数へ振り分ける形は、敵AIと同じです。考え方の基本は ステートマシン入門 にあります。

他のエンジンでいうと: Unity で C# の enum と switch を使って書く状態管理と、同じ書き方です。

違うのは、状態を切り替える理由です。敵は距離や視界で切り替わりますが、プレイヤーはボタンで切り替わります。しかも同じボタンでも、いまの状態によって意味が変わります。

同じ←キーが4つの状態に届く。待機では歩き出し、移動では左へ歩き、空中では空中で左へ動き、攻撃中は何も起きない

入力の受け取り方は、次の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 は、プレイヤーが間合いにいるあいだ続く状態でした。プレイヤーの攻撃は、ボタンを押し続けていてもいなくても、決めた時間が来たら自分で終わります。

攻撃ボタンを押すと ATTACK に入り、約0.4秒のあいだ横には動かない。終わったときに←を押していれば MOVE、離していれば IDLE へ戻る

残り時間は、攻撃の状態に入った瞬間にセットし、攻撃の関数の中で毎フレーム、経過した秒数(delta)ずつ減らします。0 になったら、そのとき押している向きを読み直して、←→が押されていれば MOVE、離れていれば IDLE へ戻します。「攻撃の前は歩いていた」を覚えておく必要はありません。

攻撃中に止めるのは、横の速さ(velocity.x、1秒あたりに進むピクセル数)だけです。velocity.x は0を入れ直すまで前の値が残るので、歩きから攻撃に入ったら自分で0にします。velocity に入れた速さで体を動かすのが move_and_slide() です。重力とこの関数は、どの状態でも同じ場所で動かしておきます。

攻撃中は攻撃ボタンも読まないので、連打しても攻撃の時間は延びません。状態を切り替えるのは、自分で書く change_state() という関数です(全文は実践のコード)。その先頭では、同じ状態への切り替えを無視します。何かの拍子に ATTACK を指定し直しても、残り時間は戻りません。

Sponsored

攻撃の長さを 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 で待つと、途中でのけぞりへ移っても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 に直接書くと名前だけ変わり、3つとも飛ぶ

状態は必ず change_state() を通して変えます。state = State.ATTACK と直接書くと、残り時間のセットもアニメの開始も飛ばされます。残り時間は 0 のままなので、次のフレームで攻撃が終わってしまい、振ったようには見えません。AnimationTree のステートマシンで見た目を切り替えるときも、change_state() の中で1回だけ切り替えます。作り方は AnimationTree入門 にあります。

Sponsored

実践:攻撃中は動けず、終わると歩きに戻る主人公を作る

横スクロールの剣士、メトロイドヴァニアの主人公、ベルトスクロールの格闘家——攻撃のあいだ足を止める主人公は、多くのアクションゲームにいます。ここでは床の上を歩いて跳べる主人公に攻撃を足し、攻撃中は動けず、終わったら押している向きへ歩き出すところまで作ります。

① 置くもの

新しいシーンから始めます。見た目の素材は使わず、状態の名前をラベルに出して確かめます。床は Debug メニューの Visible Collision Shapes をオンにすると見えます。

Project Settings の Input Map に、次の4つのアクションを足します(InputMap入門)。

アクションキー
move_leftA と ←
move_rightD と →
jumpSpace
attackJ
  • ルートに 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))を置く
実践のツリーと Input Map。Main の下に Floor と Player、Player の下に当たり判定・絵・StateLabel。アクションは4つ

新規プロジェクトの既定の画面(1152×648)の下端に床を置いています。床の上面は y=608 で、32×32 の Player は中心が y=592 のとき、ちょうど床に乗ります。

② 組み立て

状態の行き来は次の図のとおりです。攻撃の終わりと着地だけは、そのとき押している向きで MOVE と IDLE に分かれます。

IDLE と MOVE は←→で行き来し、ジャンプで AIR、攻撃で ATTACK へ。着地と攻撃の終わりは、押している向きで戻り先が決まる

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" など)をラベルに出しています。

③ 動かして確かめる

←を押すと MOVE で左へ進み、攻撃を押すと約0.4秒 x が止まり、そのまま MOVE に戻る。空中で攻撃を押しても AIR のまま

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() で移します。攻撃の残り時間は攻撃の関数の中にしかないので、のけぞりの途中で勝手に戻ることはありません
  • 状態が増えてきたら: 状態ごとの関数が長くなったら、状態ごとにノードとスクリプトを分ける形に進みます。移り時は ステートマシン入門 の「状態クラスへ分けるタイミング」にあります
Sponsored

まとめ

  • 入力は3種類: 同じボタンでも、状態によって「切り替える」「動かす」「捨てる」に分かれる。押した瞬間は is_action_just_pressed()、押しているあいだは get_axis()
  • 状態の関数の中で読む: 捨てる入力は、その状態の関数に書かない。読まなかった押下があとから効くことはない
  • 攻撃は自分で終わる: 残り時間を攻撃の関数の中で数え、終わったら押している向きで戻り先を決める
  • 止めるのは横だけ: 重力と move_and_slide() はどの状態でも同じ場所で動かす
  • 状態は change_state() を通して変える: 直接書くと、時計のセットも見た目の切り替えも飛ぶ

まずは今の主人公の状態と入力を、上の表の形に書き出してみてください。どのマスにも「切り替える」「動かす」「捨てる」のどれかが入れば、そのままコードにできます。あなたの主人公には、状態がいくつありますか。

次に読む学習パスの次の記事
GDScript

GDScriptで使うOOPデザインパターン

この記事を読む
Godot このセクションのノート104