Chapter 40

データを名前で管理しよう / Goのマップ(連想配列)

eihigh
eihigh
2025.01.31に更新

画像の読み込み処理を画像ごとに書いたり、さらに前回はシナリオから取り出した文字列によって描画する画像を切り替えることを行いました。ですが画像の種類が増えてくるとこのままでは大変なことになるのは容易に想像できます。そこで今回はたくさんのデータを名前で管理する「マップ」を紹介し、大幅に楽になるよう改修します。

マップ

まずはざっくり使い方を見てみましょう。

func main() {
	// 型は map[キーの型]要素の型 と書く
	// 合成リテラルで初期化できる
	m := map[string]int{"taro": 60, "jiro": 80}
	// 取得
	fmt.Println(m["taro"]) // 60
	// 更新(すでに存在するキーなので)
	m["jiro"] = 70
	fmt.Println(m["jiro"]) // 70
	// 追加(まだ存在しないキーなので)
	m["saburo"] = 80
	fmt.Println(m["saburo"]) // 80
}

スライスは連番でデータを管理しますが、マップは名前(キーと呼びます)でデータを管理します。名簿のようなイメージですね。連想配列や辞書型とも呼ばれることがあります。

キーは重複しません。すでに存在するキーへの代入は上書きになり、まだ存在しないキーへの代入は新規作成になります。この性質がマップを使っていて一番嬉しいポイントです。

型の記法が map[キーの型]要素の型 というちょっと不安になる書き方ですが、これは仕様なので受け入れるしかないですね。

ではノベルゲームで実際に使っていきましょう。

画像管理をマップに置き換える

今までは画像ごとにフィールドを用意していましたが、これを images map[string]*ebiten.Image フィールドに集約するように変えます。キーが string で、要素が *ebiten.Image ですね。

最終的に g.images[画像名] の形で画像を利用できるようになるのがゴールです。

 type game struct {
-	// bg        *ebiten.Image
-	// person    *ebiten.Image
-	// cat       *ebiten.Image
-	// messageBG *ebiten.Image
+	images map[string]*ebiten.Image
 
 	fontFace *text.GoTextFace
 
 	scenario   []string
 	progress   int // シナリオ進行度
 	message    string
 	leftChara  chara
 	rightChara chara
 }

そうするとどうなるのかというと、以下のようにfor文を使って画像読み込みをシュッと書けます!

 func newGame() (*game, error) {
 	g := &game{}
 
 	// 画像を読み込む
+	imageNames := []string{
+		"bg.jpg",
+		"person.png",
+		"cat.png",
+		"message-bg.png",
+	}
+	g.images = map[string]*ebiten.Image{}
+	for _, name := range imageNames {
+		img, _, err := ebitenutil.NewImageFromFile(name)
+		if err != nil {
+			return nil, err
+		}
+		g.images[name] = img
+	}

 	// フォントファイルを開く
 	// ...以下略...

g.images[name] = img のところがミソですね。これによって g.image["bg.jpg"] のように書いて画像を利用できるようになります。

注意点として、g.images = map[string]*ebiten.Image{} を忘れないでください。mapのゼロ値はnilですが、nilのまま利用しようとするとクラッシュします。必ず合成リテラルで何か値を入れてから使い始めましょう。

これを描画処理に反映していきます。

 func (g *game) Draw(screen *ebiten.Image) {
 	// 画像を描画
 	op := &ebiten.DrawImageOptions{}
+	screen.DrawImage(g.images["bg.jpg"], op)
 
 	// 左の立ち絵描画
-	// switch g.leftChara.name {
-	// case "person.png":
-	// 	screen.DrawImage(g.person, op)
-	// case "cat.png":
-	// 	screen.DrawImage(g.cat, op)
-	// }
+	if g.leftChara.name != "" {
+		op = &ebiten.DrawImageOptions{}
+		screen.DrawImage(g.images[g.leftChara.name], op)
+	}
 
 	// 右の立ち絵描画
-	// op.GeoM.Translate(560, 0)
-	// switch g.rightChara.name {
-	// case "person.png":
-	// 	screen.DrawImage(g.person, op)
-	// case "cat.png":
-	// 	screen.DrawImage(g.cat, op)
-	// }
+	if g.rightChara.name != "" {
+		op = &ebiten.DrawImageOptions{}
+		op.GeoM.Translate(560, 0)
+		screen.DrawImage(g.images[g.rightChara.name], op)
+	}
 
 	// メッセージ背景描画
 	op = &ebiten.DrawImageOptions{}
 	op.GeoM.Translate(0, 450)
 	op.ColorScale.ScaleAlpha(0.7)
+	screen.DrawImage(g.images["message-bg.png"], op)
 
 	// 会話文表示
 	textop := &text.DrawOptions{}
 	textop.GeoM.Translate(200, 480)
 	textop.ColorScale.Scale(0, 0, 0, 1) // 黒
 	textop.LineSpacing = 30 * 1.5
 	text.Draw(screen, g.message, g.fontFace, textop)
 }

だいぶすっきりしましたね。このありがたみをじっくり味わっておいてください。

立ち絵の管理をマップに置き換える

さらにマップの威力を体感してみましょう。今度は leftChara, rightChara フィールドをマップに置き換えて、g.charas["left"] のように利用できることを目指します。

 type game struct {
 	images map[string]*ebiten.Image
 
 	fontFace *text.GoTextFace
 
 	scenario []string
 	progress int // シナリオ進行度
 	message  string
-	// leftChara  chara
-	// rightChara chara
+	charas map[string]*chara
 }
 
 type chara struct {
 	name string
+	x, y float64
 }
 
 func newGame() (*game, error) {
 	g := &game{}
+	g.charas = map[string]*chara{}
 
 	// ...以下略...

今後必要になってくるのでchara型にx, yフィールドを追加しました。

マップも、要素に構造体を使う時はポインターと組み合わせる(今回だと *chara)ようにします。スライスとは微妙に理由は異なるのですが、まあそういうものだと覚えてもらえればOKです。また、newGame の中で空のマップを代入しておくところにやはり気をつけましょう。

そしたら命令の処理と、描画処理をマップに対応させます。

 	if found {
 		switch before {
 		case "leftChara":
-			// g.leftChara.name = after
+			g.charas["left"] = &chara{name: after, x: 0}
 		case "rightChara":
-			// g.rightChara.name = after
+			g.charas["right"] = &chara{name: after, x: 560}
 		}
 	} else {
 		g.message = before
 	}
 func (g *game) Draw(screen *ebiten.Image) {
 	// 画像を描画
 	op := &ebiten.DrawImageOptions{}
 	screen.DrawImage(g.images["bg.jpg"], op)
 
 	// 立ち絵描画
+	for _, chara := range g.charas {
+		if chara.name == "" {
+			continue
+		}
+		op := &ebiten.DrawImageOptions{}
+		op.GeoM.Translate(chara.x, chara.y)
+		screen.DrawImage(g.images[chara.name], op)
+	}

 	// メッセージ背景描画
 	// ...以下略...

マップもまた range で順繰りに取り出すことができます。立ち絵描画が左右ごとではなく一箇所にまとまったので、大変すっきりしましたね。しかも今後左右以外にも立ち絵を出したくなっても問題なく対応できそうです。

ただし range で取り出す順番は毎回ランダムになります。これは意図的な仕様なのですが(後半で解説します)、これだと都合の悪い場合もあります。実際に立ち絵の座標を左右で同じにしてみると、毎フレームランダムな順番で描画されるせいで前後が入れ替わって猛烈にチラつくのが確認できます。

いつでも一定の順序で取り出したいときは、マップのキーをソート(アルファベット順に並び替え)して取り出すことが多いです。これには slices.Sortedmaps.Keys 関数を使います。

func main() {
	m := map[string]int{"left": 0, "right": 1, "center": 2}
	keys := slices.Sorted(maps.Keys(m))
	fmt.Println(keys) // [center left right]
}
 	// 立ち絵描画
+	for _, key := range slices.Sorted(maps.Keys(g.charas)) {
+		chara := g.charas[key]
 		if chara.name == "" {
 			continue
 		}

マップに関する操作を提供するmapsパッケージは、比較的新しくてまだ知ってる人が少ないので、知識で差がつくポイントですね。

まとめ

マップを使ってデータを名前でうまく管理すると、プログラムが大幅に簡単になります。

  • マップは連番ではなく名前(キー)でデータを管理する。
  • マップの型は map[キーの型]要素の型 の形で書いて、変数名[キー] の形で利用する。
  • マップのキーは重複しない。既存のキーへの代入は上書きになり、新しいキーへの代入は新規作成になる。
  • range で順番に取り出せる。取り出す順番は毎回ランダム。
  • マップのゼロ値はnilで、nilのまま使うとクラッシュするので初期値を代入しておく一手間が必要。

マップはめちゃくちゃ便利なのでどんどん使っていきましょう!

詳しい解説

削除

delete 組み込み関数で特定のキーについて削除できます。そのキーが存在しない時は何も起こりません。また clear 組み込み関数は全て削除します。

func main() {
	m := map[string]int{"taro": 60, "jiro": 70}
	delete(m, "taro")
	delete(m, "saburo") // 特に何も起きない
	fmt.Println(m)      // map[jiro:70]
	clear(m)
	fmt.Println(m) // map[]
}

存在確認

taro, ok := m["taro"] のように代入文を書くと、ok にはそのキーが存在するかの真偽値が入ります。

func main() {
	m := map[string]int{"taro": 60, "jiro": 70}
	taro, ok := m["taro"]
	fmt.Println(taro, ok) // 60 true
	saburo, ok := m["saburo"]
	fmt.Println(saburo, ok) // 0 false
}

キーの型

キーは string 以外にも比較可能な型ならなんでも入ります。例えばキーを int にすることも可能です。

func main() {
	m := map[int]string{0: "zero", 100: "hundred"}
	fmt.Println(m[0], m[100]) // zero hundred
	fmt.Println(len(m))       // 2
}

キーを int にするとまるでスライスのような利用方法になりますが、スライスと違って連番ではないので、例えば 0 と 100 に値を入れても長さはたったの2です。スライスだと間も含めて長さが101になるはずなので、大きな違いですね。

とはいえ、string をキーにする機会の方が圧倒的に多いです。

range

マップは連番ではなく、順番も定められていませんが、中途半端に順番に並んでいたりするとうっかり順番が定まっている前提のプログラムを書く人が出てくる可能性があるので、Goは意図的に range で取り出す時の順番を完全にランダムにしています。これならうっかり順番がある前提のプログラムを書くことはないですね。