【Godot】GDScriptのArrayとDictionary入門:敵の名簿から1体だけ外し、拾ったアイテムを種類ごとに数える

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

GDScriptのArrayで敵の名簿から倒れた敵を外し、Dictionaryで拾ったアイテムを種類ごとに数える書き方を解説します。

敵の名簿のように同じ種類のものを並べて持つなら Array、アイテムの所持数のように名前で値を引くなら Dictionary を使います。どちらも、足す・探す・消すの書き方を覚えれば、たいていの場面に足ります。

書き方を取り違えると、倒した敵が名簿に残って次の攻撃でエラーが出たり、所持数を足すコードが1個目でエラーになったりします。別の変数に写したつもりが、元まで一緒に空になることもあります。

この記事では、敵の名簿から倒れた敵だけを外す方法と、拾ったアイテムを種類ごとに数える方法を解説します。

番号で並ぶArrayと名前で引くDictionaryの上に、足す・探す・消すの3つの矢印が並ぶ

この記事でわかること

  • 変数が持っているのは中身ではなく、入れ物そのもの
  • erase は中身を探し、remove_at は位置で外す
  • 無いキーを [] で読むと止まる。数え始めは get(キー, 0)
  • 何体も同時に消すときは、添字(何番目かを表す番号)を後ろから回す

動作確認環境: Godot 4.7

Sponsored

代入と duplicate()

数値や文字列を別の変数に入れると、値が写されます。Array と Dictionary は、= で別の変数に入れても、別の入れ物になりません。ノードや、画像・.tres のようなリソースも同じで、同じ実体を指します。

var enemies: Array[String] = ["slime", "bat", "slime"]

func _ready() -> void:
	var backup = enemies      # 写したつもり
	enemies.clear()
	print(backup.size())      # 0。backup も空になっている

変数が持っているのは入れ物そのもので、代入すると、同じ入れ物を指す変数がもう1つ増えるだけです。片方から中身を出せば、もう片方から見ても減っています。関数に渡したときも同じで、関数の中で clear() すれば、呼び出し元の名簿も空になります。

enemiesとbackupの2つの変数が同じ1つの配列を指していて、enemies側から中身を出すとbackupから見ても空になる

写しがほしいときは duplicate() を使います。var backup = enemies.duplicate() と書けば、別の入れ物ができます。ただし、中身がノードなら写るのは入れ物だけで、中の敵は同じ実体です。backup[0].hp を減らせば、元の名簿の敵も減ります。

他のエンジンでいうと: Unity の List<T> と Dictionary<TKey, TValue>。代入で同じ入れ物を指すところも同じです。UE の TArray と TMap は用途が近いものの、代入すると別の入れ物になります。

同じ理由で、空にする書き方も2つあります。backup = enemies のように1つの入れ物を共有しているとき、enemies.clear() はその入れ物を空にするので、backup からも空に見えます。enemies = [] は、enemies が新しい空の入れ物を指すように変わるだけなので、元の入れ物と backup はそのまま残ります。

clearは同じ配列を空にしてbackupからも空に見え、空の配列の代入はenemiesだけが新しい配列を指すように変わる

足す・探す・数える(append・in・get)

Array は番号で並ぶ入れ物です。使う動詞は4つで足ります。

var enemies: Array[String] = []

func _ready() -> void:
	enemies.append("slime")     # 末尾に足す
	enemies.append("bat")
	print(enemies.size())       # 2。何体いるか
	print("bat" in enemies)     # true。いるかどうか

	for id in enemies:
		print(id)               # slime, bat の順

in は has() と同じことをします。探すときは先頭から順に照合していくので、名簿が長いほど時間がかかります。

Dictionary は、名前(キー)で値を引く入れ物です。

var bag: Dictionary[String, int] = {}

func _ready() -> void:
	bag["potion"] = 1           # 名前を付けて入れる
	print(bag["potion"])        # 1
	print("potion" in bag)      # true

	for id in bag:
		print(id, bag[id])      # 入れた順に出てくる

Dictionary の in が見るのは、キーだけです。値のほうを探したいときは、bag.values() に対して in を使います。

ここに1つ、最初に必ず踏む段差があります。まだ無いキーを [] で読むと、その行で止まります。

bag["ether"] += 1           # ether がまだ無いと、読み取りで止まる

+= は「読んでから足す」なので、読むほうが先に失敗します。数え始めるコードは get() を使います。

bag["ether"] = bag.get("ether", 0) + 1   # 無ければ 0 から
あるキーは角かっこで値の1が返り、無いキーは角かっこだと止まり、get(キー, 0)な��ら既定値の0が返る

get(キー, 既定値) は「無ければこれ」を書ける読み方です。所持数や撃破数のように、0 から増やしていく値はほぼこの形になります。

Sponsored

erase と remove_at

Array から外す書き方は2つあり、受け取るものが違います。

enemies.erase("bat")        # この中身を探して外す
enemies.remove_at(0)        # 0番目を外す

erase が受け取るのは、 位置ではなく中身 です。だから enemies.erase(0) は「値が 0 の要素」を探しにいきます。型を書いていない Array なら、見つからないまま何も起きず、エラーも出ません。消えていないことに気づくのは後になります。

eraseはbatを見つけたところで止まって外し、remove_atは番号0の要素を外す。同じ並びでも外れるものが違う

Array[String] のように中身の型を書いてあれば、整数は中身になり得ないので、実行時に型のエラーが出ます。削除も行われません。型を書いておくと、この取り違えが黙って通らずに済みます。

Dictionary は名前で消します。bag.erase("potion") と書くと、消せたときに true が返ります。Array の erase は最初に見つかった1つを外すだけで、何も返しません。その値がまだ残っているかは、in で確かめます。

まとめて消すときの回し方

for で回しながら、その配列から要素を消すのは、Godotではサポートされていません。たとえば今見ている要素を消すと、後ろが1つずつ前へ詰まり、次の要素が調べられないまま残ります。

var hp_list: Array[int] = [2, 0, 0, 3]

func _ready() -> void:
	for hp in hp_list:          # これはしない
		if hp <= 0:
			hp_list.erase(hp)
2・0・0・3を前から回して1つ目の0を外すと後ろが詰まり、次に3を見るので、2つ目の0が調べられずに残る

回すものを添字に変えて、後ろから見ていけば起きません。

	for i in range(hp_list.size() - 1, -1, -1):
		if hp_list[i] <= 0:
			hp_list.remove_at(i)

range() が作るのは 3, 2, 1, 0 という整数の並びで、配列そのものを回してはいません。2つ目の -1 は「その手前で止まる」終わりの印なので、0 まで見ます。後ろから外していけば、まだ見ていない手前の番号がずれません。

右端から後ろへ向かって見ていけば、外しても手前の番号が動かないので、全部を1回ずつ調べられる

Dictionary のときは、キーの一覧をいったん受け取ります。

for id in bag.keys():
	if bag[id] <= 0:
		bag.erase(id)

keys() が返すのは、そのときのキーを並べた別の配列です。回している最中に bag を触っても、回しているほうには影響しません。

型つきの Array と Dictionary

角かっこの中に中身の型を書くと、違うものが混ざらなくなります。

var enemies: Array[Node2D] = []
var bag: Dictionary[String, int] = {}

Array[Node2D] に文字列を入れようとすると拒否されます。型を書いていないときのように、混ざったまま進むことはありません。キーと値の型まで書ける Dictionary[String, int] は Godot 4.4 からです。

型が見ているのは Node2D の仲間かどうかまでです。enemy.hp のように自分で足したプロパティがあるかどうかまでは見ていないので、そこは実行時に確かめられます。

get_children() は子ノードを並べた配列を返しますが、その型は Array[Node] で、Array[Node2D] とは違います。Node2D は Node の一種なので、実践では子を1体ずつ Node2D として append し、名簿へ詰め替えます。

Sponsored

実践:倒れた敵をまとめて外し、拾ったアイテムを数える

見下ろしARPGの雑魚、シューティングの敵編隊、ローグライトの部屋——全体攻撃で複数が同時に倒れる場面は、ジャンルが変わっても同じ形です。倒れたぶんを名簿から外し、拾ったアイテムを種類ごとに数えます。

① 置くもの

新しいシーンを1つ作り、main.tscn として保存してメインシーンに指定します。enemy.gd や item.gd は、1体に付けてから Ctrl + D で複製すると、複製にも同じスクリプトが付きます。

Main (Node2D)            main.gd
├── Player (CharacterBody2D)   player.gd
│   ├── Sprite2D
│   └── CollisionShape2D
├── Enemies (Node2D)
│   ├── Slime1 (Node2D)        enemy.gd
│   ├── Slime2 (Node2D)        enemy.gd
│   └── Slime3 (Node2D)        enemy.gd
├── Items (Node2D)
│   ├── Potion1 (Area2D)       item.gd
│   ├── Potion2 (Area2D)       item.gd
│   └── Ether1 (Area2D)        item.gd
└── Hud (Label)

値はこう置きます。

ノード置く場所と設定
Player位置 (120, 300)。Sprite2D の Texture に res://icon.svg、Scale (0.5, 0.5)。CollisionShape2D は CircleShape2D 半径 24
Slime1 〜 3位置 (400, 200) (500, 300) (600, 400)。子に Sprite2D(Texture に res://icon.svg、Scale (0.4, 0.4))。インスペクタの Hp(コードの hp)をそれぞれ 2 1 1
Potion1 Potion2位置 (250, 200) (250, 400)。子に Sprite2D(同じ Texture、Scale (0.3, 0.3))と CollisionShape2D(CircleShape2D 半径 32)。インスペクタの Item Id(コードの item_id)は potion
Ether1位置 (250, 300)。同上。見分けが付くよう Sprite2D の Modulate を青にする。Item Id は ether
Hud位置 (20, 20)
# enemy.gd
extends Node2D

@export var hp: int = 1
# item.gd
extends Area2D

@export var item_id: String = "potion"
# player.gd
extends CharacterBody2D

@export var speed: float = 220.0

func _physics_process(_delta: float) -> void:
	velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * speed
	move_and_slide()

② 名簿と所持数を持つ

main.gd の全文です。上から「2つの入れ物」「起動時の登録」「全体攻撃」「拾ったとき」「表示」の順に並んでいます。

# main.gd
extends Node2D

var enemies: Array[Node2D] = []            # いま生きている敵の名簿
var bag: Dictionary[String, int] = {}      # 種類ごとの所持数

@onready var hud: Label = $Hud

func _ready() -> void:
	for child in $Enemies.get_children():
		enemies.append(child as Node2D)    # Array[Node] なので、Node2D として扱って詰め替える
	for child in $Items.get_children():
		var item := child as Area2D
		item.body_entered.connect(_on_item_touched.bind(item))
	_update_hud()

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_accept"):   # 最初から Space と Enter に割り当てられた操作
		_attack_all()

func _attack_all() -> void:
	for enemy in enemies:
		enemy.hp -= 1                      # 減らすだけ。ここでは外さない
	for i in range(enemies.size() - 1, -1, -1):
		var enemy := enemies[i]
		if enemy.hp <= 0:
			enemies.remove_at(i)           # 先に名簿から外して
			enemy.queue_free()             # そのあとで消す
	_update_hud()

func _on_item_touched(body: Node2D, item: Area2D) -> void:
	if body != $Player:
		return
	var id: String = item.item_id
	bag[id] = bag.get(id, 0) + 1           # 無いキーは 0 から数える
	item.queue_free()
	_update_hud()

func _update_hud() -> void:
	hud.text = "敵 %d / potion %d / ether %d" % [   # %d の位置に、右の数が順に入る
		enemies.size(), bag.get("potion", 0), bag.get("ether", 0)
	]

アイテムは、Area2D に体が入ると届く body_entered を使っています。bind(item) は、シグナルが渡す body の後ろに、そのアイテム自身を足しておく書き方です。だから受け取る関数は (body, item) の順になり、3つを同じ関数につないでも、どれに触れたかが分かります(シグナル入門)。

減らす for と外す for を分けています。減らすだけの回では配列を変更していないので、そのまま回して構いません。外すほうだけ、添字を後ろから回します。

HP2・1・1の3体を前から1ずつ減らして1・0・0にし、次の周は後ろから見て0の2体を外し、HP1の1体だけが名簿に残る

queue_free() は今のフレームの終わりに消す予約で、その場では消えません。だから大事なのは、消したノードを名簿に残さないことです。残したままにすると、次に攻撃したときに、もう消えた(解放済みの)ノードの hp を読みにいってエラーになります。この記事の実践では、名簿から外してから予約する順に統一しています。

enemy.hp と item.item_id は、Node2D や Area2D には無いプロパティです。それでもエラーにはならず、実体に enemy.gd と item.gd が付いていれば、そのまま動きます。

③ 動かして確かめる

F5 で実行します。左上に 敵 3 / potion 0 / ether 0 と出ます。見るのは3つです。

起動時は敵3、スペースを1回押すと敵1、ポーション2つとエーテル1つを拾うとpotion 2 / ether 1 になるHUDの3段

1つ目は、スペースキーを1回押したときです。3体のHPが 1・0・0 になり、0になった2体が同時に外れて 敵 1 に変わります。前から回して消す書き方だと、Slime2 を外したときに Slime3 が前へ詰まり、調べられないまま名簿に残ります。

2つ目は、矢印キーでポーション2つとエーテル1つに触れたときです。表示は potion 2 / ether 1 になります。potion は2回とも同じキーへ足され、ether は1個目のときに 0 から数え始めています。1個目でエラーになるなら、get(キー, 0) ではなく [] で読んでいます。

3つ目は、もう一度スペースを押したときです。敵 0 になり、そのあとさらに押してもエラーは出ません。外してから queue_free() しているので、名簿に消えた敵が残っていないからです。

ポイントは2つです。

  • 消したものを名簿に残さない: queue_free() はその場では消しません。外す処理と組にしておけば、あとで解放済みのノードを触ることがなくなります
  • 数える値は get(キー, 0) から始める: 所持数・撃破数・階層ごとのスコアのように 0 から増やす値は、すべてこの形で書けます。アイテムを在庫として設計するところは インベントリシステム にあります

うまくいかないときは

症状原因直し方
関数に名簿を渡したら、呼び出し元の名簿まで空になった引数でも入れ物は写されない。関数の中の clear() や erase() は、呼び出し元の入れ物を触っている関数の中で変えたくないなら、渡す前か関数の先頭で duplicate() する
duplicate() した名簿の敵の hp を変えたら、本物の敵の hp も変わった写るのは入れ物だけで、中の敵(ノード)は同じ実体別の敵がほしいなら、enemy.tscn から instantiate() で新しく作る
入れ子の辞書を duplicate() したのに、中の配列が元と一緒に変わる既定の duplicate() は外側だけを写すduplicate(true) で、中の配列や辞書まで写す。ノードやリソースは true でも同じ実体のまま
あとから出した敵だけ、全体攻撃で減らない名簿への登録が、起動時の _ready() の1回きり敵を生成したところで enemies.append() する。アイテムなら body_entered の接続もそこで行う

おまけ:先に知っておくと良いこと

  • 消すかわりに作り直す手もある: enemies = enemies.filter(func(e): return e.hp > 0) は、条件に合うものだけを集めた新しい配列を返します(func(e): ... は、その場で書く小さな関数です)。回しながら消すことにならないので、飛ばしが起きません。外れた敵を消すのは、別に queue_free() します
  • keys() と values() は写し: どちらも新しい配列が返ります。受け取った配列を並べ替えても、元の Dictionary の順番は変わりません
  • 自分で作ったスクリプトも型にできる: enemy.gd の先頭に class_name Enemy と書くと、Array[Enemy] と書けます。型の書き方は 静的型付け入門 にあります
  • 入れ子にもできる: { "stage": 1, "cleared": ["a", "b"] } のように Dictionary の中に Array を入れられます。保存する形に落とすところは セーブ&ロード入門 にあります

まとめ

  • 変数が持つのは入れ物そのもの: 代入でも、関数に渡しても写されない
  • duplicate() は入れ物だけ写す: 中のノードは同じ実体のまま
  • 足すのは append、探すのは in: Dictionary の in はキーを見る。数え始めは get(キー, 0)
  • 中身で外すか、位置で外すか: erase は中身を探し、remove_at は位置で外す
  • まとめて消すなら後ろから: 添字を後ろから回す。Dictionary は keys() を回す

文法そのものが怪しいときは、GDScript入門 に戻れます。

いま書いている名簿は、erase と remove_at のどちらで外していますか。

次に読む学習パスの次の記事
基礎

デバッグ技法とprint_debug活用 - Godot Engineで効率的なバグ修正方法

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